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