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