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