Skip to main content

teksilo_core/overlay/
placement_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Overlay placement geometry: turning an anchor rect, a viewport and a
5//! [`OverlayPlacement`] into the overlay's on-screen bounds — leading-edge
6//! alignment, the flip-when-it-does-not-fit fallbacks and the viewport clamps.
7//!
8//! Everything here clamps into [`OverlayViewport::usable`] rather than into the
9//! window rectangle. The one exception is
10//! [`FullViewport`](OverlayPlacement::FullViewport), which is the modal scrim
11//! and must cover the notch too.
12//!
13//! The clamp is not unconditional. An anchored panel may leave the usable area
14//! rather than cover the control that raised it, and where it can be shrunk
15//! instead it is shrunk — see [`above_anchor`].
16//!
17//! # Where the safe area comes from
18//!
19//! Both production calls to
20//! [`position_overlays`](OverlayManager::position_overlays) are inside
21//! [`WidgetTree::layout_with_ops`](crate::WidgetTree::layout_with_ops) — which
22//! is what `teksilo-app` calls and what
23//! [`layout`](crate::WidgetTree::layout) delegates to — and it now builds the
24//! [`OverlayViewport`] from the tree's own
25//! [`safe_area`](crate::WidgetTree::safe_area) and
26//! [`occluded_inset`](crate::WidgetTree::occluded_inset), which `teksilo-app`
27//! reads from the platform after every resize and scale change.
28//!
29//! On the desktop both are usually nothing, and where they are nothing the
30//! arithmetic below reduces exactly to what it was before [`OverlayViewport`]
31//! existed: only macOS reports a safe area at all, and only Windows has a
32//! findable soft keyboard. So the notch and keyboard behaviour here is reached
33//! in production, on the platforms that have one, and is inert everywhere else
34//! — which is what the tests at the bottom of this file check by supplying the
35//! numbers directly.
36
37use super::*;
38
39/// The gap between a panel and whatever it is placed against.
40const ANCHOR_GAP: f32 = 4.0;
41
42/// The gap a contact-avoiding panel keeps from the contact patch.
43///
44/// Small on purpose: the clearance that matters is the patch itself, which the
45/// digitiser measured. The gap only stops the panel's border from touching it.
46const AVOID_GAP: f32 = 4.0;
47
48/// The gap between a selection and the toolbar floating over it.
49///
50/// Wider than [`ANCHOR_GAP`] because the thing underneath is text the user is
51/// reading, and a toolbar 4 dp off the top of a line looks attached to it.
52const SELECTION_GAP: f32 = 8.0;
53
54/// Leading-edge-aligned x for a `Below` / `Above` overlay, clamped so the
55/// overlay stays inside the viewport.
56/// Leading-edge-aligned x for a `Below` / `Above` overlay, clamped so the
57/// overlay stays inside the viewport.
58///
59/// In LTR the leading edge is `anchor.x`; in RTL it is the anchor's physical
60/// right edge. **Both are clamped.** The LTR arm used to be a bare `anchor.x`,
61/// which silently ran a popover off the right edge of the window whenever its
62/// trigger sat near that edge and its content was wider than the trigger — the
63/// ordinary case for a status-bar or toolbar-trailing control. The RTL arm has
64/// always clamped; there was no reason for the two to differ.
65///
66/// `max(x_min)` last, so a usable area narrower than the overlay pins it to the
67/// leading edge and clips at the trailing one, rather than pushing its start
68/// off-screen where the first thing the reader needs would be the part lost.
69fn leading_aligned_x_in(anchor: Rect, actual_width: f32, x_min: f32, x_max: f32, rtl: bool) -> f32 {
70    let leading = if rtl {
71        anchor.x + anchor.width - actual_width
72    } else {
73        anchor.x
74    };
75    leading.min(x_max - actual_width).max(x_min)
76}
77
78/// [`leading_aligned_x_in`] against a window that is usable to its last pixel
79/// — the shape the alignment tests below state their cases in, and exactly what
80/// the production call reduces to when nothing is inset or occluded.
81#[cfg(test)]
82fn leading_aligned_x(anchor: Rect, actual_width: f32, vw: f32, rtl: bool) -> f32 {
83    leading_aligned_x_in(anchor, actual_width, 0.0, vw, rtl)
84}
85
86/// The width an anchor-aligned panel is drawn at: its own, but never narrower
87/// than the control it hangs off.
88///
89/// This is the drop-down convention on every desktop: the list is at least as
90/// wide as the combo box, so it reads as the field opening downward rather than
91/// as a loose panel parked under its leading third. A 240 dp combo box whose
92/// 80 dp list clings to its left edge looks broken, and it looks broken in
93/// exactly the case that is most common — a short list under a field sized to
94/// the form.
95///
96/// It lives here rather than in the widget because only the placement pass has
97/// both rectangles. The content's intrinsic size is measured with no reference
98/// to the anchor (see `WidgetTree::layout`), so a widget wanting this for
99/// itself would have to observe its own final laid-out width and re-propose its
100/// popup a frame later — which every `ComboBox`-shaped widget in the catalogue
101/// would then have to do, identically.
102///
103/// The floor is not only cosmetic: the result feeds
104/// [`leading_aligned_x_in`], so it decides where the panel is clamped as well
105/// as how wide it is. Applies to [`Below`](OverlayPlacement::Below),
106/// [`Above`](OverlayPlacement::Above) and
107/// [`BelowPreferred`](OverlayPlacement::BelowPreferred) — the three placements
108/// that align to an anchor's leading edge.
109fn at_least_anchor_width(content_width: f32, anchor: Rect) -> f32 {
110    content_width.max(anchor.width)
111}
112
113/// The room a panel has between the anchor's top edge and the top of `area`,
114/// [`ANCHOR_GAP`] already taken out. Never negative.
115fn room_above(anchor: Rect, area: Rect) -> f32 {
116    (anchor.y - ANCHOR_GAP - area.y).max(0.0)
117}
118
119/// The room a panel has between the anchor's bottom edge and the bottom of
120/// `area`, [`ANCHOR_GAP`] already taken out. Never negative.
121fn room_below(anchor: Rect, area: Rect) -> f32 {
122    (area.bottom() - anchor.bottom() - ANCHOR_GAP).max(0.0)
123}
124
125/// `(y, height)` for a panel of `height` stacked **above** `anchor`.
126///
127/// A panel that fits in the room above sits at its ideal `y`, exactly as it
128/// always has. One that does not is pinned to the top of `area` and **shrunk
129/// to the room that is there** — it is not slid down until it fits, because
130/// the only thing under it is the control that opened it, and a list the user
131/// cannot see the trigger under is a list they are choosing blind.
132///
133/// Shrinking is real, not cosmetic: the overlay pass lays the content out with
134/// `SizeProposal::exact(bounds.width, bounds.height)` (see
135/// `WidgetTree::layout`), so a shorter rect is a shorter list that scrolls,
136/// not a full-height list drawn off the top of the window with its first rows
137/// unreachable.
138///
139/// The exception is an anchor with **no** room above it at all — one that
140/// reaches the top of the usable area, which is what a control occupying the
141/// whole window does. Shrinking there would mean a panel of zero height, and
142/// an empty panel is not an improvement on a badly placed one, so the ideal
143/// position is kept. The anchor stays visible either way: the panel is off the
144/// anchor's edge, not over it.
145fn above_anchor(anchor: Rect, height: f32, area: Rect) -> (f32, f32) {
146    let room = room_above(anchor, area);
147    if height <= room || room <= 0.0 {
148        (anchor.y - ANCHOR_GAP - height, height)
149    } else {
150        (area.y, room)
151    }
152}
153
154/// `(y, height)` for a panel of `height` stacked **below** `anchor`, the
155/// transpose of [`above_anchor`].
156fn below_anchor(anchor: Rect, height: f32, area: Rect) -> (f32, f32) {
157    let room = room_below(anchor, area);
158    let height = if room > 0.0 { height.min(room) } else { height };
159    (anchor.bottom() + ANCHOR_GAP, height)
160}
161
162/// Place `size` so it clears `avoid` entirely, preferring the inline-start
163/// quadrant, and stays inside `area`.
164///
165/// Four candidates, in preference order: inline-start of the contact, then
166/// inline-end, then below it, then above it. The first two clear on the
167/// horizontal axis and so accept *any* vertical position, which is what lets
168/// the vertical clamp run without ever undoing the clearance; the last two are
169/// the transpose. Only when the panel fits in none of the four strips — a panel
170/// as large as the usable area — does the fallback clamp both axes and accept
171/// the overlap, because at that size there is no placement that does not
172/// overlap.
173fn avoiding_bounds(avoid: Rect, size: Size, area: Rect, rtl: bool) -> Rect {
174    let (w, h) = (size.width, size.height);
175    let clamp_x = |x: f32| x.min(area.right() - w).max(area.x);
176    let clamp_y = |y: f32| y.min(area.bottom() - h).max(area.y);
177
178    // Inline-start is left under LTR and right under RTL: a hand reaches in
179    // from the reader's own side, so the far side is the one that stays
180    // visible under it.
181    let start_x = if rtl {
182        avoid.right() + AVOID_GAP
183    } else {
184        avoid.x - AVOID_GAP - w
185    };
186    let end_x = if rtl {
187        avoid.x - AVOID_GAP - w
188    } else {
189        avoid.right() + AVOID_GAP
190    };
191    let below_y = avoid.bottom() + AVOID_GAP;
192    let above_y = avoid.y - AVOID_GAP - h;
193
194    let fits_x = |x: f32| x >= area.x && x + w <= area.right();
195    let fits_y = |y: f32| y >= area.y && y + h <= area.bottom();
196
197    for x in [start_x, end_x] {
198        if fits_x(x) {
199            let y = if fits_y(below_y) {
200                below_y
201            } else if fits_y(above_y) {
202                above_y
203            } else {
204                clamp_y(below_y)
205            };
206            return Rect::new(x, y, w, h);
207        }
208    }
209    for y in [below_y, above_y] {
210        if fits_y(y) {
211            return Rect::new(clamp_x(start_x), y, w, h);
212        }
213    }
214    Rect::new(clamp_x(start_x), clamp_y(below_y), w, h)
215}
216
217/// Place `size` above `selection`, centred on it, flipping below when the
218/// selection is against the top of `area`.
219///
220/// The horizontal clamp is ordered by direction. Centring is what a reader
221/// expects while it is possible; when the toolbar is wider than the space it
222/// has, one edge has to be sacrificed, and the one to keep is where the line
223/// starts — the left under LTR, the right under RTL.
224fn above_selection_bounds(selection: Rect, size: Size, area: Rect, rtl: bool) -> Rect {
225    let (w, h) = (size.width, size.height);
226    let above_y = selection.y - SELECTION_GAP - h;
227    let below_y = selection.bottom() + SELECTION_GAP;
228    let y = if above_y >= area.y {
229        above_y
230    } else if below_y + h <= area.bottom() {
231        below_y
232    } else {
233        above_y.max(area.y)
234    };
235    let centred = selection.center().x - w / 2.0;
236    let x = if rtl {
237        centred.max(area.x).min(area.right() - w)
238    } else {
239        centred.min(area.right() - w).max(area.x)
240    };
241    Rect::new(x, y, w, h)
242}
243
244impl OverlayManager {
245    /// Compute overlay positions based on anchor bounds.
246    /// Called after layout to position overlays correctly.
247    /// `viewport` is (width, height) used for clamping overlays to the visible area.
248    ///
249    /// `anchor_bounds_fn` returns `None` when the anchor widget is no
250    /// longer in the arena (destroyed by a host's rebuild while the
251    /// overlay is still up). An anchor-relative placement then keeps its
252    /// bounds untouched — at its last valid position rather than
253    /// collapsing to the (0,0) origin from a `Rect::ZERO` fallback. An
254    /// anchor-independent one is still positioned, against a `Rect::ZERO`
255    /// anchor it does not read.
256    pub fn position_overlays(
257        &mut self,
258        anchor_bounds_fn: impl Fn(WidgetId) -> Option<Rect>,
259        viewport: impl Into<OverlayViewport>,
260        layout_direction: LayoutDirection,
261    ) {
262        let viewport: OverlayViewport = viewport.into();
263        let area = viewport.usable(layout_direction);
264        let rtl = matches!(layout_direction, LayoutDirection::RightToLeft);
265        for overlay in &mut self.stack {
266            let anchor = match anchor_bounds_fn(overlay.anchor) {
267                Some(a) => a,
268                None => {
269                    // Anchor destroyed. Anchor-independent placements must still
270                    // be positioned — e.g. a `Centered` modal opened from a menu
271                    // item that has since closed (the menu item is the anchor,
272                    // but `Centered` doesn't use it). Anchor-relative placements
273                    // keep their previous bounds.
274                    if matches!(
275                        overlay.placement,
276                        OverlayPlacement::Centered
277                            | OverlayPlacement::FullViewport
278                            | OverlayPlacement::BottomCenter
279                            | OverlayPlacement::ViewportCorner { .. }
280                            | OverlayPlacement::AtPointer(_)
281                            | OverlayPlacement::AtPointerAvoiding { .. }
282                            | OverlayPlacement::AboveSelection { .. }
283                    ) {
284                        Rect::ZERO
285                    } else {
286                        continue;
287                    }
288                }
289            };
290            let content_size = overlay.bounds.size(); // Will be set from content layout
291
292            overlay.bounds = match &overlay.placement {
293                OverlayPlacement::Below => {
294                    let actual_width = at_least_anchor_width(content_size.width, anchor);
295                    let x = leading_aligned_x_in(anchor, actual_width, area.x, area.right(), rtl);
296                    Rect::new(
297                        x,
298                        anchor.y + anchor.height + ANCHOR_GAP,
299                        actual_width,
300                        content_size.height,
301                    )
302                }
303                OverlayPlacement::Above => {
304                    let actual_width = at_least_anchor_width(content_size.width, anchor);
305                    let x = leading_aligned_x_in(anchor, actual_width, area.x, area.right(), rtl);
306                    let (y, height) = above_anchor(anchor, content_size.height, area);
307                    Rect::new(x, y, actual_width, height)
308                }
309                OverlayPlacement::TrailingEdge => {
310                    // In LTR trailing is to the right; in RTL trailing is to the left.
311                    let x = if rtl {
312                        let x_left = anchor.x - content_size.width - 2.0;
313                        if x_left >= area.x {
314                            x_left
315                        } else {
316                            // Fallback: open to the leading side (right in RTL)
317                            anchor.x + anchor.width + 2.0
318                        }
319                    } else {
320                        let x_right = anchor.x + anchor.width + 2.0;
321                        if x_right + content_size.width <= area.right() {
322                            x_right
323                        } else {
324                            // Fallback: open to the leading side (left in LTR)
325                            anchor.x - content_size.width - 2.0
326                        }
327                    };
328                    let y = anchor
329                        .y
330                        .min(area.bottom() - content_size.height)
331                        .max(area.y);
332                    Rect::new(x, y, content_size.width, content_size.height)
333                }
334                OverlayPlacement::AtPointer(point) => {
335                    // Clamp to the usable area so menus don't overflow off-screen
336                    let x = point.x.min(area.right() - content_size.width).max(area.x);
337                    let y = if point.y + content_size.height <= area.bottom() {
338                        point.y
339                    } else {
340                        // Not enough space below pointer — open above
341                        (point.y - content_size.height).max(area.y)
342                    };
343                    Rect::new(x, y, content_size.width, content_size.height)
344                }
345                OverlayPlacement::AtPointerAvoiding { avoid, .. } => {
346                    avoiding_bounds(*avoid, content_size, area, rtl)
347                }
348                OverlayPlacement::AboveSelection { selection } => {
349                    above_selection_bounds(*selection, content_size, area, rtl)
350                }
351                OverlayPlacement::NearAnchor { offset } => {
352                    // Prefer below the anchor at `offset` + 4 px.
353                    // Flip above when the content would otherwise spill
354                    // past the viewport bottom — same pattern as
355                    // `BelowPreferred`. Without this, a tooltip whose
356                    // anchor sits near the window edge gets clipped by
357                    // the surface bounds (overlays paint unclipped, but
358                    // the window itself still bounds the framebuffer).
359                    let below_y = anchor.y + anchor.height + offset.y + ANCHOR_GAP;
360                    let fits_below = below_y + content_size.height <= area.bottom();
361                    let y = if fits_below {
362                        below_y
363                    } else {
364                        // Symmetric offset above: same gap as below.
365                        let above_y = anchor.y - content_size.height - offset.y - ANCHOR_GAP;
366                        above_y.max(area.y)
367                    };
368                    // Horizontal anchoring is direction-aware: LTR aligns
369                    // the content's leading (left) edge to the anchor's
370                    // left edge + offset; RTL mirrors it, aligning the
371                    // content's trailing (right) edge to the anchor's
372                    // right edge - offset. The clamp then keeps it in view
373                    // when the anchor is near a viewport edge.
374                    let unclamped_x = if rtl {
375                        anchor.x + anchor.width - content_size.width - offset.x
376                    } else {
377                        anchor.x + offset.x
378                    };
379                    let x = unclamped_x
380                        .min(area.right() - content_size.width)
381                        .max(area.x);
382                    Rect::new(x, y, content_size.width, content_size.height)
383                }
384                // A modal recomputes against the *usable* area, so a soft
385                // keyboard shifts it up rather than sitting on top of it, and
386                // a panel taller than what is left pins to the top — the one
387                // edge from which the rest can still be scrolled into view.
388                OverlayPlacement::Centered => Rect::new(
389                    area.x + ((area.width - content_size.width) / 2.0).max(0.0),
390                    area.y + ((area.height - content_size.height) / 2.0).max(0.0),
391                    content_size.width.min(area.width),
392                    content_size.height.min(area.height),
393                ),
394                OverlayPlacement::BottomCenter => Rect::new(
395                    area.x + ((area.width - content_size.width) / 2.0).max(0.0),
396                    (area.bottom() - content_size.height - 24.0).max(area.y),
397                    content_size.width.min(area.width),
398                    content_size.height.min(area.height),
399                ),
400                OverlayPlacement::BelowPreferred => {
401                    let wanted = content_size.height;
402                    let above = room_above(anchor, area);
403                    let below = room_below(anchor, area);
404                    // Below while it fits — the placement's name. Otherwise
405                    // above while *it* fits, which is where this placement has
406                    // always flipped. When the panel fits on neither side,
407                    // whichever side has more room, shrunk to it; a tie keeps
408                    // the flip. Sliding the panel down over the anchor is the
409                    // one answer never taken: a list the user cannot see the
410                    // combo box under is a list they are choosing blind.
411                    let (y, height) = if wanted <= below || (wanted > above && below > above) {
412                        below_anchor(anchor, wanted, area)
413                    } else {
414                        above_anchor(anchor, wanted, area)
415                    };
416                    let actual_width = at_least_anchor_width(content_size.width, anchor);
417                    // Align leading edges, same logic as Below.
418                    let x = leading_aligned_x_in(anchor, actual_width, area.x, area.right(), rtl);
419                    Rect::new(x, y, actual_width, height)
420                }
421                OverlayPlacement::ViewportCorner { corner, margin } => {
422                    let (x, y) = corner.resolve(
423                        (content_size.width, content_size.height),
424                        (area.width, area.height),
425                        (margin.x, margin.y),
426                        rtl,
427                    );
428                    Rect::new(
429                        area.x + x,
430                        area.y + y,
431                        content_size.width.min(area.width),
432                        content_size.height.min(area.height),
433                    )
434                }
435                // The scrim, and only the scrim, ignores the safe area: one
436                // that respected it would leave the notch undimmed and the
437                // content behind it legible.
438                OverlayPlacement::FullViewport => viewport.full(),
439            };
440        }
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::overlay::tests::fake_id;
448
449    /// A `Below`/`Above` overlay must stay inside the viewport in **LTR**, not
450    /// only RTL.
451    ///
452    /// The LTR arm was a bare `anchor.x`, so a popover whose trigger sat near
453    /// the right edge — a status-bar button, a toolbar-trailing control — ran
454    /// off the screen and lost its trailing edge. Nothing caught it because the
455    /// RTL arm, which has always clamped, is the one that looks like it needs
456    /// the arithmetic.
457    #[test]
458    fn a_wide_overlay_near_the_trailing_edge_is_clamped_into_the_viewport() {
459        let vw = 1200.0;
460        // A 380 px-wide popover under a 90 px button whose left edge is at 1035:
461        // unclamped it would end at 1415, 215 px past the window.
462        let anchor = Rect::new(1035.0, 760.0, 90.0, 28.0);
463        let x = leading_aligned_x(anchor, 380.0, vw, false);
464        assert!(
465            x + 380.0 <= vw + 0.01,
466            "overlay must not extend past the viewport: x={x}"
467        );
468        assert!(x >= 0.0, "and must not start off the leading edge: x={x}");
469
470        // Comfortably inside, the leading edge is still honoured exactly —
471        // clamping must not nudge overlays that already fit.
472        let inside = Rect::new(100.0, 760.0, 90.0, 28.0);
473        assert_eq!(leading_aligned_x(inside, 380.0, vw, false), 100.0);
474
475        // RTL keeps aligning to the anchor's physical right edge.
476        let x_rtl = leading_aligned_x(inside, 380.0, vw, true);
477        assert!(x_rtl >= 0.0 && x_rtl + 380.0 <= vw + 0.01);
478    }
479
480    /// A viewport narrower than the overlay pins the **leading** edge and clips
481    /// the trailing one — losing the start of the content would hide the first
482    /// thing the reader needs (a search field, a title).
483    #[test]
484    fn an_overlay_wider_than_the_viewport_keeps_its_leading_edge_visible() {
485        let x = leading_aligned_x(Rect::new(40.0, 10.0, 60.0, 20.0), 900.0, 500.0, false);
486        assert_eq!(x, 0.0);
487    }
488
489    #[test]
490    fn centered_placement_uses_viewport_center() {
491        let mut mgr = OverlayManager::new();
492        let id = mgr.show(OverlayRequest {
493            content_id: fake_id(10),
494            anchor: fake_id(1),
495            placement: OverlayPlacement::Centered,
496            dismiss: DismissBehavior::Manual,
497            layer: OverlayLayer::InTree,
498            parent_overlay: None,
499            on_dismiss: None,
500            fade_duration: None,
501        });
502
503        mgr.set_content_bounds(id, Size::new(240.0, 120.0));
504        mgr.position_overlays(
505            |_| Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
506            (800.0, 600.0),
507            LayoutDirection::LeftToRight,
508        );
509
510        let bounds = mgr
511            .stack
512            .iter()
513            .find(|overlay| overlay.id == id)
514            .unwrap()
515            .bounds;
516        assert!((bounds.x - 280.0).abs() < 0.01);
517        assert!((bounds.y - 240.0).abs() < 0.01);
518    }
519
520    #[test]
521    fn bottom_center_placement_uses_viewport_bottom_margin() {
522        let mut mgr = OverlayManager::new();
523        let id = mgr.show(OverlayRequest {
524            content_id: fake_id(10),
525            anchor: fake_id(1),
526            placement: OverlayPlacement::BottomCenter,
527            dismiss: DismissBehavior::Manual,
528            layer: OverlayLayer::InTree,
529            parent_overlay: None,
530            on_dismiss: None,
531            fade_duration: None,
532        });
533
534        mgr.set_content_bounds(id, Size::new(240.0, 64.0));
535        mgr.position_overlays(
536            |_| Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
537            (800.0, 600.0),
538            LayoutDirection::LeftToRight,
539        );
540
541        let bounds = mgr
542            .stack
543            .iter()
544            .find(|overlay| overlay.id == id)
545            .unwrap()
546            .bounds;
547        assert!((bounds.x - 280.0).abs() < 0.01);
548        assert!((bounds.y - 512.0).abs() < 0.01);
549    }
550
551    // --- ViewportCorner placement ---
552
553    fn show_corner_overlay(
554        mgr: &mut OverlayManager,
555        corner: Corner,
556        margin: Vec2,
557        size: Size,
558    ) -> OverlayId {
559        let id = mgr.show(OverlayRequest {
560            content_id: fake_id(10),
561            anchor: fake_id(1),
562            placement: OverlayPlacement::ViewportCorner { corner, margin },
563            dismiss: DismissBehavior::Manual,
564            layer: OverlayLayer::InTree,
565            parent_overlay: None,
566            on_dismiss: None,
567            fade_duration: None,
568        });
569        mgr.set_content_bounds(id, size);
570        id
571    }
572
573    fn overlay_bounds(mgr: &OverlayManager, id: OverlayId) -> Rect {
574        mgr.stack.iter().find(|o| o.id == id).unwrap().bounds
575    }
576
577    #[test]
578    fn viewport_corner_top_leading_ltr() {
579        let mut mgr = OverlayManager::new();
580        let id = show_corner_overlay(
581            &mut mgr,
582            Corner::TopLeading,
583            Vec2::new(24.0, 24.0),
584            Size::new(380.0, 100.0),
585        );
586        mgr.position_overlays(
587            |_| Some(Rect::ZERO),
588            (800.0, 600.0),
589            LayoutDirection::LeftToRight,
590        );
591        let b = overlay_bounds(&mgr, id);
592        assert!((b.x - 24.0).abs() < 0.01, "x = {}", b.x);
593        assert!((b.y - 24.0).abs() < 0.01, "y = {}", b.y);
594    }
595
596    #[test]
597    fn viewport_corner_top_trailing_ltr() {
598        let mut mgr = OverlayManager::new();
599        let id = show_corner_overlay(
600            &mut mgr,
601            Corner::TopTrailing,
602            Vec2::new(24.0, 24.0),
603            Size::new(380.0, 100.0),
604        );
605        mgr.position_overlays(
606            |_| Some(Rect::ZERO),
607            (800.0, 600.0),
608            LayoutDirection::LeftToRight,
609        );
610        let b = overlay_bounds(&mgr, id);
611        // 800 - 380 - 24 = 396
612        assert!((b.x - 396.0).abs() < 0.01, "x = {}", b.x);
613        assert!((b.y - 24.0).abs() < 0.01);
614    }
615
616    #[test]
617    fn viewport_corner_bottom_leading_ltr() {
618        let mut mgr = OverlayManager::new();
619        let id = show_corner_overlay(
620            &mut mgr,
621            Corner::BottomLeading,
622            Vec2::new(24.0, 24.0),
623            Size::new(380.0, 100.0),
624        );
625        mgr.position_overlays(
626            |_| Some(Rect::ZERO),
627            (800.0, 600.0),
628            LayoutDirection::LeftToRight,
629        );
630        let b = overlay_bounds(&mgr, id);
631        // 600 - 100 - 24 = 476
632        assert!((b.x - 24.0).abs() < 0.01);
633        assert!((b.y - 476.0).abs() < 0.01, "y = {}", b.y);
634    }
635
636    #[test]
637    fn viewport_corner_bottom_trailing_ltr() {
638        let mut mgr = OverlayManager::new();
639        let id = show_corner_overlay(
640            &mut mgr,
641            Corner::BottomTrailing,
642            Vec2::new(24.0, 24.0),
643            Size::new(380.0, 100.0),
644        );
645        mgr.position_overlays(
646            |_| Some(Rect::ZERO),
647            (800.0, 600.0),
648            LayoutDirection::LeftToRight,
649        );
650        let b = overlay_bounds(&mgr, id);
651        assert!((b.x - 396.0).abs() < 0.01);
652        assert!((b.y - 476.0).abs() < 0.01);
653    }
654
655    #[test]
656    fn viewport_corner_top_trailing_rtl_flips_to_left() {
657        let mut mgr = OverlayManager::new();
658        let id = show_corner_overlay(
659            &mut mgr,
660            Corner::TopTrailing,
661            Vec2::new(24.0, 24.0),
662            Size::new(380.0, 100.0),
663        );
664        mgr.position_overlays(
665            |_| Some(Rect::ZERO),
666            (800.0, 600.0),
667            LayoutDirection::RightToLeft,
668        );
669        let b = overlay_bounds(&mgr, id);
670        // RTL flips Trailing to physical left
671        assert!((b.x - 24.0).abs() < 0.01, "x = {}", b.x);
672        assert!((b.y - 24.0).abs() < 0.01);
673    }
674
675    #[test]
676    fn viewport_corner_bottom_leading_rtl_flips_to_right() {
677        let mut mgr = OverlayManager::new();
678        let id = show_corner_overlay(
679            &mut mgr,
680            Corner::BottomLeading,
681            Vec2::new(24.0, 24.0),
682            Size::new(380.0, 100.0),
683        );
684        mgr.position_overlays(
685            |_| Some(Rect::ZERO),
686            (800.0, 600.0),
687            LayoutDirection::RightToLeft,
688        );
689        let b = overlay_bounds(&mgr, id);
690        assert!((b.x - 396.0).abs() < 0.01, "x = {}", b.x);
691        assert!((b.y - 476.0).abs() < 0.01);
692    }
693
694    #[test]
695    fn viewport_corner_ignores_anchor_bounds() {
696        let mut mgr = OverlayManager::new();
697        let id = show_corner_overlay(
698            &mut mgr,
699            Corner::BottomTrailing,
700            Vec2::new(0.0, 0.0),
701            Size::new(100.0, 100.0),
702        );
703        // Even with an absurd anchor location, ViewportCorner only uses viewport.
704        mgr.position_overlays(
705            |_| Some(Rect::new(123.0, 456.0, 7.0, 8.0)),
706            (800.0, 600.0),
707            LayoutDirection::LeftToRight,
708        );
709        let b = overlay_bounds(&mgr, id);
710        assert_eq!((b.x, b.y), (700.0, 500.0));
711    }
712
713    #[test]
714    fn near_anchor_horizontal_is_direction_aware() {
715        // NearAnchor (used by tooltips): LTR aligns the content's leading
716        // (left) edge to the anchor's left edge; RTL mirrors it, aligning
717        // the content's trailing (right) edge to the anchor's right edge.
718        // Anchor x=600, w=100 (right edge 700); content w=200; offset 0.
719        // Viewport 800×600 — wide enough that the clamp doesn't bite.
720        let anchor = Rect::new(600.0, 100.0, 100.0, 20.0);
721        let resolved_x = |dir: LayoutDirection| {
722            let mut mgr = OverlayManager::new();
723            let id = mgr.show(OverlayRequest {
724                content_id: fake_id(10),
725                anchor: fake_id(1),
726                placement: OverlayPlacement::NearAnchor {
727                    offset: Vec2::new(0.0, 8.0),
728                },
729                dismiss: DismissBehavior::Manual,
730                layer: OverlayLayer::InTree,
731                parent_overlay: None,
732                on_dismiss: None,
733                fade_duration: None,
734            });
735            mgr.set_content_bounds(id, Size::new(200.0, 50.0));
736            mgr.position_overlays(|_| Some(anchor), (800.0, 600.0), dir);
737            overlay_bounds(&mgr, id).x
738        };
739        // LTR: anchor.x + offset.x = 600.
740        assert!(
741            (resolved_x(LayoutDirection::LeftToRight) - 600.0).abs() < 0.01,
742            "LTR x = {}",
743            resolved_x(LayoutDirection::LeftToRight)
744        );
745        // RTL: anchor.x + anchor.width - content.w - offset.x = 500.
746        assert!(
747            (resolved_x(LayoutDirection::RightToLeft) - 500.0).abs() < 0.01,
748            "RTL x = {}",
749            resolved_x(LayoutDirection::RightToLeft)
750        );
751    }
752
753    // -----------------------------------------------------------------
754    // Contact avoidance
755    // -----------------------------------------------------------------
756
757    fn overlaps(a: Rect, b: Rect) -> bool {
758        a.x < b.right() && b.x < a.right() && a.y < b.bottom() && b.y < a.bottom()
759    }
760
761    /// Show one overlay with `placement` at `size` and return where it landed.
762    fn placed(
763        placement: OverlayPlacement,
764        size: Size,
765        anchor: Rect,
766        viewport: impl Into<OverlayViewport>,
767        direction: LayoutDirection,
768    ) -> Rect {
769        let mut mgr = OverlayManager::new();
770        let id = mgr.show(OverlayRequest {
771            content_id: fake_id(10),
772            anchor: fake_id(1),
773            placement,
774            dismiss: DismissBehavior::Manual,
775            layer: OverlayLayer::InTree,
776            parent_overlay: None,
777            on_dismiss: None,
778            fade_duration: None,
779        });
780        mgr.set_content_bounds(id, size);
781        mgr.position_overlays(|_| Some(anchor), viewport, direction);
782        overlay_bounds(&mgr, id)
783    }
784
785    /// A finger is an opaque disc, so the menu it raises must not open beneath
786    /// it — **at every corner of the screen**, which is where the obvious
787    /// "offset it down and right" fix stops working and quietly puts the panel
788    /// back under the hand (or off-screen).
789    #[test]
790    fn a_coarse_menu_clears_the_contact_at_every_corner() {
791        let viewport = (800.0, 600.0);
792        let menu = Size::new(220.0, 260.0);
793        for point in [
794            Point::new(6.0, 6.0),
795            Point::new(794.0, 6.0),
796            Point::new(6.0, 594.0),
797            Point::new(794.0, 594.0),
798        ] {
799            for direction in [LayoutDirection::LeftToRight, LayoutDirection::RightToLeft] {
800                let placement = OverlayPlacement::at_pointer_for(
801                    point,
802                    &crate::pointer::PointerInfo::touch(
803                        crate::pointer::PointerId::MOUSE,
804                        crate::pointer::EventTime::ZERO,
805                    ),
806                );
807                let OverlayPlacement::AtPointerAvoiding { avoid, .. } = placement else {
808                    panic!("a coarse pointer must get the avoiding placement");
809                };
810                let bounds = placed(placement.clone(), menu, Rect::ZERO, viewport, direction);
811                assert!(
812                    !overlaps(bounds, avoid),
813                    "menu at {point:?} ({direction:?}) sits under the contact: \
814                     bounds {bounds:?}, contact {avoid:?}"
815                );
816                assert!(
817                    bounds.x >= -0.01
818                        && bounds.y >= -0.01
819                        && bounds.right() <= 800.01
820                        && bounds.bottom() <= 600.01,
821                    "menu at {point:?} ({direction:?}) left the viewport: {bounds:?}"
822                );
823            }
824        }
825    }
826
827    /// The preferred quadrant is inline-start — the far side from the hand —
828    /// and it mirrors, so an Arabic user's menu opens where an English user's
829    /// does relative to the *line*, not relative to the screen.
830    #[test]
831    fn the_avoiding_quadrant_mirrors_under_rtl() {
832        let point = Point::new(400.0, 300.0);
833        let avoid = crate::overlay::rect_centred_on(point, ASSUMED_CONTACT_PATCH);
834        let menu = Size::new(220.0, 260.0);
835        let at = |direction| {
836            placed(
837                OverlayPlacement::AtPointerAvoiding { point, avoid },
838                menu,
839                Rect::ZERO,
840                (800.0, 600.0),
841                direction,
842            )
843        };
844        let ltr = at(LayoutDirection::LeftToRight);
845        let rtl = at(LayoutDirection::RightToLeft);
846        assert!(
847            ltr.right() <= avoid.x,
848            "LTR opens to the left of the contact: {ltr:?} vs {avoid:?}"
849        );
850        assert!(
851            rtl.x >= avoid.right(),
852            "RTL opens to the right of the contact: {rtl:?} vs {avoid:?}"
853        );
854        // Same distance from the contact on either side — a mirror, not two
855        // independently tuned offsets.
856        assert!(
857            ((avoid.x - ltr.right()) - (rtl.x - avoid.right())).abs() < 0.01,
858            "the two sides must be symmetric: {ltr:?} / {rtl:?}"
859        );
860        assert_eq!(ltr.y, rtl.y, "only the inline axis mirrors");
861    }
862
863    /// A finger, with whatever contact patch the digitiser reported.
864    fn finger(contact: Option<Size>) -> crate::pointer::PointerInfo {
865        let mut pointer = crate::pointer::PointerInfo::touch(
866            crate::pointer::PointerId::MOUSE,
867            crate::pointer::EventTime::ZERO,
868        );
869        pointer.axes.contact = contact;
870        pointer
871    }
872
873    /// The rectangle a menu clears, and where that put it.
874    fn avoided(point: Point, contact: Option<Size>) -> (Rect, Rect) {
875        let placement = OverlayPlacement::at_pointer_for(point, &finger(contact));
876        let OverlayPlacement::AtPointerAvoiding { avoid, .. } = placement else {
877            panic!("a coarse pointer must get the avoiding placement");
878        };
879        let bounds = placed(
880            placement,
881            Size::new(220.0, 260.0),
882            Rect::ZERO,
883            (800.0, 600.0),
884            LayoutDirection::LeftToRight,
885        );
886        (avoid, bounds)
887    }
888
889    /// A device that reports a **bigger** patch than the floor is believed: a
890    /// thumb covers more than the 24 dp a fingertip is assumed to, and the menu
891    /// has to clear the thumb, not the assumption.
892    ///
893    /// The check is on where the panel *lands*, not only on the rectangle,
894    /// because ignoring `PointerAxes::contact` entirely leaves a perfectly
895    /// self-consistent 24 dp answer that only a comparison against the floor
896    /// can tell apart.
897    #[test]
898    fn a_contact_patch_larger_than_the_floor_widens_what_the_menu_clears() {
899        let point = Point::new(400.0, 300.0);
900        let (avoid, bounds) = avoided(point, Some(Size::new(60.0, 40.0)));
901        assert_eq!(
902            avoid,
903            Rect::new(370.0, 280.0, 60.0, 40.0),
904            "the reported patch, centred on the contact"
905        );
906        assert!(
907            bounds.right() <= avoid.x + 0.01,
908            "the menu must clear the thumb: {bounds:?} vs {avoid:?}"
909        );
910        let (_, floored) = avoided(point, None);
911        assert!(
912            bounds.right() < floored.right() - 0.01,
913            "a 60 dp patch must push the menu further out than the 24 dp floor \
914             would ({} vs {})",
915            bounds.right(),
916            floored.right()
917        );
918    }
919
920    /// A device that reports a **smaller** patch than the floor is not: 24 dp
921    /// is the smallest thing a finger is ever asked to hit, so it is the
922    /// smallest rectangle a finger can be assumed to cover, and a digitiser
923    /// claiming 6 dp is under-reporting a fingertip rather than describing one.
924    #[test]
925    fn a_contact_patch_smaller_than_the_floor_is_raised_to_it() {
926        let point = Point::new(400.0, 300.0);
927        let (avoid, bounds) = avoided(point, Some(Size::new(6.0, 6.0)));
928        assert_eq!(
929            avoid,
930            crate::overlay::rect_centred_on(point, ASSUMED_CONTACT_PATCH),
931            "the floor, not the 6 dp the device claimed"
932        );
933        let claimed = crate::overlay::rect_centred_on(point, Size::new(6.0, 6.0));
934        assert!(
935            bounds.right() < claimed.x - 0.01,
936            "clearing only the reported 6 dp leaves the menu under the finger: \
937             {bounds:?} vs the 24 dp floor at {avoid:?}"
938        );
939        let (_, floored) = avoided(point, None);
940        assert_eq!(
941            bounds, floored,
942            "an under-reported patch must place exactly as no report at all"
943        );
944    }
945
946    /// A precise pointer keeps the placement it always had, to the pixel.
947    #[test]
948    fn a_mouse_still_gets_the_plain_at_pointer_placement() {
949        let point = Point::new(400.0, 300.0);
950        let mouse = crate::pointer::PointerInfo::mouse(crate::pointer::EventTime::ZERO);
951        assert!(matches!(
952            OverlayPlacement::at_pointer_for(point, &mouse),
953            OverlayPlacement::AtPointer(p) if p == point
954        ));
955    }
956
957    // -----------------------------------------------------------------
958    // An anchored panel never covers its anchor
959    // -----------------------------------------------------------------
960
961    /// The rule, stated once: **a panel never covers the control that opened
962    /// it.** A viewport clamp that slides a flipped-up panel down onto its own
963    /// anchor buys an on-screen rectangle at the price of the thing the user
964    /// is choosing *for*, and that is never the better trade.
965    ///
966    /// The case is the degenerate one, because it is the one where a clamp is
967    /// most tempting and most wrong: the combo box fills the window, so no
968    /// placement can hold the list and the panel comes out empty rather than
969    /// on top of the trigger. `teksilo-widgets`'
970    /// `below_preferred_opens_above_when_no_space` is the same geometry seen
971    /// from the widget side; this test is here so the rule cannot be broken
972    /// from inside core alone.
973    #[test]
974    fn a_drop_down_never_covers_the_combo_box_that_opened_it() {
975        let anchor = Rect::new(0.0, 0.0, 300.0, 60.0);
976        let bounds = placed(
977            OverlayPlacement::BelowPreferred,
978            Size::new(300.0, 80.0),
979            anchor,
980            (300.0, 60.0),
981            LayoutDirection::LeftToRight,
982        );
983        assert!(
984            !overlaps(bounds, anchor),
985            "the list sits on top of its own combo box: {bounds:?} over {anchor:?}"
986        );
987        assert!(
988            bounds.bottom() <= anchor.y,
989            "the list must stay above the trigger: {bounds:?}"
990        );
991        // And it keeps its height. There is no room to shrink into here, and a
992        // zero-height list is not an improvement on a badly placed one.
993        assert_eq!(bounds.height, 80.0, "an empty panel is not the answer");
994        assert_eq!(bounds.y, -84.0, "hung off the anchor's top edge");
995    }
996
997    /// Too tall for either side, so it takes the side with more room and is
998    /// **shrunk to that room** — 166 dp below beats 6 dp above, and neither a
999    /// list hanging off the top of the window nor one clamped down over the
1000    /// trigger is on offer.
1001    #[test]
1002    fn a_drop_down_that_fits_neither_side_takes_the_roomier_one_and_shrinks() {
1003        let anchor = Rect::new(100.0, 10.0, 80.0, 20.0);
1004        let bounds = placed(
1005            OverlayPlacement::BelowPreferred,
1006            Size::new(120.0, 200.0),
1007            anchor,
1008            // Short viewport: 6 dp of room above the anchor, 166 below.
1009            (800.0, 200.0),
1010            LayoutDirection::LeftToRight,
1011        );
1012        assert_eq!(bounds.y, 34.0, "stays below — that is the roomier side");
1013        assert_eq!(bounds.height, 166.0, "shrunk to the room that is there");
1014        assert!(
1015            bounds.bottom() <= 200.0,
1016            "and inside the window: {bounds:?}"
1017        );
1018    }
1019
1020    /// The flip itself is untouched: while the panel fits above, it goes there
1021    /// at full height and at the y it has always had.
1022    #[test]
1023    fn a_drop_down_that_fits_above_still_flips_there_unchanged() {
1024        let anchor = Rect::new(100.0, 400.0, 80.0, 20.0);
1025        let bounds = placed(
1026            OverlayPlacement::BelowPreferred,
1027            Size::new(120.0, 200.0),
1028            anchor,
1029            (800.0, 460.0),
1030            LayoutDirection::LeftToRight,
1031        );
1032        assert_eq!(bounds.y, 196.0, "400 - 200 - 4");
1033        assert_eq!(bounds.height, 200.0, "no shrink: it fits");
1034    }
1035
1036    /// `Above` is the same rule without the side choice: a panel taller than
1037    /// the room above is pinned to the top of the usable area and shrunk to
1038    /// that room, never slid down over the anchor.
1039    #[test]
1040    fn an_above_panel_too_tall_for_the_room_shrinks_rather_than_covering() {
1041        let anchor = Rect::new(100.0, 60.0, 80.0, 20.0);
1042        let bounds = placed(
1043            OverlayPlacement::Above,
1044            Size::new(120.0, 200.0),
1045            anchor,
1046            (800.0, 600.0),
1047            LayoutDirection::LeftToRight,
1048        );
1049        assert_eq!(bounds.y, 0.0, "pinned to the top of the usable area");
1050        assert_eq!(
1051            bounds.height, 56.0,
1052            "the 60 dp above the anchor, less the gap"
1053        );
1054        assert!(
1055            bounds.bottom() <= anchor.y,
1056            "and clear of the anchor: {bounds:?}"
1057        );
1058    }
1059
1060    /// The safe area moves the ceiling, not the rule: under a 40 dp notch the
1061    /// panel starts at 40 and is shrunk to the 156 dp between the notch and
1062    /// the anchor.
1063    #[test]
1064    fn a_shrunk_panel_starts_below_the_notch() {
1065        let viewport = OverlayViewport::from((800.0, 600.0))
1066            .with_safe_area(teksilo_canvas::EdgeInsets::new(40.0, 0.0, 0.0, 0.0));
1067        let anchor = Rect::new(100.0, 200.0, 80.0, 20.0);
1068        let bounds = placed(
1069            OverlayPlacement::Above,
1070            Size::new(120.0, 220.0),
1071            anchor,
1072            viewport,
1073            LayoutDirection::LeftToRight,
1074        );
1075        assert_eq!(bounds.y, 40.0, "below the notch");
1076        assert_eq!(bounds.height, 156.0, "200 - 40 - 4");
1077    }
1078
1079    // -----------------------------------------------------------------
1080    // AboveSelection
1081    // -----------------------------------------------------------------
1082
1083    #[test]
1084    fn a_selection_toolbar_floats_centred_above_the_selection() {
1085        let selection = Rect::new(300.0, 300.0, 100.0, 20.0);
1086        let bounds = placed(
1087            OverlayPlacement::AboveSelection { selection },
1088            Size::new(200.0, 40.0),
1089            Rect::ZERO,
1090            (800.0, 600.0),
1091            LayoutDirection::LeftToRight,
1092        );
1093        assert_eq!(bounds.x, 250.0, "centred on the selection");
1094        assert_eq!(bounds.y, 252.0, "8 dp above it");
1095    }
1096
1097    /// A selection on the first line has nothing above it, so the toolbar goes
1098    /// under rather than off the top.
1099    #[test]
1100    fn a_selection_toolbar_flips_below_at_the_top_of_the_window() {
1101        let selection = Rect::new(300.0, 10.0, 100.0, 20.0);
1102        let bounds = placed(
1103            OverlayPlacement::AboveSelection { selection },
1104            Size::new(200.0, 40.0),
1105            Rect::ZERO,
1106            (800.0, 600.0),
1107            LayoutDirection::LeftToRight,
1108        );
1109        assert_eq!(bounds.y, 38.0, "8 dp below the selection");
1110    }
1111
1112    /// When the toolbar cannot be centred, the edge that survives is the one
1113    /// the line starts at — left in English, right in Arabic.
1114    #[test]
1115    fn a_selection_toolbar_keeps_its_inline_start_edge_when_it_cannot_fit() {
1116        let selection = Rect::new(10.0, 300.0, 20.0, 20.0);
1117        let toolbar = Size::new(200.0, 40.0);
1118        let at = |direction| {
1119            placed(
1120                OverlayPlacement::AboveSelection { selection },
1121                toolbar,
1122                Rect::ZERO,
1123                (100.0, 600.0),
1124                direction,
1125            )
1126        };
1127        let ltr = at(LayoutDirection::LeftToRight);
1128        let rtl = at(LayoutDirection::RightToLeft);
1129        assert_eq!(ltr.x, 0.0, "LTR keeps the left edge");
1130        assert_eq!(rtl.right(), 100.0, "RTL keeps the right edge");
1131    }
1132
1133    // -----------------------------------------------------------------
1134    // The viewport
1135    // -----------------------------------------------------------------
1136
1137    /// A modal centres in the part of the window a person can see, not in the
1138    /// window.
1139    #[test]
1140    fn a_centered_modal_centres_inside_the_safe_area() {
1141        let viewport = OverlayViewport::from((800.0, 600.0))
1142            .with_safe_area(teksilo_canvas::EdgeInsets::new(100.0, 0.0, 0.0, 0.0));
1143        let bounds = placed(
1144            OverlayPlacement::Centered,
1145            Size::new(200.0, 100.0),
1146            Rect::ZERO,
1147            viewport,
1148            LayoutDirection::LeftToRight,
1149        );
1150        assert_eq!(bounds.y, 300.0, "centred in the 500 dp below the notch");
1151    }
1152
1153    /// The scrim is the one placement that ignores the safe area: one that
1154    /// respected it would leave the notch undimmed.
1155    #[test]
1156    fn the_scrim_still_covers_the_whole_window() {
1157        let viewport = OverlayViewport::from((800.0, 600.0))
1158            .with_safe_area(teksilo_canvas::EdgeInsets::uniform(40.0))
1159            .with_occluded(Some(Rect::new(0.0, 400.0, 800.0, 200.0)));
1160        let bounds = placed(
1161            OverlayPlacement::FullViewport,
1162            Size::ZERO,
1163            Rect::ZERO,
1164            viewport,
1165            LayoutDirection::LeftToRight,
1166        );
1167        assert_eq!(bounds, Rect::new(0.0, 0.0, 800.0, 600.0));
1168    }
1169
1170    /// A menu raised while a keyboard is up opens in the band above it, not
1171    /// behind it.
1172    #[test]
1173    fn an_occluded_band_is_not_a_place_to_put_a_menu() {
1174        let viewport = OverlayViewport::from((800.0, 600.0))
1175            .with_occluded(Some(Rect::new(0.0, 340.0, 800.0, 260.0)));
1176        let bounds = placed(
1177            OverlayPlacement::AtPointer(Point::new(100.0, 300.0)),
1178            Size::new(200.0, 120.0),
1179            Rect::ZERO,
1180            viewport,
1181            LayoutDirection::LeftToRight,
1182        );
1183        assert!(
1184            bounds.bottom() <= 340.01,
1185            "the menu must stay above the keyboard: {bounds:?}"
1186        );
1187    }
1188
1189    #[test]
1190    fn viewport_corner_zero_margin_snaps_to_edge() {
1191        let mut mgr = OverlayManager::new();
1192        let id = show_corner_overlay(
1193            &mut mgr,
1194            Corner::TopLeading,
1195            Vec2::ZERO,
1196            Size::new(50.0, 50.0),
1197        );
1198        mgr.position_overlays(
1199            |_| Some(Rect::ZERO),
1200            (800.0, 600.0),
1201            LayoutDirection::LeftToRight,
1202        );
1203        let b = overlay_bounds(&mgr, id);
1204        assert_eq!((b.x, b.y), (0.0, 0.0));
1205    }
1206
1207    // -----------------------------------------------------------------
1208    // Every placement is measured against the usable area, not the window
1209    // -----------------------------------------------------------------
1210    //
1211    // §1 of `docs/overlays.md` says placement clamps into
1212    // `OverlayViewport::usable`, and §2's table repeats the promise row by
1213    // row. On a desktop the two rectangles are the same rectangle, so a
1214    // placement that clamps against the window satisfies every existing
1215    // assertion and the guarantee holds by coincidence rather than by code.
1216    //
1217    // The tests below are the ones that stop coinciding: each uses a viewport
1218    // whose insets move every edge, and each anchor is placed where the two
1219    // rectangles give different answers. Swap any `area` in
1220    // `position_overlays` for the window and one of them reports a number
1221    // from the wrong rectangle.
1222
1223    /// A viewport whose safe area moves **every** edge: a 60 dp sensor
1224    /// housing at the start of the line, a 40 dp rounded corner at its end, a
1225    /// 40 dp notch and a 30 dp home indicator, on an 800 × 600 window.
1226    ///
1227    /// Under LTR that leaves `Rect::new(60, 40, 700, 530)`, under RTL
1228    /// `Rect::new(40, 40, 700, 530)` — the two horizontal insets swap. No edge
1229    /// of either coincides with the window's, which is the whole point: a
1230    /// clamp against the wrong rectangle lands on a different number instead
1231    /// of accidentally the right one.
1232    fn inset_viewport() -> OverlayViewport {
1233        OverlayViewport::from((800.0, 600.0))
1234            .with_safe_area(teksilo_canvas::EdgeInsets::new(40.0, 40.0, 30.0, 60.0))
1235    }
1236
1237    /// The rectangle [`inset_viewport`] leaves usable in `direction`.
1238    fn inset_usable(direction: LayoutDirection) -> Rect {
1239        inset_viewport().usable(direction)
1240    }
1241
1242    /// `Below` clamps **x** into the usable area.
1243    ///
1244    /// Four cases, because the row makes four separate promises and a change
1245    /// can break them one at a time: the trailing bound (`area.right()`), the
1246    /// leading bound (`area.x`), the mirroring of both under RTL — the area is
1247    /// `usable(direction)`, not `usable(LTR)` — and the row's negative half,
1248    /// that **y** is *not* clamped, so a panel opened against the bottom drops
1249    /// past it rather than sliding back up over the control that opened it.
1250    #[test]
1251    fn a_below_panel_is_clamped_into_the_usable_area_not_the_window() {
1252        let ltr = inset_usable(LayoutDirection::LeftToRight);
1253        let panel = Size::new(300.0, 180.0);
1254
1255        // A trigger against the trailing inset: the panel's far edge lands on
1256        // the usable edge (760), not on the window's (800).
1257        let trailing = placed(
1258            OverlayPlacement::Below,
1259            panel,
1260            Rect::new(690.0, 200.0, 60.0, 24.0),
1261            inset_viewport(),
1262            LayoutDirection::LeftToRight,
1263        );
1264        assert_eq!(trailing.x, 460.0, "clamped to the usable trailing edge");
1265        assert_eq!(trailing.right(), ltr.right());
1266
1267        // A trigger under the sensor housing: the panel starts at the usable
1268        // leading edge (60), not at the window's (0).
1269        let leading = placed(
1270            OverlayPlacement::Below,
1271            panel,
1272            Rect::new(20.0, 200.0, 60.0, 24.0),
1273            inset_viewport(),
1274            LayoutDirection::LeftToRight,
1275        );
1276        assert_eq!(leading.x, ltr.x, "clamped to the usable leading edge");
1277
1278        // RTL puts each inset on the other physical edge, so the same anchor
1279        // clamps to 440 rather than to the LTR 460.
1280        let rtl = inset_usable(LayoutDirection::RightToLeft);
1281        let mirrored = placed(
1282            OverlayPlacement::Below,
1283            panel,
1284            Rect::new(700.0, 200.0, 60.0, 24.0),
1285            inset_viewport(),
1286            LayoutDirection::RightToLeft,
1287        );
1288        assert_eq!(mirrored.x, 440.0, "the mirrored trailing edge");
1289        assert_eq!(mirrored.right(), rtl.right());
1290
1291        // And y, which the row promises *not* to clamp.
1292        let low = placed(
1293            OverlayPlacement::Below,
1294            panel,
1295            Rect::new(100.0, 520.0, 60.0, 24.0),
1296            inset_viewport(),
1297            LayoutDirection::LeftToRight,
1298        );
1299        assert_eq!(low.y, 548.0, "the anchor's bottom edge plus the gap");
1300        assert!(
1301            low.bottom() > ltr.bottom(),
1302            "and it is allowed to run past the usable bottom: {low:?}"
1303        );
1304    }
1305
1306    /// `Above` aligns and clamps x exactly as `Below` does; only its y differs.
1307    #[test]
1308    fn an_above_panel_is_clamped_into_the_usable_area_not_the_window() {
1309        let ltr = inset_usable(LayoutDirection::LeftToRight);
1310        let panel = Size::new(300.0, 100.0);
1311
1312        let trailing = placed(
1313            OverlayPlacement::Above,
1314            panel,
1315            Rect::new(690.0, 300.0, 60.0, 24.0),
1316            inset_viewport(),
1317            LayoutDirection::LeftToRight,
1318        );
1319        assert_eq!(trailing.x, 460.0, "clamped to the usable trailing edge");
1320        assert_eq!(trailing.right(), ltr.right());
1321        assert_eq!(trailing.y, 196.0, "and still sits above the anchor");
1322
1323        let leading = placed(
1324            OverlayPlacement::Above,
1325            panel,
1326            Rect::new(20.0, 300.0, 60.0, 24.0),
1327            inset_viewport(),
1328            LayoutDirection::LeftToRight,
1329        );
1330        assert_eq!(leading.x, ltr.x, "clamped to the usable leading edge");
1331    }
1332
1333    /// `BelowPreferred` uses the usable area twice: for the x clamp it shares
1334    /// with `Below`, and for the room it measures on each side before choosing
1335    /// one. The third case is the one only the second use can answer — a combo
1336    /// box with 172 dp of window below it but only 142 dp a person can see,
1337    /// which is a flip upward rather than a list running under the home
1338    /// indicator.
1339    #[test]
1340    fn a_below_preferred_panel_is_placed_against_the_usable_area_not_the_window() {
1341        let ltr = inset_usable(LayoutDirection::LeftToRight);
1342        let panel = Size::new(300.0, 100.0);
1343
1344        let trailing = placed(
1345            OverlayPlacement::BelowPreferred,
1346            panel,
1347            Rect::new(690.0, 300.0, 60.0, 24.0),
1348            inset_viewport(),
1349            LayoutDirection::LeftToRight,
1350        );
1351        assert_eq!(trailing.x, 460.0, "clamped to the usable trailing edge");
1352        assert_eq!(trailing.right(), ltr.right());
1353        assert_eq!(trailing.y, 328.0, "and drops below, which still fits");
1354
1355        let leading = placed(
1356            OverlayPlacement::BelowPreferred,
1357            panel,
1358            Rect::new(20.0, 300.0, 60.0, 24.0),
1359            inset_viewport(),
1360            LayoutDirection::LeftToRight,
1361        );
1362        assert_eq!(leading.x, ltr.x, "clamped to the usable leading edge");
1363
1364        let anchor = Rect::new(100.0, 400.0, 60.0, 24.0);
1365        let flipped = placed(
1366            OverlayPlacement::BelowPreferred,
1367            Size::new(300.0, 160.0),
1368            anchor,
1369            inset_viewport(),
1370            LayoutDirection::LeftToRight,
1371        );
1372        assert_eq!(
1373            flipped.y, 236.0,
1374            "flipped above: 160 dp does not fit in 142"
1375        );
1376        assert_eq!(flipped.height, 160.0, "with nothing shrunk away");
1377        assert!(
1378            flipped.bottom() <= anchor.y,
1379            "and clear of the anchor: {flipped:?}"
1380        );
1381    }
1382
1383    /// `NearAnchor` — the tooltip placement — clamps x at both ends and flips
1384    /// above when the room below runs out, clamping to the top.
1385    ///
1386    /// The two vertical cases separate the two uses of the area: whether there
1387    /// is room below is asked of `area.bottom()` — under the first anchor's
1388    /// gap there are 124 dp of window and 94 dp of usable area, and the 100 dp
1389    /// tooltip fits the first of those and not the second — and the flipped
1390    /// position is floored at `area.y` rather than at the window's top edge,
1391    /// which is under the notch.
1392    #[test]
1393    fn a_near_anchor_tooltip_is_placed_against_the_usable_area_not_the_window() {
1394        let ltr = inset_usable(LayoutDirection::LeftToRight);
1395        let tooltip = Size::new(300.0, 100.0);
1396        let near = |anchor, size| {
1397            placed(
1398                OverlayPlacement::NearAnchor {
1399                    offset: Vec2::new(0.0, 8.0),
1400                },
1401                size,
1402                anchor,
1403                inset_viewport(),
1404                LayoutDirection::LeftToRight,
1405            )
1406        };
1407
1408        let trailing = near(Rect::new(690.0, 200.0, 60.0, 24.0), tooltip);
1409        assert_eq!(trailing.x, 460.0, "clamped to the usable trailing edge");
1410        assert_eq!(trailing.right(), ltr.right());
1411
1412        let leading = near(Rect::new(20.0, 200.0, 60.0, 24.0), tooltip);
1413        assert_eq!(leading.x, ltr.x, "clamped to the usable leading edge");
1414
1415        let flipped = near(Rect::new(100.0, 440.0, 60.0, 24.0), tooltip);
1416        assert_eq!(
1417            flipped.y, 328.0,
1418            "no room below the anchor a person can see"
1419        );
1420
1421        let pinned = near(Rect::new(100.0, 500.0, 60.0, 24.0), Size::new(300.0, 480.0));
1422        assert_eq!(pinned.y, ltr.y, "flipped and pinned below the notch");
1423    }
1424
1425    /// `AtPointer` — the mouse context menu — clamps x at both ends, decides
1426    /// which way to open against the usable bottom, and floors the flipped
1427    /// position at the usable top.
1428    #[test]
1429    fn an_at_pointer_menu_is_placed_against_the_usable_area_not_the_window() {
1430        let ltr = inset_usable(LayoutDirection::LeftToRight);
1431        let at = |point, size| {
1432            placed(
1433                OverlayPlacement::AtPointer(point),
1434                size,
1435                Rect::ZERO,
1436                inset_viewport(),
1437                LayoutDirection::LeftToRight,
1438            )
1439        };
1440        let menu = Size::new(300.0, 100.0);
1441
1442        let trailing = at(Point::new(700.0, 200.0), menu);
1443        assert_eq!(trailing.x, 460.0, "clamped to the usable trailing edge");
1444        assert_eq!(trailing.right(), ltr.right());
1445
1446        let leading = at(Point::new(20.0, 200.0), menu);
1447        assert_eq!(leading.x, ltr.x, "clamped to the usable leading edge");
1448
1449        // 80 dp of menu fits in the window below y = 500 and does not fit in
1450        // the usable area, so this opens upward.
1451        let flipped = at(Point::new(100.0, 500.0), Size::new(300.0, 80.0));
1452        assert_eq!(flipped.y, 420.0, "opened above the pointer");
1453
1454        let pinned = at(Point::new(100.0, 500.0), Size::new(300.0, 480.0));
1455        assert_eq!(pinned.y, ltr.y, "flipped and pinned below the notch");
1456    }
1457
1458    /// `TrailingEdge` — the submenu — asks whether it fits on the trailing
1459    /// side of its parent, flips to the leading side when it does not, and
1460    /// clamps y at both ends. All three questions are about the usable area.
1461    ///
1462    /// The LTR case is a submenu with 238 dp of window to its right and 198 of
1463    /// usable area: the window says it fits, the rounded corner says it does
1464    /// not. The RTL case is the transpose — the same submenu opening leftward,
1465    /// ruled out by the inset on *that* edge.
1466    #[test]
1467    fn a_submenu_flips_and_clamps_against_the_usable_area_not_the_window() {
1468        let ltr = inset_usable(LayoutDirection::LeftToRight);
1469        let submenu = Size::new(200.0, 100.0);
1470        let at = |anchor, direction| {
1471            placed(
1472                OverlayPlacement::TrailingEdge,
1473                submenu,
1474                anchor,
1475                inset_viewport(),
1476                direction,
1477            )
1478        };
1479
1480        let flipped = at(
1481            Rect::new(500.0, 200.0, 60.0, 24.0),
1482            LayoutDirection::LeftToRight,
1483        );
1484        assert_eq!(flipped.x, 298.0, "opened to the leading side instead");
1485
1486        let low = at(
1487            Rect::new(500.0, 520.0, 60.0, 24.0),
1488            LayoutDirection::LeftToRight,
1489        );
1490        assert_eq!(low.y, 470.0, "pulled up to sit above the home indicator");
1491        assert_eq!(low.bottom(), ltr.bottom());
1492
1493        let high = at(
1494            Rect::new(500.0, 10.0, 60.0, 24.0),
1495            LayoutDirection::LeftToRight,
1496        );
1497        assert_eq!(high.y, ltr.y, "and pushed down below the notch");
1498
1499        let mirrored = at(
1500            Rect::new(230.0, 200.0, 60.0, 24.0),
1501            LayoutDirection::RightToLeft,
1502        );
1503        assert_eq!(
1504            mirrored.x, 292.0,
1505            "in RTL the flip is to the right, because 28 is under the rounded \
1506             corner: RTL puts `safe_area.trailing` (40 dp) on the left, and \
1507             the 60 dp sensor housing on the right"
1508        );
1509    }
1510
1511    /// A coarse pointer's menu clears the contact patch **and** stays inside
1512    /// the usable area.
1513    ///
1514    /// The inline-start quadrant this placement prefers would start at x = 26
1515    /// here — inside the window, underneath the sensor housing. The area is
1516    /// what rules it out, so the menu takes the inline-end quadrant instead
1517    /// and the finger still sees all of it.
1518    #[test]
1519    fn a_coarse_menu_avoids_the_contact_without_leaving_the_usable_area() {
1520        let ltr = inset_usable(LayoutDirection::LeftToRight);
1521        let avoid = Rect::new(250.0, 200.0, 24.0, 24.0);
1522        let bounds = placed(
1523            OverlayPlacement::AtPointerAvoiding {
1524                point: avoid.center(),
1525                avoid,
1526            },
1527            Size::new(220.0, 260.0),
1528            Rect::ZERO,
1529            inset_viewport(),
1530            LayoutDirection::LeftToRight,
1531        );
1532        assert_eq!(bounds.x, 278.0, "the inline-end quadrant, past the contact");
1533        assert!(
1534            bounds.x >= ltr.x && bounds.right() <= ltr.right(),
1535            "inside the usable area: {bounds:?}"
1536        );
1537        assert!(
1538            !overlaps(bounds, avoid),
1539            "and still clear of the finger: {bounds:?}"
1540        );
1541    }
1542
1543    /// The selection toolbar flips below when the selection is against the top
1544    /// of the **usable** area — a line under the notch has nothing above it,
1545    /// even though the window says there are 60 dp there — and sacrifices the
1546    /// edge the line ends at when it cannot be centred inside that area.
1547    #[test]
1548    fn a_selection_toolbar_is_placed_against_the_usable_area_not_the_window() {
1549        let toolbar = Size::new(200.0, 40.0);
1550        let at = |selection, direction| {
1551            placed(
1552                OverlayPlacement::AboveSelection { selection },
1553                toolbar,
1554                Rect::ZERO,
1555                inset_viewport(),
1556                direction,
1557            )
1558        };
1559
1560        let flipped = at(
1561            Rect::new(300.0, 60.0, 100.0, 20.0),
1562            LayoutDirection::LeftToRight,
1563        );
1564        assert_eq!(flipped.y, 88.0, "below the selection, clear of the notch");
1565
1566        let ltr = inset_usable(LayoutDirection::LeftToRight);
1567        let start = at(
1568            Rect::new(70.0, 300.0, 20.0, 20.0),
1569            LayoutDirection::LeftToRight,
1570        );
1571        assert_eq!(start.x, ltr.x, "LTR keeps the usable left edge");
1572
1573        let rtl = inset_usable(LayoutDirection::RightToLeft);
1574        let end = at(
1575            Rect::new(720.0, 300.0, 20.0, 20.0),
1576            LayoutDirection::RightToLeft,
1577        );
1578        assert_eq!(end.right(), rtl.right(), "RTL keeps the usable right edge");
1579    }
1580
1581    /// A modal centres inside the usable area on **both** axes, and one bigger
1582    /// than that area is shrunk to it rather than to the window — the overlay
1583    /// pass lays content out at the bounds it is given, so the difference is a
1584    /// dialog that scrolls versus one whose last rows are behind the home
1585    /// indicator.
1586    #[test]
1587    fn a_centered_modal_is_centred_and_shrunk_inside_the_usable_area() {
1588        let ltr = inset_usable(LayoutDirection::LeftToRight);
1589        let centred = placed(
1590            OverlayPlacement::Centered,
1591            Size::new(200.0, 100.0),
1592            Rect::ZERO,
1593            inset_viewport(),
1594            LayoutDirection::LeftToRight,
1595        );
1596        assert_eq!(centred.x, 310.0, "centred between the side insets");
1597        assert_eq!(centred.y, 255.0, "and between the notch and the indicator");
1598
1599        let oversized = placed(
1600            OverlayPlacement::Centered,
1601            Size::new(900.0, 700.0),
1602            Rect::ZERO,
1603            inset_viewport(),
1604            LayoutDirection::LeftToRight,
1605        );
1606        assert_eq!(oversized.size(), ltr.size(), "shrunk to the usable area");
1607        assert_eq!((oversized.x, oversized.y), (ltr.x, ltr.y));
1608    }
1609
1610    /// A snackbar keeps its 24 dp margin from the bottom of the usable area,
1611    /// which on a phone is the top of the home indicator, not the bottom of
1612    /// the window.
1613    #[test]
1614    fn a_snackbar_sits_above_the_home_indicator() {
1615        let bounds = placed(
1616            OverlayPlacement::BottomCenter,
1617            Size::new(300.0, 60.0),
1618            Rect::ZERO,
1619            inset_viewport(),
1620            LayoutDirection::LeftToRight,
1621        );
1622        assert_eq!(bounds.y, 486.0, "24 dp above the usable bottom");
1623        assert_eq!(bounds.x, 260.0, "centred between the side insets");
1624    }
1625
1626    /// A toast's corner is a corner of the usable area. At zero margin the
1627    /// window's corner is under the rounded glass, which is exactly where a
1628    /// dismiss button must not be.
1629    #[test]
1630    fn a_toast_corner_is_a_corner_of_the_usable_area() {
1631        let ltr = inset_usable(LayoutDirection::LeftToRight);
1632        let corner = |corner| {
1633            placed(
1634                OverlayPlacement::ViewportCorner {
1635                    corner,
1636                    margin: Vec2::ZERO,
1637                },
1638                Size::new(100.0, 50.0),
1639                Rect::ZERO,
1640                inset_viewport(),
1641                LayoutDirection::LeftToRight,
1642            )
1643        };
1644
1645        let top_leading = corner(Corner::TopLeading);
1646        assert_eq!((top_leading.x, top_leading.y), (ltr.x, ltr.y));
1647
1648        let bottom_trailing = corner(Corner::BottomTrailing);
1649        assert_eq!(
1650            (bottom_trailing.right(), bottom_trailing.bottom()),
1651            (ltr.right(), ltr.bottom())
1652        );
1653    }
1654
1655    /// A toast bigger than the usable area is shrunk to it.
1656    ///
1657    /// [`a_toast_corner_is_a_corner_of_the_usable_area`] pins the corner but
1658    /// never asks for the shrink: its toast is 100 x 50 with hundreds of dp to
1659    /// spare, so the `min` against the area is the identity there and would go
1660    /// on being the identity if it were deleted. Here the toast is 760 x 560 in
1661    /// a 700 x 530 usable area, and measured against the *window* it survives
1662    /// at full size — 60 dp past the rounded corner, 30 dp into the home
1663    /// indicator, with the dismiss button somewhere under the glass.
1664    ///
1665    /// The shrink is real, not a clip: the overlay pass lays content out at
1666    /// `SizeProposal::exact` of the bounds it is handed (see
1667    /// `WidgetTree::layout`), so this is a toast that wraps rather than one
1668    /// drawn off the edge of what a person can touch.
1669    #[test]
1670    fn an_oversized_toast_is_shrunk_to_the_usable_area() {
1671        let ltr = inset_usable(LayoutDirection::LeftToRight);
1672        let corner = |corner| {
1673            placed(
1674                OverlayPlacement::ViewportCorner {
1675                    corner,
1676                    margin: Vec2::ZERO,
1677                },
1678                Size::new(760.0, 560.0),
1679                Rect::ZERO,
1680                inset_viewport(),
1681                LayoutDirection::LeftToRight,
1682            )
1683        };
1684
1685        // Corner-independent, because it is the size that is capped and a size
1686        // does not know which corner it was aligned to.
1687        for c in [
1688            Corner::TopLeading,
1689            Corner::TopTrailing,
1690            Corner::BottomLeading,
1691            Corner::BottomTrailing,
1692        ] {
1693            assert_eq!(
1694                corner(c).size(),
1695                ltr.size(),
1696                "{c:?}: shrunk to the usable area, not left at 760 x 560"
1697            );
1698        }
1699
1700        // And at the leading corner, where a panel that fills the area has
1701        // nowhere left to slide, all four edges land on the area's own.
1702        let top_leading = corner(Corner::TopLeading);
1703        assert_eq!((top_leading.x, top_leading.y), (ltr.x, ltr.y));
1704        assert_eq!(
1705            (top_leading.right(), top_leading.bottom()),
1706            (ltr.right(), ltr.bottom()),
1707            "clear of the rounded corner and of the home indicator"
1708        );
1709    }
1710
1711    /// The contact-avoiding fallback — the one branch that gives up on clearing
1712    /// the finger — still clamps **both** axes.
1713    ///
1714    /// Four strips are tried before it, and here none fits: the panel is very
1715    /// nearly the whole usable area, so no placement exists that does not
1716    /// overlap the contact. Accepting the overlap is the documented cost. What
1717    /// the last line is *for* is the rest of the promise — that the panel is
1718    /// still somewhere a person can see.
1719    ///
1720    /// Drop either clamp and what lands is the raw quadrant coordinate that fed
1721    /// it: `x` becomes the inline-start start, `380 - 4 - 700 = -324`, a menu
1722    /// with 324 dp off the left of the window and its leading half — where the
1723    /// first row is — gone; `y` becomes the below-the-contact start, 324,
1724    /// putting the last 244 dp of it below the bottom of the window. Neither is
1725    /// caught by the four-strip cases, which never reach the fallback, nor by
1726    /// [`a_coarse_menu_avoids_the_contact_without_leaving_the_usable_area`],
1727    /// whose menu is placed by `fits_x` and never touches a clamp.
1728    #[test]
1729    fn a_menu_too_big_to_clear_the_contact_is_still_clamped_on_both_axes() {
1730        let ltr = inset_usable(LayoutDirection::LeftToRight);
1731        let avoid = Rect::new(380.0, 280.0, 40.0, 40.0);
1732        let bounds = placed(
1733            OverlayPlacement::AtPointerAvoiding {
1734                point: avoid.center(),
1735                avoid,
1736            },
1737            // 700 x 520 against a 700 x 530 usable area: it fits on neither
1738            // side of the contact, on either axis.
1739            Size::new(700.0, 520.0),
1740            Rect::ZERO,
1741            inset_viewport(),
1742            LayoutDirection::LeftToRight,
1743        );
1744
1745        assert_eq!(
1746            bounds.x, ltr.x,
1747            "clamped to the usable leading edge, not left at -324"
1748        );
1749        assert_eq!(
1750            bounds.y, 50.0,
1751            "and lifted off the usable bottom, not left at 324"
1752        );
1753        assert_eq!(
1754            (bounds.right(), bounds.bottom()),
1755            (ltr.right(), ltr.bottom())
1756        );
1757        assert!(
1758            bounds.x >= ltr.x
1759                && bounds.right() <= ltr.right()
1760                && bounds.y >= ltr.y
1761                && bounds.bottom() <= ltr.bottom(),
1762            "wholly inside the usable area: {bounds:?}"
1763        );
1764
1765        // The overlap is the price, and this is the only branch that pays it.
1766        assert!(
1767            overlaps(bounds, avoid),
1768            "at this size there is nothing that clears the contact: {bounds:?}"
1769        );
1770    }
1771
1772    /// `BelowPreferred` measures the room it **shrinks into** against the
1773    /// usable area, not only the side it picks.
1774    ///
1775    /// [`a_below_preferred_panel_is_placed_against_the_usable_area_not_the_window`]
1776    /// covers the choice, and every panel in it then fits on the side chosen —
1777    /// so the height that comes out is the height that was asked for, and the
1778    /// rectangle the shrink measured against never shows. These two do not fit
1779    /// on the side they pick, which makes `height` a report of the room itself.
1780    ///
1781    /// Flipping up: a 400 dp list over an anchor at y = 400 has 356 dp of
1782    /// usable room above and 396 dp of window. It is pinned under the notch at
1783    /// 40 and cut to 356. Measured against the window it starts at 0 — behind
1784    /// the notch, first row unreadable — and is drawn 40 dp taller than the
1785    /// room it has.
1786    ///
1787    /// Dropping down: a 500 dp list under an anchor at y = 100 takes the
1788    /// roomier side and is cut to the 442 dp above the home indicator, not to
1789    /// the 472 dp above the bottom of the glass.
1790    #[test]
1791    fn a_below_preferred_panel_shrinks_to_the_usable_room_not_the_window() {
1792        let ltr = inset_usable(LayoutDirection::LeftToRight);
1793
1794        let flipped_up = placed(
1795            OverlayPlacement::BelowPreferred,
1796            Size::new(300.0, 400.0),
1797            Rect::new(100.0, 400.0, 60.0, 24.0),
1798            inset_viewport(),
1799            LayoutDirection::LeftToRight,
1800        );
1801        assert_eq!(flipped_up.y, ltr.y, "pinned below the notch, not to y = 0");
1802        assert_eq!(
1803            flipped_up.height, 356.0,
1804            "and cut to the room between the notch and the anchor"
1805        );
1806        assert_eq!(
1807            flipped_up.bottom(),
1808            396.0,
1809            "which still leaves the anchor visible"
1810        );
1811
1812        let dropped_down = placed(
1813            OverlayPlacement::BelowPreferred,
1814            Size::new(500.0, 500.0),
1815            Rect::new(100.0, 100.0, 60.0, 24.0),
1816            inset_viewport(),
1817            LayoutDirection::LeftToRight,
1818        );
1819        assert_eq!(
1820            dropped_down.y, 128.0,
1821            "below the anchor, which is the roomier side"
1822        );
1823        assert_eq!(
1824            dropped_down.height, 442.0,
1825            "cut at the home indicator, not at the bottom of the window"
1826        );
1827        assert_eq!(dropped_down.bottom(), ltr.bottom());
1828    }
1829
1830    /// A drop-down is never narrower than the control it drops out of.
1831    ///
1832    /// [`at_least_anchor_width`] is the rule and it governs all three
1833    /// anchor-aligned placements. Nothing else in this file notices it: every
1834    /// other anchor-aligned case above gives its panel a width at least the
1835    /// anchor's — a 120 dp list off an 80 dp field, a 300 dp panel off a 60 dp
1836    /// control, a 300 dp list off a 300 dp combo box — so the floor sits at or
1837    /// under the content and `max` is the identity. Here it is the other way
1838    /// round — an 80 dp list under a 240 dp combo box comes out 240 wide and
1839    /// flush at both of the field's edges, which is what makes it read as the
1840    /// field opening rather than as a chit parked under its leading third.
1841    #[test]
1842    fn a_short_list_is_widened_to_the_control_that_opened_it() {
1843        let combo = Rect::new(100.0, 200.0, 240.0, 24.0);
1844        let window = (800.0, 600.0);
1845        let at = |placement| {
1846            placed(
1847                placement,
1848                Size::new(80.0, 120.0),
1849                combo,
1850                window,
1851                LayoutDirection::LeftToRight,
1852            )
1853        };
1854
1855        for placement in [
1856            OverlayPlacement::Below,
1857            OverlayPlacement::Above,
1858            OverlayPlacement::BelowPreferred,
1859        ] {
1860            let bounds = at(placement.clone());
1861            assert_eq!(
1862                bounds.width, combo.width,
1863                "{placement:?}: widened to the control, not left at 80"
1864            );
1865            assert_eq!(
1866                bounds.x, combo.x,
1867                "{placement:?}: and still on the control's leading edge"
1868            );
1869            assert_eq!(
1870                bounds.right(),
1871                combo.right(),
1872                "{placement:?}: so it is flush at both"
1873            );
1874        }
1875
1876        // A floor, not a width: content wider than its anchor keeps its own.
1877        let wide = placed(
1878            OverlayPlacement::Below,
1879            Size::new(420.0, 120.0),
1880            combo,
1881            window,
1882            LayoutDirection::LeftToRight,
1883        );
1884        assert_eq!(wide.width, 420.0, "wider than the anchor, so untouched");
1885    }
1886
1887    // -----------------------------------------------------------------
1888    // Branches whose neighbours' assertions cannot see them
1889    //
1890    // Each of the following is a live mechanism that survived its own
1891    // deletion with the rest of this file green — a direction branch two
1892    // arms of which coincide at every existing fixture, a preference order
1893    // never asked to choose, a clamp on a path no case reaches, a shrink on
1894    // the one of three placements nobody sized past its area.
1895    // -----------------------------------------------------------------
1896
1897    /// An RTL panel hangs off the anchor's physical **right** edge, and that
1898    /// is a choice something has to make — the existing RTL row cannot see it
1899    /// being made.
1900    ///
1901    /// [`a_below_panel_is_clamped_into_the_usable_area_not_the_window`] does
1902    /// place a panel under RTL, but its trailing clamp maps both arms of
1903    /// [`leading_aligned_x_in`] onto the same number, so replacing the RTL
1904    /// branch with the LTR one leaves it green. Here they diverge: a 300 dp
1905    /// panel under a 60 dp control at x = 100 hangs its right edge on the
1906    /// control's, which starts it at -140 and pins it to the leading edge at
1907    /// 0. Take the LTR arm and it starts at 100 instead — running from the
1908    /// control's *trailing* edge in Arabic, with 240 dp of it out past the
1909    /// far side of the thing it belongs to.
1910    #[test]
1911    fn an_rtl_panel_hangs_off_the_anchors_right_edge_not_its_left() {
1912        let anchor = Rect::new(100.0, 200.0, 60.0, 24.0);
1913        let panel = Size::new(300.0, 120.0);
1914        let at = |direction| {
1915            placed(
1916                OverlayPlacement::Below,
1917                panel,
1918                anchor,
1919                (800.0, 600.0),
1920                direction,
1921            )
1922        };
1923
1924        assert_eq!(
1925            at(LayoutDirection::RightToLeft).x,
1926            0.0,
1927            "aligned to the anchor's right edge (100 + 60 - 300 = -140), then \
1928             pinned to the leading edge"
1929        );
1930        // Both arms fit here, so nothing but the branch decides between them.
1931        assert_eq!(
1932            at(LayoutDirection::LeftToRight).x,
1933            anchor.x,
1934            "which is 100 dp from where the LTR arm puts it"
1935        );
1936    }
1937
1938    /// An RTL submenu opens to the **left** of the row that raised it, and the
1939    /// existing RTL submenu row cannot see that either.
1940    ///
1941    /// [`a_submenu_flips_and_clamps_against_the_usable_area_not_the_window`]
1942    /// exercises RTL-specific arithmetic — its `area.x` bound is load-bearing
1943    /// — but at that fixture the LTR arm's rightward `x` happens to land on
1944    /// the same number as the RTL arm's leftward one, so forcing the LTR
1945    /// branch leaves it green. Here the two are 264 dp apart, and both fit, so
1946    /// the answer is the branch and nothing else.
1947    #[test]
1948    fn an_rtl_submenu_opens_to_the_left_of_its_parent_row() {
1949        let anchor = Rect::new(400.0, 200.0, 60.0, 24.0);
1950        let submenu = Size::new(200.0, 100.0);
1951        let at = |direction| {
1952            placed(
1953                OverlayPlacement::TrailingEdge,
1954                submenu,
1955                anchor,
1956                (800.0, 600.0),
1957                direction,
1958            )
1959        };
1960
1961        assert_eq!(
1962            at(LayoutDirection::RightToLeft).x,
1963            198.0,
1964            "trailing is leftward in Arabic: 400 - 200 - 2"
1965        );
1966        assert_eq!(
1967            at(LayoutDirection::LeftToRight).x,
1968            462.0,
1969            "and rightward in English: 400 + 60 + 2"
1970        );
1971    }
1972
1973    /// A menu that fits beside the contact takes the room **below** the finger
1974    /// before the room above it.
1975    ///
1976    /// [`avoiding_bounds`]' own doc gives the order — 'below it, then above
1977    /// it' — and nothing exercises it: every other avoidance case either fits
1978    /// below on the first ask, so the second candidate never runs, or is too
1979    /// large for any strip and goes to the fallback, so the inner branch never
1980    /// runs. A 100 x 100 menu beside a 40 dp contact at y = 300 fits both
1981    /// ways, 344 below and 196 above. Swapped, it opens *above* the contact —
1982    /// on the side the hand is coming from, which is the arrangement this
1983    /// placement exists to avoid.
1984    ///
1985    /// The second case is the branch's own clamp. A 100 x 560 menu fits on
1986    /// neither side of the contact, and the raw below-the-contact `y` it falls
1987    /// back to would be 344, putting its last 304 dp past the bottom of the
1988    /// window.
1989    #[test]
1990    fn a_menu_beside_the_contact_prefers_the_room_below_it() {
1991        let avoid = Rect::new(400.0, 300.0, 40.0, 40.0);
1992        let beside = |size| {
1993            placed(
1994                OverlayPlacement::AtPointerAvoiding {
1995                    point: avoid.center(),
1996                    avoid,
1997                },
1998                size,
1999                Rect::ZERO,
2000                (800.0, 600.0),
2001                LayoutDirection::LeftToRight,
2002            )
2003        };
2004
2005        let fits_either = beside(Size::new(100.0, 100.0));
2006        assert_eq!(
2007            fits_either.x, 296.0,
2008            "inline-start of the contact, which is what puts us in this branch"
2009        );
2010        assert_eq!(
2011            fits_either.y, 344.0,
2012            "below the contact, not at 196 above it"
2013        );
2014
2015        let fits_neither = beside(Size::new(100.0, 560.0));
2016        assert_eq!(fits_neither.x, 296.0, "still inline-start of the contact");
2017        assert_eq!(
2018            fits_neither.y, 40.0,
2019            "lifted until it fits, not left at 344 with its foot at 904"
2020        );
2021        assert_eq!(fits_neither.bottom(), 600.0);
2022    }
2023
2024    /// A menu too wide to sit beside the contact drops **below** it, and is
2025    /// clamped onto the screen on the axis the strip did not decide.
2026    ///
2027    /// This is the same pair of promises as the branch above, one branch
2028    /// later, and equally unasked: a 700 dp menu clears neither side of a
2029    /// contact at x = 400 in an 800 dp window, so the horizontal strips are
2030    /// out and the vertical ones answer. The order is the same one
2031    /// [`avoiding_bounds`] documents — below the finger before above it — and
2032    /// the `x` that comes with it is the inline-start start of -304, which is
2033    /// 304 dp off the leading edge of the window with the whole leading half
2034    /// of the menu, where the first row is, gone.
2035    ///
2036    /// [`a_menu_too_big_to_clear_the_contact_is_still_clamped_on_both_axes`]
2037    /// covers the same clamp in the final fallback; this one is the branch
2038    /// before it, which no case reached.
2039    #[test]
2040    fn a_menu_too_wide_to_sit_beside_the_contact_drops_below_it_and_stays_on_screen() {
2041        let avoid = Rect::new(400.0, 300.0, 40.0, 40.0);
2042        let bounds = placed(
2043            OverlayPlacement::AtPointerAvoiding {
2044                point: avoid.center(),
2045                avoid,
2046            },
2047            Size::new(700.0, 100.0),
2048            Rect::ZERO,
2049            (800.0, 600.0),
2050            LayoutDirection::LeftToRight,
2051        );
2052
2053        assert_eq!(bounds.y, 344.0, "below the contact, not at 196 above it");
2054        assert_eq!(bounds.x, 0.0, "clamped onto the screen, not left at -304");
2055        assert_eq!(bounds.right(), 700.0);
2056        assert!(
2057            !overlaps(bounds, avoid),
2058            "and the clamp did not cost the clearance: {bounds:?}"
2059        );
2060    }
2061
2062    /// A selection toolbar that fits neither above the selection nor below it
2063    /// is pinned to the top of the area, not left off it.
2064    ///
2065    /// The two existing flip cases each have somewhere to go — above at
2066    /// [`a_selection_toolbar_floats_centred_above_the_selection`], below at
2067    /// [`a_selection_toolbar_flips_below_at_the_top_of_the_window`] — so the
2068    /// last line of [`above_selection_bounds`], which is what runs when
2069    /// neither is available, has never been reached. A 580 dp toolbar over a
2070    /// selection on the first line has 2 dp above it and 562 below: its
2071    /// unfloored `y` is -578, which is the whole toolbar bar two rows above
2072    /// the top of the screen.
2073    ///
2074    /// The inset case says which rectangle the floor is: under a 40 dp notch
2075    /// the answer is 40, not 0.
2076    #[test]
2077    fn a_selection_toolbar_that_fits_on_neither_side_is_pinned_to_the_top() {
2078        let toolbar = Size::new(200.0, 580.0);
2079        let over = |selection, viewport: OverlayViewport| {
2080            placed(
2081                OverlayPlacement::AboveSelection { selection },
2082                toolbar,
2083                Rect::ZERO,
2084                viewport,
2085                LayoutDirection::LeftToRight,
2086            )
2087        };
2088
2089        let window = over(
2090            Rect::new(300.0, 10.0, 100.0, 20.0),
2091            OverlayViewport::from((800.0, 600.0)),
2092        );
2093        assert_eq!(window.y, 0.0, "pinned to the top, not left at -578");
2094
2095        let ltr = inset_usable(LayoutDirection::LeftToRight);
2096        let inset = over(Rect::new(300.0, 60.0, 100.0, 20.0), inset_viewport());
2097        assert_eq!(
2098            inset.y, ltr.y,
2099            "and the top it is pinned to is the usable one, under the notch"
2100        );
2101    }
2102
2103    /// A snackbar bigger than the usable area is shrunk to it, and never
2104    /// starts above its top edge.
2105    ///
2106    /// [`a_snackbar_sits_above_the_home_indicator`] pins the 24 dp bottom
2107    /// margin but asks for neither: its snackbar is 300 x 60 with hundreds of
2108    /// dp to spare, so the `min` against the area and the `max` against its
2109    /// top are both the identity there and would go on being the identity if
2110    /// they were deleted. `BottomCenter` is the third of the three placements
2111    /// that shrink — [`an_oversized_toast_is_shrunk_to_the_usable_area`]
2112    /// covers `ViewportCorner`'s copy and
2113    /// [`a_centered_modal_is_centred_and_shrunk_inside_the_usable_area`]
2114    /// covers `Centered`'s — and it was the one left unasked.
2115    ///
2116    /// Measured against the *window*, a 760 x 560 snackbar survives at full
2117    /// size, 60 dp past the rounded corner and 30 into the home indicator, and
2118    /// the 24 dp margin under it then puts its top at -14: a notification whose
2119    /// first line is 14 dp above the top of the window, and 54 above the top
2120    /// of the area it was supposed to stay inside.
2121    ///
2122    /// Its **x** is the same story on the other axis. Centring subtracts the
2123    /// content's width from the area's, and a snackbar wider than the area
2124    /// makes that difference negative: 760 in a usable width of 700 halves to
2125    /// -30, so without the floor the shrunk snackbar starts at 30 — 30 dp back
2126    /// under the sensor housing at one end, and 30 dp short of the rounded
2127    /// corner at the other. The floor puts it flush at the usable leading
2128    /// edge, which for something exactly as wide as the area is the only
2129    /// position that fills it.
2130    ///
2131    /// The shrink is real, not a clip — the overlay pass lays content out at
2132    /// `SizeProposal::exact` of the bounds it is handed (see
2133    /// `WidgetTree::layout`) — so this is a snackbar that wraps rather than
2134    /// one drawn off the glass.
2135    #[test]
2136    fn an_oversized_snackbar_is_shrunk_to_the_usable_area() {
2137        let ltr = inset_usable(LayoutDirection::LeftToRight);
2138        let bounds = placed(
2139            OverlayPlacement::BottomCenter,
2140            Size::new(760.0, 560.0),
2141            Rect::ZERO,
2142            inset_viewport(),
2143            LayoutDirection::LeftToRight,
2144        );
2145
2146        assert_eq!(
2147            (bounds.width, bounds.height),
2148            (ltr.width, ltr.height),
2149            "shrunk to the usable area, not left at 760 x 560"
2150        );
2151        assert_eq!(
2152            bounds.y, ltr.y,
2153            "and floored at the top of that area, not lifted to -14"
2154        );
2155        assert_eq!(
2156            bounds.x, ltr.x,
2157            "and flush against its leading edge, not pulled back to 30"
2158        );
2159        assert_eq!(
2160            bounds.right(),
2161            ltr.right(),
2162            "which is also what puts its far edge on the rounded corner"
2163        );
2164        assert!(
2165            bounds.y >= ltr.y && bounds.bottom() <= ltr.bottom(),
2166            "wholly inside the usable area: {bounds:?}"
2167        );
2168    }
2169}