A game framework written with osu! in mind.
at master 371 lines 16 kB view raw
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}