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