A game framework written with osu! in mind.
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2// See the LICENCE file in the repository root for full licence text.
3
4using System;
5using System.Collections.Generic;
6using System.Linq;
7using osu.Framework.Graphics.Containers;
8using osu.Framework.Graphics.Primitives;
9using osu.Framework.Graphics.Shapes;
10using osu.Framework.Graphics.Sprites;
11using osu.Framework.Input;
12using osu.Framework.Localisation;
13using osuTK;
14using osuTK.Graphics;
15
16namespace osu.Framework.Graphics.Cursor
17{
18 /// <summary>
19 /// Displays Tooltips for all its children that inherit from the <see cref="IHasTooltip"/> or <see cref="IHasCustomTooltip"/> interfaces. Keep in mind that only children with <see cref="Drawable.HandlePositionalInput"/> set to true will be checked for their tooltips.
20 /// </summary>
21 public class TooltipContainer : CursorEffectContainer<TooltipContainer, ITooltipContentProvider>
22 {
23 private readonly CursorContainer cursorContainer;
24 private readonly ITooltip defaultTooltip;
25
26 protected ITooltip CurrentTooltip;
27
28 private InputManager inputManager;
29
30 /// <summary>
31 /// Duration the cursor has to stay in a circular region of <see cref="AppearRadius"/>
32 /// for the tooltip to appear.
33 /// </summary>
34 protected virtual double AppearDelay => 220;
35
36 /// <summary>
37 /// Radius of the circular region the cursor has to stay in for <see cref="AppearDelay"/>
38 /// milliseconds for the tooltip to appear.
39 /// </summary>
40 protected virtual float AppearRadius => 20;
41
42 private ITooltipContentProvider currentlyDisplayed;
43
44 /// <summary>
45 /// Creates a new tooltip. Can be overridden to supply custom subclass of <see cref="Tooltip"/>.
46 /// </summary>
47 protected virtual ITooltip CreateTooltip() => new Tooltip();
48
49 private readonly Container content;
50 protected override Container<Drawable> Content => content;
51
52 /// <summary>
53 /// Creates a tooltip container where the tooltip is positioned at the bottom-right of
54 /// the <see cref="CursorContainer.ActiveCursor"/> of the given <see cref="CursorContainer"/>.
55 /// </summary>
56 /// <param name="cursorContainer">The <see cref="CursorContainer"/> of which the <see cref="CursorContainer.ActiveCursor"/>
57 /// shall be used for positioning. If null is provided, then a small offset from the current mouse position is used.</param>
58 public TooltipContainer(CursorContainer cursorContainer = null)
59 {
60 this.cursorContainer = cursorContainer;
61 AddInternal(content = new Container
62 {
63 RelativeSizeAxes = Axes.Both,
64 });
65 AddInternal((Drawable)(CurrentTooltip = CreateTooltip()));
66 defaultTooltip = CurrentTooltip;
67 }
68
69 protected override void OnSizingChanged()
70 {
71 base.OnSizingChanged();
72
73 if (content != null)
74 {
75 // reset to none to prevent exceptions
76 content.RelativeSizeAxes = Axes.None;
77 content.AutoSizeAxes = Axes.None;
78
79 // in addition to using this.RelativeSizeAxes, sets RelativeSizeAxes on every axis that is neither relative size nor auto size
80 content.RelativeSizeAxes = Axes.Both & ~AutoSizeAxes;
81 content.AutoSizeAxes = AutoSizeAxes;
82 }
83 }
84
85 protected override void LoadComplete()
86 {
87 base.LoadComplete();
88 inputManager = GetContainingInputManager();
89 }
90
91 private Vector2 computeTooltipPosition()
92 {
93 // Update the position of the displayed tooltip.
94 // Our goal is to find the bounding circle of the cursor in screen-space, and to
95 // position the top-left corner of the tooltip at the circle's southeast position.
96 float boundingRadius;
97 Vector2 cursorCentre;
98
99 if (cursorContainer == null)
100 {
101 cursorCentre = ToLocalSpace(inputManager.CurrentState.Mouse.Position);
102 boundingRadius = 14f;
103 }
104 else
105 {
106 Quad cursorQuad = cursorContainer.ActiveCursor.ToSpaceOfOtherDrawable(cursorContainer.ActiveCursor.DrawRectangle, this);
107 cursorCentre = cursorQuad.Centre;
108 // We only need to check 2 of the 4 vertices, because we only allow affine transformations
109 // and the quad is therefore symmetric around the centre.
110 boundingRadius = Math.Max(
111 (cursorQuad.TopLeft - cursorCentre).Length,
112 (cursorQuad.TopRight - cursorCentre).Length);
113 }
114
115 Vector2 southEast = new Vector2(1).Normalized();
116 Vector2 tooltipPos = cursorCentre + southEast * boundingRadius;
117
118 // Clamp position to tooltip container
119 tooltipPos.X = Math.Min(tooltipPos.X, DrawWidth - CurrentTooltip.DrawSize.X - 5);
120 float dX = Math.Max(0, tooltipPos.X - cursorCentre.X);
121 float dY = MathF.Sqrt(boundingRadius * boundingRadius - dX * dX);
122
123 if (tooltipPos.Y > DrawHeight - CurrentTooltip.DrawSize.Y - 5)
124 tooltipPos.Y = cursorCentre.Y - dY - CurrentTooltip.DrawSize.Y;
125 else
126 tooltipPos.Y = cursorCentre.Y + dY;
127
128 return tooltipPos;
129 }
130
131 private struct TimedPosition
132 {
133 public double Time;
134 public Vector2 Position;
135 }
136
137 private object getTargetContent(ITooltipContentProvider target) => (target as IHasCustomTooltip)?.TooltipContent ?? (target as IHasTooltip)?.TooltipText;
138
139 protected override void Update()
140 {
141 base.Update();
142
143 ITooltipContentProvider target = findTooltipTarget();
144
145 if (target != null && target != currentlyDisplayed)
146 {
147 currentlyDisplayed = target;
148
149 var proposedTooltip = getTooltip(target);
150
151 if (proposedTooltip.GetType() == CurrentTooltip.GetType())
152 CurrentTooltip.SetContent(getTargetContent(target));
153 else
154 {
155 RemoveInternal((Drawable)CurrentTooltip);
156 CurrentTooltip = proposedTooltip;
157 AddInternal((Drawable)proposedTooltip);
158 }
159
160 if (hasValidTooltip(target))
161 CurrentTooltip.Show();
162
163 RefreshTooltip(CurrentTooltip, target);
164 }
165 }
166
167 protected override void UpdateAfterChildren()
168 {
169 base.UpdateAfterChildren();
170
171 RefreshTooltip(CurrentTooltip, currentlyDisplayed);
172
173 if (currentlyDisplayed != null && ShallHideTooltip(currentlyDisplayed))
174 hideTooltip();
175 }
176
177 private readonly List<TimedPosition> recentMousePositions = new List<TimedPosition>();
178 private double lastRecordedPositionTime;
179
180 private bool hasValidTooltip(ITooltipContentProvider target)
181 {
182 var targetContent = getTargetContent(target);
183
184 if (targetContent is LocalisableString localisableString)
185 return !string.IsNullOrEmpty(localisableString.Data?.ToString());
186
187 return targetContent != null;
188 }
189
190 private ITooltipContentProvider lastCandidate;
191
192 /// <summary>
193 /// Determines which drawable should currently receive a tooltip, taking into account
194 /// <see cref="AppearDelay"/> and <see cref="AppearRadius"/>. Returns null if no valid
195 /// target is found.
196 /// </summary>
197 /// <returns>The tooltip target. null if no valid one is found.</returns>
198 private ITooltipContentProvider findTooltipTarget()
199 {
200 // While we are dragging a tooltipped drawable we should show a tooltip for it.
201 if (inputManager.DraggedDrawable is IHasTooltip draggedTarget)
202 return hasValidTooltip(draggedTarget) ? draggedTarget : null;
203
204 if (inputManager.DraggedDrawable is IHasCustomTooltip customDraggedTarget)
205 return hasValidTooltip(customDraggedTarget) ? customDraggedTarget : null;
206
207 ITooltipContentProvider targetCandidate = null;
208
209 foreach (var target in FindTargets())
210 {
211 if (hasValidTooltip(target))
212 {
213 targetCandidate = target;
214 break;
215 }
216 }
217
218 // check this first - if we find no target candidate we still want to clear the recorded positions and update the lastCandidate.
219 if (targetCandidate != lastCandidate)
220 {
221 recentMousePositions.Clear();
222 lastCandidate = targetCandidate;
223 }
224
225 if (targetCandidate == null)
226 return null;
227
228 return handlePotentialTarget(targetCandidate);
229 }
230
231 private ITooltipContentProvider handlePotentialTarget(ITooltipContentProvider targetCandidate)
232 {
233 // this method is intentionally split out from the main lookup above as it has several expensive delegate (LINQ) allocations.
234 // this allows the case where no tooltip is displayed to run with no allocations.
235 // further optimisation work can be done here to reduce allocations while a tooltip is being displayed.
236
237 double appearDelay = (targetCandidate as IHasAppearDelay)?.AppearDelay ?? AppearDelay;
238 // Always keep 10 positions at equally-sized time intervals that add up to AppearDelay.
239 double positionRecordInterval = appearDelay / 10;
240
241 if (Time.Current - lastRecordedPositionTime >= positionRecordInterval)
242 {
243 lastRecordedPositionTime = Time.Current;
244 recentMousePositions.Add(new TimedPosition
245 {
246 Time = Time.Current,
247 Position = ToLocalSpace(inputManager.CurrentState.Mouse.Position)
248 });
249 }
250
251 // check that we have recorded enough positions to make a judgement about whether or not the cursor has been standing still for the required amount of time.
252 // we can skip this if the appear-delay is set to 0, since then tooltips can appear instantly and we don't need to wait to record enough positions.
253 if (appearDelay > 0 && (recentMousePositions.Count == 0 || lastRecordedPositionTime - recentMousePositions[0].Time < appearDelay - positionRecordInterval))
254 return null;
255
256 recentMousePositions.RemoveAll(t => Time.Current - t.Time > appearDelay);
257
258 // For determining whether to show a tooltip we first select only those positions
259 // which happened within a shorter, alpha-adjusted appear delay.
260 double alphaModifiedAppearDelay = (1 - CurrentTooltip.Alpha) * appearDelay;
261 var relevantPositions = recentMousePositions.Where(t => Time.Current - t.Time <= alphaModifiedAppearDelay);
262
263 // We then check whether all relevant positions fall within a radius of AppearRadius within the
264 // first relevant position. If so, then the mouse has stayed within a small circular region of
265 // AppearRadius for the duration of the modified appear delay, and we therefore want to display
266 // the tooltip.
267 Vector2 first = relevantPositions.FirstOrDefault().Position;
268 float appearRadiusSq = AppearRadius * AppearRadius;
269
270 if (relevantPositions.All(t => Vector2Extensions.DistanceSquared(t.Position, first) < appearRadiusSq))
271 return targetCandidate;
272
273 return null;
274 }
275
276 /// <summary>
277 /// Refreshes the displayed tooltip. By default, this <see cref="ITooltip.Move(Vector2)"/>s the tooltip to the cursor position and updates its content via <see cref="ITooltip.SetContent"/>.
278 /// </summary>
279 /// <param name="tooltip">The tooltip that is refreshed.</param>
280 /// <param name="tooltipTarget">The target of the tooltip.</param>
281 protected virtual void RefreshTooltip(ITooltip tooltip, ITooltipContentProvider tooltipTarget)
282 {
283 bool isValid = tooltipTarget != null && hasValidTooltip(tooltipTarget);
284
285 if (isValid)
286 tooltip.SetContent(getTargetContent(tooltipTarget));
287
288 if (isValid || tooltip.IsPresent)
289 tooltip.Move(computeTooltipPosition());
290 }
291
292 private void hideTooltip()
293 {
294 CurrentTooltip.Hide();
295 currentlyDisplayed = null;
296 }
297
298 /// <summary>
299 /// Returns true if the currently visible tooltip should be hidden, false otherwise. By default, returns true if the target of the tooltip is neither hovered nor dragged.
300 /// </summary>
301 /// <param name="tooltipTarget">The target of the tooltip.</param>
302 /// <returns>True if the currently visible tooltip should be hidden, false otherwise.</returns>
303 protected virtual bool ShallHideTooltip(ITooltipContentProvider tooltipTarget) => !hasValidTooltip(tooltipTarget) || !tooltipTarget.IsHovered && !tooltipTarget.IsDragged;
304
305 private ITooltip getTooltip(ITooltipContentProvider target) => (target as IHasCustomTooltip)?.GetCustomTooltip() ?? defaultTooltip;
306
307 /// <summary>
308 /// The default tooltip. Simply displays its text on a gray background and performs no easing.
309 /// </summary>
310 public class Tooltip : VisibilityContainer, ITooltip<LocalisableString>
311 {
312 private readonly SpriteText text;
313
314 /// <summary>
315 /// The text to be displayed by this tooltip. This property is assigned to whenever the tooltip text changes.
316 /// </summary>
317 public virtual string TooltipText
318 {
319 set => SetContent(value);
320 }
321
322 public virtual void SetContent(LocalisableString content) => text.Text = content;
323
324 private const float text_size = 16;
325
326 /// <summary>
327 /// Constructs a new tooltip that starts out invisible.
328 /// </summary>
329 public Tooltip()
330 {
331 Alpha = 0;
332 AutoSizeAxes = Axes.Both;
333
334 Children = new Drawable[]
335 {
336 new Box
337 {
338 RelativeSizeAxes = Axes.Both,
339 Colour = Color4.Gray,
340 },
341 text = new SpriteText
342 {
343 Font = FrameworkFont.Regular.With(size: text_size),
344 Padding = new MarginPadding(5),
345 }
346 };
347 }
348
349 public virtual void Refresh()
350 {
351 }
352
353 /// <summary>
354 /// Called whenever the tooltip appears. When overriding do not forget to fade in.
355 /// </summary>
356 protected override void PopIn() => this.FadeIn();
357
358 /// <summary>
359 /// Called whenever the tooltip disappears. When overriding do not forget to fade out.
360 /// </summary>
361 protected override void PopOut() => this.FadeOut();
362
363 /// <summary>
364 /// Called whenever the position of the tooltip changes. Can be overridden to customize
365 /// easing.
366 /// </summary>
367 /// <param name="pos">The new position of the tooltip.</param>
368 public virtual void Move(Vector2 pos) => Position = pos;
369 }
370 }
371}