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