Skip to main content

fret_ui_kit/primitives/
popper.rs

1//! Popper / floating placement helpers (Radix `@radix-ui/react-popper` outcomes).
2//!
3//! This primitive is a thin, stable wrapper around `fret-ui`'s deterministic placement solver
4//! (`fret_ui::overlay_placement`). It is intentionally pure and testable.
5
6use fret_core::{Edges, Point, Px, Rect, Size};
7use fret_ui::overlay_placement::{
8    AnchoredPanelLayout, AnchoredPanelLayoutTrace, AnchoredPanelOptions, CollisionOptions,
9    anchored_panel_layout, anchored_panel_layout_sized, anchored_panel_layout_sized_with_trace,
10    anchored_panel_layout_with_trace, inset_rect, intersect_rect,
11};
12
13pub use fret_ui::overlay_placement::{
14    Align, ArrowLayout, ArrowOptions, LayoutDirection, Offset, ShiftOptions, Side, StickyMode,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct PopperAvailableMetrics {
19    pub available_width: Px,
20    pub available_height: Px,
21    pub anchor_width: Px,
22    pub anchor_height: Px,
23}
24
25/// Build `AnchoredPanelOptions` for popper-like floating content.
26///
27/// Radix `PopperContent` effectively adds an extra main-axis offset when an arrow is present
28/// (the arrow protrudes outside the panel rect), and supports a cross-axis alignment offset.
29///
30/// In Fret we keep `side_offset` (gap between anchor and panel) separate from the arrow
31/// protrusion, so callers pass `arrow_protrusion` here and keep `side_offset` for the solver.
32pub fn anchored_panel_options_for_popper_content(
33    direction: LayoutDirection,
34    arrow_protrusion: Px,
35    align_offset: Px,
36    arrow: Option<ArrowOptions>,
37) -> AnchoredPanelOptions {
38    AnchoredPanelOptions {
39        direction,
40        offset: Offset {
41            main_axis: arrow_protrusion,
42            cross_axis: align_offset,
43            // Radix maps `alignOffset` to Floating UI's `alignmentAxis` offset, which flips sign
44            // for `*-end` placements (and flips under RTL for vertical placements).
45            alignment_axis: Some(align_offset),
46        },
47        // Radix uses Floating UI `shift({ crossAxis: false })` by default for popper content.
48        shift: ShiftOptions {
49            main_axis: true,
50            cross_axis: false,
51        },
52        arrow,
53        collision: Default::default(),
54        sticky: Default::default(),
55    }
56}
57
58/// Placement policy for Radix-like `PopperContent`.
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub struct PopperContentPlacement {
61    pub direction: LayoutDirection,
62    pub side: Side,
63    pub align: Align,
64    pub side_offset: Px,
65    pub align_offset: Px,
66    pub arrow: Option<ArrowOptions>,
67    pub arrow_protrusion: Px,
68    /// Whether to enable cross-axis shifting while resolving collisions.
69    ///
70    /// Radix typically uses Floating UI's `shift({ crossAxis: false })` by default, but some
71    /// primitives opt into cross-axis shifting for better clamping behavior.
72    pub shift_cross_axis: bool,
73    pub collision_padding: Edges,
74    pub collision_boundary: Option<Rect>,
75    pub hide_when_detached: bool,
76    pub sticky: StickyMode,
77}
78
79impl PopperContentPlacement {
80    pub fn new(direction: LayoutDirection, side: Side, align: Align, side_offset: Px) -> Self {
81        Self {
82            direction,
83            side,
84            align,
85            side_offset,
86            align_offset: Px(0.0),
87            arrow: None,
88            arrow_protrusion: Px(0.0),
89            shift_cross_axis: false,
90            collision_padding: Edges::all(Px(0.0)),
91            collision_boundary: None,
92            hide_when_detached: false,
93            sticky: StickyMode::Partial,
94        }
95    }
96
97    pub fn with_align_offset(mut self, align_offset: Px) -> Self {
98        self.align_offset = align_offset;
99        self
100    }
101
102    pub fn with_arrow(mut self, arrow: Option<ArrowOptions>, arrow_protrusion: Px) -> Self {
103        self.arrow = arrow;
104        self.arrow_protrusion = arrow_protrusion;
105        self
106    }
107
108    pub fn with_shift_cross_axis(mut self, cross_axis: bool) -> Self {
109        self.shift_cross_axis = cross_axis;
110        self
111    }
112
113    pub fn with_collision_padding(mut self, collision_padding: Edges) -> Self {
114        self.collision_padding = collision_padding;
115        self
116    }
117
118    pub fn with_collision_boundary(mut self, collision_boundary: Option<Rect>) -> Self {
119        self.collision_boundary = collision_boundary;
120        self
121    }
122
123    pub fn with_hide_when_detached(mut self, hide_when_detached: bool) -> Self {
124        self.hide_when_detached = hide_when_detached;
125        self
126    }
127
128    pub fn with_sticky(mut self, sticky: StickyMode) -> Self {
129        self.sticky = sticky;
130        self
131    }
132
133    /// Returns `true` when the anchor is fully clipped by the effective collision boundary.
134    ///
135    /// This approximates Floating UI's `hide({ strategy: 'referenceHidden' })` middleware as used by
136    /// Radix (`hideWhenDetached`).
137    pub fn reference_hidden(self, outer: Rect, anchor: Rect) -> bool {
138        if !self.hide_when_detached {
139            return false;
140        }
141
142        let mut boundary = outer;
143        if let Some(extra_boundary) = self.collision_boundary {
144            boundary = fret_ui::overlay_placement::intersect_rect(boundary, extra_boundary);
145        }
146        boundary = fret_ui::overlay_placement::inset_rect(boundary, self.collision_padding);
147
148        let intersection = fret_ui::overlay_placement::intersect_rect(boundary, anchor);
149        intersection.size.width.0 <= 0.0 || intersection.size.height.0 <= 0.0
150    }
151
152    pub fn options(self) -> AnchoredPanelOptions {
153        let mut options = anchored_panel_options_for_popper_content(
154            self.direction,
155            self.arrow_protrusion,
156            self.align_offset,
157            self.arrow,
158        );
159        options.shift.cross_axis = self.shift_cross_axis;
160        options.collision = CollisionOptions {
161            padding: self.collision_padding,
162            boundary: self.collision_boundary,
163        };
164        options.sticky = self.sticky;
165        options
166    }
167}
168
169/// Computes a Radix-style `PopperContent` layout from a placement policy.
170pub fn popper_content_layout_sized(
171    outer: Rect,
172    anchor: Rect,
173    desired: Size,
174    placement: PopperContentPlacement,
175) -> AnchoredPanelLayout {
176    popper_layout_sized(
177        outer,
178        anchor,
179        desired,
180        placement.side_offset,
181        placement.side,
182        placement.align,
183        placement.options(),
184    )
185}
186
187/// Computes a Radix-style `PopperContent` layout without clamping the panel `Size` to available
188/// space.
189///
190/// This matches primitives where the floating rect can overflow the collision boundary on the
191/// main axis while preserving its intrinsic size, but still wants collision shifting/clamping to
192/// run for the final origin.
193pub fn popper_content_layout_size_unclamped(
194    outer: Rect,
195    anchor: Rect,
196    desired: Size,
197    placement: PopperContentPlacement,
198) -> AnchoredPanelLayout {
199    anchored_panel_layout(
200        outer,
201        anchor,
202        desired,
203        placement.side_offset,
204        placement.side,
205        placement.align,
206        placement.options(),
207    )
208}
209
210/// Like [`popper_content_layout_size_unclamped`], but returns a debug trace describing solver decisions.
211pub fn popper_content_layout_size_unclamped_with_trace(
212    outer: Rect,
213    anchor: Rect,
214    desired: Size,
215    placement: PopperContentPlacement,
216) -> (AnchoredPanelLayout, AnchoredPanelLayoutTrace) {
217    anchored_panel_layout_with_trace(
218        outer,
219        anchor,
220        desired,
221        placement.side_offset,
222        placement.side,
223        placement.align,
224        placement.options(),
225    )
226}
227
228/// Computes a Radix-style `PopperContent` layout without clamping the panel size to available space.
229///
230/// This matches Radix/Floating UI behavior for primitives that allow the floating rect to overflow
231/// the viewport while preserving its intrinsic size (e.g. NavigationMenu viewport in mobile mode).
232pub fn popper_content_layout_unclamped(
233    outer: Rect,
234    anchor: Rect,
235    desired: Size,
236    placement: PopperContentPlacement,
237) -> AnchoredPanelLayout {
238    let mut options = placement.options();
239    // Allow the panel to overflow the collision boundary on the main axis (no shift/clamp).
240    options.shift.main_axis = false;
241    anchored_panel_layout(
242        outer,
243        anchor,
244        desired,
245        placement.side_offset,
246        placement.side,
247        placement.align,
248        options,
249    )
250}
251
252/// Like [`popper_content_layout_unclamped`], but returns a debug trace describing solver decisions.
253pub fn popper_content_layout_unclamped_with_trace(
254    outer: Rect,
255    anchor: Rect,
256    desired: Size,
257    placement: PopperContentPlacement,
258) -> (AnchoredPanelLayout, AnchoredPanelLayoutTrace) {
259    let mut options = placement.options();
260    options.shift.main_axis = false;
261    anchored_panel_layout_with_trace(
262        outer,
263        anchor,
264        desired,
265        placement.side_offset,
266        placement.side,
267        placement.align,
268        options,
269    )
270}
271
272/// Compute Radix-like "available metrics" exposed by Floating UI's `size()` middleware.
273///
274/// Radix writes these to CSS variables:
275/// - `--radix-popper-available-width`
276/// - `--radix-popper-available-height`
277/// - `--radix-popper-anchor-width`
278/// - `--radix-popper-anchor-height`
279///
280/// Fret exposes the same concepts as a structured return value.
281pub fn popper_available_metrics(
282    outer: Rect,
283    anchor: Rect,
284    layout: &AnchoredPanelLayout,
285    direction: LayoutDirection,
286) -> PopperAvailableMetrics {
287    let rect = layout.rect;
288    let width = rect.size.width.0.max(0.0);
289    let height = rect.size.height.0.max(0.0);
290
291    let outer_left = outer.origin.x.0;
292    let outer_top = outer.origin.y.0;
293    let outer_right = outer_left + outer.size.width.0.max(0.0);
294    let outer_bottom = outer_top + outer.size.height.0.max(0.0);
295
296    let rect_left = rect.origin.x.0;
297    let rect_top = rect.origin.y.0;
298    let rect_right = rect_left + rect.size.width.0.max(0.0);
299    let rect_bottom = rect_top + rect.size.height.0.max(0.0);
300
301    // Signed overflow values:
302    // - positive: overflows the boundary
303    // - negative: remaining space within the boundary
304    let overflow_left = outer_left - rect_left;
305    let overflow_top = outer_top - rect_top;
306    let overflow_right = rect_right - outer_right;
307    let overflow_bottom = rect_bottom - outer_bottom;
308
309    let maximum_clipping_width = (width - overflow_left - overflow_right).max(0.0);
310    let maximum_clipping_height = (height - overflow_top - overflow_bottom).max(0.0);
311
312    let alignment = match layout.align {
313        Align::Center => None,
314        other => Some(other),
315    };
316
317    let side = layout.side;
318
319    let (height_side, width_side) = match side {
320        Side::Top | Side::Bottom => {
321            let height_side = side;
322            let width_side = match alignment {
323                Some(Align::Start) => {
324                    if direction == LayoutDirection::Rtl {
325                        Side::Right
326                    } else {
327                        Side::Left
328                    }
329                }
330                Some(Align::End) => {
331                    if direction == LayoutDirection::Rtl {
332                        Side::Left
333                    } else {
334                        Side::Right
335                    }
336                }
337                _ => Side::Right,
338            };
339            (height_side, width_side)
340        }
341        Side::Left | Side::Right => {
342            let width_side = side;
343            let height_side = match alignment {
344                Some(Align::End) => Side::Top,
345                _ => Side::Bottom,
346            };
347            (height_side, width_side)
348        }
349    };
350
351    let overflow_for_side = |side: Side| match side {
352        Side::Top => overflow_top,
353        Side::Bottom => overflow_bottom,
354        Side::Left => overflow_left,
355        Side::Right => overflow_right,
356    };
357
358    let overflow_available_height = (height - overflow_for_side(height_side))
359        .min(maximum_clipping_height)
360        .max(0.0);
361    let overflow_available_width = (width - overflow_for_side(width_side))
362        .min(maximum_clipping_width)
363        .max(0.0);
364
365    // Radix shift config:
366    // - `mainAxis: true`
367    // - `crossAxis: false`
368    // For Top/Bottom, this enables shifting along X. For Left/Right, along Y.
369    let shift_enabled_x = matches!(side, Side::Top | Side::Bottom);
370    let shift_enabled_y = matches!(side, Side::Left | Side::Right);
371
372    let mut available_height = overflow_available_height;
373    let mut available_width = overflow_available_width;
374
375    if shift_enabled_x {
376        available_width = maximum_clipping_width;
377    }
378    if shift_enabled_y {
379        available_height = maximum_clipping_height;
380    }
381
382    PopperAvailableMetrics {
383        available_width: Px(available_width),
384        available_height: Px(available_height),
385        anchor_width: anchor.size.width,
386        anchor_height: anchor.size.height,
387    }
388}
389
390pub fn popper_desired_width(outer: Rect, anchor: Rect, min_width: Px) -> Px {
391    Px(anchor.size.width.0.max(min_width.0).min(outer.size.width.0))
392}
393
394/// Compute Radix-like `size()` middleware metrics for a `PopperContentPlacement`.
395///
396/// Radix uses Floating UI's `size()` middleware to expose:
397/// - `--radix-popper-available-width`
398/// - `--radix-popper-available-height`
399/// - `--radix-popper-anchor-width`
400/// - `--radix-popper-anchor-height`
401///
402/// In Fret, we expose the same concepts as `PopperAvailableMetrics`. This helper computes them
403/// without requiring callers to duplicate the "probe layout" step.
404pub fn popper_available_metrics_for_placement(
405    outer: Rect,
406    anchor: Rect,
407    min_width: Px,
408    placement: PopperContentPlacement,
409) -> PopperAvailableMetrics {
410    let desired_w = popper_desired_width(outer, anchor, min_width);
411    let probe_desired = Size::new(desired_w, outer.size.height);
412    let layout = popper_content_layout_sized(outer, anchor, probe_desired, placement);
413    // `size()` middleware metrics are computed against the *collision boundary*, i.e. after Radix
414    // applies collision padding + boundary overrides. Match that by using the effective boundary
415    // here (the placement solver already applies the same collision options when producing
416    // `layout`).
417    let mut boundary = outer;
418    if let Some(extra_boundary) = placement.collision_boundary {
419        boundary = intersect_rect(boundary, extra_boundary);
420    }
421    boundary = inset_rect(boundary, placement.collision_padding);
422    popper_available_metrics(boundary, anchor, &layout, placement.direction)
423}
424
425/// Computes an anchored popper layout (rect + optional arrow) with deterministic flip/clamp rules.
426pub fn popper_layout_sized(
427    outer: Rect,
428    anchor: Rect,
429    desired: Size,
430    side_offset: Px,
431    side: Side,
432    align: Align,
433    options: AnchoredPanelOptions,
434) -> AnchoredPanelLayout {
435    anchored_panel_layout_sized(outer, anchor, desired, side_offset, side, align, options)
436}
437
438/// Like [`popper_layout_sized`], but returns a debug trace describing solver decisions.
439pub fn popper_layout_sized_with_trace(
440    outer: Rect,
441    anchor: Rect,
442    desired: Size,
443    side_offset: Px,
444    side: Side,
445    align: Align,
446    options: AnchoredPanelOptions,
447) -> (AnchoredPanelLayout, AnchoredPanelLayoutTrace) {
448    anchored_panel_layout_sized_with_trace(
449        outer,
450        anchor,
451        desired,
452        side_offset,
453        side,
454        align,
455        options,
456    )
457}
458
459fn opposite_side(side: Side) -> Side {
460    match side {
461        Side::Top => Side::Bottom,
462        Side::Bottom => Side::Top,
463        Side::Left => Side::Right,
464        Side::Right => Side::Left,
465    }
466}
467
468/// Computes a Radix-style transform origin for popper content animations.
469///
470/// Radix exposes this via a CSS variable (e.g. `--radix-tooltip-content-transform-origin`). We
471/// approximate the same concept in a pure, geometry-driven way so component wrappers can scale
472/// and/or slide from the edge that faces the anchor.
473///
474/// Returns a point in the same coordinate space as `layout.rect` (i.e. window/overlay coordinates).
475pub fn popper_content_transform_origin(
476    layout: &AnchoredPanelLayout,
477    anchor: Rect,
478    arrow_size: Option<Px>,
479) -> Point {
480    let rect = layout.rect;
481    let anchor_center = Point::new(
482        Px(anchor.origin.x.0 + anchor.size.width.0 * 0.5),
483        Px(anchor.origin.y.0 + anchor.size.height.0 * 0.5),
484    );
485
486    let face = layout
487        .arrow
488        .map(|a| a.side)
489        .unwrap_or_else(|| opposite_side(layout.side));
490
491    let arrow_hidden = should_hide_arrow(layout);
492
493    let (mut x, mut y) = match face {
494        Side::Top => (Px(rect.size.width.0 * 0.5), Px(0.0)),
495        Side::Bottom => (Px(rect.size.width.0 * 0.5), rect.size.height),
496        Side::Left => (Px(0.0), Px(rect.size.height.0 * 0.5)),
497        Side::Right => (rect.size.width, Px(rect.size.height.0 * 0.5)),
498    };
499
500    if let (Some(arrow), Some(arrow_size)) = (layout.arrow, arrow_size) {
501        if !arrow_hidden {
502            let cross_x = Px((arrow.offset.0 + arrow_size.0 * 0.5).clamp(0.0, rect.size.width.0));
503            let cross_y = Px((arrow.offset.0 + arrow_size.0 * 0.5).clamp(0.0, rect.size.height.0));
504            match face {
505                Side::Top | Side::Bottom => x = cross_x,
506                Side::Left | Side::Right => y = cross_y,
507            }
508        } else {
509            // Radix hides the arrow when it can't be centered. When that happens, their
510            // transform-origin math uses the placed alignment (`0%/50%/100%`) instead of the arrow
511            // geometry.
512            let align_x = match layout.align {
513                Align::Start => Px(0.0),
514                Align::Center => Px(rect.size.width.0 * 0.5),
515                Align::End => rect.size.width,
516            };
517            let align_y = match layout.align {
518                Align::Start => Px(0.0),
519                Align::Center => Px(rect.size.height.0 * 0.5),
520                Align::End => rect.size.height,
521            };
522            match face {
523                Side::Top | Side::Bottom => x = align_x,
524                Side::Left | Side::Right => y = align_y,
525            }
526        }
527    } else {
528        match face {
529            Side::Top | Side::Bottom => {
530                x = Px((anchor_center.x.0 - rect.origin.x.0).clamp(0.0, rect.size.width.0));
531            }
532            Side::Left | Side::Right => {
533                y = Px((anchor_center.y.0 - rect.origin.y.0).clamp(0.0, rect.size.height.0));
534            }
535        }
536    }
537
538    Point::new(Px(rect.origin.x.0 + x.0), Px(rect.origin.y.0 + y.0))
539}
540
541pub fn should_hide_arrow(layout: &AnchoredPanelLayout) -> bool {
542    layout
543        .arrow
544        .is_some_and(|arrow| arrow.center_offset.0.abs() > 0.01)
545}
546
547/// Default arrow protrusion used by shadcn/Radix-style diamonds.
548///
549/// A rotated square of side `s` has a half-diagonal of `s * sqrt(2) / 2 ≈ s * 0.707`.
550/// We intentionally bias slightly higher for the common "diamond + border" look.
551pub fn default_arrow_protrusion(arrow_size: Px) -> Px {
552    Px(arrow_size.0 * 0.75)
553}
554
555/// Build Radix-style "diamond arrow" placement options.
556///
557/// Returns `(arrow_options, arrow_protrusion)`.
558pub fn diamond_arrow_options(
559    enabled: bool,
560    arrow_size: Px,
561    arrow_padding: Px,
562) -> (Option<ArrowOptions>, Px) {
563    if !enabled {
564        return (None, Px(0.0));
565    }
566
567    (
568        Some(ArrowOptions {
569            size: Size::new(arrow_size, arrow_size),
570            padding: Edges::all(arrow_padding),
571        }),
572        default_arrow_protrusion(arrow_size),
573    )
574}
575
576/// Returns wrapper insets that keep the arrow hit-testable by expanding the overlay container.
577///
578/// This is useful when the overlay system uses the overlay root bounds for hit-testing
579/// (outside-press / hover regions), and the arrow visually protrudes outside the panel rect.
580pub fn wrapper_insets_for_arrow(layout: &AnchoredPanelLayout, protrusion: Px) -> Edges {
581    if should_hide_arrow(layout) {
582        return Edges::all(Px(0.0));
583    }
584
585    let Some(arrow) = layout.arrow else {
586        return Edges::all(Px(0.0));
587    };
588
589    match arrow.side {
590        Side::Top => Edges {
591            top: protrusion,
592            ..Edges::all(Px(0.0))
593        },
594        Side::Bottom => Edges {
595            bottom: protrusion,
596            ..Edges::all(Px(0.0))
597        },
598        Side::Left => Edges {
599            left: protrusion,
600            ..Edges::all(Px(0.0))
601        },
602        Side::Right => Edges {
603            right: protrusion,
604            ..Edges::all(Px(0.0))
605        },
606    }
607}
608
609#[cfg(test)]
610mod tests {
611    use fret_core::{Point, Rect, Size};
612
613    use super::*;
614
615    #[test]
616    fn wrapper_insets_are_zero_without_arrow() {
617        let layout = AnchoredPanelLayout {
618            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0))),
619            side: Side::Bottom,
620            align: Align::Center,
621            arrow: None,
622        };
623        assert_eq!(
624            wrapper_insets_for_arrow(&layout, Px(9.0)),
625            Edges::all(Px(0.0))
626        );
627    }
628
629    #[test]
630    fn wrapper_insets_follow_arrow_side() {
631        let mut layout = AnchoredPanelLayout {
632            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0))),
633            side: Side::Bottom,
634            align: Align::Center,
635            arrow: Some(ArrowLayout {
636                side: Side::Top,
637                offset: Px(1.0),
638                alignment_offset: Px(0.0),
639                center_offset: Px(0.0),
640            }),
641        };
642
643        assert_eq!(wrapper_insets_for_arrow(&layout, Px(7.0)).top, Px(7.0));
644
645        layout.arrow = Some(ArrowLayout {
646            side: Side::Left,
647            offset: Px(1.0),
648            alignment_offset: Px(0.0),
649            center_offset: Px(0.0),
650        });
651        assert_eq!(wrapper_insets_for_arrow(&layout, Px(7.0)).left, Px(7.0));
652    }
653
654    #[test]
655    fn wrapper_insets_are_zero_when_arrow_is_hidden() {
656        let layout = AnchoredPanelLayout {
657            rect: Rect::new(Point::new(Px(0.0), Px(0.0)), Size::new(Px(10.0), Px(10.0))),
658            side: Side::Bottom,
659            align: Align::Center,
660            arrow: Some(ArrowLayout {
661                side: Side::Top,
662                offset: Px(1.0),
663                alignment_offset: Px(0.0),
664                center_offset: Px(10.0),
665            }),
666        };
667
668        assert_eq!(
669            wrapper_insets_for_arrow(&layout, Px(7.0)),
670            Edges::all(Px(0.0))
671        );
672    }
673
674    #[test]
675    fn popper_layout_sized_returns_arrow_layout_when_configured() {
676        let outer = Rect::new(
677            Point::new(Px(0.0), Px(0.0)),
678            Size::new(Px(200.0), Px(200.0)),
679        );
680        let anchor = Rect::new(
681            Point::new(Px(50.0), Px(60.0)),
682            Size::new(Px(40.0), Px(10.0)),
683        );
684        let desired = Size::new(Px(120.0), Px(80.0));
685
686        let layout = popper_layout_sized(
687            outer,
688            anchor,
689            desired,
690            Px(8.0),
691            Side::Bottom,
692            Align::Center,
693            AnchoredPanelOptions {
694                direction: LayoutDirection::Ltr,
695                offset: Offset::default(),
696                shift: Default::default(),
697                arrow: Some(ArrowOptions {
698                    size: Size::new(Px(12.0), Px(12.0)),
699                    padding: Edges::all(Px(8.0)),
700                }),
701                collision: Default::default(),
702                sticky: Default::default(),
703            },
704        );
705
706        let arrow = layout.arrow.expect("arrow layout");
707        assert_eq!(arrow.side, Side::Top);
708    }
709
710    #[test]
711    fn transform_origin_tracks_arrow_on_anchor_edge() {
712        let outer = Rect::new(
713            Point::new(Px(0.0), Px(0.0)),
714            Size::new(Px(200.0), Px(200.0)),
715        );
716        let anchor = Rect::new(
717            Point::new(Px(50.0), Px(60.0)),
718            Size::new(Px(40.0), Px(10.0)),
719        );
720        let desired = Size::new(Px(120.0), Px(80.0));
721        let arrow_size = Px(12.0);
722
723        let layout = popper_layout_sized(
724            outer,
725            anchor,
726            desired,
727            Px(8.0),
728            Side::Bottom,
729            Align::Center,
730            AnchoredPanelOptions {
731                direction: LayoutDirection::Ltr,
732                offset: Offset::default(),
733                shift: Default::default(),
734                arrow: Some(ArrowOptions {
735                    size: Size::new(arrow_size, arrow_size),
736                    padding: Edges::all(Px(8.0)),
737                }),
738                collision: Default::default(),
739                sticky: Default::default(),
740            },
741        );
742
743        let origin = popper_content_transform_origin(&layout, anchor, Some(arrow_size));
744        let arrow = layout.arrow.expect("expected arrow layout");
745        assert_eq!(origin.y, layout.rect.origin.y);
746        assert_eq!(
747            origin.x,
748            Px(layout.rect.origin.x.0 + arrow.offset.0 + arrow_size.0 * 0.5)
749        );
750    }
751
752    #[test]
753    fn transform_origin_tracks_anchor_center_without_arrow() {
754        let outer = Rect::new(
755            Point::new(Px(0.0), Px(0.0)),
756            Size::new(Px(200.0), Px(200.0)),
757        );
758        let anchor = Rect::new(
759            Point::new(Px(50.0), Px(60.0)),
760            Size::new(Px(40.0), Px(10.0)),
761        );
762        let desired = Size::new(Px(120.0), Px(80.0));
763
764        let layout = popper_layout_sized(
765            outer,
766            anchor,
767            desired,
768            Px(8.0),
769            Side::Bottom,
770            Align::Center,
771            AnchoredPanelOptions {
772                direction: LayoutDirection::Ltr,
773                offset: Offset::default(),
774                shift: Default::default(),
775                arrow: None,
776                collision: Default::default(),
777                sticky: Default::default(),
778            },
779        );
780
781        let origin = popper_content_transform_origin(&layout, anchor, None);
782        assert_eq!(origin.y, layout.rect.origin.y);
783
784        let anchor_center_x = anchor.origin.x.0 + anchor.size.width.0 * 0.5;
785        let x_in_panel =
786            (anchor_center_x - layout.rect.origin.x.0).clamp(0.0, layout.rect.size.width.0);
787        assert_eq!(origin.x, Px(layout.rect.origin.x.0 + x_in_panel));
788    }
789
790    #[test]
791    fn transform_origin_uses_alignment_when_arrow_is_hidden() {
792        let layout = AnchoredPanelLayout {
793            rect: Rect::new(
794                Point::new(Px(10.0), Px(20.0)),
795                Size::new(Px(100.0), Px(50.0)),
796            ),
797            side: Side::Bottom,
798            align: Align::End,
799            arrow: Some(ArrowLayout {
800                side: Side::Top,
801                offset: Px(1.0),
802                alignment_offset: Px(0.0),
803                center_offset: Px(10.0),
804            }),
805        };
806
807        let origin = popper_content_transform_origin(&layout, Rect::default(), Some(Px(12.0)));
808        assert_eq!(origin.y, Px(20.0));
809        assert_eq!(origin.x, Px(110.0));
810    }
811
812    #[test]
813    fn popper_content_placement_passes_collision_padding_to_solver() {
814        let outer = Rect::new(
815            Point::new(Px(0.0), Px(0.0)),
816            Size::new(Px(200.0), Px(100.0)),
817        );
818        let anchor = Rect::new(
819            Point::new(Px(10.0), Px(40.0)),
820            Size::new(Px(40.0), Px(10.0)),
821        );
822        let desired = Size::new(Px(120.0), Px(40.0));
823
824        let layout = popper_content_layout_sized(
825            outer,
826            anchor,
827            desired,
828            PopperContentPlacement::new(LayoutDirection::Ltr, Side::Bottom, Align::Start, Px(0.0))
829                .with_collision_padding(Edges {
830                    bottom: Px(20.0),
831                    ..Edges::all(Px(0.0))
832                }),
833        );
834
835        assert_eq!(layout.side, Side::Top);
836    }
837
838    #[test]
839    fn popper_content_reference_hidden_false_when_disabled() {
840        let outer = Rect::new(
841            Point::new(Px(0.0), Px(0.0)),
842            Size::new(Px(100.0), Px(100.0)),
843        );
844        let anchor_outside = Rect::new(
845            Point::new(Px(200.0), Px(200.0)),
846            Size::new(Px(10.0), Px(10.0)),
847        );
848        let placement =
849            PopperContentPlacement::new(LayoutDirection::Ltr, Side::Bottom, Align::Start, Px(0.0));
850
851        assert!(!placement.reference_hidden(outer, anchor_outside));
852    }
853
854    #[test]
855    fn popper_content_reference_hidden_true_when_anchor_outside_boundary() {
856        let outer = Rect::new(
857            Point::new(Px(0.0), Px(0.0)),
858            Size::new(Px(100.0), Px(100.0)),
859        );
860        let anchor_outside = Rect::new(
861            Point::new(Px(200.0), Px(200.0)),
862            Size::new(Px(10.0), Px(10.0)),
863        );
864        let placement =
865            PopperContentPlacement::new(LayoutDirection::Ltr, Side::Bottom, Align::Start, Px(0.0))
866                .with_hide_when_detached(true);
867
868        assert!(placement.reference_hidden(outer, anchor_outside));
869    }
870
871    #[test]
872    fn available_metrics_track_anchor_and_available_space() {
873        let outer = Rect::new(
874            Point::new(Px(0.0), Px(0.0)),
875            Size::new(Px(100.0), Px(100.0)),
876        );
877        let anchor = Rect::new(
878            Point::new(Px(10.0), Px(20.0)),
879            Size::new(Px(30.0), Px(40.0)),
880        );
881        let layout = AnchoredPanelLayout {
882            rect: Rect::new(
883                Point::new(Px(40.0), Px(40.0)),
884                Size::new(Px(20.0), Px(10.0)),
885            ),
886            side: Side::Bottom,
887            align: Align::Center,
888            arrow: None,
889        };
890
891        let m = popper_available_metrics(outer, anchor, &layout, LayoutDirection::Ltr);
892        assert_eq!(m.anchor_width, Px(30.0));
893        assert_eq!(m.anchor_height, Px(40.0));
894        assert_eq!(m.available_width, Px(100.0));
895        assert_eq!(m.available_height, Px(60.0));
896    }
897}