Skip to main content

blockworx/shell/
insets.rs

1//! Nothing reflows the canvas, so its usable region is the viewport minus
2//! the *measured* boxes of whatever chrome is on screen — not the raw
3//! viewport, and not a table of constants that the chrome would drift away
4//! from. Docked and floating pieces alike take room from it: the difference
5//! between them is how they are drawn, by the elevation rule, not whether
6//! the drawing has to clear them.
7//!
8//! One resolver, consumed everywhere: fit-to-view frames inside
9//! [`SafeArea::region`], and an overlay that must not land under the chrome
10//! asks [`SafeArea::clamp`] for somewhere it can go. It is built fresh from
11//! each frame's rects, so opening or closing the navigator changes it the
12//! frame it happens — there is nothing to invalidate.
13
14use egui::{Rect, pos2};
15
16/// Which side of the viewport a piece of chrome takes room from.
17#[derive(Clone, Copy, PartialEq, Eq, Debug)]
18pub enum Edge {
19    Top,
20    Left,
21    Right,
22    Bottom,
23}
24
25impl Edge {
26    const ALL: [Edge; 4] = [Edge::Top, Edge::Left, Edge::Right, Edge::Bottom];
27
28    fn slot(self) -> usize {
29        match self {
30            Edge::Top => 0,
31            Edge::Left => 1,
32            Edge::Right => 2,
33            Edge::Bottom => 3,
34        }
35    }
36}
37
38/// How much clear canvas the region keeps beyond the chrome's own box, so the
39/// drawing stops short of the chrome rather than touching it.
40const CLEARANCE: f32 = 12.0;
41
42/// The part of the canvas viewport no chrome covers.
43#[derive(Clone, Copy, PartialEq, Debug)]
44pub struct SafeArea {
45    viewport: Rect,
46    inset: [f32; Edge::ALL.len()],
47}
48
49impl Default for SafeArea {
50    fn default() -> Self {
51        SafeArea::over(Rect::NOTHING)
52    }
53}
54
55impl SafeArea {
56    /// A viewport with nothing over it yet.
57    pub fn over(viewport: Rect) -> Self {
58        SafeArea {
59            viewport,
60            inset: [0.0; Edge::ALL.len()],
61        }
62    }
63
64    /// Record a piece of chrome that laid out at `rect`, hanging off `edge`.
65    /// Pieces that share an edge take the deepest of them, since the region
66    /// has to clear both.
67    pub fn covered_by(&mut self, edge: Edge, rect: Rect) {
68        if !rect.is_positive() || !self.viewport.is_positive() {
69            return;
70        }
71        let depth = match edge {
72            Edge::Top => rect.max.y - self.viewport.min.y,
73            Edge::Left => rect.max.x - self.viewport.min.x,
74            Edge::Right => self.viewport.max.x - rect.min.x,
75            Edge::Bottom => self.viewport.max.y - rect.min.y,
76        };
77        let slot = &mut self.inset[edge.slot()];
78        *slot = slot.max(depth);
79    }
80
81    /// How deep the chrome measured on `edge` reaches into the viewport, with
82    /// no clearance added — where the next piece along that edge begins. The
83    /// navigator hangs from the top bar's own bottom, not from a gap below it.
84    pub fn depth(&self, edge: Edge) -> f32 {
85        self.inset[edge.slot()]
86    }
87
88    /// The canvas region the chrome leaves clear. A window too small for its
89    /// own chrome yields the whole viewport rather than nothing: a model that
90    /// lands under a bar is a blemish, and one that cannot be framed at all
91    /// is a bug.
92    pub fn region(&self) -> Rect {
93        let clear = |depth: f32| if depth > 0.0 { depth + CLEARANCE } else { 0.0 };
94        let [top, left, right, bottom] = self.inset.map(clear);
95        let region = Rect::from_min_max(
96            pos2(self.viewport.min.x + left, self.viewport.min.y + top),
97            pos2(self.viewport.max.x - right, self.viewport.max.y - bottom),
98        );
99        if region.is_positive() {
100            region
101        } else {
102            self.viewport
103        }
104    }
105
106    /// The viewport the region was measured against.
107    pub fn viewport(&self) -> Rect {
108        self.viewport
109    }
110
111    /// Move `rect` inside [`Self::region`] without resizing it — where an
112    /// overlay has to go so it lands clear of the chrome. A rect too big for
113    /// the region is aligned to its top-left corner, which is the only
114    /// placement that keeps its own leading edge readable.
115    #[cfg_attr(
116        not(test),
117        allow(
118            dead_code,
119            reason = "the selection overlay is Phase G; the resolver is built once, here"
120        )
121    )]
122    pub fn clamp(&self, rect: Rect) -> Rect {
123        use crate::canvas::convert::{IntoEgui as _, IntoGeom as _};
124        blockworx_kernel::bar::clamp(rect.geom(), self.region().geom()).egui()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use egui::vec2;
132
133    fn viewport() -> Rect {
134        Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 800.0))
135    }
136
137    /// A piece of chrome takes room from its own edge and no other.
138    #[test]
139    fn each_edge_is_inset_by_the_chrome_that_hangs_from_it() {
140        let mut safe = SafeArea::over(viewport());
141        safe.covered_by(
142            Edge::Left,
143            Rect::from_min_size(pos2(16.0, 300.0), vec2(64.0, 200.0)),
144        );
145        let region = safe.region();
146        assert_eq!(
147            region.min.x,
148            16.0 + 64.0 + CLEARANCE,
149            "the left edge clears the cluster",
150        );
151        assert_eq!(
152            (region.min.y, region.max.x, region.max.y),
153            (viewport().min.y, viewport().max.x, viewport().max.y),
154            "a left-edge piece moved an edge it does not hang from",
155        );
156    }
157
158    /// Two pieces on one edge: the region clears the deeper of them, so a
159    /// short pill beside a tall one cannot pull the inset back.
160    #[test]
161    fn pieces_sharing_an_edge_take_the_deepest_of_them() {
162        let mut safe = SafeArea::over(viewport());
163        safe.covered_by(
164            Edge::Top,
165            Rect::from_min_size(pos2(16.0, 16.0), vec2(200.0, 52.0)),
166        );
167        let deep = safe.region().min.y;
168        safe.covered_by(
169            Edge::Top,
170            Rect::from_min_size(pos2(700.0, 16.0), vec2(120.0, 30.0)),
171        );
172        assert_eq!(
173            safe.region().min.y,
174            deep,
175            "a shallower piece pulled the inset back",
176        );
177    }
178
179    /// The depth is the chrome's own reach, with no clearance in it: the
180    /// navigator hangs from where the top bar ends, and the canvas keeps its
181    /// air from the region instead.
182    #[test]
183    fn the_depth_is_where_the_chrome_ends_and_the_region_is_the_air_after_it() {
184        let mut safe = SafeArea::over(viewport());
185        safe.covered_by(
186            Edge::Top,
187            Rect::from_min_size(pos2(0.0, 0.0), vec2(1000.0, 54.0)),
188        );
189        assert_eq!(safe.depth(Edge::Top), 54.0);
190        assert_eq!(safe.region().min.y, 54.0 + CLEARANCE);
191        assert_eq!(
192            safe.depth(Edge::Right),
193            0.0,
194            "an edge nothing drew on has no depth",
195        );
196    }
197
198    /// The load-bearing case: chrome that has *not* drawn takes no room, so
199    /// closing the navigator gives the canvas its width back in the same frame.
200    #[test]
201    fn chrome_that_did_not_draw_insets_nothing() {
202        let open = {
203            let mut safe = SafeArea::over(viewport());
204            safe.covered_by(
205                Edge::Right,
206                Rect::from_min_size(pos2(640.0, 16.0), vec2(344.0, 768.0)),
207            );
208            safe.region()
209        };
210        let shut = SafeArea::over(viewport()).region();
211        assert!(
212            open.width() < shut.width(),
213            "precondition: the navigator took room when it was open",
214        );
215        assert_eq!(
216            shut,
217            viewport(),
218            "a closed navigator still insets the canvas"
219        );
220    }
221
222    /// Clamping moves an overlay inside the region without changing its size.
223    #[test]
224    fn clamping_slides_a_rect_inside_the_region_whole() {
225        let mut safe = SafeArea::over(viewport());
226        safe.covered_by(
227            Edge::Top,
228            Rect::from_min_size(pos2(16.0, 16.0), vec2(200.0, 52.0)),
229        );
230        safe.covered_by(
231            Edge::Left,
232            Rect::from_min_size(pos2(16.0, 300.0), vec2(64.0, 200.0)),
233        );
234        let region = safe.region();
235        let stray = Rect::from_min_size(pos2(-40.0, -40.0), vec2(120.0, 40.0));
236        assert!(
237            !region.contains_rect(stray),
238            "precondition: the overlay starts under the chrome",
239        );
240        let landed = safe.clamp(stray);
241        assert_eq!(landed.size(), stray.size(), "clamping resized the overlay");
242        assert!(
243            region.contains_rect(landed),
244            "the overlay landed at {landed:?}, outside {region:?}",
245        );
246    }
247
248    /// A window smaller than its own chrome still frames something.
249    #[test]
250    fn a_region_the_chrome_swallowed_falls_back_to_the_viewport() {
251        let tiny = Rect::from_min_size(pos2(0.0, 0.0), vec2(80.0, 60.0));
252        let mut safe = SafeArea::over(tiny);
253        safe.covered_by(Edge::Left, Rect::from_min_size(pos2(0.0, 0.0), tiny.size()));
254        safe.covered_by(
255            Edge::Right,
256            Rect::from_min_size(pos2(0.0, 0.0), tiny.size()),
257        );
258        assert_eq!(safe.region(), tiny);
259    }
260}