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