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) -> (i32, i32) {
110 let step_delta_x = position.x.saturating_sub(self.last_position.x);
111 let step_delta_y = position.y.saturating_sub(self.last_position.y);
112 let step_sign_x = Self::delta_sign(step_delta_x);
113 let step_sign_y = Self::delta_sign(step_delta_y);
114
115 if self.sample_count > 0
116 && ((step_sign_x != 0
117 && self.previous_step_sign_x != 0
118 && step_sign_x != self.previous_step_sign_x)
119 || (step_sign_y != 0
120 && self.previous_step_sign_y != 0
121 && step_sign_y != self.previous_step_sign_y))
122 {
123 self.direction_changes = self.direction_changes.saturating_add(1);
124 }
125
126 self.cumulative_delta_x = self.cumulative_delta_x.saturating_add(step_delta_x);
127 self.cumulative_delta_y = self.cumulative_delta_y.saturating_add(step_delta_y);
128 self.sample_count = self.sample_count.saturating_add(1);
129 self.previous_step_sign_x = step_sign_x;
130 self.previous_step_sign_y = step_sign_y;
131
132 (step_delta_x, step_delta_y)
133 }
134
135 fn motion_summary(&self) -> PaneMotionVector {
136 PaneMotionVector::from_delta(
137 self.cumulative_delta_x,
138 self.cumulative_delta_y,
139 self.sample_count.saturating_mul(16),
140 self.direction_changes,
141 )
142 }
143
144 fn release_command(self) -> Option<PanePointerCaptureCommand> {
145 self.capture_state
146 .is_acquired()
147 .then_some(PanePointerCaptureCommand::Release {
148 pointer_id: self.pointer_id,
149 })
150 }
151
152 fn finish_gesture(
153 self,
154 position: PanePointerPosition,
155 ) -> (PaneMotionVector, PaneInertialThrow, PanePointerPosition) {
156 let motion = self.motion_summary();
157 let inertial_throw = PaneInertialThrow::from_motion(motion);
158 let projected_position = inertial_throw.projected_pointer(position);
159 (motion, inertial_throw, projected_position)
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164struct DispatchContext {
165 phase: PanePointerLifecyclePhase,
166 pointer_id: Option<u32>,
167 target: Option<PaneResizeTarget>,
168 position: Option<PanePointerPosition>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum PanePointerCaptureCommand {
174 Acquire { pointer_id: u32 },
175 Release { pointer_id: u32 },
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum PanePointerLifecyclePhase {
181 PointerDown,
182 PointerMove,
183 PointerUp,
184 PointerCancel,
185 PointerLeave,
186 NativeTouchGesture,
187 Blur,
188 VisibilityHidden,
189 LostPointerCapture,
190 ContextLost,
191 RenderStalled,
192 CaptureAcquired,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum PanePointerIgnoredReason {
198 InvalidPointerId,
199 ButtonNotAllowed,
200 ButtonMismatch,
201 ActivePointerAlreadyInProgress,
202 NativeTouchGesture,
203 NoActivePointer,
204 PointerMismatch,
205 LeaveWhileCaptured,
206 MachineRejectedEvent,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211pub enum PanePointerLogOutcome {
212 SemanticForwarded,
213 CaptureStateUpdated,
214 Ignored(PanePointerIgnoredReason),
215}
216
217#[derive(Debug, Clone, Copy, PartialEq, Eq)]
219pub struct PanePointerLogEntry {
220 pub phase: PanePointerLifecyclePhase,
221 pub sequence: Option<u64>,
222 pub pointer_id: Option<u32>,
223 pub target: Option<PaneResizeTarget>,
224 pub position: Option<PanePointerPosition>,
225 pub capture_command: Option<PanePointerCaptureCommand>,
226 pub outcome: PanePointerLogOutcome,
227}
228
229#[derive(Debug, Clone, PartialEq)]
231pub struct PanePointerDispatch {
232 pub semantic_event: Option<PaneSemanticInputEvent>,
233 pub transition: Option<PaneDragResizeTransition>,
234 pub motion: Option<PaneMotionVector>,
235 pub inertial_throw: Option<PaneInertialThrow>,
236 pub projected_position: Option<PanePointerPosition>,
237 pub capture_command: Option<PanePointerCaptureCommand>,
238 pub log: PanePointerLogEntry,
239}
240
241impl PanePointerDispatch {
242 #[must_use]
244 pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
245 self.motion.map(PanePressureSnapProfile::from_motion)
246 }
247
248 fn ignored(
249 phase: PanePointerLifecyclePhase,
250 reason: PanePointerIgnoredReason,
251 pointer_id: Option<u32>,
252 target: Option<PaneResizeTarget>,
253 position: Option<PanePointerPosition>,
254 ) -> Self {
255 Self {
256 semantic_event: None,
257 transition: None,
258 motion: None,
259 inertial_throw: None,
260 projected_position: None,
261 capture_command: None,
262 log: PanePointerLogEntry {
263 phase,
264 sequence: None,
265 pointer_id,
266 target,
267 position,
268 capture_command: None,
269 outcome: PanePointerLogOutcome::Ignored(reason),
270 },
271 }
272 }
273
274 fn capture_state_updated(
275 phase: PanePointerLifecyclePhase,
276 pointer_id: u32,
277 target: PaneResizeTarget,
278 ) -> Self {
279 Self {
280 semantic_event: None,
281 transition: None,
282 motion: None,
283 inertial_throw: None,
284 projected_position: None,
285 capture_command: None,
286 log: PanePointerLogEntry {
287 phase,
288 sequence: None,
289 pointer_id: Some(pointer_id),
290 target: Some(target),
291 position: None,
292 capture_command: None,
293 outcome: PanePointerLogOutcome::CaptureStateUpdated,
294 },
295 }
296 }
297}
298
299#[derive(Debug, Clone)]
305pub struct PanePointerCaptureAdapter {
306 machine: PaneDragResizeMachine,
307 config: PanePointerCaptureConfig,
308 active: Option<ActivePointerCapture>,
309 next_sequence: u64,
310}
311
312impl Default for PanePointerCaptureAdapter {
313 fn default() -> Self {
314 Self {
315 machine: PaneDragResizeMachine::default(),
316 config: PanePointerCaptureConfig::default(),
317 active: None,
318 next_sequence: 1,
319 }
320 }
321}
322
323impl PanePointerCaptureAdapter {
324 pub fn new(config: PanePointerCaptureConfig) -> Result<Self, PaneDragResizeMachineError> {
326 let machine = PaneDragResizeMachine::new_with_hysteresis(
327 config.drag_threshold,
328 config.update_hysteresis,
329 )?;
330 Ok(Self {
331 machine,
332 config,
333 active: None,
334 next_sequence: 1,
335 })
336 }
337
338 #[must_use]
340 pub const fn config(&self) -> PanePointerCaptureConfig {
341 self.config
342 }
343
344 #[must_use]
346 pub fn active_pointer_id(&self) -> Option<u32> {
347 self.active.map(|active| active.pointer_id)
348 }
349
350 #[must_use]
352 pub const fn machine_state(&self) -> PaneDragResizeState {
353 self.machine.state()
354 }
355
356 #[allow(clippy::result_large_err)]
357 fn active_for_pointer(
358 &self,
359 phase: PanePointerLifecyclePhase,
360 pointer_id: u32,
361 position: Option<PanePointerPosition>,
362 ) -> Result<ActivePointerCapture, PanePointerDispatch> {
363 let Some(active) = self.active else {
364 return Err(PanePointerDispatch::ignored(
365 phase,
366 PanePointerIgnoredReason::NoActivePointer,
367 Some(pointer_id),
368 None,
369 position,
370 ));
371 };
372 if active.pointer_id != pointer_id {
373 return Err(PanePointerDispatch::ignored(
374 phase,
375 PanePointerIgnoredReason::PointerMismatch,
376 Some(pointer_id),
377 Some(active.target),
378 position,
379 ));
380 }
381 Ok(active)
382 }
383
384 pub fn pointer_down(
386 &mut self,
387 target: PaneResizeTarget,
388 pointer_id: u32,
389 button: PanePointerButton,
390 position: PanePointerPosition,
391 modifiers: PaneModifierSnapshot,
392 ) -> PanePointerDispatch {
393 if pointer_id == 0 {
394 return PanePointerDispatch::ignored(
395 PanePointerLifecyclePhase::PointerDown,
396 PanePointerIgnoredReason::InvalidPointerId,
397 Some(pointer_id),
398 Some(target),
399 Some(position),
400 );
401 }
402 if button != self.config.activation_button {
403 return PanePointerDispatch::ignored(
404 PanePointerLifecyclePhase::PointerDown,
405 PanePointerIgnoredReason::ButtonNotAllowed,
406 Some(pointer_id),
407 Some(target),
408 Some(position),
409 );
410 }
411 if self.active.is_some() {
412 return PanePointerDispatch::ignored(
413 PanePointerLifecyclePhase::PointerDown,
414 PanePointerIgnoredReason::ActivePointerAlreadyInProgress,
415 Some(pointer_id),
416 Some(target),
417 Some(position),
418 );
419 }
420
421 let kind = PaneSemanticInputEventKind::PointerDown {
422 target,
423 pointer_id,
424 button,
425 position,
426 };
427 let dispatch = self.forward_semantic(
428 DispatchContext {
429 phase: PanePointerLifecyclePhase::PointerDown,
430 pointer_id: Some(pointer_id),
431 target: Some(target),
432 position: Some(position),
433 },
434 kind,
435 modifiers,
436 Some(PanePointerCaptureCommand::Acquire { pointer_id }),
437 );
438 if dispatch.transition.is_some() {
439 self.active = Some(ActivePointerCapture::new(
440 pointer_id, target, button, position,
441 ));
442 }
443 dispatch
444 }
445
446 pub fn touch_pointer_down(
453 &mut self,
454 target: PaneResizeTarget,
455 pointer_id: u32,
456 position: PanePointerPosition,
457 active_touch_points: u8,
458 modifiers: PaneModifierSnapshot,
459 ) -> PanePointerDispatch {
460 if active_touch_points > 1 {
461 return self.native_touch_gesture();
462 }
463 self.pointer_down(
464 target,
465 pointer_id,
466 PanePointerButton::Primary,
467 position,
468 modifiers,
469 )
470 }
471
472 pub fn native_touch_gesture(&mut self) -> PanePointerDispatch {
474 let Some(active) = self.active else {
475 return PanePointerDispatch::ignored(
476 PanePointerLifecyclePhase::NativeTouchGesture,
477 PanePointerIgnoredReason::NativeTouchGesture,
478 None,
479 None,
480 None,
481 );
482 };
483 self.cancel_active(
484 PanePointerLifecyclePhase::NativeTouchGesture,
485 Some(active.pointer_id),
486 PaneCancelReason::PointerCancel,
487 true,
488 )
489 }
490
491 pub fn capture_acquired(&mut self, pointer_id: u32) -> PanePointerDispatch {
493 let mut active = match self.active_for_pointer(
494 PanePointerLifecyclePhase::CaptureAcquired,
495 pointer_id,
496 None,
497 ) {
498 Ok(active) => active,
499 Err(dispatch) => return dispatch,
500 };
501 active.capture_state = CaptureState::Acquired;
502 self.active = Some(active);
503 PanePointerDispatch::capture_state_updated(
504 PanePointerLifecyclePhase::CaptureAcquired,
505 pointer_id,
506 active.target,
507 )
508 }
509
510 pub fn pointer_move(
512 &mut self,
513 pointer_id: u32,
514 position: PanePointerPosition,
515 modifiers: PaneModifierSnapshot,
516 ) -> PanePointerDispatch {
517 let mut active = match self.active_for_pointer(
518 PanePointerLifecyclePhase::PointerMove,
519 pointer_id,
520 Some(position),
521 ) {
522 Ok(active) => active,
523 Err(dispatch) => return dispatch,
524 };
525
526 let (delta_x, delta_y) = active.record_pointer_step(position);
530 let kind = PaneSemanticInputEventKind::PointerMove {
531 target: active.target,
532 pointer_id,
533 position,
534 delta_x,
535 delta_y,
536 };
537
538 let mut dispatch = self.forward_semantic(
539 DispatchContext {
540 phase: PanePointerLifecyclePhase::PointerMove,
541 pointer_id: Some(pointer_id),
542 target: Some(active.target),
543 position: Some(position),
544 },
545 kind,
546 modifiers,
547 None,
548 );
549 if dispatch.transition.is_some() {
550 active.last_position = position;
551 self.active = Some(active);
552 dispatch.motion = Some(active.motion_summary());
553 }
554 dispatch
555 }
556
557 pub fn pointer_up(
559 &mut self,
560 pointer_id: u32,
561 button: PanePointerButton,
562 position: PanePointerPosition,
563 modifiers: PaneModifierSnapshot,
564 ) -> PanePointerDispatch {
565 let active = match self.active_for_pointer(
566 PanePointerLifecyclePhase::PointerUp,
567 pointer_id,
568 Some(position),
569 ) {
570 Ok(active) => active,
571 Err(dispatch) => return dispatch,
572 };
573 if active.button != button {
574 return PanePointerDispatch::ignored(
575 PanePointerLifecyclePhase::PointerUp,
576 PanePointerIgnoredReason::ButtonMismatch,
577 Some(pointer_id),
578 Some(active.target),
579 Some(position),
580 );
581 }
582
583 let kind = PaneSemanticInputEventKind::PointerUp {
584 target: active.target,
585 pointer_id,
586 button: active.button,
587 position,
588 };
589 let mut dispatch = self.forward_semantic(
590 DispatchContext {
591 phase: PanePointerLifecyclePhase::PointerUp,
592 pointer_id: Some(pointer_id),
593 target: Some(active.target),
594 position: Some(position),
595 },
596 kind,
597 modifiers,
598 active.release_command(),
599 );
600 if dispatch.transition.is_some() {
601 let (motion, inertial, projected_position) = active.finish_gesture(position);
602 dispatch.motion = Some(motion);
603 dispatch.projected_position = Some(projected_position);
604 dispatch.inertial_throw = Some(inertial);
605 self.active = None;
606 }
607 dispatch
608 }
609
610 pub fn pointer_cancel(&mut self, pointer_id: Option<u32>) -> PanePointerDispatch {
612 self.cancel_active(
613 PanePointerLifecyclePhase::PointerCancel,
614 pointer_id,
615 PaneCancelReason::PointerCancel,
616 true,
617 )
618 }
619
620 pub fn pointer_leave(&mut self, pointer_id: u32) -> PanePointerDispatch {
622 let active = match self.active_for_pointer(
623 PanePointerLifecyclePhase::PointerLeave,
624 pointer_id,
625 None,
626 ) {
627 Ok(active) => active,
628 Err(dispatch) => return dispatch,
629 };
630
631 if matches!(active.capture_state, CaptureState::Requested)
632 && self.config.cancel_on_leave_without_capture
633 {
634 self.cancel_active(
635 PanePointerLifecyclePhase::PointerLeave,
636 Some(pointer_id),
637 PaneCancelReason::PointerCancel,
638 true,
639 )
640 } else {
641 PanePointerDispatch::ignored(
642 PanePointerLifecyclePhase::PointerLeave,
643 PanePointerIgnoredReason::LeaveWhileCaptured,
644 Some(pointer_id),
645 Some(active.target),
646 None,
647 )
648 }
649 }
650
651 pub fn blur(&mut self) -> PanePointerDispatch {
653 let Some(active) = self.active else {
654 return PanePointerDispatch::ignored(
655 PanePointerLifecyclePhase::Blur,
656 PanePointerIgnoredReason::NoActivePointer,
657 None,
658 None,
659 None,
660 );
661 };
662 let kind = PaneSemanticInputEventKind::Blur {
663 target: Some(active.target),
664 };
665 let dispatch = self.forward_semantic(
666 DispatchContext {
667 phase: PanePointerLifecyclePhase::Blur,
668 pointer_id: Some(active.pointer_id),
669 target: Some(active.target),
670 position: None,
671 },
672 kind,
673 PaneModifierSnapshot::default(),
674 active.release_command(),
675 );
676 if dispatch.transition.is_some() {
677 self.active = None;
678 }
679 dispatch
680 }
681
682 pub fn visibility_hidden(&mut self) -> PanePointerDispatch {
684 self.cancel_active(
685 PanePointerLifecyclePhase::VisibilityHidden,
686 None,
687 PaneCancelReason::FocusLost,
688 true,
689 )
690 }
691
692 pub fn lost_pointer_capture(&mut self, pointer_id: u32) -> PanePointerDispatch {
694 self.cancel_active(
695 PanePointerLifecyclePhase::LostPointerCapture,
696 Some(pointer_id),
697 PaneCancelReason::PointerCancel,
698 false,
699 )
700 }
701
702 pub fn context_lost(&mut self) -> PanePointerDispatch {
704 self.cancel_active(
705 PanePointerLifecyclePhase::ContextLost,
706 None,
707 PaneCancelReason::ContextLost,
708 true,
709 )
710 }
711
712 pub fn render_stalled(&mut self) -> PanePointerDispatch {
714 self.cancel_active(
715 PanePointerLifecyclePhase::RenderStalled,
716 None,
717 PaneCancelReason::RenderStalled,
718 true,
719 )
720 }
721
722 fn cancel_active(
723 &mut self,
724 phase: PanePointerLifecyclePhase,
725 pointer_id: Option<u32>,
726 reason: PaneCancelReason,
727 release_capture: bool,
728 ) -> PanePointerDispatch {
729 let Some(active) = self.active else {
730 return PanePointerDispatch::ignored(
731 phase,
732 PanePointerIgnoredReason::NoActivePointer,
733 pointer_id,
734 None,
735 None,
736 );
737 };
738 if let Some(id) = pointer_id
739 && id != active.pointer_id
740 {
741 return PanePointerDispatch::ignored(
742 phase,
743 PanePointerIgnoredReason::PointerMismatch,
744 Some(id),
745 Some(active.target),
746 None,
747 );
748 }
749
750 let kind = PaneSemanticInputEventKind::Cancel {
751 target: Some(active.target),
752 reason,
753 };
754 let command = if release_capture {
755 active.release_command()
756 } else {
757 None
758 };
759 let dispatch = self.forward_semantic(
760 DispatchContext {
761 phase,
762 pointer_id: Some(active.pointer_id),
763 target: Some(active.target),
764 position: None,
765 },
766 kind,
767 PaneModifierSnapshot::default(),
768 command,
769 );
770 if dispatch.transition.is_some() {
771 self.active = None;
772 }
773 dispatch
774 }
775
776 fn forward_semantic(
777 &mut self,
778 context: DispatchContext,
779 kind: PaneSemanticInputEventKind,
780 modifiers: PaneModifierSnapshot,
781 capture_command: Option<PanePointerCaptureCommand>,
782 ) -> PanePointerDispatch {
783 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
784 event.modifiers = modifiers;
785 match self.machine.apply_event(&event) {
786 Ok(transition) => {
787 let sequence = Some(event.sequence);
788 PanePointerDispatch {
789 semantic_event: Some(event),
790 transition: Some(transition),
791 motion: None,
792 inertial_throw: None,
793 projected_position: None,
794 capture_command,
795 log: PanePointerLogEntry {
796 phase: context.phase,
797 sequence,
798 pointer_id: context.pointer_id,
799 target: context.target,
800 position: context.position,
801 capture_command,
802 outcome: PanePointerLogOutcome::SemanticForwarded,
803 },
804 }
805 }
806 Err(_error) => PanePointerDispatch::ignored(
807 context.phase,
808 PanePointerIgnoredReason::MachineRejectedEvent,
809 context.pointer_id,
810 context.target,
811 context.position,
812 ),
813 }
814 }
815
816 fn next_sequence(&mut self) -> u64 {
817 let sequence = self.next_sequence;
818 self.next_sequence = self.next_sequence.saturating_add(1);
819 sequence
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::{
826 PanePointerCaptureAdapter, PanePointerCaptureCommand, PanePointerCaptureConfig,
827 PanePointerDispatch, PanePointerIgnoredReason, PanePointerLifecyclePhase,
828 PanePointerLogOutcome,
829 };
830 use ftui_layout::{
831 PANE_TREE_SCHEMA_VERSION, PaneCancelReason, PaneDragResizeEffect, PaneDragResizeState,
832 PaneId, PaneInertialThrow, PaneLayout, PaneLeaf, PaneModifierSnapshot, PaneMotionVector,
833 PaneNodeKind, PaneNodeRecord, PanePointerButton, PanePointerPosition,
834 PanePressureSnapProfile, PaneResizeTarget, PaneSemanticInputEventKind, PaneSplit,
835 PaneSplitRatio, PaneTree, PaneTreeSnapshot, Rect, SplitAxis,
836 };
837
838 fn target() -> PaneResizeTarget {
839 PaneResizeTarget {
840 split_id: PaneId::MIN,
841 axis: SplitAxis::Horizontal,
842 }
843 }
844
845 fn pos(x: i32, y: i32) -> PanePointerPosition {
846 PanePointerPosition::new(x, y)
847 }
848
849 fn adapter() -> PanePointerCaptureAdapter {
850 PanePointerCaptureAdapter::new(PanePointerCaptureConfig::default())
851 .expect("default config should be valid")
852 }
853
854 fn pane_id(raw: u64) -> PaneId {
855 PaneId::new(raw).expect("test pane id must be non-zero")
856 }
857
858 fn nested_pane_tree() -> PaneTree {
863 let root = pane_id(1);
864 let left = pane_id(2);
865 let right_split = pane_id(3);
866 let right_top = pane_id(4);
867 let right_bottom = pane_id(5);
868 let snapshot = PaneTreeSnapshot {
869 schema_version: PANE_TREE_SCHEMA_VERSION,
870 root,
871 next_id: pane_id(6),
872 nodes: vec![
873 PaneNodeRecord::split(
874 root,
875 None,
876 PaneSplit {
877 axis: SplitAxis::Horizontal,
878 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
879 first: left,
880 second: right_split,
881 },
882 ),
883 PaneNodeRecord::leaf(left, Some(root), PaneLeaf::new("left")),
884 PaneNodeRecord::split(
885 right_split,
886 Some(root),
887 PaneSplit {
888 axis: SplitAxis::Vertical,
889 ratio: PaneSplitRatio::new(1, 1).expect("valid ratio"),
890 first: right_top,
891 second: right_bottom,
892 },
893 ),
894 PaneNodeRecord::leaf(right_top, Some(right_split), PaneLeaf::new("right_top")),
895 PaneNodeRecord::leaf(
896 right_bottom,
897 Some(right_split),
898 PaneLeaf::new("right_bottom"),
899 ),
900 ],
901 extensions: std::collections::BTreeMap::new(),
902 };
903 PaneTree::from_snapshot(snapshot).expect("valid nested pane tree")
904 }
905
906 fn root_first_share_bps(tree: &PaneTree, split: PaneId) -> u32 {
907 match &tree.node(split).expect("split node present").kind {
908 PaneNodeKind::Split(node) => {
909 node.ratio.numerator() * 10_000
910 / (node.ratio.numerator() + node.ratio.denominator())
911 }
912 PaneNodeKind::Leaf(_) => panic!("expected split node"),
913 }
914 }
915
916 fn apply_dispatch(
917 tree: &mut PaneTree,
918 layout: &PaneLayout,
919 dispatch: &PanePointerDispatch,
920 neutral: PanePressureSnapProfile,
921 seed: &mut u64,
922 ) -> usize {
923 let Some(transition) = dispatch.transition.as_ref() else {
924 return 0;
925 };
926 let pressure = dispatch.pressure_snap_profile().unwrap_or(neutral);
927 let ops = tree.operations_for_transition(transition, layout, pressure);
928 let applied = ops.len();
929 for op in ops {
930 tree.apply_operation(*seed, op).expect("operation applies");
931 *seed += 1;
932 }
933 applied
934 }
935
936 #[test]
944 fn web_pointer_capture_drives_live_tree_mutation() {
945 let mut tree = nested_pane_tree();
946 let layout = tree
947 .solve_layout(Rect::new(0, 0, 50, 20))
948 .expect("layout should solve");
949 let root_split = pane_id(1);
950 let resize_target = PaneResizeTarget {
951 split_id: root_split,
952 axis: SplitAxis::Horizontal,
953 };
954 let neutral = PanePressureSnapProfile {
955 strength_bps: 5_000,
956 hysteresis_bps: 100,
957 };
958
959 let before = root_first_share_bps(&tree, root_split);
960
961 let mut adapter = adapter();
962 let mut op_seed = 9_000u64;
963 let mut geometry_transitions = 0usize;
964
965 let down = adapter.pointer_down(
968 resize_target,
969 7,
970 PanePointerButton::Primary,
971 pos(25, 10),
972 PaneModifierSnapshot::default(),
973 );
974 geometry_transitions += apply_dispatch(&mut tree, &layout, &down, neutral, &mut op_seed);
975
976 for x in [30, 36, 42] {
978 let moved = adapter.pointer_move(7, pos(x, 10), PaneModifierSnapshot::default());
979 geometry_transitions +=
980 apply_dispatch(&mut tree, &layout, &moved, neutral, &mut op_seed);
981 }
982
983 let up = adapter.pointer_up(
985 7,
986 PanePointerButton::Primary,
987 pos(42, 10),
988 PaneModifierSnapshot::default(),
989 );
990 geometry_transitions += apply_dispatch(&mut tree, &layout, &up, neutral, &mut op_seed);
991
992 assert!(
993 geometry_transitions > 0,
994 "drag lifecycle should yield at least one geometry-bearing transition"
995 );
996 let after = root_first_share_bps(&tree, root_split);
997 assert!(
998 after > before,
999 "rightward pointer drag should grow the first child: after={after} before={before}"
1000 );
1001 assert!(matches!(adapter.machine_state(), PaneDragResizeState::Idle));
1002 }
1003
1004 #[test]
1005 fn pointer_down_arms_machine_and_requests_capture() {
1006 let mut adapter = adapter();
1007 let dispatch = adapter.pointer_down(
1008 target(),
1009 11,
1010 PanePointerButton::Primary,
1011 pos(5, 8),
1012 PaneModifierSnapshot::default(),
1013 );
1014 assert_eq!(
1015 dispatch.capture_command,
1016 Some(PanePointerCaptureCommand::Acquire { pointer_id: 11 })
1017 );
1018 assert_eq!(adapter.active_pointer_id(), Some(11));
1019 assert!(matches!(
1020 adapter.machine_state(),
1021 PaneDragResizeState::Armed { pointer_id: 11, .. }
1022 ));
1023 assert!(matches!(
1024 dispatch
1025 .transition
1026 .as_ref()
1027 .expect("transition should exist")
1028 .effect,
1029 PaneDragResizeEffect::Armed { pointer_id: 11, .. }
1030 ));
1031 }
1032
1033 #[test]
1034 fn single_touch_down_uses_primary_pointer_capture_path() {
1035 let mut adapter = adapter();
1036 let dispatch =
1037 adapter.touch_pointer_down(target(), 14, pos(5, 8), 1, PaneModifierSnapshot::default());
1038
1039 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::PointerDown);
1040 assert_eq!(
1041 dispatch.capture_command,
1042 Some(PanePointerCaptureCommand::Acquire { pointer_id: 14 })
1043 );
1044 assert_eq!(adapter.active_pointer_id(), Some(14));
1045 assert!(matches!(
1046 dispatch
1047 .semantic_event
1048 .as_ref()
1049 .expect("semantic event expected")
1050 .kind,
1051 PaneSemanticInputEventKind::PointerDown {
1052 pointer_id: 14,
1053 button: PanePointerButton::Primary,
1054 ..
1055 }
1056 ));
1057 }
1058
1059 #[test]
1060 fn second_touch_yields_to_native_gesture_and_releases_capture() {
1061 let mut adapter = adapter();
1062 adapter.touch_pointer_down(target(), 21, pos(3, 3), 1, PaneModifierSnapshot::default());
1063 let ack = adapter.capture_acquired(21);
1064 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1065
1066 let dispatch =
1067 adapter.touch_pointer_down(target(), 22, pos(6, 6), 2, PaneModifierSnapshot::default());
1068
1069 assert_eq!(
1070 dispatch.log.phase,
1071 PanePointerLifecyclePhase::NativeTouchGesture
1072 );
1073 assert!(matches!(
1074 dispatch
1075 .semantic_event
1076 .as_ref()
1077 .expect("semantic cancel expected")
1078 .kind,
1079 PaneSemanticInputEventKind::Cancel {
1080 reason: PaneCancelReason::PointerCancel,
1081 ..
1082 }
1083 ));
1084 assert_eq!(
1085 dispatch.capture_command,
1086 Some(PanePointerCaptureCommand::Release { pointer_id: 21 })
1087 );
1088 assert_eq!(dispatch.log.pointer_id, Some(21));
1089 assert_eq!(adapter.active_pointer_id(), None);
1090 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1091 }
1092
1093 #[test]
1094 fn native_touch_gesture_without_active_capture_is_ignored() {
1095 let mut adapter = adapter();
1096 let dispatch = adapter.native_touch_gesture();
1097
1098 assert_eq!(
1099 dispatch.log.phase,
1100 PanePointerLifecyclePhase::NativeTouchGesture
1101 );
1102 assert_eq!(dispatch.semantic_event, None);
1103 assert_eq!(
1104 dispatch.log.outcome,
1105 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::NativeTouchGesture)
1106 );
1107 assert_eq!(adapter.active_pointer_id(), None);
1108 }
1109
1110 #[test]
1111 fn non_activation_button_is_ignored_deterministically() {
1112 let mut adapter = adapter();
1113 let dispatch = adapter.pointer_down(
1114 target(),
1115 3,
1116 PanePointerButton::Secondary,
1117 pos(1, 1),
1118 PaneModifierSnapshot::default(),
1119 );
1120 assert_eq!(dispatch.semantic_event, None);
1121 assert_eq!(dispatch.transition, None);
1122 assert_eq!(dispatch.capture_command, None);
1123 assert_eq!(adapter.active_pointer_id(), None);
1124 assert_eq!(
1125 dispatch.log.outcome,
1126 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonNotAllowed)
1127 );
1128 }
1129
1130 #[test]
1131 fn pointer_move_mismatch_is_ignored_without_state_mutation() {
1132 let mut adapter = adapter();
1133 adapter.pointer_down(
1134 target(),
1135 9,
1136 PanePointerButton::Primary,
1137 pos(10, 10),
1138 PaneModifierSnapshot::default(),
1139 );
1140 let before = adapter.machine_state();
1141 let dispatch = adapter.pointer_move(77, pos(14, 14), PaneModifierSnapshot::default());
1142 assert_eq!(dispatch.semantic_event, None);
1143 assert_eq!(
1144 dispatch.log.outcome,
1145 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::PointerMismatch)
1146 );
1147 assert_eq!(before, adapter.machine_state());
1148 assert_eq!(adapter.active_pointer_id(), Some(9));
1149 }
1150
1151 #[test]
1152 fn pointer_up_releases_capture_and_returns_idle() {
1153 let mut adapter = adapter();
1154 adapter.pointer_down(
1155 target(),
1156 9,
1157 PanePointerButton::Primary,
1158 pos(1, 1),
1159 PaneModifierSnapshot::default(),
1160 );
1161 let ack = adapter.capture_acquired(9);
1162 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1163 let dispatch = adapter.pointer_up(
1164 9,
1165 PanePointerButton::Primary,
1166 pos(6, 1),
1167 PaneModifierSnapshot::default(),
1168 );
1169 assert_eq!(
1170 dispatch.capture_command,
1171 Some(PanePointerCaptureCommand::Release { pointer_id: 9 })
1172 );
1173 assert_eq!(adapter.active_pointer_id(), None);
1174 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1175 assert!(matches!(
1176 dispatch
1177 .semantic_event
1178 .as_ref()
1179 .expect("semantic event expected")
1180 .kind,
1181 PaneSemanticInputEventKind::PointerUp { pointer_id: 9, .. }
1182 ));
1183 }
1184
1185 #[test]
1186 fn pointer_move_emits_motion_and_pressure_snap_profile() {
1187 let mut adapter = adapter();
1188 adapter.pointer_down(
1189 target(),
1190 17,
1191 PanePointerButton::Primary,
1192 pos(4, 4),
1193 PaneModifierSnapshot::default(),
1194 );
1195
1196 let dispatch = adapter.pointer_move(17, pos(18, 8), PaneModifierSnapshot::default());
1197 let expected_motion = PaneMotionVector::from_delta(14, 4, 16, 0);
1198
1199 assert_eq!(dispatch.motion, Some(expected_motion));
1200 assert_eq!(
1201 dispatch.pressure_snap_profile(),
1202 Some(PanePressureSnapProfile::from_motion(expected_motion))
1203 );
1204 }
1205
1206 #[test]
1207 fn pointer_move_event_delta_matches_position_step() {
1208 let mut adapter = adapter();
1214 adapter.pointer_down(
1215 target(),
1216 31,
1217 PanePointerButton::Primary,
1218 pos(10, 10),
1219 PaneModifierSnapshot::default(),
1220 );
1221 let mut prev = pos(10, 10);
1222 for &(x, y) in &[(24, 12), (20, 18), (35, 18)] {
1223 let here = pos(x, y);
1224 let dispatch = adapter.pointer_move(31, here, PaneModifierSnapshot::default());
1225 match &dispatch
1226 .semantic_event
1227 .as_ref()
1228 .expect("move emits a semantic event")
1229 .kind
1230 {
1231 PaneSemanticInputEventKind::PointerMove {
1232 delta_x,
1233 delta_y,
1234 position,
1235 ..
1236 } => {
1237 assert_eq!(*delta_x, here.x - prev.x);
1238 assert_eq!(*delta_y, here.y - prev.y);
1239 assert_eq!(position.x, here.x);
1240 assert_eq!(position.y, here.y);
1241 }
1242 other => panic!("expected PointerMove, got {other:?}"),
1243 }
1244 if dispatch.motion.is_some() {
1246 prev = here;
1247 }
1248 }
1249 }
1250
1251 #[test]
1252 fn pointer_move_tracks_direction_changes_in_motion_summary() {
1253 let mut adapter = adapter();
1254 adapter.pointer_down(
1255 target(),
1256 23,
1257 PanePointerButton::Primary,
1258 pos(10, 10),
1259 PaneModifierSnapshot::default(),
1260 );
1261
1262 let first = adapter.pointer_move(23, pos(24, 10), PaneModifierSnapshot::default());
1263 let second = adapter.pointer_move(23, pos(18, 10), PaneModifierSnapshot::default());
1264
1265 assert_eq!(
1266 first.motion,
1267 Some(PaneMotionVector::from_delta(14, 0, 16, 0))
1268 );
1269 assert_eq!(
1270 second.motion,
1271 Some(PaneMotionVector::from_delta(8, 0, 32, 1))
1272 );
1273 assert!(
1274 second
1275 .pressure_snap_profile()
1276 .expect("pressure profile should be derived from motion")
1277 .strength_bps
1278 < first
1279 .pressure_snap_profile()
1280 .expect("pressure profile should be derived from motion")
1281 .strength_bps
1282 );
1283 }
1284
1285 #[test]
1286 fn pointer_move_zero_delta_does_not_count_as_direction_change() {
1287 let mut adapter = adapter();
1288 adapter.pointer_down(
1289 target(),
1290 31,
1291 PanePointerButton::Primary,
1292 pos(10, 10),
1293 PaneModifierSnapshot::default(),
1294 );
1295
1296 let first = adapter.pointer_move(31, pos(24, 10), PaneModifierSnapshot::default());
1297 let stationary = adapter.pointer_move(31, pos(24, 10), PaneModifierSnapshot::default());
1298 let second = adapter.pointer_move(31, pos(18, 10), PaneModifierSnapshot::default());
1299
1300 assert_eq!(
1301 first.motion,
1302 Some(PaneMotionVector::from_delta(14, 0, 16, 0))
1303 );
1304 assert_eq!(
1305 stationary.motion,
1306 Some(PaneMotionVector::from_delta(14, 0, 32, 0))
1307 );
1308 assert_eq!(
1309 second.motion,
1310 Some(PaneMotionVector::from_delta(8, 0, 48, 0))
1311 );
1312 }
1313
1314 #[test]
1315 fn pointer_up_exposes_inertial_throw_and_projected_pointer() {
1316 let mut adapter = adapter();
1317 adapter.pointer_down(
1318 target(),
1319 29,
1320 PanePointerButton::Primary,
1321 pos(2, 3),
1322 PaneModifierSnapshot::default(),
1323 );
1324 let ack = adapter.capture_acquired(29);
1325 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1326
1327 let drag = adapter.pointer_move(29, pos(28, 11), PaneModifierSnapshot::default());
1328 let release = adapter.pointer_up(
1329 29,
1330 PanePointerButton::Primary,
1331 pos(31, 12),
1332 PaneModifierSnapshot::default(),
1333 );
1334 let expected_motion = drag.motion.expect("drag motion should be recorded");
1335 let expected_inertial = PaneInertialThrow::from_motion(expected_motion);
1336
1337 assert_eq!(release.motion, Some(expected_motion));
1338 assert_eq!(release.inertial_throw, Some(expected_inertial));
1339 assert_eq!(
1340 release.projected_position,
1341 Some(expected_inertial.projected_pointer(pos(31, 12)))
1342 );
1343 }
1344
1345 #[test]
1346 fn pointer_up_with_wrong_button_is_ignored() {
1347 let mut adapter = adapter();
1348 adapter.pointer_down(
1349 target(),
1350 4,
1351 PanePointerButton::Primary,
1352 pos(2, 2),
1353 PaneModifierSnapshot::default(),
1354 );
1355 let dispatch = adapter.pointer_up(
1356 4,
1357 PanePointerButton::Secondary,
1358 pos(3, 2),
1359 PaneModifierSnapshot::default(),
1360 );
1361 assert_eq!(
1362 dispatch.log.outcome,
1363 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonMismatch)
1364 );
1365 assert_eq!(adapter.active_pointer_id(), Some(4));
1366 }
1367
1368 #[test]
1369 fn blur_emits_semantic_blur_and_releases_capture() {
1370 let mut adapter = adapter();
1371 adapter.pointer_down(
1372 target(),
1373 6,
1374 PanePointerButton::Primary,
1375 pos(0, 0),
1376 PaneModifierSnapshot::default(),
1377 );
1378 let ack = adapter.capture_acquired(6);
1379 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1380 let dispatch = adapter.blur();
1381 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::Blur);
1382 assert!(matches!(
1383 dispatch
1384 .semantic_event
1385 .as_ref()
1386 .expect("semantic event expected")
1387 .kind,
1388 PaneSemanticInputEventKind::Blur { .. }
1389 ));
1390 assert_eq!(
1391 dispatch.capture_command,
1392 Some(PanePointerCaptureCommand::Release { pointer_id: 6 })
1393 );
1394 assert_eq!(adapter.active_pointer_id(), None);
1395 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1396 }
1397
1398 #[test]
1399 fn blur_before_capture_ack_clears_state_without_release() {
1400 let mut adapter = adapter();
1401 adapter.pointer_down(
1402 target(),
1403 61,
1404 PanePointerButton::Primary,
1405 pos(0, 0),
1406 PaneModifierSnapshot::default(),
1407 );
1408 let dispatch = adapter.blur();
1409 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::Blur);
1410 assert!(matches!(
1411 dispatch
1412 .semantic_event
1413 .as_ref()
1414 .expect("semantic event expected")
1415 .kind,
1416 PaneSemanticInputEventKind::Blur { .. }
1417 ));
1418 assert_eq!(dispatch.capture_command, None);
1419 assert_eq!(adapter.active_pointer_id(), None);
1420 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1421 }
1422
1423 #[test]
1424 fn visibility_hidden_emits_focus_lost_cancel() {
1425 let mut adapter = adapter();
1426 adapter.pointer_down(
1427 target(),
1428 8,
1429 PanePointerButton::Primary,
1430 pos(5, 2),
1431 PaneModifierSnapshot::default(),
1432 );
1433 let ack = adapter.capture_acquired(8);
1434 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1435 let dispatch = adapter.visibility_hidden();
1436 assert!(matches!(
1437 dispatch
1438 .semantic_event
1439 .as_ref()
1440 .expect("semantic event expected")
1441 .kind,
1442 PaneSemanticInputEventKind::Cancel {
1443 reason: PaneCancelReason::FocusLost,
1444 ..
1445 }
1446 ));
1447 assert_eq!(
1448 dispatch.capture_command,
1449 Some(PanePointerCaptureCommand::Release { pointer_id: 8 })
1450 );
1451 assert_eq!(adapter.active_pointer_id(), None);
1452 }
1453
1454 #[test]
1455 fn visibility_hidden_before_capture_ack_cancels_without_release() {
1456 let mut adapter = adapter();
1457 adapter.pointer_down(
1458 target(),
1459 81,
1460 PanePointerButton::Primary,
1461 pos(5, 2),
1462 PaneModifierSnapshot::default(),
1463 );
1464 let dispatch = adapter.visibility_hidden();
1465 assert!(matches!(
1466 dispatch
1467 .semantic_event
1468 .as_ref()
1469 .expect("semantic event expected")
1470 .kind,
1471 PaneSemanticInputEventKind::Cancel {
1472 reason: PaneCancelReason::FocusLost,
1473 ..
1474 }
1475 ));
1476 assert_eq!(dispatch.capture_command, None);
1477 assert_eq!(adapter.active_pointer_id(), None);
1478 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1479 }
1480
1481 #[test]
1482 fn lost_pointer_capture_cancels_without_double_release() {
1483 let mut adapter = adapter();
1484 adapter.pointer_down(
1485 target(),
1486 42,
1487 PanePointerButton::Primary,
1488 pos(7, 7),
1489 PaneModifierSnapshot::default(),
1490 );
1491 let dispatch = adapter.lost_pointer_capture(42);
1492 assert_eq!(dispatch.capture_command, None);
1493 assert!(matches!(
1494 dispatch
1495 .semantic_event
1496 .as_ref()
1497 .expect("semantic event expected")
1498 .kind,
1499 PaneSemanticInputEventKind::Cancel {
1500 reason: PaneCancelReason::PointerCancel,
1501 ..
1502 }
1503 ));
1504 assert_eq!(adapter.active_pointer_id(), None);
1505 }
1506
1507 #[test]
1508 fn pointer_leave_before_capture_ack_cancels() {
1509 let mut adapter = adapter();
1510 adapter.pointer_down(
1511 target(),
1512 31,
1513 PanePointerButton::Primary,
1514 pos(1, 1),
1515 PaneModifierSnapshot::default(),
1516 );
1517 let dispatch = adapter.pointer_leave(31);
1518 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::PointerLeave);
1519 assert!(matches!(
1520 dispatch
1521 .semantic_event
1522 .as_ref()
1523 .expect("semantic event expected")
1524 .kind,
1525 PaneSemanticInputEventKind::Cancel {
1526 reason: PaneCancelReason::PointerCancel,
1527 ..
1528 }
1529 ));
1530 assert_eq!(dispatch.capture_command, None);
1531 assert_eq!(adapter.active_pointer_id(), None);
1532 }
1533
1534 #[test]
1535 fn pointer_cancel_after_capture_ack_releases_and_cancels() {
1536 let mut adapter = adapter();
1537 adapter.pointer_down(
1538 target(),
1539 39,
1540 PanePointerButton::Primary,
1541 pos(3, 3),
1542 PaneModifierSnapshot::default(),
1543 );
1544 let ack = adapter.capture_acquired(39);
1545 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1546
1547 let dispatch = adapter.pointer_cancel(Some(39));
1548 assert!(matches!(
1549 dispatch
1550 .semantic_event
1551 .as_ref()
1552 .expect("semantic event expected")
1553 .kind,
1554 PaneSemanticInputEventKind::Cancel {
1555 reason: PaneCancelReason::PointerCancel,
1556 ..
1557 }
1558 ));
1559 assert_eq!(
1560 dispatch.capture_command,
1561 Some(PanePointerCaptureCommand::Release { pointer_id: 39 })
1562 );
1563 assert_eq!(adapter.active_pointer_id(), None);
1564 }
1565
1566 #[test]
1567 fn pointer_cancel_without_pointer_id_releases_active_capture() {
1568 let mut adapter = adapter();
1569 adapter.pointer_down(
1570 target(),
1571 74,
1572 PanePointerButton::Primary,
1573 pos(2, 3),
1574 PaneModifierSnapshot::default(),
1575 );
1576 let ack = adapter.capture_acquired(74);
1577 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1578 let dispatch = adapter.pointer_cancel(None);
1579 assert!(matches!(
1580 dispatch
1581 .semantic_event
1582 .as_ref()
1583 .expect("semantic event expected")
1584 .kind,
1585 PaneSemanticInputEventKind::Cancel {
1586 reason: PaneCancelReason::PointerCancel,
1587 ..
1588 }
1589 ));
1590 assert_eq!(
1591 dispatch.capture_command,
1592 Some(PanePointerCaptureCommand::Release { pointer_id: 74 })
1593 );
1594 assert_eq!(adapter.active_pointer_id(), None);
1595 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1596 }
1597
1598 #[test]
1599 fn pointer_leave_after_capture_ack_is_ignored() {
1600 let mut adapter = adapter();
1601 adapter.pointer_down(
1602 target(),
1603 55,
1604 PanePointerButton::Primary,
1605 pos(4, 4),
1606 PaneModifierSnapshot::default(),
1607 );
1608 let ack = adapter.capture_acquired(55);
1609 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1610
1611 let dispatch = adapter.pointer_leave(55);
1612 assert_eq!(dispatch.semantic_event, None);
1613 assert_eq!(
1614 dispatch.log.outcome,
1615 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::LeaveWhileCaptured)
1616 );
1617 assert_eq!(adapter.active_pointer_id(), Some(55));
1618 }
1619
1620 #[test]
1621 fn context_lost_releases_capture_and_cancels_with_explicit_reason() {
1622 let mut adapter = adapter();
1623 adapter.pointer_down(
1624 target(),
1625 91,
1626 PanePointerButton::Primary,
1627 pos(5, 5),
1628 PaneModifierSnapshot::default(),
1629 );
1630 let ack = adapter.capture_acquired(91);
1631 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1632
1633 let dispatch = adapter.context_lost();
1634
1635 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::ContextLost);
1636 assert!(matches!(
1637 dispatch
1638 .semantic_event
1639 .as_ref()
1640 .expect("semantic event expected")
1641 .kind,
1642 PaneSemanticInputEventKind::Cancel {
1643 reason: PaneCancelReason::ContextLost,
1644 ..
1645 }
1646 ));
1647 assert_eq!(
1648 dispatch.capture_command,
1649 Some(PanePointerCaptureCommand::Release { pointer_id: 91 })
1650 );
1651 assert_eq!(adapter.active_pointer_id(), None);
1652 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1653 }
1654
1655 #[test]
1656 fn render_stalled_before_capture_ack_cancels_without_release() {
1657 let mut adapter = adapter();
1658 adapter.pointer_down(
1659 target(),
1660 92,
1661 PanePointerButton::Primary,
1662 pos(8, 6),
1663 PaneModifierSnapshot::default(),
1664 );
1665
1666 let dispatch = adapter.render_stalled();
1667
1668 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::RenderStalled);
1669 assert!(matches!(
1670 dispatch
1671 .semantic_event
1672 .as_ref()
1673 .expect("semantic event expected")
1674 .kind,
1675 PaneSemanticInputEventKind::Cancel {
1676 reason: PaneCancelReason::RenderStalled,
1677 ..
1678 }
1679 ));
1680 assert_eq!(dispatch.capture_command, None);
1681 assert_eq!(adapter.active_pointer_id(), None);
1682 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
1683 }
1684}