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