Skip to main content

gpui_base/
positioner.rs

1//! Shared popup positioning.
2//!
3//! Every anchored surface in Base resolves its position here so that flipping,
4//! alignment, and viewport clamping cannot drift apart between popups,
5//! tooltips, and menus.
6
7use gpui::{
8    Anchor, AnyElement, App, Bounds, Decorations, Display, Edges, Element, GlobalElementId,
9    Half as _, HitboxBehavior, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels,
10    Point, Position, Size, Style, Window, point, px,
11};
12
13use crate::Placement;
14
15/// Alignment of a popup along the side it is placed on.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub enum Align {
18    /// Align with the trigger's leading edge.
19    Start,
20    /// Center on the trigger.
21    #[default]
22    Center,
23    /// Align with the trigger's trailing edge.
24    End,
25}
26
27/// How a popup derives its position.
28#[derive(Clone, Copy, Debug, PartialEq)]
29enum Strategy {
30    /// Place `anchor`'s corner of the popup at `position`, then clamp into the
31    /// viewport. This reproduces GPUI's `anchored` corner behavior and does not
32    /// flip to the opposite side.
33    Corner {
34        anchor: Anchor,
35        position: Point<Pixels>,
36    },
37    /// Place the popup on `placement`'s side of the trigger, flipping to the
38    /// opposite side when it does not fit, then clamp into the viewport.
39    Side {
40        trigger_bounds: Bounds<Pixels>,
41        placement: Option<Placement>,
42        align: Align,
43        offset: Pixels,
44    },
45}
46
47/// The bounds a popup resolved to, and the side it ended up on.
48#[derive(Clone, Copy, Debug, PartialEq)]
49pub struct ResolvedPosition {
50    /// Final popup bounds in window coordinates.
51    pub bounds: Bounds<Pixels>,
52    /// The side the popup was placed on. `None` for corner positioning, which
53    /// has no notion of a side.
54    pub placement: Option<Placement>,
55}
56
57/// An unstyled element that positions its children as an anchored popup.
58///
59/// This owns measurement, side selection, alignment, and viewport clamping. It
60/// installs no presentation of its own and adds no wrapper element around its
61/// children.
62pub struct Positioner {
63    strategy: Strategy,
64    margin: Pixels,
65    occlude: bool,
66    children: Vec<AnyElement>,
67}
68
69#[doc(hidden)]
70pub struct PositionerState {
71    child_layout_ids: Vec<LayoutId>,
72}
73
74impl Positioner {
75    /// Places the popup on a side of `trigger_bounds`, flipping when needed.
76    pub fn side(trigger_bounds: Bounds<Pixels>) -> Self {
77        Self {
78            strategy: Strategy::Side {
79                trigger_bounds,
80                placement: None,
81                align: Align::Center,
82                offset: px(0.),
83            },
84            margin: px(4.),
85            occlude: false,
86            children: Vec::new(),
87        }
88    }
89
90    /// Places `anchor`'s corner of the popup at `position`.
91    ///
92    /// This is the corner-anchoring path used by triggers that were written
93    /// against GPUI's `anchored` element. It clamps into the viewport without
94    /// changing the requested anchor.
95    pub fn corner(anchor: Anchor, position: Point<Pixels>) -> Self {
96        Self {
97            strategy: Strategy::Corner { anchor, position },
98            margin: px(4.),
99            occlude: false,
100            children: Vec::new(),
101        }
102    }
103
104    /// Sets the preferred side. Only meaningful for [`Positioner::side`].
105    pub fn placement(mut self, placement: Placement) -> Self {
106        if let Strategy::Side {
107            placement: slot, ..
108        } = &mut self.strategy
109        {
110            *slot = Some(placement);
111        }
112        self
113    }
114
115    /// Sets the alignment along the chosen side. Only meaningful for
116    /// [`Positioner::side`].
117    pub fn align(mut self, align: Align) -> Self {
118        if let Strategy::Side { align: slot, .. } = &mut self.strategy {
119            *slot = align;
120        }
121        self
122    }
123
124    /// Sets the gap between the trigger and the popup. Only meaningful for
125    /// [`Positioner::side`].
126    pub fn offset(mut self, offset: Pixels) -> Self {
127        if let Strategy::Side { offset: slot, .. } = &mut self.strategy {
128            *slot = offset;
129        }
130        self
131    }
132
133    /// Blocks the mouse over the positioned popup.
134    ///
135    /// Off by default, because a tooltip that swallowed the pointer would
136    /// un-hover the very trigger keeping it open. An interactive surface — a
137    /// popover, a menu, a dropdown — wants it on: what the surface covers is
138    /// the surface's, not the panel underneath.
139    pub fn occlude(mut self) -> Self {
140        self.occlude = true;
141        self
142    }
143
144    /// Sets the minimum distance kept between the popup and the viewport edge.
145    pub fn margin(mut self, margin: Pixels) -> Self {
146        self.margin = margin;
147        self
148    }
149}
150
151/// The part of the window's viewport that is frame rather than content, per
152/// side.
153///
154/// A window drawn with client-side decorations pads its content by the client
155/// inset to make room for a shadow, except along an edge that is tiled against
156/// the screen, where the frame draws no shadow and the content runs to the
157/// viewport edge. `Window::client_inset` is the one value on every side (the
158/// platform needs it stable across tiling changes to size the window), so the
159/// tiling is what says where it actually applies. A server-decorated window
160/// has no frame of its own.
161fn frame_insets(decorations: Decorations, client_inset: Pixels) -> Edges<Pixels> {
162    match decorations {
163        Decorations::Server => Edges::default(),
164        Decorations::Client { tiling } => Edges {
165            top: if tiling.top { px(0.) } else { client_inset },
166            right: if tiling.right { px(0.) } else { client_inset },
167            bottom: if tiling.bottom { px(0.) } else { client_inset },
168            left: if tiling.left { px(0.) } else { client_inset },
169        },
170    }
171}
172
173/// Resolves the bounds of a popup of `popup_size`.
174///
175/// Side placement picks the preferred side when the popup fits, otherwise the
176/// opposite side, otherwise whichever side has more room. The result is always
177/// clamped into the viewport, keeping `margin` from each edge.
178fn resolve(
179    strategy: Strategy,
180    popup_size: Size<Pixels>,
181    viewport_size: Size<Pixels>,
182    margin: Edges<Pixels>,
183) -> ResolvedPosition {
184    match strategy {
185        Strategy::Corner { anchor, position } => ResolvedPosition {
186            bounds: clamp(
187                Bounds::from_anchor_and_size(anchor, position, popup_size),
188                viewport_size,
189                margin,
190            ),
191            placement: None,
192        },
193        Strategy::Side {
194            trigger_bounds,
195            placement,
196            align,
197            offset,
198        } => {
199            let placement =
200                resolve_placement(trigger_bounds, popup_size, viewport_size, margin, placement);
201            let origin = side_origin(trigger_bounds, popup_size, placement, align, offset);
202            ResolvedPosition {
203                bounds: clamp(Bounds::new(origin, popup_size), viewport_size, margin),
204                placement: Some(placement),
205            }
206        }
207    }
208}
209
210fn resolve_placement(
211    trigger_bounds: Bounds<Pixels>,
212    popup_size: Size<Pixels>,
213    viewport_size: Size<Pixels>,
214    margin: Edges<Pixels>,
215    preferred: Option<Placement>,
216) -> Placement {
217    let right_limit = (viewport_size.width - margin.right).max(margin.left);
218    let bottom_limit = (viewport_size.height - margin.bottom).max(margin.top);
219    let available_left = (trigger_bounds.left() - margin.left).max(px(0.));
220    let available_right = (right_limit - trigger_bounds.right()).max(px(0.));
221    let available_above = (trigger_bounds.top() - margin.top).max(px(0.));
222    let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.));
223
224    match preferred {
225        Some(Placement::Right) if popup_size.width <= available_right => Placement::Right,
226        Some(Placement::Right) if popup_size.width <= available_left => Placement::Left,
227        Some(Placement::Right) if available_right >= available_left => Placement::Right,
228        Some(Placement::Right) => Placement::Left,
229        Some(Placement::Left) if popup_size.width <= available_left => Placement::Left,
230        Some(Placement::Left) if popup_size.width <= available_right => Placement::Right,
231        Some(Placement::Left) if available_left >= available_right => Placement::Left,
232        Some(Placement::Left) => Placement::Right,
233        Some(Placement::Bottom) if popup_size.height <= available_below => Placement::Bottom,
234        Some(Placement::Bottom) if popup_size.height <= available_above => Placement::Top,
235        Some(Placement::Bottom) if available_below >= available_above => Placement::Bottom,
236        Some(Placement::Bottom) => Placement::Top,
237        Some(Placement::Top) | None if popup_size.height <= available_above => Placement::Top,
238        Some(Placement::Top) | None if popup_size.height <= available_below => Placement::Bottom,
239        Some(Placement::Top) | None if available_below >= available_above => Placement::Bottom,
240        Some(Placement::Top) | None => Placement::Top,
241    }
242}
243
244fn side_origin(
245    trigger_bounds: Bounds<Pixels>,
246    popup_size: Size<Pixels>,
247    placement: Placement,
248    align: Align,
249    offset: Pixels,
250) -> Point<Pixels> {
251    let aligned_x = match align {
252        Align::Start => trigger_bounds.left(),
253        Align::Center => trigger_bounds.center().x - popup_size.width.half(),
254        Align::End => trigger_bounds.right() - popup_size.width,
255    };
256    let aligned_y = match align {
257        Align::Start => trigger_bounds.top(),
258        Align::Center => trigger_bounds.center().y - popup_size.height.half(),
259        Align::End => trigger_bounds.bottom() - popup_size.height,
260    };
261
262    match placement {
263        Placement::Top => point(aligned_x, trigger_bounds.top() - popup_size.height - offset),
264        Placement::Bottom => point(aligned_x, trigger_bounds.bottom() + offset),
265        Placement::Left => point(trigger_bounds.left() - popup_size.width - offset, aligned_y),
266        Placement::Right => point(trigger_bounds.right() + offset, aligned_y),
267    }
268}
269
270fn clamp(
271    mut bounds: Bounds<Pixels>,
272    viewport_size: Size<Pixels>,
273    margin: Edges<Pixels>,
274) -> Bounds<Pixels> {
275    let right_limit = (viewport_size.width - margin.right).max(margin.left);
276    let bottom_limit = (viewport_size.height - margin.bottom).max(margin.top);
277
278    if bounds.right() > right_limit {
279        bounds.origin.x -= bounds.right() - right_limit;
280    }
281    if bounds.left() < margin.left {
282        bounds.origin.x = margin.left;
283    }
284    if bounds.bottom() > bottom_limit {
285        bounds.origin.y -= bounds.bottom() - bottom_limit;
286    }
287    if bounds.top() < margin.top {
288        bounds.origin.y = margin.top;
289    }
290
291    bounds
292}
293
294impl ParentElement for Positioner {
295    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
296        self.children.extend(elements);
297    }
298}
299
300impl Element for Positioner {
301    type RequestLayoutState = PositionerState;
302    type PrepaintState = ();
303
304    fn id(&self) -> Option<gpui::ElementId> {
305        None
306    }
307
308    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
309        None
310    }
311
312    fn request_layout(
313        &mut self,
314        _: Option<&GlobalElementId>,
315        _: Option<&InspectorElementId>,
316        window: &mut Window,
317        cx: &mut App,
318    ) -> (LayoutId, Self::RequestLayoutState) {
319        let child_layout_ids = self
320            .children
321            .iter_mut()
322            .map(|child| child.request_layout(window, cx))
323            .collect::<Vec<_>>();
324        let layout_id = window.request_layout(
325            Style {
326                position: Position::Absolute,
327                display: Display::Flex,
328                ..Style::default()
329            },
330            child_layout_ids.iter().copied(),
331            cx,
332        );
333
334        (layout_id, PositionerState { child_layout_ids })
335    }
336
337    fn prepaint(
338        &mut self,
339        _: Option<&GlobalElementId>,
340        _: Option<&InspectorElementId>,
341        bounds: Bounds<Pixels>,
342        request_layout: &mut Self::RequestLayoutState,
343        window: &mut Window,
344        cx: &mut App,
345    ) {
346        if request_layout.child_layout_ids.is_empty() {
347            return;
348        }
349
350        let mut child_min = point(Pixels::MAX, Pixels::MAX);
351        let mut child_max = Point::default();
352        for child_layout_id in &request_layout.child_layout_ids {
353            let child_bounds = window.layout_bounds(*child_layout_id);
354            child_min = child_min.min(&child_bounds.origin);
355            child_max = child_max.max(&child_bounds.bottom_right());
356        }
357
358        let popup_size = (child_max - child_min).into();
359        let frame = frame_insets(
360            window.window_decorations(),
361            window.client_inset().unwrap_or(px(0.)),
362        );
363        let position = resolve(
364            self.strategy,
365            popup_size,
366            window.viewport_size(),
367            frame.map(|inset| *inset + self.margin),
368        );
369        // Ahead of the children so it blocks what is behind the popup without
370        // blocking the popup's own content.
371        if self.occlude {
372            window.insert_hitbox(position.bounds, HitboxBehavior::BlockMouse);
373        }
374
375        let offset = position.bounds.origin - bounds.origin;
376        let offset = point(offset.x.round(), offset.y.round());
377
378        window.with_element_offset(offset, |window| {
379            for child in &mut self.children {
380                child.prepaint(window, cx);
381            }
382        });
383    }
384
385    fn paint(
386        &mut self,
387        _: Option<&GlobalElementId>,
388        _: Option<&InspectorElementId>,
389        _: Bounds<Pixels>,
390        _: &mut Self::RequestLayoutState,
391        _: &mut Self::PrepaintState,
392        window: &mut Window,
393        cx: &mut App,
394    ) {
395        for child in &mut self.children {
396            child.paint(window, cx);
397        }
398    }
399}
400
401impl IntoElement for Positioner {
402    type Element = Self;
403
404    fn into_element(self) -> Self::Element {
405        self
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use gpui::Tiling;
413
414    const MARGIN: Pixels = px(4.);
415
416    fn trigger(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
417        Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h)))
418    }
419
420    fn viewport() -> Size<Pixels> {
421        Size::new(px(500.), px(400.))
422    }
423
424    fn side(
425        trigger_bounds: Bounds<Pixels>,
426        placement: Option<Placement>,
427        align: Align,
428        popup: Size<Pixels>,
429    ) -> ResolvedPosition {
430        resolve(
431            Strategy::Side {
432                trigger_bounds,
433                placement,
434                align,
435                offset: px(0.),
436            },
437            popup,
438            viewport(),
439            Edges::all(MARGIN),
440        )
441    }
442
443    #[test]
444    fn prefers_the_requested_side_when_it_fits() {
445        let position = side(
446            trigger(200., 200., 40., 20.),
447            Some(Placement::Top),
448            Align::Center,
449            Size::new(px(80.), px(30.)),
450        );
451
452        assert_eq!(position.placement, Some(Placement::Top));
453        assert_eq!(position.bounds.bottom(), px(200.));
454    }
455
456    #[test]
457    fn flips_to_the_opposite_side_when_the_preferred_side_does_not_fit() {
458        let position = side(
459            trigger(200., 10., 40., 20.),
460            Some(Placement::Top),
461            Align::Center,
462            Size::new(px(80.), px(60.)),
463        );
464
465        assert_eq!(position.placement, Some(Placement::Bottom));
466        assert_eq!(position.bounds.top(), px(30.));
467    }
468
469    #[test]
470    fn clamps_into_the_viewport_while_keeping_the_flipped_side() {
471        let position = side(
472            trigger(480., 200., 40., 20.),
473            Some(Placement::Bottom),
474            Align::Center,
475            Size::new(px(120.), px(30.)),
476        );
477
478        assert_eq!(position.placement, Some(Placement::Bottom));
479        assert_eq!(position.bounds.right(), viewport().width - MARGIN);
480    }
481
482    #[test]
483    fn alignment_selects_the_leading_center_or_trailing_edge() {
484        let trigger_bounds = trigger(200., 200., 100., 20.);
485        let popup = Size::new(px(40.), px(30.));
486
487        let start = side(trigger_bounds, Some(Placement::Bottom), Align::Start, popup);
488        let center = side(
489            trigger_bounds,
490            Some(Placement::Bottom),
491            Align::Center,
492            popup,
493        );
494        let end = side(trigger_bounds, Some(Placement::Bottom), Align::End, popup);
495
496        assert_eq!(start.bounds.left(), px(200.));
497        assert_eq!(center.bounds.left(), px(230.));
498        assert_eq!(end.bounds.left(), px(260.));
499    }
500
501    #[test]
502    fn side_offset_adds_a_gap_between_trigger_and_popup() {
503        let position = resolve(
504            Strategy::Side {
505                trigger_bounds: trigger(200., 200., 40., 20.),
506                placement: Some(Placement::Bottom),
507                align: Align::Center,
508                offset: px(8.),
509            },
510            Size::new(px(40.), px(30.)),
511            viewport(),
512            Edges::all(MARGIN),
513        );
514
515        assert_eq!(position.bounds.top(), px(228.));
516    }
517
518    #[test]
519    fn corner_positioning_places_the_named_corner_and_never_reports_a_side() {
520        let position = resolve(
521            Strategy::Corner {
522                anchor: Anchor::TopLeft,
523                position: point(px(100.), px(100.)),
524            },
525            Size::new(px(40.), px(30.)),
526            viewport(),
527            Edges::all(MARGIN),
528        );
529
530        assert_eq!(position.placement, None);
531        assert_eq!(position.bounds.origin, point(px(100.), px(100.)));
532    }
533
534    #[test]
535    fn corner_positioning_clamps_but_does_not_flip() {
536        let position = resolve(
537            Strategy::Corner {
538                anchor: Anchor::TopLeft,
539                position: point(px(480.), px(390.)),
540            },
541            Size::new(px(40.), px(30.)),
542            viewport(),
543            Edges::all(MARGIN),
544        );
545
546        assert_eq!(position.placement, None);
547        assert_eq!(position.bounds.right(), viewport().width - MARGIN);
548        assert_eq!(position.bounds.bottom(), viewport().height - MARGIN);
549    }
550
551    /// A tiled edge draws no shadow, so a popup may run right up to the
552    /// margin there; an untiled edge keeps the whole client inset as well.
553    #[test]
554    fn frame_insets_apply_the_client_inset_only_on_untiled_edges() {
555        let inset = px(20.);
556
557        assert_eq!(
558            frame_insets(Decorations::Server, inset),
559            Edges::default(),
560            "a server-decorated window has no frame to keep clear of"
561        );
562        assert_eq!(
563            frame_insets(
564                Decorations::Client {
565                    tiling: Tiling::tiled()
566                },
567                inset
568            ),
569            Edges::default(),
570            "a window tiled on every side draws no shadow padding"
571        );
572        assert_eq!(
573            frame_insets(
574                Decorations::Client {
575                    tiling: Tiling {
576                        top: false,
577                        left: true,
578                        right: false,
579                        bottom: true,
580                    }
581                },
582                inset
583            ),
584            Edges {
585                top: inset,
586                right: inset,
587                bottom: px(0.),
588                left: px(0.),
589            }
590        );
591    }
592
593    /// The bug this guards: a menu opened from a trigger near the right edge
594    /// of a tiled window was pushed inward by the client inset although no
595    /// shadow was drawn there, so it no longer lined up with its trigger.
596    #[test]
597    fn clamping_keeps_only_the_margin_on_a_tiled_edge() {
598        let popup = Size::new(px(120.), px(30.));
599        // A menu aligned to a trigger that ends 10px short of the right edge.
600        let corner = Strategy::Corner {
601            anchor: Anchor::TopRight,
602            position: point(viewport().width - px(10.), px(100.)),
603        };
604        let margin = Edges::all(MARGIN);
605        let inset = px(20.);
606
607        let tiled = resolve(corner, popup, viewport(), margin);
608        assert_eq!(tiled.bounds.right(), viewport().width - px(10.));
609
610        let untiled = resolve(
611            corner,
612            popup,
613            viewport(),
614            margin.map(|margin| *margin + inset),
615        );
616        assert_eq!(untiled.bounds.right(), viewport().width - MARGIN - inset);
617    }
618
619    // Migrated from the tooltip module when its private positioning logic was
620    // merged into this one. They passed unchanged across that move, which is
621    // what proves the merge preserved tooltip placement behavior.
622    const WINDOW_MARGIN: Pixels = px(4.);
623
624    fn tooltip_placement(
625        trigger_bounds: Bounds<Pixels>,
626        popup: Size<Pixels>,
627        viewport: Size<Pixels>,
628        margin: Pixels,
629        placement: Option<Placement>,
630    ) -> (Bounds<Pixels>, Placement) {
631        let resolved = resolve(
632            Strategy::Side {
633                trigger_bounds,
634                placement,
635                align: Align::Center,
636                offset: px(0.),
637            },
638            popup,
639            viewport,
640            Edges::all(margin),
641        );
642        (resolved.bounds, resolved.placement.unwrap())
643    }
644
645    fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
646        Bounds::new(point(px(x), px(y)), Size::new(px(width), px(height)))
647    }
648
649    fn size_px(width: f32, height: f32) -> Size<Pixels> {
650        Size::new(px(width), px(height))
651    }
652
653    #[test]
654    fn prefers_above_when_space_allows() {
655        let trigger = bounds(100., 80., 80., 24.);
656        let position = tooltip_placement(
657            trigger,
658            size_px(120., 30.),
659            size_px(300., 200.),
660            WINDOW_MARGIN,
661            None,
662        );
663        assert_eq!(position.1, Placement::Top);
664        assert_eq!(position.0.origin, point(px(80.), px(50.)));
665    }
666    #[test]
667    fn flips_and_clamps_on_each_axis() {
668        let top = tooltip_placement(
669            bounds(24., 4., 120., 32.),
670            size_px(240., 32.),
671            size_px(520., 260.),
672            WINDOW_MARGIN,
673            None,
674        );
675        assert_eq!(top.1, Placement::Bottom);
676
677        let right = tooltip_placement(
678            bounds(260., 60., 32., 32.),
679            size_px(120., 30.),
680            size_px(300., 200.),
681            WINDOW_MARGIN,
682            Some(Placement::Right),
683        );
684        assert_eq!(right.1, Placement::Left);
685
686        let left_edge = tooltip_placement(
687            bounds(4., 80., 24., 24.),
688            size_px(120., 30.),
689            size_px(300., 200.),
690            WINDOW_MARGIN,
691            None,
692        );
693        assert_eq!(left_edge.0.left(), WINDOW_MARGIN);
694    }
695    #[test]
696    fn places_tooltip_to_the_right() {
697        let trigger = bounds(20., 60., 32., 32.);
698        let position = tooltip_placement(
699            trigger,
700            size_px(120., 30.),
701            size_px(300., 200.),
702            WINDOW_MARGIN,
703            Some(Placement::Right),
704        );
705        assert_eq!(position.1, Placement::Right);
706        assert_eq!(position.0.left(), trigger.right());
707        assert_eq!(position.0.center().y, trigger.center().y);
708    }
709    #[test]
710    fn right_placement_clamps_vertical_edges() {
711        let trigger = bounds(20., 2., 32., 20.);
712        let position = tooltip_placement(
713            trigger,
714            size_px(120., 40.),
715            size_px(300., 200.),
716            WINDOW_MARGIN,
717            Some(Placement::Right),
718        );
719        assert_eq!(position.1, Placement::Right);
720        assert_eq!(position.0.top(), WINDOW_MARGIN);
721        assert_eq!(position.0.left(), trigger.right());
722    }
723    #[test]
724    fn uses_larger_side_when_neither_vertical_side_fits() {
725        let position = tooltip_placement(
726            bounds(120., 20., 40., 20.),
727            size_px(160., 120.),
728            size_px(300., 100.),
729            WINDOW_MARGIN,
730            None,
731        );
732        assert_eq!(position.1, Placement::Bottom);
733        assert_eq!(position.0.top(), WINDOW_MARGIN);
734        assert_eq!(position.0.left(), px(60.));
735    }
736}