1
//! Event and callback filtering module
2

            
3
#[cfg(not(feature = "std"))]
4
use alloc::string::{String, ToString};
5
use alloc::{
6
    boxed::Box,
7
    collections::{btree_map::BTreeMap, btree_set::BTreeSet},
8
    vec::Vec,
9
};
10

            
11
use azul_css::AzString;
12

            
13
use 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)]
26
pub 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

            
38
pub type RestyleNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
39
pub type RelayoutNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
40
pub type RelayoutWords = BTreeMap<NodeId, AzString>;
41

            
42
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43
pub struct FocusChange {
44
    pub old: Option<DomNodeId>,
45
    pub new: Option<DomNodeId>,
46
}
47

            
48
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49
pub struct CallbackToCall {
50
    pub node_id: NodeId,
51
    pub hit_test_item: Option<HitTestItem>,
52
    pub event_filter: EventFilter,
53
}
54

            
55
impl CallbackToCall {
56
1
    #[must_use] pub const fn new(
57
1
        node_id: NodeId,
58
1
        hit_test_item: Option<HitTestItem>,
59
1
        event_filter: EventFilter,
60
1
    ) -> Self {
61
1
        Self { node_id, hit_test_item, event_filter }
62
1
    }
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
2
    #[must_use] pub fn from_hit_test(
68
2
        hit_test: &FullHitTest,
69
2
        dom_id: DomId,
70
2
        event_filter: EventFilter,
71
2
    ) -> Vec<Self> {
72
2
        let Some(hit) = hit_test.hovered_nodes.get(&dom_id) else {
73
1
            return Vec::new();
74
        };
75
1
        hit.regular_hit_test_nodes
76
1
            .iter()
77
1
            .map(|(node_id, item)| Self {
78
1
                node_id: *node_id,
79
1
                hit_test_item: Some(*item),
80
1
                event_filter,
81
1
            })
82
1
            .collect()
83
2
    }
84
}
85

            
86
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
87
#[must_use = "ProcessEventResult must be used to determine if relayout/repaint is needed"]
88
pub 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

            
103
impl ProcessEventResult {
104
3780
    #[must_use] pub const fn order(&self) -> usize {
105
        use self::ProcessEventResult::{DoNothing, ShouldReRenderCurrentWindow, ShouldUpdateDisplayListCurrentWindow, UpdateHitTesterAndProcessAgain, ShouldIncrementalRelayout, ShouldRegenerateDomCurrentWindow, ShouldRegenerateDomAllWindows};
106
3780
        match self {
107
1447
            DoNothing => 0,
108
1018
            ShouldReRenderCurrentWindow => 1,
109
240
            ShouldUpdateDisplayListCurrentWindow => 2,
110
92
            UpdateHitTesterAndProcessAgain => 3,
111
143
            ShouldIncrementalRelayout => 4,
112
742
            ShouldRegenerateDomCurrentWindow => 5,
113
98
            ShouldRegenerateDomAllWindows => 6,
114
        }
115
3780
    }
116
}
117

            
118
impl PartialOrd for ProcessEventResult {
119
1764
    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
120
1764
        self.order().partial_cmp(&other.order())
121
1764
    }
122
}
123

            
124
impl Ord for ProcessEventResult {
125
    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
126
        self.order().cmp(&other.order())
127
    }
128
}
129

            
130
impl ProcessEventResult {
131
150
    pub fn max_self(self, other: Self) -> Self {
132
150
        self.max(other)
133
150
    }
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)]
142
pub 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)]
162
pub 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)]
176
pub 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)]
186
pub 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)]
198
pub 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)]
215
pub 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

            
225
impl ScrollIntoViewOptions {
226
    /// Create options with "nearest" alignment for both axes
227
135
    #[must_use] pub const fn nearest() -> Self {
228
135
        Self {
229
135
            block: ScrollLogicalPosition::Nearest,
230
135
            inline_axis: ScrollLogicalPosition::Nearest,
231
135
            behavior: ScrollIntoViewBehavior::Auto,
232
135
        }
233
135
    }
234
    
235
    /// Create options with "center" alignment for both axes
236
2
    #[must_use] pub const fn center() -> Self {
237
2
        Self {
238
2
            block: ScrollLogicalPosition::Center,
239
2
            inline_axis: ScrollLogicalPosition::Center,
240
2
            behavior: ScrollIntoViewBehavior::Auto,
241
2
        }
242
2
    }
243
    
244
    /// Create options with "start" alignment for both axes
245
2
    #[must_use] pub const fn start() -> Self {
246
2
        Self {
247
2
            block: ScrollLogicalPosition::Start,
248
2
            inline_axis: ScrollLogicalPosition::Start,
249
2
            behavior: ScrollIntoViewBehavior::Auto,
250
2
        }
251
2
    }
252
    
253
    /// Create options to align the end of the target with the end of the viewport
254
1
    #[must_use] pub const fn end() -> Self {
255
1
        Self {
256
1
            block: ScrollLogicalPosition::End,
257
1
            inline_axis: ScrollLogicalPosition::End,
258
1
            behavior: ScrollIntoViewBehavior::Auto,
259
1
        }
260
1
    }
261
    
262
    /// Set instant scroll behavior
263
12
    #[must_use] pub const fn with_instant(mut self) -> Self {
264
12
        self.behavior = ScrollIntoViewBehavior::Instant;
265
12
        self
266
12
    }
267
    
268
    /// Set smooth scroll behavior
269
12
    #[must_use] pub const fn with_smooth(mut self) -> Self {
270
12
        self.behavior = ScrollIntoViewBehavior::Smooth;
271
12
        self
272
12
    }
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)]
281
pub 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)]
299
pub 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)]
312
pub 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)]
328
pub struct KeyModifiers {
329
    pub shift: bool,
330
    pub ctrl: bool,
331
    pub alt: bool,
332
    pub meta: bool,
333
}
334

            
335
impl KeyModifiers {
336
20
    #[must_use] pub fn new() -> Self {
337
20
        Self::default()
338
20
    }
339

            
340
5
    #[must_use] pub const fn with_shift(mut self) -> Self {
341
5
        self.shift = true;
342
5
        self
343
5
    }
344

            
345
10
    #[must_use] pub const fn with_ctrl(mut self) -> Self {
346
10
        self.ctrl = true;
347
10
        self
348
10
    }
349

            
350
3
    #[must_use] pub const fn with_alt(mut self) -> Self {
351
3
        self.alt = true;
352
3
        self
353
3
    }
354

            
355
3
    #[must_use] pub const fn with_meta(mut self) -> Self {
356
3
        self.meta = true;
357
3
        self
358
3
    }
359

            
360
8
    #[must_use] pub const fn is_empty(&self) -> bool {
361
8
        !self.shift && !self.ctrl && !self.alt && !self.meta
362
8
    }
363
}
364

            
365
/// Type-specific event data for mouse events.
366
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367
pub 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)]
380
pub 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)]
393
pub 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)]
402
pub 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)]
413
pub 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)]
420
pub 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)]
431
pub 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)]
446
pub 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)]
460
pub 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)]
467
pub 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)]
497
pub 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
    /// The window's frame state changed — minimized, maximized, restored to
662
    /// normal, or entered/left fullscreen.
663
    ///
664
    /// Read the new state from `flags.frame` on the current window state; the
665
    /// event carries no payload of its own because the flag IS the payload and
666
    /// a callback that cares will already be reading window state.
667
    WindowFrameChanged,
668
    /// Window received focus
669
    WindowFocusIn,
670
    /// Window lost focus
671
    WindowFocusOut,
672
    /// System theme changed
673
    ThemeChange,
674
    /// Window DPI/scale factor changed (moved to different monitor)
675
    WindowDpiChanged,
676
    /// Window moved to a different monitor
677
    WindowMonitorChanged,
678

            
679
    // Application Events
680
    /// A monitor/display was connected
681
    MonitorConnected,
682
    /// A monitor/display was disconnected
683
    MonitorDisconnected,
684

            
685
    // File Events
686
    /// File is being hovered
687
    FileHover,
688
    /// File was dropped
689
    FileDrop,
690
    /// File hover cancelled
691
    FileHoverCancel,
692

            
693
    // Hardware input-device Events (P6 sensors / gamepad)
694
    /// A motion-sensor reading (accelerometer / gyroscope / magnetometer)
695
    /// changed. Read the value with `CallbackInfo::get_sensor_reading`.
696
    SensorChanged,
697
    /// A gamepad's buttons / axes changed, or one was (dis)connected. Read it
698
    /// with `CallbackInfo::get_primary_gamepad` / `get_gamepad_state`.
699
    GamepadInput,
700

            
701
    // Geolocation Events (MWA-A1 — synthesized by the capability pump's
702
    // GeolocationManager EventProvider; both filter enums already carried
703
    // the matching variants, only this dispatch type was missing them).
704
    /// A new GPS / network location fix arrived. Read it with
705
    /// `CallbackInfo::get_geolocation_fix`.
706
    GeolocationFix,
707
    /// The native geolocation subscription errored, timed out, or was
708
    /// revoked.
709
    GeolocationError,
710

            
711
    // Async capability outcomes (MWA-A1b — synthesized by the capability
712
    // pump's manager EventProviders so idle apps observe prompt results).
713
    /// A permission's OS-observed state changed (granted / denied /
714
    /// revoked / restricted). Targeted at the capability's most recent
715
    /// subscriber node when known, else the root. Read the new state via
716
    /// `CallbackInfo` permission accessors.
717
    PermissionChanged,
718
    /// A biometric authentication prompt completed. Read the outcome via
719
    /// `CallbackInfo::get_biometric_result`.
720
    BiometricResult,
721
    /// A keyring store / get / delete operation completed. Read the outcome
722
    /// via `CallbackInfo::get_keyring_result`.
723
    KeyringResult,
724

            
725
    // Structural document editing (C11 — synthesized once per recorded
726
    // changeset by the LayoutWindow's document-edit EventProvider).
727
    /// A STRUCTURAL document edit (Enter split / Backspace merge / wrap /
728
    /// selection-spanning replace…) was recorded and awaits the app's
729
    /// apply-and-ack. Fired ONCE per changeset so the app's apply loop is
730
    /// prompt instead of polling `get_pending_document_edit()` on its next
731
    /// unrelated callback. The changeset id rides on
732
    /// `EventData::DocumentEdit`; the full changeset is read via
733
    /// `CallbackInfo` / `LayoutWindow::get_pending_document_edit()`.
734
    DocumentEdit,
735
}
736

            
737
/// Unified event wrapper (similar to React's `SyntheticEvent`).
738
///
739
/// All events in the system are wrapped in this structure, providing
740
/// a consistent interface and enabling event propagation control.
741
#[derive(Debug, Clone, PartialEq)]
742
pub struct SyntheticEvent {
743
    /// The type of event
744
    pub event_type: EventType,
745

            
746
    /// Where the event came from
747
    pub source: EventSource,
748

            
749
    /// Current propagation phase
750
    pub phase: EventPhase,
751

            
752
    /// Target node that the event was dispatched to
753
    pub target: DomNodeId,
754

            
755
    /// Current node in the propagation path
756
    pub current_target: DomNodeId,
757

            
758
    /// Timestamp when event was created
759
    pub timestamp: Instant,
760

            
761
    /// Type-specific event data
762
    pub data: EventData,
763

            
764
    /// Whether propagation has been stopped
765
    pub stopped: bool,
766

            
767
    /// Whether immediate propagation has been stopped
768
    pub stopped_immediate: bool,
769

            
770
    /// Whether default action has been prevented
771
    pub prevented_default: bool,
772
}
773

            
774
impl SyntheticEvent {
775
    /// Create a new synthetic event.
776
    ///
777
    /// # Parameters
778
    /// - `timestamp`: Current time from `(system_callbacks.get_system_time_fn.cb)()`
779
66233
    #[must_use] pub const fn new(
780
66233
        event_type: EventType,
781
66233
        source: EventSource,
782
66233
        target: DomNodeId,
783
66233
        timestamp: Instant,
784
66233
        data: EventData,
785
66233
    ) -> Self {
786
66233
        Self {
787
66233
            event_type,
788
66233
            source,
789
66233
            phase: EventPhase::Target,
790
66233
            target,
791
66233
            current_target: target,
792
66233
            timestamp,
793
66233
            data,
794
66233
            stopped: false,
795
66233
            stopped_immediate: false,
796
66233
            prevented_default: false,
797
66233
        }
798
66233
    }
799

            
800
    /// Stop event propagation after the current phase completes.
801
    ///
802
    /// This prevents the event from reaching handlers in subsequent phases
803
    /// (e.g., stopping during capture prevents bubble phase).
804
3
    pub const fn stop_propagation(&mut self) {
805
3
        self.stopped = true;
806
3
    }
807

            
808
    /// Stop event propagation immediately.
809
    ///
810
    /// This prevents any further handlers from being called, even on the
811
    /// current target element.
812
5
    pub const fn stop_immediate_propagation(&mut self) {
813
5
        self.stopped_immediate = true;
814
5
        self.stopped = true;
815
5
    }
816

            
817
    /// Prevent the default action associated with this event.
818
    ///
819
    /// For example, prevents form submission on Enter key, or prevents
820
    /// text selection on drag.
821
4
    pub const fn prevent_default(&mut self) {
822
4
        self.prevented_default = true;
823
4
    }
824

            
825
    /// Check if propagation was stopped.
826
6
    #[must_use] pub const fn is_propagation_stopped(&self) -> bool {
827
6
        self.stopped
828
6
    }
829

            
830
    /// Check if immediate propagation was stopped.
831
5
    #[must_use] pub const fn is_immediate_propagation_stopped(&self) -> bool {
832
5
        self.stopped_immediate
833
5
    }
834

            
835
    /// Check if default action was prevented.
836
5
    #[must_use] pub const fn is_default_prevented(&self) -> bool {
837
5
        self.prevented_default
838
5
    }
839
}
840

            
841
/// Result of event propagation through DOM tree.
842
#[derive(Debug, Clone)]
843
#[derive(Default)]
844
pub struct PropagationResult {
845
    /// Callbacks that should be invoked, in order
846
    pub callbacks_to_invoke: Vec<(NodeId, EventFilter)>,
847
    /// Whether default action should be prevented
848
    pub default_prevented: bool,
849
}
850

            
851
/// Get the path from root to target node in the DOM tree.
852
///
853
/// This is used for event propagation - we need to know which nodes
854
/// are ancestors of the target to implement capture/bubble phases.
855
///
856
/// Returns nodes in order from root to target (inclusive).
857
91
#[must_use] pub fn get_dom_path(
858
91
    node_hierarchy: &crate::id::NodeHierarchy,
859
91
    target_node: NodeHierarchyItemId,
860
91
) -> Vec<NodeId> {
861
91
    let mut path = Vec::new();
862
91
    let Some(target_node_id) = target_node.into_crate_internal() else {
863
2
        return path;
864
    };
865

            
866
89
    let hier_ref = node_hierarchy.as_ref();
867

            
868
    // Build path from target to root. Bounded by the node count and guarded by a
869
    // visited-set: a corrupt hierarchy with a parent cycle (or a parent chain
870
    // longer than the arena) would otherwise loop forever / OOM here, and this
871
    // runs on every event dispatch.
872
89
    let node_count = hier_ref.len();
873
89
    let mut visited: BTreeSet<NodeId> = BTreeSet::new();
874
89
    let mut current = Some(target_node_id);
875
5285
    while let Some(node_id) = current {
876
5198
        if path.len() > node_count || !visited.insert(node_id) {
877
            // Cycle or overrun detected: stop rather than spin forever.
878
2
            break;
879
5196
        }
880
5196
        path.push(node_id);
881
5196
        current = hier_ref.get(node_id).and_then(|node| node.parent);
882
    }
883

            
884
    // Reverse to get root → target order
885
89
    path.reverse();
886
89
    path
887
91
}
888

            
889
/// Propagate event through DOM tree with capture and bubble phases.
890
///
891
/// This implements DOM Level 2 event propagation:
892
/// 1. **Capture Phase**: Event travels from root down to target
893
/// 2. **Target Phase**: Event is at the target element
894
/// 3. **Bubble Phase**: Event travels from target back up to root
895
///
896
/// The event can be stopped at any point via `stopPropagation()` or
897
/// `stopImmediatePropagation()`.
898
///
899
/// # Panics
900
///
901
/// Panics if `path` is empty; it must contain at least the target node.
902
26
pub fn propagate_event(
903
26
    event: &mut SyntheticEvent,
904
26
    node_hierarchy: &crate::id::NodeHierarchy,
905
26
    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
906
26
) -> PropagationResult {
907
26
    let path = get_dom_path(node_hierarchy, event.target.node);
908
26
    if path.is_empty() {
909
1
        return PropagationResult::default();
910
25
    }
911

            
912
25
    let ancestors = &path[..path.len().saturating_sub(1)];
913
25
    let target_node_id = *path.last().unwrap();
914

            
915
25
    let mut result = PropagationResult::default();
916

            
917
    // Phase 1: Capture (root → target)
918
25
    propagate_phase(
919
25
        event,
920
25
        ancestors.iter().copied(),
921
25
        EventPhase::Capture,
922
25
        callbacks,
923
25
        &mut result,
924
    );
925

            
926
    // Phase 2: Target
927
25
    if !event.stopped {
928
23
        propagate_target_phase(event, target_node_id, callbacks, &mut result);
929
23
    }
930

            
931
    // Phase 3: Bubble (target → root)
932
25
    if !event.stopped {
933
23
        propagate_phase(
934
23
            event,
935
23
            ancestors.iter().rev().copied(),
936
23
            EventPhase::Bubble,
937
23
            callbacks,
938
23
            &mut result,
939
23
        );
940
23
    }
941

            
942
25
    result.default_prevented = event.prevented_default;
943
25
    result
944
26
}
945

            
946
/// Process a single propagation phase (Capture or Bubble)
947
49
fn propagate_phase(
948
49
    event: &mut SyntheticEvent,
949
49
    nodes: impl Iterator<Item = NodeId>,
950
49
    phase: EventPhase,
951
49
    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
952
49
    result: &mut PropagationResult,
953
49
) {
954
49
    event.phase = phase;
955

            
956
135
    for node_id in nodes {
957
88
        if event.stopped_immediate || event.stopped {
958
2
            return;
959
86
        }
960

            
961
86
        event.current_target = DomNodeId {
962
86
            dom: event.target.dom,
963
86
            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
964
86
        };
965

            
966
86
        collect_matching_callbacks(event, node_id, phase, callbacks, result);
967
    }
968
49
}
969

            
970
/// Process the target phase
971
24
fn propagate_target_phase(
972
24
    event: &mut SyntheticEvent,
973
24
    target_node_id: NodeId,
974
24
    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
975
24
    result: &mut PropagationResult,
976
24
) {
977
24
    event.phase = EventPhase::Target;
978
24
    event.current_target = event.target;
979

            
980
24
    collect_matching_callbacks(event, target_node_id, EventPhase::Target, callbacks, result);
981
24
}
982

            
983
/// Collect callbacks that match the current phase for a node
984
112
fn collect_matching_callbacks(
985
112
    event: &SyntheticEvent,
986
112
    node_id: NodeId,
987
112
    phase: EventPhase,
988
112
    callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
989
112
    result: &mut PropagationResult,
990
112
) {
991
112
    let Some(node_callbacks) = callbacks.get(&node_id) else {
992
100
        return;
993
    };
994

            
995
12
    let matching = node_callbacks
996
12
        .iter()
997
14
        .take_while(|_| !event.stopped_immediate)
998
13
        .filter(|filter| matches_filter_phase(**filter, event, phase))
999
12
        .map(|filter| (node_id, *filter));
12
    result.callbacks_to_invoke.extend(matching);
112
}
// =============================================================================
// DEFAULT ACTIONS (W3C UI Events / HTML5 Activation Behavior)
// =============================================================================
/// Default actions are built-in behaviors that occur in response to events.
///
/// Per W3C DOM Event specification:
/// > A default action is an action that the implementation is expected to take
/// > in response to an event, unless that action is cancelled by the script.
///
/// Examples:
/// - Tab key → move focus to next focusable element
/// - Enter/Space on button → activate (click) the button
/// - Escape → clear focus or close modal
/// - Arrow keys in listbox → move selection
///
/// Default actions are processed AFTER all event callbacks have been invoked,
/// and only if `event.prevent_default()` was NOT called.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C, u8)]
pub enum DefaultAction {
    /// Move focus to the next focusable element (Tab key)
    FocusNext,
    /// Move focus to the previous focusable element (Shift+Tab)
    FocusPrevious,
    /// Move focus to the first focusable element
    FocusFirst,
    /// Move focus to the last focusable element
    FocusLast,
    /// Clear focus from the currently focused element (Escape key)
    ClearFocus,
    /// Activate the focused element (Enter/Space on activatable elements)
    /// This generates a synthetic Click event on the target
    ActivateFocusedElement {
        target: DomNodeId,
    },
    /// Submit the form containing the focused element (Enter in form input)
    SubmitForm {
        form_node: DomNodeId,
    },
    /// Close the current modal/dialog (Escape key when modal is open)
    CloseModal {
        modal_node: DomNodeId,
    },
    /// Scroll the focused scrollable container
    ScrollFocusedContainer {
        direction: ScrollDirection,
        amount: ScrollAmount,
    },
    /// Select all text in the focused text input (Ctrl+A / Cmd+A)
    SelectAllText,
    /// Enter in a contenteditable host: record a STRUCTURAL split-block
    /// changeset for the app to apply to its model (azul never mutates the
    /// DOM). Execution = `LayoutWindow::record_structural_default_action`.
    SplitBlockAtCursor {
        target: DomNodeId,
    },
    /// Backspace at block start in a contenteditable host: record a
    /// merge-with-previous-block changeset (same record-only semantics).
    MergeWithPrevious {
        target: DomNodeId,
    },
    /// Delete at block end in a contenteditable host: record a
    /// merge-with-next-block changeset (same record-only semantics).
    MergeWithNext {
        target: DomNodeId,
    },
    /// No default action for this event
    None,
    /// Enter in a PLAIN-TEXT editing context (the editing host's computed
    /// `white-space` preserves newlines: pre / pre-wrap / break-spaces /
    /// pre-line), and Shift+Enter in ANY contenteditable host: insert a
    /// literal `"\n"` through the standard text-input pipeline (which brings
    /// veto, undo, and caret-follow along) instead of recording a structural
    /// block split. APPENDED at the enum tail for ABI stability.
    InsertLineBreakAtCursor {
        target: DomNodeId,
    },
}
/// Amount to scroll for keyboard-based scrolling
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum ScrollAmount {
    /// Scroll by one line (arrow keys)
    Line,
    /// Scroll by one page (Page Up/Down)
    Page,
    /// Scroll to start/end (Home/End)
    Document,
}
/// Result of determining what default action should occur for an event.
///
/// This is computed AFTER event dispatch, based on:
/// 1. The event type
/// 2. The target element's type/role
/// 3. Whether `prevent_default()` was called
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct DefaultActionResult {
    /// The default action to perform (if any)
    pub action: DefaultAction,
    /// Whether the action was prevented by a callback
    pub prevented: bool,
}
impl Default for DefaultActionResult {
11
    fn default() -> Self {
11
        Self {
11
            action: DefaultAction::None,
11
            prevented: false,
11
        }
11
    }
}
impl DefaultActionResult {
    /// Create a new result with a specific action
82795
    #[must_use] pub const fn new(action: DefaultAction) -> Self {
82795
        Self {
82795
            action,
82795
            prevented: false,
82795
        }
82795
    }
    /// Create a prevented result (callback called `prevent_default`)
10813
    #[must_use] pub const fn prevented() -> Self {
10813
        Self {
10813
            action: DefaultAction::None,
10813
            prevented: true,
10813
        }
10813
    }
    /// Check if there's an action to perform
27
    #[must_use] pub const fn has_action(&self) -> bool {
27
        !self.prevented && !matches!(self.action, DefaultAction::None)
27
    }
}
/// Trait for elements that have activation behavior (can be "clicked" via keyboard).
///
/// Per HTML5 spec, elements with activation behavior include:
/// - `<button>` elements
/// - `<input type="submit">`, `<input type="button">`, `<input type="reset">`
/// - `<a>` elements with href
/// - `<area>` elements with href
/// - Any element with a click handler (implicit activation)
///
/// When an element with activation behavior is focused and the user presses
/// Enter or Space, a synthetic click event is generated.
pub trait ActivationBehavior {
    /// Returns true if this element can be activated via keyboard (Enter/Space)
    fn has_activation_behavior(&self) -> bool;
    /// Returns true if this element is currently activatable
    /// (e.g., not disabled, not aria-disabled="true")
    fn is_activatable(&self) -> bool;
}
/// Trait to query if a node is focusable for tab navigation
pub trait Focusable {
    /// Returns the tabindex value for this element (-1, 0, or positive)
    fn get_tabindex(&self) -> Option<i32>;
    /// Returns true if this element can receive focus
    fn is_focusable(&self) -> bool;
    /// Returns true if this element should be in the tab order
    fn is_in_tab_order(&self) -> bool {
        self.get_tabindex().map_or_else(|| self.is_naturally_focusable(), |i| i >= 0)
    }
    /// Returns true if this element type is naturally focusable
    /// (button, input, select, textarea, a[href])
    fn is_naturally_focusable(&self) -> bool;
}
/// Check if an event filter matches the given event in the current phase.
///
/// This is used during event propagation to determine which callbacks
/// should be invoked at each phase.
64
fn matches_filter_phase(
64
    filter: EventFilter,
64
    event: &SyntheticEvent,
64
    current_phase: EventPhase,
64
) -> bool {
    // azul has no capture-phase listeners (no `addEventListener(…, capture=true)`
    // equivalent): every `EventFilter` is a bubble-phase listener, which by the W3C
    // model fires only in the Target and Bubble phases — never Capture. Without this
    // guard an ancestor node's Hover/Focus callback was collected in BOTH the capture
    // and the bubble walk, so it fired TWICE whenever the hit target was a descendant
    // (e.g. a menubar item, hit via its text child, opened two stacked popups; any
    // button containing a text/child node ran its MouseUp callback twice).
64
    if matches!(current_phase, EventPhase::Capture) {
11
        return false;
53
    }
53
    match filter {
51
        EventFilter::Hover(hover_filter) => {
51
            matches_hover_filter(hover_filter, event, current_phase)
        }
        EventFilter::Focus(focus_filter) => {
            matches_focus_filter(focus_filter, event, current_phase)
        }
        EventFilter::Window(window_filter) => {
            matches_window_filter(window_filter, event, current_phase)
        }
        EventFilter::Component(component_filter) => {
            matches_component_filter(component_filter, event, current_phase)
        }
        EventFilter::Application(_) => {
            // Application events - will be implemented in future
2
            false
        }
    }
64
}
/// Check if a component (lifecycle) filter matches the event.
///
/// Lifecycle events produced by `diff::reconcile_dom` carry the target node in
/// `SyntheticEvent.target`, so dispatchers that bypass `propagate_event` and
/// invoke the target directly also need a way to compare. This predicate is
/// the single source of truth for that comparison; changing it without
/// updating `event_type_to_filters` will de-sync dispatch.
24
const fn matches_component_filter(
24
    filter: ComponentEventFilter,
24
    event: &SyntheticEvent,
24
    _phase: EventPhase,
24
) -> bool {
20
    matches!(
24
        (filter, &event.event_type),
        (ComponentEventFilter::AfterMount, EventType::Mount)
            | (ComponentEventFilter::BeforeUnmount, EventType::Unmount)
            | (ComponentEventFilter::Updated, EventType::Update)
            | (ComponentEventFilter::NodeResized, EventType::Resize)
    )
24
}
/// Check if the event data contains a mouse event with the expected button.
27
fn check_mouse_button(data: &EventData, expected: MouseButton) -> bool {
27
    if let EventData::Mouse(mouse_data) = data {
14
        mouse_data.button == expected
    } else {
13
        false
    }
27
}
/// Check if a hover filter matches the event.
// Exhaustive (filter, event-type) truth table: many distinct pairs share the
// `=> true` body. One arm per pair is intentional; merging into giant or-patterns
// would destroy the table's readability/maintainability.
#[allow(clippy::match_same_arms)]
63
fn matches_hover_filter(
63
    filter: HoverEventFilter,
63
    event: &SyntheticEvent,
63
    _phase: EventPhase,
63
) -> bool {
    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};
63
    match (filter, &event.event_type) {
4
        (MouseOver, EventType::MouseOver) => true,
10
        (MouseDown, EventType::MouseDown) => true,
5
        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseDown, EventType::MouseDown) => {
2
            check_mouse_button(&event.data, MouseButton::Right)
        }
        (MiddleMouseDown, EventType::MouseDown) => {
2
            check_mouse_button(&event.data, MouseButton::Middle)
        }
1
        (MouseUp, EventType::MouseUp) => true,
1
        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1
        (MouseEnter, EventType::MouseEnter) => true,
1
        (MouseLeave, EventType::MouseLeave) => true,
1
        (Scroll, EventType::Scroll) => true,
        (ScrollStart, EventType::ScrollStart) => true,
        (ScrollEnd, EventType::ScrollEnd) => true,
        (TextInput, EventType::Input) => true,
        (VirtualKeyDown, EventType::KeyDown) => true,
        (VirtualKeyUp, EventType::KeyUp) => true,
1
        (HoveredFile, EventType::FileHover) => true,
1
        (DroppedFile, EventType::FileDrop) => true,
1
        (HoveredFileCancelled, EventType::FileHoverCancel) => true,
1
        (TouchStart, EventType::TouchStart) => true,
1
        (TouchMove, EventType::TouchMove) => true,
1
        (TouchEnd, EventType::TouchEnd) => true,
1
        (TouchCancel, EventType::TouchCancel) => true,
        (PenDown, EventType::PenDown) => true,
        (PenMove, EventType::PenMove) => true,
        (PenUp, EventType::PenUp) => true,
        (PenEnter, EventType::PenEnter) => true,
        (PenLeave, EventType::PenLeave) => true,
1
        (DragStart, EventType::DragStart) => true,
1
        (Drag, EventType::Drag) => true,
1
        (DragEnd, EventType::DragEnd) => true,
1
        (DragEnter, EventType::DragEnter) => true,
1
        (DragOver, EventType::DragOver) => true,
1
        (DragLeave, EventType::DragLeave) => true,
1
        (Drop, EventType::Drop) => true,
1
        (DoubleClick, EventType::DoubleClick) => true,
1
        (SensorChanged, EventType::SensorChanged) => true,
1
        (GamepadInput, EventType::GamepadInput) => true,
1
        (GeolocationFix, EventType::GeolocationFix) => true,
1
        (GeolocationError, EventType::GeolocationError) => true,
1
        (PermissionChanged, EventType::PermissionChanged) => true,
1
        (BiometricResult, EventType::BiometricResult) => true,
1
        (KeyringResult, EventType::KeyringResult) => true,
13
        _ => false,
    }
63
}
/// Check if a focus filter matches the event.
// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
#[allow(clippy::match_same_arms)]
1
fn matches_focus_filter(
1
    filter: FocusEventFilter,
1
    event: &SyntheticEvent,
1
    _phase: EventPhase,
1
) -> bool {
    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};
1
    match (filter, &event.event_type) {
        (MouseOver, EventType::MouseOver) => true,
        (MouseDown, EventType::MouseDown) => true,
        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseDown, EventType::MouseDown) => {
            check_mouse_button(&event.data, MouseButton::Right)
        }
        (MiddleMouseDown, EventType::MouseDown) => {
            check_mouse_button(&event.data, MouseButton::Middle)
        }
        (MouseUp, EventType::MouseUp) => true,
1
        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
        (MouseEnter, EventType::MouseEnter) => true,
        (MouseLeave, EventType::MouseLeave) => true,
        (Scroll, EventType::Scroll) => true,
        (ScrollStart, EventType::ScrollStart) => true,
        (ScrollEnd, EventType::ScrollEnd) => true,
        (TextInput, EventType::Input) => true,
        (FocusEventFilter::DocumentEdit, EventType::DocumentEdit) => true,
        (VirtualKeyDown, EventType::KeyDown) => true,
        (VirtualKeyUp, EventType::KeyUp) => true,
        (FocusReceived, EventType::Focus) => true,
        (FocusLost, EventType::Blur) => true,
        (DragStart, EventType::DragStart) => true,
        (Drag, EventType::Drag) => true,
        (DragEnd, EventType::DragEnd) => true,
        (DragEnter, EventType::DragEnter) => true,
        (DragOver, EventType::DragOver) => true,
        (DragLeave, EventType::DragLeave) => true,
        (Drop, EventType::Drop) => true,
        // MWA-C-clipboard: W3C clipboard events on the focused element
        // (qualified paths — `use FocusEventFilter::Copy` would shadow the
        // `Copy` trait in this scope).
        (FocusEventFilter::Copy, EventType::Copy) => true,
        (FocusEventFilter::Cut, EventType::Cut) => true,
        (FocusEventFilter::Paste, EventType::Paste) => true,
        _ => false,
    }
1
}
/// Check if a window filter matches the event.
// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
#[allow(clippy::match_same_arms)]
1
fn matches_window_filter(
1
    filter: WindowEventFilter,
1
    event: &SyntheticEvent,
1
    _phase: EventPhase,
1
) -> bool {
    use WindowEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, Resized, Moved, FrameChanged, 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};
1
    match (filter, &event.event_type) {
        (MouseOver, EventType::MouseOver) => true,
        (MouseDown, EventType::MouseDown) => true,
        (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseDown, EventType::MouseDown) => {
            check_mouse_button(&event.data, MouseButton::Right)
        }
        (MiddleMouseDown, EventType::MouseDown) => {
            check_mouse_button(&event.data, MouseButton::Middle)
        }
        (MouseUp, EventType::MouseUp) => true,
1
        (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
        (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
        (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
        (MouseEnter, EventType::MouseEnter) => true,
        (MouseLeave, EventType::MouseLeave) => true,
        (Scroll, EventType::Scroll) => true,
        (ScrollStart, EventType::ScrollStart) => true,
        (ScrollEnd, EventType::ScrollEnd) => true,
        (TextInput, EventType::Input) => true,
        (VirtualKeyDown, EventType::KeyDown) => true,
        (VirtualKeyUp, EventType::KeyUp) => true,
        (HoveredFile, EventType::FileHover) => true,
        (DroppedFile, EventType::FileDrop) => true,
        (HoveredFileCancelled, EventType::FileHoverCancel) => true,
        (Resized, EventType::WindowResize) => true,
        (FrameChanged, EventType::WindowFrameChanged) => true,
        (Moved, EventType::WindowMove) => true,
        (TouchStart, EventType::TouchStart) => true,
        (TouchMove, EventType::TouchMove) => true,
        (TouchEnd, EventType::TouchEnd) => true,
        (TouchCancel, EventType::TouchCancel) => true,
        (PenDown, EventType::PenDown) => true,
        (PenMove, EventType::PenMove) => true,
        (PenUp, EventType::PenUp) => true,
        (PenEnter, EventType::PenEnter) => true,
        (PenLeave, EventType::PenLeave) => true,
        (FocusReceived, EventType::Focus) => true,
        (FocusLost, EventType::Blur) => true,
        (CloseRequested, EventType::WindowClose) => true,
        (ThemeChanged, EventType::ThemeChange) => true,
        (WindowFocusReceived, EventType::WindowFocusIn) => true,
        (WindowFocusLost, EventType::WindowFocusOut) => true,
        (SensorChanged, EventType::SensorChanged) => true,
        (GamepadInput, EventType::GamepadInput) => true,
        (GeolocationFix, EventType::GeolocationFix) => true,
        (GeolocationError, EventType::GeolocationError) => true,
        (PermissionChanged, EventType::PermissionChanged) => true,
        (BiometricResult, EventType::BiometricResult) => true,
        (KeyringResult, EventType::KeyringResult) => true,
        (DragStart, EventType::DragStart) => true,
        (Drag, EventType::Drag) => true,
        (DragEnd, EventType::DragEnd) => true,
        (DragEnter, EventType::DragEnter) => true,
        (DragOver, EventType::DragOver) => true,
        (DragLeave, EventType::DragLeave) => true,
        (Drop, EventType::Drop) => true,
        _ => false,
    }
1
}
/// Detect lifecycle events by comparing old and new DOM state.
///
/// This is the simple, index-based lifecycle detection that doesn't account for
/// node reordering. For more sophisticated reconciliation that can detect moves,
/// use `detect_lifecycle_events_with_reconciliation`.
///
/// Generates Mount, Unmount, and Resize events by comparing DOM hierarchies.
#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
62
#[must_use] pub fn detect_lifecycle_events(
62
    old_dom_id: DomId,
62
    new_dom_id: DomId,
62
    old_hierarchy: Option<&crate::id::NodeHierarchy>,
62
    new_hierarchy: Option<&crate::id::NodeHierarchy>,
62
    old_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
62
    new_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
62
    timestamp: Instant,
62
) -> Vec<SyntheticEvent> {
62
    let old_nodes = collect_node_ids(old_hierarchy);
62
    let new_nodes = collect_node_ids(new_hierarchy);
62
    let mut events = Vec::new();
    // Mount events: nodes in new but not in old
62
    if let Some(layout) = new_layout {
60
        for &node_id in new_nodes.difference(&old_nodes) {
60
            events.push(create_mount_event(node_id, new_dom_id, layout, &timestamp));
60
        }
21
    }
    // Unmount events: nodes in old but not in new
62
    if let Some(layout) = old_layout {
59
        for &node_id in old_nodes.difference(&new_nodes) {
58
            events.push(create_unmount_event(
58
                node_id, old_dom_id, layout, &timestamp,
58
            ));
58
        }
22
    }
    // Resize events: nodes in both with changed bounds
62
    if let (Some(old_l), Some(new_l)) = (old_layout, new_layout) {
61
        for &node_id in old_nodes.intersection(&new_nodes) {
61
            if let Some(ev) = create_resize_event(node_id, new_dom_id, old_l, new_l, &timestamp) {
21
                events.push(ev);
40
            }
        }
41
    }
62
    events
62
}
127
fn collect_node_ids(hierarchy: Option<&crate::id::NodeHierarchy>) -> BTreeSet<NodeId> {
127
    hierarchy
127
        .map(|h| h.as_ref().linear_iter().collect())
127
        .unwrap_or_default()
127
}
144
fn create_lifecycle_event(
144
    event_type: EventType,
144
    node_id: NodeId,
144
    dom_id: DomId,
144
    timestamp: &Instant,
144
    data: LifecycleEventData,
144
) -> SyntheticEvent {
144
    let dom_node_id = DomNodeId {
144
        dom: dom_id,
144
        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
144
    };
144
    SyntheticEvent {
144
        event_type,
144
        source: EventSource::Lifecycle,
144
        phase: EventPhase::Target,
144
        target: dom_node_id,
144
        current_target: dom_node_id,
144
        timestamp: timestamp.clone(),
144
        data: EventData::Lifecycle(data),
144
        stopped: false,
144
        stopped_immediate: false,
144
        prevented_default: false,
144
    }
144
}
63
fn create_mount_event(
63
    node_id: NodeId,
63
    dom_id: DomId,
63
    layout: &BTreeMap<NodeId, LogicalRect>,
63
    timestamp: &Instant,
63
) -> SyntheticEvent {
63
    let current_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
63
    create_lifecycle_event(
63
        EventType::Mount,
63
        node_id,
63
        dom_id,
63
        timestamp,
63
        LifecycleEventData {
63
            reason: LifecycleReason::InitialMount,
63
            previous_bounds: None,
63
            current_bounds,
63
        },
    )
63
}
59
fn create_unmount_event(
59
    node_id: NodeId,
59
    dom_id: DomId,
59
    layout: &BTreeMap<NodeId, LogicalRect>,
59
    timestamp: &Instant,
59
) -> SyntheticEvent {
59
    let previous_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
59
    create_lifecycle_event(
59
        EventType::Unmount,
59
        node_id,
59
        dom_id,
59
        timestamp,
59
        LifecycleEventData {
59
            reason: LifecycleReason::Unmount,
59
            previous_bounds: Some(previous_bounds),
59
            current_bounds: LogicalRect::zero(),
59
        },
    )
59
}
/// Returns `true` iff the two logical sizes differ after fixed-point
/// quantization (~0.001 tolerance), treating a dimension that is NaN on *both*
/// sides as unchanged so a degenerate layout cannot emit a Resize every frame.
44
fn size_changed(old: crate::geom::LogicalSize, new: crate::geom::LogicalSize) -> bool {
64
    fn dim_changed(a: f32, b: f32) -> bool {
64
        if a.is_nan() && b.is_nan() {
4
            return false;
60
        }
        // Fixed-point quantization mirrors `LogicalSize`'s `Ord`/`Hash`.
        // `f32 as i64` saturates on overflow (no wasm32 wraparound); a lone
        // NaN quantizes to `i64::MIN` and so registers as changed.
        #[allow(clippy::cast_possible_truncation)] // intentional fixed-point quantization; saturates
120
        let q = |v: f32| -> i64 {
120
            if v.is_nan() {
2
                i64::MIN
            } else {
118
                (v * 1000.0) as i64
            }
120
        };
60
        q(a) != q(b)
64
    }
44
    dim_changed(old.width, new.width) || dim_changed(old.height, new.height)
44
}
68
fn create_resize_event(
68
    node_id: NodeId,
68
    dom_id: DomId,
68
    old_layout: &BTreeMap<NodeId, LogicalRect>,
68
    new_layout: &BTreeMap<NodeId, LogicalRect>,
68
    timestamp: &Instant,
68
) -> Option<SyntheticEvent> {
68
    let old_bounds = *old_layout.get(&node_id)?;
28
    let new_bounds = *new_layout.get(&node_id)?;
    // Quantized/tolerance compare with an explicit NaN guard. A raw `==` on
    // `LogicalSize` used to compare f32 bit patterns, so a single NaN dimension
    // made `old != new` true *every frame forever* -> an endless Resize-event
    // loop. `size_changed` treats a NaN dimension present on both sides as
    // "unchanged" and otherwise compares fixed-point-quantized values.
27
    if !size_changed(old_bounds.size, new_bounds.size) {
5
        return None;
22
    }
22
    Some(create_lifecycle_event(
22
        EventType::Resize,
22
        node_id,
22
        dom_id,
22
        timestamp,
22
        LifecycleEventData {
22
            reason: LifecycleReason::Resize,
22
            previous_bounds: Some(old_bounds),
22
            current_bounds: new_bounds,
22
        },
22
    ))
68
}
/// Result of lifecycle event detection with reconciliation.
///
/// Contains both the generated lifecycle events and a mapping from old to new
/// node IDs for state migration (focus, scroll, etc.).
#[derive(Debug, Clone, Default)]
pub struct LifecycleEventResult {
    /// Lifecycle events (Mount, Unmount, Resize, Update)
    pub events: Vec<SyntheticEvent>,
    /// Maps old `NodeId` -> new `NodeId` for matched nodes.
    /// Use this to migrate focus, scroll state, and other node-specific state.
    pub node_id_mapping: OrderedMap<NodeId, NodeId>,
}
/// Detect lifecycle events using reconciliation with stable keys and content hashing.
///
/// This is the advanced lifecycle detection that can correctly identify:
/// - **Moves**: When a node changes position but keeps its identity (via key or hash)
/// - **Mounts**: When a new node appears
/// - **Unmounts**: When an existing node disappears
/// - **Resizes**: When a node's layout bounds change
/// - **Updates**: When a keyed node's content changes
///
/// The reconciliation strategy is:
/// 1. **Stable Key Match:** Nodes with `.with_reconciliation_key()` are matched by key (O(1))
/// 2. **Hash Match:** Nodes without keys are matched by content hash (enables reorder detection)
/// 3. **Fallback:** Unmatched nodes generate Mount/Unmount events
///
/// # Arguments
/// * `dom_id` - The DOM identifier
/// * `old_node_data` - Node data from the previous frame
/// * `new_node_data` - Node data from the current frame
/// * `old_layout` - Layout bounds from the previous frame
/// * `new_layout` - Layout bounds from the current frame
/// * `timestamp` - Current timestamp for events
///
/// # Returns
/// A `LifecycleEventResult` containing:
/// - `events`: Lifecycle events to dispatch
/// - `node_id_mapping`: Mapping from old to new `NodeIds` for state migration
///
/// # Example
/// ```rust,ignore
/// let result = detect_lifecycle_events_with_reconciliation(
///     dom_id,
///     &old_node_data,
///     &new_node_data,
///     &old_layout,
///     &new_layout,
///     timestamp,
/// );
///
/// // Dispatch lifecycle events
/// for event in result.events {
///     dispatch_event(event);
/// }
///
/// // Migrate focus to new node ID
/// if let Some(focused) = focus_manager.focused_node {
///     if let Some(&new_id) = result.node_id_mapping.get(&focused) {
///         focus_manager.focused_node = Some(new_id);
///     } else {
///         // Focused node was unmounted
///         focus_manager.focused_node = None;
///     }
/// }
/// ```
1
#[must_use] pub fn detect_lifecycle_events_with_reconciliation(
1
    dom_id: DomId,
1
    old_node_data: &[crate::dom::NodeData],
1
    new_node_data: &[crate::dom::NodeData],
1
    old_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
1
    new_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
1
    old_layout: &OrderedMap<NodeId, LogicalRect>,
1
    new_layout: &OrderedMap<NodeId, LogicalRect>,
1
    timestamp: Instant,
1
) -> LifecycleEventResult {
1
    let diff_result = crate::diff::reconcile_dom(
1
        old_node_data,
1
        new_node_data,
1
        old_hierarchy,
1
        new_hierarchy,
1
        old_layout,
1
        new_layout,
1
        dom_id,
1
        timestamp,
    );
1
    LifecycleEventResult {
1
        events: diff_result.events,
1
        node_id_mapping: crate::diff::create_migration_map(&diff_result.node_moves),
1
    }
1
}
/// Event filter that only fires when an element is hovered over.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum HoverEventFilter {
    /// Mouse moved over the hovered element
    MouseOver,
    /// Any mouse button pressed on the hovered element
    MouseDown,
    /// Left mouse button pressed on the hovered element
    LeftMouseDown,
    /// Right mouse button pressed on the hovered element
    RightMouseDown,
    /// Middle mouse button pressed on the hovered element
    MiddleMouseDown,
    /// Any mouse button released on the hovered element
    MouseUp,
    /// Left mouse button released on the hovered element
    LeftMouseUp,
    /// Right mouse button released on the hovered element
    RightMouseUp,
    /// Middle mouse button released on the hovered element
    MiddleMouseUp,
    /// Mouse entered the hovered element bounds
    MouseEnter,
    /// Mouse left the hovered element bounds
    MouseLeave,
    /// Scroll event on the hovered element
    Scroll,
    /// Scroll started on the hovered element
    ScrollStart,
    /// Scroll ended on the hovered element
    ScrollEnd,
    /// Text input received while element is hovered
    TextInput,
    /// Virtual key pressed while element is hovered
    VirtualKeyDown,
    /// Virtual key released while element is hovered
    VirtualKeyUp,
    /// File is being hovered over the element
    HoveredFile,
    /// File was dropped onto the element
    DroppedFile,
    /// File hover was cancelled
    HoveredFileCancelled,
    /// Touch started on the hovered element
    TouchStart,
    /// Touch moved on the hovered element
    TouchMove,
    /// Touch ended on the hovered element
    TouchEnd,
    /// Touch was cancelled on the hovered element
    TouchCancel,
    /// Pen/stylus made contact on the hovered element
    PenDown,
    /// Pen/stylus moved while in contact on the hovered element
    PenMove,
    /// Pen/stylus lifted from the hovered element
    PenUp,
    /// Pen/stylus entered proximity of the hovered element
    PenEnter,
    /// Pen/stylus left proximity of the hovered element
    PenLeave,
    /// Apple Pencil 2 / Surface Slim Pen 2 barrel squeeze on the hovered
    /// element. Fires once per squeeze. The matching W3C primitive is the
    /// `PointerEvent` with `pointerType: "pen"` and a transient
    /// `tangentialPressure` spike — most apps tie a tool-switch to it.
    PenSqueeze,
    /// Apple Pencil 2 side double-tap on the hovered element. Fires once
    /// per gesture. Usually mapped to "undo" or "switch eraser".
    PenDoubleTap,
    /// Pen/stylus is hovering above the hovered element (in proximity,
    /// not in contact). Continuous: fires per pen-axis update while the
    /// stylus is held above the surface. Maps to W3C
    /// `PointerEvent('pointermove')` with `buttons: 0` and
    /// `pointerType: 'pen'`.
    PenHover,
    /// New GPS / network location fix arrived for a `GeolocationProbe`
    /// in this node's subtree. Payload accessor:
    /// `CallbackInfo::get_geolocation_fix()`.
    GeolocationFix,
    /// Native geolocation subscription errored / was revoked /
    /// timed out.
    GeolocationError,
    /// A motion-sensor reading changed (P6). Window-level mirror:
    /// `WindowEventFilter::SensorChanged`. Read via `get_sensor_reading`.
    SensorChanged,
    /// A gamepad's state changed / it (dis)connected (P6). Read via
    /// `get_primary_gamepad` / `get_gamepad_state`.
    GamepadInput,
    /// Drag started on the hovered element
    DragStart,
    /// Drag in progress on the hovered element
    Drag,
    /// Drag ended on the hovered element
    DragEnd,
    /// Dragged element entered this element (drop target)
    DragEnter,
    /// Dragged element is over this element (drop target, fires continuously)
    DragOver,
    /// Dragged element left this element (drop target)
    DragLeave,
    /// Element was dropped on this element (drop target)
    Drop,
    /// Double-click detected on the hovered element
    DoubleClick,
    /// Long press detected on the hovered element
    LongPress,
    /// Swipe left gesture on the hovered element
    SwipeLeft,
    /// Swipe right gesture on the hovered element
    SwipeRight,
    /// Swipe up gesture on the hovered element
    SwipeUp,
    /// Swipe down gesture on the hovered element
    SwipeDown,
    /// Pinch-in (zoom out) gesture on the hovered element
    PinchIn,
    /// Pinch-out (zoom in) gesture on the hovered element
    PinchOut,
    /// Clockwise rotation gesture on the hovered element
    RotateClockwise,
    /// Counter-clockwise rotation gesture on the hovered element
    RotateCounterClockwise,
    // W3C MouseOut event (bubbling version of MouseLeave)
    /// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
    MouseOut,
    // W3C Focus events (bubbling versions)
    /// Focus is about to move INTO this element or a descendant (W3C `focusin`, bubbles)
    FocusIn,
    /// Focus is about to move OUT of this element or a descendant (W3C `focusout`, bubbles)
    FocusOut,
    // IME Composition events
    /// IME composition started (W3C `compositionstart`)
    CompositionStart,
    /// IME composition updated (W3C `compositionupdate`)
    CompositionUpdate,
    /// IME composition ended (W3C `compositionend`)
    CompositionEnd,
    // Internal System Events (not exposed to user callbacks)
    #[doc(hidden)]
    /// Internal: Single click for text cursor placement
    SystemTextSingleClick,
    #[doc(hidden)]
    /// Internal: Double click for word selection
    SystemTextDoubleClick,
    #[doc(hidden)]
    /// Internal: Triple click for paragraph/line selection
    SystemTextTripleClick,
    // Async capability outcomes (MWA-A1b)
    /// A permission's OS-observed state changed while this node (the
    /// capability's most recent subscriber) is in the target chain.
    PermissionChanged,
    /// A biometric authentication prompt completed.
    BiometricResult,
    /// A keyring store / get / delete operation completed.
    KeyringResult,
}
impl HoverEventFilter {
    /// Check if this is an internal system event that should not be exposed to user callbacks
33
    #[must_use] pub const fn is_system_internal(&self) -> bool {
30
        matches!(
33
            self,
            Self::SystemTextSingleClick
                | Self::SystemTextDoubleClick
                | Self::SystemTextTripleClick
        )
33
    }
    // Exhaustive On -> Option<FocusEventFilter> mapping table; the several `=> None`
    // rows (window-only events) are intentional 1:1 rows — merging would collapse the table.
    #[allow(clippy::match_same_arms)]
12
    #[must_use] pub const fn to_focus_event_filter(&self) -> Option<FocusEventFilter> {
12
        match self {
1
            Self::MouseOver => Some(FocusEventFilter::MouseOver),
            Self::MouseDown => Some(FocusEventFilter::MouseDown),
1
            Self::LeftMouseDown => Some(FocusEventFilter::LeftMouseDown),
            Self::RightMouseDown => Some(FocusEventFilter::RightMouseDown),
            Self::MiddleMouseDown => Some(FocusEventFilter::MiddleMouseDown),
            Self::MouseUp => Some(FocusEventFilter::MouseUp),
            Self::LeftMouseUp => Some(FocusEventFilter::LeftMouseUp),
1
            Self::RightMouseUp => Some(FocusEventFilter::RightMouseUp),
            Self::MiddleMouseUp => Some(FocusEventFilter::MiddleMouseUp),
            Self::MouseEnter => Some(FocusEventFilter::MouseEnter),
            Self::MouseLeave => Some(FocusEventFilter::MouseLeave),
            Self::Scroll => Some(FocusEventFilter::Scroll),
            Self::ScrollStart => Some(FocusEventFilter::ScrollStart),
            Self::ScrollEnd => Some(FocusEventFilter::ScrollEnd),
1
            Self::TextInput => Some(FocusEventFilter::TextInput),
1
            Self::VirtualKeyDown => Some(FocusEventFilter::VirtualKeyDown),
            Self::VirtualKeyUp => Some(FocusEventFilter::VirtualKeyUp),
            Self::HoveredFile => None,
1
            Self::DroppedFile => None,
            Self::HoveredFileCancelled => None,
1
            Self::TouchStart => None,
            Self::TouchMove => None,
            Self::TouchEnd => None,
            Self::TouchCancel => None,
            Self::PenDown => Some(FocusEventFilter::PenDown),
            Self::PenMove => Some(FocusEventFilter::PenMove),
            Self::PenUp => Some(FocusEventFilter::PenUp),
            Self::PenEnter => None,
            Self::PenLeave => None,
            Self::PenSqueeze => None,
            Self::PenDoubleTap => None,
            Self::PenHover => None,
            Self::GeolocationFix => None,
            Self::GeolocationError => None,
            Self::SensorChanged => None,
            Self::GamepadInput => None,
1
            Self::DragStart => Some(FocusEventFilter::DragStart),
            Self::Drag => Some(FocusEventFilter::Drag),
            Self::DragEnd => Some(FocusEventFilter::DragEnd),
            Self::DragEnter => Some(FocusEventFilter::DragEnter),
            Self::DragOver => Some(FocusEventFilter::DragOver),
            Self::DragLeave => Some(FocusEventFilter::DragLeave),
1
            Self::Drop => Some(FocusEventFilter::Drop),
            Self::DoubleClick => Some(FocusEventFilter::DoubleClick),
            Self::LongPress => Some(FocusEventFilter::LongPress),
            Self::SwipeLeft => Some(FocusEventFilter::SwipeLeft),
            Self::SwipeRight => Some(FocusEventFilter::SwipeRight),
            Self::SwipeUp => Some(FocusEventFilter::SwipeUp),
            Self::SwipeDown => Some(FocusEventFilter::SwipeDown),
            Self::PinchIn => Some(FocusEventFilter::PinchIn),
            Self::PinchOut => Some(FocusEventFilter::PinchOut),
            Self::RotateClockwise => Some(FocusEventFilter::RotateClockwise),
            Self::RotateCounterClockwise => {
                Some(FocusEventFilter::RotateCounterClockwise)
            }
            Self::MouseOut => Some(FocusEventFilter::MouseLeave), // mouseout → closest focus equivalent
            Self::FocusIn => Some(FocusEventFilter::FocusIn),
            Self::FocusOut => Some(FocusEventFilter::FocusOut),
            Self::CompositionStart => Some(FocusEventFilter::CompositionStart),
            Self::CompositionUpdate => Some(FocusEventFilter::CompositionUpdate),
            Self::CompositionEnd => Some(FocusEventFilter::CompositionEnd),
            // System internal events - don't convert to focus events
1
            Self::SystemTextSingleClick => None,
1
            Self::SystemTextDoubleClick => None,
1
            Self::SystemTextTripleClick => None,
            // Async capability outcomes — no focus-filter equivalents
            Self::PermissionChanged => None,
            Self::BiometricResult => None,
            Self::KeyringResult => None,
        }
12
    }
}
/// Event filter similar to `HoverEventFilter` that only fires when the element is focused.
///
/// **Important**: In order for this to fire, the item must have a `tabindex` attribute
/// (to indicate that the item is focus-able).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum FocusEventFilter {
    /// Mouse moved over the focused element
    MouseOver,
    /// Any mouse button pressed on the focused element
    MouseDown,
    /// Left mouse button pressed on the focused element
    LeftMouseDown,
    /// Right mouse button pressed on the focused element
    RightMouseDown,
    /// Middle mouse button pressed on the focused element
    MiddleMouseDown,
    /// Any mouse button released on the focused element
    MouseUp,
    /// Left mouse button released on the focused element
    LeftMouseUp,
    /// Right mouse button released on the focused element
    RightMouseUp,
    /// Middle mouse button released on the focused element
    MiddleMouseUp,
    /// Mouse entered the focused element bounds
    MouseEnter,
    /// Mouse left the focused element bounds
    MouseLeave,
    /// Scroll event on the focused element
    Scroll,
    /// Scroll started on the focused element
    ScrollStart,
    /// Scroll ended on the focused element
    ScrollEnd,
    /// Text input received while element is focused
    TextInput,
    /// Virtual key pressed while element is focused
    VirtualKeyDown,
    /// Virtual key released while element is focused
    VirtualKeyUp,
    /// Element received keyboard focus
    FocusReceived,
    /// Element lost keyboard focus
    FocusLost,
    /// Pen/stylus made contact on the focused element
    PenDown,
    /// Pen/stylus moved while in contact on the focused element
    PenMove,
    /// Pen/stylus lifted from the focused element
    PenUp,
    /// Drag started on the focused element
    DragStart,
    /// Drag in progress on the focused element
    Drag,
    /// Drag ended on the focused element
    DragEnd,
    /// Dragged element entered this focused element (drop target)
    DragEnter,
    /// Dragged element is over this focused element (drop target)
    DragOver,
    /// Dragged element left this focused element (drop target)
    DragLeave,
    /// Element was dropped on this focused element (drop target)
    Drop,
    /// Double-click detected on the focused element
    DoubleClick,
    /// Long press detected on the focused element
    LongPress,
    /// Swipe left gesture on the focused element
    SwipeLeft,
    /// Swipe right gesture on the focused element
    SwipeRight,
    /// Swipe up gesture on the focused element
    SwipeUp,
    /// Swipe down gesture on the focused element
    SwipeDown,
    /// Pinch-in (zoom out) gesture on the focused element
    PinchIn,
    /// Pinch-out (zoom in) gesture on the focused element
    PinchOut,
    /// Clockwise rotation gesture on the focused element
    RotateClockwise,
    /// Counter-clockwise rotation gesture on the focused element
    RotateCounterClockwise,
    // W3C Focus events (bubbling versions, fires on focused element when focus changes)
    /// Focus moved into this element or a descendant (W3C `focusin`)
    FocusIn,
    /// Focus moved out of this element or a descendant (W3C `focusout`)
    FocusOut,
    // IME Composition events
    /// IME composition started (W3C `compositionstart`)
    CompositionStart,
    /// IME composition updated (W3C `compositionupdate`)
    CompositionUpdate,
    /// IME composition ended (W3C `compositionend`)
    CompositionEnd,
    // Clipboard events (W3C clipboard-events; MWA-C-clipboard: fire on the
    // focused element BEFORE the OS default action, which preventDefault
    // suppresses). APPENDED at the end for ABI stability — sync to api.json
    // via azul-doc autofix in Phase D.
    /// Content is about to be copied from the focused element (W3C `copy`)
    Copy,
    /// Content is about to be cut from the focused element (W3C `cut`)
    Cut,
    /// Content is about to be pasted into the focused element (W3C `paste`)
    Paste,
    /// A structural document edit was recorded on (or under) the focused
    /// element and awaits the app's apply-and-ack (see
    /// `EventType::DocumentEdit`). APPENDED at the end for ABI stability.
    DocumentEdit,
}
/// Event filter that fires when any action fires on the entire window
/// (regardless of whether any element is hovered or focused over).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum WindowEventFilter {
    /// Mouse moved anywhere in window
    MouseOver,
    /// Any mouse button pressed anywhere in window
    MouseDown,
    /// Left mouse button pressed anywhere in window
    LeftMouseDown,
    /// Right mouse button pressed anywhere in window
    RightMouseDown,
    /// Middle mouse button pressed anywhere in window
    MiddleMouseDown,
    /// Any mouse button released anywhere in window
    MouseUp,
    /// Left mouse button released anywhere in window
    LeftMouseUp,
    /// Right mouse button released anywhere in window
    RightMouseUp,
    /// Middle mouse button released anywhere in window
    MiddleMouseUp,
    /// Mouse entered the window
    MouseEnter,
    /// Mouse left the window
    MouseLeave,
    /// Scroll event anywhere in window
    Scroll,
    /// Scroll started anywhere in window
    ScrollStart,
    /// Scroll ended anywhere in window
    ScrollEnd,
    /// Text input received in window
    TextInput,
    /// Virtual key pressed in window
    VirtualKeyDown,
    /// Virtual key released in window
    VirtualKeyUp,
    /// File is being hovered over the window
    HoveredFile,
    /// File was dropped onto the window
    DroppedFile,
    /// File hover was cancelled
    HoveredFileCancelled,
    /// Window was resized
    Resized,
    /// Window was moved
    Moved,
    /// Window was minimized, maximized, restored, or toggled fullscreen
    FrameChanged,
    /// Touch started anywhere in window
    TouchStart,
    /// Touch moved anywhere in window
    TouchMove,
    /// Touch ended anywhere in window
    TouchEnd,
    /// Touch was cancelled
    TouchCancel,
    /// Window received focus
    FocusReceived,
    /// Window lost focus
    FocusLost,
    /// Window close was requested
    CloseRequested,
    /// System theme changed (light/dark mode)
    ThemeChanged,
    /// Window received OS-level focus
    WindowFocusReceived,
    /// Window lost OS-level focus
    WindowFocusLost,
    /// Pen/stylus made contact anywhere in window
    PenDown,
    /// Pen/stylus moved while in contact anywhere in window
    PenMove,
    /// Pen/stylus lifted anywhere in window
    PenUp,
    /// Pen/stylus entered window proximity
    PenEnter,
    /// Pen/stylus left window proximity
    PenLeave,
    /// Pen barrel-squeeze gesture fired in the window. See
    /// [`HoverEventFilter::PenSqueeze`].
    PenSqueeze,
    /// Pen side double-tap gesture fired in the window. See
    /// [`HoverEventFilter::PenDoubleTap`].
    PenDoubleTap,
    /// Pen hover in the window (in proximity, not in contact). See
    /// [`HoverEventFilter::PenHover`].
    PenHover,
    /// New GPS / network location fix arrived. Payload accessor:
    /// `CallbackInfo::get_geolocation_fix()`. Window-level rather
    /// than per-node because the user's location isn't bound to any
    /// particular DOM node — but a node-level mirror
    /// (`HoverEventFilter::GeolocationFix`) fires on every
    /// `GeolocationProbe` in the tree as well, for the common
    /// "redraw this node when the location changes" pattern.
    GeolocationFix,
    /// Native geolocation subscription dropped or errored (signal
    /// lost, no provider, permission revoked mid-session).
    GeolocationError,
    /// A motion-sensor reading changed (P6). Fires window-level (the device
    /// isn't bound to a node); read via `CallbackInfo::get_sensor_reading`.
    SensorChanged,
    /// A gamepad's buttons / axes changed or it (dis)connected (P6); read via
    /// `get_primary_gamepad` / `get_gamepad_state`.
    GamepadInput,
    /// Drag started anywhere in window
    DragStart,
    /// Drag in progress anywhere in window
    Drag,
    /// Drag ended anywhere in window
    DragEnd,
    /// Dragged element entered a drop target in window
    DragEnter,
    /// Dragged element is over a drop target in window
    DragOver,
    /// Dragged element left a drop target in window
    DragLeave,
    /// Element was dropped on a drop target in window
    Drop,
    /// Double-click detected anywhere in window
    DoubleClick,
    /// Long press detected anywhere in window
    LongPress,
    /// Swipe left gesture anywhere in window
    SwipeLeft,
    /// Swipe right gesture anywhere in window
    SwipeRight,
    /// Swipe up gesture anywhere in window
    SwipeUp,
    /// Swipe down gesture anywhere in window
    SwipeDown,
    /// Pinch-in (zoom out) gesture anywhere in window
    PinchIn,
    /// Pinch-out (zoom in) gesture anywhere in window
    PinchOut,
    /// Clockwise rotation gesture anywhere in window
    RotateClockwise,
    /// Counter-clockwise rotation gesture anywhere in window
    RotateCounterClockwise,
    /// The window's DPI scale factor changed (e.g., moved to a monitor with
    /// different scaling). The new DPI is available via `CallbackInfo::get_hidpi_factor()`.
    DpiChanged,
    /// The window moved to a different monitor. The new monitor is available
    /// via `CallbackInfo::get_current_monitor()`.
    MonitorChanged,
    // Async capability outcomes (MWA-A1b) — window-level mirrors (the
    // outcome isn't inherently bound to a node).
    /// A permission's OS-observed state changed.
    PermissionChanged,
    /// A biometric authentication prompt completed.
    BiometricResult,
    /// A keyring store / get / delete operation completed.
    KeyringResult,
}
impl WindowEventFilter {
    // Exhaustive On -> Option<HoverEventFilter> mapping table (see to_focus_event_filter).
    #[allow(clippy::match_same_arms)]
46
    #[must_use] pub const fn to_hover_event_filter(&self) -> Option<HoverEventFilter> {
46
        match self {
2
            Self::MouseOver => Some(HoverEventFilter::MouseOver),
1
            Self::MouseDown => Some(HoverEventFilter::MouseDown),
2
            Self::LeftMouseDown => Some(HoverEventFilter::LeftMouseDown),
1
            Self::RightMouseDown => Some(HoverEventFilter::RightMouseDown),
1
            Self::MiddleMouseDown => Some(HoverEventFilter::MiddleMouseDown),
1
            Self::MouseUp => Some(HoverEventFilter::MouseUp),
1
            Self::LeftMouseUp => Some(HoverEventFilter::LeftMouseUp),
2
            Self::RightMouseUp => Some(HoverEventFilter::RightMouseUp),
1
            Self::MiddleMouseUp => Some(HoverEventFilter::MiddleMouseUp),
1
            Self::Scroll => Some(HoverEventFilter::Scroll),
            Self::ScrollStart => Some(HoverEventFilter::ScrollStart),
            Self::ScrollEnd => Some(HoverEventFilter::ScrollEnd),
2
            Self::TextInput => Some(HoverEventFilter::TextInput),
2
            Self::VirtualKeyDown => Some(HoverEventFilter::VirtualKeyDown),
1
            Self::VirtualKeyUp => Some(HoverEventFilter::VirtualKeyUp),
1
            Self::HoveredFile => Some(HoverEventFilter::HoveredFile),
2
            Self::DroppedFile => Some(HoverEventFilter::DroppedFile),
1
            Self::HoveredFileCancelled => Some(HoverEventFilter::HoveredFileCancelled),
            // MouseEnter and MouseLeave on the **window** - does not mean a mouseenter
            // and a mouseleave on the hovered element
1
            Self::MouseEnter => None,
1
            Self::MouseLeave => None,
1
            Self::Resized => None,
1
            Self::Moved => None,
            // A frame transition is a WINDOW fact; there is no per-element
            // hover equivalent to map it onto.
            Self::FrameChanged => None,
2
            Self::TouchStart => Some(HoverEventFilter::TouchStart),
            Self::TouchMove => Some(HoverEventFilter::TouchMove),
1
            Self::TouchEnd => Some(HoverEventFilter::TouchEnd),
            Self::TouchCancel => Some(HoverEventFilter::TouchCancel),
1
            Self::FocusReceived => None,
1
            Self::FocusLost => None,
1
            Self::CloseRequested => None,
1
            Self::ThemeChanged => None,
1
            Self::WindowFocusReceived => None, // specific to window!
1
            Self::WindowFocusLost => None,     // specific to window!
1
            Self::PenDown => Some(HoverEventFilter::PenDown),
            Self::PenMove => Some(HoverEventFilter::PenMove),
            Self::PenUp => Some(HoverEventFilter::PenUp),
            Self::PenEnter => Some(HoverEventFilter::PenEnter),
            Self::PenLeave => Some(HoverEventFilter::PenLeave),
            Self::PenSqueeze => Some(HoverEventFilter::PenSqueeze),
            Self::PenDoubleTap => Some(HoverEventFilter::PenDoubleTap),
            Self::PenHover => Some(HoverEventFilter::PenHover),
            Self::GeolocationFix => Some(HoverEventFilter::GeolocationFix),
            Self::GeolocationError => Some(HoverEventFilter::GeolocationError),
            Self::SensorChanged => Some(HoverEventFilter::SensorChanged),
            Self::GamepadInput => Some(HoverEventFilter::GamepadInput),
2
            Self::DragStart => Some(HoverEventFilter::DragStart),
            Self::Drag => Some(HoverEventFilter::Drag),
            Self::DragEnd => Some(HoverEventFilter::DragEnd),
            Self::DragEnter => Some(HoverEventFilter::DragEnter),
            Self::DragOver => Some(HoverEventFilter::DragOver),
            Self::DragLeave => Some(HoverEventFilter::DragLeave),
2
            Self::Drop => Some(HoverEventFilter::Drop),
1
            Self::DoubleClick => Some(HoverEventFilter::DoubleClick),
            Self::LongPress => Some(HoverEventFilter::LongPress),
            Self::SwipeLeft => Some(HoverEventFilter::SwipeLeft),
            Self::SwipeRight => Some(HoverEventFilter::SwipeRight),
            Self::SwipeUp => Some(HoverEventFilter::SwipeUp),
            Self::SwipeDown => Some(HoverEventFilter::SwipeDown),
            Self::PinchIn => Some(HoverEventFilter::PinchIn),
            Self::PinchOut => Some(HoverEventFilter::PinchOut),
            Self::RotateClockwise => Some(HoverEventFilter::RotateClockwise),
            Self::RotateCounterClockwise => {
                Some(HoverEventFilter::RotateCounterClockwise)
            }
            // Window-specific events with no hover equivalent
1
            Self::DpiChanged => None,
1
            Self::MonitorChanged => None,
            // Async capability outcomes — mirror to the hover twin
1
            Self::PermissionChanged => Some(HoverEventFilter::PermissionChanged),
1
            Self::BiometricResult => Some(HoverEventFilter::BiometricResult),
1
            Self::KeyringResult => Some(HoverEventFilter::KeyringResult),
        }
46
    }
}
/// Defines events related to the lifecycle of a DOM node itself.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum ComponentEventFilter {
    /// Fired after the component is first mounted into the DOM.
    AfterMount,
    /// Fired just before the component is removed from the DOM.
    BeforeUnmount,
    /// Fired when the node's layout rectangle has been resized.
    NodeResized,
    /// Fired to trigger the default action for an accessibility component.
    DefaultAction,
    /// Fired when the component becomes selected.
    Selected,
    /// Fired when a keyed component's content has changed (props/state update).
    Updated,
}
/// Defines application-level events not tied to a specific window or node.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum ApplicationEventFilter {
    /// Fired when a new hardware device is connected.
    DeviceConnected,
    /// Fired when a hardware device is disconnected.
    DeviceDisconnected,
    /// Fired when a new monitor/display is connected to the system.
    /// Callback receives updated monitor list via `CallbackInfo::get_monitors()`.
    MonitorConnected,
    /// Fired when a monitor/display is disconnected from the system.
    MonitorDisconnected,
}
/// Sets the target for what events can reach the callbacks specifically.
///
/// This determines the condition under which an event is fired, such as whether
/// the node is hovered, focused, or if the event is window-global.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C, u8)]
pub enum EventFilter {
    /// Calls the attached callback when the mouse is actively over the
    /// given element.
    Hover(HoverEventFilter),
    /// Calls the attached callback when the element is currently focused.
    Focus(FocusEventFilter),
    /// Calls the callback when anything related to the window is happening.
    /// The "hit item" will be the root item of the DOM.
    /// For example, this can be useful for tracking the mouse position
    /// (in relation to the window). In difference to `Desktop`, this only
    /// fires when the window is focused.
    ///
    /// This can also be good for capturing controller input, touch input
    /// (i.e. global gestures that aren't attached to any component, but rather
    /// the "window" itself).
    Window(WindowEventFilter),
    /// API stub: Something happened with the node itself (node resized, created or removed).
    Component(ComponentEventFilter),
    /// Something happened with the application (started, shutdown, device plugged in).
    Application(ApplicationEventFilter),
}
impl EventFilter {
393875
    #[must_use] pub const fn is_focus_callback(&self) -> bool {
393875
        matches!(self, Self::Focus(_))
393875
    }
7926
    #[must_use] pub const fn is_window_callback(&self) -> bool {
7926
        matches!(self, Self::Window(_))
7926
    }
}
/// Creates a function inside an impl <enum type> block that returns a single
/// variant if the enum is that variant.
macro_rules! get_single_enum_type {
    ($fn_name:ident, $enum_name:ident:: $variant:ident($return_type:ty)) => {
6
        #[must_use] pub const fn $fn_name(&self) -> Option<$return_type> {
            use self::$enum_name::*;
6
            match self {
3
                $variant(e) => Some(*e),
3
                _ => None,
            }
6
        }
    };
}
impl EventFilter {
    get_single_enum_type!(as_hover_event_filter, EventFilter::Hover(HoverEventFilter));
    get_single_enum_type!(as_focus_event_filter, EventFilter::Focus(FocusEventFilter));
    get_single_enum_type!(
        as_window_event_filter,
        EventFilter::Window(WindowEventFilter)
    );
}
/// Convert from `On` enum to `EventFilter`.
///
/// This determines which specific filter variant is used based on the event type.
/// For example, `On::TextInput` becomes a Focus event filter, while `On::VirtualKeyDown`
/// becomes a Window event filter (since it's global to the window).
impl From<On> for EventFilter {
    // Exhaustive On -> EventFilter mapping table; the a11y events (Default/Collapse/
    // Expand/Increment/Decrement) all map to MouseUp ("click") as intentional 1:1
    // documented rows — merging would drop the per-row rationale comments.
    #[allow(clippy::match_same_arms)]
10
    fn from(input: On) -> Self {
        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};
10
        match input {
1
            MouseOver => Self::Hover(HoverEventFilter::MouseOver),
            MouseDown => Self::Hover(HoverEventFilter::MouseDown),
            LeftMouseDown => Self::Hover(HoverEventFilter::LeftMouseDown),
            MiddleMouseDown => Self::Hover(HoverEventFilter::MiddleMouseDown),
            RightMouseDown => Self::Hover(HoverEventFilter::RightMouseDown),
            MouseUp => Self::Hover(HoverEventFilter::MouseUp),
            LeftMouseUp => Self::Hover(HoverEventFilter::LeftMouseUp),
            MiddleMouseUp => Self::Hover(HoverEventFilter::MiddleMouseUp),
            RightMouseUp => Self::Hover(HoverEventFilter::RightMouseUp),
            MouseEnter => Self::Hover(HoverEventFilter::MouseEnter),
            MouseLeave => Self::Hover(HoverEventFilter::MouseLeave),
            Scroll => Self::Hover(HoverEventFilter::Scroll),
2
            TextInput => Self::Focus(FocusEventFilter::TextInput), // focus!
            On::DocumentEdit => Self::Focus(FocusEventFilter::DocumentEdit), // focus!
1
            VirtualKeyDown => Self::Window(WindowEventFilter::VirtualKeyDown), // window!
1
            VirtualKeyUp => Self::Window(WindowEventFilter::VirtualKeyUp), // window!
            HoveredFile => Self::Hover(HoverEventFilter::HoveredFile),
            DroppedFile => Self::Hover(HoverEventFilter::DroppedFile),
            HoveredFileCancelled => Self::Hover(HoverEventFilter::HoveredFileCancelled),
            FocusReceived => Self::Focus(FocusEventFilter::FocusReceived), // focus!
            FocusLost => Self::Focus(FocusEventFilter::FocusLost),         // focus!
            // Accessibility events - treat as hover events (element-specific)
1
            Default => Self::Hover(HoverEventFilter::MouseUp), // Default action = click
1
            Collapse => Self::Hover(HoverEventFilter::MouseUp), // Collapse = click
1
            Expand => Self::Hover(HoverEventFilter::MouseUp),  // Expand = click
1
            Increment => Self::Hover(HoverEventFilter::MouseUp), // Increment = click
1
            Decrement => Self::Hover(HoverEventFilter::MouseUp), // Decrement = click
        }
10
    }
}
// Cross-Platform Event Dispatch System
// NOTE: The old dispatch_synthetic_events / CallbackTarget / CallbackToInvoke / EventDispatchResult
// pipeline has been removed. Event dispatch now goes through dispatch_events_propagated() in
// event_v2.rs which uses propagate_event() for W3C Capture→Target→Bubble propagation.
/// Trait for managers to provide their pending events.
///
/// Each manager (`TextInputManager`, `ScrollManager`, etc.) implements this to
/// report what events occurred since the last frame. This enables a unified,
/// lazy event determination system.
pub trait EventProvider {
    /// Get all pending events from this manager.
    ///
    /// Events should include:
    ///
    /// - `target`: The `DomNodeId` that was affected
    /// - `event_type`: What happened (Input, Scroll, Focus, etc.)
    /// - `source`: `EventSource::User` for input, `EventSource::Programmatic` for API calls
    /// - `data`: Type-specific event data
    ///
    /// After calling this, the manager should mark events as "read" so they
    /// aren't returned again next frame.
    fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent>;
}
/// Deduplicate synthetic events by (target node, event type).
///
/// Groups by (target.dom, target.node, `event_type`), keeping the latest timestamp.
1368
#[must_use] pub fn deduplicate_synthetic_events(mut events: Vec<SyntheticEvent>) -> Vec<SyntheticEvent> {
1368
    if events.len() <= 1 {
1146
        return events;
222
    }
170704
    events.sort_by_key(|e| (e.target.dom, e.target.node, e.event_type));
    // Coalesce consecutive events with same target and event_type
222
    let mut result = Vec::with_capacity(events.len());
222
    let mut iter = events.into_iter();
222
    if let Some(mut prev) = iter.next() {
68291
        for curr in iter {
68069
            if prev.target == curr.target && prev.event_type == curr.event_type {
                // Keep the one with later timestamp
65135
                prev = if curr.timestamp > prev.timestamp {
65034
                    curr
                } else {
101
                    prev
                };
2934
            } else {
2934
                result.push(prev);
2934
                prev = curr;
2934
            }
        }
222
        result.push(prev);
    }
222
    result
1368
}
/// Convert `EventType` to `EventFilters` (returns multiple filters for generic + specific events)
///
/// For mouse button events, returns both generic (`MouseUp`) AND button-specific (LeftMouseUp/RightMouseUp).
/// The button-specific filter is derived from the `EventData::Mouse` payload.
// Exhaustive EventType -> Vec<EventFilter> mapping table; some event types map to
// the same filter set as intentional 1:1 rows — merging would collapse the table.
#[allow(clippy::match_same_arms)]
#[allow(clippy::too_many_lines)] // exhaustive EventType -> EventFilter mapping table
205
#[must_use] pub fn event_type_to_filters(event_type: EventType, event_data: &EventData) -> Vec<EventFilter> {
    use EventFilter as EF;
    use EventType as E;
    use FocusEventFilter as F;
    use HoverEventFilter as H;
    use WindowEventFilter as W;
    // Helper: get the button-specific MouseDown filter from EventData
205
    let button_specific_down = || -> Option<EventFilter> {
6
        match event_data {
6
            EventData::Mouse(m) => match m.button {
5
                MouseButton::Left => Some(EF::Hover(H::LeftMouseDown)),
                MouseButton::Right => Some(EF::Hover(H::RightMouseDown)),
                MouseButton::Middle => Some(EF::Hover(H::MiddleMouseDown)),
1
                MouseButton::Other(_) => None, // no specific filter for other buttons
            },
            _ => Some(EF::Hover(H::LeftMouseDown)), // fallback
        }
6
    };
205
    let button_specific_up = || -> Option<EventFilter> {
6
        match event_data {
6
            EventData::Mouse(m) => match m.button {
5
                MouseButton::Left => Some(EF::Hover(H::LeftMouseUp)),
                MouseButton::Right => Some(EF::Hover(H::RightMouseUp)),
                MouseButton::Middle => Some(EF::Hover(H::MiddleMouseUp)),
1
                MouseButton::Other(_) => None, // no specific filter for other buttons
            },
            _ => Some(EF::Hover(H::LeftMouseUp)), // fallback
        }
6
    };
205
    match event_type {
        // Mouse button events - return BOTH generic and button-specific
        E::MouseDown => {
6
            let mut v = vec![EF::Hover(H::MouseDown)];
6
            if let Some(f) = button_specific_down() { v.push(f); }
6
            v
        }
        E::MouseUp => {
6
            let mut v = vec![EF::Hover(H::MouseUp)];
6
            if let Some(f) = button_specific_up() { v.push(f); }
6
            v
        }
        // Click maps to LeftMouseUp: per W3C a `click` completes on button
        // *release* over the target (left-button only). Mapping it to
        // LeftMouseDown fired synthesized clicks as a duplicate MouseDown
        // (press semantics) instead of a completed click.
6
        E::Click => vec![EF::Hover(H::LeftMouseUp)],
        // Other mouse events
4
        E::MouseOver => vec![EF::Hover(H::MouseOver)],
12
        E::MouseEnter => vec![EF::Hover(H::MouseEnter)],
3
        E::MouseLeave => vec![EF::Hover(H::MouseLeave)],
1
        E::MouseOut => vec![EF::Hover(H::MouseOut)],
1
        E::DoubleClick => vec![EF::Hover(H::DoubleClick), EF::Window(W::DoubleClick)],
1
        E::ContextMenu => vec![EF::Hover(H::RightMouseDown)],
        // Keyboard events
15
        E::KeyDown => vec![EF::Focus(F::VirtualKeyDown)],
15
        E::KeyUp => vec![EF::Focus(F::VirtualKeyUp)],
1
        E::KeyPress => vec![EF::Focus(F::TextInput)],
        // IME Composition events
1
        E::CompositionStart => vec![EF::Hover(H::CompositionStart), EF::Focus(F::CompositionStart)],
1
        E::CompositionUpdate => vec![EF::Hover(H::CompositionUpdate), EF::Focus(F::CompositionUpdate)],
1
        E::CompositionEnd => vec![EF::Hover(H::CompositionEnd), EF::Focus(F::CompositionEnd)],
        // Focus events
18
        E::Focus => vec![EF::Focus(F::FocusReceived)],
11
        E::Blur => vec![EF::Focus(F::FocusLost)],
1
        E::FocusIn => vec![EF::Hover(H::FocusIn), EF::Focus(F::FocusIn)],
1
        E::FocusOut => vec![EF::Hover(H::FocusOut), EF::Focus(F::FocusOut)],
        // Input events
2
        E::Input | E::Change => vec![EF::Focus(F::TextInput)],
        // Scroll events
3
        E::Scroll | E::ScrollStart | E::ScrollEnd => vec![EF::Hover(H::Scroll)],
        // Drag events
1
        E::DragStart => vec![EF::Hover(H::DragStart), EF::Window(W::DragStart)],
1
        E::Drag => vec![EF::Hover(H::Drag), EF::Window(W::Drag)],
1
        E::DragEnd => vec![EF::Hover(H::DragEnd), EF::Window(W::DragEnd)],
1
        E::DragEnter => vec![EF::Hover(H::DragEnter), EF::Window(W::DragEnter)],
1
        E::DragOver => vec![EF::Hover(H::DragOver), EF::Window(W::DragOver)],
1
        E::DragLeave => vec![EF::Hover(H::DragLeave), EF::Window(W::DragLeave)],
1
        E::Drop => vec![EF::Hover(H::Drop), EF::Window(W::Drop)],
        // Touch events
12
        E::TouchStart => vec![EF::Hover(H::TouchStart)],
1
        E::TouchMove => vec![EF::Hover(H::TouchMove)],
1
        E::TouchEnd => vec![EF::Hover(H::TouchEnd)],
1
        E::TouchCancel => vec![EF::Hover(H::TouchCancel)],
        // Window events
10
        E::WindowResize => vec![EF::Window(W::Resized)],
        E::WindowFrameChanged => vec![EF::Window(W::FrameChanged)],
3
        E::WindowMove => vec![EF::Window(W::Moved)],
1
        E::WindowClose => vec![EF::Window(W::CloseRequested)],
1
        E::WindowFocusIn => vec![EF::Window(W::WindowFocusReceived)],
2
        E::WindowFocusOut => vec![EF::Window(W::WindowFocusLost)],
1
        E::ThemeChange => vec![EF::Window(W::ThemeChanged)],
2
        E::WindowDpiChanged => vec![EF::Window(W::DpiChanged)],
        E::WindowMonitorChanged => vec![EF::Window(W::MonitorChanged)],
        // Application events
        E::MonitorConnected => vec![EF::Application(ApplicationEventFilter::MonitorConnected)],
        E::MonitorDisconnected => vec![EF::Application(ApplicationEventFilter::MonitorDisconnected)],
        // File events
        // MWA-B7: node-level Hover mirror + the window-level filter. Without
        // the Window mirrors, even WindowEventFilter::DroppedFile
        // registrations were unreachable (file events dispatched to Hover
        // filters only).
1
        E::FileHover => vec![EF::Hover(H::HoveredFile), EF::Window(W::HoveredFile)],
1
        E::FileDrop => vec![EF::Hover(H::DroppedFile), EF::Window(W::DroppedFile)],
1
        E::FileHoverCancel => vec![
1
            EF::Hover(H::HoveredFileCancelled),
1
            EF::Window(W::HoveredFileCancelled),
        ],
        // Lifecycle events — dispatched on the target node via EventFilter::Component.
        // Both Mount and Unmount map to their respective Component filters so that
        // `.add_callback(EventFilter::Component(ComponentEventFilter::AfterMount))`
        // actually fires after reconcile_dom emits a SyntheticEvent{EventType::Mount,..}.
1
        E::Mount => vec![EF::Component(ComponentEventFilter::AfterMount)],
1
        E::Unmount => vec![EF::Component(ComponentEventFilter::BeforeUnmount)],
1
        E::Update => vec![EF::Component(ComponentEventFilter::Updated)],
1
        E::Resize => vec![EF::Component(ComponentEventFilter::NodeResized)],
        // Hardware input-device events (P6) — node-level Hover mirror + the
        // window-level filter (the device isn't bound to a node).
12
        E::SensorChanged => vec![EF::Hover(H::SensorChanged), EF::Window(W::SensorChanged)],
12
        E::GamepadInput => vec![EF::Hover(H::GamepadInput), EF::Window(W::GamepadInput)],
        // Geolocation (MWA-A1): node-level Hover mirror + the window-level
        // filter (a fix isn't bound to a node). The fix itself is read via
        // CallbackInfo::get_geolocation_fix.
1
        E::GeolocationFix => vec![EF::Hover(H::GeolocationFix), EF::Window(W::GeolocationFix)],
1
        E::GeolocationError => vec![EF::Hover(H::GeolocationError), EF::Window(W::GeolocationError)],
        // Async capability outcomes (MWA-A1b): node-level Hover mirror (the
        // permission event targets the capability's subscriber node) + the
        // window-level filter.
1
        E::PermissionChanged => vec![EF::Hover(H::PermissionChanged), EF::Window(W::PermissionChanged)],
1
        E::BiometricResult => vec![EF::Hover(H::BiometricResult), EF::Window(W::BiometricResult)],
1
        E::KeyringResult => vec![EF::Hover(H::KeyringResult), EF::Window(W::KeyringResult)],
        // MWA-C-clipboard: W3C clipboard events — fire on the focused
        // element before the OS default action (preventDefault suppresses
        // the default copy/cut/paste).
1
        E::Copy => vec![EF::Focus(F::Copy)],
1
        E::Cut => vec![EF::Focus(F::Cut)],
1
        E::Paste => vec![EF::Focus(F::Paste)],
        // Unsupported events
14
        _ => vec![],
    }
205
}
// Internal System Event Processing
/// Framework-determined side effects (system changes).
///
/// Unlike `CallbackChange` (from user callbacks), these are determined by the
/// framework's event analysis: hit tests, gesture detection, focus rules,
/// text selection, keyboard shortcuts, etc.
///
/// Both `CallbackChange` (user) and `SystemChange` (framework) are processed
/// through exhaustive match on `PlatformWindowV2` — adding a new variant
/// causes a compile error in `apply_system_change()`.
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "SystemChange must be processed through apply_system_change()"]
pub enum SystemChange {
    /// Advance layout animations by an EXACT step, bypassing the wall clock.
    ///
    /// Only the E2E surface emits this. A headless scenario cannot sample real
    /// time: the same test would land on a different point of the curve on a
    /// fast machine than a slow one, so any assertion mid-flight would be
    /// flaky. Stepping by a fixed `dt` makes the trajectory a pure function of
    /// how many times this ran.
    ///
    /// A `SystemChange` rather than a direct call because `CallbackInfo` hands
    /// out `&LayoutWindow` only — mutation is required to go through this
    /// channel, which is also where the mutable window actually exists.
    // === Text Selection ===
    /// Process a mouse click for text selection (single/double/triple click).
    TextSelectionClick {
        position: LogicalPosition,
        timestamp: Instant,
    },
    /// Extend text selection via mouse drag.
    TextSelectionDrag {
        start_position: LogicalPosition,
        current_position: LogicalPosition,
    },
    /// Unified selection operation: cursor movement, selection extension, or deletion.
    ///
    /// Replaces the old `ArrowKeyNavigation` and `DeleteTextSelection` variants.
    /// Every keyboard shortcut maps to a single `SelectionOp` — see its docs.
    ApplySelectionOp {
        target: DomNodeId,
        op: SelectionOp,
    },
    // === Keyboard Shortcuts ===
    /// Copy selected text to system clipboard (Ctrl+C / Cmd+C).
    CopyToClipboard,
    /// Cut selected text to clipboard and delete (Ctrl+X / Cmd+X).
    CutToClipboard { target: DomNodeId },
    /// Paste text from system clipboard at cursor (Ctrl+V / Cmd+V).
    PasteFromClipboard,
    /// Select all text in focused node (Ctrl+A / Cmd+A).
    SelectAllText,
    /// Undo last text edit (Ctrl+Z / Cmd+Z).
    UndoTextEdit { target: DomNodeId },
    /// Redo last undone edit (Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z).
    RedoTextEdit { target: DomNodeId },
    // === Multi-Cursor ===
    /// Add a cursor at the clicked position (Ctrl+Click).
    /// The position will be hit-tested to find the text cursor location.
    AddCursorAtClick {
        position: LogicalPosition,
    },
    /// Select the next occurrence of the current selection's text (Ctrl+D).
    /// If the primary selection is a cursor, expand it to the word first.
    SelectNextOccurrence {
        target: DomNodeId,
    },
    // === Text Input ===
    /// Apply pending text input from platform (keyboard/IME).
    ApplyPendingTextInput,
    /// Apply text changeset (incremental relayout).
    ApplyTextChangeset,
    // === Drag & Drop ===
    /// Activate node drag on a draggable element.
    ActivateNodeDrag {
        dom_id: DomId,
        node_id: NodeId,
    },
    /// Activate window drag (CSD titlebar).
    ActivateWindowDrag,
    /// Set up drag visual state (:dragging pseudo-state, GPU transform key).
    InitDragVisualState,
    /// Set :drag-over pseudo-state on a target node.
    SetDragOverState { target: DomNodeId, active: bool },
    /// Update current drop target in drag context.
    UpdateDropTarget { target: DomNodeId },
    /// Update GPU transform for active node drag.
    UpdateDragGpuTransform,
    /// End drag: clear pseudo-states, remove GPU keys, end drag session.
    DeactivateDrag,
    // === Focus ===
    /// Change focus to a new target (or clear focus if None).
    /// Handles: `set_focused_node`, `apply_focus_restyle`, `scroll_node_into_view`,
    /// `cursor_blink_timer` start/stop.
    SetFocus {
        new_focus: Option<DomNodeId>,
        old_focus: Option<DomNodeId>,
    },
    /// Clear all text selections.
    ClearAllSelections,
    /// Finalize pending focus changes (cursor initialization after layout).
    FinalizePendingFocusChanges,
    // === Scroll ===
    /// Scroll cursor/selection into view.
    ScrollSelectionIntoView,
    /// Scroll a specific node into view.
    ScrollNodeIntoView { target: DomNodeId },
    /// Scroll cursor into view after text input (needs relayout first).
    ScrollCursorIntoViewAfterTextInput,
    // === Auto-Scroll Timer ===
    /// Start auto-scroll timer for drag-to-scroll (60Hz).
    StartAutoScrollTimer,
    /// Cancel auto-scroll timer.
    StopAutoScrollTimer,
}
impl_option!(
    SystemChange,
    OptionSystemChange,
    copy = false,
    clone = false,
    [Debug, Clone, PartialEq, Eq]
);
impl_vec!(SystemChange, SystemChangeVec, SystemChangeVecDestructor, SystemChangeVecDestructorType, SystemChangeVecSlice, OptionSystemChange);
impl_vec_debug!(SystemChange, SystemChangeVec);
impl_vec_clone!(SystemChange, SystemChangeVec, SystemChangeVecDestructor);
impl_vec_partialeq!(SystemChange, SystemChangeVec);
/// Result of pre-callback internal event filtering
#[derive(Debug, Clone, PartialEq)]
pub struct PreCallbackFilterResult {
    /// System changes to process BEFORE user callbacks
    pub system_changes: Vec<SystemChange>,
    /// Regular events that will be passed to user callbacks
    pub user_events: Vec<SyntheticEvent>,
}
/// Flattened focus/selection state for the input interpreter (replaces trait objects).
#[derive(Debug, Clone, Copy)]
pub struct InputInterpreterState {
    pub focused_node: Option<DomNodeId>,
    pub click_count: u8,
    pub drag_start_position: Option<LogicalPosition>,
    pub has_selection: bool,
}
/// All context needed by the input interpreter to map events to system changes.
///
/// Passed to the interpreter callback. Contains references to the current
/// events and window state. The interpreter reads this and returns system changes.
#[derive(Debug)]
pub struct InputInterpreterInfo<'a> {
    pub events: &'a [SyntheticEvent],
    pub hit_test: Option<&'a FullHitTest>,
    pub keyboard_state: &'a crate::window::KeyboardState,
    pub mouse_state: &'a crate::window::MouseState,
    pub state: InputInterpreterState,
}
/// The `extern "C"` callback type for the input interpreter.
///
/// The first `RefAny` is the user data (vim mode, repeat counter, etc.)
/// held in `InputInterpreterCallback.ctx`. The `*const ()` is an opaque
/// pointer to `InputInterpreterInfo` — callers use the safe wrapper
/// methods to access event data. Returns a `PreCallbackFilterResult`.
///
/// For C/Python: the trampoline extracts the foreign callable from `RefAny.ctx`.
/// For Rust: use `InputInterpreterCallback::from(fn_ptr)` which sets ctx=None.
pub type InputInterpreterCallbackType = extern "C" fn(
    crate::refany::RefAny,
    *const InputInterpreterInfo<'static>,  // Opaque; actual lifetime managed by caller
) -> PreCallbackFilterResult;
/// Configurable input interpreter callback.
///
/// Maps raw platform events + window state → semantic `SystemChange` actions.
/// The default (`default_input_interpreter`) handles standard desktop keybindings.
/// Replace this on `LayoutWindow` to implement vim, game controls, etc.
///
/// ## Pattern
/// - **Rust**: `InputInterpreterCallback::from(my_fn_ptr)` — `ctx` is None
/// - **Python/C**: Set `cb` to a trampoline, `ctx` to `RefAny` wrapping the foreign callable
#[repr(C)]
pub struct InputInterpreterCallback {
    pub cb: InputInterpreterCallbackType,
    pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(InputInterpreterCallback, InputInterpreterCallbackType);
impl Default for InputInterpreterCallback {
23987
    fn default() -> Self {
23987
        Self {
23987
            cb: default_input_interpreter_extern,
23987
            ctx: crate::refany::OptionRefAny::None,
23987
        }
23987
    }
}
/// The `extern "C"` callback type for the post-callback filter.
pub type PostFilterCallbackType = extern "C" fn(
    crate::refany::RefAny,
    bool,                    // prevent_default
    SystemChangeVecSlice,    // pre_changes (immutable slice)
    DomNodeId,               // old_focus (0xFFFF = None)
    DomNodeId,               // new_focus (0xFFFF = None)
) -> SystemChangeVec;
/// Configurable post-callback filter.
#[repr(C)]
pub struct PostFilterCallback {
    pub cb: PostFilterCallbackType,
    pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(PostFilterCallback, PostFilterCallbackType);
impl Default for PostFilterCallback {
23987
    fn default() -> Self {
23987
        Self {
23987
            cb: default_post_filter_extern,
23987
            ctx: crate::refany::OptionRefAny::None,
23987
        }
23987
    }
}
/// What JSON type an op argument expects.
///
/// Spelled out rather than left to prose, because this is read by machines:
/// an agent choosing arguments and a UI validating a macro form both need the
/// type, not a sentence describing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde-json", serde(rename_all = "lowercase"))]
pub enum E2eOpArgType {
    String,
    Number,
    Bool,
    Object,
    Array,
    /// Any JSON value is acceptable.
    Any,
}
/// One argument of one op.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
pub struct E2eOpArg {
    pub name: String,
    #[cfg_attr(feature = "serde-json", serde(rename = "type"))]
    pub arg_type: E2eOpArgType,
    pub required: bool,
    pub description: String,
}
impl Default for E2eOpArgType {
    fn default() -> Self {
        Self::Any
    }
}
/// A worked example: what to send, and what comes back.
///
/// Both halves matter. The arguments alone tell a caller how to invoke the op;
/// the RETURN tells it what it can then assert on, which is what a scenario
/// author and an agent each need before committing to a call.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
pub struct E2eOpExample {
    pub description: String,
    /// Example arguments — a real JSON value, not a string containing JSON.
    ///
    /// These were `String` first, which serialized as escaped JSON inside
    /// JSON: every consumer parsed twice and nothing checked the inner text
    /// was even well-formed, so a malformed example sat in the schema looking
    /// fine.
    pub args: crate::json::Json,
    /// What the op returns for those arguments.
    ///
    /// MUST contain a `success` boolean — validated when the schema is
    /// installed, see `E2eOpSchema::validate`.
    pub returns: crate::json::Json,
}
/// One op the application answers.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
pub struct E2eOpDef {
    pub name: String,
    /// One line, for a list or a picker.
    pub summary: String,
    /// The long form, for a tooltip or an agent's context.
    pub description: String,
    pub args: Vec<E2eOpArg>,
    pub examples: Vec<E2eOpExample>,
}
/// Everything an application advertises about its ops.
///
/// Built in memory as a normal Rust struct and serialized to `Json` at the
/// boundary — on BOTH sides, framework and application. The `Json` hop is a
/// deliberate interim bridge so the shape can be iterated on without an ABI
/// break each time; these types get exposed through api.json later and the
/// bridge goes away.
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
pub struct E2eOpSchema {
    pub ops: Vec<E2eOpDef>,
}
/// Does this value have a top-level `success` boolean?
///
/// Deliberately requires a BOOLEAN, not merely the key: `"success": "yes"` and
/// `"success": null` are the shapes a hand-written schema actually produces,
/// and each would otherwise pass while telling a consumer nothing.
// Only the `not(serde-json)` arm is const-eligible (it is a literal `true`);
// the serde arm calls `to_serde_value`, which allocates. Marking the fn `const`
// would therefore stop compiling in the configuration that actually parses.
#[allow(clippy::missing_const_for_fn)]
4
fn json_has_success_bool(v: &crate::json::Json) -> bool {
    #[cfg(feature = "serde-json")]
    {
4
        v
4
            .to_serde_value()
4
            .get("success")
4
            .is_some_and(serde_json::Value::is_boolean)
    }
    #[cfg(not(feature = "serde-json"))]
    {
        // Without serde there is no parser here. Returning TRUE would silently
        // pass every schema; the honest fallback is a textual check that can
        // only reject things that are definitely wrong.
        let _ = v;
        true
    }
4
}
/// Why an advertised schema is unusable.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum E2eSchemaError {
    /// An op has no name.
    UnnamedOp { index: usize },
    /// Two ops share a name, so dispatch by name is ambiguous.
    DuplicateOpName { name: String },
    /// An argument has no name, or no usable type.
    UnnamedArg { op: String, index: usize },
    /// An example's `returns` has no `success` boolean.
    ///
    /// The contract is that every op result says whether it worked. An
    /// example that omits it is advertising a result shape the runtime is
    /// required to reject.
    ExampleMissingSuccess { op: String, index: usize },
}
impl core::fmt::Display for E2eSchemaError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::UnnamedOp { index } => write!(f, "op #{index} has an empty name"),
            Self::DuplicateOpName { name } => {
                write!(f, "two ops are both named '{name}'; dispatch would be ambiguous")
            }
            Self::UnnamedArg { op, index } => {
                write!(f, "op '{op}' argument #{index} has an empty name")
            }
            Self::ExampleMissingSuccess { op, index } => write!(
                f,
                "op '{op}' example #{index}: `returns` has no `success` boolean. Every op \
                 result must say whether it worked, or a failure is indistinguishable from a \
                 success"
            ),
        }
    }
}
impl E2eOpSchema {
    /// Check the schema is usable, BEFORE anything reads or dispatches it.
    ///
    /// Called when the schema is installed, not on first invocation. The whole
    /// point of advertising a schema is that a plugin, an MCP server or an
    /// agent reads it before calling anything — a schema that only proves
    /// malformed when an op is finally invoked has failed at its one job. An
    /// app shipping an unusable advertisement is a bug in the app, and its own
    /// startup is the cheapest place to catch it.
    ///
    /// # Errors
    ///
    /// Returns [`E2eSchemaError`] if the schema is malformed: a duplicate or
    /// empty op name, or an argument whose declared type is not a valid JSON
    /// type name.
4
    pub fn validate(&self) -> Result<(), E2eSchemaError> {
4
        let mut seen: Vec<&str> = Vec::new();
5
        for (i, op) in self.ops.iter().enumerate() {
5
            if op.name.trim().is_empty() {
                return Err(E2eSchemaError::UnnamedOp { index: i });
5
            }
5
            if seen.contains(&op.name.as_str()) {
1
                return Err(E2eSchemaError::DuplicateOpName { name: op.name.clone() });
4
            }
4
            seen.push(op.name.as_str());
4
            for (a, arg) in op.args.iter().enumerate() {
4
                if arg.name.trim().is_empty() {
                    return Err(E2eSchemaError::UnnamedArg { op: op.name.clone(), index: a });
4
                }
            }
4
            for (e, ex) in op.examples.iter().enumerate() {
4
                if !json_has_success_bool(&ex.returns) {
2
                    return Err(E2eSchemaError::ExampleMissingSuccess {
2
                        op: op.name.clone(),
2
                        index: e,
2
                    });
2
                }
            }
        }
1
        Ok(())
4
    }
    /// Serialize to the `Json` that crosses the C ABI.
    #[must_use]
26412
    pub fn to_json(&self) -> crate::json::Json {
        #[cfg(feature = "serde-json")]
        {
            // Falling back to an empty-op object rather than to null: a
            // consumer must never be handed something that reads as "not
            // parseable" when the truth is "serialization failed".
            // serde_json preserves declaration order; `Json::parse` does NOT
            // (it re-serializes sorted, which buried `name` and `summary`
            // under `args`/`description`/`examples`). Keep the serialized text
            // and wrap it, rather than round-tripping through the parser.
67
            serde_json::to_string(self).ok().map_or_else(
                || crate::json::Json {
                    value_type: crate::json::JsonType::Object,
                    internal: crate::json::JsonInternal {
                        string_value: AzString::from_const_str(r#"{"ops":[]}"#),
                        ..Default::default()
                    },
                },
                |text| crate::json::Json {
67
                    value_type: crate::json::JsonType::Object,
67
                    internal: crate::json::JsonInternal {
67
                        string_value: AzString::from(text),
67
                        ..Default::default()
67
                    },
67
                },
            )
        }
        #[cfg(not(feature = "serde-json"))]
26345
        crate::json::Json {
26345
            value_type: crate::json::JsonType::Object,
26345
            internal: crate::json::JsonInternal {
26345
                string_value: AzString::from_const_str(r#"{"ops":[]}"#),
26345
                ..Default::default()
26345
            },
26345
        }
26412
    }
}
/// Outcome of a user-defined E2E op.
#[repr(C)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomE2eOpResult {
    /// Whether the application RECOGNISED this op name.
    ///
    /// This is not "did it succeed" — it is "was this op mine at all". The
    /// debug server turns `false` into an explicit unknown-op error rather
    /// than an OK. Without it, a hook that returns a result for every name
    /// makes a typo'd op in a scenario indistinguishable from one that ran:
    /// non-assert ops produce no output of their own, so a silent success and
    /// a silent miss look identical from the outside.
    pub handled: bool,
    /// Result payload, JSON. Reported back to the scenario as-is, so a
    /// scenario can assert on it. Empty string is a valid empty result.
    pub json: AzString,
}
impl Default for CustomE2eOpResult {
    /// "Not my op": `handled: false` with an empty payload — the value a
    /// bridge (e.g. the generated Python trampoline) returns when no user
    /// handler can run. Mirrors the debug server's unknown-op semantics.
    fn default() -> Self {
        Self { handled: false, json: AzString::from_const_str("") }
    }
}
/// The `extern "C"` callback type for a user-defined E2E op.
///
/// Receives the op name and its arguments as a JSON string, exactly as they
/// appeared in the scenario, and returns a `CustomE2eOpResult`. This is the
/// hook for driving application-level actions from a scenario — "now load the
/// document" — that the engine has no way to express on the app's behalf.
pub type CustomE2eOpCallbackType = extern "C" fn(
    crate::refany::RefAny, // ctx
    AzString,              // op name
    AzString,              // arguments, JSON
) -> CustomE2eOpResult;
/// Application-provided handler for E2E ops the engine does not implement.
#[repr(C)]
pub struct CustomE2eOpCallback {
    pub cb: CustomE2eOpCallbackType,
    pub ctx: crate::refany::OptionRefAny,
    /// Describes every op `cb` answers: name, summary, description, the JSON
    /// type each argument expects, usage examples, and example returns.
    ///
    /// DATA, not a second callback — discovery is a field read, so it needs
    /// no invocation and no debug HTTP server. That is what lets a plugin
    /// enumerate host capabilities, a locally-spawned MCP server expose the
    /// app to an agent that would otherwise drive it by screenshot, or a
    /// `script.json` be handed straight to the binary.
    ///
    /// The examples and types are not documentation garnish: they are what a
    /// macro picker renders and what an agent reads in place of a screenshot.
    ///
    /// `Json` deliberately, not typed structs — the schema can then follow an
    /// OpenAPI-style operation shape and gain fields without an ABI break.
    /// Typed structs come later.
    pub op_schema: crate::json::Json,
}
// `impl_callback_traits!` only ever reads `self.cb`, so it is correct here.
// The full `impl_callback!` is NOT usable: its generated `Clone` and `From`
// construct `Self { cb, ctx }` literally, and this struct has a third field.
impl_callback_traits!(CustomE2eOpCallback);
impl Clone for CustomE2eOpCallback {
    fn clone(&self) -> Self {
        Self {
            cb: self.cb,
            ctx: self.ctx.clone(),
            op_schema: self.op_schema.clone(),
        }
    }
}
impl From<CustomE2eOpCallbackType> for CustomE2eOpCallback {
    /// Installs a handler that advertises NOTHING.
    ///
    /// A bare fn pointer carries no schema, so this cannot invent one. An app
    /// converting from a fn pointer gets a working handler whose ops are
    /// undiscoverable until it sets `op_schema` — visible in a plugin listing
    /// as an empty op list, which is the honest answer rather than a guess.
    fn from(cb: CustomE2eOpCallbackType) -> Self {
        Self {
            cb,
            ..Self::default()
        }
    }
}
impl Default for CustomE2eOpCallback {
26410
    fn default() -> Self {
26410
        Self {
26410
            cb: default_custom_e2e_op_extern,
26410
            ctx: crate::refany::OptionRefAny::None,
26410
            // An empty LIST, not an empty string or a null. A consumer must
26410
            // be able to tell "this app advertises no ops" from "this app
26410
            // returned nothing parseable"; those mean different things to a
26410
            // plugin deciding whether the host is usable at all.
26410
            // An empty LIST, not an empty string or a null. A consumer must
26410
            // be able to tell "this app advertises no ops" from "this app
26410
            // returned nothing parseable"; those mean different things to a
26410
            // plugin deciding whether the host is usable at all.
26410
            op_schema: E2eOpSchema::default().to_json(),
26410
        }
26410
    }
}
/// Default handler: recognises NOTHING.
///
/// `handled: false` is the load-bearing part. An app that has not installed a
/// handler must make a scenario referencing a custom op FAIL, not pass
/// quietly — the default has to be the safe answer, because it is the one
/// that ships when nobody thought about this.
#[must_use]
1
pub extern "C" fn default_custom_e2e_op_extern(
1
    _ctx: crate::refany::RefAny,
1
    _op: AzString,
1
    _args: AzString,
1
) -> CustomE2eOpResult {
1
    CustomE2eOpResult {
1
        handled: false,
1
        json: AzString::from_const_str(""),
1
    }
1
}
// Keep simpler Rust fn pointer aliases for internal use
pub type InputInterpreterFn = fn(
    info: &InputInterpreterInfo<'_>,
) -> PreCallbackFilterResult;
pub type PostFilterFn = fn(
    prevent_default: bool,
    pre_changes: &[SystemChange],
    old_focus: Option<DomNodeId>,
    new_focus: Option<DomNodeId>,
) -> Vec<SystemChange>;
/// Mouse button state for drag tracking
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MouseButtonState {
    pub left_down: bool,
    pub right_down: bool,
    pub middle_down: bool,
}
/// Arrow key / cursor navigation directions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArrowDirection {
    Left,
    Right,
    Up,
    Down,
    /// Home key: move to start of current line
    LineStart,
    /// End key: move to end of current line
    LineEnd,
    /// Ctrl+Home: move to start of document
    DocumentStart,
    /// Ctrl+End: move to end of document
    DocumentEnd,
}
impl ArrowDirection {
    /// Map a `VirtualKeyCode` plus the `ctrl` modifier into an `ArrowDirection`.
    /// Returns `None` if the key is not a navigation key.
349
    #[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, ctrl: bool) -> Option<Self> {
        use crate::window::VirtualKeyCode::{Left, Right, Up, Down, Home, End};
5
        Some(match vk {
4
            Left => Self::Left,
4
            Right => Self::Right,
3
            Up => Self::Up,
3
            Down => Self::Down,
3
            Home if ctrl => Self::DocumentStart,
2
            Home => Self::LineStart,
3
            End if ctrl => Self::DocumentEnd,
2
            End => Self::LineEnd,
325
            _ => return None,
        })
349
    }
    /// Convert to a `(SelectionDirection, SelectionStep)` pair for the
    /// selection-op interpreter. `ctrl` upgrades arrow keys to word jumps.
30
    #[must_use] pub const fn to_selection(self, ctrl: bool) -> (SelectionDirection, SelectionStep) {
7
        match self {
3
            Self::Left if ctrl => (SelectionDirection::Backward, SelectionStep::Word),
2
            Self::Right if ctrl => (SelectionDirection::Forward, SelectionStep::Word),
4
            Self::Left => (SelectionDirection::Backward, SelectionStep::Character),
3
            Self::Right => (SelectionDirection::Forward, SelectionStep::Character),
5
            Self::Up => (SelectionDirection::Backward, SelectionStep::VisualLine),
4
            Self::Down => (SelectionDirection::Forward, SelectionStep::VisualLine),
3
            Self::LineStart => (SelectionDirection::Backward, SelectionStep::Line),
1
            Self::LineEnd => (SelectionDirection::Forward, SelectionStep::Line),
1
            Self::DocumentStart => (SelectionDirection::Backward, SelectionStep::Document),
4
            Self::DocumentEnd => (SelectionDirection::Forward, SelectionStep::Document),
        }
30
    }
}
/// Direction of cursor movement or selection expansion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionDirection {
    Forward,
    Backward,
}
/// Granularity of cursor movement or selection expansion.
///
/// Combined with `SelectionDirection`, determines how far a cursor moves
/// or a selection expands. Reused for navigation, deletion, and visual
/// selection — a single code path for word boundaries etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionStep {
    /// One grapheme cluster (arrow keys, Backspace, Delete)
    Character,
    /// One word boundary (Ctrl+arrow, Ctrl+Backspace, Ctrl+Delete)
    Word,
    /// To line boundary (Home/End)
    Line,
    /// One visual line up/down (Up/Down arrows)
    VisualLine,
    /// To document boundary (Ctrl+Home/End)
    Document,
}
/// What to do with the selection after moving.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionMode {
    /// Collapse selection to cursor, then move (plain arrow key).
    Move,
    /// Extend selection from anchor to new position (Shift+arrow).
    Extend,
    /// Expand cursor to range in the given direction, then delete the range
    /// (Backspace/Delete). If a range already exists, just delete it.
    Delete,
}
/// A unified selection operation that replaces all cursor movement,
/// selection extension, and text deletion commands.
///
/// Every keyboard shortcut for cursor movement or deletion maps to this:
/// - Arrow Left = (Backward, Character, Move, 1)
/// - Shift+Right = (Forward, Character, Extend, 1)
/// - Ctrl+Backspace = (Backward, Word, Delete, 1)
/// - Home = (Backward, Line, Move, 1)
/// - Ctrl+End = (Forward, Document, Move, 1)
///
/// The `repeat` field enables vim-style commands: 3w = (Forward, Word, Move, 3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub struct SelectionOp {
    pub direction: SelectionDirection,
    pub step: SelectionStep,
    pub mode: SelectionMode,
    pub repeat: usize,
}
impl SelectionOp {
31
    #[must_use] pub const fn new(direction: SelectionDirection, step: SelectionStep, mode: SelectionMode) -> Self {
31
        Self { direction, step, mode, repeat: 1 }
31
    }
}
/// Keyboard shortcuts for text editing
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyboardShortcut {
    Copy,      // Ctrl+C
    Cut,       // Ctrl+X
    Paste,     // Ctrl+V
    SelectAll, // Ctrl+A
    Undo,      // Ctrl+Z
    Redo,      // Ctrl+Y or Ctrl+Shift+Z
}
impl KeyboardShortcut {
    /// Map a `(VirtualKeyCode, primary, shift)` triple to a text-editing
    /// shortcut. Returns `None` if the key combination is not a recognized
    /// shortcut or if `primary` is not held. `primary` is the platform's
    /// primary modifier — Cmd on macOS, Ctrl elsewhere — obtained from
    /// `KeyboardState::primary_down()` (MWA-A2: hardcoding Ctrl here made
    /// every editing shortcut dead on macOS).
351
    #[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, primary: bool, shift: bool) -> Option<Self> {
        use crate::window::VirtualKeyCode::{C, X, V, A, Z, Y};
351
        if !primary {
329
            return None;
22
        }
5
        Some(match vk {
6
            C => Self::Copy,
2
            X => Self::Cut,
2
            V => Self::Paste,
2
            A => Self::SelectAll,
3
            Z if shift => Self::Redo,
2
            Z => Self::Undo,
2
            Y => Self::Redo,
3
            _ => return None,
        })
351
    }
}
/// Default input interpreter: standard desktop keybindings.
///
/// This is the default `InputInterpreterFn` that handles arrow keys, Home/End,
/// Backspace/Delete, Ctrl+C/V/A/Z, mouse clicks, and drag selection.
/// Replace it on `LayoutWindow` to implement vim, game controls, etc.
/// `extern "C"` trampoline for `default_input_interpreter`.
#[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.
1
#[must_use] pub extern "C" fn default_input_interpreter_extern(
1
    _user_data: crate::refany::RefAny,
1
    info_ptr: *const InputInterpreterInfo<'static>,
1
) -> PreCallbackFilterResult {
1
    if info_ptr.is_null() {
1
        return PreCallbackFilterResult {
1
            system_changes: Vec::new(),
1
            user_events: Vec::new(),
1
        };
    }
    let info = unsafe { &*info_ptr };
    default_input_interpreter(info)
1
}
/// `extern "C"` trampoline for `default_post_filter`.
#[must_use] pub extern "C" fn default_post_filter_extern(
    _user_data: crate::refany::RefAny,
    prevent_default: bool,
    pre_changes: SystemChangeVecSlice,
    old_focus: DomNodeId,
    new_focus: DomNodeId,
) -> SystemChangeVec {
    let pre_changes_slice = pre_changes.as_slice();
    let old = old_focus.node.into_crate_internal().map(|_| old_focus);
    let new = new_focus.node.into_crate_internal().map(|_| new_focus);
    default_post_filter(prevent_default, pre_changes_slice, old, new).into()
}
9
#[must_use] pub fn default_input_interpreter(
9
    info: &InputInterpreterInfo<'_>,
9
) -> PreCallbackFilterResult {
9
    let ctx = FilterContext {
9
        hit_test: info.hit_test,
9
        keyboard_state: info.keyboard_state,
9
        mouse_state: info.mouse_state,
9
        click_count: info.state.click_count,
9
        focused_node: info.state.focused_node,
9
        drag_start_position: info.state.drag_start_position,
9
    };
9
    let (system_changes, user_events) = info.events.iter().fold(
9
        (Vec::new(), Vec::new()),
10
        |(mut internal, mut user), event| {
10
            match process_event_for_internal(&ctx, event) {
5
                Some(InternalEventAction::AddAndSkip(evt)) => {
5
                    internal.push(evt);
5
                }
2
                Some(InternalEventAction::AddAndPass(evt)) => {
2
                    internal.push(evt);
2
                    user.push(event.clone());
2
                }
3
                None => {
3
                    user.push(event.clone());
3
                }
            }
10
            (internal, user)
10
        },
    );
9
    PreCallbackFilterResult {
9
        system_changes,
9
        user_events,
9
    }
9
}
/// Backward-compatible wrapper that calls `default_input_interpreter`.
7
pub fn pre_callback_filter_internal_events<SM, FM>(
7
    events: &[SyntheticEvent],
7
    hit_test: Option<&FullHitTest>,
7
    keyboard_state: &crate::window::KeyboardState,
7
    mouse_state: &crate::window::MouseState,
7
    selection_manager: &SM,
7
    focus_manager: &FM,
7
) -> PreCallbackFilterResult
7
where
7
    SM: SelectionManagerQuery,
7
    FM: FocusManagerQuery,
{
7
    let info = InputInterpreterInfo {
7
        events,
7
        hit_test,
7
        keyboard_state,
7
        mouse_state,
7
        state: InputInterpreterState {
7
            focused_node: focus_manager.get_focused_node_id(),
7
            click_count: selection_manager.get_click_count(),
7
            drag_start_position: selection_manager.get_drag_start_position(),
7
            has_selection: selection_manager.has_selection(),
7
        },
7
    };
7
    default_input_interpreter(&info)
7
}
/// Context for filtering internal events (used by `default_input_interpreter`)
struct FilterContext<'a> {
    hit_test: Option<&'a FullHitTest>,
    keyboard_state: &'a crate::window::KeyboardState,
    mouse_state: &'a crate::window::MouseState,
    click_count: u8,
    focused_node: Option<DomNodeId>,
    drag_start_position: Option<LogicalPosition>,
}
/// Process a single event and determine if it generates an internal event
10
fn process_event_for_internal(
10
    ctx: &FilterContext<'_>,
10
    event: &SyntheticEvent,
10
) -> Option<InternalEventAction> {
10
    match event.event_type {
2
        EventType::MouseDown => handle_mouse_down(event, ctx.hit_test, ctx.click_count, ctx.mouse_state, ctx.keyboard_state),
        EventType::MouseOver => handle_mouse_over(
            event,
            ctx.hit_test,
            ctx.mouse_state,
            ctx.drag_start_position,
        ),
7
        EventType::KeyDown => handle_key_down(
7
            event,
7
            ctx.keyboard_state,
7
            ctx.focused_node,
        ),
        EventType::MouseUp => Some(handle_mouse_up()),
1
        _ => None,
    }
10
}
/// Releasing the button ends a text-selection drag, so the autoscroll timer
/// has to go.
///
/// `StopAutoScrollTimer` existed and was handled, but NOTHING emitted it:
/// teardown relied entirely on the 60Hz callback noticing on its next tick
/// that the button had been released and terminating itself. Any path that
/// loses the release — a grab broken by the window manager, a crossing that
/// swallows it — left a timer running at 60Hz for the life of the window.
///
/// It passes to callbacks: a `MouseUp` is a user event, and stopping an
/// internal timer must not swallow it.
1
const fn handle_mouse_up() -> InternalEventAction {
1
    InternalEventAction::AddAndPass(SystemChange::StopAutoScrollTimer)
1
}
/// Action to take after processing an event for internal system events
enum InternalEventAction {
    /// Add system change and skip passing to user callbacks
    AddAndSkip(SystemChange),
    /// Add system change but also pass to user callbacks
    AddAndPass(SystemChange),
}
/// Extract the front-most hovered node from a hit test.
///
/// Picks the node with the minimum `hit_depth` (0 = frontmost/topmost in
/// z-order) across every hovered DOM. The previous implementation took the
/// first entry of the `BTreeMap` (lowest `NodeId`), which ignored z-order
/// entirely and targeted the back-most node under overlapping elements.
/// Ties are broken deterministically by (`DomId`, `NodeId`) iteration order.
18
fn get_first_hovered_node(hit_test: Option<&FullHitTest>) -> Option<DomNodeId> {
18
    let ht = hit_test?;
16
    let mut best: Option<(DomId, NodeId, u32)> = None;
30
    for (dom_id, hit_data) in &ht.hovered_nodes {
34
        for (node_id, item) in &hit_data.regular_hit_test_nodes {
20
            let is_better = match best {
13
                None => true,
7
                Some((_, _, best_depth)) => item.hit_depth < best_depth,
            };
20
            if is_better {
15
                best = Some((*dom_id, *node_id, item.hit_depth));
15
            }
        }
    }
16
    let (dom_id, node_id, _) = best?;
13
    Some(DomNodeId {
13
        dom: dom_id,
13
        node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
13
    })
18
}
/// Extract mouse position from event data, falling back to `mouse_state` if not available
19
fn get_mouse_position_with_fallback(
19
    event: &SyntheticEvent,
19
    mouse_state: &crate::window::MouseState,
19
) -> LogicalPosition {
19
    match &event.data {
16
        EventData::Mouse(mouse_data) => mouse_data.position,
        _ => {
            // Fallback: use current cursor position from mouse_state
            // This handles synthetic events from debug API and automation
            // where EventData may not contain the mouse position
3
            mouse_state.cursor_position.get_position().unwrap_or(LogicalPosition::zero())
        }
    }
19
}
/// Handle `MouseDown` event - detect text selection clicks and Ctrl+Click for multi-cursor
14
fn handle_mouse_down(
14
    event: &SyntheticEvent,
14
    hit_test: Option<&FullHitTest>,
14
    click_count: u8,
14
    mouse_state: &crate::window::MouseState,
14
    keyboard_state: &crate::window::KeyboardState,
14
) -> Option<InternalEventAction> {
14
    let effective_click_count = if click_count == 0 { 1 } else { click_count };
14
    if effective_click_count > 3 {
4
        return None;
10
    }
10
    let _target = get_first_hovered_node(hit_test)?;
8
    let position = get_mouse_position_with_fallback(event, mouse_state);
    // Ctrl+Click (or Cmd+Click on macOS): add cursor at click position.
    // Use the platform PRIMARY modifier so this fires on Cmd on macOS
    // (where Ctrl+Click is the secondary-click gesture) — `ctrl_down()`
    // was wrong there.
8
    if keyboard_state.primary_down() && effective_click_count == 1 {
1
        return Some(InternalEventAction::AddAndPass(
1
            SystemChange::AddCursorAtClick { position },
1
        ));
7
    }
7
    Some(InternalEventAction::AddAndPass(
7
        SystemChange::TextSelectionClick {
7
            position,
7
            timestamp: event.timestamp.clone(),
7
        },
7
    ))
14
}
/// Handle `MouseOver` event - detect drag selection
5
fn handle_mouse_over(
5
    event: &SyntheticEvent,
5
    _hit_test: Option<&FullHitTest>,
5
    mouse_state: &crate::window::MouseState,
5
    drag_start_position: Option<LogicalPosition>,
5
) -> Option<InternalEventAction> {
5
    if !mouse_state.left_down {
1
        return None;
4
    }
4
    let start_position = drag_start_position?;
    // Deliberately NOT gated on a hovered hit node. A drag that leaves the
    // text — into the container's padding, over a gap, past the last line —
    // still extends the selection in every native editor, and the endpoint is
    // resolved from the pointer position against the ANCHOR block's layout
    // (`process_mouse_drag_for_selection`), not from whatever happens to be
    // under the cursor. Requiring a hit node froze the selection exactly where
    // the user was reaching for more of it.
3
    let current_position = get_mouse_position_with_fallback(event, mouse_state);
3
    Some(InternalEventAction::AddAndPass(
3
        SystemChange::TextSelectionDrag {
3
            start_position,
3
            current_position,
3
        },
3
    ))
5
}
/// Handle `KeyDown` event - detect shortcuts, arrow keys, and delete keys
23
fn handle_key_down(
23
    event: &SyntheticEvent,
23
    keyboard_state: &crate::window::KeyboardState,
23
    focused_node: Option<DomNodeId>,
23
) -> Option<InternalEventAction> {
    use crate::window::VirtualKeyCode;
23
    let target = focused_node?;
21
    let EventData::Keyboard(kbd) = &event.data else {
2
        return None;
    };
    // Read the key and modifiers from THIS event's payload, not from the live
    // `keyboard_state`. The live state can have advanced (another key pressed /
    // released) between when the event was queued and when it is dispatched, so
    // reading it here could act on the wrong key/modifiers. `keyboard_state` is
    // retained only for the platform where the event does not carry a key.
19
    let _ = keyboard_state;
    // MWA-A2: standard shortcuts key off the PRIMARY modifier (Cmd on
    // macOS, Ctrl elsewhere); word-jump / word-delete keys off the
    // platform's word modifier (Option on macOS, Ctrl elsewhere).
19
    let primary = if cfg!(target_os = "macos") {
        kbd.modifiers.meta
    } else {
19
        kbd.modifiers.ctrl
    };
19
    let word_mod = if cfg!(target_os = "macos") {
        kbd.modifiers.alt
    } else {
19
        kbd.modifiers.ctrl
    };
19
    let shift = kbd.modifiers.shift;
19
    let vk_owned = VirtualKeyCode::from_u32(kbd.key_code)?;
15
    let vk = &vk_owned;
    // Check keyboard shortcuts (primary+key) → emit specific SystemChange
    // variants. Standard editing shortcuts are routed through the
    // `KeyboardShortcut` enum, and a couple of additional Azul-specific
    // primary-modifier combos are matched after.
15
    if primary {
4
        if let Some(shortcut) = KeyboardShortcut::from_key(*vk, primary, shift) {
3
            let change = match shortcut {
3
                KeyboardShortcut::Copy => SystemChange::CopyToClipboard,
                KeyboardShortcut::Cut => SystemChange::CutToClipboard { target },
                KeyboardShortcut::Paste => SystemChange::PasteFromClipboard,
                KeyboardShortcut::SelectAll => SystemChange::SelectAllText,
                KeyboardShortcut::Undo => SystemChange::UndoTextEdit { target },
                KeyboardShortcut::Redo => SystemChange::RedoTextEdit { target },
            };
3
            return Some(InternalEventAction::AddAndSkip(change));
1
        }
1
        if matches!(vk, VirtualKeyCode::D) {
            return Some(InternalEventAction::AddAndSkip(
                SystemChange::SelectNextOccurrence { target },
            ));
1
        }
11
    }
    // Unified: arrow keys, Home/End, Backspace/Delete all map to SelectionOp.
12
    let mode_for_shift = if shift { SelectionMode::Extend } else { SelectionMode::Move };
12
    let selection_op = if let Some(arrow) = ArrowDirection::from_key(*vk, word_mod) {
2
        let (direction, step) = arrow.to_selection(word_mod);
2
        SelectionOp::new(direction, step, mode_for_shift)
    } else {
10
        match vk {
            // Backspace/Delete = Delete mode (word modifier upgrades to
            // Word: Option+Backspace on macOS, Ctrl+Backspace elsewhere)
3
            VirtualKeyCode::Back => SelectionOp::new(
3
                SelectionDirection::Backward,
3
                if word_mod { SelectionStep::Word } else { SelectionStep::Character },
3
                SelectionMode::Delete,
            ),
2
            VirtualKeyCode::Delete => SelectionOp::new(
2
                SelectionDirection::Forward,
2
                if word_mod { SelectionStep::Word } else { SelectionStep::Character },
2
                SelectionMode::Delete,
            ),
5
            _ => return None,
        }
    };
7
    Some(InternalEventAction::AddAndSkip(
7
        SystemChange::ApplySelectionOp { target, op: selection_op },
7
    ))
23
}
/// Trait for querying selection manager state.
///
/// This allows `pre_callback_filter_internal_events` to query manager state
/// without depending on the concrete `SelectionManager` type from layout crate.
pub trait SelectionManagerQuery {
    /// Get the current click count (1 = single, 2 = double, 3 = triple)
    fn get_click_count(&self) -> u8;
    /// Get the drag start position if a drag is in progress
    fn get_drag_start_position(&self) -> Option<LogicalPosition>;
    /// Check if any selection exists (click selection or drag selection)
    fn has_selection(&self) -> bool;
}
/// Trait for querying focus manager state.
///
/// This allows `pre_callback_filter_internal_events` to query manager state
/// without depending on the concrete `FocusManager` type from layout crate.
pub trait FocusManagerQuery {
    /// Get the currently focused node ID
    fn get_focused_node_id(&self) -> Option<DomNodeId>;
}
/// Post-callback filter: Determine additional system changes needed after user callbacks.
///
/// Takes the pre-callback system changes and focus state to determine what
/// post-callback system changes are needed (text input, scrolling, timers).
/// Default post-callback filter: scroll-into-view after cursor ops, auto-scroll during drag.
24
#[must_use] pub fn default_post_filter(
24
    prevent_default: bool,
24
    pre_changes: &[SystemChange],
24
    old_focus: Option<DomNodeId>,
24
    new_focus: Option<DomNodeId>,
24
) -> Vec<SystemChange> {
24
    post_callback_filter_system_changes(prevent_default, pre_changes, old_focus, new_focus)
24
}
// SystemChange dispatch table; a few arms incidentally push the same follow-up
// change but are kept as distinct documented cases.
#[allow(clippy::match_same_arms)]
84
#[must_use] pub fn post_callback_filter_system_changes(
84
    prevent_default: bool,
84
    pre_changes: &[SystemChange],
84
    old_focus: Option<DomNodeId>,
84
    new_focus: Option<DomNodeId>,
84
) -> Vec<SystemChange> {
84
    let mut changes = Vec::new();
84
    if prevent_default {
        // Only focus change passes through preventDefault
6
        if old_focus != new_focus {
3
            changes.push(SystemChange::SetFocus { new_focus, old_focus });
3
        }
6
        return changes;
78
    }
    // Always apply pending text input
78
    changes.push(SystemChange::ApplyPendingTextInput);
    // Determine post-callback actions based on pre-callback system changes
5097
    for change in pre_changes {
5019
        match change {
            SystemChange::TextSelectionClick { .. }
            | SystemChange::ApplySelectionOp { .. }
            | SystemChange::AddCursorAtClick { .. }
5004
            | SystemChange::SelectNextOccurrence { .. } => {
5004
                changes.push(SystemChange::ScrollSelectionIntoView);
5004
            }
5
            SystemChange::TextSelectionDrag { .. } => {
5
                changes.push(SystemChange::StartAutoScrollTimer);
5
            }
            SystemChange::CutToClipboard { .. }
            | SystemChange::PasteFromClipboard
            | SystemChange::UndoTextEdit { .. }
            | SystemChange::RedoTextEdit { .. }
9
            | SystemChange::SelectAllText => {
9
                changes.push(SystemChange::ScrollSelectionIntoView);
9
            }
            // Other system changes don't generate post-callback actions
1
            _ => {}
        }
    }
    // Focus changed during callbacks
78
    if old_focus != new_focus {
5
        changes.push(SystemChange::SetFocus { new_focus, old_focus });
73
    }
78
    changes
84
}
#[cfg(test)]
mod tests {
    use super::*;
    use azul_css::AzString;
    use crate::dom::{DomId, DomNodeId};
    use crate::styled_dom::NodeHierarchyItemId;
    use crate::id::NodeId;
    use crate::window::{KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec, OptionVirtualKeyCode};
    use crate::geom::LogicalPosition;
    use crate::task::{Instant, SystemTick};
    struct MockSelectionManager {
        click_count: u8,
        has_sel: bool,
    }
    impl SelectionManagerQuery for MockSelectionManager {
7
        fn get_click_count(&self) -> u8 { self.click_count }
7
        fn get_drag_start_position(&self) -> Option<LogicalPosition> { None }
7
        fn has_selection(&self) -> bool { self.has_sel }
    }
    struct MockFocusManager(Option<DomNodeId>);
    impl FocusManagerQuery for MockFocusManager {
7
        fn get_focused_node_id(&self) -> Option<DomNodeId> { self.0 }
    }
7
    fn focused_node(node_idx: usize) -> DomNodeId {
7
        DomNodeId {
7
            dom: DomId { inner: 0 },
7
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node_idx))),
7
        }
7
    }
6
    fn make_keyboard_state(vk: VirtualKeyCode) -> KeyboardState {
6
        KeyboardState {
6
            current_virtual_keycode: OptionVirtualKeyCode::Some(vk),
6
            pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![vk]),
6
            ..KeyboardState::default()
6
        }
6
    }
2
    fn make_keydown_event(target: DomNodeId) -> SyntheticEvent {
2
        SyntheticEvent::new(
2
            EventType::KeyDown,
2
            EventSource::User,
2
            target,
2
            Instant::Tick(SystemTick::new(0)),
2
            EventData::Keyboard(KeyboardEventData {
2
                key_code: VirtualKeyCode::Back as u32,
2
                char_code: None,
2
                modifiers: KeyModifiers::default(),
2
                repeat: false,
2
            }),
        )
2
    }
    #[test]
1
    fn backspace_generates_delete_text_selection() {
1
        let target = focused_node(2);
1
        let events = vec![make_keydown_event(target)];
1
        let kb = make_keyboard_state(VirtualKeyCode::Back);
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(
1
            &events, None, &kb, &mouse, &sel, &focus,
        );
1
        let ops: Vec<_> = result.system_changes.iter()
1
            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
1
            .collect();
1
        assert_eq!(ops.len(), 1, "Backspace should generate ApplySelectionOp");
1
        match &ops[0] {
1
            SystemChange::ApplySelectionOp { op, .. } => {
1
                assert_eq!(op.direction, SelectionDirection::Backward);
1
                assert_eq!(op.step, SelectionStep::Character);
1
                assert_eq!(op.mode, SelectionMode::Delete);
            }
            _ => unreachable!(),
        }
1
    }
    #[test]
1
    fn delete_key_generates_forward_deletion() {
1
        let target = focused_node(2);
1
        let event = SyntheticEvent::new(
1
            EventType::KeyDown, EventSource::User, target,
1
            Instant::Tick(SystemTick::new(0)),
1
            EventData::Keyboard(KeyboardEventData {
1
                key_code: VirtualKeyCode::Delete as u32,
1
                char_code: None, modifiers: KeyModifiers::default(), repeat: false,
1
            }),
        );
1
        let kb = make_keyboard_state(VirtualKeyCode::Delete);
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
1
        let ops: Vec<_> = result.system_changes.iter()
1
            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
1
            .collect();
1
        assert_eq!(ops.len(), 1);
1
        match &ops[0] {
1
            SystemChange::ApplySelectionOp { op, .. } => {
1
                assert_eq!(op.direction, SelectionDirection::Forward);
1
                assert_eq!(op.step, SelectionStep::Character);
1
                assert_eq!(op.mode, SelectionMode::Delete);
            }
            _ => unreachable!(),
        }
1
    }
    #[test]
1
    fn arrow_left_generates_navigation() {
1
        let target = focused_node(2);
1
        let event = SyntheticEvent::new(
1
            EventType::KeyDown, EventSource::User, target,
1
            Instant::Tick(SystemTick::new(0)),
1
            EventData::Keyboard(KeyboardEventData {
1
                key_code: VirtualKeyCode::Left as u32,
1
                char_code: None, modifiers: KeyModifiers::default(), repeat: false,
1
            }),
        );
1
        let kb = make_keyboard_state(VirtualKeyCode::Left);
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
1
        let ops: Vec<_> = result.system_changes.iter()
1
            .filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
1
            .collect();
1
        assert_eq!(ops.len(), 1, "Left arrow should generate ApplySelectionOp");
1
        match &ops[0] {
1
            SystemChange::ApplySelectionOp { op, .. } => {
1
                assert_eq!(op.direction, SelectionDirection::Backward);
1
                assert_eq!(op.step, SelectionStep::Character);
1
                assert_eq!(op.mode, SelectionMode::Move);
            }
            _ => unreachable!(),
        }
1
    }
    #[test]
1
    fn no_focused_node_means_no_keyboard_system_changes() {
1
        let target = focused_node(2);
1
        let event = make_keydown_event(target);
1
        let kb = make_keyboard_state(VirtualKeyCode::Back);
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(None); // No focus!
1
        let result = pre_callback_filter_internal_events(
1
            &[event], None, &kb, &mouse, &sel, &focus,
        );
1
        assert!(result.system_changes.is_empty(),
            "No system changes should be generated without focused node");
1
    }
    #[test]
1
    fn keydown_without_keyboard_data_generates_no_system_change() {
1
        let target = focused_node(2);
1
        let event = SyntheticEvent::new(
1
            EventType::KeyDown,
1
            EventSource::User,
1
            target,
1
            Instant::Tick(SystemTick::new(0)),
1
            EventData::None, // Bug: missing keyboard data
        );
1
        let kb = make_keyboard_state(VirtualKeyCode::Back);
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(
1
            &[event], None, &kb, &mouse, &sel, &focus,
        );
        // This test documents the bug we just fixed: EventData::None causes
        // the handle_key_down function to return None (early exit at line 2737)
1
        assert!(result.system_changes.is_empty(),
            "EventData::None should not generate system changes (documents the old bug)");
1
    }
    #[test]
1
    fn ctrl_c_generates_copy() {
        // MWA-A2/MWA-D: shortcuts key off the PRIMARY modifier — Cmd on
        // macOS, Ctrl elsewhere — so this test presses the platform's
        // primary key. (The old version hardcoded LControl and correctly
        // started failing on macOS hosts once primary_down() landed:
        // Ctrl+C must NOT copy on macOS, Cmd+C does.)
1
        let primary_key = if cfg!(target_os = "macos") {
            VirtualKeyCode::LWin
        } else {
1
            VirtualKeyCode::LControl
        };
1
        let target = focused_node(2);
1
        let event = SyntheticEvent::new(
1
            EventType::KeyDown,
1
            EventSource::User,
1
            target,
1
            Instant::Tick(SystemTick::new(0)),
1
            EventData::Keyboard(KeyboardEventData {
1
                key_code: VirtualKeyCode::C as u32,
1
                char_code: Some('c'),
1
                modifiers: KeyModifiers {
1
                    ctrl: !cfg!(target_os = "macos"),
1
                    shift: false,
1
                    alt: false,
1
                    meta: cfg!(target_os = "macos"),
1
                },
1
                repeat: false,
1
            }),
        );
1
        let mut kb = make_keyboard_state(VirtualKeyCode::C);
1
        kb.pressed_virtual_keycodes = VirtualKeyCodeVec::from_vec(
1
            vec![VirtualKeyCode::C, primary_key]
        );
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 0, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(
1
            &[event], None, &kb, &mouse, &sel, &focus,
        );
1
        let copy_changes = result.system_changes.iter()
1
            .filter(|c| matches!(c, SystemChange::CopyToClipboard))
1
            .count();
1
        assert_eq!(copy_changes, 1, "primary+C should generate CopyToClipboard");
1
    }
2
    fn make_hit_test_with_node(node_idx: usize) -> FullHitTest {
        use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
        use crate::dom::OptionDomNodeId;
        use std::collections::BTreeMap;
2
        let node_id = NodeId::new(node_idx);
2
        let dom_id = DomId { inner: 0 };
2
        let mut regular = BTreeMap::new();
2
        regular.insert(node_id, HitTestItem {
2
            point_in_viewport: LogicalPosition::new(100.0, 200.0),
2
            point_relative_to_item: LogicalPosition::new(50.0, 30.0),
2
            is_focusable: true,
2
            is_virtual_view_hit: None,
2
            hit_depth: 0,
2
        });
2
        let mut hovered = BTreeMap::new();
2
        hovered.insert(dom_id, HitTest {
2
            regular_hit_test_nodes: regular,
2
            scroll_hit_test_nodes: BTreeMap::new(),
2
            scrollbar_hit_test_nodes: BTreeMap::new(),
2
            cursor_hit_test_nodes: BTreeMap::new(),
2
        });
2
        FullHitTest {
2
            hovered_nodes: hovered,
2
            focused_node: OptionDomNodeId::None,
2
        }
2
    }
    #[test]
1
    fn mousedown_generates_text_selection_click() {
1
        let target = focused_node(2);
1
        let event = SyntheticEvent::new(
1
            EventType::MouseDown,
1
            EventSource::User,
1
            target,
1
            Instant::Tick(SystemTick::new(0)),
1
            EventData::Mouse(MouseEventData {
1
                position: LogicalPosition::new(100.0, 200.0),
1
                button: MouseButton::Left,
1
                buttons: 1,
1
                modifiers: KeyModifiers::default(),
1
            }),
        );
1
        let hit_test = make_hit_test_with_node(2);
1
        let kb = KeyboardState::default();
1
        let mouse = MouseState::default();
1
        let sel = MockSelectionManager { click_count: 1, has_sel: false };
1
        let focus = MockFocusManager(Some(target));
1
        let result = pre_callback_filter_internal_events(
1
            &[event], Some(&hit_test), &kb, &mouse, &sel, &focus,
        );
1
        let click_changes = result.system_changes.iter()
1
            .filter(|c| matches!(c, SystemChange::TextSelectionClick { .. }))
1
            .count();
1
        assert_eq!(click_changes, 1, "MouseDown with hit_test should generate TextSelectionClick");
1
    }
    #[test]
1
    fn process_event_result_max_self_picks_higher_variant() {
1
        let lo = ProcessEventResult::ShouldReRenderCurrentWindow;
1
        let hi = ProcessEventResult::ShouldRegenerateDomCurrentWindow;
1
        assert_eq!(lo.max_self(hi), hi);
1
        assert_eq!(hi.max_self(lo), hi);
1
        assert_eq!(lo.max_self(lo), lo);
1
    }
    #[test]
1
    fn keyboard_shortcut_keys_off_primary_modifier() {
        use crate::window::VirtualKeyCode::{A, C, V, X, Z};
        // No primary modifier → never a shortcut (MWA-A2).
1
        assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
1
        assert_eq!(KeyboardShortcut::from_key(Z, false, true), None);
        // Primary held → the standard editing set.
1
        assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
1
        assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
1
        assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
1
        assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
1
        assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
1
        assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
1
    }
    #[test]
1
    fn primary_modifier_is_platform_correct() {
        use crate::window::{KeyboardState, VirtualKeyCode};
1
        let cmd_held = KeyboardState {
1
            pressed_virtual_keycodes: vec![VirtualKeyCode::LWin].into(),
1
            ..Default::default()
1
        };
        // Cmd/super is primary ONLY on macOS.
1
        assert_eq!(cmd_held.primary_down(), cfg!(target_os = "macos"));
1
        let ctrl_held = KeyboardState {
1
            pressed_virtual_keycodes: vec![VirtualKeyCode::LControl].into(),
1
            ..Default::default()
1
        };
        // Ctrl is primary everywhere EXCEPT macOS.
1
        assert_eq!(ctrl_held.primary_down(), !cfg!(target_os = "macos"));
1
    }
    #[test]
1
    fn arrow_direction_from_key_maps_arrows_and_home_end() {
        use crate::window::VirtualKeyCode::*;
1
        assert_eq!(ArrowDirection::from_key(Left, false), Some(ArrowDirection::Left));
1
        assert_eq!(ArrowDirection::from_key(Right, false), Some(ArrowDirection::Right));
1
        assert_eq!(ArrowDirection::from_key(Up, false), Some(ArrowDirection::Up));
1
        assert_eq!(ArrowDirection::from_key(Down, false), Some(ArrowDirection::Down));
1
        assert_eq!(ArrowDirection::from_key(Home, false), Some(ArrowDirection::LineStart));
1
        assert_eq!(ArrowDirection::from_key(End, false), Some(ArrowDirection::LineEnd));
1
        assert_eq!(ArrowDirection::from_key(Home, true), Some(ArrowDirection::DocumentStart));
1
        assert_eq!(ArrowDirection::from_key(End, true), Some(ArrowDirection::DocumentEnd));
1
        assert_eq!(ArrowDirection::from_key(C, false), None);
1
    }
    #[test]
1
    fn arrow_direction_to_selection_respects_ctrl() {
1
        let (d, s) = ArrowDirection::Left.to_selection(false);
1
        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Character));
1
        let (d, s) = ArrowDirection::Left.to_selection(true);
1
        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Word));
1
        let (d, s) = ArrowDirection::Up.to_selection(false);
1
        assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::VisualLine));
1
        let (d, s) = ArrowDirection::DocumentEnd.to_selection(false);
1
        assert_eq!((d, s), (SelectionDirection::Forward, SelectionStep::Document));
1
    }
    #[test]
1
    fn keyboard_shortcut_from_key_recognizes_editing_combos() {
        use crate::window::VirtualKeyCode::*;
1
        assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
1
        assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
1
        assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
1
        assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
1
        assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
1
        assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
1
        assert_eq!(KeyboardShortcut::from_key(Y, true, false), Some(KeyboardShortcut::Redo));
        // Non-ctrl combos must not match
1
        assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
        // Unknown keys
1
        assert_eq!(KeyboardShortcut::from_key(D, true, false), None);
1
    }
    #[test]
1
    fn mouse_button_state_round_trips_from_mouse_state() {
1
        let ms = MouseState {
1
            left_down: true,
1
            middle_down: true,
1
            ..MouseState::default()
1
        };
1
        let bs: MouseButtonState = (&ms).into();
1
        assert!(bs.left_down);
1
        assert!(!bs.right_down);
1
        assert!(bs.middle_down);
1
        assert!(bs.any_down());
1
        let none = MouseButtonState { left_down: false, right_down: false, middle_down: false };
1
        assert!(!none.any_down());
1
    }
    #[test]
1
    fn callback_to_call_collects_hits_for_dom() {
1
        let dom_id = DomId { inner: 0 };
1
        let hit_test = make_hit_test_with_node(2);
1
        let filter = EventFilter::Hover(HoverEventFilter::MouseDown);
1
        let calls = CallbackToCall::from_hit_test(&hit_test, dom_id, filter);
1
        assert_eq!(calls.len(), 1);
1
        assert_eq!(calls[0].node_id, NodeId::new(2));
1
        assert_eq!(calls[0].event_filter, filter);
1
        assert!(calls[0].hit_test_item.is_some());
        // Unknown DOM id => empty list
1
        let other = CallbackToCall::from_hit_test(
1
            &hit_test,
1
            DomId { inner: 999 },
1
            EventFilter::Hover(HoverEventFilter::MouseUp),
        );
1
        assert!(other.is_empty());
        // Direct constructor builds expected fields
1
        let direct = CallbackToCall::new(
1
            NodeId::new(7),
1
            None,
1
            EventFilter::Focus(FocusEventFilter::FocusReceived),
        );
1
        assert_eq!(direct.node_id, NodeId::new(7));
1
        assert!(direct.hit_test_item.is_none());
1
    }
    #[test]
1
    fn restyle_relayout_aliases_are_btreemap_compatible() {
        // RestyleNodes / RelayoutNodes are aliases for BTreeMap<NodeId, Vec<ChangedCssProperty>>.
        // Confirm we can construct empty ones via the alias and that they accept the same keys.
1
        let restyle: RestyleNodes = BTreeMap::new();
1
        let relayout: RelayoutNodes = BTreeMap::new();
1
        assert!(restyle.is_empty());
1
        assert!(relayout.is_empty());
        // RelayoutWords is BTreeMap<NodeId, AzString>.
1
        let mut words: RelayoutWords = BTreeMap::new();
1
        words.insert(NodeId::new(1), AzString::from_const_str("hello"));
1
        assert_eq!(words.get(&NodeId::new(1)).map(azul_css::AzString::as_str), Some("hello"));
1
    }
    #[test]
1
    fn detect_lifecycle_events_with_reconciliation_is_callable() {
        // Smoke test: empty old/new node data must produce no events and an
        // empty migration map. This proves the function is callable from
        // the public API and threads through `crate::diff::reconcile_dom`.
1
        let dom_id = DomId { inner: 0 };
1
        let old_data: Vec<crate::dom::NodeData> = Vec::new();
1
        let new_data: Vec<crate::dom::NodeData> = Vec::new();
1
        let old_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
1
        let new_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
1
        let old_layout = OrderedMap::default();
1
        let new_layout = OrderedMap::default();
1
        let result: LifecycleEventResult = detect_lifecycle_events_with_reconciliation(
1
            dom_id,
1
            &old_data,
1
            &new_data,
1
            &old_hier,
1
            &new_hier,
1
            &old_layout,
1
            &new_layout,
1
            Instant::Tick(SystemTick::new(0)),
        );
1
        assert!(result.events.is_empty());
1
        assert!(result.node_id_mapping.is_empty());
1
    }
    #[test]
1
    fn nodedata_focusable_and_activation_traits_are_wired() {
        use crate::dom::{NodeData, NodeType};
        use crate::events::{ActivationBehavior as _, Focusable as _};
        // <button> is naturally focusable and has activation behavior.
1
        let btn = NodeData::create_node(NodeType::Button);
1
        assert!(<NodeData as Focusable>::is_naturally_focusable(&btn));
1
        assert!(<NodeData as Focusable>::is_focusable(&btn));
1
        assert!(<NodeData as ActivationBehavior>::has_activation_behavior(&btn));
1
        assert!(<NodeData as ActivationBehavior>::is_activatable(&btn));
        // A plain <div> is neither naturally focusable nor activatable.
1
        let div = NodeData::create_node(NodeType::Div);
1
        assert!(!<NodeData as Focusable>::is_naturally_focusable(&div));
1
        assert!(!<NodeData as ActivationBehavior>::has_activation_behavior(&div));
        // <input> is naturally focusable.
1
        let input = NodeData::create_node(NodeType::Input);
1
        assert!(<NodeData as Focusable>::is_naturally_focusable(&input));
1
    }
    #[test]
1
    fn first_hovered_node_picks_frontmost_by_depth() {
        use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
        use crate::dom::OptionDomNodeId;
        use std::collections::BTreeMap;
1
        let item = |depth: u32| HitTestItem {
2
            point_in_viewport: LogicalPosition::zero(),
2
            point_relative_to_item: LogicalPosition::zero(),
            is_focusable: true,
2
            is_virtual_view_hit: None,
2
            hit_depth: depth,
2
        };
        // Front-most node (depth 0) has the HIGHER NodeId; back node (depth 5)
        // has the lower id. The old `.next()` logic returned the lowest id
        // (node 2, the back one). We must now return the front-most (node 5).
1
        let mut regular = BTreeMap::new();
1
        regular.insert(NodeId::new(2), item(5));
1
        regular.insert(NodeId::new(5), item(0));
1
        let mut hovered = BTreeMap::new();
1
        hovered.insert(DomId { inner: 0 }, HitTest {
1
            regular_hit_test_nodes: regular,
1
            scroll_hit_test_nodes: BTreeMap::new(),
1
            scrollbar_hit_test_nodes: BTreeMap::new(),
1
            cursor_hit_test_nodes: BTreeMap::new(),
1
        });
1
        let ht = FullHitTest { hovered_nodes: hovered, focused_node: OptionDomNodeId::None };
1
        let got = get_first_hovered_node(Some(&ht)).unwrap();
1
        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
1
    }
    #[test]
1
    fn size_changed_nan_guard_stops_resize_loop() {
        use crate::geom::LogicalSize;
        // A NaN dimension present on BOTH frames must read as "unchanged" so no
        // Resize is emitted every frame.
1
        let a = LogicalSize::new(f32::NAN, 100.0);
1
        let b = LogicalSize::new(f32::NAN, 100.0);
1
        assert!(!size_changed(a, b));
        // A real change is still detected.
1
        assert!(size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.0, 120.0)));
        // Sub-quantum jitter is ignored.
1
        assert!(!size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.00005, 100.0)));
1
    }
    #[test]
1
    fn dom_path_terminates_on_parent_cycle() {
        use crate::id::{Node, NodeHierarchy};
        // Two nodes whose parents point at each other -> a cycle.
1
        let nodes = vec![
1
            Node { parent: Some(NodeId::new(1)), ..Node::ROOT },
1
            Node { parent: Some(NodeId::new(0)), ..Node::ROOT },
        ];
1
        let hier = NodeHierarchy::new(nodes);
1
        let target = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0)));
        // Must not hang / OOM; bounded by node count + visited-set.
1
        let path = get_dom_path(&hier, target);
1
        assert!(path.len() <= 2);
1
    }
    #[test]
1
    fn click_event_maps_to_left_mouse_up() {
1
        let filters = event_type_to_filters(EventType::Click, &EventData::None);
1
        assert!(filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseUp)));
1
        assert!(!filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseDown)));
1
    }
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
    use super::*;
    use crate::{
        dom::{DomId, DomNodeId, OptionDomNodeId},
        geom::{LogicalPosition, LogicalRect, LogicalSize},
        hit_test::{FullHitTest, HitTest, HitTestItem},
        id::{Node, NodeHierarchy, NodeId},
        styled_dom::NodeHierarchyItemId,
        task::{Instant, SystemTick},
        window::{CursorPosition, KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec},
    };
    // ---------------------------------------------------------------- helpers
    fn tick(n: u64) -> Instant {
        Instant::Tick(SystemTick::new(n))
    }
    fn dnid(dom: usize, node: usize) -> DomNodeId {
        DomNodeId {
            dom: DomId { inner: dom },
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
        }
    }
    /// A `DomNodeId` whose node slot is the `None` sentinel (raw inner == 0).
    fn dnid_none(dom: usize) -> DomNodeId {
        DomNodeId {
            dom: DomId { inner: dom },
            node: NodeHierarchyItemId::NONE,
        }
    }
    fn hit_item(depth: u32) -> HitTestItem {
        HitTestItem {
            point_in_viewport: LogicalPosition::new(1.0, 2.0),
            point_relative_to_item: LogicalPosition::new(3.0, 4.0),
            is_focusable: true,
            is_virtual_view_hit: None,
            hit_depth: depth,
        }
    }
    /// Hit test containing `(node_index, hit_depth)` pairs, all under one DOM.
    fn hit_test_with(dom: usize, nodes: &[(usize, u32)]) -> FullHitTest {
        let mut regular = BTreeMap::new();
        for (idx, depth) in nodes {
            regular.insert(NodeId::new(*idx), hit_item(*depth));
        }
        let mut hovered = BTreeMap::new();
        hovered.insert(
            DomId { inner: dom },
            HitTest {
                regular_hit_test_nodes: regular,
                scroll_hit_test_nodes: BTreeMap::new(),
                scrollbar_hit_test_nodes: BTreeMap::new(),
                cursor_hit_test_nodes: BTreeMap::new(),
            },
        );
        FullHitTest {
            hovered_nodes: hovered,
            focused_node: OptionDomNodeId::None,
        }
    }
    fn empty_hit_test() -> FullHitTest {
        FullHitTest {
            hovered_nodes: BTreeMap::new(),
            focused_node: OptionDomNodeId::None,
        }
    }
    fn mouse_event(ty: EventType, button: MouseButton, pos: LogicalPosition) -> SyntheticEvent {
        SyntheticEvent::new(
            ty,
            EventSource::User,
            dnid(0, 0),
            tick(0),
            EventData::Mouse(MouseEventData {
                position: pos,
                button,
                buttons: 1,
                modifiers: KeyModifiers::default(),
            }),
        )
    }
    fn key_event(key_code: u32, modifiers: KeyModifiers) -> SyntheticEvent {
        SyntheticEvent::new(
            EventType::KeyDown,
            EventSource::User,
            dnid(0, 0),
            tick(0),
            EventData::Keyboard(KeyboardEventData {
                key_code,
                char_code: None,
                modifiers,
                repeat: false,
            }),
        )
    }
    /// Straight parent chain: node 0 = root, node i's parent = node i-1.
    fn hierarchy_chain(len: usize) -> NodeHierarchy {
        let nodes = (0..len)
            .map(|i| Node {
                parent: if i == 0 { None } else { Some(NodeId::new(i - 1)) },
                ..Node::ROOT
            })
            .collect::<Vec<_>>();
        NodeHierarchy::new(nodes)
    }
    /// Modifiers with the platform's PRIMARY modifier held (Cmd on macOS, Ctrl elsewhere).
    fn primary_modifiers() -> KeyModifiers {
        if cfg!(target_os = "macos") {
            KeyModifiers::new().with_meta()
        } else {
            KeyModifiers::new().with_ctrl()
        }
    }
    fn keyboard_with_primary_held() -> KeyboardState {
        let key = if cfg!(target_os = "macos") {
            VirtualKeyCode::LWin
        } else {
            VirtualKeyCode::LControl
        };
        KeyboardState {
            pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![key]),
            ..KeyboardState::default()
        }
    }
    // ============================================================ numeric edge
    // size_changed / quantization
    #[test]
    fn size_changed_zero_and_identity() {
        assert!(!size_changed(LogicalSize::zero(), LogicalSize::zero()));
        assert!(!size_changed(
            LogicalSize::new(0.0, 0.0),
            LogicalSize::new(-0.0, -0.0)
        ));
        // 0 -> any real size is a change.
        assert!(size_changed(LogicalSize::zero(), LogicalSize::new(0.0, 1.0)));
        assert!(size_changed(LogicalSize::zero(), LogicalSize::new(1.0, 0.0)));
    }
    #[test]
    fn size_changed_single_sided_nan_is_a_change() {
        // NaN on ONE side only must register as changed (the both-sides NaN case
        // is the loop-guard covered by `size_changed_nan_guard_stops_resize_loop`).
        assert!(size_changed(
            LogicalSize::new(f32::NAN, 10.0),
            LogicalSize::new(10.0, 10.0)
        ));
        assert!(size_changed(
            LogicalSize::new(10.0, 10.0),
            LogicalSize::new(10.0, f32::NAN)
        ));
        // NaN on both sides in *different* dimensions is still a change in the
        // other dimension only if that dimension actually differs.
        assert!(!size_changed(
            LogicalSize::new(f32::NAN, f32::NAN),
            LogicalSize::new(f32::NAN, f32::NAN)
        ));
    }
    #[test]
    fn size_changed_negative_and_infinite_do_not_panic() {
        // Negative sizes (degenerate layouts) must be handled deterministically.
        assert!(size_changed(
            LogicalSize::new(-100.0, 0.0),
            LogicalSize::new(100.0, 0.0)
        ));
        assert!(!size_changed(
            LogicalSize::new(-100.0, -50.0),
            LogicalSize::new(-100.0, -50.0)
        ));
        // f32 * 1000.0 overflows to +/-inf, and `inf as i64` SATURATES (it does
        // not wrap or UB). So the comparison stays total and panic-free.
        assert!(!size_changed(
            LogicalSize::new(f32::INFINITY, f32::INFINITY),
            LogicalSize::new(f32::INFINITY, f32::INFINITY)
        ));
        assert!(size_changed(
            LogicalSize::new(f32::INFINITY, 0.0),
            LogicalSize::new(f32::NEG_INFINITY, 0.0)
        ));
        // Finite-but-huge values saturate into the same bucket as infinity: the
        // documented quantization trade-off, asserted here so a future change of
        // the quantizer (e.g. to i128 or a float compare) is a deliberate one.
        assert!(!size_changed(
            LogicalSize::new(f32::MAX, 0.0),
            LogicalSize::new(f32::INFINITY, 0.0)
        ));
    }
    #[test]
    fn size_changed_ignores_sub_quantum_jitter_but_sees_one_quantum() {
        // The quantizer is 1/1000, so a 0.0005 wobble must be ignored...
        assert!(!size_changed(
            LogicalSize::new(50.0, 50.0),
            LogicalSize::new(50.0004, 50.0)
        ));
        // ...but a full quantum must be seen.
        assert!(size_changed(
            LogicalSize::new(50.0, 50.0),
            LogicalSize::new(50.002, 50.0)
        ));
    }
    // ------------------------------------------------- create_*_event numerics
    #[test]
    fn create_mount_event_without_layout_entry_falls_back_to_zero_rect() {
        let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
        let ev = create_mount_event(NodeId::new(3), DomId { inner: 0 }, &layout, &tick(7));
        assert_eq!(ev.event_type, EventType::Mount);
        assert_eq!(ev.source, EventSource::Lifecycle);
        assert_eq!(ev.phase, EventPhase::Target);
        assert_eq!(ev.target, ev.current_target);
        assert_eq!(ev.target.node.into_crate_internal(), Some(NodeId::new(3)));
        match ev.data {
            EventData::Lifecycle(d) => {
                assert_eq!(d.reason, LifecycleReason::InitialMount);
                assert!(d.previous_bounds.is_none());
                assert_eq!(d.current_bounds, LogicalRect::zero());
            }
            _ => panic!("mount event must carry lifecycle data"),
        }
    }
    #[test]
    fn create_unmount_event_reports_previous_bounds_and_zero_current() {
        let mut layout = BTreeMap::new();
        let rect = LogicalRect::new(LogicalPosition::new(1.0, 2.0), LogicalSize::new(3.0, 4.0));
        layout.insert(NodeId::new(1), rect);
        let ev = create_unmount_event(NodeId::new(1), DomId { inner: 2 }, &layout, &tick(9));
        assert_eq!(ev.event_type, EventType::Unmount);
        match ev.data {
            EventData::Lifecycle(d) => {
                assert_eq!(d.reason, LifecycleReason::Unmount);
                assert_eq!(d.previous_bounds, Some(rect));
                assert_eq!(d.current_bounds, LogicalRect::zero());
            }
            _ => panic!("unmount event must carry lifecycle data"),
        }
    }
    #[test]
    fn create_lifecycle_event_survives_extreme_node_ids() {
        // The largest NodeId that survives the 1-based (`n + 1`) FFI encoding.
        // (NodeId::new(usize::MAX) would overflow that encoding — out of scope here.)
        let huge = NodeId::new(usize::MAX - 1);
        let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
        let ev = create_mount_event(huge, DomId { inner: usize::MAX }, &layout, &tick(0));
        assert_eq!(ev.target.node.into_crate_internal(), Some(huge));
        assert_eq!(ev.target.dom, DomId { inner: usize::MAX });
        // NodeId 0 (the root) must round-trip too — the 1-based encoding makes
        // 0 the value most likely to collide with the `None` sentinel.
        let root = create_mount_event(NodeId::ZERO, DomId { inner: 0 }, &layout, &tick(0));
        assert_eq!(
            root.target.node.into_crate_internal(),
            Some(NodeId::ZERO),
            "NodeId 0 must not decode as `None`"
        );
    }
    #[test]
    fn create_resize_event_returns_none_for_missing_or_unchanged_layout() {
        let dom = DomId { inner: 0 };
        let node = NodeId::new(1);
        let rect = LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
        let empty: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
        let mut one = BTreeMap::new();
        one.insert(node, rect);
        // Missing in old, missing in new, missing in both -> None (no panic).
        assert!(create_resize_event(node, dom, &empty, &one, &tick(0)).is_none());
        assert!(create_resize_event(node, dom, &one, &empty, &tick(0)).is_none());
        assert!(create_resize_event(node, dom, &empty, &empty, &tick(0)).is_none());
        // Present on both sides but unchanged -> None.
        assert!(create_resize_event(node, dom, &one, &one, &tick(0)).is_none());
    }
    #[test]
    fn create_resize_event_ignores_pure_origin_moves() {
        // Only the SIZE is compared: moving a node without resizing it must not
        // emit a Resize event.
        let dom = DomId { inner: 0 };
        let node = NodeId::new(0);
        let size = LogicalSize::new(10.0, 10.0);
        let mut old = BTreeMap::new();
        old.insert(node, LogicalRect::new(LogicalPosition::new(0.0, 0.0), size));
        let mut new = BTreeMap::new();
        new.insert(
            node,
            LogicalRect::new(LogicalPosition::new(500.0, 500.0), size),
        );
        assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
    }
    #[test]
    fn create_resize_event_nan_size_does_not_loop_forever() {
        // Regression guard: a NaN dimension on BOTH frames must NOT emit a Resize
        // every frame (a raw f32 `!=` would, since NaN != NaN).
        let dom = DomId { inner: 0 };
        let node = NodeId::new(0);
        let nan_rect = LogicalRect::new(
            LogicalPosition::zero(),
            LogicalSize::new(f32::NAN, 100.0),
        );
        let mut old = BTreeMap::new();
        old.insert(node, nan_rect);
        let mut new = BTreeMap::new();
        new.insert(node, nan_rect);
        assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
    }
    #[test]
    fn create_resize_event_reports_both_bounds_on_real_change() {
        let dom = DomId { inner: 0 };
        let node = NodeId::new(0);
        let old_rect =
            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
        let new_rect =
            LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 20.0));
        let mut old = BTreeMap::new();
        old.insert(node, old_rect);
        let mut new = BTreeMap::new();
        new.insert(node, new_rect);
        let ev = create_resize_event(node, dom, &old, &new, &tick(3))
            .expect("a real size change must emit a Resize");
        assert_eq!(ev.event_type, EventType::Resize);
        match ev.data {
            EventData::Lifecycle(d) => {
                assert_eq!(d.reason, LifecycleReason::Resize);
                assert_eq!(d.previous_bounds, Some(old_rect));
                assert_eq!(d.current_bounds, new_rect);
            }
            _ => panic!("resize event must carry lifecycle data"),
        }
    }
    // ------------------------------------------------- detect_lifecycle_events
    #[test]
    fn detect_lifecycle_events_all_none_is_empty() {
        let events = detect_lifecycle_events(
            DomId { inner: 0 },
            DomId { inner: 0 },
            None,
            None,
            None,
            None,
            tick(0),
        );
        assert!(events.is_empty());
    }
    #[test]
    fn detect_lifecycle_events_without_layout_emits_nothing() {
        // Hierarchies differ, but no layout maps -> the fn must not fabricate events.
        let old = hierarchy_chain(1);
        let new = hierarchy_chain(4);
        let events = detect_lifecycle_events(
            DomId { inner: 0 },
            DomId { inner: 0 },
            Some(&old),
            Some(&new),
            None,
            None,
            tick(0),
        );
        assert!(events.is_empty());
    }
    #[test]
    fn detect_lifecycle_events_emits_mounts_unmounts_and_resizes() {
        let dom = DomId { inner: 0 };
        let old_hier = hierarchy_chain(2); // nodes 0,1
        let new_hier = hierarchy_chain(3); // nodes 0,1,2
        let r = |h: f32| LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, h));
        let mut old_layout = BTreeMap::new();
        old_layout.insert(NodeId::new(0), r(10.0));
        old_layout.insert(NodeId::new(1), r(10.0));
        let mut new_layout = BTreeMap::new();
        new_layout.insert(NodeId::new(0), r(10.0)); // unchanged
        new_layout.insert(NodeId::new(1), r(99.0)); // resized
        new_layout.insert(NodeId::new(2), r(10.0)); // mounted
        let events = detect_lifecycle_events(
            dom,
            dom,
            Some(&old_hier),
            Some(&new_hier),
            Some(&old_layout),
            Some(&new_layout),
            tick(5),
        );
        let mounts: Vec<_> = events
            .iter()
            .filter(|e| e.event_type == EventType::Mount)
            .collect();
        let resizes: Vec<_> = events
            .iter()
            .filter(|e| e.event_type == EventType::Resize)
            .collect();
        assert_eq!(mounts.len(), 1, "only node 2 is new");
        assert_eq!(
            mounts[0].target.node.into_crate_internal(),
            Some(NodeId::new(2))
        );
        assert_eq!(resizes.len(), 1, "only node 1 changed size");
        assert_eq!(
            resizes[0].target.node.into_crate_internal(),
            Some(NodeId::new(1))
        );
        assert!(
            !events.iter().any(|e| e.event_type == EventType::Unmount),
            "nothing was removed"
        );
        assert!(events.iter().all(|e| e.source == EventSource::Lifecycle));
        // Reverse direction: the removed node must unmount.
        let events = detect_lifecycle_events(
            dom,
            dom,
            Some(&new_hier),
            Some(&old_hier),
            Some(&new_layout),
            Some(&old_layout),
            tick(6),
        );
        let unmounts: Vec<_> = events
            .iter()
            .filter(|e| e.event_type == EventType::Unmount)
            .collect();
        assert_eq!(unmounts.len(), 1);
        assert_eq!(
            unmounts[0].target.node.into_crate_internal(),
            Some(NodeId::new(2))
        );
    }
    #[test]
    fn detect_lifecycle_events_mount_of_node_missing_from_layout_uses_zero_rect() {
        let dom = DomId { inner: 0 };
        let new_hier = hierarchy_chain(2);
        let new_layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new(); // empty!
        let events = detect_lifecycle_events(
            dom,
            dom,
            None,
            Some(&new_hier),
            None,
            Some(&new_layout),
            tick(0),
        );
        assert_eq!(events.len(), 2);
        for ev in &events {
            match ev.data {
                EventData::Lifecycle(d) => assert_eq!(d.current_bounds, LogicalRect::zero()),
                _ => panic!("expected lifecycle data"),
            }
        }
    }
    #[test]
    fn collect_node_ids_handles_none_and_empty_hierarchies() {
        assert!(collect_node_ids(None).is_empty());
        let empty = NodeHierarchy::new(Vec::new());
        assert!(collect_node_ids(Some(&empty)).is_empty());
        let three = hierarchy_chain(3);
        let ids = collect_node_ids(Some(&three));
        assert_eq!(ids.len(), 3);
        assert!(ids.contains(&NodeId::ZERO));
        assert!(ids.contains(&NodeId::new(2)));
    }
    // ======================================================== getters/predicates
    #[test]
    fn process_event_result_order_is_dense_and_monotonic() {
        let all = [
            ProcessEventResult::DoNothing,
            ProcessEventResult::ShouldReRenderCurrentWindow,
            ProcessEventResult::ShouldUpdateDisplayListCurrentWindow,
            ProcessEventResult::UpdateHitTesterAndProcessAgain,
            ProcessEventResult::ShouldIncrementalRelayout,
            ProcessEventResult::ShouldRegenerateDomCurrentWindow,
            ProcessEventResult::ShouldRegenerateDomAllWindows,
        ];
        for (i, r) in all.iter().enumerate() {
            assert_eq!(r.order(), i, "order() must match declaration index");
        }
        // Ord/PartialOrd must agree with order(), and max_self must be the join.
        for a in all {
            for b in all {
                assert_eq!(a < b, a.order() < b.order());
                let joined = a.max_self(b);
                assert_eq!(joined.order(), a.order().max(b.order()));
                assert_eq!(joined, b.max_self(a), "max_self must be commutative");
                assert_eq!(a.max_self(a), a, "max_self must be idempotent");
            }
        }
    }
    #[test]
    fn key_modifiers_builders_are_orthogonal_and_is_empty_tracks_them() {
        let empty = KeyModifiers::new();
        assert!(empty.is_empty());
        assert_eq!(empty, KeyModifiers::default());
        // Each builder sets exactly one flag.
        assert_eq!(
            KeyModifiers::new().with_shift(),
            KeyModifiers { shift: true, ctrl: false, alt: false, meta: false }
        );
        assert_eq!(
            KeyModifiers::new().with_ctrl(),
            KeyModifiers { shift: false, ctrl: true, alt: false, meta: false }
        );
        assert_eq!(
            KeyModifiers::new().with_alt(),
            KeyModifiers { shift: false, ctrl: false, alt: true, meta: false }
        );
        assert_eq!(
            KeyModifiers::new().with_meta(),
            KeyModifiers { shift: false, ctrl: false, alt: false, meta: true }
        );
        // Any single flag defeats is_empty; builders are idempotent and composable.
        assert!(!KeyModifiers::new().with_shift().is_empty());
        assert!(!KeyModifiers::new().with_ctrl().is_empty());
        assert!(!KeyModifiers::new().with_alt().is_empty());
        assert!(!KeyModifiers::new().with_meta().is_empty());
        assert_eq!(
            KeyModifiers::new().with_ctrl().with_ctrl(),
            KeyModifiers::new().with_ctrl()
        );
        let all = KeyModifiers::new().with_shift().with_ctrl().with_alt().with_meta();
        assert!(!all.is_empty());
        assert!(all.shift && all.ctrl && all.alt && all.meta);
    }
    #[test]
    fn scroll_into_view_options_presets_and_behavior_setters() {
        assert_eq!(
            ScrollIntoViewOptions::default(),
            ScrollIntoViewOptions::nearest(),
            "Default must be the `nearest`/`auto` preset"
        );
        for (opts, expected) in [
            (ScrollIntoViewOptions::nearest(), ScrollLogicalPosition::Nearest),
            (ScrollIntoViewOptions::center(), ScrollLogicalPosition::Center),
            (ScrollIntoViewOptions::start(), ScrollLogicalPosition::Start),
            (ScrollIntoViewOptions::end(), ScrollLogicalPosition::End),
        ] {
            assert_eq!(opts.block, expected);
            assert_eq!(opts.inline_axis, expected, "both axes must be aligned alike");
            assert_eq!(opts.behavior, ScrollIntoViewBehavior::Auto);
            // The behavior setters must not disturb the axes, and last-writer-wins.
            let instant = opts.with_instant();
            assert_eq!(instant.behavior, ScrollIntoViewBehavior::Instant);
            assert_eq!(instant.block, opts.block);
            assert_eq!(instant.inline_axis, opts.inline_axis);
            let smooth = opts.with_smooth();
            assert_eq!(smooth.behavior, ScrollIntoViewBehavior::Smooth);
            assert_eq!(
                opts.with_instant().with_smooth().behavior,
                ScrollIntoViewBehavior::Smooth
            );
            assert_eq!(
                opts.with_smooth().with_instant().behavior,
                ScrollIntoViewBehavior::Instant
            );
        }
    }
    #[test]
    fn default_action_result_has_action_predicate() {
        assert!(!DefaultActionResult::default().has_action());
        assert!(!DefaultActionResult::prevented().has_action());
        assert!(DefaultActionResult::prevented().prevented);
        assert_eq!(DefaultActionResult::prevented().action, DefaultAction::None);
        // `None` action => nothing to do, even though it was not prevented.
        let none = DefaultActionResult::new(DefaultAction::None);
        assert!(!none.prevented);
        assert!(!none.has_action());
        // Any real action => has_action.
        for action in [
            DefaultAction::FocusNext,
            DefaultAction::FocusPrevious,
            DefaultAction::FocusFirst,
            DefaultAction::FocusLast,
            DefaultAction::ClearFocus,
            DefaultAction::SelectAllText,
            DefaultAction::ActivateFocusedElement { target: dnid(0, 1) },
            DefaultAction::SubmitForm { form_node: dnid(0, 1) },
            DefaultAction::CloseModal { modal_node: dnid(0, 1) },
            DefaultAction::ScrollFocusedContainer {
                direction: ScrollDirection::Down,
                amount: ScrollAmount::Page,
            },
        ] {
            let r = DefaultActionResult::new(action);
            assert_eq!(r.action, action);
            assert!(!r.prevented);
            assert!(r.has_action(), "{action:?} must be reported as actionable");
        }
    }
    #[test]
    fn synthetic_event_constructor_invariants_and_flag_transitions() {
        let target = dnid(3, 7);
        let mut ev = SyntheticEvent::new(
            EventType::Click,
            EventSource::Programmatic,
            target,
            tick(42),
            EventData::None,
        );
        // Post-construction invariants.
        assert_eq!(ev.event_type, EventType::Click);
        assert_eq!(ev.source, EventSource::Programmatic);
        assert_eq!(ev.phase, EventPhase::Target);
        assert_eq!(ev.target, target);
        assert_eq!(ev.current_target, target);
        assert_eq!(ev.timestamp, tick(42));
        assert!(!ev.is_propagation_stopped());
        assert!(!ev.is_immediate_propagation_stopped());
        assert!(!ev.is_default_prevented());
        // stop_propagation does NOT imply stop_immediate_propagation...
        ev.stop_propagation();
        assert!(ev.is_propagation_stopped());
        assert!(!ev.is_immediate_propagation_stopped());
        // ...but the reverse implication MUST hold, or propagate_phase's
        // `stopped_immediate` check could be bypassed by the `stopped` fast path.
        let mut ev2 = SyntheticEvent::new(
            EventType::Click,
            EventSource::User,
            target,
            tick(0),
            EventData::None,
        );
        ev2.stop_immediate_propagation();
        assert!(ev2.is_immediate_propagation_stopped());
        assert!(
            ev2.is_propagation_stopped(),
            "immediate stop must also stop normal propagation"
        );
        // All three flags are idempotent and independent.
        let mut ev3 = ev2.clone();
        ev3.stop_immediate_propagation();
        ev3.prevent_default();
        ev3.prevent_default();
        assert!(ev3.is_default_prevented());
        assert!(!ev.is_default_prevented(), "flags must not leak across events");
    }
    #[test]
    fn hover_filter_is_system_internal_only_for_system_text_clicks() {
        for f in [
            HoverEventFilter::SystemTextSingleClick,
            HoverEventFilter::SystemTextDoubleClick,
            HoverEventFilter::SystemTextTripleClick,
        ] {
            assert!(f.is_system_internal(), "{f:?} is internal");
            assert!(
                f.to_focus_event_filter().is_none(),
                "internal filters must never be exposed as focus callbacks"
            );
        }
        for f in [
            HoverEventFilter::MouseOver,
            HoverEventFilter::MouseDown,
            HoverEventFilter::Drop,
            HoverEventFilter::KeyringResult,
            HoverEventFilter::MouseOut,
        ] {
            assert!(!f.is_system_internal(), "{f:?} is a user-visible filter");
        }
    }
    #[test]
    fn event_filter_kind_predicates_are_mutually_exclusive() {
        let hover = EventFilter::Hover(HoverEventFilter::MouseDown);
        let focus = EventFilter::Focus(FocusEventFilter::FocusReceived);
        let window = EventFilter::Window(WindowEventFilter::Resized);
        let component = EventFilter::Component(ComponentEventFilter::AfterMount);
        let app = EventFilter::Application(ApplicationEventFilter::DeviceConnected);
        assert!(focus.is_focus_callback());
        assert!(window.is_window_callback());
        for f in [hover, window, component, app] {
            assert!(!f.is_focus_callback(), "{f:?} is not a focus callback");
        }
        for f in [hover, focus, component, app] {
            assert!(!f.is_window_callback(), "{f:?} is not a window callback");
        }
        // The `as_*` accessors must agree with the predicates.
        assert_eq!(hover.as_hover_event_filter(), Some(HoverEventFilter::MouseDown));
        assert_eq!(hover.as_focus_event_filter(), None);
        assert_eq!(hover.as_window_event_filter(), None);
        assert_eq!(focus.as_focus_event_filter(), Some(FocusEventFilter::FocusReceived));
        assert_eq!(window.as_window_event_filter(), Some(WindowEventFilter::Resized));
        assert_eq!(component.as_hover_event_filter(), None);
    }
    // ============================================================== round-trips
    #[test]
    fn window_to_hover_filter_mapping_never_yields_an_internal_filter() {
        // Every window filter that has a hover twin must map onto a filter the
        // user is actually allowed to register (never a SystemText* internal).
        for w in [
            WindowEventFilter::MouseOver,
            WindowEventFilter::MouseDown,
            WindowEventFilter::LeftMouseDown,
            WindowEventFilter::RightMouseDown,
            WindowEventFilter::MiddleMouseDown,
            WindowEventFilter::MouseUp,
            WindowEventFilter::LeftMouseUp,
            WindowEventFilter::RightMouseUp,
            WindowEventFilter::MiddleMouseUp,
            WindowEventFilter::Scroll,
            WindowEventFilter::TextInput,
            WindowEventFilter::VirtualKeyDown,
            WindowEventFilter::VirtualKeyUp,
            WindowEventFilter::HoveredFile,
            WindowEventFilter::DroppedFile,
            WindowEventFilter::HoveredFileCancelled,
            WindowEventFilter::TouchStart,
            WindowEventFilter::TouchEnd,
            WindowEventFilter::PenDown,
            WindowEventFilter::DragStart,
            WindowEventFilter::Drop,
            WindowEventFilter::DoubleClick,
            WindowEventFilter::PermissionChanged,
            WindowEventFilter::BiometricResult,
            WindowEventFilter::KeyringResult,
        ] {
            let hover = w
                .to_hover_event_filter()
                .unwrap_or_else(|| panic!("{w:?} should have a hover twin"));
            assert!(
                !hover.is_system_internal(),
                "{w:?} must not map onto an internal filter"
            );
        }
        // Window-only events have deliberately NO hover twin.
        for w in [
            WindowEventFilter::MouseEnter,
            WindowEventFilter::MouseLeave,
            WindowEventFilter::Resized,
            WindowEventFilter::Moved,
            WindowEventFilter::FocusReceived,
            WindowEventFilter::FocusLost,
            WindowEventFilter::CloseRequested,
            WindowEventFilter::ThemeChanged,
            WindowEventFilter::WindowFocusReceived,
            WindowEventFilter::WindowFocusLost,
            WindowEventFilter::DpiChanged,
            WindowEventFilter::MonitorChanged,
        ] {
            assert_eq!(
                w.to_hover_event_filter(),
                None,
                "{w:?} is window-specific and must not map to a hover filter"
            );
        }
    }
    #[test]
    fn window_hover_focus_filter_names_round_trip() {
        // Window -> Hover -> Focus must preserve the *identity* of the event for
        // the shared (mouse / key / drag) subset — a mismatched row here means a
        // callback registered as Focus(X) would fire for hover event Y.
        let pairs = [
            (
                WindowEventFilter::MouseOver,
                HoverEventFilter::MouseOver,
                Some(FocusEventFilter::MouseOver),
            ),
            (
                WindowEventFilter::LeftMouseDown,
                HoverEventFilter::LeftMouseDown,
                Some(FocusEventFilter::LeftMouseDown),
            ),
            (
                WindowEventFilter::RightMouseUp,
                HoverEventFilter::RightMouseUp,
                Some(FocusEventFilter::RightMouseUp),
            ),
            (
                WindowEventFilter::TextInput,
                HoverEventFilter::TextInput,
                Some(FocusEventFilter::TextInput),
            ),
            (
                WindowEventFilter::VirtualKeyDown,
                HoverEventFilter::VirtualKeyDown,
                Some(FocusEventFilter::VirtualKeyDown),
            ),
            (
                WindowEventFilter::DragStart,
                HoverEventFilter::DragStart,
                Some(FocusEventFilter::DragStart),
            ),
            (
                WindowEventFilter::Drop,
                HoverEventFilter::Drop,
                Some(FocusEventFilter::Drop),
            ),
            // File events exist on window + hover, but have no focus twin.
            (
                WindowEventFilter::DroppedFile,
                HoverEventFilter::DroppedFile,
                None,
            ),
            (
                WindowEventFilter::TouchStart,
                HoverEventFilter::TouchStart,
                None,
            ),
        ];
        for (w, h, f) in pairs {
            assert_eq!(w.to_hover_event_filter(), Some(h), "window->hover for {w:?}");
            assert_eq!(h.to_focus_event_filter(), f, "hover->focus for {h:?}");
        }
    }
    #[test]
    fn on_to_event_filter_conversion_is_stable() {
        use crate::dom::On;
        // On::TextInput / FocusReceived / FocusLost are FOCUS filters, and the
        // virtual-key events are WINDOW filters — everything else is Hover.
        assert_eq!(
            EventFilter::from(On::TextInput),
            EventFilter::Focus(FocusEventFilter::TextInput)
        );
        assert_eq!(
            EventFilter::from(On::VirtualKeyDown),
            EventFilter::Window(WindowEventFilter::VirtualKeyDown)
        );
        assert_eq!(
            EventFilter::from(On::MouseOver),
            EventFilter::Hover(HoverEventFilter::MouseOver)
        );
        // The a11y actions all collapse onto "click" (= MouseUp).
        for on in [On::Default, On::Collapse, On::Expand, On::Increment, On::Decrement] {
            assert_eq!(
                EventFilter::from(on),
                EventFilter::Hover(HoverEventFilter::MouseUp),
                "{on:?} must map to the click filter"
            );
        }
        assert!(EventFilter::from(On::TextInput).is_focus_callback());
        assert!(EventFilter::from(On::VirtualKeyUp).is_window_callback());
    }
    #[test]
    fn virtual_keycode_round_trips_for_every_key_events_rs_interprets() {
        // handle_key_down decodes `KeyboardEventData.key_code` with `from_u32`,
        // while producers write `vk as u32`. If that round-trip ever breaks, every
        // shortcut silently dies — so pin it for the keys this module interprets.
        for vk in [
            VirtualKeyCode::Left,
            VirtualKeyCode::Right,
            VirtualKeyCode::Up,
            VirtualKeyCode::Down,
            VirtualKeyCode::Home,
            VirtualKeyCode::End,
            VirtualKeyCode::Back,
            VirtualKeyCode::Delete,
            VirtualKeyCode::A,
            VirtualKeyCode::C,
            VirtualKeyCode::D,
            VirtualKeyCode::V,
            VirtualKeyCode::X,
            VirtualKeyCode::Y,
            VirtualKeyCode::Z,
        ] {
            assert_eq!(
                VirtualKeyCode::from_u32(vk as u32),
                Some(vk),
                "{vk:?} must survive the as-u32 / from_u32 round trip"
            );
        }
        // Out-of-range key codes must decode to None rather than index out of bounds.
        assert_eq!(VirtualKeyCode::from_u32(u32::MAX), None);
        assert_eq!(VirtualKeyCode::from_u32(100_000), None);
    }
    // ============================================ ArrowDirection / KeyboardShortcut
    #[test]
    fn arrow_direction_from_key_is_total_over_every_decodable_key() {
        // Fuzz every decodable key code (plus the undecodable tail) through both
        // key mappers: they must never panic and must only claim the nav keys.
        let nav = [
            VirtualKeyCode::Left,
            VirtualKeyCode::Right,
            VirtualKeyCode::Up,
            VirtualKeyCode::Down,
            VirtualKeyCode::Home,
            VirtualKeyCode::End,
        ];
        for raw in 0u32..1024 {
            let Some(vk) = VirtualKeyCode::from_u32(raw) else {
                continue;
            };
            for ctrl in [false, true] {
                let got = ArrowDirection::from_key(vk, ctrl);
                assert_eq!(
                    got.is_some(),
                    nav.contains(&vk),
                    "{vk:?} (ctrl={ctrl}) must map to an ArrowDirection iff it is a nav key"
                );
                if let Some(dir) = got {
                    // to_selection is total and never panics for any (dir, ctrl).
                    let (_d, _s) = dir.to_selection(ctrl);
                }
            }
        }
    }
    #[test]
    fn arrow_direction_ctrl_only_upgrades_horizontal_arrows_to_words() {
        // ctrl must upgrade Left/Right to Word steps, and must NOT change the
        // step for Up/Down/Home/End (those are already line/document scoped).
        for (dir, expect_no_ctrl, expect_ctrl) in [
            (
                ArrowDirection::Left,
                (SelectionDirection::Backward, SelectionStep::Character),
                (SelectionDirection::Backward, SelectionStep::Word),
            ),
            (
                ArrowDirection::Right,
                (SelectionDirection::Forward, SelectionStep::Character),
                (SelectionDirection::Forward, SelectionStep::Word),
            ),
            (
                ArrowDirection::Up,
                (SelectionDirection::Backward, SelectionStep::VisualLine),
                (SelectionDirection::Backward, SelectionStep::VisualLine),
            ),
            (
                ArrowDirection::Down,
                (SelectionDirection::Forward, SelectionStep::VisualLine),
                (SelectionDirection::Forward, SelectionStep::VisualLine),
            ),
            (
                ArrowDirection::LineStart,
                (SelectionDirection::Backward, SelectionStep::Line),
                (SelectionDirection::Backward, SelectionStep::Line),
            ),
            (
                ArrowDirection::DocumentEnd,
                (SelectionDirection::Forward, SelectionStep::Document),
                (SelectionDirection::Forward, SelectionStep::Document),
            ),
        ] {
            assert_eq!(dir.to_selection(false), expect_no_ctrl, "{dir:?} plain");
            assert_eq!(dir.to_selection(true), expect_ctrl, "{dir:?} + ctrl");
        }
        // Ctrl+Home/End are distinct DIRECTIONS (not a step upgrade).
        assert_eq!(
            ArrowDirection::from_key(VirtualKeyCode::Home, true),
            Some(ArrowDirection::DocumentStart)
        );
        assert_eq!(
            ArrowDirection::from_key(VirtualKeyCode::End, true),
            Some(ArrowDirection::DocumentEnd)
        );
    }
    #[test]
    fn keyboard_shortcut_from_key_requires_primary_for_every_key() {
        // Without the primary modifier NO key may produce a shortcut — otherwise
        // typing plain "c" into a text field would copy.
        for raw in 0u32..1024 {
            let Some(vk) = VirtualKeyCode::from_u32(raw) else {
                continue;
            };
            for shift in [false, true] {
                assert_eq!(
                    KeyboardShortcut::from_key(vk, false, shift),
                    None,
                    "{vk:?} (shift={shift}) must need the primary modifier"
                );
            }
        }
        // With primary held, exactly the editing set is recognised.
        assert_eq!(
            KeyboardShortcut::from_key(VirtualKeyCode::Z, true, true),
            Some(KeyboardShortcut::Redo),
            "primary+shift+Z is Redo, not Undo"
        );
        assert_eq!(
            KeyboardShortcut::from_key(VirtualKeyCode::Y, true, true),
            Some(KeyboardShortcut::Redo),
            "shift must not disturb primary+Y"
        );
        assert_eq!(
            KeyboardShortcut::from_key(VirtualKeyCode::C, true, true),
            Some(KeyboardShortcut::Copy),
            "shift must not disturb primary+C"
        );
        // D is handled separately (SelectNextOccurrence), not as a KeyboardShortcut.
        assert_eq!(KeyboardShortcut::from_key(VirtualKeyCode::D, true, false), None);
    }
    #[test]
    fn selection_op_new_defaults_to_a_single_repeat() {
        let op = SelectionOp::new(
            SelectionDirection::Forward,
            SelectionStep::Word,
            SelectionMode::Delete,
        );
        assert_eq!(op.direction, SelectionDirection::Forward);
        assert_eq!(op.step, SelectionStep::Word);
        assert_eq!(op.mode, SelectionMode::Delete);
        assert_eq!(op.repeat, 1, "a fresh op must apply exactly once");
    }
    // ================================================== filter/phase matching
    #[test]
    fn capture_phase_never_matches_any_filter() {
        // Regression guard: azul has no capture listeners. If this breaks, every
        // ancestor callback fires TWICE (once capturing, once bubbling).
        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        for filter in [
            EventFilter::Hover(HoverEventFilter::MouseDown),
            EventFilter::Hover(HoverEventFilter::LeftMouseDown),
            EventFilter::Focus(FocusEventFilter::MouseDown),
            EventFilter::Window(WindowEventFilter::MouseDown),
            EventFilter::Component(ComponentEventFilter::AfterMount),
            EventFilter::Application(ApplicationEventFilter::DeviceConnected),
        ] {
            assert!(
                !matches_filter_phase(filter, &ev, EventPhase::Capture),
                "{filter:?} must not match in the capture phase"
            );
        }
        // ...but the same filter DOES match at Target and Bubble.
        for phase in [EventPhase::Target, EventPhase::Bubble] {
            assert!(matches_filter_phase(
                EventFilter::Hover(HoverEventFilter::MouseDown),
                &ev,
                phase
            ));
        }
    }
    #[test]
    fn application_filters_never_match_yet() {
        // Documented stub: Application events are not routed through propagation.
        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        for phase in [EventPhase::Capture, EventPhase::Target, EventPhase::Bubble] {
            assert!(!matches_filter_phase(
                EventFilter::Application(ApplicationEventFilter::MonitorConnected),
                &ev,
                phase
            ));
        }
    }
    #[test]
    fn check_mouse_button_is_false_for_every_non_mouse_payload() {
        for data in [
            EventData::None,
            EventData::Keyboard(KeyboardEventData {
                key_code: 0,
                char_code: None,
                modifiers: KeyModifiers::default(),
                repeat: false,
            }),
            EventData::Touch(TouchEventData {
                id: u64::MAX,
                position: LogicalPosition::zero(),
                force: f32::NAN,
            }),
            EventData::Clipboard(ClipboardEventData { content: None }),
        ] {
            for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
                assert!(
                    !check_mouse_button(&data, button),
                    "non-mouse payload must never claim a button"
                );
            }
        }
        // Exotic button ids compare by value, including the u8 boundary.
        let other_max = EventData::Mouse(MouseEventData {
            position: LogicalPosition::zero(),
            button: MouseButton::Other(u8::MAX),
            buttons: u8::MAX,
            modifiers: KeyModifiers::default(),
        });
        assert!(check_mouse_button(&other_max, MouseButton::Other(u8::MAX)));
        assert!(!check_mouse_button(&other_max, MouseButton::Other(0)));
        assert!(!check_mouse_button(&other_max, MouseButton::Left));
    }
    #[test]
    fn button_specific_filters_require_the_matching_button() {
        let left = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        let right = mouse_event(EventType::MouseDown, MouseButton::Right, LogicalPosition::zero());
        let middle = mouse_event(EventType::MouseDown, MouseButton::Middle, LogicalPosition::zero());
        // The generic filter fires for every button...
        for ev in [&left, &right, &middle] {
            assert!(matches_hover_filter(
                HoverEventFilter::MouseDown,
                ev,
                EventPhase::Target
            ));
        }
        // ...the specific ones only for theirs.
        assert!(matches_hover_filter(HoverEventFilter::LeftMouseDown, &left, EventPhase::Target));
        assert!(!matches_hover_filter(HoverEventFilter::LeftMouseDown, &right, EventPhase::Target));
        assert!(matches_hover_filter(HoverEventFilter::RightMouseDown, &right, EventPhase::Target));
        assert!(!matches_hover_filter(HoverEventFilter::MiddleMouseDown, &right, EventPhase::Target));
        assert!(matches_hover_filter(HoverEventFilter::MiddleMouseDown, &middle, EventPhase::Target));
        // A MouseDown filter must never fire on a MouseUp event and vice versa.
        let up = mouse_event(EventType::MouseUp, MouseButton::Left, LogicalPosition::zero());
        assert!(!matches_hover_filter(HoverEventFilter::MouseDown, &up, EventPhase::Target));
        assert!(!matches_hover_filter(HoverEventFilter::MouseUp, &left, EventPhase::Target));
        assert!(matches_focus_filter(FocusEventFilter::LeftMouseUp, &up, EventPhase::Target));
        assert!(matches_window_filter(WindowEventFilter::LeftMouseUp, &up, EventPhase::Target));
        // A MouseDown event carrying a NON-mouse payload cannot satisfy a
        // button-specific filter (there is no button to compare against).
        let payloadless = SyntheticEvent::new(
            EventType::MouseDown,
            EventSource::Synthetic,
            dnid(0, 0),
            tick(0),
            EventData::None,
        );
        assert!(matches_hover_filter(HoverEventFilter::MouseDown, &payloadless, EventPhase::Target));
        assert!(!matches_hover_filter(
            HoverEventFilter::LeftMouseDown,
            &payloadless,
            EventPhase::Target
        ));
    }
    #[test]
    fn component_filter_matches_only_its_own_lifecycle_event() {
        let lifecycle = |ty: EventType| {
            SyntheticEvent::new(ty, EventSource::Lifecycle, dnid(0, 0), tick(0), EventData::None)
        };
        let pairs = [
            (ComponentEventFilter::AfterMount, EventType::Mount),
            (ComponentEventFilter::BeforeUnmount, EventType::Unmount),
            (ComponentEventFilter::Updated, EventType::Update),
            (ComponentEventFilter::NodeResized, EventType::Resize),
        ];
        for (filter, ty) in pairs {
            let ev = lifecycle(ty);
            assert!(
                matches_component_filter(filter, &ev, EventPhase::Target),
                "{filter:?} must match {ty:?}"
            );
            // ...and must NOT match any of the other lifecycle event types.
            for (_, other_ty) in pairs.iter().filter(|(_, t)| *t != ty) {
                assert!(
                    !matches_component_filter(filter, &lifecycle(*other_ty), EventPhase::Target),
                    "{filter:?} must not match {other_ty:?}"
                );
            }
        }
        // DefaultAction / Selected have no EventType twin: they must never match
        // a lifecycle event (they are driven by the a11y layer instead).
        for filter in [ComponentEventFilter::DefaultAction, ComponentEventFilter::Selected] {
            for (_, ty) in pairs {
                assert!(!matches_component_filter(filter, &lifecycle(ty), EventPhase::Target));
            }
        }
    }
    #[test]
    fn event_type_to_filters_never_panics_and_stays_synced_with_the_hover_matcher() {
        // ROUND-TRIP INVARIANT: a Hover filter emitted by `event_type_to_filters`
        // is later re-checked by `matches_filter_phase` inside `propagate_event`
        // (see shell2/common/event.rs). If the two tables disagree, the callback
        // is collected and then silently dropped — a dead filter.
        //
        // KNOWN_DESYNC records the pairs that are ALREADY broken today (reported
        // separately). The assertion is a *subset* check, so fixing one of them
        // keeps this test green while any NEW desync fails it.
        const KNOWN_DESYNC: &[EventType] = &[
            EventType::Click,             // -> Hover(LeftMouseUp), matcher wants EventType::MouseUp
            EventType::ContextMenu,       // -> Hover(RightMouseDown), matcher wants MouseDown
            EventType::MouseOut,          // -> Hover(MouseOut), matcher has no MouseOut arm
            EventType::ScrollStart,       // -> Hover(Scroll), matcher wants EventType::Scroll
            EventType::ScrollEnd,         // -> Hover(Scroll), matcher wants EventType::Scroll
            EventType::FocusIn,           // -> Hover(FocusIn), matcher has no FocusIn arm
            EventType::FocusOut,          // -> Hover(FocusOut), matcher has no FocusOut arm
            EventType::CompositionStart,  // -> Hover(CompositionStart), no arm
            EventType::CompositionUpdate, // -> Hover(CompositionUpdate), no arm
            EventType::CompositionEnd,    // -> Hover(CompositionEnd), no arm
        ];
        let mouse_data = EventData::Mouse(MouseEventData {
            position: LogicalPosition::new(1.0, 1.0),
            button: MouseButton::Left,
            buttons: 1,
            modifiers: KeyModifiers::default(),
        });
        let cases: Vec<(EventType, EventData)> = vec![
            (EventType::MouseOver, EventData::None),
            (EventType::MouseEnter, EventData::None),
            (EventType::MouseLeave, EventData::None),
            (EventType::MouseOut, EventData::None),
            (EventType::MouseDown, mouse_data.clone()),
            (EventType::MouseUp, mouse_data.clone()),
            (EventType::Click, mouse_data.clone()),
            (EventType::DoubleClick, mouse_data.clone()),
            (EventType::ContextMenu, mouse_data.clone()),
            (EventType::KeyDown, EventData::None),
            (EventType::KeyUp, EventData::None),
            (EventType::KeyPress, EventData::None),
            (EventType::CompositionStart, EventData::None),
            (EventType::CompositionUpdate, EventData::None),
            (EventType::CompositionEnd, EventData::None),
            (EventType::Focus, EventData::None),
            (EventType::Blur, EventData::None),
            (EventType::FocusIn, EventData::None),
            (EventType::FocusOut, EventData::None),
            (EventType::Input, EventData::None),
            (EventType::Change, EventData::None),
            (EventType::Scroll, EventData::None),
            (EventType::ScrollStart, EventData::None),
            (EventType::ScrollEnd, EventData::None),
            (EventType::DragStart, EventData::None),
            (EventType::Drag, EventData::None),
            (EventType::DragEnd, EventData::None),
            (EventType::DragEnter, EventData::None),
            (EventType::DragOver, EventData::None),
            (EventType::DragLeave, EventData::None),
            (EventType::Drop, EventData::None),
            (EventType::TouchStart, EventData::None),
            (EventType::TouchMove, EventData::None),
            (EventType::TouchEnd, EventData::None),
            (EventType::TouchCancel, EventData::None),
            (EventType::Mount, EventData::None),
            (EventType::Unmount, EventData::None),
            (EventType::Update, EventData::None),
            (EventType::Resize, EventData::None),
            (EventType::WindowResize, EventData::None),
            (EventType::WindowMove, EventData::None),
            (EventType::WindowClose, EventData::None),
            (EventType::ThemeChange, EventData::None),
            (EventType::FileHover, EventData::None),
            (EventType::FileDrop, EventData::None),
            (EventType::FileHoverCancel, EventData::None),
            (EventType::Copy, EventData::None),
            (EventType::Cut, EventData::None),
            (EventType::Paste, EventData::None),
            (EventType::SensorChanged, EventData::None),
            (EventType::GamepadInput, EventData::None),
            (EventType::GeolocationFix, EventData::None),
            (EventType::GeolocationError, EventData::None),
            (EventType::PermissionChanged, EventData::None),
            (EventType::BiometricResult, EventData::None),
            (EventType::KeyringResult, EventData::None),
            (EventType::LongPress, EventData::None),
            (EventType::Play, EventData::None),
        ];
        for (ty, data) in cases {
            let filters = event_type_to_filters(ty, &data);
            let ev = SyntheticEvent::new(ty, EventSource::User, dnid(0, 0), tick(0), data);
            // No duplicate filters — a duplicate would invoke the callback twice.
            let mut seen = BTreeSet::new();
            for f in &filters {
                assert!(seen.insert(*f), "{ty:?} emitted {f:?} twice");
            }
            for f in &filters {
                if !matches!(f, EventFilter::Hover(_)) {
                    continue; // only Hover filters are re-checked by propagate_event
                }
                if matches_filter_phase(*f, &ev, EventPhase::Target) {
                    continue;
                }
                assert!(
                    KNOWN_DESYNC.contains(&ty),
                    "NEW DESYNC: event_type_to_filters({ty:?}) emits {f:?}, but \
                     matches_filter_phase rejects it at the Target phase, so the \
                     callback would be collected and then silently dropped"
                );
            }
        }
    }
    #[test]
    fn event_type_to_filters_omits_button_specific_filter_for_exotic_buttons() {
        // MouseButton::Other(n) has no dedicated filter: only the generic one.
        let data = EventData::Mouse(MouseEventData {
            position: LogicalPosition::zero(),
            button: MouseButton::Other(u8::MAX),
            buttons: 0,
            modifiers: KeyModifiers::default(),
        });
        let down = event_type_to_filters(EventType::MouseDown, &data);
        assert_eq!(down, vec![EventFilter::Hover(HoverEventFilter::MouseDown)]);
        let up = event_type_to_filters(EventType::MouseUp, &data);
        assert_eq!(up, vec![EventFilter::Hover(HoverEventFilter::MouseUp)]);
        // Unmapped event types produce an empty filter list (never a panic).
        for ty in [
            EventType::Submit,
            EventType::Reset,
            EventType::Invalid,
            EventType::Play,
            EventType::Pause,
            EventType::Ended,
            EventType::TimeUpdate,
            EventType::VolumeChange,
            EventType::MediaError,
            EventType::PinchIn,
            EventType::RotateClockwise,
            EventType::SwipeLeft,
        ] {
            assert!(
                event_type_to_filters(ty, &EventData::None).is_empty(),
                "{ty:?} is unmapped and must yield no filters"
            );
        }
    }
    // ================================================== DOM path / propagation
    #[test]
    fn get_dom_path_none_target_yields_empty_path() {
        let hier = hierarchy_chain(3);
        assert!(get_dom_path(&hier, NodeHierarchyItemId::NONE).is_empty());
        // An empty hierarchy with a real target must not index out of bounds.
        let empty = NodeHierarchy::new(Vec::new());
        let path = get_dom_path(&empty, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
        assert_eq!(path, vec![NodeId::ZERO], "unknown nodes still path to themselves");
    }
    #[test]
    fn get_dom_path_out_of_range_target_does_not_panic() {
        let hier = hierarchy_chain(3);
        let huge = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(usize::MAX - 1)));
        let path = get_dom_path(&hier, huge);
        assert_eq!(path, vec![NodeId::new(usize::MAX - 1)]);
    }
    #[test]
    fn get_dom_path_returns_root_to_target_order() {
        let hier = hierarchy_chain(4); // 0 <- 1 <- 2 <- 3
        let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(3))));
        assert_eq!(
            path,
            vec![NodeId::new(0), NodeId::new(1), NodeId::new(2), NodeId::new(3)],
            "path must run root -> target"
        );
        // The root itself paths to a single-element vec.
        let root_path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
        assert_eq!(root_path, vec![NodeId::ZERO]);
    }
    #[test]
    fn get_dom_path_terminates_on_a_self_parent_cycle() {
        // A node that is its own parent must not spin forever.
        let hier = NodeHierarchy::new(vec![Node {
            parent: Some(NodeId::ZERO),
            ..Node::ROOT
        }]);
        let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
        assert_eq!(path, vec![NodeId::ZERO]);
    }
    #[test]
    fn get_dom_path_handles_a_deep_chain_without_recursing() {
        // 5000 levels deep: an iterative walk copes, a recursive one would blow
        // the stack.
        let hier = hierarchy_chain(5000);
        let path = get_dom_path(
            &hier,
            NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(4999))),
        );
        assert_eq!(path.len(), 5000);
        assert_eq!(path[0], NodeId::ZERO);
        assert_eq!(path[4999], NodeId::new(4999));
    }
    #[test]
    fn propagate_event_visits_each_node_exactly_once() {
        // Regression guard for the double-fire bug: with capture + bubble both
        // walking the ancestors, a node's callback used to be collected TWICE.
        let hier = hierarchy_chain(3); // 0 <- 1 <- 2
        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        for i in 0..3 {
            callbacks.insert(
                NodeId::new(i),
                vec![EventFilter::Hover(HoverEventFilter::MouseDown)],
            );
        }
        let mut ev = SyntheticEvent::new(
            EventType::MouseDown,
            EventSource::User,
            dnid(0, 2),
            tick(0),
            EventData::Mouse(MouseEventData {
                position: LogicalPosition::zero(),
                button: MouseButton::Left,
                buttons: 1,
                modifiers: KeyModifiers::default(),
            }),
        );
        let result = propagate_event(&mut ev, &hier, &callbacks);
        let nodes: Vec<NodeId> = result.callbacks_to_invoke.iter().map(|(n, _)| *n).collect();
        assert_eq!(
            nodes,
            vec![NodeId::new(2), NodeId::new(1), NodeId::new(0)],
            "target first, then bubbling up to the root — each node once"
        );
        assert!(!result.default_prevented);
    }
    #[test]
    fn propagate_event_on_a_dangling_target_is_a_no_op() {
        let hier = hierarchy_chain(2);
        let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        // Target = the `None` sentinel: the doc comment claims a panic, but the
        // implementation returns a default result. Pin the safe behavior.
        let mut ev = SyntheticEvent::new(
            EventType::MouseDown,
            EventSource::User,
            dnid_none(0),
            tick(0),
            EventData::None,
        );
        let result = propagate_event(&mut ev, &hier, &callbacks);
        assert!(result.callbacks_to_invoke.is_empty());
        assert!(!result.default_prevented);
        // Target = a node id far outside the hierarchy: also a no-op, no panic.
        let mut ev = SyntheticEvent::new(
            EventType::MouseDown,
            EventSource::User,
            dnid(0, 10_000),
            tick(0),
            EventData::None,
        );
        let result = propagate_event(&mut ev, &hier, &callbacks);
        assert!(result.callbacks_to_invoke.is_empty());
    }
    #[test]
    fn propagate_event_respects_a_pre_stopped_event() {
        let hier = hierarchy_chain(3);
        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        for i in 0..3 {
            callbacks.insert(
                NodeId::new(i),
                vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
            );
        }
        let base = SyntheticEvent::new(
            EventType::MouseOver,
            EventSource::User,
            dnid(0, 2),
            tick(0),
            EventData::None,
        );
        // stopped => neither target nor bubble collect anything.
        let mut stopped = base.clone();
        stopped.stop_propagation();
        let r = propagate_event(&mut stopped, &hier, &callbacks);
        assert!(r.callbacks_to_invoke.is_empty(), "a stopped event collects nothing");
        // stopped_immediate => likewise (and it implies `stopped`).
        let mut immediate = base.clone();
        immediate.stop_immediate_propagation();
        let r = propagate_event(&mut immediate, &hier, &callbacks);
        assert!(r.callbacks_to_invoke.is_empty());
        // prevented_default is faithfully reported back out.
        let mut prevented = base;
        prevented.prevent_default();
        let r = propagate_event(&mut prevented, &hier, &callbacks);
        assert!(r.default_prevented);
        assert_eq!(r.callbacks_to_invoke.len(), 3, "preventDefault must not stop dispatch");
    }
    #[test]
    fn propagate_event_ignores_filters_that_do_not_match_the_event() {
        let hier = hierarchy_chain(2);
        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        callbacks.insert(
            NodeId::new(1),
            vec![
                EventFilter::Hover(HoverEventFilter::MouseUp), // wrong event type
                EventFilter::Hover(HoverEventFilter::RightMouseDown), // wrong button
                EventFilter::Hover(HoverEventFilter::LeftMouseDown), // match
            ],
        );
        let mut ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        ev.target = dnid(0, 1);
        ev.current_target = ev.target;
        let r = propagate_event(&mut ev, &hier, &callbacks);
        assert_eq!(
            r.callbacks_to_invoke,
            vec![(
                NodeId::new(1),
                EventFilter::Hover(HoverEventFilter::LeftMouseDown)
            )]
        );
        // The event is left in the state of the LAST walked phase: bubble, ending
        // on the root ancestor. (`current_target` is only meaningful while a
        // callback is running, so this pins the post-walk residue rather than
        // asserting it is reset.)
        assert_eq!(ev.phase, EventPhase::Bubble);
        assert_eq!(ev.current_target, dnid(0, 0));
        assert_eq!(ev.target, dnid(0, 1), "the target itself must never be rewritten");
    }
    #[test]
    fn collect_matching_callbacks_collects_nothing_once_immediate_stop_is_set() {
        let mut result = PropagationResult::default();
        let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        callbacks.insert(
            NodeId::ZERO,
            vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
        );
        let mut ev = SyntheticEvent::new(
            EventType::MouseOver,
            EventSource::User,
            dnid(0, 0),
            tick(0),
            EventData::None,
        );
        ev.stop_immediate_propagation();
        collect_matching_callbacks(&ev, NodeId::ZERO, EventPhase::Target, &callbacks, &mut result);
        assert!(result.callbacks_to_invoke.is_empty());
        // A node with no registered callbacks is simply skipped.
        let mut fresh = PropagationResult::default();
        let clean = SyntheticEvent::new(
            EventType::MouseOver,
            EventSource::User,
            dnid(0, 0),
            tick(0),
            EventData::None,
        );
        collect_matching_callbacks(&clean, NodeId::new(9), EventPhase::Target, &callbacks, &mut fresh);
        assert!(fresh.callbacks_to_invoke.is_empty());
    }
    #[test]
    fn propagate_phase_over_an_empty_iterator_only_sets_the_phase() {
        let mut result = PropagationResult::default();
        let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
        let mut ev = SyntheticEvent::new(
            EventType::MouseOver,
            EventSource::User,
            dnid(0, 0),
            tick(0),
            EventData::None,
        );
        propagate_phase(
            &mut ev,
            core::iter::empty(),
            EventPhase::Bubble,
            &callbacks,
            &mut result,
        );
        assert_eq!(ev.phase, EventPhase::Bubble);
        assert!(result.callbacks_to_invoke.is_empty());
        // propagate_target_phase resets phase + current_target to the target.
        propagate_target_phase(&mut ev, NodeId::ZERO, &callbacks, &mut result);
        assert_eq!(ev.phase, EventPhase::Target);
        assert_eq!(ev.current_target, ev.target);
    }
    // ================================================================== dedup
    #[test]
    fn deduplicate_synthetic_events_handles_empty_and_single() {
        assert!(deduplicate_synthetic_events(Vec::new()).is_empty());
        let one = vec![SyntheticEvent::new(
            EventType::Scroll,
            EventSource::User,
            dnid(0, 0),
            tick(1),
            EventData::None,
        )];
        assert_eq!(deduplicate_synthetic_events(one).len(), 1);
    }
    #[test]
    fn deduplicate_synthetic_events_keeps_the_latest_timestamp_per_target_and_type() {
        let mk = |node: usize, ty: EventType, t: u64| {
            SyntheticEvent::new(ty, EventSource::User, dnid(0, node), tick(t), EventData::None)
        };
        // Same (target, type), out-of-order timestamps -> keep the newest.
        let events = vec![
            mk(1, EventType::Scroll, 5),
            mk(1, EventType::Scroll, 99),
            mk(1, EventType::Scroll, 1),
        ];
        let out = deduplicate_synthetic_events(events);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].timestamp, tick(99), "the newest event must survive");
        // Different node OR different type -> both survive.
        let events = vec![
            mk(1, EventType::Scroll, 1),
            mk(2, EventType::Scroll, 1),
            mk(1, EventType::MouseOver, 1),
        ];
        assert_eq!(deduplicate_synthetic_events(events).len(), 3);
        // Different DOM with the same node index -> distinct targets.
        let a = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(0, 1), tick(0), EventData::None);
        let b = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(1, 1), tick(0), EventData::None);
        assert_eq!(deduplicate_synthetic_events(vec![a, b]).len(), 2);
    }
    #[test]
    fn deduplicate_synthetic_events_collapses_a_large_duplicate_burst() {
        // 10k identical events (e.g. a scroll storm) must collapse to one, and
        // the result must be the newest — no quadratic blowup, no overflow.
        let events: Vec<SyntheticEvent> = (0..10_000u64)
            .map(|t| {
                SyntheticEvent::new(
                    EventType::Scroll,
                    EventSource::User,
                    dnid(0, 0),
                    tick(t),
                    EventData::None,
                )
            })
            .collect();
        let out = deduplicate_synthetic_events(events);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].timestamp, tick(9_999));
    }
    #[test]
    fn deduplicate_synthetic_events_preserves_unicode_payloads() {
        // Deduplication keys off (target, event_type) only — the payload must
        // survive untouched, including multi-byte / combining / RTL text.
        let text = "🦀 グラフ é\u{0301} مرحبا \u{1F1E6}\u{1F1F9}".repeat(200);
        let ev = SyntheticEvent::new(
            EventType::Input,
            EventSource::User,
            dnid(0, 0),
            tick(1),
            EventData::TextInput(TextInputEventData {
                inserted_text: text.clone(),
                old_text: String::new(),
            }),
        );
        let newer = SyntheticEvent::new(
            EventType::Input,
            EventSource::User,
            dnid(0, 0),
            tick(2),
            EventData::TextInput(TextInputEventData {
                inserted_text: text.clone(),
                old_text: text.clone(),
            }),
        );
        let out = deduplicate_synthetic_events(vec![ev, newer]);
        assert_eq!(out.len(), 1);
        match &out[0].data {
            EventData::TextInput(d) => {
                assert_eq!(d.inserted_text, text);
                assert_eq!(d.old_text, text, "the newer event won");
            }
            _ => panic!("payload must be preserved"),
        }
    }
    // ====================================================== hit-test extraction
    #[test]
    fn get_first_hovered_node_on_empty_input() {
        assert!(get_first_hovered_node(None).is_none());
        assert!(
            get_first_hovered_node(Some(&empty_hit_test())).is_none(),
            "a hit test with no hovered DOMs has no front-most node"
        );
        // A DOM entry that is present but has zero hit nodes is also `None`.
        let ht = hit_test_with(0, &[]);
        assert!(get_first_hovered_node(Some(&ht)).is_none());
    }
    #[test]
    fn get_first_hovered_node_picks_minimum_depth_and_breaks_ties_deterministically() {
        // Front-most (depth 0) has the HIGHER node id — a naive `.next()` on the
        // BTreeMap would wrongly return node 2.
        let ht = hit_test_with(0, &[(2, 5), (5, 0), (9, 3)]);
        let got = get_first_hovered_node(Some(&ht)).unwrap();
        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
        // Equal depths: the first in (DomId, NodeId) iteration order wins, and the
        // choice must be stable across calls.
        let ht = hit_test_with(0, &[(7, 2), (3, 2), (11, 2)]);
        let a = get_first_hovered_node(Some(&ht)).unwrap();
        let b = get_first_hovered_node(Some(&ht)).unwrap();
        assert_eq!(a, b, "tie-breaking must be deterministic");
        assert_eq!(a.node.into_crate_internal(), Some(NodeId::new(3)));
        // u32::MAX depth is still a valid (and only) candidate.
        let ht = hit_test_with(0, &[(1, u32::MAX)]);
        let got = get_first_hovered_node(Some(&ht)).unwrap();
        assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(1)));
        assert_eq!(got.dom, DomId { inner: 0 });
    }
    #[test]
    fn get_mouse_position_with_fallback_prefers_the_event_payload() {
        let mouse = MouseState {
            cursor_position: CursorPosition::InWindow(LogicalPosition::new(9.0, 9.0)),
            ..MouseState::default()
        };
        let ev = mouse_event(
            EventType::MouseDown,
            MouseButton::Left,
            LogicalPosition::new(1.0, 2.0),
        );
        assert_eq!(
            get_mouse_position_with_fallback(&ev, &mouse),
            LogicalPosition::new(1.0, 2.0),
            "the event's own payload wins over the live cursor"
        );
        // Non-mouse payload -> fall back to the live cursor...
        let keyless = SyntheticEvent::new(
            EventType::MouseDown,
            EventSource::Synthetic,
            dnid(0, 0),
            tick(0),
            EventData::None,
        );
        assert_eq!(
            get_mouse_position_with_fallback(&keyless, &mouse),
            LogicalPosition::new(9.0, 9.0)
        );
        // ...and if the cursor is Uninitialized or OutOfWindow, fall back to zero
        // (`CursorPosition::get_position` only yields InWindow positions).
        for cursor in [
            CursorPosition::Uninitialized,
            CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
        ] {
            let ms = MouseState { cursor_position: cursor, ..MouseState::default() };
            assert_eq!(
                get_mouse_position_with_fallback(&keyless, &ms),
                LogicalPosition::zero()
            );
        }
    }
    #[test]
    fn get_mouse_position_with_fallback_passes_through_extreme_coordinates() {
        let mouse = MouseState::default();
        for pos in [
            LogicalPosition::new(f32::NAN, f32::NAN),
            LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
            LogicalPosition::new(f32::MAX, f32::MIN),
            LogicalPosition::new(-0.0, 0.0),
        ] {
            let ev = mouse_event(EventType::MouseDown, MouseButton::Left, pos);
            let got = get_mouse_position_with_fallback(&ev, &mouse);
            // Compare bitwise so NaN == NaN holds: the value must be forwarded
            // verbatim, never sanitized or panicked on.
            assert_eq!(got.x.to_bits(), pos.x.to_bits());
            assert_eq!(got.y.to_bits(), pos.y.to_bits());
        }
    }
    // ============================================= input-interpreter handlers
    #[test]
    fn handle_mouse_down_treats_zero_click_count_as_one() {
        let ht = hit_test_with(0, &[(0, 0)]);
        let mouse = MouseState::default();
        let kb = KeyboardState::default();
        let ev = mouse_event(
            EventType::MouseDown,
            MouseButton::Left,
            LogicalPosition::new(4.0, 5.0),
        );
        // click_count 0 is normalised to 1 -> a plain text-selection click.
        let action = handle_mouse_down(&ev, Some(&ht), 0, &mouse, &kb)
            .expect("click_count 0 must be treated as a single click");
        match action {
            InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { position, .. }) => {
                assert_eq!(position, LogicalPosition::new(4.0, 5.0));
            }
            _ => panic!("expected a passed-through TextSelectionClick"),
        }
    }
    #[test]
    fn handle_mouse_down_saturates_above_a_triple_click() {
        let ht = hit_test_with(0, &[(0, 0)]);
        let mouse = MouseState::default();
        let kb = KeyboardState::default();
        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        // 1..=3 are real clicks.
        for count in 1u8..=3 {
            assert!(
                handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_some(),
                "click_count {count} must produce a selection click"
            );
        }
        // 4 and above (up to the u8 boundary) are dropped — no wraparound, no panic.
        for count in [4u8, 5, 100, u8::MAX] {
            assert!(
                handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_none(),
                "click_count {count} must be ignored"
            );
        }
    }
    #[test]
    fn handle_mouse_down_without_a_hit_test_is_a_no_op() {
        let mouse = MouseState::default();
        let kb = KeyboardState::default();
        let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        assert!(handle_mouse_down(&ev, None, 1, &mouse, &kb).is_none());
        assert!(handle_mouse_down(&ev, Some(&empty_hit_test()), 1, &mouse, &kb).is_none());
    }
    #[test]
    fn handle_mouse_down_with_primary_held_adds_a_cursor_only_on_a_single_click() {
        let ht = hit_test_with(0, &[(0, 0)]);
        let mouse = MouseState::default();
        let kb = keyboard_with_primary_held();
        let ev = mouse_event(
            EventType::MouseDown,
            MouseButton::Left,
            LogicalPosition::new(7.0, 8.0),
        );
        // primary + single click -> multi-cursor add.
        match handle_mouse_down(&ev, Some(&ht), 1, &mouse, &kb) {
            Some(InternalEventAction::AddAndPass(SystemChange::AddCursorAtClick { position })) => {
                assert_eq!(position, LogicalPosition::new(7.0, 8.0));
            }
            _ => panic!("primary+click must add a cursor at the click position"),
        }
        // primary + double click -> NOT a cursor add (falls back to selection).
        match handle_mouse_down(&ev, Some(&ht), 2, &mouse, &kb) {
            Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { .. })) => {}
            _ => panic!("primary+double-click must not add a cursor"),
        }
    }
    /// Releasing the button must stop the autoscroll timer. Nothing emitted
    /// `StopAutoScrollTimer` at all, so a lost release left a 60Hz timer
    /// running for the life of the window.
    #[test]
    fn releasing_the_button_stops_the_autoscroll_timer() {
        match handle_mouse_up() {
            InternalEventAction::AddAndPass(SystemChange::StopAutoScrollTimer) => {}
            _ => panic!("expected AddAndPass(StopAutoScrollTimer)"),
        }
    }
    #[test]
    fn handle_mouse_over_requires_a_held_button_and_a_drag_origin() {
        let ht = hit_test_with(0, &[(0, 0)]);
        let start = LogicalPosition::new(1.0, 1.0);
        let ev = mouse_event(
            EventType::MouseOver,
            MouseButton::Left,
            LogicalPosition::new(50.0, 60.0),
        );
        // Button up -> never a drag, even with a drag origin.
        let up = MouseState::default();
        assert!(handle_mouse_over(&ev, Some(&ht), &up, Some(start)).is_none());
        // Button down but no drag origin -> not a drag either.
        let down = MouseState { left_down: true, ..MouseState::default() };
        assert!(handle_mouse_over(&ev, Some(&ht), &down, None).is_none());
        // Button down + origin but nothing under the cursor -> STILL a drag:
        // reaching past the text (into padding, past the last line) is how a
        // selection gets extended, and the endpoint resolves against the
        // anchor block, not against whatever is under the pointer.
        assert!(handle_mouse_over(&ev, None, &down, Some(start)).is_some());
        assert!(handle_mouse_over(&ev, Some(&empty_hit_test()), &down, Some(start)).is_some());
        // All three present -> a drag selection from origin to the current point.
        match handle_mouse_over(&ev, Some(&ht), &down, Some(start)) {
            Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionDrag {
                start_position,
                current_position,
            })) => {
                assert_eq!(start_position, start);
                assert_eq!(current_position, LogicalPosition::new(50.0, 60.0));
            }
            _ => panic!("expected a TextSelectionDrag"),
        }
    }
    #[test]
    fn handle_key_down_needs_a_focused_node_and_a_keyboard_payload() {
        let kb = KeyboardState::default();
        let ev = key_event(VirtualKeyCode::Back as u32, KeyModifiers::default());
        assert!(
            handle_key_down(&ev, &kb, None).is_none(),
            "no focus => no keyboard system change"
        );
        // Focused, but the event carries no keyboard payload.
        let payloadless = SyntheticEvent::new(
            EventType::KeyDown,
            EventSource::User,
            dnid(0, 1),
            tick(0),
            EventData::None,
        );
        assert!(handle_key_down(&payloadless, &kb, Some(dnid(0, 1))).is_none());
    }
    #[test]
    fn handle_key_down_rejects_undecodable_key_codes() {
        let kb = KeyboardState::default();
        let target = Some(dnid(0, 1));
        // u32::MAX / out-of-table codes must fall out via `from_u32` -> None,
        // never index a table or panic.
        for code in [u32::MAX, u32::MAX - 1, 100_000, 9_999] {
            let ev = key_event(code, KeyModifiers::default());
            assert!(
                handle_key_down(&ev, &kb, target).is_none(),
                "key_code {code} must decode to None"
            );
        }
    }
    #[test]
    fn handle_key_down_reads_modifiers_from_the_event_not_the_live_keyboard() {
        // The live KeyboardState is deliberately EMPTY here: the handler must key
        // off the event payload's modifiers (the live state may have advanced
        // between queueing and dispatch).
        let kb = KeyboardState::default();
        let target = dnid(0, 1);
        let ev = key_event(VirtualKeyCode::C as u32, primary_modifiers());
        match handle_key_down(&ev, &kb, Some(target)) {
            Some(InternalEventAction::AddAndSkip(SystemChange::CopyToClipboard)) => {}
            _ => panic!("primary+C in the payload must copy, regardless of the live state"),
        }
        // ...and conversely, a live primary key must NOT rewrite an unmodified event.
        let live = keyboard_with_primary_held();
        let plain = key_event(VirtualKeyCode::C as u32, KeyModifiers::default());
        assert!(
            handle_key_down(&plain, &live, Some(target)).is_none(),
            "an unmodified C is plain text input, not a copy"
        );
    }
    #[test]
    fn handle_key_down_maps_backspace_and_delete_to_selection_ops() {
        let kb = KeyboardState::default();
        let target = dnid(0, 1);
        let expect_op = |ev: &SyntheticEvent| -> SelectionOp {
            match handle_key_down(ev, &kb, Some(target)) {
                Some(InternalEventAction::AddAndSkip(SystemChange::ApplySelectionOp {
                    target: t,
                    op,
                })) => {
                    assert_eq!(t, target);
                    op
                }
                _ => panic!("expected an ApplySelectionOp"),
            }
        };
        let back = expect_op(&key_event(VirtualKeyCode::Back as u32, KeyModifiers::default()));
        assert_eq!(back.direction, SelectionDirection::Backward);
        assert_eq!(back.step, SelectionStep::Character);
        assert_eq!(back.mode, SelectionMode::Delete);
        let del = expect_op(&key_event(VirtualKeyCode::Delete as u32, KeyModifiers::default()));
        assert_eq!(del.direction, SelectionDirection::Forward);
        assert_eq!(del.step, SelectionStep::Character);
        assert_eq!(del.mode, SelectionMode::Delete);
        // Shift+arrow extends instead of moving.
        let shift_right = expect_op(&key_event(
            VirtualKeyCode::Right as u32,
            KeyModifiers::new().with_shift(),
        ));
        assert_eq!(shift_right.mode, SelectionMode::Extend);
        assert_eq!(shift_right.step, SelectionStep::Character);
        // The word modifier upgrades Backspace to a word delete.
        let word_mod = if cfg!(target_os = "macos") {
            KeyModifiers::new().with_alt()
        } else {
            KeyModifiers::new().with_ctrl()
        };
        let word_back = expect_op(&key_event(VirtualKeyCode::Back as u32, word_mod));
        assert_eq!(word_back.step, SelectionStep::Word);
        assert_eq!(word_back.mode, SelectionMode::Delete);
    }
    #[test]
    fn handle_key_down_ignores_keys_it_does_not_interpret() {
        let kb = KeyboardState::default();
        let target = Some(dnid(0, 1));
        // Ordinary text keys must pass through to the user callbacks untouched.
        for vk in [VirtualKeyCode::B, VirtualKeyCode::Q, VirtualKeyCode::Space, VirtualKeyCode::F5] {
            let ev = key_event(vk as u32, KeyModifiers::default());
            assert!(
                handle_key_down(&ev, &kb, target).is_none(),
                "{vk:?} must not generate a system change"
            );
        }
    }
    // ================================================ default_input_interpreter
    #[test]
    fn default_input_interpreter_with_no_events_produces_nothing() {
        let kb = KeyboardState::default();
        let mouse = MouseState::default();
        let info = InputInterpreterInfo {
            events: &[],
            hit_test: None,
            keyboard_state: &kb,
            mouse_state: &mouse,
            state: InputInterpreterState {
                focused_node: None,
                click_count: 0,
                drag_start_position: None,
                has_selection: false,
            },
        };
        let r = default_input_interpreter(&info);
        assert!(r.system_changes.is_empty());
        assert!(r.user_events.is_empty());
    }
    #[test]
    fn default_input_interpreter_skips_shortcut_events_but_passes_clicks_through() {
        let kb = KeyboardState::default();
        let mouse = MouseState::default();
        let ht = hit_test_with(0, &[(0, 0)]);
        let target = dnid(0, 1);
        // A primary+C shortcut is consumed (AddAndSkip) — the user callback must
        // NOT also see the raw key event...
        let copy = key_event(VirtualKeyCode::C as u32, primary_modifiers());
        // ...while a MouseDown is consumed AND forwarded (AddAndPass).
        let click = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
        // ...and an unhandled event type is forwarded untouched.
        let scroll = SyntheticEvent::new(
            EventType::Scroll,
            EventSource::User,
            target,
            tick(0),
            EventData::None,
        );
        let events = vec![copy, click, scroll];
        let info = InputInterpreterInfo {
            events: &events,
            hit_test: Some(&ht),
            keyboard_state: &kb,
            mouse_state: &mouse,
            state: InputInterpreterState {
                focused_node: Some(target),
                click_count: 1,
                drag_start_position: None,
                has_selection: false,
            },
        };
        let r = default_input_interpreter(&info);
        assert_eq!(r.system_changes.len(), 2, "copy + selection click");
        assert!(r.system_changes.contains(&SystemChange::CopyToClipboard));
        assert!(r
            .system_changes
            .iter()
            .any(|c| matches!(c, SystemChange::TextSelectionClick { .. })));
        assert_eq!(r.user_events.len(), 2, "the consumed KeyDown must not be forwarded");
        assert!(!r.user_events.iter().any(|e| e.event_type == EventType::KeyDown));
        assert!(r.user_events.iter().any(|e| e.event_type == EventType::MouseDown));
        assert!(r.user_events.iter().any(|e| e.event_type == EventType::Scroll));
    }
    #[test]
    fn default_input_interpreter_extern_survives_a_null_info_pointer() {
        // The C-ABI trampoline must null-check rather than deref garbage.
        let user_data = crate::refany::RefAny::new(0u8);
        let r = default_input_interpreter_extern(user_data, core::ptr::null());
        assert!(r.system_changes.is_empty());
        assert!(r.user_events.is_empty());
    }
    // ==================================================== post-callback filter
    #[test]
    fn post_filter_with_prevent_default_only_lets_focus_changes_through() {
        let old = Some(dnid(0, 1));
        let new = Some(dnid(0, 2));
        let pre = vec![
            SystemChange::TextSelectionClick {
                position: LogicalPosition::zero(),
                timestamp: tick(0),
            },
            SystemChange::PasteFromClipboard,
        ];
        // prevent_default + no focus change -> absolutely nothing (not even the
        // usual ApplyPendingTextInput).
        let out = default_post_filter(true, &pre, old, old);
        assert!(out.is_empty(), "preventDefault must suppress every side effect");
        // prevent_default + a focus change -> ONLY the focus change.
        let out = default_post_filter(true, &pre, old, new);
        assert_eq!(out, vec![SystemChange::SetFocus { new_focus: new, old_focus: old }]);
    }
    #[test]
    fn post_filter_maps_pre_changes_to_their_follow_ups() {
        // No pre-changes, no focus change -> just the text-input flush.
        let out = default_post_filter(false, &[], None, None);
        assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
        // Cursor-moving ops schedule a scroll-into-view.
        for change in [
            SystemChange::TextSelectionClick {
                position: LogicalPosition::zero(),
                timestamp: tick(0),
            },
            SystemChange::ApplySelectionOp {
                target: dnid(0, 1),
                op: SelectionOp::new(
                    SelectionDirection::Forward,
                    SelectionStep::Character,
                    SelectionMode::Move,
                ),
            },
            SystemChange::AddCursorAtClick { position: LogicalPosition::zero() },
            SystemChange::SelectNextOccurrence { target: dnid(0, 1) },
            SystemChange::CutToClipboard { target: dnid(0, 1) },
            SystemChange::PasteFromClipboard,
            SystemChange::UndoTextEdit { target: dnid(0, 1) },
            SystemChange::RedoTextEdit { target: dnid(0, 1) },
            SystemChange::SelectAllText,
        ] {
            let out = default_post_filter(false, core::slice::from_ref(&change), None, None);
            assert!(
                out.contains(&SystemChange::ScrollSelectionIntoView),
                "{change:?} must schedule a scroll-into-view"
            );
            assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
        }
        // A drag starts the auto-scroll timer instead.
        let drag = SystemChange::TextSelectionDrag {
            start_position: LogicalPosition::zero(),
            current_position: LogicalPosition::new(1.0, 1.0),
        };
        let out = default_post_filter(false, core::slice::from_ref(&drag), None, None);
        assert!(out.contains(&SystemChange::StartAutoScrollTimer));
        assert!(!out.contains(&SystemChange::ScrollSelectionIntoView));
        // Changes with no follow-up add nothing beyond the text-input flush.
        let out = default_post_filter(false, &[SystemChange::CopyToClipboard], None, None);
        assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
    }
    #[test]
    fn post_filter_emits_set_focus_only_when_focus_actually_moved() {
        let a = Some(dnid(0, 1));
        let b = Some(dnid(0, 2));
        // Unchanged (both Some, both None) -> no SetFocus.
        for (old, new) in [(a, a), (None, None)] {
            let out = default_post_filter(false, &[], old, new);
            assert!(!out.iter().any(|c| matches!(c, SystemChange::SetFocus { .. })));
        }
        // Changed (including to/from None) -> exactly one SetFocus, and it is last.
        for (old, new) in [(a, b), (None, a), (a, None)] {
            let out = default_post_filter(false, &[], old, new);
            assert_eq!(
                out.last(),
                Some(&SystemChange::SetFocus { new_focus: new, old_focus: old })
            );
            assert_eq!(
                out.iter()
                    .filter(|c| matches!(c, SystemChange::SetFocus { .. }))
                    .count(),
                1
            );
        }
    }
    #[test]
    fn post_filter_handles_a_large_pre_change_list_without_blowing_up() {
        // 5000 cursor ops -> 1 flush + 5000 scroll-into-views. Bounded, no overflow.
        let pre: Vec<SystemChange> = (0..5000)
            .map(|_| SystemChange::AddCursorAtClick { position: LogicalPosition::zero() })
            .collect();
        let out = default_post_filter(false, &pre, None, None);
        assert_eq!(out.len(), 5001);
        assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
        assert!(out[1..]
            .iter()
            .all(|c| *c == SystemChange::ScrollSelectionIntoView));
    }
    #[test]
    fn default_post_filter_delegates_to_post_callback_filter_system_changes() {
        let pre = vec![
            SystemChange::TextSelectionDrag {
                start_position: LogicalPosition::zero(),
                current_position: LogicalPosition::new(2.0, 2.0),
            },
            SystemChange::SelectAllText,
        ];
        for prevent in [false, true] {
            for (old, new) in [(None, None), (Some(dnid(0, 1)), Some(dnid(0, 2)))] {
                assert_eq!(
                    default_post_filter(prevent, &pre, old, new),
                    post_callback_filter_system_changes(prevent, &pre, old, new),
                    "the two entry points must stay in lock-step"
                );
            }
        }
    }
    /// The default schema must be an empty op LIST, not null and not a string.
    ///
    /// A plugin deciding whether a host is usable has to distinguish "this app
    /// advertises no ops" from "this app returned nothing parseable". Those
    /// are different answers and only one of them means "move on".
    #[test]
    fn default_op_schema_is_an_empty_list_not_a_null() {
        let cb = CustomE2eOpCallback::default();
        assert!(cb.op_schema.is_object(), "schema must be an object");
        assert!(!cb.op_schema.is_null());
        let text = cb.op_schema.internal.string_value.as_str();
        assert!(text.contains("\"ops\""), "got {text}");
        // Positive control: a NON-empty schema must serialize its contents,
        // so this test cannot pass by everything being empty.
        let schema = E2eOpSchema {
            ops: alloc::vec![E2eOpDef {
                name: "load_document".to_string(),
                summary: "Open a file".to_string(),
                description: "Loads a markdown file into the editor.".to_string(),
                args: alloc::vec![E2eOpArg {
                    name: "path".to_string(),
                    arg_type: E2eOpArgType::String,
                    required: true,
                    description: "Absolute path.".to_string(),
                }],
                examples: alloc::vec![E2eOpExample {
                    description: "Open big.md".to_string(),
                    args: crate::json::Json::parse(r#"{"path":"/tmp/big.md"}"#).unwrap(),
                    returns: crate::json::Json::parse(r#"{"success":true,"pages":40}"#)
                        .unwrap(),
                }],
            }],
        };
        let j = schema.to_json();
        let t = j.internal.string_value.as_str();
        for needle in ["load_document", "Open a file", "\"type\":\"string\"", "big.md", "pages"] {
            assert!(t.contains(needle), "missing {needle} in {t}");
        }
    }
    fn sample_op(returns: &str) -> E2eOpDef {
        E2eOpDef {
            name: "load_document".to_string(),
            summary: "Open a markdown file".to_string(),
            description: "Reads and paginates a file.".to_string(),
            args: alloc::vec![E2eOpArg {
                name: "path".to_string(),
                arg_type: E2eOpArgType::String,
                required: true,
                description: "Absolute path.".to_string(),
            }],
            examples: alloc::vec![E2eOpExample {
                description: "Open big.md".to_string(),
                args: crate::json::Json::parse(r#"{"path":"/tmp/big.md"}"#).unwrap(),
                returns: crate::json::Json::parse(returns).unwrap(),
            }],
        }
    }
    /// `success` is REQUIRED, and checked when the schema is installed.
    #[test]
    fn schema_validation_requires_a_success_boolean_in_every_example() {
        let ok = E2eOpSchema { ops: alloc::vec![sample_op(r#"{"success":true,"pages":40}"#)] };
        assert_eq!(ok.validate(), Ok(()));
        // NEGATIVE CONTROL: drop `success` and validation must reject.
        let missing = E2eOpSchema { ops: alloc::vec![sample_op(r#"{"pages":40}"#)] };
        assert!(matches!(
            missing.validate(),
            Err(E2eSchemaError::ExampleMissingSuccess { .. })
        ));
        // Present but not a BOOLEAN is still a reject — `"success":"yes"` is
        // what a hand-written schema actually produces and tells a consumer
        // nothing.
        let stringy = E2eOpSchema { ops: alloc::vec![sample_op(r#"{"success":"yes"}"#)] };
        assert!(stringy.validate().is_err());
        let dupe = E2eOpSchema {
            ops: alloc::vec![
                sample_op(r#"{"success":true}"#),
                sample_op(r#"{"success":true}"#),
            ],
        };
        assert!(matches!(dupe.validate(), Err(E2eSchemaError::DuplicateOpName { .. })));
    }
    /// Identity fields lead, and examples are NESTED JSON not escaped strings.
    #[test]
    fn schema_json_keeps_declaration_order_and_nests_examples() {
        let schema = E2eOpSchema { ops: alloc::vec![sample_op(r#"{"success":true,"pages":40}"#)] };
        let text = schema.to_json().internal.string_value.as_str().to_string();
        let name_at = text.find("\"name\"").expect("name present");
        let args_at = text.find("\"args\"").expect("args present");
        assert!(name_at < args_at, "identity fields must lead: {text}");
        // Nested, so NO backslash-escaped quotes anywhere in the payload.
        assert!(!text.contains("\\\""), "examples must nest, not escape: {text}");
        assert!(text.contains("\"success\":true"), "{text}");
    }
    #[test]
    fn default_custom_op_handler_recognises_nothing() {
        // handled=false is the load-bearing default: an app with no handler
        // must make a scenario naming a custom op FAIL, not pass quietly.
        let r = default_custom_e2e_op_extern(
            crate::refany::RefAny::new(0u8),
            AzString::from_const_str("anything"),
            AzString::from_const_str("{}"),
        );
        assert!(!r.handled);
    }
    fn default_post_filter_extern_decodes_the_none_focus_sentinel() {
        // old_focus = the `None` sentinel, new_focus = a real node => a focus change.
        let pre: Vec<SystemChange> = Vec::new();
        let slice = SystemChangeVecSlice {
            ptr: pre.as_ptr(),
            len: pre.len(),
        };
        let out = default_post_filter_extern(
            crate::refany::RefAny::new(0u8),
            false,
            slice,
            dnid_none(0),
            dnid(0, 4),
        );
        let changes = out.as_slice();
        assert_eq!(changes.first(), Some(&SystemChange::ApplyPendingTextInput));
        assert_eq!(
            changes.last(),
            Some(&SystemChange::SetFocus {
                new_focus: Some(dnid(0, 4)),
                old_focus: None,
            }),
            "a `NONE` node id must decode to `None`, not to node 0"
        );
        // An empty C-slice must be accepted (ptr may be dangling-but-aligned).
        let out = default_post_filter_extern(
            crate::refany::RefAny::new(0u8),
            true,
            SystemChangeVecSlice::empty(),
            dnid_none(0),
            dnid_none(0),
        );
        assert!(out.as_slice().is_empty());
    }
}