1use egui::{Rect, pos2};
15
16#[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
38const CLEARANCE: f32 = 12.0;
41
42#[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 pub fn over(viewport: Rect) -> Self {
58 SafeArea {
59 viewport,
60 inset: [0.0; Edge::ALL.len()],
61 }
62 }
63
64 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 pub fn depth(&self, edge: Edge) -> f32 {
85 self.inset[edge.slot()]
86 }
87
88 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 pub fn viewport(&self) -> Rect {
108 self.viewport
109 }
110
111 #[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 #[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 #[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 #[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 #[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 #[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 #[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}