1#[derive(Clone, Copy, Debug, PartialEq)]
4pub struct Rect {
5 pub min: [f32; 2],
6 pub size: [f32; 2],
7}
8impl Rect {
9 pub fn valid(self) -> bool {
10 self.min.into_iter().chain(self.size).all(f32::is_finite)
11 && self.size.into_iter().all(|n| n >= 0.0)
12 && (0..2).all(|i| (self.min[i] + self.size[i]).is_finite())
13 }
14 pub fn contains(self, p: [f32; 2]) -> bool {
16 self.valid()
17 && (0..2).all(|i| {
18 p[i].is_finite() && p[i] >= self.min[i] && p[i] < self.min[i] + self.size[i]
19 })
20 }
21 pub fn intersect(self, other: Self) -> Option<Self> {
22 if !self.valid() || !other.valid() {
23 return None;
24 }
25 let min = std::array::from_fn(|i| self.min[i].max(other.min[i]));
26 let max: [f32; 2] =
27 std::array::from_fn(|i| (self.min[i] + self.size[i]).min(other.min[i] + other.size[i]));
28 (max[0] > min[0] && max[1] > min[1]).then_some(Self {
29 min,
30 size: [max[0] - min[0], max[1] - min[1]],
31 })
32 }
33}
34#[derive(Clone, Copy, Debug)]
35pub struct Anchors {
36 pub min: [f32; 2],
37 pub max: [f32; 2],
38 pub offset_min: [f32; 2],
39 pub offset_max: [f32; 2],
40}
41#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub enum UiError {
43 InvalidLayout,
44 DuplicateId,
45}
46impl Anchors {
47 pub fn resolve(self, parent: Rect) -> Result<Rect, UiError> {
48 if !parent.valid()
49 || !self
50 .min
51 .into_iter()
52 .chain(self.max)
53 .all(|n| n.is_finite() && (0.0..=1.0).contains(&n))
54 || !self
55 .offset_min
56 .into_iter()
57 .chain(self.offset_max)
58 .all(f32::is_finite)
59 || (0..2).any(|i| self.min[i] > self.max[i])
60 {
61 return Err(UiError::InvalidLayout);
62 }
63 let min = std::array::from_fn(|i| {
64 parent.min[i] + parent.size[i] * self.min[i] + self.offset_min[i]
65 });
66 let max: [f32; 2] = std::array::from_fn(|i| {
67 parent.min[i] + parent.size[i] * self.max[i] + self.offset_max[i]
68 });
69 let rect = Rect {
70 min,
71 size: [max[0] - min[0], max[1] - min[1]],
72 };
73 if rect.valid() {
74 Ok(rect)
75 } else {
76 Err(UiError::InvalidLayout)
77 }
78 }
79}
80pub type WidgetId = u64;
81#[derive(Clone, Copy, Debug)]
82pub struct Widget {
83 pub id: WidgetId,
84 pub rect: Rect,
85 pub clip: Option<Rect>,
86 pub enabled: bool,
87 pub focusable: bool,
88}
89impl Widget {
90 fn contains(self, point: [f32; 2]) -> bool {
91 self.enabled
92 && self.rect.contains(point)
93 && self.clip.is_none_or(|clip| clip.contains(point))
94 }
95}
96#[derive(Default)]
97pub struct UiState {
98 widgets: Vec<Widget>,
99 focus: Option<WidgetId>,
100 pressed: Option<WidgetId>,
101}
102impl UiState {
103 pub fn new() -> Self {
104 Self::default()
105 }
106 pub fn begin_frame(&mut self) {
108 self.widgets.clear();
109 }
110 pub fn add(&mut self, widget: Widget) -> Result<(), UiError> {
111 if !widget.rect.valid() || widget.clip.is_some_and(|clip| !clip.valid()) {
112 return Err(UiError::InvalidLayout);
113 }
114 if self.widgets.iter().any(|old| old.id == widget.id) {
115 return Err(UiError::DuplicateId);
116 }
117 self.widgets.push(widget);
118 Ok(())
119 }
120 pub fn end_frame(&mut self) {
122 if self.focus.is_some_and(|id| {
123 !self
124 .widgets
125 .iter()
126 .any(|w| w.id == id && w.enabled && w.focusable)
127 }) {
128 self.focus = None;
129 }
130 if self
131 .pressed
132 .is_some_and(|id| !self.widgets.iter().any(|w| w.id == id && w.enabled))
133 {
134 self.pressed = None;
135 }
136 }
137 pub fn focused(&self) -> Option<WidgetId> {
138 self.focus
139 }
140 pub fn captured(&self) -> Option<WidgetId> {
141 self.pressed
142 }
143 pub fn hit_test(&self, point: [f32; 2]) -> Option<WidgetId> {
144 self.widgets
145 .iter()
146 .rev()
147 .find(|w| w.contains(point))
148 .map(|w| w.id)
149 }
150 pub fn pointer_down(&mut self, point: [f32; 2]) -> Option<WidgetId> {
151 self.pressed = self.hit_test(point);
152 self.focus = self
153 .pressed
154 .filter(|id| self.widgets.iter().any(|w| w.id == *id && w.focusable));
155 self.pressed
156 }
157 pub fn pointer_up(&mut self, point: [f32; 2]) -> Option<WidgetId> {
159 let pressed = self.pressed.take();
160 pressed.filter(|id| self.hit_test(point) == Some(*id))
161 }
162 pub fn cancel_pointer(&mut self) {
163 self.pressed = None;
164 }
165 pub fn focus_next(&mut self, reverse: bool) -> Option<WidgetId> {
166 let count = self.widgets.len();
167 if count == 0 {
168 self.focus = None;
169 return None;
170 }
171 let current = self
172 .focus
173 .and_then(|id| self.widgets.iter().position(|w| w.id == id));
174 for step in 1..=count {
175 let index = if reverse {
176 (current.unwrap_or(0) + count - step) % count
177 } else {
178 current.map_or(step - 1, |index| (index + step) % count)
179 };
180 let w = self.widgets[index];
181 if w.enabled && w.focusable {
182 self.focus = Some(w.id);
183 return self.focus;
184 }
185 }
186 self.focus = None;
187 None
188 }
189 pub fn activate_focused(&self) -> Option<WidgetId> {
190 self.focus.filter(|id| {
191 self.widgets
192 .iter()
193 .any(|w| w.id == *id && w.enabled && w.focusable)
194 })
195 }
196}
197#[cfg(test)]
198mod tests {
199 use super::*;
200 fn widget(id: u64) -> Widget {
201 Widget {
202 id,
203 rect: Rect {
204 min: [0.0; 2],
205 size: [100.0; 2],
206 },
207 clip: None,
208 enabled: true,
209 focusable: true,
210 }
211 }
212 #[test]
213 fn anchors_resolve_stretch_and_center_after_resize() {
214 let parent = Rect {
215 min: [10.0, 20.0],
216 size: [400.0, 200.0],
217 };
218 let center = Anchors {
219 min: [0.5; 2],
220 max: [0.5; 2],
221 offset_min: [-50.0, -20.0],
222 offset_max: [50.0, 20.0],
223 }
224 .resolve(parent)
225 .unwrap();
226 assert_eq!(
227 center,
228 Rect {
229 min: [160.0, 100.0],
230 size: [100.0, 40.0]
231 }
232 );
233 let stretch = Anchors {
234 min: [0.0; 2],
235 max: [1.0; 2],
236 offset_min: [5.0; 2],
237 offset_max: [-5.0; 2],
238 }
239 .resolve(parent)
240 .unwrap();
241 assert_eq!(stretch.size, [390.0, 190.0]);
242 }
243 #[test]
244 fn topmost_clipped_widget_captures_click_and_drag_out_cancels() {
245 let mut ui = UiState::new();
246 ui.add(widget(1)).unwrap();
247 ui.add(Widget {
248 clip: Some(Rect {
249 min: [20.0; 2],
250 size: [20.0; 2],
251 }),
252 ..widget(2)
253 })
254 .unwrap();
255 assert_eq!(ui.hit_test([30.0; 2]), Some(2));
256 assert_eq!(ui.hit_test([10.0; 2]), Some(1));
257 ui.pointer_down([30.0; 2]);
258 assert_eq!(ui.pointer_up([90.0; 2]), None);
259 ui.pointer_down([30.0; 2]);
260 assert_eq!(ui.pointer_up([30.0; 2]), Some(2));
261 }
262 #[test]
263 fn keyboard_focus_skips_disabled_and_clears_removed_nodes() {
264 let mut ui = UiState::new();
265 ui.add(widget(1)).unwrap();
266 ui.add(Widget {
267 enabled: false,
268 ..widget(2)
269 })
270 .unwrap();
271 ui.add(widget(3)).unwrap();
272 assert_eq!(ui.focus_next(false), Some(1));
273 assert_eq!(ui.focus_next(false), Some(3));
274 assert_eq!(ui.focus_next(false), Some(1));
275 assert_eq!(ui.focus_next(true), Some(3));
276 ui.begin_frame();
277 ui.add(widget(1)).unwrap();
278 ui.end_frame();
279 assert_eq!(ui.focused(), None);
280 assert_eq!(ui.add(widget(1)), Err(UiError::DuplicateId));
281 }
282}