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