Another project
1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::input::{
6 ClickCount, DoubleClickWindow, FrameInstant, InputSnapshot, PointerButton, PointerButtonMask,
7};
8use crate::layout::{LayoutOffset, LayoutPos, LayoutRect};
9use crate::widget_id::WidgetId;
10
11#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(transparent)]
13pub struct Sense(u8);
14
15impl Sense {
16 pub const HOVER: Self = Self(1 << 0);
17 pub const PRESS: Self = Self(1 << 1);
18 pub const DRAG: Self = Self(1 << 2);
19 pub const INTERACTIVE: Self = Self(Self::HOVER.0 | Self::PRESS.0);
20 pub const DRAGGABLE: Self = Self(Self::HOVER.0 | Self::PRESS.0 | Self::DRAG.0);
21
22 #[must_use]
23 pub const fn contains(self, other: Self) -> bool {
24 (self.0 & other.0) == other.0
25 }
26}
27
28#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
29#[serde(transparent)]
30pub struct ZLayer(u32);
31
32impl ZLayer {
33 pub const BASE: Self = Self(0);
34 pub const POPUP: Self = Self(100);
35
36 #[must_use]
37 pub const fn new(value: u32) -> Self {
38 Self(value)
39 }
40
41 #[must_use]
42 pub const fn get(self) -> u32 {
43 self.0
44 }
45}
46
47#[derive(Copy, Clone, Debug, PartialEq)]
48pub struct HitItem {
49 pub id: WidgetId,
50 pub rect: LayoutRect,
51 pub sense: Sense,
52 pub z: ZLayer,
53 pub disabled: bool,
54 pub active: bool,
55}
56
57#[derive(Clone, Debug, Default, PartialEq)]
58pub struct HitFrame {
59 items: Vec<HitItem>,
60 seen: BTreeSet<WidgetId>,
61}
62
63impl HitFrame {
64 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 pub fn push(&mut self, item: HitItem) {
70 assert!(
71 !item.sense.contains(Sense::DRAG) || item.sense.contains(Sense::PRESS),
72 "Sense::DRAG implies Sense::PRESS",
73 );
74 assert!(
75 !item.sense.contains(Sense::PRESS) || item.sense.contains(Sense::HOVER),
76 "Sense::PRESS implies Sense::HOVER",
77 );
78 assert!(
79 self.seen.insert(item.id),
80 "duplicate WidgetId in HitFrame: {:?}",
81 item.id,
82 );
83 self.items.push(item);
84 }
85
86 #[must_use]
87 pub fn items(&self) -> &[HitItem] {
88 &self.items
89 }
90
91 pub fn clear(&mut self) {
92 self.items.clear();
93 self.seen.clear();
94 }
95
96 fn topmost(&self, pos: LayoutPos, required: Sense) -> Option<&HitItem> {
97 self.items
98 .iter()
99 .enumerate()
100 .filter(|(_, item)| item.sense.contains(required) && item.rect.contains(pos))
101 .max_by_key(|(idx, item)| (item.z, *idx))
102 .map(|(_, item)| item)
103 }
104
105 fn lookup(&self, id: WidgetId) -> Option<&HitItem> {
106 self.items.iter().find(|item| item.id == id)
107 }
108}
109
110#[derive(Copy, Clone, Debug, PartialEq)]
111pub struct PointerCapture {
112 pub id: WidgetId,
113 pub button: PointerButton,
114}
115
116#[derive(Clone, Debug, Default, PartialEq)]
117pub struct PointerCaptureStack {
118 captures: Vec<PointerCapture>,
119}
120
121impl PointerCaptureStack {
122 #[must_use]
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 #[must_use]
128 pub fn top(&self) -> Option<PointerCapture> {
129 self.captures.last().copied()
130 }
131
132 pub fn push(&mut self, capture: PointerCapture) {
133 self.captures.push(capture);
134 }
135
136 pub fn release_button(&mut self, button: PointerButton) {
137 self.captures.retain(|c| c.button != button);
138 }
139
140 #[must_use]
141 pub fn captures(&self) -> &[PointerCapture] {
142 &self.captures
143 }
144
145 #[must_use]
146 pub fn is_empty(&self) -> bool {
147 self.captures.is_empty()
148 }
149}
150
151#[derive(Copy, Clone, Debug, PartialEq)]
152pub struct PressedRecord {
153 pub id: WidgetId,
154 pub button: PointerButton,
155 pub origin: LayoutPos,
156 pub started_at: FrameInstant,
157 pub drag_active: bool,
158}
159
160#[derive(Copy, Clone, Debug, PartialEq)]
161pub struct ClickRecord {
162 pub id: WidgetId,
163 pub at: FrameInstant,
164 pub count: ClickCount,
165}
166
167#[derive(
168 Copy, Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize,
169)]
170#[serde(transparent)]
171pub struct InteractionState(u16);
172
173impl InteractionState {
174 pub const NONE: Self = Self(0);
175 pub const HOVER: Self = Self(1 << 0);
176 pub const PRESSED: Self = Self(1 << 1);
177 pub const FOCUSED: Self = Self(1 << 2);
178 pub const DISABLED: Self = Self(1 << 3);
179 pub const ACTIVE: Self = Self(1 << 4);
180 pub const CLICK: Self = Self(1 << 5);
181 pub const DOUBLE_CLICK: Self = Self(1 << 6);
182 pub const DRAG_START: Self = Self(1 << 7);
183 pub const DRAG_RELEASE: Self = Self(1 << 8);
184
185 #[must_use]
186 pub const fn contains(self, other: Self) -> bool {
187 (self.0 & other.0) == other.0
188 }
189
190 #[must_use]
191 pub const fn with(self, flag: Self, on: bool) -> Self {
192 if on {
193 Self(self.0 | flag.0)
194 } else {
195 Self(self.0 & !flag.0)
196 }
197 }
198}
199
200#[derive(Copy, Clone, Debug, PartialEq, Default)]
201pub struct Interaction {
202 pub state: InteractionState,
203 pub drag_delta: LayoutOffset,
204 pub pressed_buttons: PointerButtonMask,
205 pub click_button: Option<PointerButton>,
206 pub drag_button: Option<PointerButton>,
207}
208
209impl Interaction {
210 #[must_use]
211 pub const fn idle() -> Self {
212 Self {
213 state: InteractionState::NONE,
214 drag_delta: LayoutOffset::ZERO,
215 pressed_buttons: PointerButtonMask::EMPTY,
216 click_button: None,
217 drag_button: None,
218 }
219 }
220
221 #[must_use]
222 pub const fn hover(self) -> bool {
223 self.state.contains(InteractionState::HOVER)
224 }
225
226 #[must_use]
227 pub const fn pressed(self) -> bool {
228 self.state.contains(InteractionState::PRESSED)
229 }
230
231 #[must_use]
232 pub const fn focused(self) -> bool {
233 self.state.contains(InteractionState::FOCUSED)
234 }
235
236 #[must_use]
237 pub const fn disabled(self) -> bool {
238 self.state.contains(InteractionState::DISABLED)
239 }
240
241 #[must_use]
242 pub const fn active(self) -> bool {
243 self.state.contains(InteractionState::ACTIVE)
244 }
245
246 #[must_use]
247 pub const fn click(self) -> bool {
248 self.state.contains(InteractionState::CLICK)
249 }
250
251 #[must_use]
252 pub const fn double_click(self) -> bool {
253 self.state.contains(InteractionState::DOUBLE_CLICK)
254 }
255
256 #[must_use]
257 pub const fn drag_start(self) -> bool {
258 self.state.contains(InteractionState::DRAG_START)
259 }
260
261 #[must_use]
262 pub const fn drag_release(self) -> bool {
263 self.state.contains(InteractionState::DRAG_RELEASE)
264 }
265}
266
267#[derive(Clone, Debug, Default, PartialEq)]
268pub struct HitState {
269 pub hovered: Option<WidgetId>,
270 pub pressed: BTreeMap<PointerButton, PressedRecord>,
271 pub last_click: BTreeMap<PointerButton, ClickRecord>,
272 pub captures: PointerCaptureStack,
273 pub interactions: BTreeMap<WidgetId, Interaction>,
274 pub last_pointer: Option<LayoutPos>,
275}
276
277impl HitState {
278 #[must_use]
279 pub fn new() -> Self {
280 Self::default()
281 }
282
283 #[must_use]
284 pub fn interaction(&self, id: WidgetId) -> Interaction {
285 self.interactions
286 .get(&id)
287 .copied()
288 .unwrap_or(Interaction::idle())
289 }
290}
291
292#[derive(Copy, Clone, Debug, PartialEq)]
293struct ButtonEvent {
294 id: WidgetId,
295 button: PointerButton,
296}
297
298#[derive(Copy, Clone, Debug, PartialEq)]
299struct ClickEvent {
300 id: WidgetId,
301 button: PointerButton,
302 count: ClickCount,
303}
304
305#[derive(Default)]
306struct Phase {
307 pressed: BTreeMap<PointerButton, PressedRecord>,
308 captures: PointerCaptureStack,
309 last_click: BTreeMap<PointerButton, ClickRecord>,
310 drag_starts: Vec<ButtonEvent>,
311 drag_releases: Vec<ButtonEvent>,
312 clicks: Vec<ClickEvent>,
313 release_records: Vec<PressedRecord>,
314}
315
316#[must_use]
317pub fn resolve(
318 prev: &HitState,
319 frame: &HitFrame,
320 input: &InputSnapshot,
321 focused: Option<WidgetId>,
322) -> HitState {
323 let pointer = input.pointer.map(|p| p.position);
324 let phase = Phase {
325 pressed: prev.pressed.clone(),
326 captures: prev.captures.clone(),
327 last_click: prev.last_click.clone(),
328 ..Phase::default()
329 };
330 let phase = apply_press(phase, frame, input, pointer);
331 let phase = apply_drag_start(phase, input, frame, pointer);
332 let phase = apply_release(phase, frame, input, pointer);
333
334 let routed_id = route_pointer(&phase.captures, frame, pointer);
335 let sticky_pointer = pointer.or(prev.last_pointer);
336 let interactions = build_interactions(
337 frame,
338 &InteractionInputs {
339 hovered: routed_id,
340 focused,
341 pressed: &phase.pressed,
342 release_records: &phase.release_records,
343 pointer: sticky_pointer,
344 drag_starts: &phase.drag_starts,
345 drag_releases: &phase.drag_releases,
346 clicks: &phase.clicks,
347 },
348 );
349
350 HitState {
351 hovered: routed_id,
352 pressed: phase.pressed,
353 last_click: phase.last_click,
354 captures: phase.captures,
355 interactions,
356 last_pointer: sticky_pointer,
357 }
358}
359
360fn route_pointer(
361 captures: &PointerCaptureStack,
362 frame: &HitFrame,
363 pointer: Option<LayoutPos>,
364) -> Option<WidgetId> {
365 if let Some(cap) = captures.top()
366 && frame.lookup(cap.id).is_some()
367 {
368 return Some(cap.id);
369 }
370 pointer
371 .and_then(|p| frame.topmost(p, Sense::HOVER))
372 .map(|item| item.id)
373}
374
375fn apply_press(
376 phase: Phase,
377 frame: &HitFrame,
378 input: &InputSnapshot,
379 pointer: Option<LayoutPos>,
380) -> Phase {
381 PointerButton::ALL
382 .iter()
383 .copied()
384 .filter(|button| input.buttons_pressed.contains(*button))
385 .fold(phase, |mut phase, button| {
386 let target = pointer
387 .and_then(|p| frame.topmost(p, Sense::PRESS))
388 .filter(|item| !item.disabled);
389 if let Some(item) = target
390 && let Some(p) = pointer
391 {
392 phase.pressed.insert(
393 button,
394 PressedRecord {
395 id: item.id,
396 button,
397 origin: p,
398 started_at: input.frame,
399 drag_active: false,
400 },
401 );
402 if item.sense.contains(Sense::DRAG) {
403 phase.captures.push(PointerCapture {
404 id: item.id,
405 button,
406 });
407 }
408 }
409 phase
410 })
411}
412
413fn apply_drag_start(
414 phase: Phase,
415 input: &InputSnapshot,
416 frame: &HitFrame,
417 pointer: Option<LayoutPos>,
418) -> Phase {
419 let candidates: Vec<PressedRecord> = phase
420 .pressed
421 .values()
422 .filter(|rec| !rec.drag_active)
423 .filter(|rec| {
424 frame
425 .lookup(rec.id)
426 .is_some_and(|item| item.sense.contains(Sense::DRAG) && !item.disabled)
427 })
428 .copied()
429 .collect();
430 candidates.into_iter().fold(phase, |mut phase, rec| {
431 let Some(p) = pointer else { return phase };
432 let offset = LayoutOffset::between(rec.origin, p);
433 if !input.drag_threshold.exceeded_by(offset) {
434 return phase;
435 }
436 if let Some(record) = phase.pressed.get_mut(&rec.button) {
437 record.drag_active = true;
438 }
439 let already_captured = phase
440 .captures
441 .captures()
442 .iter()
443 .any(|c| c.id == rec.id && c.button == rec.button);
444 if !already_captured {
445 phase.captures.push(PointerCapture {
446 id: rec.id,
447 button: rec.button,
448 });
449 }
450 phase.drag_starts.push(ButtonEvent {
451 id: rec.id,
452 button: rec.button,
453 });
454 phase
455 })
456}
457
458fn apply_release(
459 phase: Phase,
460 frame: &HitFrame,
461 input: &InputSnapshot,
462 pointer: Option<LayoutPos>,
463) -> Phase {
464 PointerButton::ALL
465 .iter()
466 .copied()
467 .filter(|button| input.buttons_released.contains(*button))
468 .fold(phase, |mut phase, button| {
469 if let Some(rec) = phase.pressed.remove(&button) {
470 phase.release_records.push(rec);
471 if rec.drag_active {
472 phase.drag_releases.push(ButtonEvent { id: rec.id, button });
473 } else {
474 let pointer_over = pointer.is_some_and(|p| {
475 frame
476 .lookup(rec.id)
477 .is_some_and(|item| item.rect.contains(p))
478 });
479 if pointer_over {
480 let count = next_click_count(
481 phase.last_click.get(&button).copied(),
482 rec.id,
483 input.frame,
484 input.double_click_window,
485 );
486 phase.last_click.insert(
487 button,
488 ClickRecord {
489 id: rec.id,
490 at: input.frame,
491 count,
492 },
493 );
494 phase.clicks.push(ClickEvent {
495 id: rec.id,
496 button,
497 count,
498 });
499 }
500 }
501 }
502 phase.captures.release_button(button);
503 phase
504 })
505}
506
507struct InteractionInputs<'a> {
508 hovered: Option<WidgetId>,
509 focused: Option<WidgetId>,
510 pressed: &'a BTreeMap<PointerButton, PressedRecord>,
511 release_records: &'a [PressedRecord],
512 pointer: Option<LayoutPos>,
513 drag_starts: &'a [ButtonEvent],
514 drag_releases: &'a [ButtonEvent],
515 clicks: &'a [ClickEvent],
516}
517
518fn build_interactions(
519 frame: &HitFrame,
520 inputs: &InteractionInputs<'_>,
521) -> BTreeMap<WidgetId, Interaction> {
522 frame
523 .items()
524 .iter()
525 .map(|item| (item.id, interaction_for(item, inputs)))
526 .collect()
527}
528
529fn interaction_for(item: &HitItem, inputs: &InteractionInputs<'_>) -> Interaction {
530 let pressed_buttons = inputs
531 .pressed
532 .values()
533 .filter(|rec| rec.id == item.id)
534 .fold(PointerButtonMask::EMPTY, |mask, rec| mask.with(rec.button));
535 let drag_record = inputs
536 .pressed
537 .values()
538 .find(|rec| rec.id == item.id && rec.drag_active)
539 .copied()
540 .or_else(|| {
541 inputs
542 .release_records
543 .iter()
544 .find(|rec| rec.id == item.id && rec.drag_active)
545 .copied()
546 });
547 let drag_delta = match (drag_record, inputs.pointer) {
548 (Some(rec), Some(p)) => LayoutOffset::between(rec.origin, p),
549 _ => LayoutOffset::ZERO,
550 };
551 let drag_start_event = inputs.drag_starts.iter().find(|ev| ev.id == item.id);
552 let drag_button = drag_record
553 .map(|rec| rec.button)
554 .or_else(|| drag_start_event.map(|ev| ev.button));
555 let click_match = inputs.clicks.iter().find(|ev| ev.id == item.id);
556 let state = InteractionState::NONE
557 .with(InteractionState::HOVER, inputs.hovered == Some(item.id))
558 .with(InteractionState::FOCUSED, inputs.focused == Some(item.id))
559 .with(InteractionState::PRESSED, !pressed_buttons.is_empty())
560 .with(InteractionState::DISABLED, item.disabled)
561 .with(InteractionState::ACTIVE, item.active)
562 .with(InteractionState::CLICK, click_match.is_some())
563 .with(
564 InteractionState::DOUBLE_CLICK,
565 click_match.is_some_and(|ev| ev.count.is_double()),
566 )
567 .with(InteractionState::DRAG_START, drag_start_event.is_some())
568 .with(
569 InteractionState::DRAG_RELEASE,
570 inputs.drag_releases.iter().any(|ev| ev.id == item.id),
571 );
572 Interaction {
573 state,
574 drag_delta,
575 pressed_buttons,
576 click_button: click_match.map(|ev| ev.button),
577 drag_button,
578 }
579}
580
581fn next_click_count(
582 last: Option<ClickRecord>,
583 id: WidgetId,
584 now: FrameInstant,
585 window: DoubleClickWindow,
586) -> ClickCount {
587 match last {
588 Some(prev) if prev.id == id && window.contains(now.since(prev.at)) => prev.count.next(),
589 _ => ClickCount::SINGLE,
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use core::time::Duration;
596
597 use super::{HitFrame, HitItem, HitState, Interaction, Sense, ZLayer, resolve};
598 use crate::input::{
599 DoubleClickWindow, DragThreshold, FrameInstant, InputSnapshot, PointerButton,
600 PointerButtonMask, PointerSample,
601 };
602 use crate::layout::{LayoutOffset, LayoutPos, LayoutPx, LayoutRect, LayoutSize};
603 use crate::widget_id::{WidgetId, WidgetKey};
604
605 fn id(key: &'static str) -> WidgetId {
606 WidgetId::ROOT.child(WidgetKey::new(key))
607 }
608
609 fn rect(x: f32, y: f32, w: f32, h: f32) -> LayoutRect {
610 LayoutRect::new(
611 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y)),
612 LayoutSize::new(LayoutPx::new(w), LayoutPx::new(h)),
613 )
614 }
615
616 fn pos(x: f32, y: f32) -> LayoutPos {
617 LayoutPos::new(LayoutPx::new(x), LayoutPx::new(y))
618 }
619
620 fn item(id: WidgetId, rect: LayoutRect, sense: Sense) -> HitItem {
621 HitItem {
622 id,
623 rect,
624 sense,
625 z: ZLayer::BASE,
626 disabled: false,
627 active: false,
628 }
629 }
630
631 fn snap_at(pointer: LayoutPos, frame_ms: u64) -> InputSnapshot {
632 let mut s =
633 InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(frame_ms)));
634 s.pointer = Some(PointerSample::new(pointer));
635 s
636 }
637
638 fn press_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot {
639 let mut s = snap_at(pointer, frame_ms);
640 s.buttons_pressed = PointerButtonMask::just(button);
641 s
642 }
643
644 fn release_snap(pointer: LayoutPos, frame_ms: u64, button: PointerButton) -> InputSnapshot {
645 let mut s = snap_at(pointer, frame_ms);
646 s.buttons_released = PointerButtonMask::just(button);
647 s
648 }
649
650 #[test]
651 fn hover_routes_to_topmost_item() {
652 let mut frame = HitFrame::new();
653 frame.push(item(
654 id("under"),
655 rect(0.0, 0.0, 100.0, 100.0),
656 Sense::HOVER,
657 ));
658 frame.push(HitItem {
659 z: ZLayer::new(10),
660 ..item(id("over"), rect(20.0, 20.0, 40.0, 40.0), Sense::HOVER)
661 });
662 let state = resolve(&HitState::new(), &frame, &snap_at(pos(30.0, 30.0), 0), None);
663 assert_eq!(state.hovered, Some(id("over")));
664 }
665
666 #[test]
667 fn click_fires_on_press_and_release_inside() {
668 let mut frame = HitFrame::new();
669 frame.push(item(
670 id("btn"),
671 rect(0.0, 0.0, 50.0, 50.0),
672 Sense::INTERACTIVE,
673 ));
674 let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
675 let after_press = resolve(&HitState::new(), &frame, &press, None);
676 assert!(after_press.interaction(id("btn")).pressed());
677 assert!(!after_press.interaction(id("btn")).click());
678
679 let release = release_snap(pos(15.0, 15.0), 50, PointerButton::Primary);
680 let after_release = resolve(&after_press, &frame, &release, None);
681 let i = after_release.interaction(id("btn"));
682 assert!(i.click());
683 assert!(!i.pressed());
684 assert_eq!(i.click_button, Some(PointerButton::Primary));
685 }
686
687 #[test]
688 fn release_outside_does_not_fire_click() {
689 let mut frame = HitFrame::new();
690 frame.push(item(
691 id("btn"),
692 rect(0.0, 0.0, 50.0, 50.0),
693 Sense::INTERACTIVE,
694 ));
695 let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
696 let after_press = resolve(&HitState::new(), &frame, &press, None);
697
698 let release = release_snap(pos(200.0, 200.0), 50, PointerButton::Primary);
699 let after_release = resolve(&after_press, &frame, &release, None);
700 assert!(!after_release.interaction(id("btn")).click());
701 }
702
703 #[test]
704 fn double_click_fires_within_window() {
705 let mut frame = HitFrame::new();
706 frame.push(item(
707 id("btn"),
708 rect(0.0, 0.0, 50.0, 50.0),
709 Sense::INTERACTIVE,
710 ));
711 let s1 = resolve(
712 &HitState::new(),
713 &frame,
714 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
715 None,
716 );
717 let s2 = resolve(
718 &s1,
719 &frame,
720 &release_snap(pos(5.0, 5.0), 30, PointerButton::Primary),
721 None,
722 );
723 assert!(s2.interaction(id("btn")).click());
724 assert!(!s2.interaction(id("btn")).double_click());
725
726 let s3 = resolve(
727 &s2,
728 &frame,
729 &press_snap(pos(5.0, 5.0), 60, PointerButton::Primary),
730 None,
731 );
732 let s4 = resolve(
733 &s3,
734 &frame,
735 &release_snap(pos(5.0, 5.0), 90, PointerButton::Primary),
736 None,
737 );
738 let i = s4.interaction(id("btn"));
739 assert!(i.click());
740 assert!(i.double_click());
741 }
742
743 #[test]
744 fn triple_click_keeps_double_click_flag() {
745 let mut frame = HitFrame::new();
746 frame.push(item(
747 id("btn"),
748 rect(0.0, 0.0, 50.0, 50.0),
749 Sense::INTERACTIVE,
750 ));
751 let states = (0..3).fold(HitState::new(), |state, n| {
752 let press_ms = n * 60;
753 let release_ms = press_ms + 30;
754 let s = resolve(
755 &state,
756 &frame,
757 &press_snap(pos(5.0, 5.0), press_ms, PointerButton::Primary),
758 None,
759 );
760 resolve(
761 &s,
762 &frame,
763 &release_snap(pos(5.0, 5.0), release_ms, PointerButton::Primary),
764 None,
765 )
766 });
767 let Some(last) = states.last_click.get(&PointerButton::Primary).copied() else {
768 panic!("triple-click leaves a click record");
769 };
770 assert_eq!(last.count.get(), 3);
771 assert!(last.count.is_double());
772 }
773
774 #[test]
775 fn drag_capture_routes_motion_outside_bounds() {
776 let mut frame = HitFrame::new();
777 frame.push(item(
778 id("handle"),
779 rect(0.0, 0.0, 20.0, 20.0),
780 Sense::DRAGGABLE,
781 ));
782 let s1 = resolve(
783 &HitState::new(),
784 &frame,
785 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
786 None,
787 );
788 assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle")));
789
790 let motion = snap_at(pos(200.0, 200.0), 16);
791 let s2 = resolve(&s1, &frame, &motion, None);
792 let i = s2.interaction(id("handle"));
793 assert!(i.drag_start());
794 assert_ne!(i.drag_delta, LayoutOffset::ZERO);
795 assert_eq!(i.drag_button, Some(PointerButton::Primary));
796 assert_eq!(s2.hovered, Some(id("handle")));
797 }
798
799 #[test]
800 fn drag_release_clears_capture_and_skips_click() {
801 let mut frame = HitFrame::new();
802 frame.push(item(
803 id("handle"),
804 rect(0.0, 0.0, 20.0, 20.0),
805 Sense::DRAGGABLE,
806 ));
807 let s1 = resolve(
808 &HitState::new(),
809 &frame,
810 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
811 None,
812 );
813 let motion = snap_at(pos(200.0, 200.0), 16);
814 let s2 = resolve(&s1, &frame, &motion, None);
815 let s3 = resolve(
816 &s2,
817 &frame,
818 &release_snap(pos(220.0, 220.0), 32, PointerButton::Primary),
819 None,
820 );
821 let i = s3.interaction(id("handle"));
822 assert!(i.drag_release());
823 assert!(!i.click());
824 assert!(s3.captures.is_empty());
825 }
826
827 #[test]
828 fn disabled_item_does_not_press() {
829 let mut frame = HitFrame::new();
830 frame.push(HitItem {
831 disabled: true,
832 ..item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::INTERACTIVE)
833 });
834 let press = press_snap(pos(10.0, 10.0), 0, PointerButton::Primary);
835 let s = resolve(&HitState::new(), &frame, &press, None);
836 let i = s.interaction(id("btn"));
837 assert!(!i.pressed());
838 assert!(i.disabled());
839 }
840
841 #[test]
842 fn focused_id_threads_into_interaction() {
843 let mut frame = HitFrame::new();
844 frame.push(item(id("btn"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER));
845 let s = resolve(
846 &HitState::new(),
847 &frame,
848 &snap_at(pos(10.0, 10.0), 0),
849 Some(id("btn")),
850 );
851 assert!(s.interaction(id("btn")).focused());
852 }
853
854 #[test]
855 fn active_flag_threads_from_hit_item() {
856 let mut frame = HitFrame::new();
857 frame.push(HitItem {
858 active: true,
859 ..item(id("toggle"), rect(0.0, 0.0, 50.0, 50.0), Sense::HOVER)
860 });
861 let s = resolve(&HitState::new(), &frame, &snap_at(pos(10.0, 10.0), 0), None);
862 assert!(s.interaction(id("toggle")).active());
863 }
864
865 #[test]
866 fn interaction_idle_default() {
867 let i = Interaction::idle();
868 assert!(!i.hover());
869 assert!(!i.click());
870 assert_eq!(i.drag_delta, LayoutOffset::ZERO);
871 assert!(i.pressed_buttons.is_empty());
872 assert_eq!(i.click_button, None);
873 assert_eq!(i.drag_button, None);
874 }
875
876 #[test]
877 fn pressed_buttons_records_each_button_held() {
878 let mut frame = HitFrame::new();
879 frame.push(item(
880 id("btn"),
881 rect(0.0, 0.0, 50.0, 50.0),
882 Sense::INTERACTIVE,
883 ));
884 let mut snap = snap_at(pos(10.0, 10.0), 0);
885 snap.buttons_pressed =
886 PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary);
887 let s = resolve(&HitState::new(), &frame, &snap, None);
888 let i = s.interaction(id("btn"));
889 assert!(i.pressed_buttons.contains(PointerButton::Primary));
890 assert!(i.pressed_buttons.contains(PointerButton::Secondary));
891 assert!(!i.pressed_buttons.contains(PointerButton::Middle));
892 }
893
894 #[test]
895 #[should_panic(expected = "Sense::PRESS implies Sense::HOVER")]
896 fn press_without_hover_panics_on_push() {
897 let mut frame = HitFrame::new();
898 frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::PRESS));
899 }
900
901 #[test]
902 #[should_panic(expected = "duplicate WidgetId in HitFrame")]
903 fn duplicate_widget_id_in_frame_panics() {
904 let mut frame = HitFrame::new();
905 frame.push(item(id("dup"), rect(0.0, 0.0, 10.0, 10.0), Sense::HOVER));
906 frame.push(item(id("dup"), rect(20.0, 0.0, 10.0, 10.0), Sense::HOVER));
907 }
908
909 #[test]
910 fn release_button_clears_all_captures_for_that_button() {
911 let mut stack = super::PointerCaptureStack::new();
912 stack.push(super::PointerCapture {
913 id: id("ghost"),
914 button: PointerButton::Primary,
915 });
916 stack.push(super::PointerCapture {
917 id: id("active"),
918 button: PointerButton::Primary,
919 });
920 stack.push(super::PointerCapture {
921 id: id("middle"),
922 button: PointerButton::Middle,
923 });
924 stack.release_button(PointerButton::Primary);
925 assert_eq!(stack.captures().len(), 1);
926 assert_eq!(stack.captures()[0].button, PointerButton::Middle);
927 }
928
929 #[test]
930 fn drag_start_skipped_when_widget_disables_mid_press() {
931 let id_h = id("handle");
932 let mut frame = HitFrame::new();
933 frame.push(item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE));
934 let s1 = resolve(
935 &HitState::new(),
936 &frame,
937 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
938 None,
939 );
940 let mut disabled_frame = HitFrame::new();
941 disabled_frame.push(HitItem {
942 disabled: true,
943 ..item(id_h, rect(0.0, 0.0, 20.0, 20.0), Sense::DRAGGABLE)
944 });
945 let s2 = resolve(&s1, &disabled_frame, &snap_at(pos(80.0, 80.0), 16), None);
946 assert!(!s2.interaction(id_h).drag_start());
947 }
948
949 #[test]
950 fn double_click_window_default() {
951 assert_eq!(
952 DoubleClickWindow::DEFAULT.duration(),
953 Duration::from_millis(400)
954 );
955 }
956
957 #[test]
958 fn drag_threshold_default_4_px() {
959 assert_eq!(DragThreshold::DEFAULT.px(), LayoutPx::new(4.0));
960 }
961
962 #[test]
963 fn secondary_button_press_release_fires_click() {
964 let mut frame = HitFrame::new();
965 frame.push(item(
966 id("btn"),
967 rect(0.0, 0.0, 50.0, 50.0),
968 Sense::INTERACTIVE,
969 ));
970 let s1 = resolve(
971 &HitState::new(),
972 &frame,
973 &press_snap(pos(10.0, 10.0), 0, PointerButton::Secondary),
974 None,
975 );
976 assert!(s1.interaction(id("btn")).pressed());
977 let s2 = resolve(
978 &s1,
979 &frame,
980 &release_snap(pos(10.0, 10.0), 30, PointerButton::Secondary),
981 None,
982 );
983 let i = s2.interaction(id("btn"));
984 assert!(i.click());
985 assert_eq!(i.click_button, Some(PointerButton::Secondary));
986 }
987
988 #[test]
989 fn two_buttons_pressed_in_same_snapshot() {
990 let mut frame = HitFrame::new();
991 frame.push(item(
992 id("btn"),
993 rect(0.0, 0.0, 50.0, 50.0),
994 Sense::INTERACTIVE,
995 ));
996 let mut snap = snap_at(pos(10.0, 10.0), 0);
997 snap.buttons_pressed =
998 PointerButtonMask::just(PointerButton::Primary).with(PointerButton::Secondary);
999 let s = resolve(&HitState::new(), &frame, &snap, None);
1000 assert_eq!(s.pressed.len(), 2);
1001 assert!(s.pressed.contains_key(&PointerButton::Primary));
1002 assert!(s.pressed.contains_key(&PointerButton::Secondary));
1003 assert!(s.interaction(id("btn")).pressed());
1004 }
1005
1006 #[test]
1007 fn middle_button_drag_records_drag_button() {
1008 let mut frame = HitFrame::new();
1009 frame.push(item(
1010 id("viewport"),
1011 rect(0.0, 0.0, 200.0, 200.0),
1012 Sense::DRAGGABLE,
1013 ));
1014 let s1 = resolve(
1015 &HitState::new(),
1016 &frame,
1017 &press_snap(pos(50.0, 50.0), 0, PointerButton::Middle),
1018 None,
1019 );
1020 let motion = snap_at(pos(120.0, 120.0), 16);
1021 let s2 = resolve(&s1, &frame, &motion, None);
1022 let i = s2.interaction(id("viewport"));
1023 assert!(i.drag_start());
1024 assert_eq!(i.drag_button, Some(PointerButton::Middle));
1025 }
1026
1027 #[test]
1028 #[should_panic(expected = "Sense::DRAG implies Sense::PRESS")]
1029 fn drag_without_press_panics_on_push() {
1030 let mut frame = HitFrame::new();
1031 frame.push(item(id("rogue"), rect(0.0, 0.0, 10.0, 10.0), Sense::DRAG));
1032 }
1033
1034 #[test]
1035 fn drag_delta_falls_back_to_last_pointer_when_pointer_drops() {
1036 let mut frame = HitFrame::new();
1037 frame.push(item(
1038 id("handle"),
1039 rect(0.0, 0.0, 20.0, 20.0),
1040 Sense::DRAGGABLE,
1041 ));
1042 let s1 = resolve(
1043 &HitState::new(),
1044 &frame,
1045 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
1046 None,
1047 );
1048 let s2 = resolve(&s1, &frame, &snap_at(pos(80.0, 80.0), 16), None);
1049 assert!(s2.interaction(id("handle")).drag_start());
1050
1051 let mut idle = InputSnapshot::idle(FrameInstant::from_duration(Duration::from_millis(32)));
1052 idle.pointer = None;
1053 let s3 = resolve(&s2, &frame, &idle, None);
1054 let i = s3.interaction(id("handle"));
1055 assert_ne!(i.drag_delta, LayoutOffset::ZERO);
1056 assert_eq!(s3.last_pointer, Some(pos(80.0, 80.0)));
1057 }
1058
1059 #[test]
1060 fn ghost_capture_falls_through_to_position_routing() {
1061 let mut frame = HitFrame::new();
1062 frame.push(item(
1063 id("handle"),
1064 rect(0.0, 0.0, 20.0, 20.0),
1065 Sense::DRAGGABLE,
1066 ));
1067 let s1 = resolve(
1068 &HitState::new(),
1069 &frame,
1070 &press_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
1071 None,
1072 );
1073 assert_eq!(s1.captures.top().map(|c| c.id), Some(id("handle")));
1074
1075 let mut next_frame = HitFrame::new();
1076 next_frame.push(item(
1077 id("other"),
1078 rect(50.0, 50.0, 20.0, 20.0),
1079 Sense::HOVER,
1080 ));
1081 let s2 = resolve(&s1, &next_frame, &snap_at(pos(55.0, 55.0), 16), None);
1082 assert_eq!(s2.hovered, Some(id("other")));
1083 }
1084
1085 #[test]
1086 fn release_without_press_clears_stale_capture() {
1087 let mut frame = HitFrame::new();
1088 frame.push(item(
1089 id("handle"),
1090 rect(0.0, 0.0, 20.0, 20.0),
1091 Sense::DRAGGABLE,
1092 ));
1093 let mut prev = HitState::new();
1094 prev.captures.push(super::PointerCapture {
1095 id: id("handle"),
1096 button: PointerButton::Primary,
1097 });
1098 let s = resolve(
1099 &prev,
1100 &frame,
1101 &release_snap(pos(5.0, 5.0), 0, PointerButton::Primary),
1102 None,
1103 );
1104 assert!(s.captures.is_empty());
1105 }
1106}