ferravex_play/
ui.rs

1//! Renderer-independent anchored rectangles, clipped hit testing, pointer capture,
2//! and keyboard focus. Rendering and accessibility adapters remain host concerns.
3#[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    /// Half-open bounds give adjacent widgets an unambiguous shared edge.
15    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    /// Rebuild widgets in back-to-front paint order; IDs preserve focus/capture.
107    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    /// Call after layout, before delivering input, to discard removed/disabled IDs.
121    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    /// Click activates only when both press and release belong to the same widget.
158    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}