1#![forbid(unsafe_code)]
2
3use ftui_layout::{
12 PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS, PANE_DRAG_RESIZE_DEFAULT_THRESHOLD, PaneCancelReason,
13 PaneDragResizeMachine, PaneDragResizeMachineError, PaneDragResizeState,
14 PaneDragResizeTransition, PaneInertialThrow, PaneModifierSnapshot, PaneMotionVector,
15 PanePointerButton, PanePointerPosition, PanePressureSnapProfile, PaneResizeTarget,
16 PaneSemanticInputEvent, PaneSemanticInputEventKind,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct PanePointerCaptureConfig {
22 pub drag_threshold: u16,
24 pub update_hysteresis: u16,
26 pub activation_button: PanePointerButton,
28 pub cancel_on_leave_without_capture: bool,
30}
31
32impl Default for PanePointerCaptureConfig {
33 fn default() -> Self {
34 Self {
35 drag_threshold: PANE_DRAG_RESIZE_DEFAULT_THRESHOLD,
36 update_hysteresis: PANE_DRAG_RESIZE_DEFAULT_HYSTERESIS,
37 activation_button: PanePointerButton::Primary,
38 cancel_on_leave_without_capture: true,
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum CaptureState {
45 Requested,
46 Acquired,
47}
48
49impl CaptureState {
50 const fn is_acquired(self) -> bool {
51 matches!(self, Self::Acquired)
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56struct ActivePointerCapture {
57 pointer_id: u32,
58 target: PaneResizeTarget,
59 button: PanePointerButton,
60 last_position: PanePointerPosition,
61 cumulative_delta_x: i32,
62 cumulative_delta_y: i32,
63 direction_changes: u16,
64 sample_count: u32,
65 previous_step_sign_x: i8,
66 previous_step_sign_y: i8,
67 capture_state: CaptureState,
68}
69
70impl ActivePointerCapture {
71 fn new(
72 pointer_id: u32,
73 target: PaneResizeTarget,
74 button: PanePointerButton,
75 position: PanePointerPosition,
76 ) -> Self {
77 Self {
78 pointer_id,
79 target,
80 button,
81 last_position: position,
82 cumulative_delta_x: 0,
83 cumulative_delta_y: 0,
84 direction_changes: 0,
85 sample_count: 0,
86 previous_step_sign_x: 0,
87 previous_step_sign_y: 0,
88 capture_state: CaptureState::Requested,
89 }
90 }
91
92 const fn delta_sign(delta: i32) -> i8 {
93 if delta > 0 {
94 1
95 } else if delta < 0 {
96 -1
97 } else {
98 0
99 }
100 }
101
102 fn record_pointer_step(&mut self, position: PanePointerPosition) {
103 let step_delta_x = position.x.saturating_sub(self.last_position.x);
104 let step_delta_y = position.y.saturating_sub(self.last_position.y);
105 let step_sign_x = Self::delta_sign(step_delta_x);
106 let step_sign_y = Self::delta_sign(step_delta_y);
107
108 if self.sample_count > 0
109 && ((step_sign_x != 0
110 && self.previous_step_sign_x != 0
111 && step_sign_x != self.previous_step_sign_x)
112 || (step_sign_y != 0
113 && self.previous_step_sign_y != 0
114 && step_sign_y != self.previous_step_sign_y))
115 {
116 self.direction_changes = self.direction_changes.saturating_add(1);
117 }
118
119 self.cumulative_delta_x = self.cumulative_delta_x.saturating_add(step_delta_x);
120 self.cumulative_delta_y = self.cumulative_delta_y.saturating_add(step_delta_y);
121 self.sample_count = self.sample_count.saturating_add(1);
122 self.previous_step_sign_x = step_sign_x;
123 self.previous_step_sign_y = step_sign_y;
124 }
125
126 fn motion_summary(&self) -> PaneMotionVector {
127 PaneMotionVector::from_delta(
128 self.cumulative_delta_x,
129 self.cumulative_delta_y,
130 self.sample_count.saturating_mul(16),
131 self.direction_changes,
132 )
133 }
134
135 fn release_command(self) -> Option<PanePointerCaptureCommand> {
136 self.capture_state
137 .is_acquired()
138 .then_some(PanePointerCaptureCommand::Release {
139 pointer_id: self.pointer_id,
140 })
141 }
142
143 fn finish_gesture(
144 self,
145 position: PanePointerPosition,
146 ) -> (PaneMotionVector, PaneInertialThrow, PanePointerPosition) {
147 let motion = self.motion_summary();
148 let inertial_throw = PaneInertialThrow::from_motion(motion);
149 let projected_position = inertial_throw.projected_pointer(position);
150 (motion, inertial_throw, projected_position)
151 }
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155struct DispatchContext {
156 phase: PanePointerLifecyclePhase,
157 pointer_id: Option<u32>,
158 target: Option<PaneResizeTarget>,
159 position: Option<PanePointerPosition>,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum PanePointerCaptureCommand {
165 Acquire { pointer_id: u32 },
166 Release { pointer_id: u32 },
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum PanePointerLifecyclePhase {
172 PointerDown,
173 PointerMove,
174 PointerUp,
175 PointerCancel,
176 PointerLeave,
177 Blur,
178 VisibilityHidden,
179 LostPointerCapture,
180 ContextLost,
181 RenderStalled,
182 CaptureAcquired,
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum PanePointerIgnoredReason {
188 InvalidPointerId,
189 ButtonNotAllowed,
190 ButtonMismatch,
191 ActivePointerAlreadyInProgress,
192 NoActivePointer,
193 PointerMismatch,
194 LeaveWhileCaptured,
195 MachineRejectedEvent,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum PanePointerLogOutcome {
201 SemanticForwarded,
202 CaptureStateUpdated,
203 Ignored(PanePointerIgnoredReason),
204}
205
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub struct PanePointerLogEntry {
209 pub phase: PanePointerLifecyclePhase,
210 pub sequence: Option<u64>,
211 pub pointer_id: Option<u32>,
212 pub target: Option<PaneResizeTarget>,
213 pub position: Option<PanePointerPosition>,
214 pub capture_command: Option<PanePointerCaptureCommand>,
215 pub outcome: PanePointerLogOutcome,
216}
217
218#[derive(Debug, Clone, PartialEq)]
220pub struct PanePointerDispatch {
221 pub semantic_event: Option<PaneSemanticInputEvent>,
222 pub transition: Option<PaneDragResizeTransition>,
223 pub motion: Option<PaneMotionVector>,
224 pub inertial_throw: Option<PaneInertialThrow>,
225 pub projected_position: Option<PanePointerPosition>,
226 pub capture_command: Option<PanePointerCaptureCommand>,
227 pub log: PanePointerLogEntry,
228}
229
230impl PanePointerDispatch {
231 #[must_use]
233 pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
234 self.motion.map(PanePressureSnapProfile::from_motion)
235 }
236
237 fn ignored(
238 phase: PanePointerLifecyclePhase,
239 reason: PanePointerIgnoredReason,
240 pointer_id: Option<u32>,
241 target: Option<PaneResizeTarget>,
242 position: Option<PanePointerPosition>,
243 ) -> Self {
244 Self {
245 semantic_event: None,
246 transition: None,
247 motion: None,
248 inertial_throw: None,
249 projected_position: None,
250 capture_command: None,
251 log: PanePointerLogEntry {
252 phase,
253 sequence: None,
254 pointer_id,
255 target,
256 position,
257 capture_command: None,
258 outcome: PanePointerLogOutcome::Ignored(reason),
259 },
260 }
261 }
262
263 fn capture_state_updated(
264 phase: PanePointerLifecyclePhase,
265 pointer_id: u32,
266 target: PaneResizeTarget,
267 ) -> Self {
268 Self {
269 semantic_event: None,
270 transition: None,
271 motion: None,
272 inertial_throw: None,
273 projected_position: None,
274 capture_command: None,
275 log: PanePointerLogEntry {
276 phase,
277 sequence: None,
278 pointer_id: Some(pointer_id),
279 target: Some(target),
280 position: None,
281 capture_command: None,
282 outcome: PanePointerLogOutcome::CaptureStateUpdated,
283 },
284 }
285 }
286}
287
288#[derive(Debug, Clone)]
294pub struct PanePointerCaptureAdapter {
295 machine: PaneDragResizeMachine,
296 config: PanePointerCaptureConfig,
297 active: Option<ActivePointerCapture>,
298 next_sequence: u64,
299}
300
301impl Default for PanePointerCaptureAdapter {
302 fn default() -> Self {
303 Self {
304 machine: PaneDragResizeMachine::default(),
305 config: PanePointerCaptureConfig::default(),
306 active: None,
307 next_sequence: 1,
308 }
309 }
310}
311
312impl PanePointerCaptureAdapter {
313 pub fn new(config: PanePointerCaptureConfig) -> Result<Self, PaneDragResizeMachineError> {
315 let machine = PaneDragResizeMachine::new_with_hysteresis(
316 config.drag_threshold,
317 config.update_hysteresis,
318 )?;
319 Ok(Self {
320 machine,
321 config,
322 active: None,
323 next_sequence: 1,
324 })
325 }
326
327 #[must_use]
329 pub const fn config(&self) -> PanePointerCaptureConfig {
330 self.config
331 }
332
333 #[must_use]
335 pub fn active_pointer_id(&self) -> Option<u32> {
336 self.active.map(|active| active.pointer_id)
337 }
338
339 #[must_use]
341 pub const fn machine_state(&self) -> PaneDragResizeState {
342 self.machine.state()
343 }
344
345 #[allow(clippy::result_large_err)]
346 fn active_for_pointer(
347 &self,
348 phase: PanePointerLifecyclePhase,
349 pointer_id: u32,
350 position: Option<PanePointerPosition>,
351 ) -> Result<ActivePointerCapture, PanePointerDispatch> {
352 let Some(active) = self.active else {
353 return Err(PanePointerDispatch::ignored(
354 phase,
355 PanePointerIgnoredReason::NoActivePointer,
356 Some(pointer_id),
357 None,
358 position,
359 ));
360 };
361 if active.pointer_id != pointer_id {
362 return Err(PanePointerDispatch::ignored(
363 phase,
364 PanePointerIgnoredReason::PointerMismatch,
365 Some(pointer_id),
366 Some(active.target),
367 position,
368 ));
369 }
370 Ok(active)
371 }
372
373 pub fn pointer_down(
375 &mut self,
376 target: PaneResizeTarget,
377 pointer_id: u32,
378 button: PanePointerButton,
379 position: PanePointerPosition,
380 modifiers: PaneModifierSnapshot,
381 ) -> PanePointerDispatch {
382 if pointer_id == 0 {
383 return PanePointerDispatch::ignored(
384 PanePointerLifecyclePhase::PointerDown,
385 PanePointerIgnoredReason::InvalidPointerId,
386 Some(pointer_id),
387 Some(target),
388 Some(position),
389 );
390 }
391 if button != self.config.activation_button {
392 return PanePointerDispatch::ignored(
393 PanePointerLifecyclePhase::PointerDown,
394 PanePointerIgnoredReason::ButtonNotAllowed,
395 Some(pointer_id),
396 Some(target),
397 Some(position),
398 );
399 }
400 if self.active.is_some() {
401 return PanePointerDispatch::ignored(
402 PanePointerLifecyclePhase::PointerDown,
403 PanePointerIgnoredReason::ActivePointerAlreadyInProgress,
404 Some(pointer_id),
405 Some(target),
406 Some(position),
407 );
408 }
409
410 let kind = PaneSemanticInputEventKind::PointerDown {
411 target,
412 pointer_id,
413 button,
414 position,
415 };
416 let dispatch = self.forward_semantic(
417 DispatchContext {
418 phase: PanePointerLifecyclePhase::PointerDown,
419 pointer_id: Some(pointer_id),
420 target: Some(target),
421 position: Some(position),
422 },
423 kind,
424 modifiers,
425 Some(PanePointerCaptureCommand::Acquire { pointer_id }),
426 );
427 if dispatch.transition.is_some() {
428 self.active = Some(ActivePointerCapture::new(
429 pointer_id, target, button, position,
430 ));
431 }
432 dispatch
433 }
434
435 pub fn capture_acquired(&mut self, pointer_id: u32) -> PanePointerDispatch {
437 let mut active = match self.active_for_pointer(
438 PanePointerLifecyclePhase::CaptureAcquired,
439 pointer_id,
440 None,
441 ) {
442 Ok(active) => active,
443 Err(dispatch) => return dispatch,
444 };
445 active.capture_state = CaptureState::Acquired;
446 self.active = Some(active);
447 PanePointerDispatch::capture_state_updated(
448 PanePointerLifecyclePhase::CaptureAcquired,
449 pointer_id,
450 active.target,
451 )
452 }
453
454 pub fn pointer_move(
456 &mut self,
457 pointer_id: u32,
458 position: PanePointerPosition,
459 modifiers: PaneModifierSnapshot,
460 ) -> PanePointerDispatch {
461 let mut active = match self.active_for_pointer(
462 PanePointerLifecyclePhase::PointerMove,
463 pointer_id,
464 Some(position),
465 ) {
466 Ok(active) => active,
467 Err(dispatch) => return dispatch,
468 };
469
470 let kind = PaneSemanticInputEventKind::PointerMove {
471 target: active.target,
472 pointer_id,
473 position,
474 delta_x: position.x.saturating_sub(active.last_position.x),
475 delta_y: position.y.saturating_sub(active.last_position.y),
476 };
477 active.record_pointer_step(position);
478
479 let mut dispatch = self.forward_semantic(
480 DispatchContext {
481 phase: PanePointerLifecyclePhase::PointerMove,
482 pointer_id: Some(pointer_id),
483 target: Some(active.target),
484 position: Some(position),
485 },
486 kind,
487 modifiers,
488 None,
489 );
490 if dispatch.transition.is_some() {
491 active.last_position = position;
492 self.active = Some(active);
493 dispatch.motion = Some(active.motion_summary());
494 }
495 dispatch
496 }
497
498 pub fn pointer_up(
500 &mut self,
501 pointer_id: u32,
502 button: PanePointerButton,
503 position: PanePointerPosition,
504 modifiers: PaneModifierSnapshot,
505 ) -> PanePointerDispatch {
506 let active = match self.active_for_pointer(
507 PanePointerLifecyclePhase::PointerUp,
508 pointer_id,
509 Some(position),
510 ) {
511 Ok(active) => active,
512 Err(dispatch) => return dispatch,
513 };
514 if active.button != button {
515 return PanePointerDispatch::ignored(
516 PanePointerLifecyclePhase::PointerUp,
517 PanePointerIgnoredReason::ButtonMismatch,
518 Some(pointer_id),
519 Some(active.target),
520 Some(position),
521 );
522 }
523
524 let kind = PaneSemanticInputEventKind::PointerUp {
525 target: active.target,
526 pointer_id,
527 button: active.button,
528 position,
529 };
530 let mut dispatch = self.forward_semantic(
531 DispatchContext {
532 phase: PanePointerLifecyclePhase::PointerUp,
533 pointer_id: Some(pointer_id),
534 target: Some(active.target),
535 position: Some(position),
536 },
537 kind,
538 modifiers,
539 active.release_command(),
540 );
541 if dispatch.transition.is_some() {
542 let (motion, inertial, projected_position) = active.finish_gesture(position);
543 dispatch.motion = Some(motion);
544 dispatch.projected_position = Some(projected_position);
545 dispatch.inertial_throw = Some(inertial);
546 self.active = None;
547 }
548 dispatch
549 }
550
551 pub fn pointer_cancel(&mut self, pointer_id: Option<u32>) -> PanePointerDispatch {
553 self.cancel_active(
554 PanePointerLifecyclePhase::PointerCancel,
555 pointer_id,
556 PaneCancelReason::PointerCancel,
557 true,
558 )
559 }
560
561 pub fn pointer_leave(&mut self, pointer_id: u32) -> PanePointerDispatch {
563 let active = match self.active_for_pointer(
564 PanePointerLifecyclePhase::PointerLeave,
565 pointer_id,
566 None,
567 ) {
568 Ok(active) => active,
569 Err(dispatch) => return dispatch,
570 };
571
572 if matches!(active.capture_state, CaptureState::Requested)
573 && self.config.cancel_on_leave_without_capture
574 {
575 self.cancel_active(
576 PanePointerLifecyclePhase::PointerLeave,
577 Some(pointer_id),
578 PaneCancelReason::PointerCancel,
579 true,
580 )
581 } else {
582 PanePointerDispatch::ignored(
583 PanePointerLifecyclePhase::PointerLeave,
584 PanePointerIgnoredReason::LeaveWhileCaptured,
585 Some(pointer_id),
586 Some(active.target),
587 None,
588 )
589 }
590 }
591
592 pub fn blur(&mut self) -> PanePointerDispatch {
594 let Some(active) = self.active else {
595 return PanePointerDispatch::ignored(
596 PanePointerLifecyclePhase::Blur,
597 PanePointerIgnoredReason::NoActivePointer,
598 None,
599 None,
600 None,
601 );
602 };
603 let kind = PaneSemanticInputEventKind::Blur {
604 target: Some(active.target),
605 };
606 let dispatch = self.forward_semantic(
607 DispatchContext {
608 phase: PanePointerLifecyclePhase::Blur,
609 pointer_id: Some(active.pointer_id),
610 target: Some(active.target),
611 position: None,
612 },
613 kind,
614 PaneModifierSnapshot::default(),
615 active.release_command(),
616 );
617 if dispatch.transition.is_some() {
618 self.active = None;
619 }
620 dispatch
621 }
622
623 pub fn visibility_hidden(&mut self) -> PanePointerDispatch {
625 self.cancel_active(
626 PanePointerLifecyclePhase::VisibilityHidden,
627 None,
628 PaneCancelReason::FocusLost,
629 true,
630 )
631 }
632
633 pub fn lost_pointer_capture(&mut self, pointer_id: u32) -> PanePointerDispatch {
635 self.cancel_active(
636 PanePointerLifecyclePhase::LostPointerCapture,
637 Some(pointer_id),
638 PaneCancelReason::PointerCancel,
639 false,
640 )
641 }
642
643 pub fn context_lost(&mut self) -> PanePointerDispatch {
645 self.cancel_active(
646 PanePointerLifecyclePhase::ContextLost,
647 None,
648 PaneCancelReason::ContextLost,
649 true,
650 )
651 }
652
653 pub fn render_stalled(&mut self) -> PanePointerDispatch {
655 self.cancel_active(
656 PanePointerLifecyclePhase::RenderStalled,
657 None,
658 PaneCancelReason::RenderStalled,
659 true,
660 )
661 }
662
663 fn cancel_active(
664 &mut self,
665 phase: PanePointerLifecyclePhase,
666 pointer_id: Option<u32>,
667 reason: PaneCancelReason,
668 release_capture: bool,
669 ) -> PanePointerDispatch {
670 let Some(active) = self.active else {
671 return PanePointerDispatch::ignored(
672 phase,
673 PanePointerIgnoredReason::NoActivePointer,
674 pointer_id,
675 None,
676 None,
677 );
678 };
679 if let Some(id) = pointer_id
680 && id != active.pointer_id
681 {
682 return PanePointerDispatch::ignored(
683 phase,
684 PanePointerIgnoredReason::PointerMismatch,
685 Some(id),
686 Some(active.target),
687 None,
688 );
689 }
690
691 let kind = PaneSemanticInputEventKind::Cancel {
692 target: Some(active.target),
693 reason,
694 };
695 let command = if release_capture {
696 active.release_command()
697 } else {
698 None
699 };
700 let dispatch = self.forward_semantic(
701 DispatchContext {
702 phase,
703 pointer_id: Some(active.pointer_id),
704 target: Some(active.target),
705 position: None,
706 },
707 kind,
708 PaneModifierSnapshot::default(),
709 command,
710 );
711 if dispatch.transition.is_some() {
712 self.active = None;
713 }
714 dispatch
715 }
716
717 fn forward_semantic(
718 &mut self,
719 context: DispatchContext,
720 kind: PaneSemanticInputEventKind,
721 modifiers: PaneModifierSnapshot,
722 capture_command: Option<PanePointerCaptureCommand>,
723 ) -> PanePointerDispatch {
724 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
725 event.modifiers = modifiers;
726 match self.machine.apply_event(&event) {
727 Ok(transition) => {
728 let sequence = Some(event.sequence);
729 PanePointerDispatch {
730 semantic_event: Some(event),
731 transition: Some(transition),
732 motion: None,
733 inertial_throw: None,
734 projected_position: None,
735 capture_command,
736 log: PanePointerLogEntry {
737 phase: context.phase,
738 sequence,
739 pointer_id: context.pointer_id,
740 target: context.target,
741 position: context.position,
742 capture_command,
743 outcome: PanePointerLogOutcome::SemanticForwarded,
744 },
745 }
746 }
747 Err(_error) => PanePointerDispatch::ignored(
748 context.phase,
749 PanePointerIgnoredReason::MachineRejectedEvent,
750 context.pointer_id,
751 context.target,
752 context.position,
753 ),
754 }
755 }
756
757 fn next_sequence(&mut self) -> u64 {
758 let sequence = self.next_sequence;
759 self.next_sequence = self.next_sequence.saturating_add(1);
760 sequence
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::{
767 PanePointerCaptureAdapter, PanePointerCaptureCommand, PanePointerCaptureConfig,
768 PanePointerIgnoredReason, PanePointerLifecyclePhase, PanePointerLogOutcome,
769 };
770 use ftui_layout::{
771 PaneCancelReason, PaneDragResizeEffect, PaneDragResizeState, PaneId, PaneInertialThrow,
772 PaneModifierSnapshot, PaneMotionVector, PanePointerButton, PanePointerPosition,
773 PanePressureSnapProfile, PaneResizeTarget, PaneSemanticInputEventKind, SplitAxis,
774 };
775
776 fn target() -> PaneResizeTarget {
777 PaneResizeTarget {
778 split_id: PaneId::MIN,
779 axis: SplitAxis::Horizontal,
780 }
781 }
782
783 fn pos(x: i32, y: i32) -> PanePointerPosition {
784 PanePointerPosition::new(x, y)
785 }
786
787 fn adapter() -> PanePointerCaptureAdapter {
788 PanePointerCaptureAdapter::new(PanePointerCaptureConfig::default())
789 .expect("default config should be valid")
790 }
791
792 #[test]
793 fn pointer_down_arms_machine_and_requests_capture() {
794 let mut adapter = adapter();
795 let dispatch = adapter.pointer_down(
796 target(),
797 11,
798 PanePointerButton::Primary,
799 pos(5, 8),
800 PaneModifierSnapshot::default(),
801 );
802 assert_eq!(
803 dispatch.capture_command,
804 Some(PanePointerCaptureCommand::Acquire { pointer_id: 11 })
805 );
806 assert_eq!(adapter.active_pointer_id(), Some(11));
807 assert!(matches!(
808 adapter.machine_state(),
809 PaneDragResizeState::Armed { pointer_id: 11, .. }
810 ));
811 assert!(matches!(
812 dispatch
813 .transition
814 .as_ref()
815 .expect("transition should exist")
816 .effect,
817 PaneDragResizeEffect::Armed { pointer_id: 11, .. }
818 ));
819 }
820
821 #[test]
822 fn non_activation_button_is_ignored_deterministically() {
823 let mut adapter = adapter();
824 let dispatch = adapter.pointer_down(
825 target(),
826 3,
827 PanePointerButton::Secondary,
828 pos(1, 1),
829 PaneModifierSnapshot::default(),
830 );
831 assert_eq!(dispatch.semantic_event, None);
832 assert_eq!(dispatch.transition, None);
833 assert_eq!(dispatch.capture_command, None);
834 assert_eq!(adapter.active_pointer_id(), None);
835 assert_eq!(
836 dispatch.log.outcome,
837 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonNotAllowed)
838 );
839 }
840
841 #[test]
842 fn pointer_move_mismatch_is_ignored_without_state_mutation() {
843 let mut adapter = adapter();
844 adapter.pointer_down(
845 target(),
846 9,
847 PanePointerButton::Primary,
848 pos(10, 10),
849 PaneModifierSnapshot::default(),
850 );
851 let before = adapter.machine_state();
852 let dispatch = adapter.pointer_move(77, pos(14, 14), PaneModifierSnapshot::default());
853 assert_eq!(dispatch.semantic_event, None);
854 assert_eq!(
855 dispatch.log.outcome,
856 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::PointerMismatch)
857 );
858 assert_eq!(before, adapter.machine_state());
859 assert_eq!(adapter.active_pointer_id(), Some(9));
860 }
861
862 #[test]
863 fn pointer_up_releases_capture_and_returns_idle() {
864 let mut adapter = adapter();
865 adapter.pointer_down(
866 target(),
867 9,
868 PanePointerButton::Primary,
869 pos(1, 1),
870 PaneModifierSnapshot::default(),
871 );
872 let ack = adapter.capture_acquired(9);
873 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
874 let dispatch = adapter.pointer_up(
875 9,
876 PanePointerButton::Primary,
877 pos(6, 1),
878 PaneModifierSnapshot::default(),
879 );
880 assert_eq!(
881 dispatch.capture_command,
882 Some(PanePointerCaptureCommand::Release { pointer_id: 9 })
883 );
884 assert_eq!(adapter.active_pointer_id(), None);
885 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
886 assert!(matches!(
887 dispatch
888 .semantic_event
889 .as_ref()
890 .expect("semantic event expected")
891 .kind,
892 PaneSemanticInputEventKind::PointerUp { pointer_id: 9, .. }
893 ));
894 }
895
896 #[test]
897 fn pointer_move_emits_motion_and_pressure_snap_profile() {
898 let mut adapter = adapter();
899 adapter.pointer_down(
900 target(),
901 17,
902 PanePointerButton::Primary,
903 pos(4, 4),
904 PaneModifierSnapshot::default(),
905 );
906
907 let dispatch = adapter.pointer_move(17, pos(18, 8), PaneModifierSnapshot::default());
908 let expected_motion = PaneMotionVector::from_delta(14, 4, 16, 0);
909
910 assert_eq!(dispatch.motion, Some(expected_motion));
911 assert_eq!(
912 dispatch.pressure_snap_profile(),
913 Some(PanePressureSnapProfile::from_motion(expected_motion))
914 );
915 }
916
917 #[test]
918 fn pointer_move_tracks_direction_changes_in_motion_summary() {
919 let mut adapter = adapter();
920 adapter.pointer_down(
921 target(),
922 23,
923 PanePointerButton::Primary,
924 pos(10, 10),
925 PaneModifierSnapshot::default(),
926 );
927
928 let first = adapter.pointer_move(23, pos(24, 10), PaneModifierSnapshot::default());
929 let second = adapter.pointer_move(23, pos(18, 10), PaneModifierSnapshot::default());
930
931 assert_eq!(
932 first.motion,
933 Some(PaneMotionVector::from_delta(14, 0, 16, 0))
934 );
935 assert_eq!(
936 second.motion,
937 Some(PaneMotionVector::from_delta(8, 0, 32, 1))
938 );
939 assert!(
940 second
941 .pressure_snap_profile()
942 .expect("pressure profile should be derived from motion")
943 .strength_bps
944 < first
945 .pressure_snap_profile()
946 .expect("pressure profile should be derived from motion")
947 .strength_bps
948 );
949 }
950
951 #[test]
952 fn pointer_move_zero_delta_does_not_count_as_direction_change() {
953 let mut adapter = adapter();
954 adapter.pointer_down(
955 target(),
956 31,
957 PanePointerButton::Primary,
958 pos(10, 10),
959 PaneModifierSnapshot::default(),
960 );
961
962 let first = adapter.pointer_move(31, pos(24, 10), PaneModifierSnapshot::default());
963 let stationary = adapter.pointer_move(31, pos(24, 10), PaneModifierSnapshot::default());
964 let second = adapter.pointer_move(31, pos(18, 10), PaneModifierSnapshot::default());
965
966 assert_eq!(
967 first.motion,
968 Some(PaneMotionVector::from_delta(14, 0, 16, 0))
969 );
970 assert_eq!(
971 stationary.motion,
972 Some(PaneMotionVector::from_delta(14, 0, 32, 0))
973 );
974 assert_eq!(
975 second.motion,
976 Some(PaneMotionVector::from_delta(8, 0, 48, 0))
977 );
978 }
979
980 #[test]
981 fn pointer_up_exposes_inertial_throw_and_projected_pointer() {
982 let mut adapter = adapter();
983 adapter.pointer_down(
984 target(),
985 29,
986 PanePointerButton::Primary,
987 pos(2, 3),
988 PaneModifierSnapshot::default(),
989 );
990 let ack = adapter.capture_acquired(29);
991 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
992
993 let drag = adapter.pointer_move(29, pos(28, 11), PaneModifierSnapshot::default());
994 let release = adapter.pointer_up(
995 29,
996 PanePointerButton::Primary,
997 pos(31, 12),
998 PaneModifierSnapshot::default(),
999 );
1000 let expected_motion = drag.motion.expect("drag motion should be recorded");
1001 let expected_inertial = PaneInertialThrow::from_motion(expected_motion);
1002
1003 assert_eq!(release.motion, Some(expected_motion));
1004 assert_eq!(release.inertial_throw, Some(expected_inertial));
1005 assert_eq!(
1006 release.projected_position,
1007 Some(expected_inertial.projected_pointer(pos(31, 12)))
1008 );
1009 }
1010
1011 #[test]
1012 fn pointer_up_with_wrong_button_is_ignored() {
1013 let mut adapter = adapter();
1014 adapter.pointer_down(
1015 target(),
1016 4,
1017 PanePointerButton::Primary,
1018 pos(2, 2),
1019 PaneModifierSnapshot::default(),
1020 );
1021 let dispatch = adapter.pointer_up(
1022 4,
1023 PanePointerButton::Secondary,
1024 pos(3, 2),
1025 PaneModifierSnapshot::default(),
1026 );
1027 assert_eq!(
1028 dispatch.log.outcome,
1029 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonMismatch)
1030 );
1031 assert_eq!(adapter.active_pointer_id(), Some(4));
1032 }
1033
1034 #[test]
1035 fn blur_emits_semantic_blur_and_releases_capture() {
1036 let mut adapter = adapter();
1037 adapter.pointer_down(
1038 target(),
1039 6,
1040 PanePointerButton::Primary,
1041 pos(0, 0),
1042 PaneModifierSnapshot::default(),
1043 );
1044 let ack = adapter.capture_acquired(6);
1045 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1046 let dispatch = adapter.blur();
1047 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::Blur);
1048 assert!(matches!(
1049 dispatch
1050 .semantic_event
1051 .as_ref()
1052 .expect("semantic event expected")
1053 .kind,
1054 PaneSemanticInputEventKind::Blur { .. }
1055 ));
1056 assert_eq!(
1057 dispatch.capture_command,
1058 Some(PanePointerCaptureCommand::Release { pointer_id: 6 })
1059 );
1060 assert_eq!(adapter.active_pointer_id(), None);
1061 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1062 }
1063
1064 #[test]
1065 fn blur_before_capture_ack_clears_state_without_release() {
1066 let mut adapter = adapter();
1067 adapter.pointer_down(
1068 target(),
1069 61,
1070 PanePointerButton::Primary,
1071 pos(0, 0),
1072 PaneModifierSnapshot::default(),
1073 );
1074 let dispatch = adapter.blur();
1075 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::Blur);
1076 assert!(matches!(
1077 dispatch
1078 .semantic_event
1079 .as_ref()
1080 .expect("semantic event expected")
1081 .kind,
1082 PaneSemanticInputEventKind::Blur { .. }
1083 ));
1084 assert_eq!(dispatch.capture_command, None);
1085 assert_eq!(adapter.active_pointer_id(), None);
1086 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1087 }
1088
1089 #[test]
1090 fn visibility_hidden_emits_focus_lost_cancel() {
1091 let mut adapter = adapter();
1092 adapter.pointer_down(
1093 target(),
1094 8,
1095 PanePointerButton::Primary,
1096 pos(5, 2),
1097 PaneModifierSnapshot::default(),
1098 );
1099 let ack = adapter.capture_acquired(8);
1100 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1101 let dispatch = adapter.visibility_hidden();
1102 assert!(matches!(
1103 dispatch
1104 .semantic_event
1105 .as_ref()
1106 .expect("semantic event expected")
1107 .kind,
1108 PaneSemanticInputEventKind::Cancel {
1109 reason: PaneCancelReason::FocusLost,
1110 ..
1111 }
1112 ));
1113 assert_eq!(
1114 dispatch.capture_command,
1115 Some(PanePointerCaptureCommand::Release { pointer_id: 8 })
1116 );
1117 assert_eq!(adapter.active_pointer_id(), None);
1118 }
1119
1120 #[test]
1121 fn visibility_hidden_before_capture_ack_cancels_without_release() {
1122 let mut adapter = adapter();
1123 adapter.pointer_down(
1124 target(),
1125 81,
1126 PanePointerButton::Primary,
1127 pos(5, 2),
1128 PaneModifierSnapshot::default(),
1129 );
1130 let dispatch = adapter.visibility_hidden();
1131 assert!(matches!(
1132 dispatch
1133 .semantic_event
1134 .as_ref()
1135 .expect("semantic event expected")
1136 .kind,
1137 PaneSemanticInputEventKind::Cancel {
1138 reason: PaneCancelReason::FocusLost,
1139 ..
1140 }
1141 ));
1142 assert_eq!(dispatch.capture_command, None);
1143 assert_eq!(adapter.active_pointer_id(), None);
1144 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1145 }
1146
1147 #[test]
1148 fn lost_pointer_capture_cancels_without_double_release() {
1149 let mut adapter = adapter();
1150 adapter.pointer_down(
1151 target(),
1152 42,
1153 PanePointerButton::Primary,
1154 pos(7, 7),
1155 PaneModifierSnapshot::default(),
1156 );
1157 let dispatch = adapter.lost_pointer_capture(42);
1158 assert_eq!(dispatch.capture_command, None);
1159 assert!(matches!(
1160 dispatch
1161 .semantic_event
1162 .as_ref()
1163 .expect("semantic event expected")
1164 .kind,
1165 PaneSemanticInputEventKind::Cancel {
1166 reason: PaneCancelReason::PointerCancel,
1167 ..
1168 }
1169 ));
1170 assert_eq!(adapter.active_pointer_id(), None);
1171 }
1172
1173 #[test]
1174 fn pointer_leave_before_capture_ack_cancels() {
1175 let mut adapter = adapter();
1176 adapter.pointer_down(
1177 target(),
1178 31,
1179 PanePointerButton::Primary,
1180 pos(1, 1),
1181 PaneModifierSnapshot::default(),
1182 );
1183 let dispatch = adapter.pointer_leave(31);
1184 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::PointerLeave);
1185 assert!(matches!(
1186 dispatch
1187 .semantic_event
1188 .as_ref()
1189 .expect("semantic event expected")
1190 .kind,
1191 PaneSemanticInputEventKind::Cancel {
1192 reason: PaneCancelReason::PointerCancel,
1193 ..
1194 }
1195 ));
1196 assert_eq!(dispatch.capture_command, None);
1197 assert_eq!(adapter.active_pointer_id(), None);
1198 }
1199
1200 #[test]
1201 fn pointer_cancel_after_capture_ack_releases_and_cancels() {
1202 let mut adapter = adapter();
1203 adapter.pointer_down(
1204 target(),
1205 39,
1206 PanePointerButton::Primary,
1207 pos(3, 3),
1208 PaneModifierSnapshot::default(),
1209 );
1210 let ack = adapter.capture_acquired(39);
1211 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1212
1213 let dispatch = adapter.pointer_cancel(Some(39));
1214 assert!(matches!(
1215 dispatch
1216 .semantic_event
1217 .as_ref()
1218 .expect("semantic event expected")
1219 .kind,
1220 PaneSemanticInputEventKind::Cancel {
1221 reason: PaneCancelReason::PointerCancel,
1222 ..
1223 }
1224 ));
1225 assert_eq!(
1226 dispatch.capture_command,
1227 Some(PanePointerCaptureCommand::Release { pointer_id: 39 })
1228 );
1229 assert_eq!(adapter.active_pointer_id(), None);
1230 }
1231
1232 #[test]
1233 fn pointer_cancel_without_pointer_id_releases_active_capture() {
1234 let mut adapter = adapter();
1235 adapter.pointer_down(
1236 target(),
1237 74,
1238 PanePointerButton::Primary,
1239 pos(2, 3),
1240 PaneModifierSnapshot::default(),
1241 );
1242 let ack = adapter.capture_acquired(74);
1243 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1244 let dispatch = adapter.pointer_cancel(None);
1245 assert!(matches!(
1246 dispatch
1247 .semantic_event
1248 .as_ref()
1249 .expect("semantic event expected")
1250 .kind,
1251 PaneSemanticInputEventKind::Cancel {
1252 reason: PaneCancelReason::PointerCancel,
1253 ..
1254 }
1255 ));
1256 assert_eq!(
1257 dispatch.capture_command,
1258 Some(PanePointerCaptureCommand::Release { pointer_id: 74 })
1259 );
1260 assert_eq!(adapter.active_pointer_id(), None);
1261 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1262 }
1263
1264 #[test]
1265 fn pointer_leave_after_capture_ack_is_ignored() {
1266 let mut adapter = adapter();
1267 adapter.pointer_down(
1268 target(),
1269 55,
1270 PanePointerButton::Primary,
1271 pos(4, 4),
1272 PaneModifierSnapshot::default(),
1273 );
1274 let ack = adapter.capture_acquired(55);
1275 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1276
1277 let dispatch = adapter.pointer_leave(55);
1278 assert_eq!(dispatch.semantic_event, None);
1279 assert_eq!(
1280 dispatch.log.outcome,
1281 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::LeaveWhileCaptured)
1282 );
1283 assert_eq!(adapter.active_pointer_id(), Some(55));
1284 }
1285
1286 #[test]
1287 fn context_lost_releases_capture_and_cancels_with_explicit_reason() {
1288 let mut adapter = adapter();
1289 adapter.pointer_down(
1290 target(),
1291 91,
1292 PanePointerButton::Primary,
1293 pos(5, 5),
1294 PaneModifierSnapshot::default(),
1295 );
1296 let ack = adapter.capture_acquired(91);
1297 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1298
1299 let dispatch = adapter.context_lost();
1300
1301 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::ContextLost);
1302 assert!(matches!(
1303 dispatch
1304 .semantic_event
1305 .as_ref()
1306 .expect("semantic event expected")
1307 .kind,
1308 PaneSemanticInputEventKind::Cancel {
1309 reason: PaneCancelReason::ContextLost,
1310 ..
1311 }
1312 ));
1313 assert_eq!(
1314 dispatch.capture_command,
1315 Some(PanePointerCaptureCommand::Release { pointer_id: 91 })
1316 );
1317 assert_eq!(adapter.active_pointer_id(), None);
1318 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1319 }
1320
1321 #[test]
1322 fn render_stalled_before_capture_ack_cancels_without_release() {
1323 let mut adapter = adapter();
1324 adapter.pointer_down(
1325 target(),
1326 92,
1327 PanePointerButton::Primary,
1328 pos(8, 6),
1329 PaneModifierSnapshot::default(),
1330 );
1331
1332 let dispatch = adapter.render_stalled();
1333
1334 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::RenderStalled);
1335 assert!(matches!(
1336 dispatch
1337 .semantic_event
1338 .as_ref()
1339 .expect("semantic event expected")
1340 .kind,
1341 PaneSemanticInputEventKind::Cancel {
1342 reason: PaneCancelReason::RenderStalled,
1343 ..
1344 }
1345 ));
1346 assert_eq!(dispatch.capture_command, None);
1347 assert_eq!(adapter.active_pointer_id(), None);
1348 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1349 }
1350}