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, Display, Element, GlobalElementId, Half as _, HitboxBehavior,
9    InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style,
10    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/// Resolves the bounds of a popup of `popup_size`.
152///
153/// Side placement picks the preferred side when the popup fits, otherwise the
154/// opposite side, otherwise whichever side has more room. The result is always
155/// clamped into the viewport with `margin`.
156fn resolve(
157    strategy: Strategy,
158    popup_size: Size<Pixels>,
159    viewport_size: Size<Pixels>,
160    margin: Pixels,
161) -> ResolvedPosition {
162    match strategy {
163        Strategy::Corner { anchor, position } => ResolvedPosition {
164            bounds: clamp(
165                Bounds::from_anchor_and_size(anchor, position, popup_size),
166                viewport_size,
167                margin,
168            ),
169            placement: None,
170        },
171        Strategy::Side {
172            trigger_bounds,
173            placement,
174            align,
175            offset,
176        } => {
177            let placement =
178                resolve_placement(trigger_bounds, popup_size, viewport_size, margin, placement);
179            let origin = side_origin(trigger_bounds, popup_size, placement, align, offset);
180            ResolvedPosition {
181                bounds: clamp(Bounds::new(origin, popup_size), viewport_size, margin),
182                placement: Some(placement),
183            }
184        }
185    }
186}
187
188fn resolve_placement(
189    trigger_bounds: Bounds<Pixels>,
190    popup_size: Size<Pixels>,
191    viewport_size: Size<Pixels>,
192    margin: Pixels,
193    preferred: Option<Placement>,
194) -> Placement {
195    let right_limit = (viewport_size.width - margin).max(margin);
196    let bottom_limit = (viewport_size.height - margin).max(margin);
197    let available_left = (trigger_bounds.left() - margin).max(px(0.));
198    let available_right = (right_limit - trigger_bounds.right()).max(px(0.));
199    let available_above = (trigger_bounds.top() - margin).max(px(0.));
200    let available_below = (bottom_limit - trigger_bounds.bottom()).max(px(0.));
201
202    match preferred {
203        Some(Placement::Right) if popup_size.width <= available_right => Placement::Right,
204        Some(Placement::Right) if popup_size.width <= available_left => Placement::Left,
205        Some(Placement::Right) if available_right >= available_left => Placement::Right,
206        Some(Placement::Right) => Placement::Left,
207        Some(Placement::Left) if popup_size.width <= available_left => Placement::Left,
208        Some(Placement::Left) if popup_size.width <= available_right => Placement::Right,
209        Some(Placement::Left) if available_left >= available_right => Placement::Left,
210        Some(Placement::Left) => Placement::Right,
211        Some(Placement::Bottom) if popup_size.height <= available_below => Placement::Bottom,
212        Some(Placement::Bottom) if popup_size.height <= available_above => Placement::Top,
213        Some(Placement::Bottom) if available_below >= available_above => Placement::Bottom,
214        Some(Placement::Bottom) => Placement::Top,
215        Some(Placement::Top) | None if popup_size.height <= available_above => Placement::Top,
216        Some(Placement::Top) | None if popup_size.height <= available_below => Placement::Bottom,
217        Some(Placement::Top) | None if available_below >= available_above => Placement::Bottom,
218        Some(Placement::Top) | None => Placement::Top,
219    }
220}
221
222fn side_origin(
223    trigger_bounds: Bounds<Pixels>,
224    popup_size: Size<Pixels>,
225    placement: Placement,
226    align: Align,
227    offset: Pixels,
228) -> Point<Pixels> {
229    let aligned_x = match align {
230        Align::Start => trigger_bounds.left(),
231        Align::Center => trigger_bounds.center().x - popup_size.width.half(),
232        Align::End => trigger_bounds.right() - popup_size.width,
233    };
234    let aligned_y = match align {
235        Align::Start => trigger_bounds.top(),
236        Align::Center => trigger_bounds.center().y - popup_size.height.half(),
237        Align::End => trigger_bounds.bottom() - popup_size.height,
238    };
239
240    match placement {
241        Placement::Top => point(aligned_x, trigger_bounds.top() - popup_size.height - offset),
242        Placement::Bottom => point(aligned_x, trigger_bounds.bottom() + offset),
243        Placement::Left => point(trigger_bounds.left() - popup_size.width - offset, aligned_y),
244        Placement::Right => point(trigger_bounds.right() + offset, aligned_y),
245    }
246}
247
248fn clamp(
249    mut bounds: Bounds<Pixels>,
250    viewport_size: Size<Pixels>,
251    margin: Pixels,
252) -> Bounds<Pixels> {
253    let right_limit = (viewport_size.width - margin).max(margin);
254    let bottom_limit = (viewport_size.height - margin).max(margin);
255
256    if bounds.right() > right_limit {
257        bounds.origin.x -= bounds.right() - right_limit;
258    }
259    if bounds.left() < margin {
260        bounds.origin.x = margin;
261    }
262    if bounds.bottom() > bottom_limit {
263        bounds.origin.y -= bounds.bottom() - bottom_limit;
264    }
265    if bounds.top() < margin {
266        bounds.origin.y = margin;
267    }
268
269    bounds
270}
271
272impl ParentElement for Positioner {
273    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
274        self.children.extend(elements);
275    }
276}
277
278impl Element for Positioner {
279    type RequestLayoutState = PositionerState;
280    type PrepaintState = ();
281
282    fn id(&self) -> Option<gpui::ElementId> {
283        None
284    }
285
286    fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
287        None
288    }
289
290    fn request_layout(
291        &mut self,
292        _: Option<&GlobalElementId>,
293        _: Option<&InspectorElementId>,
294        window: &mut Window,
295        cx: &mut App,
296    ) -> (LayoutId, Self::RequestLayoutState) {
297        let child_layout_ids = self
298            .children
299            .iter_mut()
300            .map(|child| child.request_layout(window, cx))
301            .collect::<Vec<_>>();
302        let layout_id = window.request_layout(
303            Style {
304                position: Position::Absolute,
305                display: Display::Flex,
306                ..Style::default()
307            },
308            child_layout_ids.iter().copied(),
309            cx,
310        );
311
312        (layout_id, PositionerState { child_layout_ids })
313    }
314
315    fn prepaint(
316        &mut self,
317        _: Option<&GlobalElementId>,
318        _: Option<&InspectorElementId>,
319        bounds: Bounds<Pixels>,
320        request_layout: &mut Self::RequestLayoutState,
321        window: &mut Window,
322        cx: &mut App,
323    ) {
324        if request_layout.child_layout_ids.is_empty() {
325            return;
326        }
327
328        let mut child_min = point(Pixels::MAX, Pixels::MAX);
329        let mut child_max = Point::default();
330        for child_layout_id in &request_layout.child_layout_ids {
331            let child_bounds = window.layout_bounds(*child_layout_id);
332            child_min = child_min.min(&child_bounds.origin);
333            child_max = child_max.max(&child_bounds.bottom_right());
334        }
335
336        let popup_size = (child_max - child_min).into();
337        let client_inset = window.client_inset().unwrap_or(px(0.));
338        let position = resolve(
339            self.strategy,
340            popup_size,
341            window.viewport_size(),
342            self.margin + client_inset,
343        );
344        // Ahead of the children so it blocks what is behind the popup without
345        // blocking the popup's own content.
346        if self.occlude {
347            window.insert_hitbox(position.bounds, HitboxBehavior::BlockMouse);
348        }
349
350        let offset = position.bounds.origin - bounds.origin;
351        let offset = point(offset.x.round(), offset.y.round());
352
353        window.with_element_offset(offset, |window| {
354            for child in &mut self.children {
355                child.prepaint(window, cx);
356            }
357        });
358    }
359
360    fn paint(
361        &mut self,
362        _: Option<&GlobalElementId>,
363        _: Option<&InspectorElementId>,
364        _: Bounds<Pixels>,
365        _: &mut Self::RequestLayoutState,
366        _: &mut Self::PrepaintState,
367        window: &mut Window,
368        cx: &mut App,
369    ) {
370        for child in &mut self.children {
371            child.paint(window, cx);
372        }
373    }
374}
375
376impl IntoElement for Positioner {
377    type Element = Self;
378
379    fn into_element(self) -> Self::Element {
380        self
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    const MARGIN: Pixels = px(4.);
389
390    fn trigger(x: f32, y: f32, w: f32, h: f32) -> Bounds<Pixels> {
391        Bounds::new(point(px(x), px(y)), Size::new(px(w), px(h)))
392    }
393
394    fn viewport() -> Size<Pixels> {
395        Size::new(px(500.), px(400.))
396    }
397
398    fn side(
399        trigger_bounds: Bounds<Pixels>,
400        placement: Option<Placement>,
401        align: Align,
402        popup: Size<Pixels>,
403    ) -> ResolvedPosition {
404        resolve(
405            Strategy::Side {
406                trigger_bounds,
407                placement,
408                align,
409                offset: px(0.),
410            },
411            popup,
412            viewport(),
413            MARGIN,
414        )
415    }
416
417    #[test]
418    fn prefers_the_requested_side_when_it_fits() {
419        let position = side(
420            trigger(200., 200., 40., 20.),
421            Some(Placement::Top),
422            Align::Center,
423            Size::new(px(80.), px(30.)),
424        );
425
426        assert_eq!(position.placement, Some(Placement::Top));
427        assert_eq!(position.bounds.bottom(), px(200.));
428    }
429
430    #[test]
431    fn flips_to_the_opposite_side_when_the_preferred_side_does_not_fit() {
432        let position = side(
433            trigger(200., 10., 40., 20.),
434            Some(Placement::Top),
435            Align::Center,
436            Size::new(px(80.), px(60.)),
437        );
438
439        assert_eq!(position.placement, Some(Placement::Bottom));
440        assert_eq!(position.bounds.top(), px(30.));
441    }
442
443    #[test]
444    fn clamps_into_the_viewport_while_keeping_the_flipped_side() {
445        let position = side(
446            trigger(480., 200., 40., 20.),
447            Some(Placement::Bottom),
448            Align::Center,
449            Size::new(px(120.), px(30.)),
450        );
451
452        assert_eq!(position.placement, Some(Placement::Bottom));
453        assert_eq!(position.bounds.right(), viewport().width - MARGIN);
454    }
455
456    #[test]
457    fn alignment_selects_the_leading_center_or_trailing_edge() {
458        let trigger_bounds = trigger(200., 200., 100., 20.);
459        let popup = Size::new(px(40.), px(30.));
460
461        let start = side(trigger_bounds, Some(Placement::Bottom), Align::Start, popup);
462        let center = side(
463            trigger_bounds,
464            Some(Placement::Bottom),
465            Align::Center,
466            popup,
467        );
468        let end = side(trigger_bounds, Some(Placement::Bottom), Align::End, popup);
469
470        assert_eq!(start.bounds.left(), px(200.));
471        assert_eq!(center.bounds.left(), px(230.));
472        assert_eq!(end.bounds.left(), px(260.));
473    }
474
475    #[test]
476    fn side_offset_adds_a_gap_between_trigger_and_popup() {
477        let position = resolve(
478            Strategy::Side {
479                trigger_bounds: trigger(200., 200., 40., 20.),
480                placement: Some(Placement::Bottom),
481                align: Align::Center,
482                offset: px(8.),
483            },
484            Size::new(px(40.), px(30.)),
485            viewport(),
486            MARGIN,
487        );
488
489        assert_eq!(position.bounds.top(), px(228.));
490    }
491
492    #[test]
493    fn corner_positioning_places_the_named_corner_and_never_reports_a_side() {
494        let position = resolve(
495            Strategy::Corner {
496                anchor: Anchor::TopLeft,
497                position: point(px(100.), px(100.)),
498            },
499            Size::new(px(40.), px(30.)),
500            viewport(),
501            MARGIN,
502        );
503
504        assert_eq!(position.placement, None);
505        assert_eq!(position.bounds.origin, point(px(100.), px(100.)));
506    }
507
508    #[test]
509    fn corner_positioning_clamps_but_does_not_flip() {
510        let position = resolve(
511            Strategy::Corner {
512                anchor: Anchor::TopLeft,
513                position: point(px(480.), px(390.)),
514            },
515            Size::new(px(40.), px(30.)),
516            viewport(),
517            MARGIN,
518        );
519
520        assert_eq!(position.placement, None);
521        assert_eq!(position.bounds.right(), viewport().width - MARGIN);
522        assert_eq!(position.bounds.bottom(), viewport().height - MARGIN);
523    }
524
525    // Migrated from the tooltip module when its private positioning logic was
526    // merged into this one. They passed unchanged across that move, which is
527    // what proves the merge preserved tooltip placement behavior.
528    const WINDOW_MARGIN: Pixels = px(4.);
529
530    fn tooltip_placement(
531        trigger_bounds: Bounds<Pixels>,
532        popup: Size<Pixels>,
533        viewport: Size<Pixels>,
534        margin: Pixels,
535        placement: Option<Placement>,
536    ) -> (Bounds<Pixels>, Placement) {
537        let resolved = resolve(
538            Strategy::Side {
539                trigger_bounds,
540                placement,
541                align: Align::Center,
542                offset: px(0.),
543            },
544            popup,
545            viewport,
546            margin,
547        );
548        (resolved.bounds, resolved.placement.unwrap())
549    }
550
551    fn bounds(x: f32, y: f32, width: f32, height: f32) -> Bounds<Pixels> {
552        Bounds::new(point(px(x), px(y)), Size::new(px(width), px(height)))
553    }
554
555    fn size_px(width: f32, height: f32) -> Size<Pixels> {
556        Size::new(px(width), px(height))
557    }
558
559    #[test]
560    fn prefers_above_when_space_allows() {
561        let trigger = bounds(100., 80., 80., 24.);
562        let position = tooltip_placement(
563            trigger,
564            size_px(120., 30.),
565            size_px(300., 200.),
566            WINDOW_MARGIN,
567            None,
568        );
569        assert_eq!(position.1, Placement::Top);
570        assert_eq!(position.0.origin, point(px(80.), px(50.)));
571    }
572    #[test]
573    fn flips_and_clamps_on_each_axis() {
574        let top = tooltip_placement(
575            bounds(24., 4., 120., 32.),
576            size_px(240., 32.),
577            size_px(520., 260.),
578            WINDOW_MARGIN,
579            None,
580        );
581        assert_eq!(top.1, Placement::Bottom);
582
583        let right = tooltip_placement(
584            bounds(260., 60., 32., 32.),
585            size_px(120., 30.),
586            size_px(300., 200.),
587            WINDOW_MARGIN,
588            Some(Placement::Right),
589        );
590        assert_eq!(right.1, Placement::Left);
591
592        let left_edge = tooltip_placement(
593            bounds(4., 80., 24., 24.),
594            size_px(120., 30.),
595            size_px(300., 200.),
596            WINDOW_MARGIN,
597            None,
598        );
599        assert_eq!(left_edge.0.left(), WINDOW_MARGIN);
600    }
601    #[test]
602    fn places_tooltip_to_the_right() {
603        let trigger = bounds(20., 60., 32., 32.);
604        let position = tooltip_placement(
605            trigger,
606            size_px(120., 30.),
607            size_px(300., 200.),
608            WINDOW_MARGIN,
609            Some(Placement::Right),
610        );
611        assert_eq!(position.1, Placement::Right);
612        assert_eq!(position.0.left(), trigger.right());
613        assert_eq!(position.0.center().y, trigger.center().y);
614    }
615    #[test]
616    fn right_placement_clamps_vertical_edges() {
617        let trigger = bounds(20., 2., 32., 20.);
618        let position = tooltip_placement(
619            trigger,
620            size_px(120., 40.),
621            size_px(300., 200.),
622            WINDOW_MARGIN,
623            Some(Placement::Right),
624        );
625        assert_eq!(position.1, Placement::Right);
626        assert_eq!(position.0.top(), WINDOW_MARGIN);
627        assert_eq!(position.0.left(), trigger.right());
628    }
629    #[test]
630    fn uses_larger_side_when_neither_vertical_side_fits() {
631        let position = tooltip_placement(
632            bounds(120., 20., 40., 20.),
633            size_px(160., 120.),
634            size_px(300., 100.),
635            WINDOW_MARGIN,
636            None,
637        );
638        assert_eq!(position.1, Placement::Bottom);
639        assert_eq!(position.0.top(), WINDOW_MARGIN);
640        assert_eq!(position.0.left(), px(60.));
641    }
642}