Skip to main content

ftui_web/
pane_pointer_capture.rs

1#![forbid(unsafe_code)]
2
3//! Deterministic web pointer-capture adapter for pane drag/resize interactions.
4//!
5//! This module bridges browser pointer lifecycle signals into
6//! [`ftui_layout::PaneSemanticInputEvent`] values while enforcing:
7//! - one active pointer at a time,
8//! - explicit capture acquire/release commands for JS hosts, and
9//! - cancellation on interruption paths (blur/visibility/lost-capture).
10
11use 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/// Adapter configuration for pane pointer-capture lifecycle handling.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct PanePointerCaptureConfig {
22    /// Drag start threshold in pane-local units.
23    pub drag_threshold: u16,
24    /// Drag update hysteresis threshold in pane-local units.
25    pub update_hysteresis: u16,
26    /// Button required to begin a drag sequence.
27    pub activation_button: PanePointerButton,
28    /// If true, pointer leave cancels drag when capture was requested but never acknowledged.
29    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/// Host command emitted by the adapter for browser pointer-capture control.
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum PanePointerCaptureCommand {
165    Acquire { pointer_id: u32 },
166    Release { pointer_id: u32 },
167}
168
169/// Lifecycle phase recorded for one adapter dispatch.
170#[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/// Deterministic reason why an incoming lifecycle signal was ignored.
184#[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/// Outcome category for one lifecycle dispatch.
197#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum PanePointerLogOutcome {
199    SemanticForwarded,
200    CaptureStateUpdated,
201    Ignored(PanePointerIgnoredReason),
202}
203
204/// Structured lifecycle log record for one adapter dispatch.
205#[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/// Result of one pointer lifecycle dispatch.
217#[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    /// Derive dynamic snap profile for this dispatch from captured motion.
230    #[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/// Deterministic pointer-capture adapter for pane web hosts.
287///
288/// The adapter emits semantic events accepted by [`PaneDragResizeMachine`] and
289/// returns host pointer-capture commands that can be wired to DOM
290/// `setPointerCapture()` / `releasePointerCapture()`.
291#[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    /// Construct a new adapter with validated thresholds.
312    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    /// Adapter configuration.
326    #[must_use]
327    pub const fn config(&self) -> PanePointerCaptureConfig {
328        self.config
329    }
330
331    /// Active pointer ID, if any.
332    #[must_use]
333    pub fn active_pointer_id(&self) -> Option<u32> {
334        self.active.map(|active| active.pointer_id)
335    }
336
337    /// Current pane drag/resize state machine state.
338    #[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    /// Handle pointer-down on a pane splitter target.
372    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    /// Mark browser pointer capture as successfully acquired.
434    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    /// Handle pointer-move during an active drag lifecycle.
453    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    /// Handle pointer-up and release capture for the active pointer.
497    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    /// Handle browser pointer-cancel events.
550    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    /// Handle pointer-leave lifecycle events.
560    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    /// Handle browser blur.
591    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    /// Handle visibility-hidden interruptions.
622    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    /// Handle `lostpointercapture`; emits cancel and clears active state.
632    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}