agg_gui/overlay_insets.rs
1//! Frame-scoped "reserved screen edges" for overlay placement.
2//!
3//! Mobile-style layouts pin chrome to the viewport edges — a button rail
4//! on the left, a control tray or the on-screen keyboard at the bottom.
5//! Anything that floats content near an anchor (tap-info cards, tooltips,
6//! popovers) must avoid those strips or it renders underneath them,
7//! looking cut off. Historically each painter hand-rolled its own clamp
8//! against its own widget bounds and knew nothing about sibling overlays
9//! — the exact bug class this module removes.
10//!
11//! Life cycle per frame:
12//!
13//! 1. [`crate::App::layout`] calls [`begin_frame`], zeroing the insets,
14//! then auto-reserves the on-screen keyboard's strip when visible (the
15//! one library-owned edge obstruction).
16//! 2. Edge-hugging overlay widgets reserve their strip during layout —
17//! wrap them in [`crate::widgets::ReserveInset`] instead of calling
18//! [`reserve`] by hand.
19//! 3. Paint-time placement code reads [`current`] — directly or through
20//! [`crate::card::anchored_rect`] — and keeps floating content inside
21//! the remaining safe area.
22//!
23//! All values are **logical units**, matching layout coordinates.
24//! `current()` is complete only after the whole tree has laid out, so
25//! consume it at paint time (which is when anchored overlays are placed).
26
27use std::cell::Cell;
28
29use crate::draw_ctx::DrawCtx;
30use crate::geometry::{Rect, Size};
31use crate::layout_props::Insets;
32
33thread_local! {
34 static RESERVED: Cell<Insets> = const {
35 Cell::new(Insets {
36 top: 0.0,
37 bottom: 0.0,
38 left: 0.0,
39 right: 0.0,
40 })
41 };
42}
43
44/// Zero the reserved insets for a new layout pass. Called by
45/// [`crate::App::layout`]; hosts driving `Widget::layout` directly (tests)
46/// call it themselves when they use reservations.
47pub fn begin_frame() {
48 RESERVED.with(|c| {
49 c.set(Insets {
50 top: 0.0,
51 bottom: 0.0,
52 left: 0.0,
53 right: 0.0,
54 })
55 });
56}
57
58/// Reserve edge strips for this frame. Each edge keeps the **max** of all
59/// reservations — two left-edge rails don't stack, the wider one wins.
60pub fn reserve(insets: Insets) {
61 RESERVED.with(|c| {
62 let cur = c.get();
63 c.set(Insets {
64 top: cur.top.max(insets.top),
65 bottom: cur.bottom.max(insets.bottom),
66 left: cur.left.max(insets.left),
67 right: cur.right.max(insets.right),
68 });
69 });
70}
71
72/// The edge strips reserved so far this frame (logical units, viewport
73/// relative). Complete once layout has finished.
74pub fn current() -> Insets {
75 RESERVED.with(|c| c.get())
76}
77
78/// Convert viewport-space insets into the local space of `container`, a
79/// rect given in absolute (viewport) logical coordinates. Each local
80/// edge is however far the corresponding reserved strip intrudes into
81/// the container — zero when the strip doesn't reach it. Pure; see
82/// [`for_paint_ctx`] for the paint-time entry point.
83pub fn clip_to(container: Rect, insets: Insets, viewport: Size) -> Insets {
84 Insets {
85 left: (insets.left - container.x).max(0.0),
86 bottom: (insets.bottom - container.y).max(0.0),
87 right: ((container.x + container.width) - (viewport.width - insets.right)).max(0.0),
88 top: ((container.y + container.height) - (viewport.height - insets.top)).max(0.0),
89 }
90}
91
92/// The frame's reserved insets expressed in the local coordinates of the
93/// widget currently painting through `ctx` (whose local size is
94/// `local_size`). Uses the context's accumulated transform, so it is
95/// exact regardless of where the widget sits in the tree — this is what
96/// paint-time overlay placement ([`crate::card`]) should feed into
97/// `anchored_rect_with_insets` when the painting widget doesn't fill the
98/// viewport.
99pub fn for_paint_ctx(ctx: &dyn DrawCtx, local_size: Size) -> Insets {
100 // root_transform maps local → root *physical* pixels (it includes the
101 // App-level DPI/UX scale); divide back to logical viewport space.
102 let t = ctx.root_transform();
103 let (mut x0, mut y0) = (0.0, 0.0);
104 let (mut x1, mut y1) = (local_size.width, local_size.height);
105 t.transform(&mut x0, &mut y0);
106 t.transform(&mut x1, &mut y1);
107 let s = crate::ux_scale::effective_scale().max(1e-6);
108 let abs = Rect::new(
109 x0.min(x1) / s,
110 y0.min(y1) / s,
111 (x1 - x0).abs() / s,
112 (y1 - y0).abs() / s,
113 );
114 clip_to(abs, current(), crate::widget::current_viewport())
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 #[test]
122 fn clip_to_converts_viewport_insets_into_container_space() {
123 let viewport = Size::new(400.0, 800.0);
124 // Sky-style container: full width, top 600 of the viewport.
125 let container = Rect::new(0.0, 200.0, 400.0, 600.0);
126 let ins = Insets {
127 left: 56.0,
128 bottom: 300.0, // keyboard: intrudes 100 into the container
129 right: 0.0,
130 top: 0.0,
131 };
132 let local = clip_to(container, ins, viewport);
133 assert_eq!(local.left, 56.0);
134 assert_eq!(local.bottom, 100.0, "only the intruding part remains");
135 assert_eq!(local.right, 0.0);
136 assert_eq!(local.top, 0.0);
137
138 // A strip that stops short of the container clips to zero.
139 let shallow = Insets {
140 bottom: 150.0,
141 ..Insets::default()
142 };
143 assert_eq!(clip_to(container, shallow, viewport).bottom, 0.0);
144 }
145
146 #[test]
147 fn reservations_max_merge_and_reset() {
148 begin_frame();
149 reserve(Insets {
150 left: 56.0,
151 bottom: 40.0,
152 ..Insets::default()
153 });
154 reserve(Insets {
155 left: 30.0,
156 bottom: 120.0,
157 ..Insets::default()
158 });
159 let r = current();
160 assert_eq!(r.left, 56.0, "wider left rail wins");
161 assert_eq!(r.bottom, 120.0, "taller bottom strip wins");
162 assert_eq!(r.right, 0.0);
163
164 begin_frame();
165 assert_eq!(current(), Insets::default(), "new frame starts clean");
166 }
167}