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