Skip to main content

azul_core/
events.rs

1//! Event and callback filtering module
2
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5use alloc::{
6    boxed::Box,
7    collections::{btree_map::BTreeMap, btree_set::BTreeSet},
8    vec::Vec,
9};
10
11use azul_css::AzString;
12
13use crate::{
14    callbacks::Update,
15    dom::{DomId, DomNodeId, On},
16    geom::{LogicalPosition, LogicalRect},
17    hit_test::{FullHitTest, HitTestItem},
18    id::NodeId,
19    styled_dom::{ChangedCssProperty, NodeHierarchyItemId},
20    task::Instant,
21    OrderedMap,
22};
23
24/// Easing functions for smooth scroll animations
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EasingFunction {
27    Linear,
28    EaseInOut,
29    EaseOut,
30}
31
32pub type RestyleNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
33pub type RelayoutNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
34pub type RelayoutWords = BTreeMap<NodeId, AzString>;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct FocusChange {
38    pub old: Option<DomNodeId>,
39    pub new: Option<DomNodeId>,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct CallbackToCall {
44    pub node_id: NodeId,
45    pub hit_test_item: Option<HitTestItem>,
46    pub event_filter: EventFilter,
47}
48
49impl CallbackToCall {
50    #[must_use] pub const fn new(
51        node_id: NodeId,
52        hit_test_item: Option<HitTestItem>,
53        event_filter: EventFilter,
54    ) -> Self {
55        Self { node_id, hit_test_item, event_filter }
56    }
57
58    /// Build a list of `CallbackToCall` entries for every node hit by the
59    /// given hit test under the given DOM, tagged with `event_filter`.
60    /// Returns an empty `Vec` when there is no hit test data for the DOM.
61    #[must_use] pub fn from_hit_test(
62        hit_test: &FullHitTest,
63        dom_id: DomId,
64        event_filter: EventFilter,
65    ) -> Vec<Self> {
66        let Some(hit) = hit_test.hovered_nodes.get(&dom_id) else {
67            return Vec::new();
68        };
69        hit.regular_hit_test_nodes
70            .iter()
71            .map(|(node_id, item)| Self {
72                node_id: *node_id,
73                hit_test_item: Some(*item),
74                event_filter,
75            })
76            .collect()
77    }
78}
79
80#[derive(Debug, Copy, Clone, PartialEq, Eq)]
81#[must_use = "ProcessEventResult must be used to determine if relayout/repaint is needed"]
82pub enum ProcessEventResult {
83    DoNothing = 0,
84    ShouldReRenderCurrentWindow = 1,
85    ShouldUpdateDisplayListCurrentWindow = 2,
86    // GPU transforms changed: do another hit-test and recurse
87    // until nothing has changed anymore
88    UpdateHitTesterAndProcessAgain = 3,
89    // Restyle or runtime edit changed layout-affecting properties:
90    // re-run layout on the EXISTING StyledDom (no DOM rebuild).
91    ShouldIncrementalRelayout = 4,
92    // Full DOM rebuild via user's layout_callback()
93    ShouldRegenerateDomCurrentWindow = 5,
94    ShouldRegenerateDomAllWindows = 6,
95}
96
97impl ProcessEventResult {
98    #[must_use] pub const fn order(&self) -> usize {
99        use self::ProcessEventResult::{DoNothing, ShouldReRenderCurrentWindow, ShouldUpdateDisplayListCurrentWindow, UpdateHitTesterAndProcessAgain, ShouldIncrementalRelayout, ShouldRegenerateDomCurrentWindow, ShouldRegenerateDomAllWindows};
100        match self {
101            DoNothing => 0,
102            ShouldReRenderCurrentWindow => 1,
103            ShouldUpdateDisplayListCurrentWindow => 2,
104            UpdateHitTesterAndProcessAgain => 3,
105            ShouldIncrementalRelayout => 4,
106            ShouldRegenerateDomCurrentWindow => 5,
107            ShouldRegenerateDomAllWindows => 6,
108        }
109    }
110}
111
112impl PartialOrd for ProcessEventResult {
113    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
114        self.order().partial_cmp(&other.order())
115    }
116}
117
118impl Ord for ProcessEventResult {
119    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
120        self.order().cmp(&other.order())
121    }
122}
123
124impl ProcessEventResult {
125    pub fn max_self(self, other: Self) -> Self {
126        self.max(other)
127    }
128}
129
130/// Tracks the origin of an event for proper handling.
131///
132/// This allows the system to distinguish between user input, programmatic
133/// changes, and synthetic events generated by UI components.
134#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
135#[repr(C)]
136pub enum EventSource {
137    /// Direct user input (mouse, keyboard, touch, gamepad)
138    User,
139    /// API call (programmatic scroll, focus change, etc.)
140    Programmatic,
141    /// Generated from UI interaction (scrollbar drag, synthetic events)
142    Synthetic,
143    /// Generated from lifecycle hooks (mount, unmount, resize)
144    Lifecycle,
145}
146
147/// Event propagation phase (similar to DOM Level 2 Events).
148///
149/// Events can be intercepted at different phases:
150/// - **Capture**: Event travels from root down to target (rarely used)
151/// - **Target**: Event is at the target element
152/// - **Bubble**: Event travels from target back up to root (most common)
153#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
154#[repr(C)]
155#[derive(Default)]
156pub enum EventPhase {
157    /// Event travels from root down to target
158    Capture,
159    /// Event is at the target element
160    Target,
161    /// Event bubbles from target back up to root
162    #[default]
163    Bubble,
164}
165
166
167/// Mouse button identifier for mouse events.
168#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
169#[repr(C)]
170pub enum MouseButton {
171    Left,
172    Middle,
173    Right,
174    Other(u8),
175}
176
177/// Scroll delta mode (how scroll deltas should be interpreted).
178#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
179#[repr(C)]
180pub enum ScrollDeltaMode {
181    /// Delta is in pixels
182    Pixel,
183    /// Delta is in lines (e.g., 3 lines of text)
184    Line,
185    /// Delta is in pages
186    Page,
187}
188
189/// Scroll direction for conditional event filtering.
190#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
191#[repr(C)]
192pub enum ScrollDirection {
193    Up,
194    Down,
195    Left,
196    Right,
197}
198
199// ============================================================================
200// W3C CSSOM View Module - Scroll Into View Types
201// ============================================================================
202
203/// W3C-compliant scroll-into-view options
204///
205/// These options control how an element is scrolled into view, following
206/// the CSSOM View Module specification.
207#[repr(C)]
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
209pub struct ScrollIntoViewOptions {
210    /// Vertical alignment: start, center, end, nearest (default: nearest)
211    pub block: ScrollLogicalPosition,
212    /// Horizontal alignment: start, center, end, nearest (default: nearest)
213    /// Note: Named `inline_axis` to avoid conflict with C keyword `inline`
214    pub inline_axis: ScrollLogicalPosition,
215    /// Animation behavior: auto, instant, smooth (default: auto)
216    pub behavior: ScrollIntoViewBehavior,
217}
218
219impl ScrollIntoViewOptions {
220    /// Create options with "nearest" alignment for both axes
221    #[must_use] pub const fn nearest() -> Self {
222        Self {
223            block: ScrollLogicalPosition::Nearest,
224            inline_axis: ScrollLogicalPosition::Nearest,
225            behavior: ScrollIntoViewBehavior::Auto,
226        }
227    }
228    
229    /// Create options with "center" alignment for both axes
230    #[must_use] pub const fn center() -> Self {
231        Self {
232            block: ScrollLogicalPosition::Center,
233            inline_axis: ScrollLogicalPosition::Center,
234            behavior: ScrollIntoViewBehavior::Auto,
235        }
236    }
237    
238    /// Create options with "start" alignment for both axes
239    #[must_use] pub const fn start() -> Self {
240        Self {
241            block: ScrollLogicalPosition::Start,
242            inline_axis: ScrollLogicalPosition::Start,
243            behavior: ScrollIntoViewBehavior::Auto,
244        }
245    }
246    
247    /// Create options to align the end of the target with the end of the viewport
248    #[must_use] pub const fn end() -> Self {
249        Self {
250            block: ScrollLogicalPosition::End,
251            inline_axis: ScrollLogicalPosition::End,
252            behavior: ScrollIntoViewBehavior::Auto,
253        }
254    }
255    
256    /// Set instant scroll behavior
257    #[must_use] pub const fn with_instant(mut self) -> Self {
258        self.behavior = ScrollIntoViewBehavior::Instant;
259        self
260    }
261    
262    /// Set smooth scroll behavior
263    #[must_use] pub const fn with_smooth(mut self) -> Self {
264        self.behavior = ScrollIntoViewBehavior::Smooth;
265        self
266    }
267}
268
269/// Scroll alignment for vertical (block) or horizontal (inline) axis
270///
271/// Determines where the target element should be positioned within
272/// the scroll container's visible area.
273#[repr(C)]
274#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
275pub enum ScrollLogicalPosition {
276    /// Align target's start edge with container's start edge
277    Start,
278    /// Center target within container
279    Center,
280    /// Align target's end edge with container's end edge
281    End,
282    /// Minimum scroll distance to make target fully visible (default)
283    #[default]
284    Nearest,
285}
286
287/// Scroll animation behavior for scrollIntoView API
288///
289/// This is distinct from the CSS `scroll-behavior` property, as it also
290/// supports the `Instant` option which CSS does not have.
291#[repr(C)]
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
293pub enum ScrollIntoViewBehavior {
294    /// Respect CSS scroll-behavior property (default)
295    #[default]
296    Auto,
297    /// Immediate jump without animation
298    Instant,
299    /// Animated smooth scroll
300    Smooth,
301}
302
303/// Reason why a lifecycle event was triggered.
304#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
305#[repr(C)]
306pub enum LifecycleReason {
307    /// First appearance in DOM
308    InitialMount,
309    /// Removed and re-added to DOM
310    Remount,
311    /// Layout bounds changed
312    Resize,
313    /// Props or state changed
314    Update,
315    /// Node was removed from DOM
316    Unmount,
317}
318
319/// Keyboard modifier keys state.
320#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
321#[repr(C)]
322pub struct KeyModifiers {
323    pub shift: bool,
324    pub ctrl: bool,
325    pub alt: bool,
326    pub meta: bool,
327}
328
329impl KeyModifiers {
330    #[must_use] pub fn new() -> Self {
331        Self::default()
332    }
333
334    #[must_use] pub const fn with_shift(mut self) -> Self {
335        self.shift = true;
336        self
337    }
338
339    #[must_use] pub const fn with_ctrl(mut self) -> Self {
340        self.ctrl = true;
341        self
342    }
343
344    #[must_use] pub const fn with_alt(mut self) -> Self {
345        self.alt = true;
346        self
347    }
348
349    #[must_use] pub const fn with_meta(mut self) -> Self {
350        self.meta = true;
351        self
352    }
353
354    #[must_use] pub const fn is_empty(&self) -> bool {
355        !self.shift && !self.ctrl && !self.alt && !self.meta
356    }
357}
358
359/// Type-specific event data for mouse events.
360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub struct MouseEventData {
362    /// Position of the mouse cursor
363    pub position: LogicalPosition,
364    /// Which button was pressed/released
365    pub button: MouseButton,
366    /// Bitmask of currently pressed buttons
367    pub buttons: u8,
368    /// Modifier keys state
369    pub modifiers: KeyModifiers,
370}
371
372/// Type-specific event data for keyboard events.
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub struct KeyboardEventData {
375    /// The virtual key code
376    pub key_code: u32,
377    /// The character produced (if any)
378    pub char_code: Option<char>,
379    /// Modifier keys state
380    pub modifiers: KeyModifiers,
381    /// Whether this is a repeat event
382    pub repeat: bool,
383}
384
385/// Type-specific event data for scroll events.
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub struct ScrollEventData {
388    /// Scroll delta (dx, dy)
389    pub delta: LogicalPosition,
390    /// How the delta should be interpreted
391    pub delta_mode: ScrollDeltaMode,
392}
393
394/// Type-specific event data for touch events.
395#[derive(Debug, Clone, Copy, PartialEq)]
396pub struct TouchEventData {
397    /// Touch identifier
398    pub id: u64,
399    /// Touch position
400    pub position: LogicalPosition,
401    /// Touch force/pressure (0.0 - 1.0)
402    pub force: f32,
403}
404
405/// Type-specific event data for clipboard events.
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct ClipboardEventData {
408    /// The clipboard content (for paste events)
409    pub content: Option<String>,
410}
411
412/// Type-specific event data for lifecycle events.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub struct LifecycleEventData {
415    /// Why this lifecycle event was triggered
416    pub reason: LifecycleReason,
417    /// Previous layout bounds (for resize events)
418    pub previous_bounds: Option<LogicalRect>,
419    /// Current layout bounds
420    pub current_bounds: LogicalRect,
421}
422
423/// Type-specific event data for window events.
424#[derive(Debug, Clone, Copy, PartialEq, Eq)]
425pub struct WindowEventData {
426    /// Window size (for resize events)
427    pub size: Option<LogicalRect>,
428    /// Window position (for move events)
429    pub position: Option<LogicalPosition>,
430}
431
432/// Type-specific event data for text-input (editing) events.
433///
434/// Carried by `EventType::Input` events so that text-input callbacks can read
435/// the edit details directly off the event — matching how mouse/keyboard/scroll
436/// callbacks read their data — instead of having to reach into the
437/// `TextInputManager`'s pending changeset. The edited node is already available
438/// via `SyntheticEvent.target`.
439#[derive(Debug, Clone, PartialEq, Eq)]
440pub struct TextInputEventData {
441    /// The text inserted by this edit (empty for pure deletions).
442    pub inserted_text: String,
443    /// The text content of the node *before* this edit was applied.
444    pub old_text: String,
445}
446
447/// Union of all possible event data types.
448#[derive(Debug, Clone, PartialEq)]
449pub enum EventData {
450    /// Mouse event data
451    Mouse(MouseEventData),
452    /// Keyboard event data
453    Keyboard(KeyboardEventData),
454    /// Scroll event data
455    Scroll(ScrollEventData),
456    /// Touch event data
457    Touch(TouchEventData),
458    /// Clipboard event data
459    Clipboard(ClipboardEventData),
460    /// Text-input (editing) event data
461    TextInput(TextInputEventData),
462    /// Lifecycle event data
463    Lifecycle(LifecycleEventData),
464    /// Window event data
465    Window(WindowEventData),
466    /// No additional data
467    None,
468}
469
470/// High-level event type classification.
471///
472/// This enum categorizes all possible events that can occur in the UI.
473/// It extends the existing event system with new event types for
474/// lifecycle, clipboard, media, and form handling.
475#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
476#[repr(C)]
477pub enum EventType {
478    // Mouse Events
479    /// Mouse cursor is over the element
480    MouseOver,
481    /// Mouse cursor entered the element
482    MouseEnter,
483    /// Mouse cursor left the element
484    MouseLeave,
485    /// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
486    MouseOut,
487    /// Mouse button pressed
488    MouseDown,
489    /// Mouse button released
490    MouseUp,
491    /// Mouse click (down + up on same element)
492    Click,
493    /// Mouse double-click
494    DoubleClick,
495    /// Right-click / context menu
496    ContextMenu,
497
498    // Keyboard Events
499    /// Key pressed down
500    KeyDown,
501    /// Key released
502    KeyUp,
503    /// Character input (respects locale/keyboard layout)
504    KeyPress,
505
506    // IME Composition Events
507    /// IME composition started
508    CompositionStart,
509    /// IME composition updated (intermediate text changed)
510    CompositionUpdate,
511    /// IME composition ended (final text committed)
512    CompositionEnd,
513
514    // Focus Events
515    /// Element received focus
516    Focus,
517    /// Element lost focus
518    Blur,
519    /// Focus entered element or its children
520    FocusIn,
521    /// Focus left element and its children
522    FocusOut,
523
524    // Input Events
525    /// Input value is being changed (fires on every keystroke)
526    Input,
527    /// Input value has changed (fires after editing complete)
528    Change,
529    /// Form submitted
530    Submit,
531    /// Form reset
532    Reset,
533    /// Form validation failed
534    Invalid,
535
536    // Scroll Events
537    /// Element is being scrolled
538    Scroll,
539    /// Scroll started
540    ScrollStart,
541    /// Scroll ended
542    ScrollEnd,
543
544    // Drag Events
545    /// Drag operation started
546    DragStart,
547    /// Element is being dragged
548    Drag,
549    /// Drag operation ended
550    DragEnd,
551    /// Dragged element entered drop target
552    DragEnter,
553    /// Dragged element is over drop target
554    DragOver,
555    /// Dragged element left drop target
556    DragLeave,
557    /// Element was dropped
558    Drop,
559
560    // Touch Events
561    /// Touch started
562    TouchStart,
563    /// Touch moved
564    TouchMove,
565    /// Touch ended
566    TouchEnd,
567    /// Touch cancelled
568    TouchCancel,
569
570    // Pen / Stylus Events (W3C PointerEvent, pointerType "pen")
571    /// Pen tip made contact (or pen entered while down)
572    PenDown,
573    /// Pen moved (in contact or hovering in range)
574    PenMove,
575    /// Pen tip lifted
576    PenUp,
577    /// Pen entered hover/sensing range (proximity in)
578    PenEnter,
579    /// Pen left hover/sensing range (proximity out)
580    PenLeave,
581
582    // Gesture Events
583    /// Long press detected (touch or mouse held down)
584    LongPress,
585    /// Swipe gesture to the left
586    SwipeLeft,
587    /// Swipe gesture to the right
588    SwipeRight,
589    /// Swipe gesture upward
590    SwipeUp,
591    /// Swipe gesture downward
592    SwipeDown,
593    /// Pinch-in gesture (zoom out)
594    PinchIn,
595    /// Pinch-out gesture (zoom in)
596    PinchOut,
597    /// Clockwise rotation gesture
598    RotateClockwise,
599    /// Counter-clockwise rotation gesture
600    RotateCounterClockwise,
601
602    // Clipboard Events
603    /// Content copied to clipboard
604    Copy,
605    /// Content cut to clipboard
606    Cut,
607    /// Content pasted from clipboard
608    Paste,
609
610    // Media Events
611    /// Media playback started
612    Play,
613    /// Media playback paused
614    Pause,
615    /// Media playback ended
616    Ended,
617    /// Media time updated
618    TimeUpdate,
619    /// Media volume changed
620    VolumeChange,
621    /// Media error occurred
622    MediaError,
623
624    // Lifecycle Events
625    /// Component was mounted to the DOM
626    Mount,
627    /// Component will be unmounted from the DOM
628    Unmount,
629    /// Component was updated
630    Update,
631    /// Component layout bounds changed
632    Resize,
633
634    // Window Events
635    /// Window resized
636    WindowResize,
637    /// Window moved
638    WindowMove,
639    /// Window close requested
640    WindowClose,
641    /// Window received focus
642    WindowFocusIn,
643    /// Window lost focus
644    WindowFocusOut,
645    /// System theme changed
646    ThemeChange,
647    /// Window DPI/scale factor changed (moved to different monitor)
648    WindowDpiChanged,
649    /// Window moved to a different monitor
650    WindowMonitorChanged,
651
652    // Application Events
653    /// A monitor/display was connected
654    MonitorConnected,
655    /// A monitor/display was disconnected
656    MonitorDisconnected,
657
658    // File Events
659    /// File is being hovered
660    FileHover,
661    /// File was dropped
662    FileDrop,
663    /// File hover cancelled
664    FileHoverCancel,
665
666    // Hardware input-device Events (P6 sensors / gamepad)
667    /// A motion-sensor reading (accelerometer / gyroscope / magnetometer)
668    /// changed. Read the value with `CallbackInfo::get_sensor_reading`.
669    SensorChanged,
670    /// A gamepad's buttons / axes changed, or one was (dis)connected. Read it
671    /// with `CallbackInfo::get_primary_gamepad` / `get_gamepad_state`.
672    GamepadInput,
673
674    // Geolocation Events (MWA-A1 — synthesized by the capability pump's
675    // GeolocationManager EventProvider; both filter enums already carried
676    // the matching variants, only this dispatch type was missing them).
677    /// A new GPS / network location fix arrived. Read it with
678    /// `CallbackInfo::get_geolocation_fix`.
679    GeolocationFix,
680    /// The native geolocation subscription errored, timed out, or was
681    /// revoked.
682    GeolocationError,
683
684    // Async capability outcomes (MWA-A1b — synthesized by the capability
685    // pump's manager EventProviders so idle apps observe prompt results).
686    /// A permission's OS-observed state changed (granted / denied /
687    /// revoked / restricted). Targeted at the capability's most recent
688    /// subscriber node when known, else the root. Read the new state via
689    /// `CallbackInfo` permission accessors.
690    PermissionChanged,
691    /// A biometric authentication prompt completed. Read the outcome via
692    /// `CallbackInfo::get_biometric_result`.
693    BiometricResult,
694    /// A keyring store / get / delete operation completed. Read the outcome
695    /// via `CallbackInfo::get_keyring_result`.
696    KeyringResult,
697}
698
699/// Unified event wrapper (similar to React's `SyntheticEvent`).
700///
701/// All events in the system are wrapped in this structure, providing
702/// a consistent interface and enabling event propagation control.
703#[derive(Debug, Clone, PartialEq)]
704pub struct SyntheticEvent {
705    /// The type of event
706    pub event_type: EventType,
707
708    /// Where the event came from
709    pub source: EventSource,
710
711    /// Current propagation phase
712    pub phase: EventPhase,
713
714    /// Target node that the event was dispatched to
715    pub target: DomNodeId,
716
717    /// Current node in the propagation path
718    pub current_target: DomNodeId,
719
720    /// Timestamp when event was created
721    pub timestamp: Instant,
722
723    /// Type-specific event data
724    pub data: EventData,
725
726    /// Whether propagation has been stopped
727    pub stopped: bool,
728
729    /// Whether immediate propagation has been stopped
730    pub stopped_immediate: bool,
731
732    /// Whether default action has been prevented
733    pub prevented_default: bool,
734}
735
736impl SyntheticEvent {
737    /// Create a new synthetic event.
738    ///
739    /// # Parameters
740    /// - `timestamp`: Current time from `(system_callbacks.get_system_time_fn.cb)()`
741    #[must_use] pub const fn new(
742        event_type: EventType,
743        source: EventSource,
744        target: DomNodeId,
745        timestamp: Instant,
746        data: EventData,
747    ) -> Self {
748        Self {
749            event_type,
750            source,
751            phase: EventPhase::Target,
752            target,
753            current_target: target,
754            timestamp,
755            data,
756            stopped: false,
757            stopped_immediate: false,
758            prevented_default: false,
759        }
760    }
761
762    /// Stop event propagation after the current phase completes.
763    ///
764    /// This prevents the event from reaching handlers in subsequent phases
765    /// (e.g., stopping during capture prevents bubble phase).
766    pub const fn stop_propagation(&mut self) {
767        self.stopped = true;
768    }
769
770    /// Stop event propagation immediately.
771    ///
772    /// This prevents any further handlers from being called, even on the
773    /// current target element.
774    pub const fn stop_immediate_propagation(&mut self) {
775        self.stopped_immediate = true;
776        self.stopped = true;
777    }
778
779    /// Prevent the default action associated with this event.
780    ///
781    /// For example, prevents form submission on Enter key, or prevents
782    /// text selection on drag.
783    pub const fn prevent_default(&mut self) {
784        self.prevented_default = true;
785    }
786
787    /// Check if propagation was stopped.
788    #[must_use] pub const fn is_propagation_stopped(&self) -> bool {
789        self.stopped
790    }
791
792    /// Check if immediate propagation was stopped.
793    #[must_use] pub const fn is_immediate_propagation_stopped(&self) -> bool {
794        self.stopped_immediate
795    }
796
797    /// Check if default action was prevented.
798    #[must_use] pub const fn is_default_prevented(&self) -> bool {
799        self.prevented_default
800    }
801}
802
803/// Result of event propagation through DOM tree.
804#[derive(Debug, Clone)]
805#[derive(Default)]
806pub struct PropagationResult {
807    /// Callbacks that should be invoked, in order
808    pub callbacks_to_invoke: Vec<(NodeId, EventFilter)>,
809    /// Whether default action should be prevented
810    pub default_prevented: bool,
811}
812
813/// Get the path from root to target node in the DOM tree.
814///
815/// This is used for event propagation - we need to know which nodes
816/// are ancestors of the target to implement capture/bubble phases.
817///
818/// Returns nodes in order from root to target (inclusive).
819#[must_use] pub fn get_dom_path(
820    node_hierarchy: &crate::id::NodeHierarchy,
821    target_node: NodeHierarchyItemId,
822) -> Vec<NodeId> {
823    let mut path = Vec::new();
824    let Some(target_node_id) = target_node.into_crate_internal() else {
825        return path;
826    };
827
828    let hier_ref = node_hierarchy.as_ref();
829
830    // Build path from target to root. Bounded by the node count and guarded by a
831    // visited-set: a corrupt hierarchy with a parent cycle (or a parent chain
832    // longer than the arena) would otherwise loop forever / OOM here, and this
833    // runs on every event dispatch.
834    let node_count = hier_ref.len();
835    let mut visited: BTreeSet<NodeId> = BTreeSet::new();
836    let mut current = Some(target_node_id);
837    while let Some(node_id) = current {
838        if path.len() > node_count || !visited.insert(node_id) {
839            // Cycle or overrun detected: stop rather than spin forever.
840            break;
841        }
842        path.push(node_id);
843        current = hier_ref.get(node_id).and_then(|node| node.parent);
844    }
845
846    // Reverse to get root → target order
847    path.reverse();
848    path
849}
850
851/// Propagate event through DOM tree with capture and bubble phases.
852///
853/// This implements DOM Level 2 event propagation:
854/// 1. **Capture Phase**: Event travels from root down to target
855/// 2. **Target Phase**: Event is at the target element
856/// 3. **Bubble Phase**: Event travels from target back up to root
857///
858/// The event can be stopped at any point via `stopPropagation()` or
859/// `stopImmediatePropagation()`.
860///
861/// # Panics
862///
863/// Panics if `path` is empty; it must contain at least the target node.
864pub fn propagate_event(
865    event: &mut SyntheticEvent,
866    node_hierarchy: &crate::id::NodeHierarchy,
867    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
868) -> PropagationResult {
869    let path = get_dom_path(node_hierarchy, event.target.node);
870    if path.is_empty() {
871        return PropagationResult::default();
872    }
873
874    let ancestors = &path[..path.len().saturating_sub(1)];
875    let target_node_id = *path.last().unwrap();
876
877    let mut result = PropagationResult::default();
878
879    // Phase 1: Capture (root → target)
880    propagate_phase(
881        event,
882        ancestors.iter().copied(),
883        EventPhase::Capture,
884        callbacks,
885        &mut result,
886    );
887
888    // Phase 2: Target
889    if !event.stopped {
890        propagate_target_phase(event, target_node_id, callbacks, &mut result);
891    }
892
893    // Phase 3: Bubble (target → root)
894    if !event.stopped {
895        propagate_phase(
896            event,
897            ancestors.iter().rev().copied(),
898            EventPhase::Bubble,
899            callbacks,
900            &mut result,
901        );
902    }
903
904    result.default_prevented = event.prevented_default;
905    result
906}
907
908/// Process a single propagation phase (Capture or Bubble)
909fn propagate_phase(
910    event: &mut SyntheticEvent,
911    nodes: impl Iterator<Item = NodeId>,
912    phase: EventPhase,
913    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
914    result: &mut PropagationResult,
915) {
916    event.phase = phase;
917
918    for node_id in nodes {
919        if event.stopped_immediate || event.stopped {
920            return;
921        }
922
923        event.current_target = DomNodeId {
924            dom: event.target.dom,
925            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
926        };
927
928        collect_matching_callbacks(event, node_id, phase, callbacks, result);
929    }
930}
931
932/// Process the target phase
933fn propagate_target_phase(
934    event: &mut SyntheticEvent,
935    target_node_id: NodeId,
936    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
937    result: &mut PropagationResult,
938) {
939    event.phase = EventPhase::Target;
940    event.current_target = event.target;
941
942    collect_matching_callbacks(event, target_node_id, EventPhase::Target, callbacks, result);
943}
944
945/// Collect callbacks that match the current phase for a node
946fn collect_matching_callbacks(
947    event: &SyntheticEvent,
948    node_id: NodeId,
949    phase: EventPhase,
950    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
951    result: &mut PropagationResult,
952) {
953    let Some(node_callbacks) = callbacks.get(&node_id) else {
954        return;
955    };
956
957    let matching = node_callbacks
958        .iter()
959        .take_while(|_| !event.stopped_immediate)
960        .filter(|filter| matches_filter_phase(**filter, event, phase))
961        .map(|filter| (node_id, *filter));
962
963    result.callbacks_to_invoke.extend(matching);
964}
965
966
967// =============================================================================
968// DEFAULT ACTIONS (W3C UI Events / HTML5 Activation Behavior)
969// =============================================================================
970
971/// Default actions are built-in behaviors that occur in response to events.
972///
973/// Per W3C DOM Event specification:
974/// > A default action is an action that the implementation is expected to take
975/// > in response to an event, unless that action is cancelled by the script.
976///
977/// Examples:
978/// - Tab key → move focus to next focusable element
979/// - Enter/Space on button → activate (click) the button
980/// - Escape → clear focus or close modal
981/// - Arrow keys in listbox → move selection
982///
983/// Default actions are processed AFTER all event callbacks have been invoked,
984/// and only if `event.prevent_default()` was NOT called.
985#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
986#[repr(C, u8)]
987pub enum DefaultAction {
988    /// Move focus to the next focusable element (Tab key)
989    FocusNext,
990    /// Move focus to the previous focusable element (Shift+Tab)
991    FocusPrevious,
992    /// Move focus to the first focusable element
993    FocusFirst,
994    /// Move focus to the last focusable element
995    FocusLast,
996    /// Clear focus from the currently focused element (Escape key)
997    ClearFocus,
998    /// Activate the focused element (Enter/Space on activatable elements)
999    /// This generates a synthetic Click event on the target
1000    ActivateFocusedElement {
1001        target: DomNodeId,
1002    },
1003    /// Submit the form containing the focused element (Enter in form input)
1004    SubmitForm {
1005        form_node: DomNodeId,
1006    },
1007    /// Close the current modal/dialog (Escape key when modal is open)
1008    CloseModal {
1009        modal_node: DomNodeId,
1010    },
1011    /// Scroll the focused scrollable container
1012    ScrollFocusedContainer {
1013        direction: ScrollDirection,
1014        amount: ScrollAmount,
1015    },
1016    /// Select all text in the focused text input (Ctrl+A / Cmd+A)
1017    SelectAllText,
1018    /// No default action for this event
1019    None,
1020}
1021
1022/// Amount to scroll for keyboard-based scrolling
1023#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1024#[repr(C)]
1025pub enum ScrollAmount {
1026    /// Scroll by one line (arrow keys)
1027    Line,
1028    /// Scroll by one page (Page Up/Down)
1029    Page,
1030    /// Scroll to start/end (Home/End)
1031    Document,
1032}
1033
1034/// Result of determining what default action should occur for an event.
1035///
1036/// This is computed AFTER event dispatch, based on:
1037/// 1. The event type
1038/// 2. The target element's type/role
1039/// 3. Whether `prevent_default()` was called
1040#[derive(Debug, Clone, Copy)]
1041#[repr(C)]
1042pub struct DefaultActionResult {
1043    /// The default action to perform (if any)
1044    pub action: DefaultAction,
1045    /// Whether the action was prevented by a callback
1046    pub prevented: bool,
1047}
1048
1049impl Default for DefaultActionResult {
1050    fn default() -> Self {
1051        Self {
1052            action: DefaultAction::None,
1053            prevented: false,
1054        }
1055    }
1056}
1057
1058impl DefaultActionResult {
1059    /// Create a new result with a specific action
1060    #[must_use] pub const fn new(action: DefaultAction) -> Self {
1061        Self {
1062            action,
1063            prevented: false,
1064        }
1065    }
1066
1067    /// Create a prevented result (callback called `prevent_default`)
1068    #[must_use] pub const fn prevented() -> Self {
1069        Self {
1070            action: DefaultAction::None,
1071            prevented: true,
1072        }
1073    }
1074
1075    /// Check if there's an action to perform
1076    #[must_use] pub const fn has_action(&self) -> bool {
1077        !self.prevented && !matches!(self.action, DefaultAction::None)
1078    }
1079}
1080
1081/// Trait for elements that have activation behavior (can be "clicked" via keyboard).
1082///
1083/// Per HTML5 spec, elements with activation behavior include:
1084/// - `<button>` elements
1085/// - `<input type="submit">`, `<input type="button">`, `<input type="reset">`
1086/// - `<a>` elements with href
1087/// - `<area>` elements with href
1088/// - Any element with a click handler (implicit activation)
1089///
1090/// When an element with activation behavior is focused and the user presses
1091/// Enter or Space, a synthetic click event is generated.
1092pub trait ActivationBehavior {
1093    /// Returns true if this element can be activated via keyboard (Enter/Space)
1094    fn has_activation_behavior(&self) -> bool;
1095
1096    /// Returns true if this element is currently activatable
1097    /// (e.g., not disabled, not aria-disabled="true")
1098    fn is_activatable(&self) -> bool;
1099}
1100
1101/// Trait to query if a node is focusable for tab navigation
1102pub trait Focusable {
1103    /// Returns the tabindex value for this element (-1, 0, or positive)
1104    fn get_tabindex(&self) -> Option<i32>;
1105
1106    /// Returns true if this element can receive focus
1107    fn is_focusable(&self) -> bool;
1108
1109    /// Returns true if this element should be in the tab order
1110    fn is_in_tab_order(&self) -> bool {
1111        self.get_tabindex().map_or_else(|| self.is_naturally_focusable(), |i| i >= 0)
1112    }
1113
1114    /// Returns true if this element type is naturally focusable
1115    /// (button, input, select, textarea, a[href])
1116    fn is_naturally_focusable(&self) -> bool;
1117}
1118
1119/// Check if an event filter matches the given event in the current phase.
1120///
1121/// This is used during event propagation to determine which callbacks
1122/// should be invoked at each phase.
1123fn matches_filter_phase(
1124    filter: EventFilter,
1125    event: &SyntheticEvent,
1126    current_phase: EventPhase,
1127) -> bool {
1128    // azul has no capture-phase listeners (no `addEventListener(…, capture=true)`
1129    // equivalent): every `EventFilter` is a bubble-phase listener, which by the W3C
1130    // model fires only in the Target and Bubble phases — never Capture. Without this
1131    // guard an ancestor node's Hover/Focus callback was collected in BOTH the capture
1132    // and the bubble walk, so it fired TWICE whenever the hit target was a descendant
1133    // (e.g. a menubar item, hit via its text child, opened two stacked popups; any
1134    // button containing a text/child node ran its MouseUp callback twice).
1135    if matches!(current_phase, EventPhase::Capture) {
1136        return false;
1137    }
1138
1139    match filter {
1140        EventFilter::Hover(hover_filter) => {
1141            matches_hover_filter(hover_filter, event, current_phase)
1142        }
1143        EventFilter::Focus(focus_filter) => {
1144            matches_focus_filter(focus_filter, event, current_phase)
1145        }
1146        EventFilter::Window(window_filter) => {
1147            matches_window_filter(window_filter, event, current_phase)
1148        }
1149        EventFilter::Component(component_filter) => {
1150            matches_component_filter(component_filter, event, current_phase)
1151        }
1152        EventFilter::Application(_) => {
1153            // Application events - will be implemented in future
1154            false
1155        }
1156    }
1157}
1158
1159/// Check if a component (lifecycle) filter matches the event.
1160///
1161/// Lifecycle events produced by `diff::reconcile_dom` carry the target node in
1162/// `SyntheticEvent.target`, so dispatchers that bypass `propagate_event` and
1163/// invoke the target directly also need a way to compare. This predicate is
1164/// the single source of truth for that comparison; changing it without
1165/// updating `event_type_to_filters` will de-sync dispatch.
1166const fn matches_component_filter(
1167    filter: ComponentEventFilter,
1168    event: &SyntheticEvent,
1169    _phase: EventPhase,
1170) -> bool {
1171    matches!(
1172        (filter, &event.event_type),
1173        (ComponentEventFilter::AfterMount, EventType::Mount)
1174            | (ComponentEventFilter::BeforeUnmount, EventType::Unmount)
1175            | (ComponentEventFilter::Updated, EventType::Update)
1176            | (ComponentEventFilter::NodeResized, EventType::Resize)
1177    )
1178}
1179
1180/// Check if the event data contains a mouse event with the expected button.
1181fn check_mouse_button(data: &EventData, expected: MouseButton) -> bool {
1182    if let EventData::Mouse(mouse_data) = data {
1183        mouse_data.button == expected
1184    } else {
1185        false
1186    }
1187}
1188
1189/// Check if a hover filter matches the event.
1190// Exhaustive (filter, event-type) truth table: many distinct pairs share the
1191// `=> true` body. One arm per pair is intentional; merging into giant or-patterns
1192// would destroy the table's readability/maintainability.
1193#[allow(clippy::match_same_arms)]
1194fn matches_hover_filter(
1195    filter: HoverEventFilter,
1196    event: &SyntheticEvent,
1197    _phase: EventPhase,
1198) -> bool {
1199    use HoverEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, TouchStart, TouchMove, TouchEnd, TouchCancel, PenDown, PenMove, PenUp, PenEnter, PenLeave, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop, DoubleClick, SensorChanged, GamepadInput, GeolocationFix, GeolocationError, PermissionChanged, BiometricResult, KeyringResult};
1200
1201    match (filter, &event.event_type) {
1202        (MouseOver, EventType::MouseOver) => true,
1203        (MouseDown, EventType::MouseDown) => true,
1204        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
1205        (RightMouseDown, EventType::MouseDown) => {
1206            check_mouse_button(&event.data, MouseButton::Right)
1207        }
1208        (MiddleMouseDown, EventType::MouseDown) => {
1209            check_mouse_button(&event.data, MouseButton::Middle)
1210        }
1211        (MouseUp, EventType::MouseUp) => true,
1212        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
1213        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
1214        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1215        (MouseEnter, EventType::MouseEnter) => true,
1216        (MouseLeave, EventType::MouseLeave) => true,
1217        (Scroll, EventType::Scroll) => true,
1218        (ScrollStart, EventType::ScrollStart) => true,
1219        (ScrollEnd, EventType::ScrollEnd) => true,
1220        (TextInput, EventType::Input) => true,
1221        (VirtualKeyDown, EventType::KeyDown) => true,
1222        (VirtualKeyUp, EventType::KeyUp) => true,
1223        (HoveredFile, EventType::FileHover) => true,
1224        (DroppedFile, EventType::FileDrop) => true,
1225        (HoveredFileCancelled, EventType::FileHoverCancel) => true,
1226        (TouchStart, EventType::TouchStart) => true,
1227        (TouchMove, EventType::TouchMove) => true,
1228        (TouchEnd, EventType::TouchEnd) => true,
1229        (TouchCancel, EventType::TouchCancel) => true,
1230        (PenDown, EventType::PenDown) => true,
1231        (PenMove, EventType::PenMove) => true,
1232        (PenUp, EventType::PenUp) => true,
1233        (PenEnter, EventType::PenEnter) => true,
1234        (PenLeave, EventType::PenLeave) => true,
1235        (DragStart, EventType::DragStart) => true,
1236        (Drag, EventType::Drag) => true,
1237        (DragEnd, EventType::DragEnd) => true,
1238        (DragEnter, EventType::DragEnter) => true,
1239        (DragOver, EventType::DragOver) => true,
1240        (DragLeave, EventType::DragLeave) => true,
1241        (Drop, EventType::Drop) => true,
1242        (DoubleClick, EventType::DoubleClick) => true,
1243        (SensorChanged, EventType::SensorChanged) => true,
1244        (GamepadInput, EventType::GamepadInput) => true,
1245        (GeolocationFix, EventType::GeolocationFix) => true,
1246        (GeolocationError, EventType::GeolocationError) => true,
1247        (PermissionChanged, EventType::PermissionChanged) => true,
1248        (BiometricResult, EventType::BiometricResult) => true,
1249        (KeyringResult, EventType::KeyringResult) => true,
1250        _ => false,
1251    }
1252}
1253
1254/// Check if a focus filter matches the event.
1255// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
1256#[allow(clippy::match_same_arms)]
1257fn matches_focus_filter(
1258    filter: FocusEventFilter,
1259    event: &SyntheticEvent,
1260    _phase: EventPhase,
1261) -> bool {
1262    use FocusEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, FocusReceived, FocusLost, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop};
1263
1264    match (filter, &event.event_type) {
1265        (MouseOver, EventType::MouseOver) => true,
1266        (MouseDown, EventType::MouseDown) => true,
1267        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
1268        (RightMouseDown, EventType::MouseDown) => {
1269            check_mouse_button(&event.data, MouseButton::Right)
1270        }
1271        (MiddleMouseDown, EventType::MouseDown) => {
1272            check_mouse_button(&event.data, MouseButton::Middle)
1273        }
1274        (MouseUp, EventType::MouseUp) => true,
1275        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
1276        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
1277        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1278        (MouseEnter, EventType::MouseEnter) => true,
1279        (MouseLeave, EventType::MouseLeave) => true,
1280        (Scroll, EventType::Scroll) => true,
1281        (ScrollStart, EventType::ScrollStart) => true,
1282        (ScrollEnd, EventType::ScrollEnd) => true,
1283        (TextInput, EventType::Input) => true,
1284        (VirtualKeyDown, EventType::KeyDown) => true,
1285        (VirtualKeyUp, EventType::KeyUp) => true,
1286        (FocusReceived, EventType::Focus) => true,
1287        (FocusLost, EventType::Blur) => true,
1288        (DragStart, EventType::DragStart) => true,
1289        (Drag, EventType::Drag) => true,
1290        (DragEnd, EventType::DragEnd) => true,
1291        (DragEnter, EventType::DragEnter) => true,
1292        (DragOver, EventType::DragOver) => true,
1293        (DragLeave, EventType::DragLeave) => true,
1294        (Drop, EventType::Drop) => true,
1295        // MWA-C-clipboard: W3C clipboard events on the focused element
1296        // (qualified paths — `use FocusEventFilter::Copy` would shadow the
1297        // `Copy` trait in this scope).
1298        (FocusEventFilter::Copy, EventType::Copy) => true,
1299        (FocusEventFilter::Cut, EventType::Cut) => true,
1300        (FocusEventFilter::Paste, EventType::Paste) => true,
1301        _ => false,
1302    }
1303}
1304
1305/// Check if a window filter matches the event.
1306// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
1307#[allow(clippy::match_same_arms)]
1308fn matches_window_filter(
1309    filter: WindowEventFilter,
1310    event: &SyntheticEvent,
1311    _phase: EventPhase,
1312) -> bool {
1313    use WindowEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, Resized, Moved, TouchStart, TouchMove, TouchEnd, TouchCancel, PenDown, PenMove, PenUp, PenEnter, PenLeave, FocusReceived, FocusLost, CloseRequested, ThemeChanged, WindowFocusReceived, WindowFocusLost, SensorChanged, GamepadInput, GeolocationFix, GeolocationError, PermissionChanged, BiometricResult, KeyringResult, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop};
1314
1315    match (filter, &event.event_type) {
1316        (MouseOver, EventType::MouseOver) => true,
1317        (MouseDown, EventType::MouseDown) => true,
1318        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
1319        (RightMouseDown, EventType::MouseDown) => {
1320            check_mouse_button(&event.data, MouseButton::Right)
1321        }
1322        (MiddleMouseDown, EventType::MouseDown) => {
1323            check_mouse_button(&event.data, MouseButton::Middle)
1324        }
1325        (MouseUp, EventType::MouseUp) => true,
1326        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
1327        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
1328        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1329        (MouseEnter, EventType::MouseEnter) => true,
1330        (MouseLeave, EventType::MouseLeave) => true,
1331        (Scroll, EventType::Scroll) => true,
1332        (ScrollStart, EventType::ScrollStart) => true,
1333        (ScrollEnd, EventType::ScrollEnd) => true,
1334        (TextInput, EventType::Input) => true,
1335        (VirtualKeyDown, EventType::KeyDown) => true,
1336        (VirtualKeyUp, EventType::KeyUp) => true,
1337        (HoveredFile, EventType::FileHover) => true,
1338        (DroppedFile, EventType::FileDrop) => true,
1339        (HoveredFileCancelled, EventType::FileHoverCancel) => true,
1340        (Resized, EventType::WindowResize) => true,
1341        (Moved, EventType::WindowMove) => true,
1342        (TouchStart, EventType::TouchStart) => true,
1343        (TouchMove, EventType::TouchMove) => true,
1344        (TouchEnd, EventType::TouchEnd) => true,
1345        (TouchCancel, EventType::TouchCancel) => true,
1346        (PenDown, EventType::PenDown) => true,
1347        (PenMove, EventType::PenMove) => true,
1348        (PenUp, EventType::PenUp) => true,
1349        (PenEnter, EventType::PenEnter) => true,
1350        (PenLeave, EventType::PenLeave) => true,
1351        (FocusReceived, EventType::Focus) => true,
1352        (FocusLost, EventType::Blur) => true,
1353        (CloseRequested, EventType::WindowClose) => true,
1354        (ThemeChanged, EventType::ThemeChange) => true,
1355        (WindowFocusReceived, EventType::WindowFocusIn) => true,
1356        (WindowFocusLost, EventType::WindowFocusOut) => true,
1357        (SensorChanged, EventType::SensorChanged) => true,
1358        (GamepadInput, EventType::GamepadInput) => true,
1359        (GeolocationFix, EventType::GeolocationFix) => true,
1360        (GeolocationError, EventType::GeolocationError) => true,
1361        (PermissionChanged, EventType::PermissionChanged) => true,
1362        (BiometricResult, EventType::BiometricResult) => true,
1363        (KeyringResult, EventType::KeyringResult) => true,
1364        (DragStart, EventType::DragStart) => true,
1365        (Drag, EventType::Drag) => true,
1366        (DragEnd, EventType::DragEnd) => true,
1367        (DragEnter, EventType::DragEnter) => true,
1368        (DragOver, EventType::DragOver) => true,
1369        (DragLeave, EventType::DragLeave) => true,
1370        (Drop, EventType::Drop) => true,
1371        _ => false,
1372    }
1373}
1374
1375/// Detect lifecycle events by comparing old and new DOM state.
1376///
1377/// This is the simple, index-based lifecycle detection that doesn't account for
1378/// node reordering. For more sophisticated reconciliation that can detect moves,
1379/// use `detect_lifecycle_events_with_reconciliation`.
1380///
1381/// Generates Mount, Unmount, and Resize events by comparing DOM hierarchies.
1382#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
1383#[must_use] pub fn detect_lifecycle_events(
1384    old_dom_id: DomId,
1385    new_dom_id: DomId,
1386    old_hierarchy: Option<&crate::id::NodeHierarchy>,
1387    new_hierarchy: Option<&crate::id::NodeHierarchy>,
1388    old_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
1389    new_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
1390    timestamp: Instant,
1391) -> Vec<SyntheticEvent> {
1392    let old_nodes = collect_node_ids(old_hierarchy);
1393    let new_nodes = collect_node_ids(new_hierarchy);
1394
1395    let mut events = Vec::new();
1396
1397    // Mount events: nodes in new but not in old
1398    if let Some(layout) = new_layout {
1399        for &node_id in new_nodes.difference(&old_nodes) {
1400            events.push(create_mount_event(node_id, new_dom_id, layout, &timestamp));
1401        }
1402    }
1403
1404    // Unmount events: nodes in old but not in new
1405    if let Some(layout) = old_layout {
1406        for &node_id in old_nodes.difference(&new_nodes) {
1407            events.push(create_unmount_event(
1408                node_id, old_dom_id, layout, &timestamp,
1409            ));
1410        }
1411    }
1412
1413    // Resize events: nodes in both with changed bounds
1414    if let (Some(old_l), Some(new_l)) = (old_layout, new_layout) {
1415        for &node_id in old_nodes.intersection(&new_nodes) {
1416            if let Some(ev) = create_resize_event(node_id, new_dom_id, old_l, new_l, &timestamp) {
1417                events.push(ev);
1418            }
1419        }
1420    }
1421
1422    events
1423}
1424
1425fn collect_node_ids(hierarchy: Option<&crate::id::NodeHierarchy>) -> BTreeSet<NodeId> {
1426    hierarchy
1427        .map(|h| h.as_ref().linear_iter().collect())
1428        .unwrap_or_default()
1429}
1430
1431fn create_lifecycle_event(
1432    event_type: EventType,
1433    node_id: NodeId,
1434    dom_id: DomId,
1435    timestamp: &Instant,
1436    data: LifecycleEventData,
1437) -> SyntheticEvent {
1438    let dom_node_id = DomNodeId {
1439        dom: dom_id,
1440        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
1441    };
1442    SyntheticEvent {
1443        event_type,
1444        source: EventSource::Lifecycle,
1445        phase: EventPhase::Target,
1446        target: dom_node_id,
1447        current_target: dom_node_id,
1448        timestamp: timestamp.clone(),
1449        data: EventData::Lifecycle(data),
1450        stopped: false,
1451        stopped_immediate: false,
1452        prevented_default: false,
1453    }
1454}
1455
1456fn create_mount_event(
1457    node_id: NodeId,
1458    dom_id: DomId,
1459    layout: &BTreeMap<NodeId, LogicalRect>,
1460    timestamp: &Instant,
1461) -> SyntheticEvent {
1462    let current_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
1463    create_lifecycle_event(
1464        EventType::Mount,
1465        node_id,
1466        dom_id,
1467        timestamp,
1468        LifecycleEventData {
1469            reason: LifecycleReason::InitialMount,
1470            previous_bounds: None,
1471            current_bounds,
1472        },
1473    )
1474}
1475
1476fn create_unmount_event(
1477    node_id: NodeId,
1478    dom_id: DomId,
1479    layout: &BTreeMap<NodeId, LogicalRect>,
1480    timestamp: &Instant,
1481) -> SyntheticEvent {
1482    let previous_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
1483    create_lifecycle_event(
1484        EventType::Unmount,
1485        node_id,
1486        dom_id,
1487        timestamp,
1488        LifecycleEventData {
1489            reason: LifecycleReason::Unmount,
1490            previous_bounds: Some(previous_bounds),
1491            current_bounds: LogicalRect::zero(),
1492        },
1493    )
1494}
1495
1496/// Returns `true` iff the two logical sizes differ after fixed-point
1497/// quantization (~0.001 tolerance), treating a dimension that is NaN on *both*
1498/// sides as unchanged so a degenerate layout cannot emit a Resize every frame.
1499fn size_changed(old: crate::geom::LogicalSize, new: crate::geom::LogicalSize) -> bool {
1500    fn dim_changed(a: f32, b: f32) -> bool {
1501        if a.is_nan() && b.is_nan() {
1502            return false;
1503        }
1504        // Fixed-point quantization mirrors `LogicalSize`'s `Ord`/`Hash`.
1505        // `f32 as i64` saturates on overflow (no wasm32 wraparound); a lone
1506        // NaN quantizes to `i64::MIN` and so registers as changed.
1507        #[allow(clippy::cast_possible_truncation)] // intentional fixed-point quantization; saturates
1508        let q = |v: f32| -> i64 {
1509            if v.is_nan() {
1510                i64::MIN
1511            } else {
1512                (v * 1000.0) as i64
1513            }
1514        };
1515        q(a) != q(b)
1516    }
1517    dim_changed(old.width, new.width) || dim_changed(old.height, new.height)
1518}
1519
1520fn create_resize_event(
1521    node_id: NodeId,
1522    dom_id: DomId,
1523    old_layout: &BTreeMap<NodeId, LogicalRect>,
1524    new_layout: &BTreeMap<NodeId, LogicalRect>,
1525    timestamp: &Instant,
1526) -> Option<SyntheticEvent> {
1527    let old_bounds = *old_layout.get(&node_id)?;
1528    let new_bounds = *new_layout.get(&node_id)?;
1529
1530    // Quantized/tolerance compare with an explicit NaN guard. A raw `==` on
1531    // `LogicalSize` used to compare f32 bit patterns, so a single NaN dimension
1532    // made `old != new` true *every frame forever* -> an endless Resize-event
1533    // loop. `size_changed` treats a NaN dimension present on both sides as
1534    // "unchanged" and otherwise compares fixed-point-quantized values.
1535    if !size_changed(old_bounds.size, new_bounds.size) {
1536        return None;
1537    }
1538
1539    Some(create_lifecycle_event(
1540        EventType::Resize,
1541        node_id,
1542        dom_id,
1543        timestamp,
1544        LifecycleEventData {
1545            reason: LifecycleReason::Resize,
1546            previous_bounds: Some(old_bounds),
1547            current_bounds: new_bounds,
1548        },
1549    ))
1550}
1551
1552/// Result of lifecycle event detection with reconciliation.
1553///
1554/// Contains both the generated lifecycle events and a mapping from old to new
1555/// node IDs for state migration (focus, scroll, etc.).
1556#[derive(Debug, Clone, Default)]
1557pub struct LifecycleEventResult {
1558    /// Lifecycle events (Mount, Unmount, Resize, Update)
1559    pub events: Vec<SyntheticEvent>,
1560    /// Maps old `NodeId` -> new `NodeId` for matched nodes.
1561    /// Use this to migrate focus, scroll state, and other node-specific state.
1562    pub node_id_mapping: OrderedMap<NodeId, NodeId>,
1563}
1564
1565/// Detect lifecycle events using reconciliation with stable keys and content hashing.
1566///
1567/// This is the advanced lifecycle detection that can correctly identify:
1568/// - **Moves**: When a node changes position but keeps its identity (via key or hash)
1569/// - **Mounts**: When a new node appears
1570/// - **Unmounts**: When an existing node disappears
1571/// - **Resizes**: When a node's layout bounds change
1572/// - **Updates**: When a keyed node's content changes
1573///
1574/// The reconciliation strategy is:
1575/// 1. **Stable Key Match:** Nodes with `.with_reconciliation_key()` are matched by key (O(1))
1576/// 2. **Hash Match:** Nodes without keys are matched by content hash (enables reorder detection)
1577/// 3. **Fallback:** Unmatched nodes generate Mount/Unmount events
1578///
1579/// # Arguments
1580/// * `dom_id` - The DOM identifier
1581/// * `old_node_data` - Node data from the previous frame
1582/// * `new_node_data` - Node data from the current frame
1583/// * `old_layout` - Layout bounds from the previous frame
1584/// * `new_layout` - Layout bounds from the current frame
1585/// * `timestamp` - Current timestamp for events
1586///
1587/// # Returns
1588/// A `LifecycleEventResult` containing:
1589/// - `events`: Lifecycle events to dispatch
1590/// - `node_id_mapping`: Mapping from old to new `NodeIds` for state migration
1591///
1592/// # Example
1593/// ```rust,ignore
1594/// let result = detect_lifecycle_events_with_reconciliation(
1595///     dom_id,
1596///     &old_node_data,
1597///     &new_node_data,
1598///     &old_layout,
1599///     &new_layout,
1600///     timestamp,
1601/// );
1602///
1603/// // Dispatch lifecycle events
1604/// for event in result.events {
1605///     dispatch_event(event);
1606/// }
1607///
1608/// // Migrate focus to new node ID
1609/// if let Some(focused) = focus_manager.focused_node {
1610///     if let Some(&new_id) = result.node_id_mapping.get(&focused) {
1611///         focus_manager.focused_node = Some(new_id);
1612///     } else {
1613///         // Focused node was unmounted
1614///         focus_manager.focused_node = None;
1615///     }
1616/// }
1617/// ```
1618#[must_use] pub fn detect_lifecycle_events_with_reconciliation(
1619    dom_id: DomId,
1620    old_node_data: &[crate::dom::NodeData],
1621    new_node_data: &[crate::dom::NodeData],
1622    old_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
1623    new_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
1624    old_layout: &OrderedMap<NodeId, LogicalRect>,
1625    new_layout: &OrderedMap<NodeId, LogicalRect>,
1626    timestamp: Instant,
1627) -> LifecycleEventResult {
1628    let diff_result = crate::diff::reconcile_dom(
1629        old_node_data,
1630        new_node_data,
1631        old_hierarchy,
1632        new_hierarchy,
1633        old_layout,
1634        new_layout,
1635        dom_id,
1636        timestamp,
1637    );
1638
1639    LifecycleEventResult {
1640        events: diff_result.events,
1641        node_id_mapping: crate::diff::create_migration_map(&diff_result.node_moves),
1642    }
1643}
1644
1645/// Event filter that only fires when an element is hovered over.
1646#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1647#[repr(C)]
1648pub enum HoverEventFilter {
1649    /// Mouse moved over the hovered element
1650    MouseOver,
1651    /// Any mouse button pressed on the hovered element
1652    MouseDown,
1653    /// Left mouse button pressed on the hovered element
1654    LeftMouseDown,
1655    /// Right mouse button pressed on the hovered element
1656    RightMouseDown,
1657    /// Middle mouse button pressed on the hovered element
1658    MiddleMouseDown,
1659    /// Any mouse button released on the hovered element
1660    MouseUp,
1661    /// Left mouse button released on the hovered element
1662    LeftMouseUp,
1663    /// Right mouse button released on the hovered element
1664    RightMouseUp,
1665    /// Middle mouse button released on the hovered element
1666    MiddleMouseUp,
1667    /// Mouse entered the hovered element bounds
1668    MouseEnter,
1669    /// Mouse left the hovered element bounds
1670    MouseLeave,
1671    /// Scroll event on the hovered element
1672    Scroll,
1673    /// Scroll started on the hovered element
1674    ScrollStart,
1675    /// Scroll ended on the hovered element
1676    ScrollEnd,
1677    /// Text input received while element is hovered
1678    TextInput,
1679    /// Virtual key pressed while element is hovered
1680    VirtualKeyDown,
1681    /// Virtual key released while element is hovered
1682    VirtualKeyUp,
1683    /// File is being hovered over the element
1684    HoveredFile,
1685    /// File was dropped onto the element
1686    DroppedFile,
1687    /// File hover was cancelled
1688    HoveredFileCancelled,
1689    /// Touch started on the hovered element
1690    TouchStart,
1691    /// Touch moved on the hovered element
1692    TouchMove,
1693    /// Touch ended on the hovered element
1694    TouchEnd,
1695    /// Touch was cancelled on the hovered element
1696    TouchCancel,
1697    /// Pen/stylus made contact on the hovered element
1698    PenDown,
1699    /// Pen/stylus moved while in contact on the hovered element
1700    PenMove,
1701    /// Pen/stylus lifted from the hovered element
1702    PenUp,
1703    /// Pen/stylus entered proximity of the hovered element
1704    PenEnter,
1705    /// Pen/stylus left proximity of the hovered element
1706    PenLeave,
1707    /// Apple Pencil 2 / Surface Slim Pen 2 barrel squeeze on the hovered
1708    /// element. Fires once per squeeze. The matching W3C primitive is the
1709    /// `PointerEvent` with `pointerType: "pen"` and a transient
1710    /// `tangentialPressure` spike — most apps tie a tool-switch to it.
1711    PenSqueeze,
1712    /// Apple Pencil 2 side double-tap on the hovered element. Fires once
1713    /// per gesture. Usually mapped to "undo" or "switch eraser".
1714    PenDoubleTap,
1715    /// Pen/stylus is hovering above the hovered element (in proximity,
1716    /// not in contact). Continuous: fires per pen-axis update while the
1717    /// stylus is held above the surface. Maps to W3C
1718    /// `PointerEvent('pointermove')` with `buttons: 0` and
1719    /// `pointerType: 'pen'`.
1720    PenHover,
1721    /// New GPS / network location fix arrived for a `GeolocationProbe`
1722    /// in this node's subtree. Payload accessor:
1723    /// `CallbackInfo::get_geolocation_fix()`.
1724    GeolocationFix,
1725    /// Native geolocation subscription errored / was revoked /
1726    /// timed out.
1727    GeolocationError,
1728    /// A motion-sensor reading changed (P6). Window-level mirror:
1729    /// `WindowEventFilter::SensorChanged`. Read via `get_sensor_reading`.
1730    SensorChanged,
1731    /// A gamepad's state changed / it (dis)connected (P6). Read via
1732    /// `get_primary_gamepad` / `get_gamepad_state`.
1733    GamepadInput,
1734    /// Drag started on the hovered element
1735    DragStart,
1736    /// Drag in progress on the hovered element
1737    Drag,
1738    /// Drag ended on the hovered element
1739    DragEnd,
1740    /// Dragged element entered this element (drop target)
1741    DragEnter,
1742    /// Dragged element is over this element (drop target, fires continuously)
1743    DragOver,
1744    /// Dragged element left this element (drop target)
1745    DragLeave,
1746    /// Element was dropped on this element (drop target)
1747    Drop,
1748    /// Double-click detected on the hovered element
1749    DoubleClick,
1750    /// Long press detected on the hovered element
1751    LongPress,
1752    /// Swipe left gesture on the hovered element
1753    SwipeLeft,
1754    /// Swipe right gesture on the hovered element
1755    SwipeRight,
1756    /// Swipe up gesture on the hovered element
1757    SwipeUp,
1758    /// Swipe down gesture on the hovered element
1759    SwipeDown,
1760    /// Pinch-in (zoom out) gesture on the hovered element
1761    PinchIn,
1762    /// Pinch-out (zoom in) gesture on the hovered element
1763    PinchOut,
1764    /// Clockwise rotation gesture on the hovered element
1765    RotateClockwise,
1766    /// Counter-clockwise rotation gesture on the hovered element
1767    RotateCounterClockwise,
1768
1769    // W3C MouseOut event (bubbling version of MouseLeave)
1770    /// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
1771    MouseOut,
1772
1773    // W3C Focus events (bubbling versions)
1774    /// Focus is about to move INTO this element or a descendant (W3C `focusin`, bubbles)
1775    FocusIn,
1776    /// Focus is about to move OUT of this element or a descendant (W3C `focusout`, bubbles)
1777    FocusOut,
1778
1779    // IME Composition events
1780    /// IME composition started (W3C `compositionstart`)
1781    CompositionStart,
1782    /// IME composition updated (W3C `compositionupdate`)
1783    CompositionUpdate,
1784    /// IME composition ended (W3C `compositionend`)
1785    CompositionEnd,
1786
1787    // Internal System Events (not exposed to user callbacks)
1788    #[doc(hidden)]
1789    /// Internal: Single click for text cursor placement
1790    SystemTextSingleClick,
1791    #[doc(hidden)]
1792    /// Internal: Double click for word selection
1793    SystemTextDoubleClick,
1794    #[doc(hidden)]
1795    /// Internal: Triple click for paragraph/line selection
1796    SystemTextTripleClick,
1797
1798    // Async capability outcomes (MWA-A1b)
1799    /// A permission's OS-observed state changed while this node (the
1800    /// capability's most recent subscriber) is in the target chain.
1801    PermissionChanged,
1802    /// A biometric authentication prompt completed.
1803    BiometricResult,
1804    /// A keyring store / get / delete operation completed.
1805    KeyringResult,
1806}
1807
1808impl HoverEventFilter {
1809    /// Check if this is an internal system event that should not be exposed to user callbacks
1810    #[must_use] pub const fn is_system_internal(&self) -> bool {
1811        matches!(
1812            self,
1813            Self::SystemTextSingleClick
1814                | Self::SystemTextDoubleClick
1815                | Self::SystemTextTripleClick
1816        )
1817    }
1818
1819    // Exhaustive On -> Option<FocusEventFilter> mapping table; the several `=> None`
1820    // rows (window-only events) are intentional 1:1 rows — merging would collapse the table.
1821    #[allow(clippy::match_same_arms)]
1822    #[must_use] pub const fn to_focus_event_filter(&self) -> Option<FocusEventFilter> {
1823        match self {
1824            Self::MouseOver => Some(FocusEventFilter::MouseOver),
1825            Self::MouseDown => Some(FocusEventFilter::MouseDown),
1826            Self::LeftMouseDown => Some(FocusEventFilter::LeftMouseDown),
1827            Self::RightMouseDown => Some(FocusEventFilter::RightMouseDown),
1828            Self::MiddleMouseDown => Some(FocusEventFilter::MiddleMouseDown),
1829            Self::MouseUp => Some(FocusEventFilter::MouseUp),
1830            Self::LeftMouseUp => Some(FocusEventFilter::LeftMouseUp),
1831            Self::RightMouseUp => Some(FocusEventFilter::RightMouseUp),
1832            Self::MiddleMouseUp => Some(FocusEventFilter::MiddleMouseUp),
1833            Self::MouseEnter => Some(FocusEventFilter::MouseEnter),
1834            Self::MouseLeave => Some(FocusEventFilter::MouseLeave),
1835            Self::Scroll => Some(FocusEventFilter::Scroll),
1836            Self::ScrollStart => Some(FocusEventFilter::ScrollStart),
1837            Self::ScrollEnd => Some(FocusEventFilter::ScrollEnd),
1838            Self::TextInput => Some(FocusEventFilter::TextInput),
1839            Self::VirtualKeyDown => Some(FocusEventFilter::VirtualKeyDown),
1840            Self::VirtualKeyUp => Some(FocusEventFilter::VirtualKeyUp),
1841            Self::HoveredFile => None,
1842            Self::DroppedFile => None,
1843            Self::HoveredFileCancelled => None,
1844            Self::TouchStart => None,
1845            Self::TouchMove => None,
1846            Self::TouchEnd => None,
1847            Self::TouchCancel => None,
1848            Self::PenDown => Some(FocusEventFilter::PenDown),
1849            Self::PenMove => Some(FocusEventFilter::PenMove),
1850            Self::PenUp => Some(FocusEventFilter::PenUp),
1851            Self::PenEnter => None,
1852            Self::PenLeave => None,
1853            Self::PenSqueeze => None,
1854            Self::PenDoubleTap => None,
1855            Self::PenHover => None,
1856            Self::GeolocationFix => None,
1857            Self::GeolocationError => None,
1858            Self::SensorChanged => None,
1859            Self::GamepadInput => None,
1860            Self::DragStart => Some(FocusEventFilter::DragStart),
1861            Self::Drag => Some(FocusEventFilter::Drag),
1862            Self::DragEnd => Some(FocusEventFilter::DragEnd),
1863            Self::DragEnter => Some(FocusEventFilter::DragEnter),
1864            Self::DragOver => Some(FocusEventFilter::DragOver),
1865            Self::DragLeave => Some(FocusEventFilter::DragLeave),
1866            Self::Drop => Some(FocusEventFilter::Drop),
1867            Self::DoubleClick => Some(FocusEventFilter::DoubleClick),
1868            Self::LongPress => Some(FocusEventFilter::LongPress),
1869            Self::SwipeLeft => Some(FocusEventFilter::SwipeLeft),
1870            Self::SwipeRight => Some(FocusEventFilter::SwipeRight),
1871            Self::SwipeUp => Some(FocusEventFilter::SwipeUp),
1872            Self::SwipeDown => Some(FocusEventFilter::SwipeDown),
1873            Self::PinchIn => Some(FocusEventFilter::PinchIn),
1874            Self::PinchOut => Some(FocusEventFilter::PinchOut),
1875            Self::RotateClockwise => Some(FocusEventFilter::RotateClockwise),
1876            Self::RotateCounterClockwise => {
1877                Some(FocusEventFilter::RotateCounterClockwise)
1878            }
1879            Self::MouseOut => Some(FocusEventFilter::MouseLeave), // mouseout → closest focus equivalent
1880            Self::FocusIn => Some(FocusEventFilter::FocusIn),
1881            Self::FocusOut => Some(FocusEventFilter::FocusOut),
1882            Self::CompositionStart => Some(FocusEventFilter::CompositionStart),
1883            Self::CompositionUpdate => Some(FocusEventFilter::CompositionUpdate),
1884            Self::CompositionEnd => Some(FocusEventFilter::CompositionEnd),
1885            // System internal events - don't convert to focus events
1886            Self::SystemTextSingleClick => None,
1887            Self::SystemTextDoubleClick => None,
1888            Self::SystemTextTripleClick => None,
1889            // Async capability outcomes — no focus-filter equivalents
1890            Self::PermissionChanged => None,
1891            Self::BiometricResult => None,
1892            Self::KeyringResult => None,
1893        }
1894    }
1895}
1896
1897/// Event filter similar to `HoverEventFilter` that only fires when the element is focused.
1898///
1899/// **Important**: In order for this to fire, the item must have a `tabindex` attribute
1900/// (to indicate that the item is focus-able).
1901#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
1902#[repr(C)]
1903pub enum FocusEventFilter {
1904    /// Mouse moved over the focused element
1905    MouseOver,
1906    /// Any mouse button pressed on the focused element
1907    MouseDown,
1908    /// Left mouse button pressed on the focused element
1909    LeftMouseDown,
1910    /// Right mouse button pressed on the focused element
1911    RightMouseDown,
1912    /// Middle mouse button pressed on the focused element
1913    MiddleMouseDown,
1914    /// Any mouse button released on the focused element
1915    MouseUp,
1916    /// Left mouse button released on the focused element
1917    LeftMouseUp,
1918    /// Right mouse button released on the focused element
1919    RightMouseUp,
1920    /// Middle mouse button released on the focused element
1921    MiddleMouseUp,
1922    /// Mouse entered the focused element bounds
1923    MouseEnter,
1924    /// Mouse left the focused element bounds
1925    MouseLeave,
1926    /// Scroll event on the focused element
1927    Scroll,
1928    /// Scroll started on the focused element
1929    ScrollStart,
1930    /// Scroll ended on the focused element
1931    ScrollEnd,
1932    /// Text input received while element is focused
1933    TextInput,
1934    /// Virtual key pressed while element is focused
1935    VirtualKeyDown,
1936    /// Virtual key released while element is focused
1937    VirtualKeyUp,
1938    /// Element received keyboard focus
1939    FocusReceived,
1940    /// Element lost keyboard focus
1941    FocusLost,
1942    /// Pen/stylus made contact on the focused element
1943    PenDown,
1944    /// Pen/stylus moved while in contact on the focused element
1945    PenMove,
1946    /// Pen/stylus lifted from the focused element
1947    PenUp,
1948    /// Drag started on the focused element
1949    DragStart,
1950    /// Drag in progress on the focused element
1951    Drag,
1952    /// Drag ended on the focused element
1953    DragEnd,
1954    /// Dragged element entered this focused element (drop target)
1955    DragEnter,
1956    /// Dragged element is over this focused element (drop target)
1957    DragOver,
1958    /// Dragged element left this focused element (drop target)
1959    DragLeave,
1960    /// Element was dropped on this focused element (drop target)
1961    Drop,
1962    /// Double-click detected on the focused element
1963    DoubleClick,
1964    /// Long press detected on the focused element
1965    LongPress,
1966    /// Swipe left gesture on the focused element
1967    SwipeLeft,
1968    /// Swipe right gesture on the focused element
1969    SwipeRight,
1970    /// Swipe up gesture on the focused element
1971    SwipeUp,
1972    /// Swipe down gesture on the focused element
1973    SwipeDown,
1974    /// Pinch-in (zoom out) gesture on the focused element
1975    PinchIn,
1976    /// Pinch-out (zoom in) gesture on the focused element
1977    PinchOut,
1978    /// Clockwise rotation gesture on the focused element
1979    RotateClockwise,
1980    /// Counter-clockwise rotation gesture on the focused element
1981    RotateCounterClockwise,
1982
1983    // W3C Focus events (bubbling versions, fires on focused element when focus changes)
1984    /// Focus moved into this element or a descendant (W3C `focusin`)
1985    FocusIn,
1986    /// Focus moved out of this element or a descendant (W3C `focusout`)
1987    FocusOut,
1988
1989    // IME Composition events
1990    /// IME composition started (W3C `compositionstart`)
1991    CompositionStart,
1992    /// IME composition updated (W3C `compositionupdate`)
1993    CompositionUpdate,
1994    /// IME composition ended (W3C `compositionend`)
1995    CompositionEnd,
1996
1997    // Clipboard events (W3C clipboard-events; MWA-C-clipboard: fire on the
1998    // focused element BEFORE the OS default action, which preventDefault
1999    // suppresses). APPENDED at the end for ABI stability — sync to api.json
2000    // via azul-doc autofix in Phase D.
2001    /// Content is about to be copied from the focused element (W3C `copy`)
2002    Copy,
2003    /// Content is about to be cut from the focused element (W3C `cut`)
2004    Cut,
2005    /// Content is about to be pasted into the focused element (W3C `paste`)
2006    Paste,
2007}
2008
2009/// Event filter that fires when any action fires on the entire window
2010/// (regardless of whether any element is hovered or focused over).
2011#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2012#[repr(C)]
2013pub enum WindowEventFilter {
2014    /// Mouse moved anywhere in window
2015    MouseOver,
2016    /// Any mouse button pressed anywhere in window
2017    MouseDown,
2018    /// Left mouse button pressed anywhere in window
2019    LeftMouseDown,
2020    /// Right mouse button pressed anywhere in window
2021    RightMouseDown,
2022    /// Middle mouse button pressed anywhere in window
2023    MiddleMouseDown,
2024    /// Any mouse button released anywhere in window
2025    MouseUp,
2026    /// Left mouse button released anywhere in window
2027    LeftMouseUp,
2028    /// Right mouse button released anywhere in window
2029    RightMouseUp,
2030    /// Middle mouse button released anywhere in window
2031    MiddleMouseUp,
2032    /// Mouse entered the window
2033    MouseEnter,
2034    /// Mouse left the window
2035    MouseLeave,
2036    /// Scroll event anywhere in window
2037    Scroll,
2038    /// Scroll started anywhere in window
2039    ScrollStart,
2040    /// Scroll ended anywhere in window
2041    ScrollEnd,
2042    /// Text input received in window
2043    TextInput,
2044    /// Virtual key pressed in window
2045    VirtualKeyDown,
2046    /// Virtual key released in window
2047    VirtualKeyUp,
2048    /// File is being hovered over the window
2049    HoveredFile,
2050    /// File was dropped onto the window
2051    DroppedFile,
2052    /// File hover was cancelled
2053    HoveredFileCancelled,
2054    /// Window was resized
2055    Resized,
2056    /// Window was moved
2057    Moved,
2058    /// Touch started anywhere in window
2059    TouchStart,
2060    /// Touch moved anywhere in window
2061    TouchMove,
2062    /// Touch ended anywhere in window
2063    TouchEnd,
2064    /// Touch was cancelled
2065    TouchCancel,
2066    /// Window received focus
2067    FocusReceived,
2068    /// Window lost focus
2069    FocusLost,
2070    /// Window close was requested
2071    CloseRequested,
2072    /// System theme changed (light/dark mode)
2073    ThemeChanged,
2074    /// Window received OS-level focus
2075    WindowFocusReceived,
2076    /// Window lost OS-level focus
2077    WindowFocusLost,
2078    /// Pen/stylus made contact anywhere in window
2079    PenDown,
2080    /// Pen/stylus moved while in contact anywhere in window
2081    PenMove,
2082    /// Pen/stylus lifted anywhere in window
2083    PenUp,
2084    /// Pen/stylus entered window proximity
2085    PenEnter,
2086    /// Pen/stylus left window proximity
2087    PenLeave,
2088    /// Pen barrel-squeeze gesture fired in the window. See
2089    /// [`HoverEventFilter::PenSqueeze`].
2090    PenSqueeze,
2091    /// Pen side double-tap gesture fired in the window. See
2092    /// [`HoverEventFilter::PenDoubleTap`].
2093    PenDoubleTap,
2094    /// Pen hover in the window (in proximity, not in contact). See
2095    /// [`HoverEventFilter::PenHover`].
2096    PenHover,
2097    /// New GPS / network location fix arrived. Payload accessor:
2098    /// `CallbackInfo::get_geolocation_fix()`. Window-level rather
2099    /// than per-node because the user's location isn't bound to any
2100    /// particular DOM node — but a node-level mirror
2101    /// (`HoverEventFilter::GeolocationFix`) fires on every
2102    /// `GeolocationProbe` in the tree as well, for the common
2103    /// "redraw this node when the location changes" pattern.
2104    GeolocationFix,
2105    /// Native geolocation subscription dropped or errored (signal
2106    /// lost, no provider, permission revoked mid-session).
2107    GeolocationError,
2108    /// A motion-sensor reading changed (P6). Fires window-level (the device
2109    /// isn't bound to a node); read via `CallbackInfo::get_sensor_reading`.
2110    SensorChanged,
2111    /// A gamepad's buttons / axes changed or it (dis)connected (P6); read via
2112    /// `get_primary_gamepad` / `get_gamepad_state`.
2113    GamepadInput,
2114    /// Drag started anywhere in window
2115    DragStart,
2116    /// Drag in progress anywhere in window
2117    Drag,
2118    /// Drag ended anywhere in window
2119    DragEnd,
2120    /// Dragged element entered a drop target in window
2121    DragEnter,
2122    /// Dragged element is over a drop target in window
2123    DragOver,
2124    /// Dragged element left a drop target in window
2125    DragLeave,
2126    /// Element was dropped on a drop target in window
2127    Drop,
2128    /// Double-click detected anywhere in window
2129    DoubleClick,
2130    /// Long press detected anywhere in window
2131    LongPress,
2132    /// Swipe left gesture anywhere in window
2133    SwipeLeft,
2134    /// Swipe right gesture anywhere in window
2135    SwipeRight,
2136    /// Swipe up gesture anywhere in window
2137    SwipeUp,
2138    /// Swipe down gesture anywhere in window
2139    SwipeDown,
2140    /// Pinch-in (zoom out) gesture anywhere in window
2141    PinchIn,
2142    /// Pinch-out (zoom in) gesture anywhere in window
2143    PinchOut,
2144    /// Clockwise rotation gesture anywhere in window
2145    RotateClockwise,
2146    /// Counter-clockwise rotation gesture anywhere in window
2147    RotateCounterClockwise,
2148    /// The window's DPI scale factor changed (e.g., moved to a monitor with
2149    /// different scaling). The new DPI is available via `CallbackInfo::get_hidpi_factor()`.
2150    DpiChanged,
2151    /// The window moved to a different monitor. The new monitor is available
2152    /// via `CallbackInfo::get_current_monitor()`.
2153    MonitorChanged,
2154
2155    // Async capability outcomes (MWA-A1b) — window-level mirrors (the
2156    // outcome isn't inherently bound to a node).
2157    /// A permission's OS-observed state changed.
2158    PermissionChanged,
2159    /// A biometric authentication prompt completed.
2160    BiometricResult,
2161    /// A keyring store / get / delete operation completed.
2162    KeyringResult,
2163}
2164
2165impl WindowEventFilter {
2166    // Exhaustive On -> Option<HoverEventFilter> mapping table (see to_focus_event_filter).
2167    #[allow(clippy::match_same_arms)]
2168    #[must_use] pub const fn to_hover_event_filter(&self) -> Option<HoverEventFilter> {
2169        match self {
2170            Self::MouseOver => Some(HoverEventFilter::MouseOver),
2171            Self::MouseDown => Some(HoverEventFilter::MouseDown),
2172            Self::LeftMouseDown => Some(HoverEventFilter::LeftMouseDown),
2173            Self::RightMouseDown => Some(HoverEventFilter::RightMouseDown),
2174            Self::MiddleMouseDown => Some(HoverEventFilter::MiddleMouseDown),
2175            Self::MouseUp => Some(HoverEventFilter::MouseUp),
2176            Self::LeftMouseUp => Some(HoverEventFilter::LeftMouseUp),
2177            Self::RightMouseUp => Some(HoverEventFilter::RightMouseUp),
2178            Self::MiddleMouseUp => Some(HoverEventFilter::MiddleMouseUp),
2179            Self::Scroll => Some(HoverEventFilter::Scroll),
2180            Self::ScrollStart => Some(HoverEventFilter::ScrollStart),
2181            Self::ScrollEnd => Some(HoverEventFilter::ScrollEnd),
2182            Self::TextInput => Some(HoverEventFilter::TextInput),
2183            Self::VirtualKeyDown => Some(HoverEventFilter::VirtualKeyDown),
2184            Self::VirtualKeyUp => Some(HoverEventFilter::VirtualKeyUp),
2185            Self::HoveredFile => Some(HoverEventFilter::HoveredFile),
2186            Self::DroppedFile => Some(HoverEventFilter::DroppedFile),
2187            Self::HoveredFileCancelled => Some(HoverEventFilter::HoveredFileCancelled),
2188            // MouseEnter and MouseLeave on the **window** - does not mean a mouseenter
2189            // and a mouseleave on the hovered element
2190            Self::MouseEnter => None,
2191            Self::MouseLeave => None,
2192            Self::Resized => None,
2193            Self::Moved => None,
2194            Self::TouchStart => Some(HoverEventFilter::TouchStart),
2195            Self::TouchMove => Some(HoverEventFilter::TouchMove),
2196            Self::TouchEnd => Some(HoverEventFilter::TouchEnd),
2197            Self::TouchCancel => Some(HoverEventFilter::TouchCancel),
2198            Self::FocusReceived => None,
2199            Self::FocusLost => None,
2200            Self::CloseRequested => None,
2201            Self::ThemeChanged => None,
2202            Self::WindowFocusReceived => None, // specific to window!
2203            Self::WindowFocusLost => None,     // specific to window!
2204            Self::PenDown => Some(HoverEventFilter::PenDown),
2205            Self::PenMove => Some(HoverEventFilter::PenMove),
2206            Self::PenUp => Some(HoverEventFilter::PenUp),
2207            Self::PenEnter => Some(HoverEventFilter::PenEnter),
2208            Self::PenLeave => Some(HoverEventFilter::PenLeave),
2209            Self::PenSqueeze => Some(HoverEventFilter::PenSqueeze),
2210            Self::PenDoubleTap => Some(HoverEventFilter::PenDoubleTap),
2211            Self::PenHover => Some(HoverEventFilter::PenHover),
2212            Self::GeolocationFix => Some(HoverEventFilter::GeolocationFix),
2213            Self::GeolocationError => Some(HoverEventFilter::GeolocationError),
2214            Self::SensorChanged => Some(HoverEventFilter::SensorChanged),
2215            Self::GamepadInput => Some(HoverEventFilter::GamepadInput),
2216            Self::DragStart => Some(HoverEventFilter::DragStart),
2217            Self::Drag => Some(HoverEventFilter::Drag),
2218            Self::DragEnd => Some(HoverEventFilter::DragEnd),
2219            Self::DragEnter => Some(HoverEventFilter::DragEnter),
2220            Self::DragOver => Some(HoverEventFilter::DragOver),
2221            Self::DragLeave => Some(HoverEventFilter::DragLeave),
2222            Self::Drop => Some(HoverEventFilter::Drop),
2223            Self::DoubleClick => Some(HoverEventFilter::DoubleClick),
2224            Self::LongPress => Some(HoverEventFilter::LongPress),
2225            Self::SwipeLeft => Some(HoverEventFilter::SwipeLeft),
2226            Self::SwipeRight => Some(HoverEventFilter::SwipeRight),
2227            Self::SwipeUp => Some(HoverEventFilter::SwipeUp),
2228            Self::SwipeDown => Some(HoverEventFilter::SwipeDown),
2229            Self::PinchIn => Some(HoverEventFilter::PinchIn),
2230            Self::PinchOut => Some(HoverEventFilter::PinchOut),
2231            Self::RotateClockwise => Some(HoverEventFilter::RotateClockwise),
2232            Self::RotateCounterClockwise => {
2233                Some(HoverEventFilter::RotateCounterClockwise)
2234            }
2235            // Window-specific events with no hover equivalent
2236            Self::DpiChanged => None,
2237            Self::MonitorChanged => None,
2238            // Async capability outcomes — mirror to the hover twin
2239            Self::PermissionChanged => Some(HoverEventFilter::PermissionChanged),
2240            Self::BiometricResult => Some(HoverEventFilter::BiometricResult),
2241            Self::KeyringResult => Some(HoverEventFilter::KeyringResult),
2242        }
2243    }
2244}
2245
2246/// Defines events related to the lifecycle of a DOM node itself.
2247#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2248#[repr(C)]
2249pub enum ComponentEventFilter {
2250    /// Fired after the component is first mounted into the DOM.
2251    AfterMount,
2252    /// Fired just before the component is removed from the DOM.
2253    BeforeUnmount,
2254    /// Fired when the node's layout rectangle has been resized.
2255    NodeResized,
2256    /// Fired to trigger the default action for an accessibility component.
2257    DefaultAction,
2258    /// Fired when the component becomes selected.
2259    Selected,
2260    /// Fired when a keyed component's content has changed (props/state update).
2261    Updated,
2262}
2263
2264/// Defines application-level events not tied to a specific window or node.
2265#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
2266#[repr(C)]
2267pub enum ApplicationEventFilter {
2268    /// Fired when a new hardware device is connected.
2269    DeviceConnected,
2270    /// Fired when a hardware device is disconnected.
2271    DeviceDisconnected,
2272    /// Fired when a new monitor/display is connected to the system.
2273    /// Callback receives updated monitor list via `CallbackInfo::get_monitors()`.
2274    MonitorConnected,
2275    /// Fired when a monitor/display is disconnected from the system.
2276    MonitorDisconnected,
2277}
2278
2279/// Sets the target for what events can reach the callbacks specifically.
2280///
2281/// This determines the condition under which an event is fired, such as whether
2282/// the node is hovered, focused, or if the event is window-global.
2283#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2284#[repr(C, u8)]
2285pub enum EventFilter {
2286    /// Calls the attached callback when the mouse is actively over the
2287    /// given element.
2288    Hover(HoverEventFilter),
2289    /// Calls the attached callback when the element is currently focused.
2290    Focus(FocusEventFilter),
2291    /// Calls the callback when anything related to the window is happening.
2292    /// The "hit item" will be the root item of the DOM.
2293    /// For example, this can be useful for tracking the mouse position
2294    /// (in relation to the window). In difference to `Desktop`, this only
2295    /// fires when the window is focused.
2296    ///
2297    /// This can also be good for capturing controller input, touch input
2298    /// (i.e. global gestures that aren't attached to any component, but rather
2299    /// the "window" itself).
2300    Window(WindowEventFilter),
2301    /// API stub: Something happened with the node itself (node resized, created or removed).
2302    Component(ComponentEventFilter),
2303    /// Something happened with the application (started, shutdown, device plugged in).
2304    Application(ApplicationEventFilter),
2305}
2306
2307impl EventFilter {
2308    #[must_use] pub const fn is_focus_callback(&self) -> bool {
2309        matches!(self, Self::Focus(_))
2310    }
2311    #[must_use] pub const fn is_window_callback(&self) -> bool {
2312        matches!(self, Self::Window(_))
2313    }
2314}
2315
2316/// Creates a function inside an impl <enum type> block that returns a single
2317/// variant if the enum is that variant.
2318macro_rules! get_single_enum_type {
2319    ($fn_name:ident, $enum_name:ident:: $variant:ident($return_type:ty)) => {
2320        #[must_use] pub const fn $fn_name(&self) -> Option<$return_type> {
2321            use self::$enum_name::*;
2322            match self {
2323                $variant(e) => Some(*e),
2324                _ => None,
2325            }
2326        }
2327    };
2328}
2329
2330impl EventFilter {
2331    get_single_enum_type!(as_hover_event_filter, EventFilter::Hover(HoverEventFilter));
2332    get_single_enum_type!(as_focus_event_filter, EventFilter::Focus(FocusEventFilter));
2333    get_single_enum_type!(
2334        as_window_event_filter,
2335        EventFilter::Window(WindowEventFilter)
2336    );
2337}
2338
2339/// Convert from `On` enum to `EventFilter`.
2340///
2341/// This determines which specific filter variant is used based on the event type.
2342/// For example, `On::TextInput` becomes a Focus event filter, while `On::VirtualKeyDown`
2343/// becomes a Window event filter (since it's global to the window).
2344impl From<On> for EventFilter {
2345    // Exhaustive On -> EventFilter mapping table; the a11y events (Default/Collapse/
2346    // Expand/Increment/Decrement) all map to MouseUp ("click") as intentional 1:1
2347    // documented rows — merging would drop the per-row rationale comments.
2348    #[allow(clippy::match_same_arms)]
2349    fn from(input: On) -> Self {
2350        use crate::dom::On::{MouseOver, MouseDown, LeftMouseDown, MiddleMouseDown, RightMouseDown, MouseUp, LeftMouseUp, MiddleMouseUp, RightMouseUp, MouseEnter, MouseLeave, Scroll, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, FocusReceived, FocusLost, Default, Collapse, Expand, Increment, Decrement};
2351        match input {
2352            MouseOver => Self::Hover(HoverEventFilter::MouseOver),
2353            MouseDown => Self::Hover(HoverEventFilter::MouseDown),
2354            LeftMouseDown => Self::Hover(HoverEventFilter::LeftMouseDown),
2355            MiddleMouseDown => Self::Hover(HoverEventFilter::MiddleMouseDown),
2356            RightMouseDown => Self::Hover(HoverEventFilter::RightMouseDown),
2357            MouseUp => Self::Hover(HoverEventFilter::MouseUp),
2358            LeftMouseUp => Self::Hover(HoverEventFilter::LeftMouseUp),
2359            MiddleMouseUp => Self::Hover(HoverEventFilter::MiddleMouseUp),
2360            RightMouseUp => Self::Hover(HoverEventFilter::RightMouseUp),
2361
2362            MouseEnter => Self::Hover(HoverEventFilter::MouseEnter),
2363            MouseLeave => Self::Hover(HoverEventFilter::MouseLeave),
2364            Scroll => Self::Hover(HoverEventFilter::Scroll),
2365            TextInput => Self::Focus(FocusEventFilter::TextInput), // focus!
2366            VirtualKeyDown => Self::Window(WindowEventFilter::VirtualKeyDown), // window!
2367            VirtualKeyUp => Self::Window(WindowEventFilter::VirtualKeyUp), // window!
2368            HoveredFile => Self::Hover(HoverEventFilter::HoveredFile),
2369            DroppedFile => Self::Hover(HoverEventFilter::DroppedFile),
2370            HoveredFileCancelled => Self::Hover(HoverEventFilter::HoveredFileCancelled),
2371            FocusReceived => Self::Focus(FocusEventFilter::FocusReceived), // focus!
2372            FocusLost => Self::Focus(FocusEventFilter::FocusLost),         // focus!
2373
2374            // Accessibility events - treat as hover events (element-specific)
2375            Default => Self::Hover(HoverEventFilter::MouseUp), // Default action = click
2376            Collapse => Self::Hover(HoverEventFilter::MouseUp), // Collapse = click
2377            Expand => Self::Hover(HoverEventFilter::MouseUp),  // Expand = click
2378            Increment => Self::Hover(HoverEventFilter::MouseUp), // Increment = click
2379            Decrement => Self::Hover(HoverEventFilter::MouseUp), // Decrement = click
2380        }
2381    }
2382}
2383
2384// Cross-Platform Event Dispatch System
2385// NOTE: The old dispatch_synthetic_events / CallbackTarget / CallbackToInvoke / EventDispatchResult
2386// pipeline has been removed. Event dispatch now goes through dispatch_events_propagated() in
2387// event_v2.rs which uses propagate_event() for W3C Capture→Target→Bubble propagation.
2388
2389/// Trait for managers to provide their pending events.
2390///
2391/// Each manager (`TextInputManager`, `ScrollManager`, etc.) implements this to
2392/// report what events occurred since the last frame. This enables a unified,
2393/// lazy event determination system.
2394pub trait EventProvider {
2395    /// Get all pending events from this manager.
2396    ///
2397    /// Events should include:
2398    ///
2399    /// - `target`: The `DomNodeId` that was affected
2400    /// - `event_type`: What happened (Input, Scroll, Focus, etc.)
2401    /// - `source`: `EventSource::User` for input, `EventSource::Programmatic` for API calls
2402    /// - `data`: Type-specific event data
2403    ///
2404    /// After calling this, the manager should mark events as "read" so they
2405    /// aren't returned again next frame.
2406    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent>;
2407}
2408
2409/// Deduplicate synthetic events by (target node, event type).
2410///
2411/// Groups by (target.dom, target.node, `event_type`), keeping the latest timestamp.
2412#[must_use] pub fn deduplicate_synthetic_events(mut events: Vec<SyntheticEvent>) -> Vec<SyntheticEvent> {
2413    if events.len() <= 1 {
2414        return events;
2415    }
2416
2417    events.sort_by_key(|e| (e.target.dom, e.target.node, e.event_type));
2418
2419    // Coalesce consecutive events with same target and event_type
2420    let mut result = Vec::with_capacity(events.len());
2421    let mut iter = events.into_iter();
2422
2423    if let Some(mut prev) = iter.next() {
2424        for curr in iter {
2425            if prev.target == curr.target && prev.event_type == curr.event_type {
2426                // Keep the one with later timestamp
2427                prev = if curr.timestamp > prev.timestamp {
2428                    curr
2429                } else {
2430                    prev
2431                };
2432            } else {
2433                result.push(prev);
2434                prev = curr;
2435            }
2436        }
2437        result.push(prev);
2438    }
2439
2440    result
2441}
2442
2443
2444
2445/// Convert `EventType` to `EventFilters` (returns multiple filters for generic + specific events)
2446///
2447/// For mouse button events, returns both generic (`MouseUp`) AND button-specific (LeftMouseUp/RightMouseUp).
2448/// The button-specific filter is derived from the `EventData::Mouse` payload.
2449// Exhaustive EventType -> Vec<EventFilter> mapping table; some event types map to
2450// the same filter set as intentional 1:1 rows — merging would collapse the table.
2451#[allow(clippy::match_same_arms)]
2452#[must_use] pub fn event_type_to_filters(event_type: EventType, event_data: &EventData) -> Vec<EventFilter> {
2453    use EventFilter as EF;
2454    use EventType as E;
2455    use FocusEventFilter as F;
2456    use HoverEventFilter as H;
2457    use WindowEventFilter as W;
2458
2459    // Helper: get the button-specific MouseDown filter from EventData
2460    let button_specific_down = || -> Option<EventFilter> {
2461        match event_data {
2462            EventData::Mouse(m) => match m.button {
2463                MouseButton::Left => Some(EF::Hover(H::LeftMouseDown)),
2464                MouseButton::Right => Some(EF::Hover(H::RightMouseDown)),
2465                MouseButton::Middle => Some(EF::Hover(H::MiddleMouseDown)),
2466                MouseButton::Other(_) => None, // no specific filter for other buttons
2467            },
2468            _ => Some(EF::Hover(H::LeftMouseDown)), // fallback
2469        }
2470    };
2471
2472    let button_specific_up = || -> Option<EventFilter> {
2473        match event_data {
2474            EventData::Mouse(m) => match m.button {
2475                MouseButton::Left => Some(EF::Hover(H::LeftMouseUp)),
2476                MouseButton::Right => Some(EF::Hover(H::RightMouseUp)),
2477                MouseButton::Middle => Some(EF::Hover(H::MiddleMouseUp)),
2478                MouseButton::Other(_) => None, // no specific filter for other buttons
2479            },
2480            _ => Some(EF::Hover(H::LeftMouseUp)), // fallback
2481        }
2482    };
2483
2484    match event_type {
2485        // Mouse button events - return BOTH generic and button-specific
2486        E::MouseDown => {
2487            let mut v = vec![EF::Hover(H::MouseDown)];
2488            if let Some(f) = button_specific_down() { v.push(f); }
2489            v
2490        }
2491        E::MouseUp => {
2492            let mut v = vec![EF::Hover(H::MouseUp)];
2493            if let Some(f) = button_specific_up() { v.push(f); }
2494            v
2495        }
2496
2497        // Click maps to LeftMouseUp: per W3C a `click` completes on button
2498        // *release* over the target (left-button only). Mapping it to
2499        // LeftMouseDown fired synthesized clicks as a duplicate MouseDown
2500        // (press semantics) instead of a completed click.
2501        E::Click => vec![EF::Hover(H::LeftMouseUp)],
2502
2503        // Other mouse events
2504        E::MouseOver => vec![EF::Hover(H::MouseOver)],
2505        E::MouseEnter => vec![EF::Hover(H::MouseEnter)],
2506        E::MouseLeave => vec![EF::Hover(H::MouseLeave)],
2507        E::MouseOut => vec![EF::Hover(H::MouseOut)],
2508
2509        E::DoubleClick => vec![EF::Hover(H::DoubleClick), EF::Window(W::DoubleClick)],
2510        E::ContextMenu => vec![EF::Hover(H::RightMouseDown)],
2511
2512        // Keyboard events
2513        E::KeyDown => vec![EF::Focus(F::VirtualKeyDown)],
2514        E::KeyUp => vec![EF::Focus(F::VirtualKeyUp)],
2515        E::KeyPress => vec![EF::Focus(F::TextInput)],
2516
2517        // IME Composition events
2518        E::CompositionStart => vec![EF::Hover(H::CompositionStart), EF::Focus(F::CompositionStart)],
2519        E::CompositionUpdate => vec![EF::Hover(H::CompositionUpdate), EF::Focus(F::CompositionUpdate)],
2520        E::CompositionEnd => vec![EF::Hover(H::CompositionEnd), EF::Focus(F::CompositionEnd)],
2521
2522        // Focus events
2523        E::Focus => vec![EF::Focus(F::FocusReceived)],
2524        E::Blur => vec![EF::Focus(F::FocusLost)],
2525        E::FocusIn => vec![EF::Hover(H::FocusIn), EF::Focus(F::FocusIn)],
2526        E::FocusOut => vec![EF::Hover(H::FocusOut), EF::Focus(F::FocusOut)],
2527
2528        // Input events
2529        E::Input | E::Change => vec![EF::Focus(F::TextInput)],
2530
2531        // Scroll events
2532        E::Scroll | E::ScrollStart | E::ScrollEnd => vec![EF::Hover(H::Scroll)],
2533
2534        // Drag events
2535        E::DragStart => vec![EF::Hover(H::DragStart), EF::Window(W::DragStart)],
2536        E::Drag => vec![EF::Hover(H::Drag), EF::Window(W::Drag)],
2537        E::DragEnd => vec![EF::Hover(H::DragEnd), EF::Window(W::DragEnd)],
2538        E::DragEnter => vec![EF::Hover(H::DragEnter), EF::Window(W::DragEnter)],
2539        E::DragOver => vec![EF::Hover(H::DragOver), EF::Window(W::DragOver)],
2540        E::DragLeave => vec![EF::Hover(H::DragLeave), EF::Window(W::DragLeave)],
2541        E::Drop => vec![EF::Hover(H::Drop), EF::Window(W::Drop)],
2542
2543        // Touch events
2544        E::TouchStart => vec![EF::Hover(H::TouchStart)],
2545        E::TouchMove => vec![EF::Hover(H::TouchMove)],
2546        E::TouchEnd => vec![EF::Hover(H::TouchEnd)],
2547        E::TouchCancel => vec![EF::Hover(H::TouchCancel)],
2548
2549        // Window events
2550        E::WindowResize => vec![EF::Window(W::Resized)],
2551        E::WindowMove => vec![EF::Window(W::Moved)],
2552        E::WindowClose => vec![EF::Window(W::CloseRequested)],
2553        E::WindowFocusIn => vec![EF::Window(W::WindowFocusReceived)],
2554        E::WindowFocusOut => vec![EF::Window(W::WindowFocusLost)],
2555        E::ThemeChange => vec![EF::Window(W::ThemeChanged)],
2556        E::WindowDpiChanged => vec![EF::Window(W::DpiChanged)],
2557        E::WindowMonitorChanged => vec![EF::Window(W::MonitorChanged)],
2558
2559        // Application events
2560        E::MonitorConnected => vec![EF::Application(ApplicationEventFilter::MonitorConnected)],
2561        E::MonitorDisconnected => vec![EF::Application(ApplicationEventFilter::MonitorDisconnected)],
2562
2563        // File events
2564        // MWA-B7: node-level Hover mirror + the window-level filter. Without
2565        // the Window mirrors, even WindowEventFilter::DroppedFile
2566        // registrations were unreachable (file events dispatched to Hover
2567        // filters only).
2568        E::FileHover => vec![EF::Hover(H::HoveredFile), EF::Window(W::HoveredFile)],
2569        E::FileDrop => vec![EF::Hover(H::DroppedFile), EF::Window(W::DroppedFile)],
2570        E::FileHoverCancel => vec![
2571            EF::Hover(H::HoveredFileCancelled),
2572            EF::Window(W::HoveredFileCancelled),
2573        ],
2574
2575        // Lifecycle events — dispatched on the target node via EventFilter::Component.
2576        // Both Mount and Unmount map to their respective Component filters so that
2577        // `.add_callback(EventFilter::Component(ComponentEventFilter::AfterMount))`
2578        // actually fires after reconcile_dom emits a SyntheticEvent{EventType::Mount,..}.
2579        E::Mount => vec![EF::Component(ComponentEventFilter::AfterMount)],
2580        E::Unmount => vec![EF::Component(ComponentEventFilter::BeforeUnmount)],
2581        E::Update => vec![EF::Component(ComponentEventFilter::Updated)],
2582        E::Resize => vec![EF::Component(ComponentEventFilter::NodeResized)],
2583
2584        // Hardware input-device events (P6) — node-level Hover mirror + the
2585        // window-level filter (the device isn't bound to a node).
2586        E::SensorChanged => vec![EF::Hover(H::SensorChanged), EF::Window(W::SensorChanged)],
2587        E::GamepadInput => vec![EF::Hover(H::GamepadInput), EF::Window(W::GamepadInput)],
2588
2589        // Geolocation (MWA-A1): node-level Hover mirror + the window-level
2590        // filter (a fix isn't bound to a node). The fix itself is read via
2591        // CallbackInfo::get_geolocation_fix.
2592        E::GeolocationFix => vec![EF::Hover(H::GeolocationFix), EF::Window(W::GeolocationFix)],
2593        E::GeolocationError => vec![EF::Hover(H::GeolocationError), EF::Window(W::GeolocationError)],
2594
2595        // Async capability outcomes (MWA-A1b): node-level Hover mirror (the
2596        // permission event targets the capability's subscriber node) + the
2597        // window-level filter.
2598        E::PermissionChanged => vec![EF::Hover(H::PermissionChanged), EF::Window(W::PermissionChanged)],
2599        E::BiometricResult => vec![EF::Hover(H::BiometricResult), EF::Window(W::BiometricResult)],
2600        E::KeyringResult => vec![EF::Hover(H::KeyringResult), EF::Window(W::KeyringResult)],
2601
2602        // MWA-C-clipboard: W3C clipboard events — fire on the focused
2603        // element before the OS default action (preventDefault suppresses
2604        // the default copy/cut/paste).
2605        E::Copy => vec![EF::Focus(F::Copy)],
2606        E::Cut => vec![EF::Focus(F::Cut)],
2607        E::Paste => vec![EF::Focus(F::Paste)],
2608
2609        // Unsupported events
2610        _ => vec![],
2611    }
2612}
2613
2614
2615
2616// Internal System Event Processing
2617
2618/// Framework-determined side effects (system changes).
2619///
2620/// Unlike `CallbackChange` (from user callbacks), these are determined by the
2621/// framework's event analysis: hit tests, gesture detection, focus rules,
2622/// text selection, keyboard shortcuts, etc.
2623///
2624/// Both `CallbackChange` (user) and `SystemChange` (framework) are processed
2625/// through exhaustive match on `PlatformWindowV2` — adding a new variant
2626/// causes a compile error in `apply_system_change()`.
2627#[derive(Debug, Clone, PartialEq, Eq)]
2628#[must_use = "SystemChange must be processed through apply_system_change()"]
2629pub enum SystemChange {
2630    // === Text Selection ===
2631
2632    /// Process a mouse click for text selection (single/double/triple click).
2633    TextSelectionClick {
2634        position: LogicalPosition,
2635        timestamp: Instant,
2636    },
2637    /// Extend text selection via mouse drag.
2638    TextSelectionDrag {
2639        start_position: LogicalPosition,
2640        current_position: LogicalPosition,
2641    },
2642    /// Unified selection operation: cursor movement, selection extension, or deletion.
2643    ///
2644    /// Replaces the old `ArrowKeyNavigation` and `DeleteTextSelection` variants.
2645    /// Every keyboard shortcut maps to a single `SelectionOp` — see its docs.
2646    ApplySelectionOp {
2647        target: DomNodeId,
2648        op: SelectionOp,
2649    },
2650
2651    // === Keyboard Shortcuts ===
2652
2653    /// Copy selected text to system clipboard (Ctrl+C / Cmd+C).
2654    CopyToClipboard,
2655    /// Cut selected text to clipboard and delete (Ctrl+X / Cmd+X).
2656    CutToClipboard { target: DomNodeId },
2657    /// Paste text from system clipboard at cursor (Ctrl+V / Cmd+V).
2658    PasteFromClipboard,
2659    /// Select all text in focused node (Ctrl+A / Cmd+A).
2660    SelectAllText,
2661    /// Undo last text edit (Ctrl+Z / Cmd+Z).
2662    UndoTextEdit { target: DomNodeId },
2663    /// Redo last undone edit (Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z).
2664    RedoTextEdit { target: DomNodeId },
2665
2666    // === Multi-Cursor ===
2667
2668    /// Add a cursor at the clicked position (Ctrl+Click).
2669    /// The position will be hit-tested to find the text cursor location.
2670    AddCursorAtClick {
2671        position: LogicalPosition,
2672    },
2673    /// Select the next occurrence of the current selection's text (Ctrl+D).
2674    /// If the primary selection is a cursor, expand it to the word first.
2675    SelectNextOccurrence {
2676        target: DomNodeId,
2677    },
2678
2679    // === Text Input ===
2680
2681    /// Apply pending text input from platform (keyboard/IME).
2682    ApplyPendingTextInput,
2683    /// Apply text changeset (incremental relayout).
2684    ApplyTextChangeset,
2685
2686    // === Drag & Drop ===
2687
2688    /// Activate node drag on a draggable element.
2689    ActivateNodeDrag {
2690        dom_id: DomId,
2691        node_id: NodeId,
2692    },
2693    /// Activate window drag (CSD titlebar).
2694    ActivateWindowDrag,
2695    /// Set up drag visual state (:dragging pseudo-state, GPU transform key).
2696    InitDragVisualState,
2697    /// Set :drag-over pseudo-state on a target node.
2698    SetDragOverState { target: DomNodeId, active: bool },
2699    /// Update current drop target in drag context.
2700    UpdateDropTarget { target: DomNodeId },
2701    /// Update GPU transform for active node drag.
2702    UpdateDragGpuTransform,
2703    /// End drag: clear pseudo-states, remove GPU keys, end drag session.
2704    DeactivateDrag,
2705
2706    // === Focus ===
2707
2708    /// Change focus to a new target (or clear focus if None).
2709    /// Handles: `set_focused_node`, `apply_focus_restyle`, `scroll_node_into_view`,
2710    /// `cursor_blink_timer` start/stop.
2711    SetFocus {
2712        new_focus: Option<DomNodeId>,
2713        old_focus: Option<DomNodeId>,
2714    },
2715    /// Clear all text selections.
2716    ClearAllSelections,
2717    /// Finalize pending focus changes (cursor initialization after layout).
2718    FinalizePendingFocusChanges,
2719
2720    // === Scroll ===
2721
2722    /// Scroll cursor/selection into view.
2723    ScrollSelectionIntoView,
2724    /// Scroll a specific node into view.
2725    ScrollNodeIntoView { target: DomNodeId },
2726    /// Scroll cursor into view after text input (needs relayout first).
2727    ScrollCursorIntoViewAfterTextInput,
2728
2729    // === Auto-Scroll Timer ===
2730
2731    /// Start auto-scroll timer for drag-to-scroll (60Hz).
2732    StartAutoScrollTimer,
2733    /// Cancel auto-scroll timer.
2734    StopAutoScrollTimer,
2735}
2736
2737impl_option!(
2738    SystemChange,
2739    OptionSystemChange,
2740    copy = false,
2741    clone = false,
2742    [Debug, Clone, PartialEq, Eq]
2743);
2744
2745impl_vec!(SystemChange, SystemChangeVec, SystemChangeVecDestructor, SystemChangeVecDestructorType, SystemChangeVecSlice, OptionSystemChange);
2746impl_vec_debug!(SystemChange, SystemChangeVec);
2747impl_vec_clone!(SystemChange, SystemChangeVec, SystemChangeVecDestructor);
2748impl_vec_partialeq!(SystemChange, SystemChangeVec);
2749
2750/// Result of pre-callback internal event filtering
2751#[derive(Debug, Clone, PartialEq)]
2752pub struct PreCallbackFilterResult {
2753    /// System changes to process BEFORE user callbacks
2754    pub system_changes: Vec<SystemChange>,
2755    /// Regular events that will be passed to user callbacks
2756    pub user_events: Vec<SyntheticEvent>,
2757}
2758
2759/// Flattened focus/selection state for the input interpreter (replaces trait objects).
2760#[derive(Debug, Clone, Copy)]
2761pub struct InputInterpreterState {
2762    pub focused_node: Option<DomNodeId>,
2763    pub click_count: u8,
2764    pub drag_start_position: Option<LogicalPosition>,
2765    pub has_selection: bool,
2766}
2767
2768/// All context needed by the input interpreter to map events to system changes.
2769///
2770/// Passed to the interpreter callback. Contains references to the current
2771/// events and window state. The interpreter reads this and returns system changes.
2772#[derive(Debug)]
2773pub struct InputInterpreterInfo<'a> {
2774    pub events: &'a [SyntheticEvent],
2775    pub hit_test: Option<&'a FullHitTest>,
2776    pub keyboard_state: &'a crate::window::KeyboardState,
2777    pub mouse_state: &'a crate::window::MouseState,
2778    pub state: InputInterpreterState,
2779}
2780
2781/// The `extern "C"` callback type for the input interpreter.
2782///
2783/// The first `RefAny` is the user data (vim mode, repeat counter, etc.)
2784/// held in `InputInterpreterCallback.ctx`. The `*const ()` is an opaque
2785/// pointer to `InputInterpreterInfo` — callers use the safe wrapper
2786/// methods to access event data. Returns a `PreCallbackFilterResult`.
2787///
2788/// For C/Python: the trampoline extracts the foreign callable from `RefAny.ctx`.
2789/// For Rust: use `InputInterpreterCallback::from(fn_ptr)` which sets ctx=None.
2790pub type InputInterpreterCallbackType = extern "C" fn(
2791    crate::refany::RefAny,
2792    *const InputInterpreterInfo<'static>,  // Opaque; actual lifetime managed by caller
2793) -> PreCallbackFilterResult;
2794
2795/// Configurable input interpreter callback.
2796///
2797/// Maps raw platform events + window state → semantic `SystemChange` actions.
2798/// The default (`default_input_interpreter`) handles standard desktop keybindings.
2799/// Replace this on `LayoutWindow` to implement vim, game controls, etc.
2800///
2801/// ## Pattern
2802/// - **Rust**: `InputInterpreterCallback::from(my_fn_ptr)` — `ctx` is None
2803/// - **Python/C**: Set `cb` to a trampoline, `ctx` to `RefAny` wrapping the foreign callable
2804#[repr(C)]
2805pub struct InputInterpreterCallback {
2806    pub cb: InputInterpreterCallbackType,
2807    pub ctx: crate::refany::OptionRefAny,
2808}
2809
2810impl_callback!(InputInterpreterCallback, InputInterpreterCallbackType);
2811
2812impl Default for InputInterpreterCallback {
2813    fn default() -> Self {
2814        Self {
2815            cb: default_input_interpreter_extern,
2816            ctx: crate::refany::OptionRefAny::None,
2817        }
2818    }
2819}
2820
2821/// The `extern "C"` callback type for the post-callback filter.
2822pub type PostFilterCallbackType = extern "C" fn(
2823    crate::refany::RefAny,
2824    bool,                    // prevent_default
2825    SystemChangeVecSlice,    // pre_changes (immutable slice)
2826    DomNodeId,               // old_focus (0xFFFF = None)
2827    DomNodeId,               // new_focus (0xFFFF = None)
2828) -> SystemChangeVec;
2829
2830/// Configurable post-callback filter.
2831#[repr(C)]
2832pub struct PostFilterCallback {
2833    pub cb: PostFilterCallbackType,
2834    pub ctx: crate::refany::OptionRefAny,
2835}
2836
2837impl_callback!(PostFilterCallback, PostFilterCallbackType);
2838
2839impl Default for PostFilterCallback {
2840    fn default() -> Self {
2841        Self {
2842            cb: default_post_filter_extern,
2843            ctx: crate::refany::OptionRefAny::None,
2844        }
2845    }
2846}
2847
2848// Keep simpler Rust fn pointer aliases for internal use
2849pub type InputInterpreterFn = fn(
2850    info: &InputInterpreterInfo<'_>,
2851) -> PreCallbackFilterResult;
2852
2853pub type PostFilterFn = fn(
2854    prevent_default: bool,
2855    pre_changes: &[SystemChange],
2856    old_focus: Option<DomNodeId>,
2857    new_focus: Option<DomNodeId>,
2858) -> Vec<SystemChange>;
2859
2860/// Mouse button state for drag tracking
2861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2862pub struct MouseButtonState {
2863    pub left_down: bool,
2864    pub right_down: bool,
2865    pub middle_down: bool,
2866}
2867
2868/// Arrow key / cursor navigation directions
2869#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2870pub enum ArrowDirection {
2871    Left,
2872    Right,
2873    Up,
2874    Down,
2875    /// Home key: move to start of current line
2876    LineStart,
2877    /// End key: move to end of current line
2878    LineEnd,
2879    /// Ctrl+Home: move to start of document
2880    DocumentStart,
2881    /// Ctrl+End: move to end of document
2882    DocumentEnd,
2883}
2884
2885impl ArrowDirection {
2886    /// Map a `VirtualKeyCode` plus the `ctrl` modifier into an `ArrowDirection`.
2887    /// Returns `None` if the key is not a navigation key.
2888    #[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, ctrl: bool) -> Option<Self> {
2889        use crate::window::VirtualKeyCode::{Left, Right, Up, Down, Home, End};
2890        Some(match vk {
2891            Left => Self::Left,
2892            Right => Self::Right,
2893            Up => Self::Up,
2894            Down => Self::Down,
2895            Home if ctrl => Self::DocumentStart,
2896            Home => Self::LineStart,
2897            End if ctrl => Self::DocumentEnd,
2898            End => Self::LineEnd,
2899            _ => return None,
2900        })
2901    }
2902
2903    /// Convert to a `(SelectionDirection, SelectionStep)` pair for the
2904    /// selection-op interpreter. `ctrl` upgrades arrow keys to word jumps.
2905    #[must_use] pub const fn to_selection(self, ctrl: bool) -> (SelectionDirection, SelectionStep) {
2906        match self {
2907            Self::Left if ctrl => (SelectionDirection::Backward, SelectionStep::Word),
2908            Self::Right if ctrl => (SelectionDirection::Forward, SelectionStep::Word),
2909            Self::Left => (SelectionDirection::Backward, SelectionStep::Character),
2910            Self::Right => (SelectionDirection::Forward, SelectionStep::Character),
2911            Self::Up => (SelectionDirection::Backward, SelectionStep::VisualLine),
2912            Self::Down => (SelectionDirection::Forward, SelectionStep::VisualLine),
2913            Self::LineStart => (SelectionDirection::Backward, SelectionStep::Line),
2914            Self::LineEnd => (SelectionDirection::Forward, SelectionStep::Line),
2915            Self::DocumentStart => (SelectionDirection::Backward, SelectionStep::Document),
2916            Self::DocumentEnd => (SelectionDirection::Forward, SelectionStep::Document),
2917        }
2918    }
2919}
2920
2921/// Direction of cursor movement or selection expansion.
2922#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2923#[repr(C)]
2924pub enum SelectionDirection {
2925    Forward,
2926    Backward,
2927}
2928
2929/// Granularity of cursor movement or selection expansion.
2930///
2931/// Combined with `SelectionDirection`, determines how far a cursor moves
2932/// or a selection expands. Reused for navigation, deletion, and visual
2933/// selection — a single code path for word boundaries etc.
2934#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2935#[repr(C)]
2936pub enum SelectionStep {
2937    /// One grapheme cluster (arrow keys, Backspace, Delete)
2938    Character,
2939    /// One word boundary (Ctrl+arrow, Ctrl+Backspace, Ctrl+Delete)
2940    Word,
2941    /// To line boundary (Home/End)
2942    Line,
2943    /// One visual line up/down (Up/Down arrows)
2944    VisualLine,
2945    /// To document boundary (Ctrl+Home/End)
2946    Document,
2947}
2948
2949/// What to do with the selection after moving.
2950#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2951#[repr(C)]
2952pub enum SelectionMode {
2953    /// Collapse selection to cursor, then move (plain arrow key).
2954    Move,
2955    /// Extend selection from anchor to new position (Shift+arrow).
2956    Extend,
2957    /// Expand cursor to range in the given direction, then delete the range
2958    /// (Backspace/Delete). If a range already exists, just delete it.
2959    Delete,
2960}
2961
2962/// A unified selection operation that replaces all cursor movement,
2963/// selection extension, and text deletion commands.
2964///
2965/// Every keyboard shortcut for cursor movement or deletion maps to this:
2966/// - Arrow Left = (Backward, Character, Move, 1)
2967/// - Shift+Right = (Forward, Character, Extend, 1)
2968/// - Ctrl+Backspace = (Backward, Word, Delete, 1)
2969/// - Home = (Backward, Line, Move, 1)
2970/// - Ctrl+End = (Forward, Document, Move, 1)
2971///
2972/// The `repeat` field enables vim-style commands: 3w = (Forward, Word, Move, 3).
2973#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2974#[repr(C)]
2975pub struct SelectionOp {
2976    pub direction: SelectionDirection,
2977    pub step: SelectionStep,
2978    pub mode: SelectionMode,
2979    pub repeat: usize,
2980}
2981
2982impl SelectionOp {
2983    #[must_use] pub const fn new(direction: SelectionDirection, step: SelectionStep, mode: SelectionMode) -> Self {
2984        Self { direction, step, mode, repeat: 1 }
2985    }
2986}
2987
2988/// Keyboard shortcuts for text editing
2989#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2990pub enum KeyboardShortcut {
2991    Copy,      // Ctrl+C
2992    Cut,       // Ctrl+X
2993    Paste,     // Ctrl+V
2994    SelectAll, // Ctrl+A
2995    Undo,      // Ctrl+Z
2996    Redo,      // Ctrl+Y or Ctrl+Shift+Z
2997}
2998
2999impl KeyboardShortcut {
3000    /// Map a `(VirtualKeyCode, primary, shift)` triple to a text-editing
3001    /// shortcut. Returns `None` if the key combination is not a recognized
3002    /// shortcut or if `primary` is not held. `primary` is the platform's
3003    /// primary modifier — Cmd on macOS, Ctrl elsewhere — obtained from
3004    /// `KeyboardState::primary_down()` (MWA-A2: hardcoding Ctrl here made
3005    /// every editing shortcut dead on macOS).
3006    #[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, primary: bool, shift: bool) -> Option<Self> {
3007        use crate::window::VirtualKeyCode::{C, X, V, A, Z, Y};
3008        if !primary {
3009            return None;
3010        }
3011        Some(match vk {
3012            C => Self::Copy,
3013            X => Self::Cut,
3014            V => Self::Paste,
3015            A => Self::SelectAll,
3016            Z if shift => Self::Redo,
3017            Z => Self::Undo,
3018            Y => Self::Redo,
3019            _ => return None,
3020        })
3021    }
3022}
3023
3024/// Default input interpreter: standard desktop keybindings.
3025///
3026/// This is the default `InputInterpreterFn` that handles arrow keys, Home/End,
3027/// Backspace/Delete, Ctrl+C/V/A/Z, mouse clicks, and drag selection.
3028/// Replace it on `LayoutWindow` to implement vim, game controls, etc.
3029/// `extern "C"` trampoline for `default_input_interpreter`.
3030#[allow(clippy::not_unsafe_ptr_arg_deref)] // SAFETY/FFI: `*const T` is the C-ABI signature; the fn null-checks then derefs under the documented caller contract (C guarantees a valid ptr/len). Marking it `unsafe fn` would force unsafe blocks into the generated dll bindings.
3031#[must_use] pub extern "C" fn default_input_interpreter_extern(
3032    _user_data: crate::refany::RefAny,
3033    info_ptr: *const InputInterpreterInfo<'static>,
3034) -> PreCallbackFilterResult {
3035    if info_ptr.is_null() {
3036        return PreCallbackFilterResult {
3037            system_changes: Vec::new(),
3038            user_events: Vec::new(),
3039        };
3040    }
3041    let info = unsafe { &*info_ptr };
3042    default_input_interpreter(info)
3043}
3044
3045/// `extern "C"` trampoline for `default_post_filter`.
3046#[must_use] pub extern "C" fn default_post_filter_extern(
3047    _user_data: crate::refany::RefAny,
3048    prevent_default: bool,
3049    pre_changes: SystemChangeVecSlice,
3050    old_focus: DomNodeId,
3051    new_focus: DomNodeId,
3052) -> SystemChangeVec {
3053    let pre_changes_slice = pre_changes.as_slice();
3054    let old = old_focus.node.into_crate_internal().map(|_| old_focus);
3055    let new = new_focus.node.into_crate_internal().map(|_| new_focus);
3056    default_post_filter(prevent_default, pre_changes_slice, old, new).into()
3057}
3058
3059#[must_use] pub fn default_input_interpreter(
3060    info: &InputInterpreterInfo<'_>,
3061) -> PreCallbackFilterResult {
3062    let ctx = FilterContext {
3063        hit_test: info.hit_test,
3064        keyboard_state: info.keyboard_state,
3065        mouse_state: info.mouse_state,
3066        click_count: info.state.click_count,
3067        focused_node: info.state.focused_node,
3068        drag_start_position: info.state.drag_start_position,
3069    };
3070
3071    let (system_changes, user_events) = info.events.iter().fold(
3072        (Vec::new(), Vec::new()),
3073        |(mut internal, mut user), event| {
3074            match process_event_for_internal(&ctx, event) {
3075                Some(InternalEventAction::AddAndSkip(evt)) => {
3076                    internal.push(evt);
3077                }
3078                Some(InternalEventAction::AddAndPass(evt)) => {
3079                    internal.push(evt);
3080                    user.push(event.clone());
3081                }
3082                None => {
3083                    user.push(event.clone());
3084                }
3085            }
3086            (internal, user)
3087        },
3088    );
3089
3090    PreCallbackFilterResult {
3091        system_changes,
3092        user_events,
3093    }
3094}
3095
3096/// Backward-compatible wrapper that calls `default_input_interpreter`.
3097pub fn pre_callback_filter_internal_events<SM, FM>(
3098    events: &[SyntheticEvent],
3099    hit_test: Option<&FullHitTest>,
3100    keyboard_state: &crate::window::KeyboardState,
3101    mouse_state: &crate::window::MouseState,
3102    selection_manager: &SM,
3103    focus_manager: &FM,
3104) -> PreCallbackFilterResult
3105where
3106    SM: SelectionManagerQuery,
3107    FM: FocusManagerQuery,
3108{
3109    let info = InputInterpreterInfo {
3110        events,
3111        hit_test,
3112        keyboard_state,
3113        mouse_state,
3114        state: InputInterpreterState {
3115            focused_node: focus_manager.get_focused_node_id(),
3116            click_count: selection_manager.get_click_count(),
3117            drag_start_position: selection_manager.get_drag_start_position(),
3118            has_selection: selection_manager.has_selection(),
3119        },
3120    };
3121    default_input_interpreter(&info)
3122}
3123
3124/// Context for filtering internal events (used by `default_input_interpreter`)
3125struct FilterContext<'a> {
3126    hit_test: Option<&'a FullHitTest>,
3127    keyboard_state: &'a crate::window::KeyboardState,
3128    mouse_state: &'a crate::window::MouseState,
3129    click_count: u8,
3130    focused_node: Option<DomNodeId>,
3131    drag_start_position: Option<LogicalPosition>,
3132}
3133
3134/// Process a single event and determine if it generates an internal event
3135fn process_event_for_internal(
3136    ctx: &FilterContext<'_>,
3137    event: &SyntheticEvent,
3138) -> Option<InternalEventAction> {
3139    match event.event_type {
3140        EventType::MouseDown => handle_mouse_down(event, ctx.hit_test, ctx.click_count, ctx.mouse_state, ctx.keyboard_state),
3141        EventType::MouseOver => handle_mouse_over(
3142            event,
3143            ctx.hit_test,
3144            ctx.mouse_state,
3145            ctx.drag_start_position,
3146        ),
3147        EventType::KeyDown => handle_key_down(
3148            event,
3149            ctx.keyboard_state,
3150            ctx.focused_node,
3151        ),
3152        _ => None,
3153    }
3154}
3155
3156/// Action to take after processing an event for internal system events
3157enum InternalEventAction {
3158    /// Add system change and skip passing to user callbacks
3159    AddAndSkip(SystemChange),
3160    /// Add system change but also pass to user callbacks
3161    AddAndPass(SystemChange),
3162}
3163
3164/// Extract the front-most hovered node from a hit test.
3165///
3166/// Picks the node with the minimum `hit_depth` (0 = frontmost/topmost in
3167/// z-order) across every hovered DOM. The previous implementation took the
3168/// first entry of the `BTreeMap` (lowest `NodeId`), which ignored z-order
3169/// entirely and targeted the back-most node under overlapping elements.
3170/// Ties are broken deterministically by (`DomId`, `NodeId`) iteration order.
3171fn get_first_hovered_node(hit_test: Option<&FullHitTest>) -> Option<DomNodeId> {
3172    let ht = hit_test?;
3173    let mut best: Option<(DomId, NodeId, u32)> = None;
3174    for (dom_id, hit_data) in &ht.hovered_nodes {
3175        for (node_id, item) in &hit_data.regular_hit_test_nodes {
3176            let is_better = match best {
3177                None => true,
3178                Some((_, _, best_depth)) => item.hit_depth < best_depth,
3179            };
3180            if is_better {
3181                best = Some((*dom_id, *node_id, item.hit_depth));
3182            }
3183        }
3184    }
3185    let (dom_id, node_id, _) = best?;
3186    Some(DomNodeId {
3187        dom: dom_id,
3188        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
3189    })
3190}
3191
3192/// Extract mouse position from event data, falling back to `mouse_state` if not available
3193fn get_mouse_position_with_fallback(
3194    event: &SyntheticEvent,
3195    mouse_state: &crate::window::MouseState,
3196) -> LogicalPosition {
3197    match &event.data {
3198        EventData::Mouse(mouse_data) => mouse_data.position,
3199        _ => {
3200            // Fallback: use current cursor position from mouse_state
3201            // This handles synthetic events from debug API and automation
3202            // where EventData may not contain the mouse position
3203            mouse_state.cursor_position.get_position().unwrap_or(LogicalPosition::zero())
3204        }
3205    }
3206}
3207
3208/// Handle `MouseDown` event - detect text selection clicks and Ctrl+Click for multi-cursor
3209fn handle_mouse_down(
3210    event: &SyntheticEvent,
3211    hit_test: Option<&FullHitTest>,
3212    click_count: u8,
3213    mouse_state: &crate::window::MouseState,
3214    keyboard_state: &crate::window::KeyboardState,
3215) -> Option<InternalEventAction> {
3216    let effective_click_count = if click_count == 0 { 1 } else { click_count };
3217
3218    if effective_click_count > 3 {
3219        return None;
3220    }
3221
3222    let _target = get_first_hovered_node(hit_test)?;
3223    let position = get_mouse_position_with_fallback(event, mouse_state);
3224
3225    // Ctrl+Click (or Cmd+Click on macOS): add cursor at click position.
3226    // Use the platform PRIMARY modifier so this fires on Cmd on macOS
3227    // (where Ctrl+Click is the secondary-click gesture) — `ctrl_down()`
3228    // was wrong there.
3229    if keyboard_state.primary_down() && effective_click_count == 1 {
3230        return Some(InternalEventAction::AddAndPass(
3231            SystemChange::AddCursorAtClick { position },
3232        ));
3233    }
3234
3235    Some(InternalEventAction::AddAndPass(
3236        SystemChange::TextSelectionClick {
3237            position,
3238            timestamp: event.timestamp.clone(),
3239        },
3240    ))
3241}
3242
3243/// Handle `MouseOver` event - detect drag selection
3244fn handle_mouse_over(
3245    event: &SyntheticEvent,
3246    hit_test: Option<&FullHitTest>,
3247    mouse_state: &crate::window::MouseState,
3248    drag_start_position: Option<LogicalPosition>,
3249) -> Option<InternalEventAction> {
3250    if !mouse_state.left_down {
3251        return None;
3252    }
3253
3254    let start_position = drag_start_position?;
3255
3256    let _target = get_first_hovered_node(hit_test)?;
3257    let current_position = get_mouse_position_with_fallback(event, mouse_state);
3258
3259    Some(InternalEventAction::AddAndPass(
3260        SystemChange::TextSelectionDrag {
3261            start_position,
3262            current_position,
3263        },
3264    ))
3265}
3266
3267/// Handle `KeyDown` event - detect shortcuts, arrow keys, and delete keys
3268fn handle_key_down(
3269    event: &SyntheticEvent,
3270    keyboard_state: &crate::window::KeyboardState,
3271    focused_node: Option<DomNodeId>,
3272) -> Option<InternalEventAction> {
3273    use crate::window::VirtualKeyCode;
3274
3275    let target = focused_node?;
3276    let EventData::Keyboard(kbd) = &event.data else {
3277        return None;
3278    };
3279
3280    // Read the key and modifiers from THIS event's payload, not from the live
3281    // `keyboard_state`. The live state can have advanced (another key pressed /
3282    // released) between when the event was queued and when it is dispatched, so
3283    // reading it here could act on the wrong key/modifiers. `keyboard_state` is
3284    // retained only for the platform where the event does not carry a key.
3285    let _ = keyboard_state;
3286
3287    // MWA-A2: standard shortcuts key off the PRIMARY modifier (Cmd on
3288    // macOS, Ctrl elsewhere); word-jump / word-delete keys off the
3289    // platform's word modifier (Option on macOS, Ctrl elsewhere).
3290    let primary = if cfg!(target_os = "macos") {
3291        kbd.modifiers.meta
3292    } else {
3293        kbd.modifiers.ctrl
3294    };
3295    let word_mod = if cfg!(target_os = "macos") {
3296        kbd.modifiers.alt
3297    } else {
3298        kbd.modifiers.ctrl
3299    };
3300    let shift = kbd.modifiers.shift;
3301    let vk_owned = VirtualKeyCode::from_u32(kbd.key_code)?;
3302    let vk = &vk_owned;
3303
3304    // Check keyboard shortcuts (primary+key) → emit specific SystemChange
3305    // variants. Standard editing shortcuts are routed through the
3306    // `KeyboardShortcut` enum, and a couple of additional Azul-specific
3307    // primary-modifier combos are matched after.
3308    if primary {
3309        if let Some(shortcut) = KeyboardShortcut::from_key(*vk, primary, shift) {
3310            let change = match shortcut {
3311                KeyboardShortcut::Copy => SystemChange::CopyToClipboard,
3312                KeyboardShortcut::Cut => SystemChange::CutToClipboard { target },
3313                KeyboardShortcut::Paste => SystemChange::PasteFromClipboard,
3314                KeyboardShortcut::SelectAll => SystemChange::SelectAllText,
3315                KeyboardShortcut::Undo => SystemChange::UndoTextEdit { target },
3316                KeyboardShortcut::Redo => SystemChange::RedoTextEdit { target },
3317            };
3318            return Some(InternalEventAction::AddAndSkip(change));
3319        }
3320        if matches!(vk, VirtualKeyCode::D) {
3321            return Some(InternalEventAction::AddAndSkip(
3322                SystemChange::SelectNextOccurrence { target },
3323            ));
3324        }
3325    }
3326
3327    // Unified: arrow keys, Home/End, Backspace/Delete all map to SelectionOp.
3328    let mode_for_shift = if shift { SelectionMode::Extend } else { SelectionMode::Move };
3329    let selection_op = if let Some(arrow) = ArrowDirection::from_key(*vk, word_mod) {
3330        let (direction, step) = arrow.to_selection(word_mod);
3331        SelectionOp::new(direction, step, mode_for_shift)
3332    } else {
3333        match vk {
3334            // Backspace/Delete = Delete mode (word modifier upgrades to
3335            // Word: Option+Backspace on macOS, Ctrl+Backspace elsewhere)
3336            VirtualKeyCode::Back => SelectionOp::new(
3337                SelectionDirection::Backward,
3338                if word_mod { SelectionStep::Word } else { SelectionStep::Character },
3339                SelectionMode::Delete,
3340            ),
3341            VirtualKeyCode::Delete => SelectionOp::new(
3342                SelectionDirection::Forward,
3343                if word_mod { SelectionStep::Word } else { SelectionStep::Character },
3344                SelectionMode::Delete,
3345            ),
3346            _ => return None,
3347        }
3348    };
3349
3350    Some(InternalEventAction::AddAndSkip(
3351        SystemChange::ApplySelectionOp { target, op: selection_op },
3352    ))
3353}
3354
3355/// Trait for querying selection manager state.
3356///
3357/// This allows `pre_callback_filter_internal_events` to query manager state
3358/// without depending on the concrete `SelectionManager` type from layout crate.
3359pub trait SelectionManagerQuery {
3360    /// Get the current click count (1 = single, 2 = double, 3 = triple)
3361    fn get_click_count(&self) -> u8;
3362
3363    /// Get the drag start position if a drag is in progress
3364    fn get_drag_start_position(&self) -> Option<LogicalPosition>;
3365
3366    /// Check if any selection exists (click selection or drag selection)
3367    fn has_selection(&self) -> bool;
3368}
3369
3370/// Trait for querying focus manager state.
3371///
3372/// This allows `pre_callback_filter_internal_events` to query manager state
3373/// without depending on the concrete `FocusManager` type from layout crate.
3374pub trait FocusManagerQuery {
3375    /// Get the currently focused node ID
3376    fn get_focused_node_id(&self) -> Option<DomNodeId>;
3377}
3378
3379/// Post-callback filter: Determine additional system changes needed after user callbacks.
3380///
3381/// Takes the pre-callback system changes and focus state to determine what
3382/// post-callback system changes are needed (text input, scrolling, timers).
3383/// Default post-callback filter: scroll-into-view after cursor ops, auto-scroll during drag.
3384#[must_use] pub fn default_post_filter(
3385    prevent_default: bool,
3386    pre_changes: &[SystemChange],
3387    old_focus: Option<DomNodeId>,
3388    new_focus: Option<DomNodeId>,
3389) -> Vec<SystemChange> {
3390    post_callback_filter_system_changes(prevent_default, pre_changes, old_focus, new_focus)
3391}
3392
3393// SystemChange dispatch table; a few arms incidentally push the same follow-up
3394// change but are kept as distinct documented cases.
3395#[allow(clippy::match_same_arms)]
3396#[must_use] pub fn post_callback_filter_system_changes(
3397    prevent_default: bool,
3398    pre_changes: &[SystemChange],
3399    old_focus: Option<DomNodeId>,
3400    new_focus: Option<DomNodeId>,
3401) -> Vec<SystemChange> {
3402    let mut changes = Vec::new();
3403
3404    if prevent_default {
3405        // Only focus change passes through preventDefault
3406        if old_focus != new_focus {
3407            changes.push(SystemChange::SetFocus { new_focus, old_focus });
3408        }
3409        return changes;
3410    }
3411
3412    // Always apply pending text input
3413    changes.push(SystemChange::ApplyPendingTextInput);
3414
3415    // Determine post-callback actions based on pre-callback system changes
3416    for change in pre_changes {
3417        match change {
3418            SystemChange::TextSelectionClick { .. }
3419            | SystemChange::ApplySelectionOp { .. }
3420            | SystemChange::AddCursorAtClick { .. }
3421            | SystemChange::SelectNextOccurrence { .. } => {
3422                changes.push(SystemChange::ScrollSelectionIntoView);
3423            }
3424            SystemChange::TextSelectionDrag { .. } => {
3425                changes.push(SystemChange::StartAutoScrollTimer);
3426            }
3427            SystemChange::CutToClipboard { .. }
3428            | SystemChange::PasteFromClipboard
3429            | SystemChange::UndoTextEdit { .. }
3430            | SystemChange::RedoTextEdit { .. }
3431            | SystemChange::SelectAllText => {
3432                changes.push(SystemChange::ScrollSelectionIntoView);
3433            }
3434            // Other system changes don't generate post-callback actions
3435            _ => {}
3436        }
3437    }
3438
3439    // Focus changed during callbacks
3440    if old_focus != new_focus {
3441        changes.push(SystemChange::SetFocus { new_focus, old_focus });
3442    }
3443
3444    changes
3445}
3446
3447
3448#[cfg(test)]
3449mod tests {
3450    use super::*;
3451    use azul_css::AzString;
3452    use crate::dom::{DomId, DomNodeId};
3453    use crate::styled_dom::NodeHierarchyItemId;
3454    use crate::id::NodeId;
3455    use crate::window::{KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec, OptionVirtualKeyCode};
3456    use crate::geom::LogicalPosition;
3457    use crate::task::{Instant, SystemTick};
3458
3459    struct MockSelectionManager {
3460        click_count: u8,
3461        has_sel: bool,
3462    }
3463    impl SelectionManagerQuery for MockSelectionManager {
3464        fn get_click_count(&self) -> u8 { self.click_count }
3465        fn get_drag_start_position(&self) -> Option<LogicalPosition> { None }
3466        fn has_selection(&self) -> bool { self.has_sel }
3467    }
3468
3469    struct MockFocusManager(Option<DomNodeId>);
3470    impl FocusManagerQuery for MockFocusManager {
3471        fn get_focused_node_id(&self) -> Option<DomNodeId> { self.0 }
3472    }
3473
3474    fn focused_node(node_idx: usize) -> DomNodeId {
3475        DomNodeId {
3476            dom: DomId { inner: 0 },
3477            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node_idx))),
3478        }
3479    }
3480
3481    fn make_keyboard_state(vk: VirtualKeyCode) -> KeyboardState {
3482        KeyboardState {
3483            current_virtual_keycode: OptionVirtualKeyCode::Some(vk),
3484            pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![vk]),
3485            ..KeyboardState::default()
3486        }
3487    }
3488
3489    fn make_keydown_event(target: DomNodeId) -> SyntheticEvent {
3490        SyntheticEvent::new(
3491            EventType::KeyDown,
3492            EventSource::User,
3493            target,
3494            Instant::Tick(SystemTick::new(0)),
3495            EventData::Keyboard(KeyboardEventData {
3496                key_code: VirtualKeyCode::Back as u32,
3497                char_code: None,
3498                modifiers: KeyModifiers::default(),
3499                repeat: false,
3500            }),
3501        )
3502    }
3503
3504    #[test]
3505    fn backspace_generates_delete_text_selection() {
3506        let target = focused_node(2);
3507        let events = vec![make_keydown_event(target)];
3508        let kb = make_keyboard_state(VirtualKeyCode::Back);
3509        let mouse = MouseState::default();
3510        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3511        let focus = MockFocusManager(Some(target));
3512
3513        let result = pre_callback_filter_internal_events(
3514            &events, None, &kb, &mouse, &sel, &focus,
3515        );
3516
3517        let ops: Vec<_> = result.system_changes.iter()
3518            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
3519            .collect();
3520        assert_eq!(ops.len(), 1, "Backspace should generate ApplySelectionOp");
3521        match &ops[0] {
3522            SystemChange::ApplySelectionOp { op, .. } => {
3523                assert_eq!(op.direction, SelectionDirection::Backward);
3524                assert_eq!(op.step, SelectionStep::Character);
3525                assert_eq!(op.mode, SelectionMode::Delete);
3526            }
3527            _ => unreachable!(),
3528        }
3529    }
3530
3531    #[test]
3532    fn delete_key_generates_forward_deletion() {
3533        let target = focused_node(2);
3534        let event = SyntheticEvent::new(
3535            EventType::KeyDown, EventSource::User, target,
3536            Instant::Tick(SystemTick::new(0)),
3537            EventData::Keyboard(KeyboardEventData {
3538                key_code: VirtualKeyCode::Delete as u32,
3539                char_code: None, modifiers: KeyModifiers::default(), repeat: false,
3540            }),
3541        );
3542        let kb = make_keyboard_state(VirtualKeyCode::Delete);
3543        let mouse = MouseState::default();
3544        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3545        let focus = MockFocusManager(Some(target));
3546        let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
3547        let ops: Vec<_> = result.system_changes.iter()
3548            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
3549            .collect();
3550        assert_eq!(ops.len(), 1);
3551        match &ops[0] {
3552            SystemChange::ApplySelectionOp { op, .. } => {
3553                assert_eq!(op.direction, SelectionDirection::Forward);
3554                assert_eq!(op.step, SelectionStep::Character);
3555                assert_eq!(op.mode, SelectionMode::Delete);
3556            }
3557            _ => unreachable!(),
3558        }
3559    }
3560
3561    #[test]
3562    fn arrow_left_generates_navigation() {
3563        let target = focused_node(2);
3564        let event = SyntheticEvent::new(
3565            EventType::KeyDown, EventSource::User, target,
3566            Instant::Tick(SystemTick::new(0)),
3567            EventData::Keyboard(KeyboardEventData {
3568                key_code: VirtualKeyCode::Left as u32,
3569                char_code: None, modifiers: KeyModifiers::default(), repeat: false,
3570            }),
3571        );
3572        let kb = make_keyboard_state(VirtualKeyCode::Left);
3573        let mouse = MouseState::default();
3574        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3575        let focus = MockFocusManager(Some(target));
3576        let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
3577        let ops: Vec<_> = result.system_changes.iter()
3578            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
3579            .collect();
3580        assert_eq!(ops.len(), 1, "Left arrow should generate ApplySelectionOp");
3581        match &ops[0] {
3582            SystemChange::ApplySelectionOp { op, .. } => {
3583                assert_eq!(op.direction, SelectionDirection::Backward);
3584                assert_eq!(op.step, SelectionStep::Character);
3585                assert_eq!(op.mode, SelectionMode::Move);
3586            }
3587            _ => unreachable!(),
3588        }
3589    }
3590
3591    #[test]
3592    fn no_focused_node_means_no_keyboard_system_changes() {
3593        let target = focused_node(2);
3594        let event = make_keydown_event(target);
3595        let kb = make_keyboard_state(VirtualKeyCode::Back);
3596        let mouse = MouseState::default();
3597        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3598        let focus = MockFocusManager(None); // No focus!
3599
3600        let result = pre_callback_filter_internal_events(
3601            &[event], None, &kb, &mouse, &sel, &focus,
3602        );
3603
3604        assert!(result.system_changes.is_empty(),
3605            "No system changes should be generated without focused node");
3606    }
3607
3608    #[test]
3609    fn keydown_without_keyboard_data_generates_no_system_change() {
3610        let target = focused_node(2);
3611        let event = SyntheticEvent::new(
3612            EventType::KeyDown,
3613            EventSource::User,
3614            target,
3615            Instant::Tick(SystemTick::new(0)),
3616            EventData::None, // Bug: missing keyboard data
3617        );
3618        let kb = make_keyboard_state(VirtualKeyCode::Back);
3619        let mouse = MouseState::default();
3620        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3621        let focus = MockFocusManager(Some(target));
3622
3623        let result = pre_callback_filter_internal_events(
3624            &[event], None, &kb, &mouse, &sel, &focus,
3625        );
3626
3627        // This test documents the bug we just fixed: EventData::None causes
3628        // the handle_key_down function to return None (early exit at line 2737)
3629        assert!(result.system_changes.is_empty(),
3630            "EventData::None should not generate system changes (documents the old bug)");
3631    }
3632
3633    #[test]
3634    fn ctrl_c_generates_copy() {
3635        // MWA-A2/MWA-D: shortcuts key off the PRIMARY modifier — Cmd on
3636        // macOS, Ctrl elsewhere — so this test presses the platform's
3637        // primary key. (The old version hardcoded LControl and correctly
3638        // started failing on macOS hosts once primary_down() landed:
3639        // Ctrl+C must NOT copy on macOS, Cmd+C does.)
3640        let primary_key = if cfg!(target_os = "macos") {
3641            VirtualKeyCode::LWin
3642        } else {
3643            VirtualKeyCode::LControl
3644        };
3645        let target = focused_node(2);
3646        let event = SyntheticEvent::new(
3647            EventType::KeyDown,
3648            EventSource::User,
3649            target,
3650            Instant::Tick(SystemTick::new(0)),
3651            EventData::Keyboard(KeyboardEventData {
3652                key_code: VirtualKeyCode::C as u32,
3653                char_code: Some('c'),
3654                modifiers: KeyModifiers {
3655                    ctrl: !cfg!(target_os = "macos"),
3656                    shift: false,
3657                    alt: false,
3658                    meta: cfg!(target_os = "macos"),
3659                },
3660                repeat: false,
3661            }),
3662        );
3663        let mut kb = make_keyboard_state(VirtualKeyCode::C);
3664        kb.pressed_virtual_keycodes = VirtualKeyCodeVec::from_vec(
3665            vec![VirtualKeyCode::C, primary_key]
3666        );
3667        let mouse = MouseState::default();
3668        let sel = MockSelectionManager { click_count: 0, has_sel: false };
3669        let focus = MockFocusManager(Some(target));
3670
3671        let result = pre_callback_filter_internal_events(
3672            &[event], None, &kb, &mouse, &sel, &focus,
3673        );
3674
3675        let copy_changes = result.system_changes.iter()
3676            .filter(|c| matches!(c, SystemChange::CopyToClipboard))
3677            .count();
3678
3679        assert_eq!(copy_changes, 1, "primary+C should generate CopyToClipboard");
3680    }
3681
3682    fn make_hit_test_with_node(node_idx: usize) -> FullHitTest {
3683        use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
3684        use crate::dom::OptionDomNodeId;
3685        use std::collections::BTreeMap;
3686
3687        let node_id = NodeId::new(node_idx);
3688        let dom_id = DomId { inner: 0 };
3689
3690        let mut regular = BTreeMap::new();
3691        regular.insert(node_id, HitTestItem {
3692            point_in_viewport: LogicalPosition::new(100.0, 200.0),
3693            point_relative_to_item: LogicalPosition::new(50.0, 30.0),
3694            is_focusable: true,
3695            is_virtual_view_hit: None,
3696            hit_depth: 0,
3697        });
3698
3699        let mut hovered = BTreeMap::new();
3700        hovered.insert(dom_id, HitTest {
3701            regular_hit_test_nodes: regular,
3702            scroll_hit_test_nodes: BTreeMap::new(),
3703            scrollbar_hit_test_nodes: BTreeMap::new(),
3704            cursor_hit_test_nodes: BTreeMap::new(),
3705        });
3706
3707        FullHitTest {
3708            hovered_nodes: hovered,
3709            focused_node: OptionDomNodeId::None,
3710        }
3711    }
3712
3713    #[test]
3714    fn mousedown_generates_text_selection_click() {
3715        let target = focused_node(2);
3716        let event = SyntheticEvent::new(
3717            EventType::MouseDown,
3718            EventSource::User,
3719            target,
3720            Instant::Tick(SystemTick::new(0)),
3721            EventData::Mouse(MouseEventData {
3722                position: LogicalPosition::new(100.0, 200.0),
3723                button: MouseButton::Left,
3724                buttons: 1,
3725                modifiers: KeyModifiers::default(),
3726            }),
3727        );
3728        let hit_test = make_hit_test_with_node(2);
3729        let kb = KeyboardState::default();
3730        let mouse = MouseState::default();
3731        let sel = MockSelectionManager { click_count: 1, has_sel: false };
3732        let focus = MockFocusManager(Some(target));
3733
3734        let result = pre_callback_filter_internal_events(
3735            &[event], Some(&hit_test), &kb, &mouse, &sel, &focus,
3736        );
3737
3738        let click_changes = result.system_changes.iter()
3739            .filter(|c| matches!(c, SystemChange::TextSelectionClick { .. }))
3740            .count();
3741
3742        assert_eq!(click_changes, 1, "MouseDown with hit_test should generate TextSelectionClick");
3743    }
3744
3745    #[test]
3746    fn process_event_result_max_self_picks_higher_variant() {
3747        let lo = ProcessEventResult::ShouldReRenderCurrentWindow;
3748        let hi = ProcessEventResult::ShouldRegenerateDomCurrentWindow;
3749        assert_eq!(lo.max_self(hi), hi);
3750        assert_eq!(hi.max_self(lo), hi);
3751        assert_eq!(lo.max_self(lo), lo);
3752    }
3753
3754    #[test]
3755    fn keyboard_shortcut_keys_off_primary_modifier() {
3756        use crate::window::VirtualKeyCode::{A, C, V, X, Z};
3757        // No primary modifier → never a shortcut (MWA-A2).
3758        assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
3759        assert_eq!(KeyboardShortcut::from_key(Z, false, true), None);
3760        // Primary held → the standard editing set.
3761        assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
3762        assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
3763        assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
3764        assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
3765        assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
3766        assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
3767    }
3768
3769    #[test]
3770    fn primary_modifier_is_platform_correct() {
3771        use crate::window::{KeyboardState, VirtualKeyCode};
3772        let cmd_held = KeyboardState {
3773            pressed_virtual_keycodes: vec![VirtualKeyCode::LWin].into(),
3774            ..Default::default()
3775        };
3776        // Cmd/super is primary ONLY on macOS.
3777        assert_eq!(cmd_held.primary_down(), cfg!(target_os = "macos"));
3778
3779        let ctrl_held = KeyboardState {
3780            pressed_virtual_keycodes: vec![VirtualKeyCode::LControl].into(),
3781            ..Default::default()
3782        };
3783        // Ctrl is primary everywhere EXCEPT macOS.
3784        assert_eq!(ctrl_held.primary_down(), !cfg!(target_os = "macos"));
3785    }
3786
3787    #[test]
3788    fn arrow_direction_from_key_maps_arrows_and_home_end() {
3789        use crate::window::VirtualKeyCode::*;
3790        assert_eq!(ArrowDirection::from_key(Left, false), Some(ArrowDirection::Left));
3791        assert_eq!(ArrowDirection::from_key(Right, false), Some(ArrowDirection::Right));
3792        assert_eq!(ArrowDirection::from_key(Up, false), Some(ArrowDirection::Up));
3793        assert_eq!(ArrowDirection::from_key(Down, false), Some(ArrowDirection::Down));
3794        assert_eq!(ArrowDirection::from_key(Home, false), Some(ArrowDirection::LineStart));
3795        assert_eq!(ArrowDirection::from_key(End, false), Some(ArrowDirection::LineEnd));
3796        assert_eq!(ArrowDirection::from_key(Home, true), Some(ArrowDirection::DocumentStart));
3797        assert_eq!(ArrowDirection::from_key(End, true), Some(ArrowDirection::DocumentEnd));
3798        assert_eq!(ArrowDirection::from_key(C, false), None);
3799    }
3800
3801    #[test]
3802    fn arrow_direction_to_selection_respects_ctrl() {
3803        let (d, s) = ArrowDirection::Left.to_selection(false);
3804        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Character));
3805        let (d, s) = ArrowDirection::Left.to_selection(true);
3806        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Word));
3807        let (d, s) = ArrowDirection::Up.to_selection(false);
3808        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::VisualLine));
3809        let (d, s) = ArrowDirection::DocumentEnd.to_selection(false);
3810        assert_eq!((d, s), (SelectionDirection::Forward, SelectionStep::Document));
3811    }
3812
3813    #[test]
3814    fn keyboard_shortcut_from_key_recognizes_editing_combos() {
3815        use crate::window::VirtualKeyCode::*;
3816        assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
3817        assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
3818        assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
3819        assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
3820        assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
3821        assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
3822        assert_eq!(KeyboardShortcut::from_key(Y, true, false), Some(KeyboardShortcut::Redo));
3823        // Non-ctrl combos must not match
3824        assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
3825        // Unknown keys
3826        assert_eq!(KeyboardShortcut::from_key(D, true, false), None);
3827    }
3828
3829    #[test]
3830    fn mouse_button_state_round_trips_from_mouse_state() {
3831        let ms = MouseState {
3832            left_down: true,
3833            middle_down: true,
3834            ..MouseState::default()
3835        };
3836        let bs: MouseButtonState = (&ms).into();
3837        assert!(bs.left_down);
3838        assert!(!bs.right_down);
3839        assert!(bs.middle_down);
3840        assert!(bs.any_down());
3841
3842        let none = MouseButtonState { left_down: false, right_down: false, middle_down: false };
3843        assert!(!none.any_down());
3844    }
3845
3846    #[test]
3847    fn callback_to_call_collects_hits_for_dom() {
3848        let dom_id = DomId { inner: 0 };
3849        let hit_test = make_hit_test_with_node(2);
3850        let filter = EventFilter::Hover(HoverEventFilter::MouseDown);
3851        let calls = CallbackToCall::from_hit_test(&hit_test, dom_id, filter);
3852        assert_eq!(calls.len(), 1);
3853        assert_eq!(calls[0].node_id, NodeId::new(2));
3854        assert_eq!(calls[0].event_filter, filter);
3855        assert!(calls[0].hit_test_item.is_some());
3856
3857        // Unknown DOM id => empty list
3858        let other = CallbackToCall::from_hit_test(
3859            &hit_test,
3860            DomId { inner: 999 },
3861            EventFilter::Hover(HoverEventFilter::MouseUp),
3862        );
3863        assert!(other.is_empty());
3864
3865        // Direct constructor builds expected fields
3866        let direct = CallbackToCall::new(
3867            NodeId::new(7),
3868            None,
3869            EventFilter::Focus(FocusEventFilter::FocusReceived),
3870        );
3871        assert_eq!(direct.node_id, NodeId::new(7));
3872        assert!(direct.hit_test_item.is_none());
3873    }
3874
3875    #[test]
3876    fn restyle_relayout_aliases_are_btreemap_compatible() {
3877        // RestyleNodes / RelayoutNodes are aliases for BTreeMap<NodeId, Vec<ChangedCssProperty>>.
3878        // Confirm we can construct empty ones via the alias and that they accept the same keys.
3879        let restyle: RestyleNodes = BTreeMap::new();
3880        let relayout: RelayoutNodes = BTreeMap::new();
3881        assert!(restyle.is_empty());
3882        assert!(relayout.is_empty());
3883
3884        // RelayoutWords is BTreeMap<NodeId, AzString>.
3885        let mut words: RelayoutWords = BTreeMap::new();
3886        words.insert(NodeId::new(1), AzString::from_const_str("hello"));
3887        assert_eq!(words.get(&NodeId::new(1)).map(azul_css::AzString::as_str), Some("hello"));
3888    }
3889
3890    #[test]
3891    fn detect_lifecycle_events_with_reconciliation_is_callable() {
3892        // Smoke test: empty old/new node data must produce no events and an
3893        // empty migration map. This proves the function is callable from
3894        // the public API and threads through `crate::diff::reconcile_dom`.
3895        let dom_id = DomId { inner: 0 };
3896        let old_data: Vec<crate::dom::NodeData> = Vec::new();
3897        let new_data: Vec<crate::dom::NodeData> = Vec::new();
3898        let old_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
3899        let new_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
3900        let old_layout = OrderedMap::default();
3901        let new_layout = OrderedMap::default();
3902        let result: LifecycleEventResult = detect_lifecycle_events_with_reconciliation(
3903            dom_id,
3904            &old_data,
3905            &new_data,
3906            &old_hier,
3907            &new_hier,
3908            &old_layout,
3909            &new_layout,
3910            Instant::Tick(SystemTick::new(0)),
3911        );
3912        assert!(result.events.is_empty());
3913        assert!(result.node_id_mapping.is_empty());
3914    }
3915
3916    #[test]
3917    fn nodedata_focusable_and_activation_traits_are_wired() {
3918        use crate::dom::{NodeData, NodeType};
3919        use crate::events::{ActivationBehavior as _, Focusable as _};
3920
3921        // <button> is naturally focusable and has activation behavior.
3922        let btn = NodeData::create_node(NodeType::Button);
3923        assert!(<NodeData as Focusable>::is_naturally_focusable(&btn));
3924        assert!(<NodeData as Focusable>::is_focusable(&btn));
3925        assert!(<NodeData as ActivationBehavior>::has_activation_behavior(&btn));
3926        assert!(<NodeData as ActivationBehavior>::is_activatable(&btn));
3927
3928        // A plain <div> is neither naturally focusable nor activatable.
3929        let div = NodeData::create_node(NodeType::Div);
3930        assert!(!<NodeData as Focusable>::is_naturally_focusable(&div));
3931        assert!(!<NodeData as ActivationBehavior>::has_activation_behavior(&div));
3932
3933        // <input> is naturally focusable.
3934        let input = NodeData::create_node(NodeType::Input);
3935        assert!(<NodeData as Focusable>::is_naturally_focusable(&input));
3936    }
3937
3938    #[test]
3939    fn first_hovered_node_picks_frontmost_by_depth() {
3940        use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
3941        use crate::dom::OptionDomNodeId;
3942        use std::collections::BTreeMap;
3943
3944        let item = |depth: u32| HitTestItem {
3945            point_in_viewport: LogicalPosition::zero(),
3946            point_relative_to_item: LogicalPosition::zero(),
3947            is_focusable: true,
3948            is_virtual_view_hit: None,
3949            hit_depth: depth,
3950        };
3951
3952        // Front-most node (depth 0) has the HIGHER NodeId; back node (depth 5)
3953        // has the lower id. The old `.next()` logic returned the lowest id
3954        // (node 2, the back one). We must now return the front-most (node 5).
3955        let mut regular = BTreeMap::new();
3956        regular.insert(NodeId::new(2), item(5));
3957        regular.insert(NodeId::new(5), item(0));
3958
3959        let mut hovered = BTreeMap::new();
3960        hovered.insert(DomId { inner: 0 }, HitTest {
3961            regular_hit_test_nodes: regular,
3962            scroll_hit_test_nodes: BTreeMap::new(),
3963            scrollbar_hit_test_nodes: BTreeMap::new(),
3964            cursor_hit_test_nodes: BTreeMap::new(),
3965        });
3966        let ht = FullHitTest { hovered_nodes: hovered, focused_node: OptionDomNodeId::None };
3967
3968        let got = get_first_hovered_node(Some(&ht)).unwrap();
3969        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
3970    }
3971
3972    #[test]
3973    fn size_changed_nan_guard_stops_resize_loop() {
3974        use crate::geom::LogicalSize;
3975        // A NaN dimension present on BOTH frames must read as "unchanged" so no
3976        // Resize is emitted every frame.
3977        let a = LogicalSize::new(f32::NAN, 100.0);
3978        let b = LogicalSize::new(f32::NAN, 100.0);
3979        assert!(!size_changed(a, b));
3980        // A real change is still detected.
3981        assert!(size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.0, 120.0)));
3982        // Sub-quantum jitter is ignored.
3983        assert!(!size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.00005, 100.0)));
3984    }
3985
3986    #[test]
3987    fn dom_path_terminates_on_parent_cycle() {
3988        use crate::id::{Node, NodeHierarchy};
3989        // Two nodes whose parents point at each other -> a cycle.
3990        let nodes = vec![
3991            Node { parent: Some(NodeId::new(1)), ..Node::ROOT },
3992            Node { parent: Some(NodeId::new(0)), ..Node::ROOT },
3993        ];
3994        let hier = NodeHierarchy::new(nodes);
3995        let target = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0)));
3996        // Must not hang / OOM; bounded by node count + visited-set.
3997        let path = get_dom_path(&hier, target);
3998        assert!(path.len() <= 2);
3999    }
4000
4001    #[test]
4002    fn click_event_maps_to_left_mouse_up() {
4003        let filters = event_type_to_filters(EventType::Click, &EventData::None);
4004        assert!(filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseUp)));
4005        assert!(!filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseDown)));
4006    }
4007}
4008
4009#[cfg(test)]
4010#[allow(clippy::float_cmp, clippy::too_many_lines)]
4011mod autotest_generated {
4012    use super::*;
4013    use crate::{
4014        dom::{DomId, DomNodeId, OptionDomNodeId},
4015        geom::{LogicalPosition, LogicalRect, LogicalSize},
4016        hit_test::{FullHitTest, HitTest, HitTestItem},
4017        id::{Node, NodeHierarchy, NodeId},
4018        styled_dom::NodeHierarchyItemId,
4019        task::{Instant, SystemTick},
4020        window::{CursorPosition, KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec},
4021    };
4022
4023    // ---------------------------------------------------------------- helpers
4024
4025    fn tick(n: u64) -> Instant {
4026        Instant::Tick(SystemTick::new(n))
4027    }
4028
4029    fn dnid(dom: usize, node: usize) -> DomNodeId {
4030        DomNodeId {
4031            dom: DomId { inner: dom },
4032            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
4033        }
4034    }
4035
4036    /// A `DomNodeId` whose node slot is the `None` sentinel (raw inner == 0).
4037    fn dnid_none(dom: usize) -> DomNodeId {
4038        DomNodeId {
4039            dom: DomId { inner: dom },
4040            node: NodeHierarchyItemId::NONE,
4041        }
4042    }
4043
4044    fn hit_item(depth: u32) -> HitTestItem {
4045        HitTestItem {
4046            point_in_viewport: LogicalPosition::new(1.0, 2.0),
4047            point_relative_to_item: LogicalPosition::new(3.0, 4.0),
4048            is_focusable: true,
4049            is_virtual_view_hit: None,
4050            hit_depth: depth,
4051        }
4052    }
4053
4054    /// Hit test containing `(node_index, hit_depth)` pairs, all under one DOM.
4055    fn hit_test_with(dom: usize, nodes: &[(usize, u32)]) -> FullHitTest {
4056        let mut regular = BTreeMap::new();
4057        for (idx, depth) in nodes {
4058            regular.insert(NodeId::new(*idx), hit_item(*depth));
4059        }
4060        let mut hovered = BTreeMap::new();
4061        hovered.insert(
4062            DomId { inner: dom },
4063            HitTest {
4064                regular_hit_test_nodes: regular,
4065                scroll_hit_test_nodes: BTreeMap::new(),
4066                scrollbar_hit_test_nodes: BTreeMap::new(),
4067                cursor_hit_test_nodes: BTreeMap::new(),
4068            },
4069        );
4070        FullHitTest {
4071            hovered_nodes: hovered,
4072            focused_node: OptionDomNodeId::None,
4073        }
4074    }
4075
4076    fn empty_hit_test() -> FullHitTest {
4077        FullHitTest {
4078            hovered_nodes: BTreeMap::new(),
4079            focused_node: OptionDomNodeId::None,
4080        }
4081    }
4082
4083    fn mouse_event(ty: EventType, button: MouseButton, pos: LogicalPosition) -> SyntheticEvent {
4084        SyntheticEvent::new(
4085            ty,
4086            EventSource::User,
4087            dnid(0, 0),
4088            tick(0),
4089            EventData::Mouse(MouseEventData {
4090                position: pos,
4091                button,
4092                buttons: 1,
4093                modifiers: KeyModifiers::default(),
4094            }),
4095        )
4096    }
4097
4098    fn key_event(key_code: u32, modifiers: KeyModifiers) -> SyntheticEvent {
4099        SyntheticEvent::new(
4100            EventType::KeyDown,
4101            EventSource::User,
4102            dnid(0, 0),
4103            tick(0),
4104            EventData::Keyboard(KeyboardEventData {
4105                key_code,
4106                char_code: None,
4107                modifiers,
4108                repeat: false,
4109            }),
4110        )
4111    }
4112
4113    /// Straight parent chain: node 0 = root, node i's parent = node i-1.
4114    fn hierarchy_chain(len: usize) -> NodeHierarchy {
4115        let nodes = (0..len)
4116            .map(|i| Node {
4117                parent: if i == 0 { None } else { Some(NodeId::new(i - 1)) },
4118                ..Node::ROOT
4119            })
4120            .collect::<Vec<_>>();
4121        NodeHierarchy::new(nodes)
4122    }
4123
4124    /// Modifiers with the platform's PRIMARY modifier held (Cmd on macOS, Ctrl elsewhere).
4125    fn primary_modifiers() -> KeyModifiers {
4126        if cfg!(target_os = "macos") {
4127            KeyModifiers::new().with_meta()
4128        } else {
4129            KeyModifiers::new().with_ctrl()
4130        }
4131    }
4132
4133    fn keyboard_with_primary_held() -> KeyboardState {
4134        let key = if cfg!(target_os = "macos") {
4135            VirtualKeyCode::LWin
4136        } else {
4137            VirtualKeyCode::LControl
4138        };
4139        KeyboardState {
4140            pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![key]),
4141            ..KeyboardState::default()
4142        }
4143    }
4144
4145    // ============================================================ numeric edge
4146    // size_changed / quantization
4147
4148    #[test]
4149    fn size_changed_zero_and_identity() {
4150        assert!(!size_changed(LogicalSize::zero(), LogicalSize::zero()));
4151        assert!(!size_changed(
4152            LogicalSize::new(0.0, 0.0),
4153            LogicalSize::new(-0.0, -0.0)
4154        ));
4155        // 0 -> any real size is a change.
4156        assert!(size_changed(LogicalSize::zero(), LogicalSize::new(0.0, 1.0)));
4157        assert!(size_changed(LogicalSize::zero(), LogicalSize::new(1.0, 0.0)));
4158    }
4159
4160    #[test]
4161    fn size_changed_single_sided_nan_is_a_change() {
4162        // NaN on ONE side only must register as changed (the both-sides NaN case
4163        // is the loop-guard covered by `size_changed_nan_guard_stops_resize_loop`).
4164        assert!(size_changed(
4165            LogicalSize::new(f32::NAN, 10.0),
4166            LogicalSize::new(10.0, 10.0)
4167        ));
4168        assert!(size_changed(
4169            LogicalSize::new(10.0, 10.0),
4170            LogicalSize::new(10.0, f32::NAN)
4171        ));
4172        // NaN on both sides in *different* dimensions is still a change in the
4173        // other dimension only if that dimension actually differs.
4174        assert!(!size_changed(
4175            LogicalSize::new(f32::NAN, f32::NAN),
4176            LogicalSize::new(f32::NAN, f32::NAN)
4177        ));
4178    }
4179
4180    #[test]
4181    fn size_changed_negative_and_infinite_do_not_panic() {
4182        // Negative sizes (degenerate layouts) must be handled deterministically.
4183        assert!(size_changed(
4184            LogicalSize::new(-100.0, 0.0),
4185            LogicalSize::new(100.0, 0.0)
4186        ));
4187        assert!(!size_changed(
4188            LogicalSize::new(-100.0, -50.0),
4189            LogicalSize::new(-100.0, -50.0)
4190        ));
4191        // f32 * 1000.0 overflows to +/-inf, and `inf as i64` SATURATES (it does
4192        // not wrap or UB). So the comparison stays total and panic-free.
4193        assert!(!size_changed(
4194            LogicalSize::new(f32::INFINITY, f32::INFINITY),
4195            LogicalSize::new(f32::INFINITY, f32::INFINITY)
4196        ));
4197        assert!(size_changed(
4198            LogicalSize::new(f32::INFINITY, 0.0),
4199            LogicalSize::new(f32::NEG_INFINITY, 0.0)
4200        ));
4201        // Finite-but-huge values saturate into the same bucket as infinity: the
4202        // documented quantization trade-off, asserted here so a future change of
4203        // the quantizer (e.g. to i128 or a float compare) is a deliberate one.
4204        assert!(!size_changed(
4205            LogicalSize::new(f32::MAX, 0.0),
4206            LogicalSize::new(f32::INFINITY, 0.0)
4207        ));
4208    }
4209
4210    #[test]
4211    fn size_changed_ignores_sub_quantum_jitter_but_sees_one_quantum() {
4212        // The quantizer is 1/1000, so a 0.0005 wobble must be ignored...
4213        assert!(!size_changed(
4214            LogicalSize::new(50.0, 50.0),
4215            LogicalSize::new(50.0004, 50.0)
4216        ));
4217        // ...but a full quantum must be seen.
4218        assert!(size_changed(
4219            LogicalSize::new(50.0, 50.0),
4220            LogicalSize::new(50.002, 50.0)
4221        ));
4222    }
4223
4224    // ------------------------------------------------- create_*_event numerics
4225
4226    #[test]
4227    fn create_mount_event_without_layout_entry_falls_back_to_zero_rect() {
4228        let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
4229        let ev = create_mount_event(NodeId::new(3), DomId { inner: 0 }, &layout, &tick(7));
4230        assert_eq!(ev.event_type, EventType::Mount);
4231        assert_eq!(ev.source, EventSource::Lifecycle);
4232        assert_eq!(ev.phase, EventPhase::Target);
4233        assert_eq!(ev.target, ev.current_target);
4234        assert_eq!(ev.target.node.into_crate_internal(), Some(NodeId::new(3)));
4235        match ev.data {
4236            EventData::Lifecycle(d) => {
4237                assert_eq!(d.reason, LifecycleReason::InitialMount);
4238                assert!(d.previous_bounds.is_none());
4239                assert_eq!(d.current_bounds, LogicalRect::zero());
4240            }
4241            _ => panic!("mount event must carry lifecycle data"),
4242        }
4243    }
4244
4245    #[test]
4246    fn create_unmount_event_reports_previous_bounds_and_zero_current() {
4247        let mut layout = BTreeMap::new();
4248        let rect = LogicalRect::new(LogicalPosition::new(1.0, 2.0), LogicalSize::new(3.0, 4.0));
4249        layout.insert(NodeId::new(1), rect);
4250        let ev = create_unmount_event(NodeId::new(1), DomId { inner: 2 }, &layout, &tick(9));
4251        assert_eq!(ev.event_type, EventType::Unmount);
4252        match ev.data {
4253            EventData::Lifecycle(d) => {
4254                assert_eq!(d.reason, LifecycleReason::Unmount);
4255                assert_eq!(d.previous_bounds, Some(rect));
4256                assert_eq!(d.current_bounds, LogicalRect::zero());
4257            }
4258            _ => panic!("unmount event must carry lifecycle data"),
4259        }
4260    }
4261
4262    #[test]
4263    fn create_lifecycle_event_survives_extreme_node_ids() {
4264        // The largest NodeId that survives the 1-based (`n + 1`) FFI encoding.
4265        // (NodeId::new(usize::MAX) would overflow that encoding — out of scope here.)
4266        let huge = NodeId::new(usize::MAX - 1);
4267        let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
4268        let ev = create_mount_event(huge, DomId { inner: usize::MAX }, &layout, &tick(0));
4269        assert_eq!(ev.target.node.into_crate_internal(), Some(huge));
4270        assert_eq!(ev.target.dom, DomId { inner: usize::MAX });
4271
4272        // NodeId 0 (the root) must round-trip too — the 1-based encoding makes
4273        // 0 the value most likely to collide with the `None` sentinel.
4274        let root = create_mount_event(NodeId::ZERO, DomId { inner: 0 }, &layout, &tick(0));
4275        assert_eq!(
4276            root.target.node.into_crate_internal(),
4277            Some(NodeId::ZERO),
4278            "NodeId 0 must not decode as `None`"
4279        );
4280    }
4281
4282    #[test]
4283    fn create_resize_event_returns_none_for_missing_or_unchanged_layout() {
4284        let dom = DomId { inner: 0 };
4285        let node = NodeId::new(1);
4286        let rect = LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
4287
4288        let empty: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
4289        let mut one = BTreeMap::new();
4290        one.insert(node, rect);
4291
4292        // Missing in old, missing in new, missing in both -> None (no panic).
4293        assert!(create_resize_event(node, dom, &empty, &one, &tick(0)).is_none());
4294        assert!(create_resize_event(node, dom, &one, &empty, &tick(0)).is_none());
4295        assert!(create_resize_event(node, dom, &empty, &empty, &tick(0)).is_none());
4296        // Present on both sides but unchanged -> None.
4297        assert!(create_resize_event(node, dom, &one, &one, &tick(0)).is_none());
4298    }
4299
4300    #[test]
4301    fn create_resize_event_ignores_pure_origin_moves() {
4302        // Only the SIZE is compared: moving a node without resizing it must not
4303        // emit a Resize event.
4304        let dom = DomId { inner: 0 };
4305        let node = NodeId::new(0);
4306        let size = LogicalSize::new(10.0, 10.0);
4307        let mut old = BTreeMap::new();
4308        old.insert(node, LogicalRect::new(LogicalPosition::new(0.0, 0.0), size));
4309        let mut new = BTreeMap::new();
4310        new.insert(
4311            node,
4312            LogicalRect::new(LogicalPosition::new(500.0, 500.0), size),
4313        );
4314        assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
4315    }
4316
4317    #[test]
4318    fn create_resize_event_nan_size_does_not_loop_forever() {
4319        // Regression guard: a NaN dimension on BOTH frames must NOT emit a Resize
4320        // every frame (a raw f32 `!=` would, since NaN != NaN).
4321        let dom = DomId { inner: 0 };
4322        let node = NodeId::new(0);
4323        let nan_rect = LogicalRect::new(
4324            LogicalPosition::zero(),
4325            LogicalSize::new(f32::NAN, 100.0),
4326        );
4327        let mut old = BTreeMap::new();
4328        old.insert(node, nan_rect);
4329        let mut new = BTreeMap::new();
4330        new.insert(node, nan_rect);
4331        assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
4332    }
4333
4334    #[test]
4335    fn create_resize_event_reports_both_bounds_on_real_change() {
4336        let dom = DomId { inner: 0 };
4337        let node = NodeId::new(0);
4338        let old_rect =
4339            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
4340        let new_rect =
4341            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 20.0));
4342        let mut old = BTreeMap::new();
4343        old.insert(node, old_rect);
4344        let mut new = BTreeMap::new();
4345        new.insert(node, new_rect);
4346
4347        let ev = create_resize_event(node, dom, &old, &new, &tick(3))
4348            .expect("a real size change must emit a Resize");
4349        assert_eq!(ev.event_type, EventType::Resize);
4350        match ev.data {
4351            EventData::Lifecycle(d) => {
4352                assert_eq!(d.reason, LifecycleReason::Resize);
4353                assert_eq!(d.previous_bounds, Some(old_rect));
4354                assert_eq!(d.current_bounds, new_rect);
4355            }
4356            _ => panic!("resize event must carry lifecycle data"),
4357        }
4358    }
4359
4360    // ------------------------------------------------- detect_lifecycle_events
4361
4362    #[test]
4363    fn detect_lifecycle_events_all_none_is_empty() {
4364        let events = detect_lifecycle_events(
4365            DomId { inner: 0 },
4366            DomId { inner: 0 },
4367            None,
4368            None,
4369            None,
4370            None,
4371            tick(0),
4372        );
4373        assert!(events.is_empty());
4374    }
4375
4376    #[test]
4377    fn detect_lifecycle_events_without_layout_emits_nothing() {
4378        // Hierarchies differ, but no layout maps -> the fn must not fabricate events.
4379        let old = hierarchy_chain(1);
4380        let new = hierarchy_chain(4);
4381        let events = detect_lifecycle_events(
4382            DomId { inner: 0 },
4383            DomId { inner: 0 },
4384            Some(&old),
4385            Some(&new),
4386            None,
4387            None,
4388            tick(0),
4389        );
4390        assert!(events.is_empty());
4391    }
4392
4393    #[test]
4394    fn detect_lifecycle_events_emits_mounts_unmounts_and_resizes() {
4395        let dom = DomId { inner: 0 };
4396        let old_hier = hierarchy_chain(2); // nodes 0,1
4397        let new_hier = hierarchy_chain(3); // nodes 0,1,2
4398
4399        let r = |h: f32| LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, h));
4400        let mut old_layout = BTreeMap::new();
4401        old_layout.insert(NodeId::new(0), r(10.0));
4402        old_layout.insert(NodeId::new(1), r(10.0));
4403        let mut new_layout = BTreeMap::new();
4404        new_layout.insert(NodeId::new(0), r(10.0)); // unchanged
4405        new_layout.insert(NodeId::new(1), r(99.0)); // resized
4406        new_layout.insert(NodeId::new(2), r(10.0)); // mounted
4407
4408        let events = detect_lifecycle_events(
4409            dom,
4410            dom,
4411            Some(&old_hier),
4412            Some(&new_hier),
4413            Some(&old_layout),
4414            Some(&new_layout),
4415            tick(5),
4416        );
4417
4418        let mounts: Vec<_> = events
4419            .iter()
4420            .filter(|e| e.event_type == EventType::Mount)
4421            .collect();
4422        let resizes: Vec<_> = events
4423            .iter()
4424            .filter(|e| e.event_type == EventType::Resize)
4425            .collect();
4426        assert_eq!(mounts.len(), 1, "only node 2 is new");
4427        assert_eq!(
4428            mounts[0].target.node.into_crate_internal(),
4429            Some(NodeId::new(2))
4430        );
4431        assert_eq!(resizes.len(), 1, "only node 1 changed size");
4432        assert_eq!(
4433            resizes[0].target.node.into_crate_internal(),
4434            Some(NodeId::new(1))
4435        );
4436        assert!(
4437            !events.iter().any(|e| e.event_type == EventType::Unmount),
4438            "nothing was removed"
4439        );
4440        assert!(events.iter().all(|e| e.source == EventSource::Lifecycle));
4441
4442        // Reverse direction: the removed node must unmount.
4443        let events = detect_lifecycle_events(
4444            dom,
4445            dom,
4446            Some(&new_hier),
4447            Some(&old_hier),
4448            Some(&new_layout),
4449            Some(&old_layout),
4450            tick(6),
4451        );
4452        let unmounts: Vec<_> = events
4453            .iter()
4454            .filter(|e| e.event_type == EventType::Unmount)
4455            .collect();
4456        assert_eq!(unmounts.len(), 1);
4457        assert_eq!(
4458            unmounts[0].target.node.into_crate_internal(),
4459            Some(NodeId::new(2))
4460        );
4461    }
4462
4463    #[test]
4464    fn detect_lifecycle_events_mount_of_node_missing_from_layout_uses_zero_rect() {
4465        let dom = DomId { inner: 0 };
4466        let new_hier = hierarchy_chain(2);
4467        let new_layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new(); // empty!
4468        let events = detect_lifecycle_events(
4469            dom,
4470            dom,
4471            None,
4472            Some(&new_hier),
4473            None,
4474            Some(&new_layout),
4475            tick(0),
4476        );
4477        assert_eq!(events.len(), 2);
4478        for ev in &events {
4479            match ev.data {
4480                EventData::Lifecycle(d) => assert_eq!(d.current_bounds, LogicalRect::zero()),
4481                _ => panic!("expected lifecycle data"),
4482            }
4483        }
4484    }
4485
4486    #[test]
4487    fn collect_node_ids_handles_none_and_empty_hierarchies() {
4488        assert!(collect_node_ids(None).is_empty());
4489        let empty = NodeHierarchy::new(Vec::new());
4490        assert!(collect_node_ids(Some(&empty)).is_empty());
4491        let three = hierarchy_chain(3);
4492        let ids = collect_node_ids(Some(&three));
4493        assert_eq!(ids.len(), 3);
4494        assert!(ids.contains(&NodeId::ZERO));
4495        assert!(ids.contains(&NodeId::new(2)));
4496    }
4497
4498    // ======================================================== getters/predicates
4499
4500    #[test]
4501    fn process_event_result_order_is_dense_and_monotonic() {
4502        let all = [
4503            ProcessEventResult::DoNothing,
4504            ProcessEventResult::ShouldReRenderCurrentWindow,
4505            ProcessEventResult::ShouldUpdateDisplayListCurrentWindow,
4506            ProcessEventResult::UpdateHitTesterAndProcessAgain,
4507            ProcessEventResult::ShouldIncrementalRelayout,
4508            ProcessEventResult::ShouldRegenerateDomCurrentWindow,
4509            ProcessEventResult::ShouldRegenerateDomAllWindows,
4510        ];
4511        for (i, r) in all.iter().enumerate() {
4512            assert_eq!(r.order(), i, "order() must match declaration index");
4513        }
4514        // Ord/PartialOrd must agree with order(), and max_self must be the join.
4515        for a in all {
4516            for b in all {
4517                assert_eq!(a < b, a.order() < b.order());
4518                let joined = a.max_self(b);
4519                assert_eq!(joined.order(), a.order().max(b.order()));
4520                assert_eq!(joined, b.max_self(a), "max_self must be commutative");
4521                assert_eq!(a.max_self(a), a, "max_self must be idempotent");
4522            }
4523        }
4524    }
4525
4526    #[test]
4527    fn key_modifiers_builders_are_orthogonal_and_is_empty_tracks_them() {
4528        let empty = KeyModifiers::new();
4529        assert!(empty.is_empty());
4530        assert_eq!(empty, KeyModifiers::default());
4531
4532        // Each builder sets exactly one flag.
4533        assert_eq!(
4534            KeyModifiers::new().with_shift(),
4535            KeyModifiers { shift: true, ctrl: false, alt: false, meta: false }
4536        );
4537        assert_eq!(
4538            KeyModifiers::new().with_ctrl(),
4539            KeyModifiers { shift: false, ctrl: true, alt: false, meta: false }
4540        );
4541        assert_eq!(
4542            KeyModifiers::new().with_alt(),
4543            KeyModifiers { shift: false, ctrl: false, alt: true, meta: false }
4544        );
4545        assert_eq!(
4546            KeyModifiers::new().with_meta(),
4547            KeyModifiers { shift: false, ctrl: false, alt: false, meta: true }
4548        );
4549
4550        // Any single flag defeats is_empty; builders are idempotent and composable.
4551        assert!(!KeyModifiers::new().with_shift().is_empty());
4552        assert!(!KeyModifiers::new().with_ctrl().is_empty());
4553        assert!(!KeyModifiers::new().with_alt().is_empty());
4554        assert!(!KeyModifiers::new().with_meta().is_empty());
4555        assert_eq!(
4556            KeyModifiers::new().with_ctrl().with_ctrl(),
4557            KeyModifiers::new().with_ctrl()
4558        );
4559        let all = KeyModifiers::new().with_shift().with_ctrl().with_alt().with_meta();
4560        assert!(!all.is_empty());
4561        assert!(all.shift && all.ctrl && all.alt && all.meta);
4562    }
4563
4564    #[test]
4565    fn scroll_into_view_options_presets_and_behavior_setters() {
4566        assert_eq!(
4567            ScrollIntoViewOptions::default(),
4568            ScrollIntoViewOptions::nearest(),
4569            "Default must be the `nearest`/`auto` preset"
4570        );
4571        for (opts, expected) in [
4572            (ScrollIntoViewOptions::nearest(), ScrollLogicalPosition::Nearest),
4573            (ScrollIntoViewOptions::center(), ScrollLogicalPosition::Center),
4574            (ScrollIntoViewOptions::start(), ScrollLogicalPosition::Start),
4575            (ScrollIntoViewOptions::end(), ScrollLogicalPosition::End),
4576        ] {
4577            assert_eq!(opts.block, expected);
4578            assert_eq!(opts.inline_axis, expected, "both axes must be aligned alike");
4579            assert_eq!(opts.behavior, ScrollIntoViewBehavior::Auto);
4580
4581            // The behavior setters must not disturb the axes, and last-writer-wins.
4582            let instant = opts.with_instant();
4583            assert_eq!(instant.behavior, ScrollIntoViewBehavior::Instant);
4584            assert_eq!(instant.block, opts.block);
4585            assert_eq!(instant.inline_axis, opts.inline_axis);
4586
4587            let smooth = opts.with_smooth();
4588            assert_eq!(smooth.behavior, ScrollIntoViewBehavior::Smooth);
4589            assert_eq!(
4590                opts.with_instant().with_smooth().behavior,
4591                ScrollIntoViewBehavior::Smooth
4592            );
4593            assert_eq!(
4594                opts.with_smooth().with_instant().behavior,
4595                ScrollIntoViewBehavior::Instant
4596            );
4597        }
4598    }
4599
4600    #[test]
4601    fn default_action_result_has_action_predicate() {
4602        assert!(!DefaultActionResult::default().has_action());
4603        assert!(!DefaultActionResult::prevented().has_action());
4604        assert!(DefaultActionResult::prevented().prevented);
4605        assert_eq!(DefaultActionResult::prevented().action, DefaultAction::None);
4606
4607        // `None` action => nothing to do, even though it was not prevented.
4608        let none = DefaultActionResult::new(DefaultAction::None);
4609        assert!(!none.prevented);
4610        assert!(!none.has_action());
4611
4612        // Any real action => has_action.
4613        for action in [
4614            DefaultAction::FocusNext,
4615            DefaultAction::FocusPrevious,
4616            DefaultAction::FocusFirst,
4617            DefaultAction::FocusLast,
4618            DefaultAction::ClearFocus,
4619            DefaultAction::SelectAllText,
4620            DefaultAction::ActivateFocusedElement { target: dnid(0, 1) },
4621            DefaultAction::SubmitForm { form_node: dnid(0, 1) },
4622            DefaultAction::CloseModal { modal_node: dnid(0, 1) },
4623            DefaultAction::ScrollFocusedContainer {
4624                direction: ScrollDirection::Down,
4625                amount: ScrollAmount::Page,
4626            },
4627        ] {
4628            let r = DefaultActionResult::new(action);
4629            assert_eq!(r.action, action);
4630            assert!(!r.prevented);
4631            assert!(r.has_action(), "{action:?} must be reported as actionable");
4632        }
4633    }
4634
4635    #[test]
4636    fn synthetic_event_constructor_invariants_and_flag_transitions() {
4637        let target = dnid(3, 7);
4638        let mut ev = SyntheticEvent::new(
4639            EventType::Click,
4640            EventSource::Programmatic,
4641            target,
4642            tick(42),
4643            EventData::None,
4644        );
4645        // Post-construction invariants.
4646        assert_eq!(ev.event_type, EventType::Click);
4647        assert_eq!(ev.source, EventSource::Programmatic);
4648        assert_eq!(ev.phase, EventPhase::Target);
4649        assert_eq!(ev.target, target);
4650        assert_eq!(ev.current_target, target);
4651        assert_eq!(ev.timestamp, tick(42));
4652        assert!(!ev.is_propagation_stopped());
4653        assert!(!ev.is_immediate_propagation_stopped());
4654        assert!(!ev.is_default_prevented());
4655
4656        // stop_propagation does NOT imply stop_immediate_propagation...
4657        ev.stop_propagation();
4658        assert!(ev.is_propagation_stopped());
4659        assert!(!ev.is_immediate_propagation_stopped());
4660
4661        // ...but the reverse implication MUST hold, or propagate_phase's
4662        // `stopped_immediate` check could be bypassed by the `stopped` fast path.
4663        let mut ev2 = SyntheticEvent::new(
4664            EventType::Click,
4665            EventSource::User,
4666            target,
4667            tick(0),
4668            EventData::None,
4669        );
4670        ev2.stop_immediate_propagation();
4671        assert!(ev2.is_immediate_propagation_stopped());
4672        assert!(
4673            ev2.is_propagation_stopped(),
4674            "immediate stop must also stop normal propagation"
4675        );
4676
4677        // All three flags are idempotent and independent.
4678        let mut ev3 = ev2.clone();
4679        ev3.stop_immediate_propagation();
4680        ev3.prevent_default();
4681        ev3.prevent_default();
4682        assert!(ev3.is_default_prevented());
4683        assert!(!ev.is_default_prevented(), "flags must not leak across events");
4684    }
4685
4686    #[test]
4687    fn hover_filter_is_system_internal_only_for_system_text_clicks() {
4688        for f in [
4689            HoverEventFilter::SystemTextSingleClick,
4690            HoverEventFilter::SystemTextDoubleClick,
4691            HoverEventFilter::SystemTextTripleClick,
4692        ] {
4693            assert!(f.is_system_internal(), "{f:?} is internal");
4694            assert!(
4695                f.to_focus_event_filter().is_none(),
4696                "internal filters must never be exposed as focus callbacks"
4697            );
4698        }
4699        for f in [
4700            HoverEventFilter::MouseOver,
4701            HoverEventFilter::MouseDown,
4702            HoverEventFilter::Drop,
4703            HoverEventFilter::KeyringResult,
4704            HoverEventFilter::MouseOut,
4705        ] {
4706            assert!(!f.is_system_internal(), "{f:?} is a user-visible filter");
4707        }
4708    }
4709
4710    #[test]
4711    fn event_filter_kind_predicates_are_mutually_exclusive() {
4712        let hover = EventFilter::Hover(HoverEventFilter::MouseDown);
4713        let focus = EventFilter::Focus(FocusEventFilter::FocusReceived);
4714        let window = EventFilter::Window(WindowEventFilter::Resized);
4715        let component = EventFilter::Component(ComponentEventFilter::AfterMount);
4716        let app = EventFilter::Application(ApplicationEventFilter::DeviceConnected);
4717
4718        assert!(focus.is_focus_callback());
4719        assert!(window.is_window_callback());
4720        for f in [hover, window, component, app] {
4721            assert!(!f.is_focus_callback(), "{f:?} is not a focus callback");
4722        }
4723        for f in [hover, focus, component, app] {
4724            assert!(!f.is_window_callback(), "{f:?} is not a window callback");
4725        }
4726        // The `as_*` accessors must agree with the predicates.
4727        assert_eq!(hover.as_hover_event_filter(), Some(HoverEventFilter::MouseDown));
4728        assert_eq!(hover.as_focus_event_filter(), None);
4729        assert_eq!(hover.as_window_event_filter(), None);
4730        assert_eq!(focus.as_focus_event_filter(), Some(FocusEventFilter::FocusReceived));
4731        assert_eq!(window.as_window_event_filter(), Some(WindowEventFilter::Resized));
4732        assert_eq!(component.as_hover_event_filter(), None);
4733    }
4734
4735    // ============================================================== round-trips
4736
4737    #[test]
4738    fn window_to_hover_filter_mapping_never_yields_an_internal_filter() {
4739        // Every window filter that has a hover twin must map onto a filter the
4740        // user is actually allowed to register (never a SystemText* internal).
4741        for w in [
4742            WindowEventFilter::MouseOver,
4743            WindowEventFilter::MouseDown,
4744            WindowEventFilter::LeftMouseDown,
4745            WindowEventFilter::RightMouseDown,
4746            WindowEventFilter::MiddleMouseDown,
4747            WindowEventFilter::MouseUp,
4748            WindowEventFilter::LeftMouseUp,
4749            WindowEventFilter::RightMouseUp,
4750            WindowEventFilter::MiddleMouseUp,
4751            WindowEventFilter::Scroll,
4752            WindowEventFilter::TextInput,
4753            WindowEventFilter::VirtualKeyDown,
4754            WindowEventFilter::VirtualKeyUp,
4755            WindowEventFilter::HoveredFile,
4756            WindowEventFilter::DroppedFile,
4757            WindowEventFilter::HoveredFileCancelled,
4758            WindowEventFilter::TouchStart,
4759            WindowEventFilter::TouchEnd,
4760            WindowEventFilter::PenDown,
4761            WindowEventFilter::DragStart,
4762            WindowEventFilter::Drop,
4763            WindowEventFilter::DoubleClick,
4764            WindowEventFilter::PermissionChanged,
4765            WindowEventFilter::BiometricResult,
4766            WindowEventFilter::KeyringResult,
4767        ] {
4768            let hover = w
4769                .to_hover_event_filter()
4770                .unwrap_or_else(|| panic!("{w:?} should have a hover twin"));
4771            assert!(
4772                !hover.is_system_internal(),
4773                "{w:?} must not map onto an internal filter"
4774            );
4775        }
4776
4777        // Window-only events have deliberately NO hover twin.
4778        for w in [
4779            WindowEventFilter::MouseEnter,
4780            WindowEventFilter::MouseLeave,
4781            WindowEventFilter::Resized,
4782            WindowEventFilter::Moved,
4783            WindowEventFilter::FocusReceived,
4784            WindowEventFilter::FocusLost,
4785            WindowEventFilter::CloseRequested,
4786            WindowEventFilter::ThemeChanged,
4787            WindowEventFilter::WindowFocusReceived,
4788            WindowEventFilter::WindowFocusLost,
4789            WindowEventFilter::DpiChanged,
4790            WindowEventFilter::MonitorChanged,
4791        ] {
4792            assert_eq!(
4793                w.to_hover_event_filter(),
4794                None,
4795                "{w:?} is window-specific and must not map to a hover filter"
4796            );
4797        }
4798    }
4799
4800    #[test]
4801    fn window_hover_focus_filter_names_round_trip() {
4802        // Window -> Hover -> Focus must preserve the *identity* of the event for
4803        // the shared (mouse / key / drag) subset — a mismatched row here means a
4804        // callback registered as Focus(X) would fire for hover event Y.
4805        let pairs = [
4806            (
4807                WindowEventFilter::MouseOver,
4808                HoverEventFilter::MouseOver,
4809                Some(FocusEventFilter::MouseOver),
4810            ),
4811            (
4812                WindowEventFilter::LeftMouseDown,
4813                HoverEventFilter::LeftMouseDown,
4814                Some(FocusEventFilter::LeftMouseDown),
4815            ),
4816            (
4817                WindowEventFilter::RightMouseUp,
4818                HoverEventFilter::RightMouseUp,
4819                Some(FocusEventFilter::RightMouseUp),
4820            ),
4821            (
4822                WindowEventFilter::TextInput,
4823                HoverEventFilter::TextInput,
4824                Some(FocusEventFilter::TextInput),
4825            ),
4826            (
4827                WindowEventFilter::VirtualKeyDown,
4828                HoverEventFilter::VirtualKeyDown,
4829                Some(FocusEventFilter::VirtualKeyDown),
4830            ),
4831            (
4832                WindowEventFilter::DragStart,
4833                HoverEventFilter::DragStart,
4834                Some(FocusEventFilter::DragStart),
4835            ),
4836            (
4837                WindowEventFilter::Drop,
4838                HoverEventFilter::Drop,
4839                Some(FocusEventFilter::Drop),
4840            ),
4841            // File events exist on window + hover, but have no focus twin.
4842            (
4843                WindowEventFilter::DroppedFile,
4844                HoverEventFilter::DroppedFile,
4845                None,
4846            ),
4847            (
4848                WindowEventFilter::TouchStart,
4849                HoverEventFilter::TouchStart,
4850                None,
4851            ),
4852        ];
4853        for (w, h, f) in pairs {
4854            assert_eq!(w.to_hover_event_filter(), Some(h), "window->hover for {w:?}");
4855            assert_eq!(h.to_focus_event_filter(), f, "hover->focus for {h:?}");
4856        }
4857    }
4858
4859    #[test]
4860    fn on_to_event_filter_conversion_is_stable() {
4861        use crate::dom::On;
4862        // On::TextInput / FocusReceived / FocusLost are FOCUS filters, and the
4863        // virtual-key events are WINDOW filters — everything else is Hover.
4864        assert_eq!(
4865            EventFilter::from(On::TextInput),
4866            EventFilter::Focus(FocusEventFilter::TextInput)
4867        );
4868        assert_eq!(
4869            EventFilter::from(On::VirtualKeyDown),
4870            EventFilter::Window(WindowEventFilter::VirtualKeyDown)
4871        );
4872        assert_eq!(
4873            EventFilter::from(On::MouseOver),
4874            EventFilter::Hover(HoverEventFilter::MouseOver)
4875        );
4876        // The a11y actions all collapse onto "click" (= MouseUp).
4877        for on in [On::Default, On::Collapse, On::Expand, On::Increment, On::Decrement] {
4878            assert_eq!(
4879                EventFilter::from(on),
4880                EventFilter::Hover(HoverEventFilter::MouseUp),
4881                "{on:?} must map to the click filter"
4882            );
4883        }
4884        assert!(EventFilter::from(On::TextInput).is_focus_callback());
4885        assert!(EventFilter::from(On::VirtualKeyUp).is_window_callback());
4886    }
4887
4888    #[test]
4889    fn virtual_keycode_round_trips_for_every_key_events_rs_interprets() {
4890        // handle_key_down decodes `KeyboardEventData.key_code` with `from_u32`,
4891        // while producers write `vk as u32`. If that round-trip ever breaks, every
4892        // shortcut silently dies — so pin it for the keys this module interprets.
4893        for vk in [
4894            VirtualKeyCode::Left,
4895            VirtualKeyCode::Right,
4896            VirtualKeyCode::Up,
4897            VirtualKeyCode::Down,
4898            VirtualKeyCode::Home,
4899            VirtualKeyCode::End,
4900            VirtualKeyCode::Back,
4901            VirtualKeyCode::Delete,
4902            VirtualKeyCode::A,
4903            VirtualKeyCode::C,
4904            VirtualKeyCode::D,
4905            VirtualKeyCode::V,
4906            VirtualKeyCode::X,
4907            VirtualKeyCode::Y,
4908            VirtualKeyCode::Z,
4909        ] {
4910            assert_eq!(
4911                VirtualKeyCode::from_u32(vk as u32),
4912                Some(vk),
4913                "{vk:?} must survive the as-u32 / from_u32 round trip"
4914            );
4915        }
4916        // Out-of-range key codes must decode to None rather than index out of bounds.
4917        assert_eq!(VirtualKeyCode::from_u32(u32::MAX), None);
4918        assert_eq!(VirtualKeyCode::from_u32(100_000), None);
4919    }
4920
4921    // ============================================ ArrowDirection / KeyboardShortcut
4922
4923    #[test]
4924    fn arrow_direction_from_key_is_total_over_every_decodable_key() {
4925        // Fuzz every decodable key code (plus the undecodable tail) through both
4926        // key mappers: they must never panic and must only claim the nav keys.
4927        let nav = [
4928            VirtualKeyCode::Left,
4929            VirtualKeyCode::Right,
4930            VirtualKeyCode::Up,
4931            VirtualKeyCode::Down,
4932            VirtualKeyCode::Home,
4933            VirtualKeyCode::End,
4934        ];
4935        for raw in 0u32..1024 {
4936            let Some(vk) = VirtualKeyCode::from_u32(raw) else {
4937                continue;
4938            };
4939            for ctrl in [false, true] {
4940                let got = ArrowDirection::from_key(vk, ctrl);
4941                assert_eq!(
4942                    got.is_some(),
4943                    nav.contains(&vk),
4944                    "{vk:?} (ctrl={ctrl}) must map to an ArrowDirection iff it is a nav key"
4945                );
4946                if let Some(dir) = got {
4947                    // to_selection is total and never panics for any (dir, ctrl).
4948                    let (_d, _s) = dir.to_selection(ctrl);
4949                }
4950            }
4951        }
4952    }
4953
4954    #[test]
4955    fn arrow_direction_ctrl_only_upgrades_horizontal_arrows_to_words() {
4956        // ctrl must upgrade Left/Right to Word steps, and must NOT change the
4957        // step for Up/Down/Home/End (those are already line/document scoped).
4958        for (dir, expect_no_ctrl, expect_ctrl) in [
4959            (
4960                ArrowDirection::Left,
4961                (SelectionDirection::Backward, SelectionStep::Character),
4962                (SelectionDirection::Backward, SelectionStep::Word),
4963            ),
4964            (
4965                ArrowDirection::Right,
4966                (SelectionDirection::Forward, SelectionStep::Character),
4967                (SelectionDirection::Forward, SelectionStep::Word),
4968            ),
4969            (
4970                ArrowDirection::Up,
4971                (SelectionDirection::Backward, SelectionStep::VisualLine),
4972                (SelectionDirection::Backward, SelectionStep::VisualLine),
4973            ),
4974            (
4975                ArrowDirection::Down,
4976                (SelectionDirection::Forward, SelectionStep::VisualLine),
4977                (SelectionDirection::Forward, SelectionStep::VisualLine),
4978            ),
4979            (
4980                ArrowDirection::LineStart,
4981                (SelectionDirection::Backward, SelectionStep::Line),
4982                (SelectionDirection::Backward, SelectionStep::Line),
4983            ),
4984            (
4985                ArrowDirection::DocumentEnd,
4986                (SelectionDirection::Forward, SelectionStep::Document),
4987                (SelectionDirection::Forward, SelectionStep::Document),
4988            ),
4989        ] {
4990            assert_eq!(dir.to_selection(false), expect_no_ctrl, "{dir:?} plain");
4991            assert_eq!(dir.to_selection(true), expect_ctrl, "{dir:?} + ctrl");
4992        }
4993        // Ctrl+Home/End are distinct DIRECTIONS (not a step upgrade).
4994        assert_eq!(
4995            ArrowDirection::from_key(VirtualKeyCode::Home, true),
4996            Some(ArrowDirection::DocumentStart)
4997        );
4998        assert_eq!(
4999            ArrowDirection::from_key(VirtualKeyCode::End, true),
5000            Some(ArrowDirection::DocumentEnd)
5001        );
5002    }
5003
5004    #[test]
5005    fn keyboard_shortcut_from_key_requires_primary_for_every_key() {
5006        // Without the primary modifier NO key may produce a shortcut — otherwise
5007        // typing plain "c" into a text field would copy.
5008        for raw in 0u32..1024 {
5009            let Some(vk) = VirtualKeyCode::from_u32(raw) else {
5010                continue;
5011            };
5012            for shift in [false, true] {
5013                assert_eq!(
5014                    KeyboardShortcut::from_key(vk, false, shift),
5015                    None,
5016                    "{vk:?} (shift={shift}) must need the primary modifier"
5017                );
5018            }
5019        }
5020        // With primary held, exactly the editing set is recognised.
5021        assert_eq!(
5022            KeyboardShortcut::from_key(VirtualKeyCode::Z, true, true),
5023            Some(KeyboardShortcut::Redo),
5024            "primary+shift+Z is Redo, not Undo"
5025        );
5026        assert_eq!(
5027            KeyboardShortcut::from_key(VirtualKeyCode::Y, true, true),
5028            Some(KeyboardShortcut::Redo),
5029            "shift must not disturb primary+Y"
5030        );
5031        assert_eq!(
5032            KeyboardShortcut::from_key(VirtualKeyCode::C, true, true),
5033            Some(KeyboardShortcut::Copy),
5034            "shift must not disturb primary+C"
5035        );
5036        // D is handled separately (SelectNextOccurrence), not as a KeyboardShortcut.
5037        assert_eq!(KeyboardShortcut::from_key(VirtualKeyCode::D, true, false), None);
5038    }
5039
5040    #[test]
5041    fn selection_op_new_defaults_to_a_single_repeat() {
5042        let op = SelectionOp::new(
5043            SelectionDirection::Forward,
5044            SelectionStep::Word,
5045            SelectionMode::Delete,
5046        );
5047        assert_eq!(op.direction, SelectionDirection::Forward);
5048        assert_eq!(op.step, SelectionStep::Word);
5049        assert_eq!(op.mode, SelectionMode::Delete);
5050        assert_eq!(op.repeat, 1, "a fresh op must apply exactly once");
5051    }
5052
5053    // ================================================== filter/phase matching
5054
5055    #[test]
5056    fn capture_phase_never_matches_any_filter() {
5057        // Regression guard: azul has no capture listeners. If this breaks, every
5058        // ancestor callback fires TWICE (once capturing, once bubbling).
5059        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5060        for filter in [
5061            EventFilter::Hover(HoverEventFilter::MouseDown),
5062            EventFilter::Hover(HoverEventFilter::LeftMouseDown),
5063            EventFilter::Focus(FocusEventFilter::MouseDown),
5064            EventFilter::Window(WindowEventFilter::MouseDown),
5065            EventFilter::Component(ComponentEventFilter::AfterMount),
5066            EventFilter::Application(ApplicationEventFilter::DeviceConnected),
5067        ] {
5068            assert!(
5069                !matches_filter_phase(filter, &ev, EventPhase::Capture),
5070                "{filter:?} must not match in the capture phase"
5071            );
5072        }
5073        // ...but the same filter DOES match at Target and Bubble.
5074        for phase in [EventPhase::Target, EventPhase::Bubble] {
5075            assert!(matches_filter_phase(
5076                EventFilter::Hover(HoverEventFilter::MouseDown),
5077                &ev,
5078                phase
5079            ));
5080        }
5081    }
5082
5083    #[test]
5084    fn application_filters_never_match_yet() {
5085        // Documented stub: Application events are not routed through propagation.
5086        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5087        for phase in [EventPhase::Capture, EventPhase::Target, EventPhase::Bubble] {
5088            assert!(!matches_filter_phase(
5089                EventFilter::Application(ApplicationEventFilter::MonitorConnected),
5090                &ev,
5091                phase
5092            ));
5093        }
5094    }
5095
5096    #[test]
5097    fn check_mouse_button_is_false_for_every_non_mouse_payload() {
5098        for data in [
5099            EventData::None,
5100            EventData::Keyboard(KeyboardEventData {
5101                key_code: 0,
5102                char_code: None,
5103                modifiers: KeyModifiers::default(),
5104                repeat: false,
5105            }),
5106            EventData::Touch(TouchEventData {
5107                id: u64::MAX,
5108                position: LogicalPosition::zero(),
5109                force: f32::NAN,
5110            }),
5111            EventData::Clipboard(ClipboardEventData { content: None }),
5112        ] {
5113            for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
5114                assert!(
5115                    !check_mouse_button(&data, button),
5116                    "non-mouse payload must never claim a button"
5117                );
5118            }
5119        }
5120        // Exotic button ids compare by value, including the u8 boundary.
5121        let other_max = EventData::Mouse(MouseEventData {
5122            position: LogicalPosition::zero(),
5123            button: MouseButton::Other(u8::MAX),
5124            buttons: u8::MAX,
5125            modifiers: KeyModifiers::default(),
5126        });
5127        assert!(check_mouse_button(&other_max, MouseButton::Other(u8::MAX)));
5128        assert!(!check_mouse_button(&other_max, MouseButton::Other(0)));
5129        assert!(!check_mouse_button(&other_max, MouseButton::Left));
5130    }
5131
5132    #[test]
5133    fn button_specific_filters_require_the_matching_button() {
5134        let left = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5135        let right = mouse_event(EventType::MouseDown, MouseButton::Right, LogicalPosition::zero());
5136        let middle = mouse_event(EventType::MouseDown, MouseButton::Middle, LogicalPosition::zero());
5137
5138        // The generic filter fires for every button...
5139        for ev in [&left, &right, &middle] {
5140            assert!(matches_hover_filter(
5141                HoverEventFilter::MouseDown,
5142                ev,
5143                EventPhase::Target
5144            ));
5145        }
5146        // ...the specific ones only for theirs.
5147        assert!(matches_hover_filter(HoverEventFilter::LeftMouseDown, &left, EventPhase::Target));
5148        assert!(!matches_hover_filter(HoverEventFilter::LeftMouseDown, &right, EventPhase::Target));
5149        assert!(matches_hover_filter(HoverEventFilter::RightMouseDown, &right, EventPhase::Target));
5150        assert!(!matches_hover_filter(HoverEventFilter::MiddleMouseDown, &right, EventPhase::Target));
5151        assert!(matches_hover_filter(HoverEventFilter::MiddleMouseDown, &middle, EventPhase::Target));
5152
5153        // A MouseDown filter must never fire on a MouseUp event and vice versa.
5154        let up = mouse_event(EventType::MouseUp, MouseButton::Left, LogicalPosition::zero());
5155        assert!(!matches_hover_filter(HoverEventFilter::MouseDown, &up, EventPhase::Target));
5156        assert!(!matches_hover_filter(HoverEventFilter::MouseUp, &left, EventPhase::Target));
5157        assert!(matches_focus_filter(FocusEventFilter::LeftMouseUp, &up, EventPhase::Target));
5158        assert!(matches_window_filter(WindowEventFilter::LeftMouseUp, &up, EventPhase::Target));
5159
5160        // A MouseDown event carrying a NON-mouse payload cannot satisfy a
5161        // button-specific filter (there is no button to compare against).
5162        let payloadless = SyntheticEvent::new(
5163            EventType::MouseDown,
5164            EventSource::Synthetic,
5165            dnid(0, 0),
5166            tick(0),
5167            EventData::None,
5168        );
5169        assert!(matches_hover_filter(HoverEventFilter::MouseDown, &payloadless, EventPhase::Target));
5170        assert!(!matches_hover_filter(
5171            HoverEventFilter::LeftMouseDown,
5172            &payloadless,
5173            EventPhase::Target
5174        ));
5175    }
5176
5177    #[test]
5178    fn component_filter_matches_only_its_own_lifecycle_event() {
5179        let lifecycle = |ty: EventType| {
5180            SyntheticEvent::new(ty, EventSource::Lifecycle, dnid(0, 0), tick(0), EventData::None)
5181        };
5182        let pairs = [
5183            (ComponentEventFilter::AfterMount, EventType::Mount),
5184            (ComponentEventFilter::BeforeUnmount, EventType::Unmount),
5185            (ComponentEventFilter::Updated, EventType::Update),
5186            (ComponentEventFilter::NodeResized, EventType::Resize),
5187        ];
5188        for (filter, ty) in pairs {
5189            let ev = lifecycle(ty);
5190            assert!(
5191                matches_component_filter(filter, &ev, EventPhase::Target),
5192                "{filter:?} must match {ty:?}"
5193            );
5194            // ...and must NOT match any of the other lifecycle event types.
5195            for (_, other_ty) in pairs.iter().filter(|(_, t)| *t != ty) {
5196                assert!(
5197                    !matches_component_filter(filter, &lifecycle(*other_ty), EventPhase::Target),
5198                    "{filter:?} must not match {other_ty:?}"
5199                );
5200            }
5201        }
5202        // DefaultAction / Selected have no EventType twin: they must never match
5203        // a lifecycle event (they are driven by the a11y layer instead).
5204        for filter in [ComponentEventFilter::DefaultAction, ComponentEventFilter::Selected] {
5205            for (_, ty) in pairs {
5206                assert!(!matches_component_filter(filter, &lifecycle(ty), EventPhase::Target));
5207            }
5208        }
5209    }
5210
5211    #[test]
5212    fn event_type_to_filters_never_panics_and_stays_synced_with_the_hover_matcher() {
5213        // ROUND-TRIP INVARIANT: a Hover filter emitted by `event_type_to_filters`
5214        // is later re-checked by `matches_filter_phase` inside `propagate_event`
5215        // (see shell2/common/event.rs). If the two tables disagree, the callback
5216        // is collected and then silently dropped — a dead filter.
5217        //
5218        // KNOWN_DESYNC records the pairs that are ALREADY broken today (reported
5219        // separately). The assertion is a *subset* check, so fixing one of them
5220        // keeps this test green while any NEW desync fails it.
5221        const KNOWN_DESYNC: &[EventType] = &[
5222            EventType::Click,             // -> Hover(LeftMouseUp), matcher wants EventType::MouseUp
5223            EventType::ContextMenu,       // -> Hover(RightMouseDown), matcher wants MouseDown
5224            EventType::MouseOut,          // -> Hover(MouseOut), matcher has no MouseOut arm
5225            EventType::ScrollStart,       // -> Hover(Scroll), matcher wants EventType::Scroll
5226            EventType::ScrollEnd,         // -> Hover(Scroll), matcher wants EventType::Scroll
5227            EventType::FocusIn,           // -> Hover(FocusIn), matcher has no FocusIn arm
5228            EventType::FocusOut,          // -> Hover(FocusOut), matcher has no FocusOut arm
5229            EventType::CompositionStart,  // -> Hover(CompositionStart), no arm
5230            EventType::CompositionUpdate, // -> Hover(CompositionUpdate), no arm
5231            EventType::CompositionEnd,    // -> Hover(CompositionEnd), no arm
5232        ];
5233
5234        let mouse_data = EventData::Mouse(MouseEventData {
5235            position: LogicalPosition::new(1.0, 1.0),
5236            button: MouseButton::Left,
5237            buttons: 1,
5238            modifiers: KeyModifiers::default(),
5239        });
5240
5241        let cases: Vec<(EventType, EventData)> = vec![
5242            (EventType::MouseOver, EventData::None),
5243            (EventType::MouseEnter, EventData::None),
5244            (EventType::MouseLeave, EventData::None),
5245            (EventType::MouseOut, EventData::None),
5246            (EventType::MouseDown, mouse_data.clone()),
5247            (EventType::MouseUp, mouse_data.clone()),
5248            (EventType::Click, mouse_data.clone()),
5249            (EventType::DoubleClick, mouse_data.clone()),
5250            (EventType::ContextMenu, mouse_data.clone()),
5251            (EventType::KeyDown, EventData::None),
5252            (EventType::KeyUp, EventData::None),
5253            (EventType::KeyPress, EventData::None),
5254            (EventType::CompositionStart, EventData::None),
5255            (EventType::CompositionUpdate, EventData::None),
5256            (EventType::CompositionEnd, EventData::None),
5257            (EventType::Focus, EventData::None),
5258            (EventType::Blur, EventData::None),
5259            (EventType::FocusIn, EventData::None),
5260            (EventType::FocusOut, EventData::None),
5261            (EventType::Input, EventData::None),
5262            (EventType::Change, EventData::None),
5263            (EventType::Scroll, EventData::None),
5264            (EventType::ScrollStart, EventData::None),
5265            (EventType::ScrollEnd, EventData::None),
5266            (EventType::DragStart, EventData::None),
5267            (EventType::Drag, EventData::None),
5268            (EventType::DragEnd, EventData::None),
5269            (EventType::DragEnter, EventData::None),
5270            (EventType::DragOver, EventData::None),
5271            (EventType::DragLeave, EventData::None),
5272            (EventType::Drop, EventData::None),
5273            (EventType::TouchStart, EventData::None),
5274            (EventType::TouchMove, EventData::None),
5275            (EventType::TouchEnd, EventData::None),
5276            (EventType::TouchCancel, EventData::None),
5277            (EventType::Mount, EventData::None),
5278            (EventType::Unmount, EventData::None),
5279            (EventType::Update, EventData::None),
5280            (EventType::Resize, EventData::None),
5281            (EventType::WindowResize, EventData::None),
5282            (EventType::WindowMove, EventData::None),
5283            (EventType::WindowClose, EventData::None),
5284            (EventType::ThemeChange, EventData::None),
5285            (EventType::FileHover, EventData::None),
5286            (EventType::FileDrop, EventData::None),
5287            (EventType::FileHoverCancel, EventData::None),
5288            (EventType::Copy, EventData::None),
5289            (EventType::Cut, EventData::None),
5290            (EventType::Paste, EventData::None),
5291            (EventType::SensorChanged, EventData::None),
5292            (EventType::GamepadInput, EventData::None),
5293            (EventType::GeolocationFix, EventData::None),
5294            (EventType::GeolocationError, EventData::None),
5295            (EventType::PermissionChanged, EventData::None),
5296            (EventType::BiometricResult, EventData::None),
5297            (EventType::KeyringResult, EventData::None),
5298            (EventType::LongPress, EventData::None),
5299            (EventType::Play, EventData::None),
5300        ];
5301
5302        for (ty, data) in cases {
5303            let filters = event_type_to_filters(ty, &data);
5304            let ev = SyntheticEvent::new(ty, EventSource::User, dnid(0, 0), tick(0), data);
5305
5306            // No duplicate filters — a duplicate would invoke the callback twice.
5307            let mut seen = BTreeSet::new();
5308            for f in &filters {
5309                assert!(seen.insert(*f), "{ty:?} emitted {f:?} twice");
5310            }
5311
5312            for f in &filters {
5313                if !matches!(f, EventFilter::Hover(_)) {
5314                    continue; // only Hover filters are re-checked by propagate_event
5315                }
5316                if matches_filter_phase(*f, &ev, EventPhase::Target) {
5317                    continue;
5318                }
5319                assert!(
5320                    KNOWN_DESYNC.contains(&ty),
5321                    "NEW DESYNC: event_type_to_filters({ty:?}) emits {f:?}, but \
5322                     matches_filter_phase rejects it at the Target phase, so the \
5323                     callback would be collected and then silently dropped"
5324                );
5325            }
5326        }
5327    }
5328
5329    #[test]
5330    fn event_type_to_filters_omits_button_specific_filter_for_exotic_buttons() {
5331        // MouseButton::Other(n) has no dedicated filter: only the generic one.
5332        let data = EventData::Mouse(MouseEventData {
5333            position: LogicalPosition::zero(),
5334            button: MouseButton::Other(u8::MAX),
5335            buttons: 0,
5336            modifiers: KeyModifiers::default(),
5337        });
5338        let down = event_type_to_filters(EventType::MouseDown, &data);
5339        assert_eq!(down, vec![EventFilter::Hover(HoverEventFilter::MouseDown)]);
5340        let up = event_type_to_filters(EventType::MouseUp, &data);
5341        assert_eq!(up, vec![EventFilter::Hover(HoverEventFilter::MouseUp)]);
5342
5343        // Unmapped event types produce an empty filter list (never a panic).
5344        for ty in [
5345            EventType::Submit,
5346            EventType::Reset,
5347            EventType::Invalid,
5348            EventType::Play,
5349            EventType::Pause,
5350            EventType::Ended,
5351            EventType::TimeUpdate,
5352            EventType::VolumeChange,
5353            EventType::MediaError,
5354            EventType::PinchIn,
5355            EventType::RotateClockwise,
5356            EventType::SwipeLeft,
5357        ] {
5358            assert!(
5359                event_type_to_filters(ty, &EventData::None).is_empty(),
5360                "{ty:?} is unmapped and must yield no filters"
5361            );
5362        }
5363    }
5364
5365    // ================================================== DOM path / propagation
5366
5367    #[test]
5368    fn get_dom_path_none_target_yields_empty_path() {
5369        let hier = hierarchy_chain(3);
5370        assert!(get_dom_path(&hier, NodeHierarchyItemId::NONE).is_empty());
5371        // An empty hierarchy with a real target must not index out of bounds.
5372        let empty = NodeHierarchy::new(Vec::new());
5373        let path = get_dom_path(&empty, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
5374        assert_eq!(path, vec![NodeId::ZERO], "unknown nodes still path to themselves");
5375    }
5376
5377    #[test]
5378    fn get_dom_path_out_of_range_target_does_not_panic() {
5379        let hier = hierarchy_chain(3);
5380        let huge = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(usize::MAX - 1)));
5381        let path = get_dom_path(&hier, huge);
5382        assert_eq!(path, vec![NodeId::new(usize::MAX - 1)]);
5383    }
5384
5385    #[test]
5386    fn get_dom_path_returns_root_to_target_order() {
5387        let hier = hierarchy_chain(4); // 0 <- 1 <- 2 <- 3
5388        let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(3))));
5389        assert_eq!(
5390            path,
5391            vec![NodeId::new(0), NodeId::new(1), NodeId::new(2), NodeId::new(3)],
5392            "path must run root -> target"
5393        );
5394        // The root itself paths to a single-element vec.
5395        let root_path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
5396        assert_eq!(root_path, vec![NodeId::ZERO]);
5397    }
5398
5399    #[test]
5400    fn get_dom_path_terminates_on_a_self_parent_cycle() {
5401        // A node that is its own parent must not spin forever.
5402        let hier = NodeHierarchy::new(vec![Node {
5403            parent: Some(NodeId::ZERO),
5404            ..Node::ROOT
5405        }]);
5406        let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
5407        assert_eq!(path, vec![NodeId::ZERO]);
5408    }
5409
5410    #[test]
5411    fn get_dom_path_handles_a_deep_chain_without_recursing() {
5412        // 5000 levels deep: an iterative walk copes, a recursive one would blow
5413        // the stack.
5414        let hier = hierarchy_chain(5000);
5415        let path = get_dom_path(
5416            &hier,
5417            NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(4999))),
5418        );
5419        assert_eq!(path.len(), 5000);
5420        assert_eq!(path[0], NodeId::ZERO);
5421        assert_eq!(path[4999], NodeId::new(4999));
5422    }
5423
5424    #[test]
5425    fn propagate_event_visits_each_node_exactly_once() {
5426        // Regression guard for the double-fire bug: with capture + bubble both
5427        // walking the ancestors, a node's callback used to be collected TWICE.
5428        let hier = hierarchy_chain(3); // 0 <- 1 <- 2
5429        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5430        for i in 0..3 {
5431            callbacks.insert(
5432                NodeId::new(i),
5433                vec![EventFilter::Hover(HoverEventFilter::MouseDown)],
5434            );
5435        }
5436        let mut ev = SyntheticEvent::new(
5437            EventType::MouseDown,
5438            EventSource::User,
5439            dnid(0, 2),
5440            tick(0),
5441            EventData::Mouse(MouseEventData {
5442                position: LogicalPosition::zero(),
5443                button: MouseButton::Left,
5444                buttons: 1,
5445                modifiers: KeyModifiers::default(),
5446            }),
5447        );
5448
5449        let result = propagate_event(&mut ev, &hier, &callbacks);
5450        let nodes: Vec<NodeId> = result.callbacks_to_invoke.iter().map(|(n, _)| *n).collect();
5451        assert_eq!(
5452            nodes,
5453            vec![NodeId::new(2), NodeId::new(1), NodeId::new(0)],
5454            "target first, then bubbling up to the root — each node once"
5455        );
5456        assert!(!result.default_prevented);
5457    }
5458
5459    #[test]
5460    fn propagate_event_on_a_dangling_target_is_a_no_op() {
5461        let hier = hierarchy_chain(2);
5462        let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5463
5464        // Target = the `None` sentinel: the doc comment claims a panic, but the
5465        // implementation returns a default result. Pin the safe behavior.
5466        let mut ev = SyntheticEvent::new(
5467            EventType::MouseDown,
5468            EventSource::User,
5469            dnid_none(0),
5470            tick(0),
5471            EventData::None,
5472        );
5473        let result = propagate_event(&mut ev, &hier, &callbacks);
5474        assert!(result.callbacks_to_invoke.is_empty());
5475        assert!(!result.default_prevented);
5476
5477        // Target = a node id far outside the hierarchy: also a no-op, no panic.
5478        let mut ev = SyntheticEvent::new(
5479            EventType::MouseDown,
5480            EventSource::User,
5481            dnid(0, 10_000),
5482            tick(0),
5483            EventData::None,
5484        );
5485        let result = propagate_event(&mut ev, &hier, &callbacks);
5486        assert!(result.callbacks_to_invoke.is_empty());
5487    }
5488
5489    #[test]
5490    fn propagate_event_respects_a_pre_stopped_event() {
5491        let hier = hierarchy_chain(3);
5492        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5493        for i in 0..3 {
5494            callbacks.insert(
5495                NodeId::new(i),
5496                vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
5497            );
5498        }
5499        let base = SyntheticEvent::new(
5500            EventType::MouseOver,
5501            EventSource::User,
5502            dnid(0, 2),
5503            tick(0),
5504            EventData::None,
5505        );
5506
5507        // stopped => neither target nor bubble collect anything.
5508        let mut stopped = base.clone();
5509        stopped.stop_propagation();
5510        let r = propagate_event(&mut stopped, &hier, &callbacks);
5511        assert!(r.callbacks_to_invoke.is_empty(), "a stopped event collects nothing");
5512
5513        // stopped_immediate => likewise (and it implies `stopped`).
5514        let mut immediate = base.clone();
5515        immediate.stop_immediate_propagation();
5516        let r = propagate_event(&mut immediate, &hier, &callbacks);
5517        assert!(r.callbacks_to_invoke.is_empty());
5518
5519        // prevented_default is faithfully reported back out.
5520        let mut prevented = base;
5521        prevented.prevent_default();
5522        let r = propagate_event(&mut prevented, &hier, &callbacks);
5523        assert!(r.default_prevented);
5524        assert_eq!(r.callbacks_to_invoke.len(), 3, "preventDefault must not stop dispatch");
5525    }
5526
5527    #[test]
5528    fn propagate_event_ignores_filters_that_do_not_match_the_event() {
5529        let hier = hierarchy_chain(2);
5530        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5531        callbacks.insert(
5532            NodeId::new(1),
5533            vec![
5534                EventFilter::Hover(HoverEventFilter::MouseUp), // wrong event type
5535                EventFilter::Hover(HoverEventFilter::RightMouseDown), // wrong button
5536                EventFilter::Hover(HoverEventFilter::LeftMouseDown), // match
5537            ],
5538        );
5539        let mut ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5540        ev.target = dnid(0, 1);
5541        ev.current_target = ev.target;
5542
5543        let r = propagate_event(&mut ev, &hier, &callbacks);
5544        assert_eq!(
5545            r.callbacks_to_invoke,
5546            vec![(
5547                NodeId::new(1),
5548                EventFilter::Hover(HoverEventFilter::LeftMouseDown)
5549            )]
5550        );
5551        // The event is left in the state of the LAST walked phase: bubble, ending
5552        // on the root ancestor. (`current_target` is only meaningful while a
5553        // callback is running, so this pins the post-walk residue rather than
5554        // asserting it is reset.)
5555        assert_eq!(ev.phase, EventPhase::Bubble);
5556        assert_eq!(ev.current_target, dnid(0, 0));
5557        assert_eq!(ev.target, dnid(0, 1), "the target itself must never be rewritten");
5558    }
5559
5560    #[test]
5561    fn collect_matching_callbacks_collects_nothing_once_immediate_stop_is_set() {
5562        let mut result = PropagationResult::default();
5563        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5564        callbacks.insert(
5565            NodeId::ZERO,
5566            vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
5567        );
5568        let mut ev = SyntheticEvent::new(
5569            EventType::MouseOver,
5570            EventSource::User,
5571            dnid(0, 0),
5572            tick(0),
5573            EventData::None,
5574        );
5575        ev.stop_immediate_propagation();
5576        collect_matching_callbacks(&ev, NodeId::ZERO, EventPhase::Target, &callbacks, &mut result);
5577        assert!(result.callbacks_to_invoke.is_empty());
5578
5579        // A node with no registered callbacks is simply skipped.
5580        let mut fresh = PropagationResult::default();
5581        let clean = SyntheticEvent::new(
5582            EventType::MouseOver,
5583            EventSource::User,
5584            dnid(0, 0),
5585            tick(0),
5586            EventData::None,
5587        );
5588        collect_matching_callbacks(&clean, NodeId::new(9), EventPhase::Target, &callbacks, &mut fresh);
5589        assert!(fresh.callbacks_to_invoke.is_empty());
5590    }
5591
5592    #[test]
5593    fn propagate_phase_over_an_empty_iterator_only_sets_the_phase() {
5594        let mut result = PropagationResult::default();
5595        let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
5596        let mut ev = SyntheticEvent::new(
5597            EventType::MouseOver,
5598            EventSource::User,
5599            dnid(0, 0),
5600            tick(0),
5601            EventData::None,
5602        );
5603        propagate_phase(
5604            &mut ev,
5605            core::iter::empty(),
5606            EventPhase::Bubble,
5607            &callbacks,
5608            &mut result,
5609        );
5610        assert_eq!(ev.phase, EventPhase::Bubble);
5611        assert!(result.callbacks_to_invoke.is_empty());
5612
5613        // propagate_target_phase resets phase + current_target to the target.
5614        propagate_target_phase(&mut ev, NodeId::ZERO, &callbacks, &mut result);
5615        assert_eq!(ev.phase, EventPhase::Target);
5616        assert_eq!(ev.current_target, ev.target);
5617    }
5618
5619    // ================================================================== dedup
5620
5621    #[test]
5622    fn deduplicate_synthetic_events_handles_empty_and_single() {
5623        assert!(deduplicate_synthetic_events(Vec::new()).is_empty());
5624        let one = vec![SyntheticEvent::new(
5625            EventType::Scroll,
5626            EventSource::User,
5627            dnid(0, 0),
5628            tick(1),
5629            EventData::None,
5630        )];
5631        assert_eq!(deduplicate_synthetic_events(one).len(), 1);
5632    }
5633
5634    #[test]
5635    fn deduplicate_synthetic_events_keeps_the_latest_timestamp_per_target_and_type() {
5636        let mk = |node: usize, ty: EventType, t: u64| {
5637            SyntheticEvent::new(ty, EventSource::User, dnid(0, node), tick(t), EventData::None)
5638        };
5639        // Same (target, type), out-of-order timestamps -> keep the newest.
5640        let events = vec![
5641            mk(1, EventType::Scroll, 5),
5642            mk(1, EventType::Scroll, 99),
5643            mk(1, EventType::Scroll, 1),
5644        ];
5645        let out = deduplicate_synthetic_events(events);
5646        assert_eq!(out.len(), 1);
5647        assert_eq!(out[0].timestamp, tick(99), "the newest event must survive");
5648
5649        // Different node OR different type -> both survive.
5650        let events = vec![
5651            mk(1, EventType::Scroll, 1),
5652            mk(2, EventType::Scroll, 1),
5653            mk(1, EventType::MouseOver, 1),
5654        ];
5655        assert_eq!(deduplicate_synthetic_events(events).len(), 3);
5656
5657        // Different DOM with the same node index -> distinct targets.
5658        let a = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(0, 1), tick(0), EventData::None);
5659        let b = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(1, 1), tick(0), EventData::None);
5660        assert_eq!(deduplicate_synthetic_events(vec![a, b]).len(), 2);
5661    }
5662
5663    #[test]
5664    fn deduplicate_synthetic_events_collapses_a_large_duplicate_burst() {
5665        // 10k identical events (e.g. a scroll storm) must collapse to one, and
5666        // the result must be the newest — no quadratic blowup, no overflow.
5667        let events: Vec<SyntheticEvent> = (0..10_000u64)
5668            .map(|t| {
5669                SyntheticEvent::new(
5670                    EventType::Scroll,
5671                    EventSource::User,
5672                    dnid(0, 0),
5673                    tick(t),
5674                    EventData::None,
5675                )
5676            })
5677            .collect();
5678        let out = deduplicate_synthetic_events(events);
5679        assert_eq!(out.len(), 1);
5680        assert_eq!(out[0].timestamp, tick(9_999));
5681    }
5682
5683    #[test]
5684    fn deduplicate_synthetic_events_preserves_unicode_payloads() {
5685        // Deduplication keys off (target, event_type) only — the payload must
5686        // survive untouched, including multi-byte / combining / RTL text.
5687        let text = "🦀 グラフ é\u{0301} مرحبا \u{1F1E6}\u{1F1F9}".repeat(200);
5688        let ev = SyntheticEvent::new(
5689            EventType::Input,
5690            EventSource::User,
5691            dnid(0, 0),
5692            tick(1),
5693            EventData::TextInput(TextInputEventData {
5694                inserted_text: text.clone(),
5695                old_text: String::new(),
5696            }),
5697        );
5698        let newer = SyntheticEvent::new(
5699            EventType::Input,
5700            EventSource::User,
5701            dnid(0, 0),
5702            tick(2),
5703            EventData::TextInput(TextInputEventData {
5704                inserted_text: text.clone(),
5705                old_text: text.clone(),
5706            }),
5707        );
5708        let out = deduplicate_synthetic_events(vec![ev, newer]);
5709        assert_eq!(out.len(), 1);
5710        match &out[0].data {
5711            EventData::TextInput(d) => {
5712                assert_eq!(d.inserted_text, text);
5713                assert_eq!(d.old_text, text, "the newer event won");
5714            }
5715            _ => panic!("payload must be preserved"),
5716        }
5717    }
5718
5719    // ====================================================== hit-test extraction
5720
5721    #[test]
5722    fn get_first_hovered_node_on_empty_input() {
5723        assert!(get_first_hovered_node(None).is_none());
5724        assert!(
5725            get_first_hovered_node(Some(&empty_hit_test())).is_none(),
5726            "a hit test with no hovered DOMs has no front-most node"
5727        );
5728        // A DOM entry that is present but has zero hit nodes is also `None`.
5729        let ht = hit_test_with(0, &[]);
5730        assert!(get_first_hovered_node(Some(&ht)).is_none());
5731    }
5732
5733    #[test]
5734    fn get_first_hovered_node_picks_minimum_depth_and_breaks_ties_deterministically() {
5735        // Front-most (depth 0) has the HIGHER node id — a naive `.next()` on the
5736        // BTreeMap would wrongly return node 2.
5737        let ht = hit_test_with(0, &[(2, 5), (5, 0), (9, 3)]);
5738        let got = get_first_hovered_node(Some(&ht)).unwrap();
5739        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
5740
5741        // Equal depths: the first in (DomId, NodeId) iteration order wins, and the
5742        // choice must be stable across calls.
5743        let ht = hit_test_with(0, &[(7, 2), (3, 2), (11, 2)]);
5744        let a = get_first_hovered_node(Some(&ht)).unwrap();
5745        let b = get_first_hovered_node(Some(&ht)).unwrap();
5746        assert_eq!(a, b, "tie-breaking must be deterministic");
5747        assert_eq!(a.node.into_crate_internal(), Some(NodeId::new(3)));
5748
5749        // u32::MAX depth is still a valid (and only) candidate.
5750        let ht = hit_test_with(0, &[(1, u32::MAX)]);
5751        let got = get_first_hovered_node(Some(&ht)).unwrap();
5752        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(1)));
5753        assert_eq!(got.dom, DomId { inner: 0 });
5754    }
5755
5756    #[test]
5757    fn get_mouse_position_with_fallback_prefers_the_event_payload() {
5758        let mouse = MouseState {
5759            cursor_position: CursorPosition::InWindow(LogicalPosition::new(9.0, 9.0)),
5760            ..MouseState::default()
5761        };
5762        let ev = mouse_event(
5763            EventType::MouseDown,
5764            MouseButton::Left,
5765            LogicalPosition::new(1.0, 2.0),
5766        );
5767        assert_eq!(
5768            get_mouse_position_with_fallback(&ev, &mouse),
5769            LogicalPosition::new(1.0, 2.0),
5770            "the event's own payload wins over the live cursor"
5771        );
5772
5773        // Non-mouse payload -> fall back to the live cursor...
5774        let keyless = SyntheticEvent::new(
5775            EventType::MouseDown,
5776            EventSource::Synthetic,
5777            dnid(0, 0),
5778            tick(0),
5779            EventData::None,
5780        );
5781        assert_eq!(
5782            get_mouse_position_with_fallback(&keyless, &mouse),
5783            LogicalPosition::new(9.0, 9.0)
5784        );
5785
5786        // ...and if the cursor is Uninitialized or OutOfWindow, fall back to zero
5787        // (`CursorPosition::get_position` only yields InWindow positions).
5788        for cursor in [
5789            CursorPosition::Uninitialized,
5790            CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
5791        ] {
5792            let ms = MouseState { cursor_position: cursor, ..MouseState::default() };
5793            assert_eq!(
5794                get_mouse_position_with_fallback(&keyless, &ms),
5795                LogicalPosition::zero()
5796            );
5797        }
5798    }
5799
5800    #[test]
5801    fn get_mouse_position_with_fallback_passes_through_extreme_coordinates() {
5802        let mouse = MouseState::default();
5803        for pos in [
5804            LogicalPosition::new(f32::NAN, f32::NAN),
5805            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
5806            LogicalPosition::new(f32::MAX, f32::MIN),
5807            LogicalPosition::new(-0.0, 0.0),
5808        ] {
5809            let ev = mouse_event(EventType::MouseDown, MouseButton::Left, pos);
5810            let got = get_mouse_position_with_fallback(&ev, &mouse);
5811            // Compare bitwise so NaN == NaN holds: the value must be forwarded
5812            // verbatim, never sanitized or panicked on.
5813            assert_eq!(got.x.to_bits(), pos.x.to_bits());
5814            assert_eq!(got.y.to_bits(), pos.y.to_bits());
5815        }
5816    }
5817
5818    // ============================================= input-interpreter handlers
5819
5820    #[test]
5821    fn handle_mouse_down_treats_zero_click_count_as_one() {
5822        let ht = hit_test_with(0, &[(0, 0)]);
5823        let mouse = MouseState::default();
5824        let kb = KeyboardState::default();
5825        let ev = mouse_event(
5826            EventType::MouseDown,
5827            MouseButton::Left,
5828            LogicalPosition::new(4.0, 5.0),
5829        );
5830
5831        // click_count 0 is normalised to 1 -> a plain text-selection click.
5832        let action = handle_mouse_down(&ev, Some(&ht), 0, &mouse, &kb)
5833            .expect("click_count 0 must be treated as a single click");
5834        match action {
5835            InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { position, .. }) => {
5836                assert_eq!(position, LogicalPosition::new(4.0, 5.0));
5837            }
5838            _ => panic!("expected a passed-through TextSelectionClick"),
5839        }
5840    }
5841
5842    #[test]
5843    fn handle_mouse_down_saturates_above_a_triple_click() {
5844        let ht = hit_test_with(0, &[(0, 0)]);
5845        let mouse = MouseState::default();
5846        let kb = KeyboardState::default();
5847        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5848
5849        // 1..=3 are real clicks.
5850        for count in 1u8..=3 {
5851            assert!(
5852                handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_some(),
5853                "click_count {count} must produce a selection click"
5854            );
5855        }
5856        // 4 and above (up to the u8 boundary) are dropped — no wraparound, no panic.
5857        for count in [4u8, 5, 100, u8::MAX] {
5858            assert!(
5859                handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_none(),
5860                "click_count {count} must be ignored"
5861            );
5862        }
5863    }
5864
5865    #[test]
5866    fn handle_mouse_down_without_a_hit_test_is_a_no_op() {
5867        let mouse = MouseState::default();
5868        let kb = KeyboardState::default();
5869        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
5870        assert!(handle_mouse_down(&ev, None, 1, &mouse, &kb).is_none());
5871        assert!(handle_mouse_down(&ev, Some(&empty_hit_test()), 1, &mouse, &kb).is_none());
5872    }
5873
5874    #[test]
5875    fn handle_mouse_down_with_primary_held_adds_a_cursor_only_on_a_single_click() {
5876        let ht = hit_test_with(0, &[(0, 0)]);
5877        let mouse = MouseState::default();
5878        let kb = keyboard_with_primary_held();
5879        let ev = mouse_event(
5880            EventType::MouseDown,
5881            MouseButton::Left,
5882            LogicalPosition::new(7.0, 8.0),
5883        );
5884
5885        // primary + single click -> multi-cursor add.
5886        match handle_mouse_down(&ev, Some(&ht), 1, &mouse, &kb) {
5887            Some(InternalEventAction::AddAndPass(SystemChange::AddCursorAtClick { position })) => {
5888                assert_eq!(position, LogicalPosition::new(7.0, 8.0));
5889            }
5890            _ => panic!("primary+click must add a cursor at the click position"),
5891        }
5892        // primary + double click -> NOT a cursor add (falls back to selection).
5893        match handle_mouse_down(&ev, Some(&ht), 2, &mouse, &kb) {
5894            Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { .. })) => {}
5895            _ => panic!("primary+double-click must not add a cursor"),
5896        }
5897    }
5898
5899    #[test]
5900    fn handle_mouse_over_requires_a_held_button_and_a_drag_origin() {
5901        let ht = hit_test_with(0, &[(0, 0)]);
5902        let start = LogicalPosition::new(1.0, 1.0);
5903        let ev = mouse_event(
5904            EventType::MouseOver,
5905            MouseButton::Left,
5906            LogicalPosition::new(50.0, 60.0),
5907        );
5908
5909        // Button up -> never a drag, even with a drag origin.
5910        let up = MouseState::default();
5911        assert!(handle_mouse_over(&ev, Some(&ht), &up, Some(start)).is_none());
5912
5913        // Button down but no drag origin -> not a drag either.
5914        let down = MouseState { left_down: true, ..MouseState::default() };
5915        assert!(handle_mouse_over(&ev, Some(&ht), &down, None).is_none());
5916
5917        // Button down + origin but nothing under the cursor -> no drag.
5918        assert!(handle_mouse_over(&ev, None, &down, Some(start)).is_none());
5919        assert!(handle_mouse_over(&ev, Some(&empty_hit_test()), &down, Some(start)).is_none());
5920
5921        // All three present -> a drag selection from origin to the current point.
5922        match handle_mouse_over(&ev, Some(&ht), &down, Some(start)) {
5923            Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionDrag {
5924                start_position,
5925                current_position,
5926            })) => {
5927                assert_eq!(start_position, start);
5928                assert_eq!(current_position, LogicalPosition::new(50.0, 60.0));
5929            }
5930            _ => panic!("expected a TextSelectionDrag"),
5931        }
5932    }
5933
5934    #[test]
5935    fn handle_key_down_needs_a_focused_node_and_a_keyboard_payload() {
5936        let kb = KeyboardState::default();
5937        let ev = key_event(VirtualKeyCode::Back as u32, KeyModifiers::default());
5938        assert!(
5939            handle_key_down(&ev, &kb, None).is_none(),
5940            "no focus => no keyboard system change"
5941        );
5942
5943        // Focused, but the event carries no keyboard payload.
5944        let payloadless = SyntheticEvent::new(
5945            EventType::KeyDown,
5946            EventSource::User,
5947            dnid(0, 1),
5948            tick(0),
5949            EventData::None,
5950        );
5951        assert!(handle_key_down(&payloadless, &kb, Some(dnid(0, 1))).is_none());
5952    }
5953
5954    #[test]
5955    fn handle_key_down_rejects_undecodable_key_codes() {
5956        let kb = KeyboardState::default();
5957        let target = Some(dnid(0, 1));
5958        // u32::MAX / out-of-table codes must fall out via `from_u32` -> None,
5959        // never index a table or panic.
5960        for code in [u32::MAX, u32::MAX - 1, 100_000, 9_999] {
5961            let ev = key_event(code, KeyModifiers::default());
5962            assert!(
5963                handle_key_down(&ev, &kb, target).is_none(),
5964                "key_code {code} must decode to None"
5965            );
5966        }
5967    }
5968
5969    #[test]
5970    fn handle_key_down_reads_modifiers_from_the_event_not_the_live_keyboard() {
5971        // The live KeyboardState is deliberately EMPTY here: the handler must key
5972        // off the event payload's modifiers (the live state may have advanced
5973        // between queueing and dispatch).
5974        let kb = KeyboardState::default();
5975        let target = dnid(0, 1);
5976        let ev = key_event(VirtualKeyCode::C as u32, primary_modifiers());
5977        match handle_key_down(&ev, &kb, Some(target)) {
5978            Some(InternalEventAction::AddAndSkip(SystemChange::CopyToClipboard)) => {}
5979            _ => panic!("primary+C in the payload must copy, regardless of the live state"),
5980        }
5981
5982        // ...and conversely, a live primary key must NOT rewrite an unmodified event.
5983        let live = keyboard_with_primary_held();
5984        let plain = key_event(VirtualKeyCode::C as u32, KeyModifiers::default());
5985        assert!(
5986            handle_key_down(&plain, &live, Some(target)).is_none(),
5987            "an unmodified C is plain text input, not a copy"
5988        );
5989    }
5990
5991    #[test]
5992    fn handle_key_down_maps_backspace_and_delete_to_selection_ops() {
5993        let kb = KeyboardState::default();
5994        let target = dnid(0, 1);
5995
5996        let expect_op = |ev: &SyntheticEvent| -> SelectionOp {
5997            match handle_key_down(ev, &kb, Some(target)) {
5998                Some(InternalEventAction::AddAndSkip(SystemChange::ApplySelectionOp {
5999                    target: t,
6000                    op,
6001                })) => {
6002                    assert_eq!(t, target);
6003                    op
6004                }
6005                _ => panic!("expected an ApplySelectionOp"),
6006            }
6007        };
6008
6009        let back = expect_op(&key_event(VirtualKeyCode::Back as u32, KeyModifiers::default()));
6010        assert_eq!(back.direction, SelectionDirection::Backward);
6011        assert_eq!(back.step, SelectionStep::Character);
6012        assert_eq!(back.mode, SelectionMode::Delete);
6013
6014        let del = expect_op(&key_event(VirtualKeyCode::Delete as u32, KeyModifiers::default()));
6015        assert_eq!(del.direction, SelectionDirection::Forward);
6016        assert_eq!(del.step, SelectionStep::Character);
6017        assert_eq!(del.mode, SelectionMode::Delete);
6018
6019        // Shift+arrow extends instead of moving.
6020        let shift_right = expect_op(&key_event(
6021            VirtualKeyCode::Right as u32,
6022            KeyModifiers::new().with_shift(),
6023        ));
6024        assert_eq!(shift_right.mode, SelectionMode::Extend);
6025        assert_eq!(shift_right.step, SelectionStep::Character);
6026
6027        // The word modifier upgrades Backspace to a word delete.
6028        let word_mod = if cfg!(target_os = "macos") {
6029            KeyModifiers::new().with_alt()
6030        } else {
6031            KeyModifiers::new().with_ctrl()
6032        };
6033        let word_back = expect_op(&key_event(VirtualKeyCode::Back as u32, word_mod));
6034        assert_eq!(word_back.step, SelectionStep::Word);
6035        assert_eq!(word_back.mode, SelectionMode::Delete);
6036    }
6037
6038    #[test]
6039    fn handle_key_down_ignores_keys_it_does_not_interpret() {
6040        let kb = KeyboardState::default();
6041        let target = Some(dnid(0, 1));
6042        // Ordinary text keys must pass through to the user callbacks untouched.
6043        for vk in [VirtualKeyCode::B, VirtualKeyCode::Q, VirtualKeyCode::Space, VirtualKeyCode::F5] {
6044            let ev = key_event(vk as u32, KeyModifiers::default());
6045            assert!(
6046                handle_key_down(&ev, &kb, target).is_none(),
6047                "{vk:?} must not generate a system change"
6048            );
6049        }
6050    }
6051
6052    // ================================================ default_input_interpreter
6053
6054    #[test]
6055    fn default_input_interpreter_with_no_events_produces_nothing() {
6056        let kb = KeyboardState::default();
6057        let mouse = MouseState::default();
6058        let info = InputInterpreterInfo {
6059            events: &[],
6060            hit_test: None,
6061            keyboard_state: &kb,
6062            mouse_state: &mouse,
6063            state: InputInterpreterState {
6064                focused_node: None,
6065                click_count: 0,
6066                drag_start_position: None,
6067                has_selection: false,
6068            },
6069        };
6070        let r = default_input_interpreter(&info);
6071        assert!(r.system_changes.is_empty());
6072        assert!(r.user_events.is_empty());
6073    }
6074
6075    #[test]
6076    fn default_input_interpreter_skips_shortcut_events_but_passes_clicks_through() {
6077        let kb = KeyboardState::default();
6078        let mouse = MouseState::default();
6079        let ht = hit_test_with(0, &[(0, 0)]);
6080        let target = dnid(0, 1);
6081
6082        // A primary+C shortcut is consumed (AddAndSkip) — the user callback must
6083        // NOT also see the raw key event...
6084        let copy = key_event(VirtualKeyCode::C as u32, primary_modifiers());
6085        // ...while a MouseDown is consumed AND forwarded (AddAndPass).
6086        let click = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
6087        // ...and an unhandled event type is forwarded untouched.
6088        let scroll = SyntheticEvent::new(
6089            EventType::Scroll,
6090            EventSource::User,
6091            target,
6092            tick(0),
6093            EventData::None,
6094        );
6095
6096        let events = vec![copy, click, scroll];
6097        let info = InputInterpreterInfo {
6098            events: &events,
6099            hit_test: Some(&ht),
6100            keyboard_state: &kb,
6101            mouse_state: &mouse,
6102            state: InputInterpreterState {
6103                focused_node: Some(target),
6104                click_count: 1,
6105                drag_start_position: None,
6106                has_selection: false,
6107            },
6108        };
6109        let r = default_input_interpreter(&info);
6110
6111        assert_eq!(r.system_changes.len(), 2, "copy + selection click");
6112        assert!(r.system_changes.contains(&SystemChange::CopyToClipboard));
6113        assert!(r
6114            .system_changes
6115            .iter()
6116            .any(|c| matches!(c, SystemChange::TextSelectionClick { .. })));
6117
6118        assert_eq!(r.user_events.len(), 2, "the consumed KeyDown must not be forwarded");
6119        assert!(!r.user_events.iter().any(|e| e.event_type == EventType::KeyDown));
6120        assert!(r.user_events.iter().any(|e| e.event_type == EventType::MouseDown));
6121        assert!(r.user_events.iter().any(|e| e.event_type == EventType::Scroll));
6122    }
6123
6124    #[test]
6125    fn default_input_interpreter_extern_survives_a_null_info_pointer() {
6126        // The C-ABI trampoline must null-check rather than deref garbage.
6127        let user_data = crate::refany::RefAny::new(0u8);
6128        let r = default_input_interpreter_extern(user_data, core::ptr::null());
6129        assert!(r.system_changes.is_empty());
6130        assert!(r.user_events.is_empty());
6131    }
6132
6133    // ==================================================== post-callback filter
6134
6135    #[test]
6136    fn post_filter_with_prevent_default_only_lets_focus_changes_through() {
6137        let old = Some(dnid(0, 1));
6138        let new = Some(dnid(0, 2));
6139        let pre = vec![
6140            SystemChange::TextSelectionClick {
6141                position: LogicalPosition::zero(),
6142                timestamp: tick(0),
6143            },
6144            SystemChange::PasteFromClipboard,
6145        ];
6146
6147        // prevent_default + no focus change -> absolutely nothing (not even the
6148        // usual ApplyPendingTextInput).
6149        let out = default_post_filter(true, &pre, old, old);
6150        assert!(out.is_empty(), "preventDefault must suppress every side effect");
6151
6152        // prevent_default + a focus change -> ONLY the focus change.
6153        let out = default_post_filter(true, &pre, old, new);
6154        assert_eq!(out, vec![SystemChange::SetFocus { new_focus: new, old_focus: old }]);
6155    }
6156
6157    #[test]
6158    fn post_filter_maps_pre_changes_to_their_follow_ups() {
6159        // No pre-changes, no focus change -> just the text-input flush.
6160        let out = default_post_filter(false, &[], None, None);
6161        assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
6162
6163        // Cursor-moving ops schedule a scroll-into-view.
6164        for change in [
6165            SystemChange::TextSelectionClick {
6166                position: LogicalPosition::zero(),
6167                timestamp: tick(0),
6168            },
6169            SystemChange::ApplySelectionOp {
6170                target: dnid(0, 1),
6171                op: SelectionOp::new(
6172                    SelectionDirection::Forward,
6173                    SelectionStep::Character,
6174                    SelectionMode::Move,
6175                ),
6176            },
6177            SystemChange::AddCursorAtClick { position: LogicalPosition::zero() },
6178            SystemChange::SelectNextOccurrence { target: dnid(0, 1) },
6179            SystemChange::CutToClipboard { target: dnid(0, 1) },
6180            SystemChange::PasteFromClipboard,
6181            SystemChange::UndoTextEdit { target: dnid(0, 1) },
6182            SystemChange::RedoTextEdit { target: dnid(0, 1) },
6183            SystemChange::SelectAllText,
6184        ] {
6185            let out = default_post_filter(false, core::slice::from_ref(&change), None, None);
6186            assert!(
6187                out.contains(&SystemChange::ScrollSelectionIntoView),
6188                "{change:?} must schedule a scroll-into-view"
6189            );
6190            assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
6191        }
6192
6193        // A drag starts the auto-scroll timer instead.
6194        let drag = SystemChange::TextSelectionDrag {
6195            start_position: LogicalPosition::zero(),
6196            current_position: LogicalPosition::new(1.0, 1.0),
6197        };
6198        let out = default_post_filter(false, core::slice::from_ref(&drag), None, None);
6199        assert!(out.contains(&SystemChange::StartAutoScrollTimer));
6200        assert!(!out.contains(&SystemChange::ScrollSelectionIntoView));
6201
6202        // Changes with no follow-up add nothing beyond the text-input flush.
6203        let out = default_post_filter(false, &[SystemChange::CopyToClipboard], None, None);
6204        assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
6205    }
6206
6207    #[test]
6208    fn post_filter_emits_set_focus_only_when_focus_actually_moved() {
6209        let a = Some(dnid(0, 1));
6210        let b = Some(dnid(0, 2));
6211        // Unchanged (both Some, both None) -> no SetFocus.
6212        for (old, new) in [(a, a), (None, None)] {
6213            let out = default_post_filter(false, &[], old, new);
6214            assert!(!out.iter().any(|c| matches!(c, SystemChange::SetFocus { .. })));
6215        }
6216        // Changed (including to/from None) -> exactly one SetFocus, and it is last.
6217        for (old, new) in [(a, b), (None, a), (a, None)] {
6218            let out = default_post_filter(false, &[], old, new);
6219            assert_eq!(
6220                out.last(),
6221                Some(&SystemChange::SetFocus { new_focus: new, old_focus: old })
6222            );
6223            assert_eq!(
6224                out.iter()
6225                    .filter(|c| matches!(c, SystemChange::SetFocus { .. }))
6226                    .count(),
6227                1
6228            );
6229        }
6230    }
6231
6232    #[test]
6233    fn post_filter_handles_a_large_pre_change_list_without_blowing_up() {
6234        // 5000 cursor ops -> 1 flush + 5000 scroll-into-views. Bounded, no overflow.
6235        let pre: Vec<SystemChange> = (0..5000)
6236            .map(|_| SystemChange::AddCursorAtClick { position: LogicalPosition::zero() })
6237            .collect();
6238        let out = default_post_filter(false, &pre, None, None);
6239        assert_eq!(out.len(), 5001);
6240        assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
6241        assert!(out[1..]
6242            .iter()
6243            .all(|c| *c == SystemChange::ScrollSelectionIntoView));
6244    }
6245
6246    #[test]
6247    fn default_post_filter_delegates_to_post_callback_filter_system_changes() {
6248        let pre = vec![
6249            SystemChange::TextSelectionDrag {
6250                start_position: LogicalPosition::zero(),
6251                current_position: LogicalPosition::new(2.0, 2.0),
6252            },
6253            SystemChange::SelectAllText,
6254        ];
6255        for prevent in [false, true] {
6256            for (old, new) in [(None, None), (Some(dnid(0, 1)), Some(dnid(0, 2)))] {
6257                assert_eq!(
6258                    default_post_filter(prevent, &pre, old, new),
6259                    post_callback_filter_system_changes(prevent, &pre, old, new),
6260                    "the two entry points must stay in lock-step"
6261                );
6262            }
6263        }
6264    }
6265
6266    #[test]
6267    fn default_post_filter_extern_decodes_the_none_focus_sentinel() {
6268        // old_focus = the `None` sentinel, new_focus = a real node => a focus change.
6269        let pre: Vec<SystemChange> = Vec::new();
6270        let slice = SystemChangeVecSlice {
6271            ptr: pre.as_ptr(),
6272            len: pre.len(),
6273        };
6274        let out = default_post_filter_extern(
6275            crate::refany::RefAny::new(0u8),
6276            false,
6277            slice,
6278            dnid_none(0),
6279            dnid(0, 4),
6280        );
6281        let changes = out.as_slice();
6282        assert_eq!(changes.first(), Some(&SystemChange::ApplyPendingTextInput));
6283        assert_eq!(
6284            changes.last(),
6285            Some(&SystemChange::SetFocus {
6286                new_focus: Some(dnid(0, 4)),
6287                old_focus: None,
6288            }),
6289            "a `NONE` node id must decode to `None`, not to node 0"
6290        );
6291
6292        // An empty C-slice must be accepted (ptr may be dangling-but-aligned).
6293        let out = default_post_filter_extern(
6294            crate::refany::RefAny::new(0u8),
6295            true,
6296            SystemChangeVecSlice::empty(),
6297            dnid_none(0),
6298            dnid_none(0),
6299        );
6300        assert!(out.as_slice().is_empty());
6301    }
6302}