Skip to main content

teksilo_core/overlay/
placement_impl.rs

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