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