Skip to main content

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}