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_delta_x: i32,
66 previous_step_delta_y: i32,
67 capture_state: CaptureState,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71struct DispatchContext {
72 phase: PanePointerLifecyclePhase,
73 pointer_id: Option<u32>,
74 target: Option<PaneResizeTarget>,
75 position: Option<PanePointerPosition>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum PanePointerCaptureCommand {
81 Acquire { pointer_id: u32 },
82 Release { pointer_id: u32 },
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum PanePointerLifecyclePhase {
88 PointerDown,
89 PointerMove,
90 PointerUp,
91 PointerCancel,
92 PointerLeave,
93 Blur,
94 VisibilityHidden,
95 LostPointerCapture,
96 CaptureAcquired,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum PanePointerIgnoredReason {
102 InvalidPointerId,
103 ButtonNotAllowed,
104 ButtonMismatch,
105 ActivePointerAlreadyInProgress,
106 NoActivePointer,
107 PointerMismatch,
108 LeaveWhileCaptured,
109 MachineRejectedEvent,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum PanePointerLogOutcome {
115 SemanticForwarded,
116 CaptureStateUpdated,
117 Ignored(PanePointerIgnoredReason),
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub struct PanePointerLogEntry {
123 pub phase: PanePointerLifecyclePhase,
124 pub sequence: Option<u64>,
125 pub pointer_id: Option<u32>,
126 pub target: Option<PaneResizeTarget>,
127 pub position: Option<PanePointerPosition>,
128 pub capture_command: Option<PanePointerCaptureCommand>,
129 pub outcome: PanePointerLogOutcome,
130}
131
132#[derive(Debug, Clone, PartialEq)]
134pub struct PanePointerDispatch {
135 pub semantic_event: Option<PaneSemanticInputEvent>,
136 pub transition: Option<PaneDragResizeTransition>,
137 pub motion: Option<PaneMotionVector>,
138 pub inertial_throw: Option<PaneInertialThrow>,
139 pub projected_position: Option<PanePointerPosition>,
140 pub capture_command: Option<PanePointerCaptureCommand>,
141 pub log: PanePointerLogEntry,
142}
143
144impl PanePointerDispatch {
145 #[must_use]
147 pub fn pressure_snap_profile(&self) -> Option<PanePressureSnapProfile> {
148 self.motion.map(PanePressureSnapProfile::from_motion)
149 }
150
151 fn ignored(
152 phase: PanePointerLifecyclePhase,
153 reason: PanePointerIgnoredReason,
154 pointer_id: Option<u32>,
155 target: Option<PaneResizeTarget>,
156 position: Option<PanePointerPosition>,
157 ) -> Self {
158 Self {
159 semantic_event: None,
160 transition: None,
161 motion: None,
162 inertial_throw: None,
163 projected_position: None,
164 capture_command: None,
165 log: PanePointerLogEntry {
166 phase,
167 sequence: None,
168 pointer_id,
169 target,
170 position,
171 capture_command: None,
172 outcome: PanePointerLogOutcome::Ignored(reason),
173 },
174 }
175 }
176
177 fn capture_state_updated(
178 phase: PanePointerLifecyclePhase,
179 pointer_id: u32,
180 target: PaneResizeTarget,
181 ) -> Self {
182 Self {
183 semantic_event: None,
184 transition: None,
185 motion: None,
186 inertial_throw: None,
187 projected_position: None,
188 capture_command: None,
189 log: PanePointerLogEntry {
190 phase,
191 sequence: None,
192 pointer_id: Some(pointer_id),
193 target: Some(target),
194 position: None,
195 capture_command: None,
196 outcome: PanePointerLogOutcome::CaptureStateUpdated,
197 },
198 }
199 }
200}
201
202#[derive(Debug, Clone)]
208pub struct PanePointerCaptureAdapter {
209 machine: PaneDragResizeMachine,
210 config: PanePointerCaptureConfig,
211 active: Option<ActivePointerCapture>,
212 next_sequence: u64,
213}
214
215impl Default for PanePointerCaptureAdapter {
216 fn default() -> Self {
217 Self {
218 machine: PaneDragResizeMachine::default(),
219 config: PanePointerCaptureConfig::default(),
220 active: None,
221 next_sequence: 1,
222 }
223 }
224}
225
226impl PanePointerCaptureAdapter {
227 pub fn new(config: PanePointerCaptureConfig) -> Result<Self, PaneDragResizeMachineError> {
229 let machine = PaneDragResizeMachine::new_with_hysteresis(
230 config.drag_threshold,
231 config.update_hysteresis,
232 )?;
233 Ok(Self {
234 machine,
235 config,
236 active: None,
237 next_sequence: 1,
238 })
239 }
240
241 #[must_use]
243 pub const fn config(&self) -> PanePointerCaptureConfig {
244 self.config
245 }
246
247 #[must_use]
249 pub fn active_pointer_id(&self) -> Option<u32> {
250 self.active.map(|active| active.pointer_id)
251 }
252
253 #[must_use]
255 pub const fn machine_state(&self) -> PaneDragResizeState {
256 self.machine.state()
257 }
258
259 pub fn pointer_down(
261 &mut self,
262 target: PaneResizeTarget,
263 pointer_id: u32,
264 button: PanePointerButton,
265 position: PanePointerPosition,
266 modifiers: PaneModifierSnapshot,
267 ) -> PanePointerDispatch {
268 if pointer_id == 0 {
269 return PanePointerDispatch::ignored(
270 PanePointerLifecyclePhase::PointerDown,
271 PanePointerIgnoredReason::InvalidPointerId,
272 Some(pointer_id),
273 Some(target),
274 Some(position),
275 );
276 }
277 if button != self.config.activation_button {
278 return PanePointerDispatch::ignored(
279 PanePointerLifecyclePhase::PointerDown,
280 PanePointerIgnoredReason::ButtonNotAllowed,
281 Some(pointer_id),
282 Some(target),
283 Some(position),
284 );
285 }
286 if self.active.is_some() {
287 return PanePointerDispatch::ignored(
288 PanePointerLifecyclePhase::PointerDown,
289 PanePointerIgnoredReason::ActivePointerAlreadyInProgress,
290 Some(pointer_id),
291 Some(target),
292 Some(position),
293 );
294 }
295
296 let kind = PaneSemanticInputEventKind::PointerDown {
297 target,
298 pointer_id,
299 button,
300 position,
301 };
302 let dispatch = self.forward_semantic(
303 DispatchContext {
304 phase: PanePointerLifecyclePhase::PointerDown,
305 pointer_id: Some(pointer_id),
306 target: Some(target),
307 position: Some(position),
308 },
309 kind,
310 modifiers,
311 Some(PanePointerCaptureCommand::Acquire { pointer_id }),
312 );
313 if dispatch.transition.is_some() {
314 self.active = Some(ActivePointerCapture {
315 pointer_id,
316 target,
317 button,
318 last_position: position,
319 cumulative_delta_x: 0,
320 cumulative_delta_y: 0,
321 direction_changes: 0,
322 sample_count: 0,
323 previous_step_delta_x: 0,
324 previous_step_delta_y: 0,
325 capture_state: CaptureState::Requested,
326 });
327 }
328 dispatch
329 }
330
331 pub fn capture_acquired(&mut self, pointer_id: u32) -> PanePointerDispatch {
333 let Some(mut active) = self.active else {
334 return PanePointerDispatch::ignored(
335 PanePointerLifecyclePhase::CaptureAcquired,
336 PanePointerIgnoredReason::NoActivePointer,
337 Some(pointer_id),
338 None,
339 None,
340 );
341 };
342 if active.pointer_id != pointer_id {
343 return PanePointerDispatch::ignored(
344 PanePointerLifecyclePhase::CaptureAcquired,
345 PanePointerIgnoredReason::PointerMismatch,
346 Some(pointer_id),
347 Some(active.target),
348 None,
349 );
350 }
351 active.capture_state = CaptureState::Acquired;
352 self.active = Some(active);
353 PanePointerDispatch::capture_state_updated(
354 PanePointerLifecyclePhase::CaptureAcquired,
355 pointer_id,
356 active.target,
357 )
358 }
359
360 pub fn pointer_move(
362 &mut self,
363 pointer_id: u32,
364 position: PanePointerPosition,
365 modifiers: PaneModifierSnapshot,
366 ) -> PanePointerDispatch {
367 let Some(mut active) = self.active else {
368 return PanePointerDispatch::ignored(
369 PanePointerLifecyclePhase::PointerMove,
370 PanePointerIgnoredReason::NoActivePointer,
371 Some(pointer_id),
372 None,
373 Some(position),
374 );
375 };
376 if active.pointer_id != pointer_id {
377 return PanePointerDispatch::ignored(
378 PanePointerLifecyclePhase::PointerMove,
379 PanePointerIgnoredReason::PointerMismatch,
380 Some(pointer_id),
381 Some(active.target),
382 Some(position),
383 );
384 }
385
386 let kind = PaneSemanticInputEventKind::PointerMove {
387 target: active.target,
388 pointer_id,
389 position,
390 delta_x: position.x.saturating_sub(active.last_position.x),
391 delta_y: position.y.saturating_sub(active.last_position.y),
392 };
393 let step_delta_x = position.x.saturating_sub(active.last_position.x);
394 let step_delta_y = position.y.saturating_sub(active.last_position.y);
395 if active.sample_count > 0 {
396 let flipped_x = step_delta_x.signum() != 0
397 && active.previous_step_delta_x.signum() != 0
398 && step_delta_x.signum() != active.previous_step_delta_x.signum();
399 let flipped_y = step_delta_y.signum() != 0
400 && active.previous_step_delta_y.signum() != 0
401 && step_delta_y.signum() != active.previous_step_delta_y.signum();
402 if flipped_x || flipped_y {
403 active.direction_changes = active.direction_changes.saturating_add(1);
404 }
405 }
406 active.cumulative_delta_x = active.cumulative_delta_x.saturating_add(step_delta_x);
407 active.cumulative_delta_y = active.cumulative_delta_y.saturating_add(step_delta_y);
408 active.sample_count = active.sample_count.saturating_add(1);
409 active.previous_step_delta_x = step_delta_x;
410 active.previous_step_delta_y = step_delta_y;
411
412 let mut dispatch = self.forward_semantic(
413 DispatchContext {
414 phase: PanePointerLifecyclePhase::PointerMove,
415 pointer_id: Some(pointer_id),
416 target: Some(active.target),
417 position: Some(position),
418 },
419 kind,
420 modifiers,
421 None,
422 );
423 if dispatch.transition.is_some() {
424 active.last_position = position;
425 self.active = Some(active);
426 dispatch.motion = Some(PaneMotionVector::from_delta(
427 active.cumulative_delta_x,
428 active.cumulative_delta_y,
429 active.sample_count.saturating_mul(16),
430 active.direction_changes,
431 ));
432 }
433 dispatch
434 }
435
436 pub fn pointer_up(
438 &mut self,
439 pointer_id: u32,
440 button: PanePointerButton,
441 position: PanePointerPosition,
442 modifiers: PaneModifierSnapshot,
443 ) -> PanePointerDispatch {
444 let Some(active) = self.active else {
445 return PanePointerDispatch::ignored(
446 PanePointerLifecyclePhase::PointerUp,
447 PanePointerIgnoredReason::NoActivePointer,
448 Some(pointer_id),
449 None,
450 Some(position),
451 );
452 };
453 if active.pointer_id != pointer_id {
454 return PanePointerDispatch::ignored(
455 PanePointerLifecyclePhase::PointerUp,
456 PanePointerIgnoredReason::PointerMismatch,
457 Some(pointer_id),
458 Some(active.target),
459 Some(position),
460 );
461 }
462 if active.button != button {
463 return PanePointerDispatch::ignored(
464 PanePointerLifecyclePhase::PointerUp,
465 PanePointerIgnoredReason::ButtonMismatch,
466 Some(pointer_id),
467 Some(active.target),
468 Some(position),
469 );
470 }
471
472 let kind = PaneSemanticInputEventKind::PointerUp {
473 target: active.target,
474 pointer_id,
475 button: active.button,
476 position,
477 };
478 let mut dispatch = self.forward_semantic(
479 DispatchContext {
480 phase: PanePointerLifecyclePhase::PointerUp,
481 pointer_id: Some(pointer_id),
482 target: Some(active.target),
483 position: Some(position),
484 },
485 kind,
486 modifiers,
487 active
488 .capture_state
489 .is_acquired()
490 .then_some(PanePointerCaptureCommand::Release { pointer_id }),
491 );
492 if dispatch.transition.is_some() {
493 let motion = PaneMotionVector::from_delta(
494 active.cumulative_delta_x,
495 active.cumulative_delta_y,
496 active.sample_count.saturating_mul(16),
497 active.direction_changes,
498 );
499 let inertial = PaneInertialThrow::from_motion(motion);
500 dispatch.motion = Some(motion);
501 dispatch.projected_position = Some(inertial.projected_pointer(position));
502 dispatch.inertial_throw = Some(inertial);
503 self.active = None;
504 }
505 dispatch
506 }
507
508 pub fn pointer_cancel(&mut self, pointer_id: Option<u32>) -> PanePointerDispatch {
510 self.cancel_active(
511 PanePointerLifecyclePhase::PointerCancel,
512 pointer_id,
513 PaneCancelReason::PointerCancel,
514 true,
515 )
516 }
517
518 pub fn pointer_leave(&mut self, pointer_id: u32) -> PanePointerDispatch {
520 let Some(active) = self.active else {
521 return PanePointerDispatch::ignored(
522 PanePointerLifecyclePhase::PointerLeave,
523 PanePointerIgnoredReason::NoActivePointer,
524 Some(pointer_id),
525 None,
526 None,
527 );
528 };
529 if active.pointer_id != pointer_id {
530 return PanePointerDispatch::ignored(
531 PanePointerLifecyclePhase::PointerLeave,
532 PanePointerIgnoredReason::PointerMismatch,
533 Some(pointer_id),
534 Some(active.target),
535 None,
536 );
537 }
538
539 if matches!(active.capture_state, CaptureState::Requested)
540 && self.config.cancel_on_leave_without_capture
541 {
542 self.cancel_active(
543 PanePointerLifecyclePhase::PointerLeave,
544 Some(pointer_id),
545 PaneCancelReason::PointerCancel,
546 true,
547 )
548 } else {
549 PanePointerDispatch::ignored(
550 PanePointerLifecyclePhase::PointerLeave,
551 PanePointerIgnoredReason::LeaveWhileCaptured,
552 Some(pointer_id),
553 Some(active.target),
554 None,
555 )
556 }
557 }
558
559 pub fn blur(&mut self) -> PanePointerDispatch {
561 let Some(active) = self.active else {
562 return PanePointerDispatch::ignored(
563 PanePointerLifecyclePhase::Blur,
564 PanePointerIgnoredReason::NoActivePointer,
565 None,
566 None,
567 None,
568 );
569 };
570 let kind = PaneSemanticInputEventKind::Blur {
571 target: Some(active.target),
572 };
573 let dispatch = self.forward_semantic(
574 DispatchContext {
575 phase: PanePointerLifecyclePhase::Blur,
576 pointer_id: Some(active.pointer_id),
577 target: Some(active.target),
578 position: None,
579 },
580 kind,
581 PaneModifierSnapshot::default(),
582 active
583 .capture_state
584 .is_acquired()
585 .then_some(PanePointerCaptureCommand::Release {
586 pointer_id: active.pointer_id,
587 }),
588 );
589 if dispatch.transition.is_some() {
590 self.active = None;
591 }
592 dispatch
593 }
594
595 pub fn visibility_hidden(&mut self) -> PanePointerDispatch {
597 self.cancel_active(
598 PanePointerLifecyclePhase::VisibilityHidden,
599 None,
600 PaneCancelReason::FocusLost,
601 true,
602 )
603 }
604
605 pub fn lost_pointer_capture(&mut self, pointer_id: u32) -> PanePointerDispatch {
607 self.cancel_active(
608 PanePointerLifecyclePhase::LostPointerCapture,
609 Some(pointer_id),
610 PaneCancelReason::PointerCancel,
611 false,
612 )
613 }
614
615 fn cancel_active(
616 &mut self,
617 phase: PanePointerLifecyclePhase,
618 pointer_id: Option<u32>,
619 reason: PaneCancelReason,
620 release_capture: bool,
621 ) -> PanePointerDispatch {
622 let Some(active) = self.active else {
623 return PanePointerDispatch::ignored(
624 phase,
625 PanePointerIgnoredReason::NoActivePointer,
626 pointer_id,
627 None,
628 None,
629 );
630 };
631 if let Some(id) = pointer_id
632 && id != active.pointer_id
633 {
634 return PanePointerDispatch::ignored(
635 phase,
636 PanePointerIgnoredReason::PointerMismatch,
637 Some(id),
638 Some(active.target),
639 None,
640 );
641 }
642
643 let kind = PaneSemanticInputEventKind::Cancel {
644 target: Some(active.target),
645 reason,
646 };
647 let command = (release_capture && active.capture_state.is_acquired()).then_some(
648 PanePointerCaptureCommand::Release {
649 pointer_id: active.pointer_id,
650 },
651 );
652 let dispatch = self.forward_semantic(
653 DispatchContext {
654 phase,
655 pointer_id: Some(active.pointer_id),
656 target: Some(active.target),
657 position: None,
658 },
659 kind,
660 PaneModifierSnapshot::default(),
661 command,
662 );
663 if dispatch.transition.is_some() {
664 self.active = None;
665 }
666 dispatch
667 }
668
669 fn forward_semantic(
670 &mut self,
671 context: DispatchContext,
672 kind: PaneSemanticInputEventKind,
673 modifiers: PaneModifierSnapshot,
674 capture_command: Option<PanePointerCaptureCommand>,
675 ) -> PanePointerDispatch {
676 let mut event = PaneSemanticInputEvent::new(self.next_sequence(), kind);
677 event.modifiers = modifiers;
678 match self.machine.apply_event(&event) {
679 Ok(transition) => {
680 let sequence = Some(event.sequence);
681 PanePointerDispatch {
682 semantic_event: Some(event),
683 transition: Some(transition),
684 motion: None,
685 inertial_throw: None,
686 projected_position: None,
687 capture_command,
688 log: PanePointerLogEntry {
689 phase: context.phase,
690 sequence,
691 pointer_id: context.pointer_id,
692 target: context.target,
693 position: context.position,
694 capture_command,
695 outcome: PanePointerLogOutcome::SemanticForwarded,
696 },
697 }
698 }
699 Err(_error) => PanePointerDispatch::ignored(
700 context.phase,
701 PanePointerIgnoredReason::MachineRejectedEvent,
702 context.pointer_id,
703 context.target,
704 context.position,
705 ),
706 }
707 }
708
709 fn next_sequence(&mut self) -> u64 {
710 let sequence = self.next_sequence;
711 self.next_sequence = self.next_sequence.saturating_add(1);
712 sequence
713 }
714}
715
716#[cfg(test)]
717mod tests {
718 use super::{
719 PanePointerCaptureAdapter, PanePointerCaptureCommand, PanePointerCaptureConfig,
720 PanePointerIgnoredReason, PanePointerLifecyclePhase, PanePointerLogOutcome,
721 };
722 use ftui_layout::{
723 PaneCancelReason, PaneDragResizeEffect, PaneDragResizeState, PaneId, PaneModifierSnapshot,
724 PanePointerButton, PanePointerPosition, PaneResizeTarget, PaneSemanticInputEventKind,
725 SplitAxis,
726 };
727
728 fn target() -> PaneResizeTarget {
729 PaneResizeTarget {
730 split_id: PaneId::MIN,
731 axis: SplitAxis::Horizontal,
732 }
733 }
734
735 fn pos(x: i32, y: i32) -> PanePointerPosition {
736 PanePointerPosition::new(x, y)
737 }
738
739 fn adapter() -> PanePointerCaptureAdapter {
740 PanePointerCaptureAdapter::new(PanePointerCaptureConfig::default())
741 .expect("default config should be valid")
742 }
743
744 #[test]
745 fn pointer_down_arms_machine_and_requests_capture() {
746 let mut adapter = adapter();
747 let dispatch = adapter.pointer_down(
748 target(),
749 11,
750 PanePointerButton::Primary,
751 pos(5, 8),
752 PaneModifierSnapshot::default(),
753 );
754 assert_eq!(
755 dispatch.capture_command,
756 Some(PanePointerCaptureCommand::Acquire { pointer_id: 11 })
757 );
758 assert_eq!(adapter.active_pointer_id(), Some(11));
759 assert!(matches!(
760 adapter.machine_state(),
761 PaneDragResizeState::Armed { pointer_id: 11, .. }
762 ));
763 assert!(matches!(
764 dispatch
765 .transition
766 .as_ref()
767 .expect("transition should exist")
768 .effect,
769 PaneDragResizeEffect::Armed { pointer_id: 11, .. }
770 ));
771 }
772
773 #[test]
774 fn non_activation_button_is_ignored_deterministically() {
775 let mut adapter = adapter();
776 let dispatch = adapter.pointer_down(
777 target(),
778 3,
779 PanePointerButton::Secondary,
780 pos(1, 1),
781 PaneModifierSnapshot::default(),
782 );
783 assert_eq!(dispatch.semantic_event, None);
784 assert_eq!(dispatch.transition, None);
785 assert_eq!(dispatch.capture_command, None);
786 assert_eq!(adapter.active_pointer_id(), None);
787 assert_eq!(
788 dispatch.log.outcome,
789 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonNotAllowed)
790 );
791 }
792
793 #[test]
794 fn pointer_move_mismatch_is_ignored_without_state_mutation() {
795 let mut adapter = adapter();
796 adapter.pointer_down(
797 target(),
798 9,
799 PanePointerButton::Primary,
800 pos(10, 10),
801 PaneModifierSnapshot::default(),
802 );
803 let before = adapter.machine_state();
804 let dispatch = adapter.pointer_move(77, pos(14, 14), PaneModifierSnapshot::default());
805 assert_eq!(dispatch.semantic_event, None);
806 assert_eq!(
807 dispatch.log.outcome,
808 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::PointerMismatch)
809 );
810 assert_eq!(before, adapter.machine_state());
811 assert_eq!(adapter.active_pointer_id(), Some(9));
812 }
813
814 #[test]
815 fn pointer_up_releases_capture_and_returns_idle() {
816 let mut adapter = adapter();
817 adapter.pointer_down(
818 target(),
819 9,
820 PanePointerButton::Primary,
821 pos(1, 1),
822 PaneModifierSnapshot::default(),
823 );
824 let ack = adapter.capture_acquired(9);
825 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
826 let dispatch = adapter.pointer_up(
827 9,
828 PanePointerButton::Primary,
829 pos(6, 1),
830 PaneModifierSnapshot::default(),
831 );
832 assert_eq!(
833 dispatch.capture_command,
834 Some(PanePointerCaptureCommand::Release { pointer_id: 9 })
835 );
836 assert_eq!(adapter.active_pointer_id(), None);
837 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
838 assert!(matches!(
839 dispatch
840 .semantic_event
841 .as_ref()
842 .expect("semantic event expected")
843 .kind,
844 PaneSemanticInputEventKind::PointerUp { pointer_id: 9, .. }
845 ));
846 }
847
848 #[test]
849 fn pointer_up_with_wrong_button_is_ignored() {
850 let mut adapter = adapter();
851 adapter.pointer_down(
852 target(),
853 4,
854 PanePointerButton::Primary,
855 pos(2, 2),
856 PaneModifierSnapshot::default(),
857 );
858 let dispatch = adapter.pointer_up(
859 4,
860 PanePointerButton::Secondary,
861 pos(3, 2),
862 PaneModifierSnapshot::default(),
863 );
864 assert_eq!(
865 dispatch.log.outcome,
866 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::ButtonMismatch)
867 );
868 assert_eq!(adapter.active_pointer_id(), Some(4));
869 }
870
871 #[test]
872 fn blur_emits_semantic_blur_and_releases_capture() {
873 let mut adapter = adapter();
874 adapter.pointer_down(
875 target(),
876 6,
877 PanePointerButton::Primary,
878 pos(0, 0),
879 PaneModifierSnapshot::default(),
880 );
881 let ack = adapter.capture_acquired(6);
882 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
883 let dispatch = adapter.blur();
884 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::Blur);
885 assert!(matches!(
886 dispatch
887 .semantic_event
888 .as_ref()
889 .expect("semantic event expected")
890 .kind,
891 PaneSemanticInputEventKind::Blur { .. }
892 ));
893 assert_eq!(
894 dispatch.capture_command,
895 Some(PanePointerCaptureCommand::Release { pointer_id: 6 })
896 );
897 assert_eq!(adapter.active_pointer_id(), None);
898 assert_eq!(adapter.machine_state(), PaneDragResizeState::Idle);
899 }
900
901 #[test]
902 fn visibility_hidden_emits_focus_lost_cancel() {
903 let mut adapter = adapter();
904 adapter.pointer_down(
905 target(),
906 8,
907 PanePointerButton::Primary,
908 pos(5, 2),
909 PaneModifierSnapshot::default(),
910 );
911 let ack = adapter.capture_acquired(8);
912 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
913 let dispatch = adapter.visibility_hidden();
914 assert!(matches!(
915 dispatch
916 .semantic_event
917 .as_ref()
918 .expect("semantic event expected")
919 .kind,
920 PaneSemanticInputEventKind::Cancel {
921 reason: PaneCancelReason::FocusLost,
922 ..
923 }
924 ));
925 assert_eq!(
926 dispatch.capture_command,
927 Some(PanePointerCaptureCommand::Release { pointer_id: 8 })
928 );
929 assert_eq!(adapter.active_pointer_id(), None);
930 }
931
932 #[test]
933 fn lost_pointer_capture_cancels_without_double_release() {
934 let mut adapter = adapter();
935 adapter.pointer_down(
936 target(),
937 42,
938 PanePointerButton::Primary,
939 pos(7, 7),
940 PaneModifierSnapshot::default(),
941 );
942 let dispatch = adapter.lost_pointer_capture(42);
943 assert_eq!(dispatch.capture_command, None);
944 assert!(matches!(
945 dispatch
946 .semantic_event
947 .as_ref()
948 .expect("semantic event expected")
949 .kind,
950 PaneSemanticInputEventKind::Cancel {
951 reason: PaneCancelReason::PointerCancel,
952 ..
953 }
954 ));
955 assert_eq!(adapter.active_pointer_id(), None);
956 }
957
958 #[test]
959 fn pointer_leave_before_capture_ack_cancels() {
960 let mut adapter = adapter();
961 adapter.pointer_down(
962 target(),
963 31,
964 PanePointerButton::Primary,
965 pos(1, 1),
966 PaneModifierSnapshot::default(),
967 );
968 let dispatch = adapter.pointer_leave(31);
969 assert_eq!(dispatch.log.phase, PanePointerLifecyclePhase::PointerLeave);
970 assert!(matches!(
971 dispatch
972 .semantic_event
973 .as_ref()
974 .expect("semantic event expected")
975 .kind,
976 PaneSemanticInputEventKind::Cancel {
977 reason: PaneCancelReason::PointerCancel,
978 ..
979 }
980 ));
981 assert_eq!(dispatch.capture_command, None);
982 assert_eq!(adapter.active_pointer_id(), None);
983 }
984
985 #[test]
986 fn pointer_leave_after_capture_ack_releases_and_cancels() {
987 let mut adapter = adapter();
988 adapter.pointer_down(
989 target(),
990 39,
991 PanePointerButton::Primary,
992 pos(3, 3),
993 PaneModifierSnapshot::default(),
994 );
995 let ack = adapter.capture_acquired(39);
996 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
997
998 let dispatch = adapter.pointer_cancel(Some(39));
999 assert!(matches!(
1000 dispatch
1001 .semantic_event
1002 .as_ref()
1003 .expect("semantic event expected")
1004 .kind,
1005 PaneSemanticInputEventKind::Cancel {
1006 reason: PaneCancelReason::PointerCancel,
1007 ..
1008 }
1009 ));
1010 assert_eq!(
1011 dispatch.capture_command,
1012 Some(PanePointerCaptureCommand::Release { pointer_id: 39 })
1013 );
1014 assert_eq!(adapter.active_pointer_id(), None);
1015 }
1016
1017 #[test]
1018 fn pointer_leave_after_capture_ack_is_ignored() {
1019 let mut adapter = adapter();
1020 adapter.pointer_down(
1021 target(),
1022 55,
1023 PanePointerButton::Primary,
1024 pos(4, 4),
1025 PaneModifierSnapshot::default(),
1026 );
1027 let ack = adapter.capture_acquired(55);
1028 assert_eq!(ack.log.outcome, PanePointerLogOutcome::CaptureStateUpdated);
1029
1030 let dispatch = adapter.pointer_leave(55);
1031 assert_eq!(dispatch.semantic_event, None);
1032 assert_eq!(
1033 dispatch.log.outcome,
1034 PanePointerLogOutcome::Ignored(PanePointerIgnoredReason::LeaveWhileCaptured)
1035 );
1036 assert_eq!(adapter.active_pointer_id(), Some(55));
1037 }
1038}