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 osu.Framework.Extensions;
6using osu.Framework.Extensions.EnumExtensions;
7using osu.Framework.Graphics.Containers;
8using osu.Framework.Graphics.Primitives;
9using osu.Framework.Graphics.UserInterface;
10using osu.Framework.Input.Events;
11using osu.Framework.Utils;
12using osuTK;
13
14#nullable enable
15
16namespace osu.Framework.Graphics.Cursor
17{
18 public class PopoverContainer : Container
19 {
20 private readonly Container content;
21 private readonly Container dismissOnMouseDownContainer;
22
23 private IHasPopover? target;
24 private Popover? currentPopover;
25
26 protected override Container<Drawable> Content => content;
27
28 public PopoverContainer()
29 {
30 InternalChildren = new Drawable[]
31 {
32 content = new Container
33 {
34 RelativeSizeAxes = Axes.Both,
35 },
36 dismissOnMouseDownContainer = new DismissOnMouseDownContainer
37 {
38 RelativeSizeAxes = Axes.Both
39 }
40 };
41 }
42
43 /// <summary>
44 /// Sets the target drawable for this <see cref="PopoverContainer"/> to <paramref name="newTarget"/>.
45 /// </summary>
46 /// <remarks>
47 /// After calling this method, the previous popover shown in this <see cref="PopoverContainer"/> will be hidden.
48 /// This method can be called with a <see langword="null"/> argument to hide the currently-visible popover.
49 /// </remarks>
50 /// <returns><see langword="true"/> if a new popover was shown, <see langword="false"/> otherwise.</returns>
51 internal bool SetTarget(IHasPopover? newTarget)
52 {
53 currentPopover?.Hide();
54 currentPopover?.Expire();
55
56 target = newTarget;
57
58 var newPopover = target?.GetPopover();
59 if (newPopover == null)
60 return false;
61
62 dismissOnMouseDownContainer.Add(currentPopover = newPopover);
63 currentPopover.Show();
64 return true;
65 }
66
67 protected override void UpdateAfterChildren()
68 {
69 base.UpdateAfterChildren();
70
71 if ((target as Drawable)?.FindClosestParent<PopoverContainer>() != this || target?.IsPresent != true)
72 {
73 SetTarget(null);
74 return;
75 }
76
77 updatePopoverPositioning();
78 }
79
80 protected override void OnSizingChanged()
81 {
82 base.OnSizingChanged();
83
84 // reset to none to prevent exceptions
85 content.RelativeSizeAxes = Axes.None;
86 content.AutoSizeAxes = Axes.None;
87
88 // in addition to using this.RelativeSizeAxes, sets RelativeSizeAxes on every axis that is neither relative size nor auto size
89 content.RelativeSizeAxes = Axes.Both & ~AutoSizeAxes;
90 content.AutoSizeAxes = AutoSizeAxes;
91 }
92
93 /// <summary>
94 /// The <see cref="Anchor"/>s to consider when auto-layouting the popover.
95 /// <see cref="Anchor.Centre"/> is not included, as it is used as a fallback if any other anchor fails.
96 /// </summary>
97 private static readonly Anchor[] candidate_anchors =
98 {
99 Anchor.TopLeft,
100 Anchor.TopCentre,
101 Anchor.TopRight,
102 Anchor.CentreLeft,
103 Anchor.CentreRight,
104 Anchor.BottomLeft,
105 Anchor.BottomCentre,
106 Anchor.BottomRight
107 };
108
109 private void updatePopoverPositioning()
110 {
111 if (target == null || currentPopover == null)
112 return;
113
114 var targetLocalQuad = ToLocalSpace(target.ScreenSpaceDrawQuad);
115
116 Anchor bestAnchor = Anchor.Centre;
117 float biggestArea = 0;
118
119 float totalSize = Math.Max(DrawSize.X * DrawSize.Y, 1);
120
121 foreach (var anchor in candidate_anchors)
122 {
123 // Compute how much free space is available on this side of the target.
124 var availableSize = availableSizeAroundTargetForAnchor(targetLocalQuad, anchor);
125 float area = availableSize.X * availableSize.Y / totalSize;
126
127 // If the free space is insufficient for the popover to fit in, do not consider this anchor further.
128 if (availableSize.X < currentPopover.BoundingBoxContainer.DrawWidth || availableSize.Y < currentPopover.BoundingBoxContainer.DrawHeight)
129 continue;
130
131 // The heuristic used to find the "best" anchor is the biggest area of free space available in the popover container
132 // on the side of the anchor.
133 if (Precision.DefinitelyBigger(area, biggestArea, 0.01f))
134 {
135 biggestArea = area;
136 bestAnchor = anchor;
137 }
138 }
139
140 currentPopover.PopoverAnchor = bestAnchor.Opposite();
141
142 var positionOnQuad = bestAnchor.PositionOnQuad(targetLocalQuad);
143 currentPopover.Position = new Vector2(positionOnQuad.X - Padding.Left, positionOnQuad.Y - Padding.Top);
144
145 // While the side has been chosen to maximise the area of free space available, that doesn't mean that the popover's body
146 // will still fit in its entirety in the default configuration.
147 // To avoid this, offset the popover so that it fits in the bounds of this container.
148 var adjustment = new Vector2();
149
150 var popoverContentLocalQuad = ToLocalSpace(currentPopover.Body.ScreenSpaceDrawQuad);
151 if (popoverContentLocalQuad.TopLeft.X < 0)
152 adjustment.X = -popoverContentLocalQuad.TopLeft.X;
153 else if (popoverContentLocalQuad.BottomRight.X > DrawWidth)
154 adjustment.X = DrawWidth - popoverContentLocalQuad.BottomRight.X;
155 if (popoverContentLocalQuad.TopLeft.Y < 0)
156 adjustment.Y = -popoverContentLocalQuad.TopLeft.Y;
157 else if (popoverContentLocalQuad.BottomRight.Y > DrawHeight)
158 adjustment.Y = DrawHeight - popoverContentLocalQuad.BottomRight.Y;
159
160 currentPopover.Position += adjustment;
161
162 // Even if the popover was moved, the arrow should stay fixed in place and point at the target's centre.
163 // In such a case, apply a counter-adjustment to the arrow position.
164 // The reason why just the body isn't moved is that the popover's autosize does not play well with that
165 // (setting X/Y on the body can lead BoundingBox to be larger than it actually needs to be, causing 1-frame-errors)
166 currentPopover.Arrow.Position = -adjustment;
167 }
168
169 /// <summary>
170 /// Computes the available size around the <paramref name="targetLocalQuad"/> on the side of it indicated by <paramref name="anchor"/>
171 /// </summary>
172 private Vector2 availableSizeAroundTargetForAnchor(Quad targetLocalQuad, Anchor anchor)
173 {
174 Vector2 availableSize = new Vector2();
175
176 // left anchor = area to the left of the quad, right anchor = area to the right of the quad.
177 // for horizontal centre assume we have the whole quad width to work with.
178 if (anchor.HasFlagFast(Anchor.x0))
179 availableSize.X = MathF.Max(0, targetLocalQuad.TopLeft.X);
180 else if (anchor.HasFlagFast(Anchor.x2))
181 availableSize.X = MathF.Max(0, DrawWidth - targetLocalQuad.BottomRight.X);
182 else
183 availableSize.X = DrawWidth;
184
185 // top anchor = area above quad, bottom anchor = area below quad.
186 // for vertical centre assume we have the whole quad height to work with.
187 if (anchor.HasFlagFast(Anchor.y0))
188 availableSize.Y = MathF.Max(0, targetLocalQuad.TopLeft.Y);
189 else if (anchor.HasFlagFast(Anchor.y2))
190 availableSize.Y = MathF.Max(0, DrawHeight - targetLocalQuad.BottomRight.Y);
191 else
192 availableSize.Y = DrawHeight;
193
194 // the final size is the intersection of the X/Y areas.
195 return availableSize;
196 }
197
198 private class DismissOnMouseDownContainer : Container
199 {
200 protected override bool OnMouseDown(MouseDownEvent e)
201 {
202 this.HidePopover();
203 return false;
204 }
205 }
206 }
207}