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}