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