retroglyph_widgets/interact/mod.rs
1//! Pointer and keyboard focus tracking for interactive widgets, without a
2//! retained widget tree.
3//!
4//! [`ListState`](crate::ListState) answers "where is this list scrolled to
5//! and what's selected"; this module answers the sibling question, "what
6//! did the user just do to this widget" (hover, click, drag, focus,
7//! scroll) for widgets that don't have a natural selection index of their
8//! own (buttons, tabs, draggable panes, ...). Four independently usable
9//! pieces, composed by [`Interaction`] the way [`ListState`](crate::ListState)
10//! composes with [`crate::widget::Table`]:
11//!
12//! - [`Pointer`]: raw mouse position/button/scroll state from a stream of
13//! [`Event`]s.
14//! - [`HitTester`]: resolves a pointer position to the topmost registered
15//! widget id.
16//! - [`FocusRing`]: which id holds keyboard focus, plus Tab/Shift+Tab
17//! cycling.
18//! - [`Response`]: what [`Interaction::interact`] reports back to a
19//! widget call site, gated by what it asked for via [`Sense`].
20//!
21//! # Example
22//!
23//! ```
24//! use retroglyph_core::{Backend, Headless, Rect, Terminal};
25//! use retroglyph_widgets::{Interaction, Sense};
26//!
27//! #[derive(Clone, Copy, PartialEq, Eq)]
28//! enum WidgetId {
29//! SaveButton,
30//! }
31//!
32//! fn draw<B: Backend>(
33//! term: &mut Terminal<B>,
34//! interaction: &mut Interaction<WidgetId>,
35//! ) -> bool {
36//! let area = Rect::new(0, 0, 10, 1);
37//! let response = interaction.interact(area, WidgetId::SaveButton, Sense::click());
38//! // ... draw the button, using response.hovered()/focused() to pick a style ...
39//! response.clicked()
40//! }
41//!
42//! let mut term = Terminal::new(Headless::new(20, 10));
43//! let mut interaction = Interaction::<WidgetId>::new();
44//! interaction.begin_frame();
45//! let saved = draw(&mut term, &mut interaction);
46//! interaction.end_frame();
47//! assert!(!saved); // nothing clicked yet: no input was fed in
48//! ```
49
50mod density;
51mod focus;
52mod hit;
53mod pointer;
54mod response;
55mod sense;
56mod shortcuts;
57
58pub use density::Density;
59pub use focus::FocusRing;
60pub use hit::HitTester;
61pub use pointer::Pointer;
62pub use response::Response;
63pub use sense::Sense;
64pub use shortcuts::Shortcuts;
65
66use retroglyph_core::{Event, KeyCode, MouseButton, Pos, Rect};
67
68/// Default [`Interaction::with_drag_threshold`].
69///
70/// The pointer must move more than one cell from its press-down position
71/// before a [`Sense::DRAG`] widget reports [`Response::dragging`] instead of
72/// a click-in-progress.
73pub const DEFAULT_DRAG_THRESHOLD: u16 = 1;
74
75/// Ties [`Pointer`], [`HitTester`], and [`FocusRing`] together into the one
76/// piece of state a draw pass needs to make its widgets interactive.
77///
78/// # Frame lifecycle
79///
80/// ```text
81/// interaction.begin_frame(); // 1
82/// for event in poll_events() {
83/// interaction.handle_event(&event); // 2
84/// }
85/// draw(&mut term, &mut interaction, &state); // 3: calls interaction.interact(...)
86/// interaction.end_frame(); // 4
87/// ```
88///
89/// 1. [`begin_frame`](Self::begin_frame) snapshots which id (if any) is
90/// under the pointer, and whether it pressed/released/scrolled, using
91/// *last* frame's hit registrations and pointer events: this frame's
92/// registrations aren't complete until step 3 finishes, and this frame's
93/// events haven't arrived yet (they're step 2), so every [`Response`] in
94/// a given frame is one frame stale relative to what's being drawn/fed in
95/// *this* frame: uniformly for hover, press, release, click, and
96/// scroll, all resolved from that one snapshot. At typical redraw rates
97/// this is imperceptible; it's the same kind of trade-off
98/// [`ListState::ensure_visible`](crate::ListState::ensure_visible)
99/// documents for a different reason (only the caller knows the current
100/// viewport height), applied here because only the *previous* frame
101/// knows the full hit list and the pointer's position as of the input
102/// that's about to be processed. `dragging` and [`Response::held`] are exceptions: both
103/// re-check the pointer's *live* position (via [`Pointer::pos`]/[`Pointer::is_down`]) rather
104/// than the frame-stale snapshot, because a drag-in-progress or a press-cancel needs to react
105/// the instant the pointer moves, not one frame later. Keyboard focus is the remaining
106/// exception: [`Response::focused`] and Enter/Space activation read [`FocusRing`]'s `current`
107/// live, since it's plain level state with no hit-testing involved: no staleness to trade
108/// off.
109/// 2. [`handle_event`](Self::handle_event) updates pointer position/buttons
110/// and, by default, cycles focus on Tab/Shift+Tab.
111/// 3. Each widget calls [`interact`](Self::interact) with its rect, a
112/// caller-chosen id, and a [`Sense`] describing what it cares about; it
113/// gets back a [`Response`] and, as a side effect, registers itself for
114/// step 1 of the *next* frame.
115/// 4. [`end_frame`](Self::end_frame) releases the active widget if step 1
116/// saw the pointer go up.
117///
118/// One consequence worth knowing: a full press-then-release gesture that
119/// arrives as two events in the *same* [`handle_event`](Self::handle_event)
120/// batch (both fed in during step 2 of one frame, e.g. a synthetic test
121/// firing them back to back) takes an extra frame to resolve versus a
122/// realistic press and release arriving in separate frames, because step
123/// 1's hover snapshot for that frame still reflects the pointer's
124/// position from *before* those events. Real input rarely lands this way
125/// (a physical click's down and up are milliseconds apart, i.e. several
126/// frames at typical redraw rates), so this only tends to show up in tests.
127///
128/// # Why `Id` is a type parameter, not a hash
129///
130/// Immediate-mode toolkits like egui derive a widget's identity from its
131/// call-site source location (optionally salted with data) hashed down to
132/// an opaque integer, flexible, but it means two widgets can collide onto
133/// the same id at runtime with no compile-time signal, and the id carries
134/// no meaning a debugger can show you. `Interaction<Id>` instead asks the
135/// app for whatever id type it already has lying around: typically a
136/// small `Copy` enum like the hand-rolled hit-target enum an app would
137/// otherwise define anyway. Collisions become unrepresentable if the enum
138/// is exhaustive, and `{:?}`-printing an id tells you exactly which widget
139/// it is. The cost is one generic parameter; `Id: Copy + PartialEq` is all
140/// any of this module asks for.
141///
142/// Consistently with that: everything here holds its state in a plain,
143/// explicitly-owned struct threaded through `&mut self`, the same
144/// convention [`ListState`](crate::ListState) uses, rather than the
145/// interior-mutability/global-context pattern egui's `Memory` relies on to
146/// keep its implicit ids from needing to be threaded everywhere.
147// Several of these are independent one-shot snapshots (primary/secondary
148// press/release, keyboard activation), not states of a single state
149// machine: see the field-level comment above `resolved_press` for why
150// they're snapshotted individually rather than read live off `pointer`.
151#[allow(clippy::struct_excessive_bools)]
152#[derive(Debug, Clone)]
153pub struct Interaction<Id> {
154 pointer: Pointer,
155 hits: HitTester<Id>,
156 focus: FocusRing<Id>,
157 resolved_hover: Option<Id>,
158 // The pointer position `resolved_hover` was computed from, kept
159 // alongside it so `interact` can independently ask "was *my* rect under
160 // the pointer" (see `scroll_delta` below) without needing `resolved_hover`
161 // to have picked this id as the single topmost winner.
162 resolved_pos: Option<Pos>,
163 // Snapshots of the pointer's one-shot flags, taken once in `begin_frame`
164 // and read by every `interact` call for the rest of this frame. Not read
165 // straight off `pointer` during `interact`: `handle_event` runs *between*
166 // `begin_frame` and `interact` calls (see the frame lifecycle docs), so a
167 // press/release arriving this frame would otherwise be visible to
168 // `interact` immediately while `resolved_hover` (computed before that
169 // event) still reflects last frame's pointer position: `active` would
170 // then latch onto whatever was hovered *last* frame, not the widget the
171 // fresh press actually landed on. Resolving everything from one
172 // consistent snapshot keeps hover/press/release/click/scroll uniformly
173 // one frame behind the input that produced them, matching the docs.
174 resolved_press: bool,
175 resolved_release: bool,
176 resolved_secondary_press: bool,
177 resolved_secondary_release: bool,
178 resolved_scroll: i32,
179 active: Option<Id>,
180 // Tracked separately from `active`: a secondary press can land on one
181 // widget while the primary button is mid-drag on another (or not
182 // pressed at all), so the two buttons need independent "which widget
183 // did this press originate on" state.
184 secondary_active: Option<Id>,
185 drag_origin: Option<Pos>,
186 drag_threshold: u16,
187 activate_focused: bool,
188}
189
190impl<Id> Interaction<Id> {
191 /// A fresh interaction context: nothing hovered, focused, or active.
192 #[must_use]
193 pub const fn new() -> Self {
194 Self {
195 pointer: Pointer::new(),
196 hits: HitTester::new(),
197 focus: FocusRing::new(),
198 resolved_hover: None,
199 resolved_pos: None,
200 resolved_press: false,
201 resolved_release: false,
202 resolved_secondary_press: false,
203 resolved_secondary_release: false,
204 resolved_scroll: 0,
205 active: None,
206 secondary_active: None,
207 drag_origin: None,
208 drag_threshold: DEFAULT_DRAG_THRESHOLD,
209 activate_focused: false,
210 }
211 }
212
213 /// Override how far (in cells) the pointer must move from its press
214 /// origin before a [`Sense::DRAG`] widget reports
215 /// [`Response::dragging`] rather than a click-in-progress. Defaults to
216 /// [`DEFAULT_DRAG_THRESHOLD`].
217 #[must_use]
218 pub const fn with_drag_threshold(mut self, cells: u16) -> Self {
219 self.drag_threshold = cells;
220 self
221 }
222
223 /// Read access to the pointer's current position/button/scroll state,
224 /// e.g. to draw a custom cursor glyph.
225 #[must_use]
226 pub const fn pointer(&self) -> &Pointer {
227 &self.pointer
228 }
229
230 /// Read access to the focus ring, e.g. to render a "press Tab to
231 /// begin" hint when nothing is focused yet.
232 #[must_use]
233 pub const fn focus(&self) -> &FocusRing<Id> {
234 &self.focus
235 }
236
237 /// Mutable access to the focus ring, e.g. to drive it from a gamepad
238 /// shoulder button instead of (or in addition to) Tab/Shift+Tab.
239 pub const fn focus_mut(&mut self) -> &mut FocusRing<Id> {
240 &mut self.focus
241 }
242}
243
244impl<Id: Copy + PartialEq> Interaction<Id> {
245 /// Resolve hover/press against last frame's registrations, finalize the
246 /// focus order, and clear the hit registry for this frame's
247 /// [`interact`](Self::interact) calls. Call once per frame, before
248 /// processing input or drawing.
249 pub fn begin_frame(&mut self) {
250 self.resolved_pos = self.pointer.pos();
251 self.resolved_hover = self.resolved_pos.and_then(|pos| self.hits.topmost_at(pos));
252 self.resolved_press = self.pointer.pressed(MouseButton::Left);
253 self.resolved_release = self.pointer.released(MouseButton::Left);
254 self.resolved_secondary_press = self.pointer.pressed(MouseButton::Right);
255 self.resolved_secondary_release = self.pointer.released(MouseButton::Right);
256 self.resolved_scroll = self.pointer.scroll_delta();
257
258 if self.resolved_press {
259 self.active = self.resolved_hover;
260 self.drag_origin = self.resolved_pos;
261 }
262 if self.resolved_secondary_press {
263 self.secondary_active = self.resolved_hover;
264 }
265
266 self.hits.clear();
267 self.focus.begin_frame();
268 // Now that this frame's snapshot is taken, clear the one-shot flags
269 // so next frame's `handle_event` calls start from a clean slate.
270 self.pointer.end_frame();
271 }
272
273 /// Feed a raw input event: updates the pointer, and (by default) Tab
274 /// cycles focus: see [`FocusRing::handle_event`] if you need to
275 /// override that.
276 pub fn handle_event(&mut self, event: &Event) {
277 self.pointer.handle_event(event);
278 self.focus.handle_event(event);
279 self.activate_focused |= is_activation_key(event);
280 }
281
282 /// Register `id`'s `rect` for whatever `sense` asks for, and report
283 /// what happened to it, resolved from *last* frame's input: see the
284 /// [`Interaction`] docs for the frame lifecycle this implies.
285 pub fn interact(&mut self, rect: Rect, id: Id, sense: Sense) -> Response {
286 if sense.wants_pointer() {
287 self.hits.push(rect, id);
288 }
289 if sense.contains(Sense::FOCUSABLE) {
290 self.focus.register(id);
291 }
292
293 let hovered = sense.wants_pointer() && self.resolved_hover == Some(id);
294 let is_active = self.active == Some(id);
295 let senses_click = sense.contains(Sense::CLICK);
296 let key_activated = senses_click
297 && sense.contains(Sense::FOCUSABLE)
298 && self.focus.is_focused(id)
299 && self.activate_focused;
300 let released_here = is_active && self.resolved_release;
301 // Deliberately not gated on `self.pointer.is_down()`: the release
302 // frame (where `is_down` just went false) must still see `dragging
303 // == true` so `clicked` below correctly stays suppressed for a
304 // drag's terminating release, not just the frames in between.
305 let dragging = is_active && sense.contains(Sense::DRAG) && self.past_drag_threshold();
306
307 // Live re-check, deliberately not gated on `hovered`/`resolved_hover` the way `pressed`
308 // is: those are resolved from *last* frame's hit-test snapshot (see the `Interaction`
309 // frame-lifecycle docs), but a slide-off cancellation needs to see the pointer's
310 // *current* position the instant it leaves this rect, not one frame later. Mirrors how
311 // `dragging` above already reads `self.pointer.pos()` live instead of `resolved_pos`, and
312 // how `scroll_delta` below bypasses the single-topmost-winner rule: same "read live
313 // state, scoped to my own rect" shape, applied a third time.
314 let held = senses_click
315 && is_active
316 && self.pointer.is_down(MouseButton::Left)
317 && self.pointer.pos().is_some_and(|pos| rect.contains_pos(pos));
318
319 if senses_click && released_here && hovered && !dragging {
320 self.focus.request(id);
321 }
322
323 // Scroll deliberately isn't gated on `hovered` (single topmost
324 // winner) the way click/press/release/drag are: a scrollable
325 // container's own rect is usually fully covered by its rows/items
326 // (each independently sensing HOVER | CLICK so they're individually
327 // clickable), which would otherwise shadow the container at every
328 // point inside it and make it un-scrollable. Any rect the resolved
329 // pointer position falls within gets scroll credit, regardless of
330 // what's drawn on top of it, matching how wheel input behaves in
331 // most real UIs (it reaches the nearest scrollable ancestor, not
332 // just whatever's topmost at the exact pixel).
333 let scrollable_here = sense.wants_pointer()
334 && sense.contains(Sense::SCROLL)
335 && self.resolved_pos.is_some_and(|pos| rect.contains_pos(pos));
336
337 // The secondary button gets a narrower resolution than the primary
338 // one: no drag-threshold suppression (secondary-button drags aren't
339 // a gesture this module tracks), and it doesn't drive focus the way
340 // a primary click does (see `Response::secondary_clicked`'s doc
341 // comment).
342 let secondary_is_active = self.secondary_active == Some(id);
343 let secondary_clicked = sense.contains(Sense::SECONDARY_CLICK)
344 && secondary_is_active
345 && self.resolved_secondary_release
346 && hovered;
347
348 Response {
349 hovered,
350 pressed: (is_active && self.resolved_press) || key_activated,
351 released: released_here || key_activated,
352 clicked: (senses_click && released_here && hovered && !dragging) || key_activated,
353 held,
354 dragging,
355 focused: self.focus.is_focused(id),
356 secondary_clicked,
357 scroll_delta: if scrollable_here {
358 self.resolved_scroll
359 } else {
360 0
361 },
362 }
363 }
364
365 /// Release the active widget (both primary and secondary), e.g. so a
366 /// later [`focus_mut`](Self::focus_mut)-driven Tab handling starts
367 /// clean. Call once per frame, after drawing.
368 pub const fn end_frame(&mut self) {
369 if self.resolved_release {
370 self.active = None;
371 self.drag_origin = None;
372 }
373 if self.resolved_secondary_release {
374 self.secondary_active = None;
375 }
376 self.activate_focused = false;
377 }
378
379 fn past_drag_threshold(&self) -> bool {
380 let (Some(origin), Some(pos)) = (self.drag_origin, self.pointer.pos()) else {
381 return false;
382 };
383 origin.x.abs_diff(pos.x).max(origin.y.abs_diff(pos.y)) > self.drag_threshold
384 }
385}
386
387impl<Id> Default for Interaction<Id> {
388 fn default() -> Self {
389 Self::new()
390 }
391}
392
393const fn is_activation_key(event: &Event) -> bool {
394 let Event::Key(key) = event else {
395 return false;
396 };
397 key.is_down() && matches!(key.code, KeyCode::Enter | KeyCode::Char(' '))
398}
399
400#[cfg(test)]
401mod tests {
402 use retroglyph_core::{KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
403
404 use super::*;
405
406 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
407 enum Id {
408 Save,
409 Cancel,
410 }
411
412 fn click_at(interaction: &mut Interaction<Id>, pos: Pos) {
413 interaction.handle_event(&Event::Mouse(MouseEvent {
414 kind: MouseEventKind::Down(MouseButton::Left),
415 position: pos,
416 pixel_position: None,
417 modifiers: KeyModifiers::NONE,
418 }));
419 interaction.handle_event(&Event::Mouse(MouseEvent {
420 kind: MouseEventKind::Up(MouseButton::Left),
421 position: pos,
422 pixel_position: None,
423 modifiers: KeyModifiers::NONE,
424 }));
425 }
426
427 /// Registers `Save`/`Cancel` at fixed rects and returns their responses,
428 /// modeling one full frame (see the [`Interaction`] docs for the
429 /// lifecycle). `events` are fed in between `begin_frame` and the
430 /// `interact` calls, exactly where the documented lifecycle puts them --
431 /// e.g. a `Tab` press only affects focus registered as of the *start*
432 /// of this call, and a click resolves against hits registered by the
433 /// *previous* `frame`/`frame_with_events` call.
434 fn frame_with_events(
435 interaction: &mut Interaction<Id>,
436 events: &[Event],
437 ) -> (Response, Response) {
438 interaction.begin_frame();
439 for event in events {
440 interaction.handle_event(event);
441 }
442 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::click());
443 let cancel = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::click());
444 interaction.end_frame();
445 (save, cancel)
446 }
447
448 fn frame(interaction: &mut Interaction<Id>) -> (Response, Response) {
449 frame_with_events(interaction, &[])
450 }
451
452 #[test]
453 fn click_is_resolved_one_frame_after_the_pointer_event() {
454 let mut interaction = Interaction::<Id>::new();
455
456 // Frame 1: nothing registered yet, so nothing can resolve.
457 let (save1, _) = frame(&mut interaction);
458 assert!(!save1.clicked());
459
460 // Click lands between frame 1 and frame 2, over "Save"'s rect.
461 click_at(&mut interaction, Pos::new(2, 0));
462
463 // Frame 2: resolves against frame 1's registrations.
464 let (save2, cancel2) = frame(&mut interaction);
465 assert!(save2.clicked());
466 assert!(!cancel2.clicked());
467 }
468
469 /// Regression test for a real bug caught while building the
470 /// `interaction_demo` example: pointer flags used to get cleared in
471 /// `end_frame` (the same frame `handle_event` set them in), so a press
472 /// recorded by `handle_event` was always gone by the time the *next*
473 /// frame's `begin_frame` went looking for it, and `active` could never
474 /// be set at all. Fixed by moving flag consumption into `begin_frame`
475 /// itself. This mirrors the realistic call pattern (`handle_event`
476 /// between `begin_frame` and drawing, once per frame) rather than
477 /// `click_at`'s frame-boundary-agnostic style above.
478 #[test]
479 fn press_and_release_in_separate_frames_still_resolves_a_click() {
480 let mut interaction = Interaction::<Id>::new();
481 let _ = frame(&mut interaction); // frame 1: registers Save/Cancel
482
483 let down = Event::Mouse(MouseEvent {
484 kind: MouseEventKind::Down(MouseButton::Left),
485 position: Pos::new(2, 0),
486 pixel_position: None,
487 modifiers: KeyModifiers::NONE,
488 });
489 // frame 2: press delivered via handle_event, same as a real tick.
490 let (save2, _) = frame_with_events(&mut interaction, &[down]);
491 assert!(!save2.pressed()); // this frame's hover snapshot predates the event
492
493 // frame 3: begin_frame now sees frame 2's press against frame 2's
494 // (correctly positioned) hit registrations.
495 let (save3, _) = frame(&mut interaction);
496 assert!(save3.pressed());
497
498 let up = Event::Mouse(MouseEvent {
499 kind: MouseEventKind::Up(MouseButton::Left),
500 position: Pos::new(2, 0),
501 pixel_position: None,
502 modifiers: KeyModifiers::NONE,
503 });
504 // frame 4: release delivered the same way.
505 let _ = frame_with_events(&mut interaction, &[up]);
506
507 // frame 5: resolves the release.
508 let (save5, _) = frame(&mut interaction);
509 assert!(save5.clicked());
510 }
511
512 #[test]
513 fn hover_follows_the_pointer_without_a_click() {
514 let mut interaction = Interaction::<Id>::new();
515 let _ = frame(&mut interaction);
516
517 interaction.handle_event(&Event::Mouse(MouseEvent {
518 kind: MouseEventKind::Moved,
519 position: Pos::new(7, 0),
520 pixel_position: None,
521 modifiers: KeyModifiers::NONE,
522 }));
523
524 let (save, cancel) = frame(&mut interaction);
525 assert!(!save.hovered());
526 assert!(cancel.hovered());
527 assert!(!cancel.clicked());
528 }
529
530 #[test]
531 fn tab_focuses_then_enter_activates_without_any_pointer() {
532 let mut interaction = Interaction::<Id>::new();
533 let _ = frame(&mut interaction); // registers Save/Cancel as focusable for the *next* frame
534
535 let tab = Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
536 let (save, _) = frame_with_events(&mut interaction, &[tab]);
537 assert!(save.focused());
538 assert!(!save.clicked());
539
540 let enter = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
541 let (save, cancel) = frame_with_events(&mut interaction, &[enter]);
542 assert!(save.clicked());
543 assert!(!cancel.clicked());
544 }
545
546 #[test]
547 fn drag_past_threshold_suppresses_the_click() {
548 let mut interaction = Interaction::<Id>::new().with_drag_threshold(1);
549 let _ = frame(&mut interaction);
550
551 interaction.handle_event(&Event::Mouse(MouseEvent {
552 kind: MouseEventKind::Down(MouseButton::Left),
553 position: Pos::new(2, 0),
554 pixel_position: None,
555 modifiers: KeyModifiers::NONE,
556 }));
557 interaction.begin_frame();
558 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::drag());
559 assert!(!save.dragging()); // hasn't moved yet
560 interaction.end_frame();
561
562 interaction.handle_event(&Event::Mouse(MouseEvent {
563 kind: MouseEventKind::Moved,
564 position: Pos::new(4, 0), // 2 cells from the press origin
565 pixel_position: None,
566 modifiers: KeyModifiers::NONE,
567 }));
568 interaction.begin_frame();
569 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::drag());
570 assert!(save.dragging());
571 interaction.end_frame();
572
573 interaction.handle_event(&Event::Mouse(MouseEvent {
574 kind: MouseEventKind::Up(MouseButton::Left),
575 position: Pos::new(4, 0),
576 pixel_position: None,
577 modifiers: KeyModifiers::NONE,
578 }));
579 interaction.begin_frame();
580 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::drag());
581 assert!(!save.clicked()); // released after dragging, not a click
582 assert!(save.released());
583 }
584
585 #[test]
586 fn held_is_true_while_pressed_and_hovering_and_false_once_the_pointer_slides_off() {
587 let mut interaction = Interaction::<Id>::new();
588 let _ = frame(&mut interaction); // frame 1: registers Save/Cancel
589
590 interaction.handle_event(&Event::Mouse(MouseEvent {
591 kind: MouseEventKind::Down(MouseButton::Left),
592 position: Pos::new(2, 0), // over Save
593 pixel_position: None,
594 modifiers: KeyModifiers::NONE,
595 }));
596
597 // frame 2: press resolves against frame 1's registrations, pointer still over Save.
598 let (save, _) = frame(&mut interaction);
599 assert!(save.held());
600
601 interaction.handle_event(&Event::Mouse(MouseEvent {
602 kind: MouseEventKind::Moved,
603 position: Pos::new(20, 0), // outside Save's rect, still held down
604 pixel_position: None,
605 modifiers: KeyModifiers::NONE,
606 }));
607 interaction.begin_frame();
608 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::click());
609 let _ = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::click());
610 interaction.end_frame();
611 assert!(!save.held()); // slid off before release: cancels immediately
612
613 interaction.handle_event(&Event::Mouse(MouseEvent {
614 kind: MouseEventKind::Moved,
615 position: Pos::new(2, 0), // back over Save, still held down, before release
616 pixel_position: None,
617 modifiers: KeyModifiers::NONE,
618 }));
619 interaction.begin_frame();
620 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::click());
621 let _ = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::click());
622 interaction.end_frame();
623 assert!(save.held()); // back inside: held again
624
625 interaction.handle_event(&Event::Mouse(MouseEvent {
626 kind: MouseEventKind::Up(MouseButton::Left),
627 position: Pos::new(2, 0),
628 pixel_position: None,
629 modifiers: KeyModifiers::NONE,
630 }));
631 interaction.begin_frame();
632 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::click());
633 let _ = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::click());
634 interaction.end_frame();
635 assert!(!save.held());
636 assert!(save.released());
637 assert!(save.clicked());
638 }
639
640 #[test]
641 fn held_requires_click_sense() {
642 let mut interaction = Interaction::<Id>::new();
643 interaction.begin_frame();
644 let _ = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::hover());
645 interaction.end_frame();
646
647 interaction.handle_event(&Event::Mouse(MouseEvent {
648 kind: MouseEventKind::Down(MouseButton::Left),
649 position: Pos::new(2, 0), // over Save
650 pixel_position: None,
651 modifiers: KeyModifiers::NONE,
652 }));
653
654 interaction.begin_frame();
655 // `active` is assigned from whichever id was topmost at press time, regardless of that
656 // id's own `Sense` (see `begin_frame`'s `self.active = self.resolved_hover;`), so Save
657 // is `is_active` here even though it only sensed `HOVER`: `held` must still stay false.
658 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::hover());
659 interaction.end_frame();
660 assert!(!save.held());
661 }
662
663 #[test]
664 fn scroll_reports_only_while_hovered_and_sensed() {
665 let mut interaction = Interaction::<Id>::new();
666 let _ = frame(&mut interaction);
667
668 interaction.handle_event(&Event::Mouse(MouseEvent {
669 kind: MouseEventKind::Moved,
670 position: Pos::new(2, 0),
671 pixel_position: None,
672 modifiers: KeyModifiers::NONE,
673 }));
674 interaction.handle_event(&Event::Mouse(MouseEvent {
675 kind: MouseEventKind::Scroll { dx: 0.0, dy: -1.0 },
676 position: Pos::new(2, 0),
677 pixel_position: None,
678 modifiers: KeyModifiers::NONE,
679 }));
680
681 interaction.begin_frame();
682 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::scroll());
683 let cancel = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::scroll());
684 interaction.end_frame();
685
686 assert_eq!(save.scroll_delta(), 1);
687 assert_eq!(cancel.scroll_delta(), 0); // outside Cancel's rect
688 }
689
690 /// Regression test for a real bug caught while building the
691 /// `interaction_demo` example: a scrollable container whose rows are
692 /// individually `Sense::HOVER | Sense::CLICK`-sensed (so they're each
693 /// clickable) covers its own rect completely, so under the old
694 /// "scroll only reports for the single topmost-hovered id" rule the
695 /// container could never win hover against its own rows and would
696 /// never see a scroll. Fixed by making `SCROLL` independent of the
697 /// topmost-hover winner: see [`Sense::SCROLL`]'s doc comment.
698 #[test]
699 fn scroll_reaches_a_container_through_an_overlapping_child() {
700 let mut interaction = Interaction::<Id>::new();
701 interaction.begin_frame();
702 // The child (Cancel, standing in for a list row) is registered
703 // *after* the container (Save), so it's topmost at any point they
704 // share, exactly like a row drawn on top of its list container.
705 let _ = interaction.interact(Rect::new(0, 0, 10, 1), Id::Save, Sense::scroll());
706 let _ = interaction.interact(
707 Rect::new(0, 0, 10, 1),
708 Id::Cancel,
709 Sense::HOVER | Sense::CLICK,
710 );
711 interaction.end_frame();
712
713 interaction.handle_event(&Event::Mouse(MouseEvent {
714 kind: MouseEventKind::Scroll { dx: 0.0, dy: -1.0 },
715 position: Pos::new(2, 0),
716 pixel_position: None,
717 modifiers: KeyModifiers::NONE,
718 }));
719
720 interaction.begin_frame();
721 let container = interaction.interact(Rect::new(0, 0, 10, 1), Id::Save, Sense::scroll());
722 let child = interaction.interact(
723 Rect::new(0, 0, 10, 1),
724 Id::Cancel,
725 Sense::HOVER | Sense::CLICK,
726 );
727 interaction.end_frame();
728
729 assert_eq!(container.scroll_delta(), 1);
730 assert!(child.hovered()); // the child still wins plain hover/click resolution
731 }
732
733 #[test]
734 fn hover_only_sense_never_reports_clicked() {
735 let mut interaction = Interaction::<Id>::new();
736 let _ = frame(&mut interaction);
737 click_at(&mut interaction, Pos::new(2, 0));
738
739 interaction.begin_frame();
740 let save = interaction.interact(Rect::new(0, 0, 5, 1), Id::Save, Sense::hover());
741 interaction.end_frame();
742
743 assert!(save.hovered());
744 assert!(!save.clicked());
745 }
746
747 fn right_click_at(interaction: &mut Interaction<Id>, pos: Pos) {
748 interaction.handle_event(&Event::Mouse(MouseEvent {
749 kind: MouseEventKind::Down(MouseButton::Right),
750 position: pos,
751 pixel_position: None,
752 modifiers: KeyModifiers::NONE,
753 }));
754 interaction.handle_event(&Event::Mouse(MouseEvent {
755 kind: MouseEventKind::Up(MouseButton::Right),
756 position: pos,
757 pixel_position: None,
758 modifiers: KeyModifiers::NONE,
759 }));
760 }
761
762 #[test]
763 fn secondary_click_is_independent_of_the_primary_button() {
764 fn frame_secondary(interaction: &mut Interaction<Id>) -> (Response, Response) {
765 interaction.begin_frame();
766 let save = interaction.interact(
767 Rect::new(0, 0, 5, 1),
768 Id::Save,
769 Sense::click() | Sense::SECONDARY_CLICK,
770 );
771 let cancel = interaction.interact(Rect::new(6, 0, 5, 1), Id::Cancel, Sense::click());
772 interaction.end_frame();
773 (save, cancel)
774 }
775
776 let mut interaction = Interaction::<Id>::new();
777 let _ = frame_secondary(&mut interaction); // frame 1: register
778 right_click_at(&mut interaction, Pos::new(2, 0)); // over Save
779
780 let (save, cancel) = frame_secondary(&mut interaction); // frame 2: resolves
781 assert!(save.secondary_clicked());
782 assert!(!save.clicked()); // primary button never touched
783 assert!(!cancel.secondary_clicked());
784 }
785
786 #[test]
787 fn secondary_click_not_sensed_never_reports_even_when_right_clicked() {
788 let mut interaction = Interaction::<Id>::new();
789 let _ = frame(&mut interaction); // Save/Cancel sensed with Sense::click() only
790 right_click_at(&mut interaction, Pos::new(2, 0));
791
792 let (save, _) = frame(&mut interaction);
793 assert!(!save.secondary_clicked()); // not sensed, so never reported
794 }
795}