Skip to main content

gpui/elements/
anchored.rs

1use smallvec::SmallVec;
2
3use crate::{
4    Anchor, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId,
5    InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
6    Window, point, px,
7};
8
9/// The layout state of an [`Anchored`] element: the layout ids of its children.
10///
11/// It is passed from `request_layout` to `prepaint`, which uses the children's bounds to place them.
12pub struct AnchoredState {
13    child_layout_ids: SmallVec<[LayoutId; 4]>,
14}
15
16/// An element that places its children at an anchor point, and keeps them inside the window.
17///
18/// Create one with [`anchored`]. Its children keep their own layout, and the anchored element
19/// moves the group so that it sits at the anchor position. It suits popovers, menus and tooltips.
20#[derive(derive_setters::Setters)]
21#[setters(generate = false)]
22pub struct Anchored {
23    children: SmallVec<[AnyElement; 2]>,
24    /// Sets which corner of the element sits at the anchor position. The default is the top left.
25    #[setters(generate)]
26    anchor: Anchor,
27    fit_mode: AnchoredFitMode,
28    /// Sets the anchor position. Its meaning depends on [`Anchored::position_mode`]: window
29    /// coordinates by default, or an offset from the parent's origin for [`AnchoredPositionMode::Local`].
30    ///
31    /// Without this, the anchor is the element's own position.
32    #[setters(generate, rename = "position", strip_option)]
33    anchor_position: Option<Point<Pixels>>,
34    /// Sets how [`Anchored::position`] is interpreted. The default is [`AnchoredPositionMode::Window`].
35    ///
36    /// [`AnchoredPositionMode::Window`] treats the position as window coordinates.
37    /// [`AnchoredPositionMode::Local`] treats it as relative to the element's parent.
38    #[setters(generate)]
39    position_mode: AnchoredPositionMode,
40    /// Moves the placed element by `offset`, after the anchor position is applied.
41    ///
42    /// Use it to sit beside an anchor rather than on it, such as a popover that's offset from the
43    /// button that opens it.
44    #[setters(generate, strip_option)]
45    offset: Option<Point<Pixels>>,
46}
47
48/// Creates an [`Anchored`] element that keeps its children inside the window.
49///
50/// By default the element's top-left corner sits at its anchor position. If the children would
51/// overflow the window, the element first switches to the opposite corner, when that fits. Use
52/// [`Anchored::snap_to_window`] to push the children inside the window instead.
53///
54/// The anchor position is in window coordinates unless you change it with
55/// [`Anchored::position_mode`]. Without [`Anchored::position`], the anchor is the element's own
56/// position.
57///
58/// # Example
59///
60/// The menu's children are drawn at the anchor, `(50, 50)`. The `canvas` reports where it was
61/// placed:
62///
63/// ```
64/// use std::{cell::Cell, rc::Rc};
65///
66/// use gpui::{
67///     Context, IntoElement, Pixels, Point, Render, TestApp, Window, anchored, canvas, deferred,
68///     div, point, prelude::*, px,
69/// };
70///
71/// struct Menu {
72///     origin: Rc<Cell<Option<Point<Pixels>>>>,
73/// }
74///
75/// impl Render for Menu {
76///     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
77///         let origin = self.origin.clone();
78///         deferred(
79///             anchored()
80///                 .position(point(px(50.), px(50.)))
81///                 .snap_to_window()
82///                 .child(
83///                     div().w(px(200.)).h(px(300.)).child(
84///                         canvas(
85///                             move |bounds, _window, _cx| origin.set(Some(bounds.origin)),
86///                             |_bounds, _prepaint_result, _window, _cx| {},
87///                         )
88///                         .size_full(),
89///                     ),
90///                 ),
91///         )
92///     }
93/// }
94///
95/// let origin = Rc::new(Cell::new(None));
96/// let mut app = TestApp::new();
97/// let mut window = app.open_window({
98///     let origin = origin.clone();
99///     move |_window, _cx| Menu { origin }
100/// });
101///
102/// window.draw();
103/// assert_eq!(origin.get(), Some(point(px(50.), px(50.))));
104/// ```
105pub fn anchored() -> Anchored {
106    Anchored {
107        children: SmallVec::new(),
108        anchor: Anchor::TopLeft,
109        fit_mode: AnchoredFitMode::SwitchAnchor,
110        anchor_position: None,
111        position_mode: AnchoredPositionMode::Window,
112        offset: None,
113    }
114}
115
116impl Anchored {
117    /// Snaps the element to the window's edge when it would overflow, rather than switching
118    /// corners.
119    pub fn snap_to_window(mut self) -> Self {
120        self.fit_mode = AnchoredFitMode::SnapToWindow;
121        self
122    }
123
124    /// Snaps the element inside the window's edges, keeping `edges` as margin from each edge.
125    ///
126    /// The margin is added to the window's client inset, when the platform sets one.
127    pub fn snap_to_window_with_margin(mut self, edges: impl Into<Edges<Pixels>>) -> Self {
128        self.fit_mode = AnchoredFitMode::SnapToWindowWithMargin(edges.into());
129        self
130    }
131}
132
133impl ParentElement for Anchored {
134    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
135        self.children.extend(elements)
136    }
137}
138
139impl Element for Anchored {
140    type RequestLayoutState = AnchoredState;
141    type PrepaintState = ();
142
143    fn id(&self) -> Option<crate::ElementId> {
144        None
145    }
146
147    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
148        None
149    }
150
151    fn request_layout(
152        &mut self,
153        _id: Option<&GlobalElementId>,
154        _inspector_id: Option<&InspectorElementId>,
155        window: &mut Window,
156        cx: &mut App,
157    ) -> (crate::LayoutId, Self::RequestLayoutState) {
158        let child_layout_ids = self
159            .children
160            .iter_mut()
161            .map(|child| child.request_layout(window, cx))
162            .collect::<SmallVec<_>>();
163
164        let anchored_style = Style {
165            position: Position::Absolute,
166            display: Display::Flex,
167            ..Style::default()
168        };
169
170        let layout_id = window.request_layout(anchored_style, child_layout_ids.iter().copied(), cx);
171
172        (layout_id, AnchoredState { child_layout_ids })
173    }
174
175    fn prepaint(
176        &mut self,
177        _id: Option<&GlobalElementId>,
178        _inspector_id: Option<&InspectorElementId>,
179        bounds: Bounds<Pixels>,
180        request_layout: &mut Self::RequestLayoutState,
181        window: &mut Window,
182        cx: &mut App,
183    ) {
184        if request_layout.child_layout_ids.is_empty() {
185            return;
186        }
187
188        let children_bounds = request_layout
189            .child_layout_ids
190            .iter()
191            .map(|id| window.layout_bounds(*id))
192            .reduce(|acc, bounds| acc.union(&bounds))
193            .unwrap();
194
195        let (origin, mut desired) = self.position_mode.get_position_and_bounds(
196            self.anchor_position,
197            self.anchor,
198            children_bounds.size,
199            bounds,
200            self.offset,
201        );
202
203        let limits = Bounds {
204            origin: Point::default(),
205            size: window.viewport_size(),
206        };
207
208        if self.fit_mode == AnchoredFitMode::SwitchAnchor {
209            let mut anchor = self.anchor;
210
211            if desired.left() < limits.left() || desired.right() > limits.right() {
212                let switched = Bounds::from_anchor_and_size(
213                    anchor.other_side_along(Axis::Horizontal),
214                    origin,
215                    children_bounds.size,
216                );
217                if !(switched.left() < limits.left() || switched.right() > limits.right()) {
218                    anchor = anchor.other_side_along(Axis::Horizontal);
219                    desired = switched
220                }
221            }
222
223            if desired.top() < limits.top() || desired.bottom() > limits.bottom() {
224                let switched = Bounds::from_anchor_and_size(
225                    anchor.other_side_along(Axis::Vertical),
226                    origin,
227                    children_bounds.size,
228                );
229                if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) {
230                    desired = switched;
231                }
232            }
233        }
234
235        let client_inset = window.client_inset.unwrap_or(px(0.));
236        let edges = match self.fit_mode {
237            AnchoredFitMode::SnapToWindowWithMargin(edges) => edges,
238            _ => Edges::default(),
239        }
240        .map(|edge| *edge + client_inset);
241
242        // Snap the horizontal edges of the anchored element to the horizontal edges of the window if
243        // its horizontal bounds overflow, aligning to the left if it is wider than the limits.
244        if desired.right() > limits.right() {
245            desired.origin.x -= desired.right() - limits.right() + edges.right;
246        }
247        if desired.left() < limits.left() {
248            desired.origin.x = limits.origin.x + edges.left;
249        }
250
251        // Snap the vertical edges of the anchored element to the vertical edges of the window if
252        // its vertical bounds overflow, aligning to the top if it is taller than the limits.
253        if desired.bottom() > limits.bottom() {
254            desired.origin.y -= desired.bottom() - limits.bottom() + edges.bottom;
255        }
256        if desired.top() < limits.top() {
257            desired.origin.y = limits.origin.y + edges.top;
258        }
259
260        let offset = desired.origin - bounds.origin;
261        let offset = point(offset.x.round(), offset.y.round());
262
263        window.with_element_offset(offset, |window| {
264            for child in &mut self.children {
265                child.prepaint(window, cx);
266            }
267        })
268    }
269
270    fn paint(
271        &mut self,
272        _id: Option<&GlobalElementId>,
273        _inspector_id: Option<&InspectorElementId>,
274        _bounds: crate::Bounds<crate::Pixels>,
275        _request_layout: &mut Self::RequestLayoutState,
276        _prepaint: &mut Self::PrepaintState,
277        window: &mut Window,
278        cx: &mut App,
279    ) {
280        for child in &mut self.children {
281            child.paint(window, cx);
282        }
283    }
284}
285
286impl IntoElement for Anchored {
287    type Element = Self;
288
289    fn into_element(self) -> Self::Element {
290        self
291    }
292}
293
294/// How an [`Anchored`] element keeps its children inside the window.
295#[derive(Copy, Clone, PartialEq)]
296pub enum AnchoredFitMode {
297    /// Moves the element to the window edge when it would overflow.
298    SnapToWindow,
299    /// Moves the element to the window edge, keeping the given margins from each edge.
300    SnapToWindowWithMargin(Edges<Pixels>),
301    /// Switches to the opposite corner when it would overflow and that corner fits. This is the
302    /// default.
303    SwitchAnchor,
304}
305
306/// The coordinate space of an [`Anchored`] element's anchor position.
307#[derive(Copy, Clone, PartialEq)]
308pub enum AnchoredPositionMode {
309    /// The anchor position is in window coordinates. This is the default.
310    Window,
311    /// The anchor position is relative to the element's parent.
312    Local,
313}
314
315impl AnchoredPositionMode {
316    fn get_position_and_bounds(
317        &self,
318        anchor_position: Option<Point<Pixels>>,
319        anchor: Anchor,
320        size: Size<Pixels>,
321        bounds: Bounds<Pixels>,
322        offset: Option<Point<Pixels>>,
323    ) -> (Point<Pixels>, Bounds<Pixels>) {
324        let offset = offset.unwrap_or_default();
325
326        match self {
327            AnchoredPositionMode::Window => {
328                let anchor_position = anchor_position.unwrap_or(bounds.origin) + offset;
329                let bounds = Bounds::from_anchor_and_size(anchor, anchor_position, size);
330                (anchor_position, bounds)
331            }
332            AnchoredPositionMode::Local => {
333                let anchor_position = bounds.origin + anchor_position.unwrap_or_default() + offset;
334                let bounds = Bounds::from_anchor_and_size(anchor, anchor_position, size);
335                (anchor_position, bounds)
336            }
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use crate::{
344        Anchor, Bounds, Context, Pixels, PlatformInput, Point, TestAppContext, Window, deferred,
345        div, point, prelude::*, px, size,
346    };
347
348    /// The point returned for switching anchors must be the one the initial placement used.
349    #[test]
350    fn anchor_switch_point_includes_offset_and_parent_origin() {
351        let layout_bounds = Bounds::new(point(px(500.), px(100.)), size(px(200.), px(100.)));
352        for (mode, expected) in [
353            (
354                super::AnchoredPositionMode::Window,
355                point(px(125.), px(25.)),
356            ),
357            (
358                super::AnchoredPositionMode::Local,
359                point(px(625.), px(125.)),
360            ),
361        ] {
362            let (origin, bounds) = mode.get_position_and_bounds(
363                Some(point(px(100.), px(20.))),
364                Anchor::TopLeft,
365                size(px(200.), px(300.)),
366                layout_bounds,
367                Some(point(px(25.), px(5.))),
368            );
369            assert_eq!(origin, expected);
370            assert_eq!(bounds.origin, expected);
371        }
372    }
373
374    struct LocalAnchoredTestView;
375
376    impl Render for LocalAnchoredTestView {
377        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
378            div().size_full().child(
379                div()
380                    .absolute()
381                    .left(px(500.))
382                    .top(px(100.))
383                    .child(deferred(
384                        super::anchored()
385                            .position_mode(super::AnchoredPositionMode::Local)
386                            .position(point(px(100.), px(20.)))
387                            .offset(point(px(25.), px(5.)))
388                            .child(
389                                div()
390                                    .debug_selector(|| "LOCAL_MENU".into())
391                                    .w(px(200.))
392                                    .h(px(300.)),
393                            ),
394                    )),
395            )
396        }
397    }
398
399    /// The menu would overflow the right edge at (625, 125), so it switches to the right anchor
400    /// at that same point.
401    #[gpui::test]
402    fn test_anchored_local_switch_anchor(cx: &mut TestAppContext) {
403        let window = cx.open_window(size(px(800.), px(600.)), |_, _| LocalAnchoredTestView);
404        cx.run_until_parked();
405
406        let menu_bounds = window
407            .update(cx, |_, window, _| {
408                window
409                    .rendered_frame
410                    .debug_bounds
411                    .get("LOCAL_MENU")
412                    .copied()
413            })
414            .unwrap()
415            .expect("LOCAL_MENU debug bounds not found");
416
417        assert_eq!(menu_bounds.origin, point(px(425.), px(125.)));
418        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
419    }
420
421    struct AnchoredTestView {
422        position: Point<Pixels>,
423    }
424
425    impl Render for AnchoredTestView {
426        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
427            div().size_full().child(
428                div()
429                    .id("scroll-container")
430                    .overflow_y_scroll()
431                    .size_full()
432                    .child(div().h(px(2000.)).w_full())
433                    .child(
434                        deferred(
435                            super::anchored()
436                                .snap_to_window()
437                                .position(self.position)
438                                .child(
439                                    div()
440                                        .id("menu")
441                                        .debug_selector(|| "MENU".into())
442                                        .w(px(200.))
443                                        .h(px(300.)),
444                                ),
445                        )
446                        .priority(1),
447                    ),
448            )
449        }
450    }
451
452    #[gpui::test]
453    fn test_anchored_position_without_scroll(cx: &mut TestAppContext) {
454        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
455            position: point(px(100.), px(100.)),
456        });
457
458        cx.run_until_parked();
459
460        let menu_bounds = window
461            .update(cx, |_, window, _| {
462                window.rendered_frame.debug_bounds.get("MENU").copied()
463            })
464            .unwrap()
465            .expect("MENU debug bounds not found");
466
467        assert_eq!(menu_bounds.origin, point(px(100.), px(100.)));
468        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
469    }
470
471    #[gpui::test]
472    fn test_anchored_position_when_scrolled(cx: &mut TestAppContext) {
473        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
474            position: point(px(100.), px(100.)),
475        });
476
477        cx.run_until_parked();
478
479        window
480            .update(cx, |_, window, cx| {
481                let event = gpui::ScrollWheelEvent {
482                    position: point(px(400.), px(300.)),
483                    delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1000.))),
484                    ..Default::default()
485                };
486                window.dispatch_event(PlatformInput::ScrollWheel(event), cx);
487            })
488            .unwrap();
489
490        cx.run_until_parked();
491
492        let menu_bounds = window
493            .update(cx, |_, window, _| {
494                window.rendered_frame.debug_bounds.get("MENU").copied()
495            })
496            .unwrap()
497            .expect("MENU debug bounds not found");
498
499        assert_eq!(menu_bounds.origin, point(px(100.), px(100.)));
500        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
501    }
502
503    #[gpui::test]
504    fn test_anchored_snaps_to_window(cx: &mut TestAppContext) {
505        let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView {
506            position: point(px(100.), px(500.)),
507        });
508
509        cx.run_until_parked();
510
511        let menu_bounds = window
512            .update(cx, |_, window, _| {
513                window.rendered_frame.debug_bounds.get("MENU").copied()
514            })
515            .unwrap()
516            .expect("MENU debug bounds not found");
517
518        assert_eq!(menu_bounds.origin, point(px(100.), px(300.)));
519        assert_eq!(menu_bounds.size, size(px(200.), px(300.)));
520    }
521}