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}