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