Skip to content

Commit 2e93731

Browse files
andreakarashoclaude
andcommitted
Add tooltip system with hover delay and rich content support
Adds Tooltip(text) for simple text tooltips and BeginTooltip/EndTooltip for rich multi-widget tooltips. Tooltips attach to the previously rendered widget, appear after a 0.5s hover delay near the cursor, and are clamped to screen bounds. Only one tooltip renders per frame. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 83a8d8c commit 2e93731

3 files changed

Lines changed: 467 additions & 1 deletion

File tree

src/Clay.Example/Program.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,14 +360,33 @@ void PageButtons()
360360

361361
ClayUI.BeginHorizontal(gap: 12);
362362
if (ClayUI.Button("Click Me")) clickCount++;
363+
ClayUI.Tooltip("Increments the click counter");
363364
if (ClayUI.Button("Reset")) { clickCount = 0; progressValue = 0; sliderValue = 0.5f; }
365+
ClayUI.Tooltip("Reset all values to defaults");
364366
if (ClayUI.Button("+ Progress")) progressValue = Math.Min(1f, progressValue + 0.1f);
367+
ClayUI.Tooltip("Increase progress by 10%");
365368
if (ClayUI.Button("- Progress")) progressValue = Math.Max(0f, progressValue - 0.1f);
369+
ClayUI.Tooltip("Decrease progress by 10%");
366370
ClayUI.EndHorizontal();
367371

368372
ClayUI.Space(8);
369373
ClayUI.Label($"Click count: {clickCount}");
370374

375+
ClayUI.Space(16);
376+
ClayUI.Label("Per-widget style override:");
377+
ClayUI.Space(4);
378+
ClayUI.Space(8);
379+
ClayUI.Label("Tooltips appear after hovering for 0.5s:");
380+
ClayUI.Space(4);
381+
ClayUI.Button("Hover for Rich Tooltip");
382+
if (ClayUI.BeginTooltip())
383+
{
384+
ClayUI.Label("Rich Tooltip");
385+
ClayUI.Separator();
386+
ClayUI.Label("Supports any widget content.");
387+
ClayUI.EndTooltip();
388+
}
389+
371390
ClayUI.Space(16);
372391
ClayUI.Label("Per-widget style override:");
373392
ClayUI.Space(4);
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
using System.Numerics;
2+
using Clay;
3+
4+
namespace Clay.Test;
5+
6+
public class ClayUITooltipTests : IDisposable
7+
{
8+
private readonly ClayUIFixture _fixture;
9+
10+
public ClayUITooltipTests()
11+
{
12+
_fixture = new ClayUIFixture();
13+
}
14+
15+
public void Dispose() => _fixture.Dispose();
16+
17+
[Fact]
18+
public void Tooltip_DoesNotRender_WhenNotHovered()
19+
{
20+
var commands = _fixture.RunFrame(() =>
21+
{
22+
ClayUI.Button("Btn");
23+
ClayUI.Tooltip("Tip text");
24+
});
25+
26+
bool hasTooltipText = false;
27+
foreach (var cmd in commands)
28+
{
29+
if (cmd.CommandType == RenderCommandType.Text && cmd.Text.Text == "Tip text")
30+
{
31+
hasTooltipText = true;
32+
break;
33+
}
34+
}
35+
Assert.False(hasTooltipText, "Tooltip should not render when not hovered");
36+
}
37+
38+
[Fact]
39+
public void Tooltip_DoesNotRender_BeforeDelay()
40+
{
41+
// Frame 1: establish bounding boxes
42+
_fixture.RunFrame(() =>
43+
{
44+
ClayUI.Button("DelayBtn");
45+
ClayUI.Tooltip("Delayed tip");
46+
}, mousePos: new Vector2(20, 10));
47+
48+
// Frame 2: hover but only 1 frame (~16ms < 500ms delay)
49+
var commands = _fixture.RunFrame(() =>
50+
{
51+
ClayUI.Button("DelayBtn");
52+
ClayUI.Tooltip("Delayed tip");
53+
}, mousePos: new Vector2(20, 10));
54+
55+
bool hasTooltipText = false;
56+
foreach (var cmd in commands)
57+
{
58+
if (cmd.CommandType == RenderCommandType.Text && cmd.Text.Text == "Delayed tip")
59+
{
60+
hasTooltipText = true;
61+
break;
62+
}
63+
}
64+
Assert.False(hasTooltipText, "Tooltip should not render before delay");
65+
}
66+
67+
[Fact]
68+
public void Tooltip_Renders_AfterDelay()
69+
{
70+
// Frame 1: establish bounding boxes with hover
71+
_fixture.RunFrame(() =>
72+
{
73+
ClayUI.Button("ShowBtn");
74+
ClayUI.Tooltip("Show tip");
75+
}, mousePos: new Vector2(20, 10));
76+
77+
// Run enough frames with large deltaTime to exceed 0.5s delay
78+
ReadOnlySpan<RenderCommand> commands = default;
79+
for (int i = 0; i < 5; i++)
80+
{
81+
commands = _fixture.RunFrame(() =>
82+
{
83+
ClayUI.Button("ShowBtn");
84+
ClayUI.Tooltip("Show tip");
85+
}, mousePos: new Vector2(20, 10), deltaTime: 0.2f);
86+
}
87+
88+
bool hasTooltipText = false;
89+
foreach (var cmd in commands)
90+
{
91+
if (cmd.CommandType == RenderCommandType.Text && cmd.Text.Text == "Show tip")
92+
{
93+
hasTooltipText = true;
94+
break;
95+
}
96+
}
97+
Assert.True(hasTooltipText, "Tooltip should render after hover delay");
98+
}
99+
100+
[Fact]
101+
public void Tooltip_Disappears_WhenUnhovered()
102+
{
103+
// Build up hover time
104+
_fixture.RunFrame(() =>
105+
{
106+
ClayUI.Button("UnhoverBtn");
107+
ClayUI.Tooltip("Unhover tip");
108+
}, mousePos: new Vector2(20, 10));
109+
110+
for (int i = 0; i < 5; i++)
111+
{
112+
_fixture.RunFrame(() =>
113+
{
114+
ClayUI.Button("UnhoverBtn");
115+
ClayUI.Tooltip("Unhover tip");
116+
}, mousePos: new Vector2(20, 10), deltaTime: 0.2f);
117+
}
118+
119+
// Move mouse away
120+
var commands = _fixture.RunFrame(() =>
121+
{
122+
ClayUI.Button("UnhoverBtn");
123+
ClayUI.Tooltip("Unhover tip");
124+
}, mousePos: new Vector2(700, 700));
125+
126+
bool hasTooltipText = false;
127+
foreach (var cmd in commands)
128+
{
129+
if (cmd.CommandType == RenderCommandType.Text && cmd.Text.Text == "Unhover tip")
130+
{
131+
hasTooltipText = true;
132+
break;
133+
}
134+
}
135+
Assert.False(hasTooltipText, "Tooltip should disappear when mouse moves away");
136+
}
137+
138+
[Fact]
139+
public void BeginTooltip_ReturnsFalse_WhenNotHovered()
140+
{
141+
bool opened = true;
142+
_fixture.RunFrame(() =>
143+
{
144+
ClayUI.Button("RichBtn");
145+
opened = ClayUI.BeginTooltip();
146+
if (opened) ClayUI.EndTooltip();
147+
});
148+
149+
Assert.False(opened);
150+
}
151+
152+
[Fact]
153+
public void BeginTooltip_ReturnsTrue_AfterDelay()
154+
{
155+
// Establish bounding boxes
156+
_fixture.RunFrame(() =>
157+
{
158+
ClayUI.Button("RichShow");
159+
var _ = ClayUI.BeginTooltip();
160+
if (_) ClayUI.EndTooltip();
161+
}, mousePos: new Vector2(20, 10));
162+
163+
// Hover long enough
164+
bool opened = false;
165+
for (int i = 0; i < 5; i++)
166+
{
167+
_fixture.RunFrame(() =>
168+
{
169+
ClayUI.Button("RichShow");
170+
opened = ClayUI.BeginTooltip();
171+
if (opened)
172+
{
173+
ClayUI.Label("Rich content");
174+
ClayUI.EndTooltip();
175+
}
176+
}, mousePos: new Vector2(20, 10), deltaTime: 0.2f);
177+
}
178+
179+
Assert.True(opened, "BeginTooltip should return true after hover delay");
180+
}
181+
182+
[Fact]
183+
public void Tooltip_OnlyOnePerFrame()
184+
{
185+
// Establish bounding boxes
186+
_fixture.RunFrame(() =>
187+
{
188+
ClayUI.Button("Btn1");
189+
ClayUI.Tooltip("Tip 1");
190+
ClayUI.Button("Btn2");
191+
ClayUI.Tooltip("Tip 2");
192+
}, mousePos: new Vector2(20, 10));
193+
194+
// Hover long enough (both buttons may be near the same position)
195+
ReadOnlySpan<RenderCommand> commands = default;
196+
for (int i = 0; i < 5; i++)
197+
{
198+
commands = _fixture.RunFrame(() =>
199+
{
200+
ClayUI.Button("Btn1");
201+
ClayUI.Tooltip("Tip 1");
202+
ClayUI.Button("Btn2");
203+
ClayUI.Tooltip("Tip 2");
204+
}, mousePos: new Vector2(20, 10), deltaTime: 0.2f);
205+
}
206+
207+
int tooltipCount = 0;
208+
foreach (var cmd in commands)
209+
{
210+
if (cmd.CommandType == RenderCommandType.Text &&
211+
(cmd.Text.Text == "Tip 1" || cmd.Text.Text == "Tip 2"))
212+
{
213+
tooltipCount++;
214+
}
215+
}
216+
Assert.True(tooltipCount <= 1, $"Only one tooltip should render per frame, got {tooltipCount}");
217+
}
218+
219+
[Fact]
220+
public void Tooltip_NoWidget_DoesNotCrash()
221+
{
222+
// Tooltip with no preceding widget should not crash
223+
_fixture.RunFrame(() =>
224+
{
225+
ClayUI.Tooltip("Orphan tooltip");
226+
});
227+
}
228+
}

0 commit comments

Comments
 (0)