Skip to main content

quadraui/
interaction.rs

1//! `InteractionState` — a single hover/pressed store keyed by
2//! [`WidgetId`].
3//!
4//! ## Why this exists
5//!
6//! Before this type, every primitive that wanted hover/pressed
7//! highlighting grew its own bespoke tracker:
8//! [`crate::compose::ToolbarHoverTracker`] for [`crate::Toolbar`],
9//! [`crate::compose::StatusBarInteraction`] for [`crate::StatusBar`],
10//! and a handful of examples that inlined two `Option<WidgetId>`
11//! fields by hand. Each reimplements the same bookkeeping, and each
12//! is scoped to a single primitive even though hover/pressed state
13//! is a generic, per-frame concept that applies to any widget with
14//! an id.
15//!
16//! `InteractionState` is the one generic store: a single instance can
17//! track the hovered/pressed widget across an entire frame's worth of
18//! primitives — a toolbar, a status bar, a sidebar panel — because
19//! it's keyed by [`WidgetId`], not tied to one primitive's layout
20//! type.
21//!
22//! ## The `Backend` trait
23//!
24//! Every `Backend` method that took a `WidgetId`-keyed hover/pressed
25//! pair now has an `*_interactive` twin that takes one
26//! `&InteractionState` instead, and **the twin is the method each
27//! backend implements**:
28//!
29//! | positional (deprecated) | implemented |
30//! |---|---|
31//! | `draw_toolbar(rect, bar, hovered_id, pressed_id)` | [`crate::Backend::draw_toolbar_interactive`] |
32//! | `draw_status_bar(rect, bar, hovered_id, pressed_id)` | [`crate::Backend::draw_status_bar_interactive`] |
33//! | `draw_sidebar_panel(rect, panel, hovered_toolbar_id, pressed_toolbar_id)` | [`crate::Backend::draw_sidebar_panel_interactive`] |
34//!
35//! The positional names survive as `#[deprecated]` trait defaults that
36//! rebuild an `InteractionState` via [`InteractionState::from_parts`]
37//! and forward.
38//!
39//! ## Methods that remain positional
40//!
41//! Four `Backend` methods still take a positional hover argument, keyed
42//! by position within a collection rather than [`WidgetId`]:
43//!
44//! - `draw_activity_bar(.., hovered_idx: Option<usize>)`
45//! - `draw_tab_bar(.., hovered_close_tab: Option<usize>)`
46//! - `draw_data_table(.., hovered_idx: Option<usize>)`
47//! - `draw_chart(.., hovered_point: Option<(usize, usize)>)`
48//!
49//! These would need stable ids for their items before moving into
50//! `InteractionState`.
51//!
52//! ## Ownership
53//!
54//! Every consumer owns and feeds its own `InteractionState` instance
55//! by hand inside its own `AppLogic::handle`. The runner does not hold
56//! a centralized instance.
57//!
58//! ## Usage
59//!
60//! ```
61//! use quadraui::{mouse_moved, InteractionState, WidgetId};
62//!
63//! let mut interaction = InteractionState::new();
64//!
65//! // On every event, feed it through with a hit-test closure that
66//! // knows how to resolve a screen position to a widget id (built
67//! // from whatever layout the app already computed to paint).
68//! let event = mouse_moved(5.0, 0.0, Default::default());
69//! let hit_test = |x: f32, _y: f32| {
70//!     if x < 10.0 {
71//!         Some(WidgetId::new("demo:button"))
72//!     } else {
73//!         None
74//!     }
75//! };
76//! if interaction.handle_mouse(&event, hit_test) {
77//!     // redraw
78//! }
79//!
80//! // At paint time, hand the whole state to the rasteriser — one
81//! // argument, not two positional slots:
82//! // backend.draw_toolbar_interactive(rect, &bar, &interaction);
83//! assert_eq!(interaction.hovered(), Some(&WidgetId::new("demo:button")));
84//! ```
85//!
86//! `examples/common/toolbar_app.rs` demonstrates the whole loop
87//! end-to-end against a real backend, and
88//! `tests/tui_example_driver.rs`'s
89//! `toolbar_hover_paints_hover_background_at_the_cursor` asserts the
90//! *painted* consequence.
91
92use crate::event::{MouseButton, UiEvent};
93use crate::types::WidgetId;
94
95/// Tracks which widget is hovered and which is pressed, keyed by
96/// [`WidgetId`] rather than by index or raw coordinates.
97///
98/// Cheap to keep one per frame (or one per app, reused every frame) —
99/// two `Option<WidgetId>` slots plus whatever hit-test the caller
100/// supplies to [`Self::handle_mouse`].
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub struct InteractionState {
103    hovered: Option<WidgetId>,
104    pressed: Option<WidgetId>,
105}
106
107impl InteractionState {
108    /// Construct an empty state — nothing hovered, nothing pressed.
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Build a state directly from an owned hovered / pressed pair.
114    ///
115    /// This is the adapter the deprecated positional `Backend::draw_*`
116    /// shims use to forward `(hovered_id, pressed_id)` into their
117    /// `*_interactive` twins, and it is equally useful for
118    /// a host mid-migration that still keeps the two ids in separate
119    /// fields.
120    pub fn from_parts(hovered: Option<WidgetId>, pressed: Option<WidgetId>) -> Self {
121        Self { hovered, pressed }
122    }
123
124    /// The currently hovered widget, if any.
125    pub fn hovered(&self) -> Option<&WidgetId> {
126        self.hovered.as_ref()
127    }
128
129    /// The currently pressed widget, if any.
130    pub fn pressed(&self) -> Option<&WidgetId> {
131        self.pressed.as_ref()
132    }
133
134    /// Whether `id` is the currently hovered widget.
135    pub fn is_hovered(&self, id: &WidgetId) -> bool {
136        self.hovered.as_ref() == Some(id)
137    }
138
139    /// Whether `id` is the currently pressed widget.
140    pub fn is_pressed(&self, id: &WidgetId) -> bool {
141        self.pressed.as_ref() == Some(id)
142    }
143
144    /// Set the hovered widget directly. Returns `true` if it changed
145    /// (caller should redraw).
146    pub fn set_hovered(&mut self, id: Option<WidgetId>) -> bool {
147        if self.hovered != id {
148            self.hovered = id;
149            true
150        } else {
151            false
152        }
153    }
154
155    /// Set the pressed widget directly. Returns `true` if it changed
156    /// (caller should redraw).
157    pub fn set_pressed(&mut self, id: Option<WidgetId>) -> bool {
158        if self.pressed != id {
159            self.pressed = id;
160            true
161        } else {
162            false
163        }
164    }
165
166    /// Clear both hovered and pressed state — e.g. on `MouseLeft`, on
167    /// window blur, or when the widget tree they refer to is torn
168    /// down. Returns `true` if anything changed.
169    pub fn clear(&mut self) -> bool {
170        let changed = self.hovered.is_some() || self.pressed.is_some();
171        self.hovered = None;
172        self.pressed = None;
173        changed
174    }
175
176    /// Update hovered/pressed state from a raw [`UiEvent`].
177    ///
178    /// Exactly three event shapes do anything; everything else returns
179    /// `false` without touching state.
180    ///
181    /// | event | effect | uses `hit_test`? |
182    /// |---|---|---|
183    /// | [`UiEvent::MouseMoved`] | `hovered = hit_test(pos)` | **always** — `MouseMoved` never carries a resolved `widget` |
184    /// | [`UiEvent::MouseDown`] (left only) | `pressed = event.widget, else hit_test(pos)` | only when `widget` is `None` |
185    /// | [`UiEvent::MouseUp`] (left only) | `pressed = None` | **never** — release always clears, wherever it lands |
186    ///
187    /// `hit_test` resolves a screen position to the widget under it;
188    /// callers supply it from whatever layout they already computed to
189    /// paint (a `ToolbarLayout::hit_test`, a `StatusBarLayout` lookup,
190    /// etc.). Note the asymmetry in the table: `MouseUp` ignores both
191    /// its own `widget` field and `hit_test` entirely, because
192    /// "released" is a global state, not a per-widget one — deciding
193    /// whether the release *counts as a click* is the caller's job
194    /// (compare the id this returned before the call against the id
195    /// under the release position, as `examples/common/toolbar_app.rs`
196    /// does).
197    ///
198    /// Returns `true` if hovered or pressed state changed (caller
199    /// should redraw).
200    ///
201    /// # Two deliberate behaviours worth knowing
202    ///
203    /// 1. **Left button only** for press/release — matches every
204    ///    existing bespoke tracker
205    ///    ([`crate::compose::ToolbarHoverTracker`],
206    ///    [`crate::compose::StatusBarInteraction`]). A right- or
207    ///    middle-button `MouseDown` does **not** set pressed state, so
208    ///    a right-click never paints a pressed highlight. Covered by
209    ///    `handle_mouse_ignores_non_left_mouse_down`.
210    /// 2. **A left `MouseDown` that misses everything clears pressed
211    ///    state** rather than leaving a stale id behind — `hit_test`
212    ///    returning `None` is `set_pressed(None)`. This is what stops a
213    ///    pressed highlight sticking after the user presses a button,
214    ///    drags off it, and presses again on empty space. Covered by
215    ///    `handle_mouse_down_on_empty_space_clears_stale_pressed`.
216    pub fn handle_mouse(
217        &mut self,
218        event: &UiEvent,
219        hit_test: impl FnOnce(f32, f32) -> Option<WidgetId>,
220    ) -> bool {
221        match event {
222            UiEvent::MouseMoved { position, .. } => {
223                self.set_hovered(hit_test(position.x, position.y))
224            }
225            UiEvent::MouseDown {
226                widget,
227                position,
228                button: MouseButton::Left,
229                ..
230            } => {
231                let id = (*widget).or_else(|| hit_test(position.x, position.y));
232                self.set_pressed(id)
233            }
234            UiEvent::MouseUp {
235                button: MouseButton::Left,
236                ..
237            } => self.set_pressed(None),
238            _ => false,
239        }
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::event::Point;
247
248    fn mouse_moved(x: f32, y: f32) -> UiEvent {
249        UiEvent::MouseMoved {
250            position: Point::new(x, y),
251            buttons: Default::default(),
252        }
253    }
254
255    fn mouse_down(widget: Option<WidgetId>, x: f32, y: f32) -> UiEvent {
256        UiEvent::MouseDown {
257            widget,
258            button: MouseButton::Left,
259            position: Point::new(x, y),
260            modifiers: Default::default(),
261        }
262    }
263
264    fn mouse_up(x: f32, y: f32) -> UiEvent {
265        UiEvent::MouseUp {
266            widget: None,
267            button: MouseButton::Left,
268            position: Point::new(x, y),
269        }
270    }
271
272    #[test]
273    fn new_state_has_nothing_hovered_or_pressed() {
274        let state = InteractionState::new();
275        assert_eq!(state.hovered(), None);
276        assert_eq!(state.pressed(), None);
277    }
278
279    #[test]
280    fn set_hovered_reports_change() {
281        let mut state = InteractionState::new();
282        assert!(state.set_hovered(Some(WidgetId::new("a"))));
283        assert_eq!(state.hovered(), Some(&WidgetId::new("a")));
284        // Setting the same id again is not a change.
285        assert!(!state.set_hovered(Some(WidgetId::new("a"))));
286        // Moving to a different id is.
287        assert!(state.set_hovered(Some(WidgetId::new("b"))));
288        assert_eq!(state.hovered(), Some(&WidgetId::new("b")));
289    }
290
291    #[test]
292    fn is_hovered_and_is_pressed_key_by_widget_id() {
293        let mut state = InteractionState::new();
294        state.set_hovered(Some(WidgetId::new("btn-a")));
295        state.set_pressed(Some(WidgetId::new("btn-b")));
296        assert!(state.is_hovered(&WidgetId::new("btn-a")));
297        assert!(!state.is_hovered(&WidgetId::new("btn-b")));
298        assert!(state.is_pressed(&WidgetId::new("btn-b")));
299        assert!(!state.is_pressed(&WidgetId::new("btn-a")));
300    }
301
302    #[test]
303    fn clear_resets_both_and_reports_whether_anything_changed() {
304        let mut state = InteractionState::new();
305        assert!(!state.clear(), "clearing an empty state is a no-op");
306        state.set_hovered(Some(WidgetId::new("a")));
307        state.set_pressed(Some(WidgetId::new("a")));
308        assert!(state.clear());
309        assert_eq!(state.hovered(), None);
310        assert_eq!(state.pressed(), None);
311    }
312
313    #[test]
314    fn handle_mouse_moved_resolves_hover_via_hit_test() {
315        let mut state = InteractionState::new();
316        let changed = state.handle_mouse(&mouse_moved(5.0, 0.0), |x, _y| {
317            if x < 10.0 {
318                Some(WidgetId::new("hit"))
319            } else {
320                None
321            }
322        });
323        assert!(changed);
324        assert_eq!(state.hovered(), Some(&WidgetId::new("hit")));
325
326        // Moving off the hit region clears hover.
327        let changed = state.handle_mouse(&mouse_moved(50.0, 0.0), |x, _y| {
328            if x < 10.0 {
329                Some(WidgetId::new("hit"))
330            } else {
331                None
332            }
333        });
334        assert!(changed);
335        assert_eq!(state.hovered(), None);
336    }
337
338    #[test]
339    fn handle_mouse_down_prefers_the_events_own_resolved_widget() {
340        let mut state = InteractionState::new();
341        // Event already carries a resolved widget — hit_test must not
342        // be consulted (it would panic-on-call via `unreachable!` if
343        // it were, since `FnOnce` only tolerates a single call, but we
344        // assert the *value* instead of relying on that).
345        let changed = state.handle_mouse(
346            &mouse_down(Some(WidgetId::new("resolved")), 0.0, 0.0),
347            |_x, _y| Some(WidgetId::new("from-hit-test")),
348        );
349        assert!(changed);
350        assert_eq!(state.pressed(), Some(&WidgetId::new("resolved")));
351    }
352
353    #[test]
354    fn handle_mouse_down_falls_back_to_hit_test_when_unresolved() {
355        let mut state = InteractionState::new();
356        let changed = state.handle_mouse(&mouse_down(None, 3.0, 0.0), |x, _y| {
357            if x < 10.0 {
358                Some(WidgetId::new("fallback"))
359            } else {
360                None
361            }
362        });
363        assert!(changed);
364        assert_eq!(state.pressed(), Some(&WidgetId::new("fallback")));
365    }
366
367    /// A right- (or middle-) button press must not paint a pressed
368    /// highlight. Left-only is the documented contract, and it differs
369    /// from the hand-rolled `matches!(UiEvent::MouseDown { .. })` arms
370    /// this type replaces, which fired for any button.
371    #[test]
372    fn handle_mouse_ignores_non_left_mouse_down() {
373        let mut state = InteractionState::new();
374        for button in [MouseButton::Right, MouseButton::Middle] {
375            let event = UiEvent::MouseDown {
376                widget: Some(WidgetId::new("btn")),
377                button,
378                position: Point::new(1.0, 0.0),
379                modifiers: Default::default(),
380            };
381            let changed = state.handle_mouse(&event, |_, _| Some(WidgetId::new("btn")));
382            assert!(
383                !changed,
384                "{button:?} MouseDown must not change pressed state"
385            );
386            assert_eq!(state.pressed(), None, "{button:?} must not press anything");
387        }
388    }
389
390    /// A left press that lands on empty space clears whatever was
391    /// pressed before, instead of leaving a stale highlight lit.
392    #[test]
393    fn handle_mouse_down_on_empty_space_clears_stale_pressed() {
394        let mut state = InteractionState::new();
395        state.set_pressed(Some(WidgetId::new("stale")));
396        // Nothing under the cursor, and the event carries no resolved
397        // widget either.
398        let changed = state.handle_mouse(&mouse_down(None, 999.0, 999.0), |_, _| None);
399        assert!(changed, "clearing a stale pressed id is a change");
400        assert_eq!(state.pressed(), None);
401    }
402
403    #[test]
404    fn from_parts_round_trips_the_positional_pair() {
405        let state =
406            InteractionState::from_parts(Some(WidgetId::new("hov")), Some(WidgetId::new("prs")));
407        assert_eq!(state.hovered(), Some(&WidgetId::new("hov")));
408        assert_eq!(state.pressed(), Some(&WidgetId::new("prs")));
409        assert_eq!(
410            InteractionState::from_parts(None, None),
411            InteractionState::new()
412        );
413    }
414
415    #[test]
416    fn handle_mouse_up_clears_pressed() {
417        let mut state = InteractionState::new();
418        state.set_pressed(Some(WidgetId::new("held")));
419        let changed = state.handle_mouse(&mouse_up(0.0, 0.0), |_, _| None);
420        assert!(changed);
421        assert_eq!(state.pressed(), None);
422    }
423
424    #[test]
425    fn handle_mouse_ignores_unrelated_events() {
426        let mut state = InteractionState::new();
427        let changed = state.handle_mouse(
428            &UiEvent::KeyPressed {
429                key: crate::event::Key::Char('a'),
430                modifiers: Default::default(),
431                repeat: false,
432            },
433            |_, _| Some(WidgetId::new("irrelevant")),
434        );
435        assert!(!changed);
436        assert_eq!(state.hovered(), None);
437    }
438}