Skip to main content

gpui/elements/
anchored.rs

1use std::cmp::Ordering;
2
3use smallvec::SmallVec;
4
5use crate::{
6    Anchor, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId,
7    InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
8    Window, point, px,
9};
10
11/// The state that the anchored element element uses to track its children.
12pub struct AnchoredState {
13    child_layout_ids: SmallVec<[LayoutId; 4]>,
14}
15
16/// An anchored element that can be used to display UI that
17/// will avoid overflowing the window bounds.
18pub struct Anchored {
19    children: SmallVec<[AnyElement; 2]>,
20    anchor: Anchor,
21    fit_mode: AnchoredFitMode,
22    placement: Placement,
23    position_mode: AnchoredPositionMode,
24    offset: Option<Point<Pixels>>,
25}
26
27#[derive(Clone, Copy)]
28enum Placement {
29    InPlace,
30    At(Point<Pixels>),
31    Attached {
32        target: Bounds<Pixels>,
33        corner: Anchor,
34    },
35}
36
37/// anchored gives you an element that will avoid overflowing the window bounds.
38/// Its children should have no margin to avoid measurement issues.
39pub fn anchored() -> Anchored {
40    Anchored {
41        children: SmallVec::new(),
42        anchor: Anchor::TopLeft,
43        fit_mode: AnchoredFitMode::SwitchAnchor,
44        placement: Placement::InPlace,
45        position_mode: AnchoredPositionMode::Window,
46        offset: None,
47    }
48}
49
50impl Anchored {
51    /// Sets which corner of the anchored element should be anchored to the current position.
52    pub fn anchor(mut self, anchor: Anchor) -> Self {
53        self.anchor = anchor;
54        self
55    }
56
57    /// Sets the position in window coordinates
58    /// (otherwise the location the anchored element is rendered is used)
59    pub fn position(mut self, anchor: Point<Pixels>) -> Self {
60        self.placement = Placement::At(anchor);
61        self
62    }
63
64    /// Places the element next to `target`, with the element's [`Anchored::anchor`] corner
65    /// on `corner` of `target`. For example, a submenu puts its top-left corner on its row's
66    /// top-right corner. Use this instead of [`Anchored::position`].
67    ///
68    /// If the element doesn't fit in the window, it moves to the other side of `target`, as
69    /// long as that side fits better. A submenu moves from the right of its row to the left,
70    /// and a dropdown moves from below its button to above. The offset flips with it.
71    /// [`AnchoredFitMode::SwitchAnchor`] has no effect here.
72    pub fn attach(mut self, target: Bounds<Pixels>, corner: Anchor) -> Self {
73        self.placement = Placement::Attached { target, corner };
74        self
75    }
76
77    /// Offset the final position by this amount.
78    /// Useful when you want to anchor to an element but offset from it, such as in PopoverMenu.
79    pub fn offset(mut self, offset: Point<Pixels>) -> Self {
80        self.offset = Some(offset);
81        self
82    }
83
84    /// Sets the position mode for this anchored element. Local will have this
85    /// interpret its [`Anchored::position`] or [`Anchored::attach`] target as
86    /// relative to the parent element. While Window will have it interpret the
87    /// position as relative to the window.
88    pub fn position_mode(mut self, mode: AnchoredPositionMode) -> Self {
89        self.position_mode = mode;
90        self
91    }
92
93    /// Snap to window edge instead of switching anchor corner when an overflow would occur.
94    pub fn snap_to_window(mut self) -> Self {
95        self.fit_mode = AnchoredFitMode::SnapToWindow;
96        self
97    }
98
99    /// Snap to window edge and leave some margins.
100    pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
101        self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
102        self
103    }
104}
105
106impl ParentElement for Anchored {
107    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
108        self.children.extend(elements)
109    }
110}
111
112impl Element for Anchored {
113    type RequestLayoutState = AnchoredState;
114    type PrepaintState = ();
115
116    fn id(&self) -> Option<crate::ElementId> {
117        None
118    }
119
120    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
121        None
122    }
123
124    fn request_layout(
125        &mut self,
126        _id: Option<&GlobalElementId>,
127        _inspector_id: Option<&InspectorElementId>,
128        window: &mut Window,
129        cx: &mut App,
130    ) -> (crate::LayoutId, Self::RequestLayoutState) {
131        let child_layout_ids = self
132            .children
133            .iter_mut()
134            .map(|child| child.request_layout(window, cx))
135            .collect::<SmallVec<_>>();
136
137        let anchored_style = Style {
138            position: Position::Absolute,
139            display: Display::Flex,
140            ..Style::default()
141        };
142
143        let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
144
145        (layout_id, AnchoredState { child_layout_ids })
146    }
147
148    fn prepaint(
149        &mut self,
150        _id: Option<&GlobalElementId>,
151        _inspector_id: Option<&InspectorElementId>,
152        bounds: Bounds<Pixels>,
153        request_layout: &mut Self::RequestLayoutState,
154        window: &mut Window,
155        cx: &mut App,
156    ) {
157        if request_layout.child_layout_ids.is_empty() {
158            return;
159        }
160
161        let children_bounds = request_layout
162            .child_layout_ids
163            .iter()
164            .map(|id| window.layout_bounds(*id))
165            .reduce(|acc, bounds| acc.union(&bounds))
166            .unwrap();
167
168        let anchor_position = match self.placement {
169            Placement::InPlace => None,
170            Placement::At(position) => Some(position),
171            Placement::Attached { target, corner } => Some(target.corner(corner)),
172        };
173        let (origin, mut desired) = self.position_mode.get_position_and_bounds(
174            anchor_position,
175            self.anchor,
176            children_bounds.size,
177            bounds,
178            self.offset,
179        );
180
181        let limits = Bounds {
182            origin: Point::default(),
183            size: window.viewport_size(),
184        };
185
186        if let Placement::Attached { target, mut corner } = self.placement {
187            // Compare against the window edges, not the snap margin, so an element that
188            // already fits doesn't move. The client inset doesn't count as room either,
189            // since on Linux it's the window's transparent shadow.
190            let visible = limits.inset(window.client_inset.unwrap_or(px(0.)));
191            let overflow_along = |bounds: Bounds<Pixels>, axis: Axis| match axis {
192                Axis::Horizontal => {
193                    (visible.left() - bounds.left()).max(px(0.))
194                        + (bounds.right() - visible.right()).max(px(0.))
195                }
196                Axis::Vertical => {
197                    (visible.top() - bounds.top()).max(px(0.))
198                        + (bounds.bottom() - visible.bottom()).max(px(0.))
199                }
200            };
201            let mut anchor = self.anchor;
202            let mut offset = self.offset.unwrap_or_default();
203            for axis in [Axis::Horizontal, Axis::Vertical] {
204                let corner_side = side_along(corner, axis);
205                let is_outside_target = corner_side != Ordering::Equal
206                    && side_along(anchor, axis) == corner_side.reverse();
207                let overflow = overflow_along(desired, axis);
208                if !is_outside_target || overflow == px(0.) {
209                    continue;
210                }
211
212                let flipped_corner = corner.other_side_along(axis);
213                let flipped_anchor = anchor.other_side_along(axis);
214                let flipped_offset = match axis {
215                    Axis::Horizontal => point(-offset.x, offset.y),
216                    Axis::Vertical => point(offset.x, -offset.y),
217                };
218                let (_, flipped) = self.position_mode.get_position_and_bounds(
219                    Some(target.corner(flipped_corner)),
220                    flipped_anchor,
221                    children_bounds.size,
222                    bounds,
223                    Some(flipped_offset),
224                );
225                if overflow_along(flipped, axis) < overflow {
226                    corner = flipped_corner;
227                    anchor = flipped_anchor;
228                    offset = flipped_offset;
229                    desired = flipped;
230                }
231            }
232        } else if self.fit_mode == AnchoredFitMode::SwitchAnchor {
233            let mut anchor = self.anchor;
234
235            if desired.left() < limits.left() || desired.right() > limits.right() {
236                let switched = Bounds::from_anchor_and_size(
237                    anchor.other_side_along(Axis::Horizontal),
238                    origin,
239                    children_bounds.size,
240                );
241                if !(switched.left() < limits.left() || switched.right() > limits.right()) {
242                    anchor = anchor.other_side_along(Axis::Horizontal);
243                    desired = switched
244                }
245            }
246
247            if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
248                let switched = Bounds::from_anchor_and_size(
249                    anchor.other_side_along(Axis::Vertical),
250                    origin,
251                    children_bounds.size,
252                );
253                if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
254                    desired = switched;
255                }
256            }
257        }
258
259        let client_inset = window.client_inset.unwrap_or(px(0.));
260        let edges = match self.fit_mode {
261            AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
262            _ => Edges::default(),
263        }
264        .map(|edge| *edge + client_inset);
265
266        // Snap the horizontal edges of the anchored element to the horizontal edges of the window if
267        // its horizontal bounds overflow, aligning to the left if it is wider than the limits.
268        if desired.right() > limits.right() {
269            desired.origin.x -= desired.right() - limits.right() + edges.right;
270        }
271        if desired.left() < limits.left() {
272            desired.origin.x = limits.origin.x + edges.left;
273        }
274
275        // Snap the vertical edges of the anchored element to the vertical edges of the window if
276        // its vertical bounds overflow, aligning to the top if it is taller than the limits.
277        if desired.bottom() > limits.bottom() {
278            desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
279        }
280        if desired.top() < limits.top() {
281            desired.origin.y = limits.origin.y + edges.top;
282        }
283
284        let offset = desired.origin - bounds.origin;
285        let offset = point(offset.x.round(), offset.y.round());
286
287        window.with_element_offset(offset, |window| {
288            for child in &mut self.children {
289                child.prepaint(window, cx);
290            }
291        })
292    }
293
294    fn paint(
295        &mut self,
296        _id: Option<&GlobalElementId>,
297        _inspector_id: Option<&InspectorElementId>,
298        _bounds: crate::Bounds<crate::Pixels>,
299        _request_layout: &mut Self::RequestLayoutState,
300        _prepaint: &mut Self::PrepaintState,
301        window: &mut Window,
302        cx: &mut App,
303    ) {
304        for child in &mut self.children {
305            child.paint(window, cx);
306        }
307    }
308}
309
310impl IntoElement for Anchored {
311    type Element = Self;
312
313    fn into_element(self) -> Self::Element {
314        self
315    }
316}
317
318/// Which side of a box `anchor` is on along `axis`. `Less` is left or top, `Equal` is the
319/// center, and `Greater` is right or bottom.
320fn side_along(anchor: Anchor, axis: Axis) -> Ordering {
321    match axis {
322        Axis::Horizontal => match anchor {
323            Anchor::TopLeft | Anchor::BottomLeft | Anchor::LeftCenter => Ordering::Less,
324            Anchor::TopCenter | Anchor::BottomCenter => Ordering::Equal,
325            Anchor::TopRight | Anchor::BottomRight | Anchor::RightCenter => Ordering::Greater,
326        },
327        Axis::Vertical => match anchor {
328            Anchor::TopLeft | Anchor::TopRight | Anchor::TopCenter => Ordering::Less,
329            Anchor::LeftCenter | Anchor::RightCenter => Ordering::Equal,
330            Anchor::BottomLeft | Anchor::BottomRight | Anchor::BottomCenter => Ordering::Greater,
331        },
332    }
333}
334
335/// Which algorithm to use when fitting the anchored element to be inside the window.
336#[derive(Copy, Clone, PartialEq)]
337pub enum AnchoredFitMode {
338    /// Snap the anchored element to the window edge.
339    SnapToWindow,
340    /// Snap to window edge and leave some margins.
341    SnapToWindowWithMargin(Edges<Pixels>),
342    /// Switch which corner anchor this anchored element is attached to.
343    SwitchAnchor,
344}
345
346/// Which algorithm to use when positioning the anchored element.
347#[derive(Copy, Clone, PartialEq)]
348pub enum AnchoredPositionMode {
349    /// Position the anchored element relative to the window.
350    Window,
351    /// Position the anchored element relative to its parent.
352    Local,
353}
354
355impl AnchoredPositionMode {
356    fn get_position_and_bounds(
357        &self,
358        anchor_position: Option<Point<Pixels>>,
359        anchor: Anchor,
360        size: Size<Pixels>,
361        bounds: Bounds<Pixels>,
362        offset: Option<Point<Pixels>>,
363    ) -> (Point<Pixels>, Bounds<Pixels>) {
364        let offset = offset.unwrap_or_default();
365
366        match self {
367            AnchoredPositionMode::Window => {
368                let anchor_position = anchor_position.unwrap_or(bounds.origin);
369                let bounds = Bounds::from_anchor_and_size(anchor, anchor_position + offset, size);
370                (anchor_position, bounds)
371            }
372            AnchoredPositionMode::Local => {
373                let anchor_position = anchor_position.unwrap_or_default();
374                let bounds = Bounds::from_anchor_and_size(
375                    anchor,
376                    bounds.origin + anchor_position + offset,
377                    size,
378                );
379                (anchor_position, bounds)
380            }
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use crate::{
388        Anchor, Bounds, Context, Pixels, PlatformInput, Point, TestAppContext, Window, deferred,
389        div, point, prelude::*, px, size,
390    };
391
392    struct AnchoredTestView {
393        position: Point<Pixels>,
394    }
395
396    impl Render for AnchoredTestView {
397        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
398            div().size_full().child(
399                div()
400                    .id("scroll-container")
401                    .overflow_y_scroll()
402                    .size_full()
403                    .child(div().h(px(2000.)).w_full())
404                    .child(
405                        deferred(
406                            super::anchored()
407                                .snap_to_window()
408                                .position(self.position)
409                                .child(
410                                    div()
411                                        .id("menu")
412                                        .debug_selector(|| "MENU".into())
413                                        .w(px(200.))
414                                        .h(px(300.)),
415                                ),
416                        )
417                        .with_priority(1),
418                    ),
419            )
420        }
421    }
422
423    #[gpui::test]
424    fn test_anchored_position_without_scroll(cx: &mut TestAppContext) {
425        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
426            position: point(px(100.), px(100.)),
427        });
428
429        cx.run_until_parked();
430
431        let menu_bounds = window
432            .update(cx, |_, window, _| {
433                window.rendered_frame.debug_bounds.get("MENU").copied()
434            })
435            .unwrap()
436            .expect("MENU debug bounds not found");
437
438        assert_eq!(menu_bounds.origin, point(px(100.), px(100.)));
439        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
440    }
441
442    #[gpui::test]
443    fn test_anchored_position_when_scrolled(cx: &mut TestAppContext) {
444        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
445            position: point(px(100.), px(100.)),
446        });
447
448        cx.run_until_parked();
449
450        window
451            .update(cx, |_, window, cx| {
452                let event = gpui::ScrollEvent {
453                    position: point(px(400.), px(300.)),
454                    delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1000.))),
455                    ..Default::default()
456                };
457                window.dispatch_event(PlatformInput::Scroll(event), cx);
458            })
459            .unwrap();
460
461        cx.run_until_parked();
462
463        let menu_bounds = window
464            .update(cx, |_, window, _| {
465                window.rendered_frame.debug_bounds.get("MENU").copied()
466            })
467            .unwrap()
468            .expect("MENU debug bounds not found");
469
470        assert_eq!(menu_bounds.origin, point(px(100.), px(100.)));
471        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
472    }
473
474    #[gpui::test]
475    fn test_anchored_snaps_to_window(cx: &mut TestAppContext) {
476        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
477            position: point(px(100.), px(500.)),
478        });
479
480        cx.run_until_parked();
481
482        let menu_bounds = window
483            .update(cx, |_, window, _| {
484                window.rendered_frame.debug_bounds.get("MENU").copied()
485            })
486            .unwrap()
487            .expect("MENU debug bounds not found");
488
489        assert_eq!(menu_bounds.origin, point(px(100.), px(300.)));
490        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
491    }
492
493    struct AttachedTestView {
494        target: Bounds<Pixels>,
495        corner: Anchor,
496        anchor: Anchor,
497    }
498
499    impl Render for AttachedTestView {
500        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
501            div().size_full().child(
502                deferred(
503                    super::anchored()
504                        .attach(self.target, self.corner)
505                        .anchor(self.anchor)
506                        .offset(point(px(-2.), px(4.)))
507                        .snap_to_window_with_margin(px(8.))
508                        .child(
509                            div()
510                                .debug_selector(|| "MENU".into())
511                                .w(px(200.))
512                                .h(px(300.)),
513                        ),
514                )
515                .with_priority(1),
516            )
517        }
518    }
519
520    #[gpui::test]
521    fn test_anchored_attach_moves_to_the_side_with_room(cx: &mut TestAppContext) {
522        let trigger = |x: f32, y: f32| Bounds::new(point(px(x), px(y)), size(px(100.), px(20.)));
523        // A submenu sits to the right of its trigger, with their top edges lined up.
524        let submenu = (Anchor::TopRight, Anchor::TopLeft);
525        // A dropdown sits below its button, with their left edges lined up.
526        let dropdown = (Anchor::BottomLeft, Anchor::TopLeft);
527        for (target, (corner, anchor), expected_origin) in [
528            (trigger(100., 100.), submenu, point(px(198.), px(104.))),
529            (trigger(550., 100.), submenu, point(px(352.), px(104.))),
530            (trigger(100., 400.), dropdown, point(px(98.), px(96.))),
531            // Ends 4px from the window edge. That's inside the snap margin but still fits,
532            // so it stays.
533            (trigger(498., 100.), submenu, point(px(596.), px(104.))),
534            // Doesn't fit on either side. The left sticks out less, so it goes left and
535            // slides back into the window.
536            (
537                Bounds::new(point(px(180.), px(100.)), size(px(500.), px(20.))),
538                submenu,
539                point(px(8.), px(104.)),
540            ),
541            // Doesn't fit on either side, but the right sticks out less, so it stays right
542            // and slides back into the window.
543            (
544                Bounds::new(point(px(50.), px(100.)), size(px(650.), px(20.))),
545                submenu,
546                point(px(592.), px(104.)),
547            ),
548            // A dropdown that runs off the right edge slides left. It never moves beside its
549            // button.
550            (trigger(700., 100.), dropdown, point(px(592.), px(124.))),
551            // Centered under its button, so it can move above it but never sideways.
552            (
553                trigger(100., 400.),
554                (Anchor::BottomCenter, Anchor::TopCenter),
555                point(px(48.), px(96.)),
556            ),
557        ] {
558            let window = cx.open_window(size(px(800.), px(600.)), |_, _| AttachedTestView {
559                target,
560                corner,
561                anchor,
562            });
563
564            cx.run_until_parked();
565
566            let menu_bounds = window
567                .update(cx, |_, window, _| {
568                    window.rendered_frame.debug_bounds.get("MENU").copied()
569                })
570                .unwrap()
571                .expect("MENU debug bounds not found");
572
573            assert_eq!(
574                menu_bounds.origin, expected_origin,
575                "target: {target:?}, corner: {corner:?}, anchor: {anchor:?}"
576            );
577        }
578    }
579}