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