azul_core/events.rs
1//! Event and callback filtering module
2
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5use alloc::{
6 boxed::Box,
7 collections::{btree_map::BTreeMap, btree_set::BTreeSet},
8 vec::Vec,
9};
10
11use azul_css::AzString;
12
13use crate::{
14 callbacks::Update,
15 dom::{DomId, DomNodeId, On},
16 geom::{LogicalPosition, LogicalRect},
17 hit_test::{FullHitTest, HitTestItem},
18 id::NodeId,
19 styled_dom::{ChangedCssProperty, NodeHierarchyItemId},
20 task::Instant,
21 OrderedMap,
22};
23
24/// Easing functions for smooth scroll animations
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum EasingFunction {
27 Linear,
28 EaseInOut,
29 EaseOut,
30 /// Critically-damped spring settle (ledger #28, per the animation
31 /// design doc): position eases like a spring released toward the
32 /// target — fast initial pull, asymptote-free exact landing. Evaluated
33 /// analytically from normalized time (same curve family the scroll
34 /// physics integrates numerically).
35 Spring,
36}
37
38pub type RestyleNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
39pub type RelayoutNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
40pub type RelayoutWords = BTreeMap<NodeId, AzString>;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct FocusChange {
44 pub old: Option<DomNodeId>,
45 pub new: Option<DomNodeId>,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub struct CallbackToCall {
50 pub node_id: NodeId,
51 pub hit_test_item: Option<HitTestItem>,
52 pub event_filter: EventFilter,
53}
54
55impl CallbackToCall {
56 #[must_use]
57 pub const fn new(
58 node_id: NodeId,
59 hit_test_item: Option<HitTestItem>,
60 event_filter: EventFilter,
61 ) -> Self {
62 Self {
63 node_id,
64 hit_test_item,
65 event_filter,
66 }
67 }
68
69 /// Build a list of `CallbackToCall` entries for every node hit by the
70 /// given hit test under the given DOM, tagged with `event_filter`.
71 /// Returns an empty `Vec` when there is no hit test data for the DOM.
72 #[must_use]
73 pub fn from_hit_test(
74 hit_test: &FullHitTest,
75 dom_id: DomId,
76 event_filter: EventFilter,
77 ) -> Vec<Self> {
78 let Some(hit) = hit_test.hovered_nodes.get(&dom_id) else {
79 return Vec::new();
80 };
81 hit.regular_hit_test_nodes
82 .iter()
83 .map(|(node_id, item)| Self {
84 node_id: *node_id,
85 hit_test_item: Some(*item),
86 event_filter,
87 })
88 .collect()
89 }
90}
91
92#[derive(Debug, Copy, Clone, PartialEq, Eq)]
93#[must_use = "ProcessEventResult must be used to determine if relayout/repaint is needed"]
94pub enum ProcessEventResult {
95 DoNothing = 0,
96 ShouldReRenderCurrentWindow = 1,
97 ShouldUpdateDisplayListCurrentWindow = 2,
98 // GPU transforms changed: do another hit-test and recurse
99 // until nothing has changed anymore
100 UpdateHitTesterAndProcessAgain = 3,
101 // Restyle or runtime edit changed layout-affecting properties:
102 // re-run layout on the EXISTING StyledDom (no DOM rebuild).
103 ShouldIncrementalRelayout = 4,
104 // Full DOM rebuild via user's layout_callback()
105 ShouldRegenerateDomCurrentWindow = 5,
106 ShouldRegenerateDomAllWindows = 6,
107}
108
109impl ProcessEventResult {
110 #[must_use]
111 pub const fn order(&self) -> usize {
112 use self::ProcessEventResult::{
113 DoNothing, ShouldIncrementalRelayout, ShouldReRenderCurrentWindow,
114 ShouldRegenerateDomAllWindows, ShouldRegenerateDomCurrentWindow,
115 ShouldUpdateDisplayListCurrentWindow, UpdateHitTesterAndProcessAgain,
116 };
117 match self {
118 DoNothing => 0,
119 ShouldReRenderCurrentWindow => 1,
120 ShouldUpdateDisplayListCurrentWindow => 2,
121 UpdateHitTesterAndProcessAgain => 3,
122 ShouldIncrementalRelayout => 4,
123 ShouldRegenerateDomCurrentWindow => 5,
124 ShouldRegenerateDomAllWindows => 6,
125 }
126 }
127}
128
129impl PartialOrd for ProcessEventResult {
130 fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
131 self.order().partial_cmp(&other.order())
132 }
133}
134
135impl Ord for ProcessEventResult {
136 fn cmp(&self, other: &Self) -> core::cmp::Ordering {
137 self.order().cmp(&other.order())
138 }
139}
140
141impl ProcessEventResult {
142 pub fn max_self(self, other: Self) -> Self {
143 self.max(other)
144 }
145}
146
147/// Tracks the origin of an event for proper handling.
148///
149/// This allows the system to distinguish between user input, programmatic
150/// changes, and synthetic events generated by UI components.
151#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
152#[repr(C)]
153pub enum EventSource {
154 /// Direct user input (mouse, keyboard, touch, gamepad)
155 User,
156 /// API call (programmatic scroll, focus change, etc.)
157 Programmatic,
158 /// Generated from UI interaction (scrollbar drag, synthetic events)
159 Synthetic,
160 /// Generated from lifecycle hooks (mount, unmount, resize)
161 Lifecycle,
162}
163
164/// Event propagation phase (similar to DOM Level 2 Events).
165///
166/// Events can be intercepted at different phases:
167/// - **Capture**: Event travels from root down to target (rarely used)
168/// - **Target**: Event is at the target element
169/// - **Bubble**: Event travels from target back up to root (most common)
170#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
171#[repr(C)]
172#[derive(Default)]
173pub enum EventPhase {
174 /// Event travels from root down to target
175 Capture,
176 /// Event is at the target element
177 Target,
178 /// Event bubbles from target back up to root
179 #[default]
180 Bubble,
181}
182
183/// Mouse button identifier for mouse events.
184#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
185#[repr(C)]
186pub enum MouseButton {
187 Left,
188 Middle,
189 Right,
190 Other(u8),
191}
192
193/// Scroll delta mode (how scroll deltas should be interpreted).
194#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
195#[repr(C)]
196pub enum ScrollDeltaMode {
197 /// Delta is in pixels
198 Pixel,
199 /// Delta is in lines (e.g., 3 lines of text)
200 Line,
201 /// Delta is in pages
202 Page,
203}
204
205/// Scroll direction for conditional event filtering.
206#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
207#[repr(C)]
208pub enum ScrollDirection {
209 Up,
210 Down,
211 Left,
212 Right,
213}
214
215// ============================================================================
216// W3C CSSOM View Module - Scroll Into View Types
217// ============================================================================
218
219/// W3C-compliant scroll-into-view options
220///
221/// These options control how an element is scrolled into view, following
222/// the CSSOM View Module specification.
223#[repr(C)]
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
225pub struct ScrollIntoViewOptions {
226 /// Vertical alignment: start, center, end, nearest (default: nearest)
227 pub block: ScrollLogicalPosition,
228 /// Horizontal alignment: start, center, end, nearest (default: nearest)
229 /// Note: Named `inline_axis` to avoid conflict with C keyword `inline`
230 pub inline_axis: ScrollLogicalPosition,
231 /// Animation behavior: auto, instant, smooth (default: auto)
232 pub behavior: ScrollIntoViewBehavior,
233}
234
235impl ScrollIntoViewOptions {
236 /// Create options with "nearest" alignment for both axes
237 #[must_use]
238 pub const fn nearest() -> Self {
239 Self {
240 block: ScrollLogicalPosition::Nearest,
241 inline_axis: ScrollLogicalPosition::Nearest,
242 behavior: ScrollIntoViewBehavior::Auto,
243 }
244 }
245
246 /// Create options with "center" alignment for both axes
247 #[must_use]
248 pub const fn center() -> Self {
249 Self {
250 block: ScrollLogicalPosition::Center,
251 inline_axis: ScrollLogicalPosition::Center,
252 behavior: ScrollIntoViewBehavior::Auto,
253 }
254 }
255
256 /// Create options with "start" alignment for both axes
257 #[must_use]
258 pub const fn start() -> Self {
259 Self {
260 block: ScrollLogicalPosition::Start,
261 inline_axis: ScrollLogicalPosition::Start,
262 behavior: ScrollIntoViewBehavior::Auto,
263 }
264 }
265
266 /// Create options to align the end of the target with the end of the viewport
267 #[must_use]
268 pub const fn end() -> Self {
269 Self {
270 block: ScrollLogicalPosition::End,
271 inline_axis: ScrollLogicalPosition::End,
272 behavior: ScrollIntoViewBehavior::Auto,
273 }
274 }
275
276 /// Set instant scroll behavior
277 #[must_use]
278 pub const fn with_instant(mut self) -> Self {
279 self.behavior = ScrollIntoViewBehavior::Instant;
280 self
281 }
282
283 /// Set smooth scroll behavior
284 #[must_use]
285 pub const fn with_smooth(mut self) -> Self {
286 self.behavior = ScrollIntoViewBehavior::Smooth;
287 self
288 }
289}
290
291/// Scroll alignment for vertical (block) or horizontal (inline) axis
292///
293/// Determines where the target element should be positioned within
294/// the scroll container's visible area.
295#[repr(C)]
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
297pub enum ScrollLogicalPosition {
298 /// Align target's start edge with container's start edge
299 Start,
300 /// Center target within container
301 Center,
302 /// Align target's end edge with container's end edge
303 End,
304 /// Minimum scroll distance to make target fully visible (default)
305 #[default]
306 Nearest,
307}
308
309/// Scroll animation behavior for scrollIntoView API
310///
311/// This is distinct from the CSS `scroll-behavior` property, as it also
312/// supports the `Instant` option which CSS does not have.
313#[repr(C)]
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
315pub enum ScrollIntoViewBehavior {
316 /// Respect CSS scroll-behavior property (default)
317 #[default]
318 Auto,
319 /// Immediate jump without animation
320 Instant,
321 /// Animated smooth scroll
322 Smooth,
323}
324
325/// Reason why a lifecycle event was triggered.
326#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
327#[repr(C)]
328pub enum LifecycleReason {
329 /// First appearance in DOM
330 InitialMount,
331 /// Removed and re-added to DOM
332 Remount,
333 /// Layout bounds changed
334 Resize,
335 /// Props or state changed
336 Update,
337 /// Node was removed from DOM
338 Unmount,
339 /// A `<transient-window>` was closed by the USER (outside click, Escape),
340 /// not by the app flipping `open`.
341 Dismiss,
342 /// A `<transient-window>` was torn off its anchor into a free toplevel
343 /// by the user's drag. `current_bounds` is where it went, in the parent.
344 TearOff,
345 /// A torn-off `<transient-window>` was docked back (onto its anchor or a
346 /// drop zone) by the user's drag.
347 Dock,
348}
349
350/// Keyboard modifier keys state.
351#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
352#[repr(C)]
353pub struct KeyModifiers {
354 pub shift: bool,
355 pub ctrl: bool,
356 pub alt: bool,
357 pub meta: bool,
358}
359
360impl KeyModifiers {
361 #[must_use]
362 pub fn new() -> Self {
363 Self::default()
364 }
365
366 #[must_use]
367 pub const fn with_shift(mut self) -> Self {
368 self.shift = true;
369 self
370 }
371
372 #[must_use]
373 pub const fn with_ctrl(mut self) -> Self {
374 self.ctrl = true;
375 self
376 }
377
378 #[must_use]
379 pub const fn with_alt(mut self) -> Self {
380 self.alt = true;
381 self
382 }
383
384 #[must_use]
385 pub const fn with_meta(mut self) -> Self {
386 self.meta = true;
387 self
388 }
389
390 #[must_use]
391 pub const fn is_empty(&self) -> bool {
392 !self.shift && !self.ctrl && !self.alt && !self.meta
393 }
394}
395
396/// What kind of device produced a pointer event.
397///
398/// The same discriminator the web calls `PointerEvent.pointerType`, GTK calls
399/// `GdkInputSource` and Qt calls `QInputDevice::DeviceType`. Azul splits Mouse,
400/// Touch and Pen into separate FILTERS, which is a stronger guarantee than a
401/// runtime tag — but it cannot express the distinctions WITHIN the pointer
402/// family, and those matter:
403///
404/// - A touchpad and a mouse both arrive as a synthesized pointer. Telling them
405/// apart is what lets an app know a pinch gesture is possible at all, and
406/// whether "scroll" means a wheel detent or a continuous drag.
407/// - A trackball and a trackpoint have their own acceleration and scroll
408/// semantics, which is why GTK and Android name them separately.
409/// - A pen reported through the pointer path (a stylus acting as a mouse, the
410/// Wayland tablet bridge) is not a finger and not a mouse.
411#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
412#[repr(C)]
413pub enum PointerSource {
414 /// The platform did not say.
415 Unknown,
416 /// A mouse.
417 Mouse,
418 /// An indirect touch surface — a laptop trackpad or a Magic Trackpad.
419 Touchpad,
420 /// A trackball.
421 Trackball,
422 /// A pointing stick (ThinkPad nub).
423 Trackpoint,
424 /// A direct touch surface reporting through the pointer path.
425 Touchscreen,
426 /// A stylus tip.
427 Pen,
428 /// The inverted end of a stylus.
429 Eraser,
430}
431
432/// Type-specific event data for mouse events.
433#[derive(Debug, Clone, Copy, PartialEq)]
434#[repr(C)]
435pub struct MouseEventData {
436 /// Position of the mouse cursor
437 pub position: LogicalPosition,
438 /// Which button was pressed/released
439 pub button: MouseButton,
440 /// Bitmask of currently pressed buttons
441 pub buttons: u8,
442 /// Modifier keys state
443 pub modifiers: KeyModifiers,
444 /// What produced this event. APPENDED for ABI stability.
445 pub source: PointerSource,
446 /// Which physical device produced it, or `0` when the platform does not
447 /// say. APPENDED for ABI stability.
448 ///
449 /// Only `PenState`, `GamepadState` and `TabletPadState` carried a device id
450 /// before, so a mouse event could not say WHICH of two mice moved — and
451 /// `MouseState` used to be a single global, which is why multi-seat (a
452 /// kiosk, a shared display, two people on one compositor) was not
453 /// expressible at all. The state fans out per seat since 9b-ii; this
454 /// field stays the PHYSICAL device, `seat_id` below says which cursor.
455 pub device_id: u64,
456 /// Which pointer SEAT - which independent cursor - produced it. APPENDED
457 /// (9b-ii). `PRIMARY_POINTER_SEAT` (0) for the ordinary mouse, which is
458 /// every event on a platform with one cursor; an X11 MPX master id or a
459 /// Wayland seat otherwise. A callback that receives a non-zero value
460 /// reads that seat's state through `get_pointer_seat_state`, because
461 /// `get_current_mouse_state` is the PRIMARY seat by definition.
462 pub seat_id: u64,
463}
464
465impl Default for MouseEventData {
466 fn default() -> Self {
467 Self {
468 position: LogicalPosition { x: 0.0, y: 0.0 },
469 button: MouseButton::Left,
470 buttons: 0,
471 modifiers: KeyModifiers::default(),
472 source: PointerSource::Unknown,
473 device_id: 0,
474 seat_id: crate::window::PRIMARY_POINTER_SEAT,
475 }
476 }
477}
478
479/// Type-specific event data for keyboard events.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub struct KeyboardEventData {
482 /// The virtual key code
483 pub key_code: u32,
484 /// The character produced (if any)
485 pub char_code: Option<char>,
486 /// Modifier keys state
487 pub modifiers: KeyModifiers,
488 /// Whether this is a repeat event
489 pub repeat: bool,
490 /// Which physical keyboard produced this event, or `0` when the platform
491 /// does not say. APPENDED for ABI stability.
492 ///
493 /// Same reasoning as `MouseEventData::device_id`: with two keyboards
494 /// attached — a laptop's built-in and an external, or a barcode scanner
495 /// presenting as a keyboard — nothing could tell their input apart.
496 pub device_id: u64,
497 /// Which KEYBOARD SEAT pressed (9b-ii-a-i): `PRIMARY_POINTER_SEAT` for
498 /// the ordinary keyboard, a seat id for a second person's - the same
499 /// number their pointer events carry.
500 pub seat_id: u64,
501}
502
503impl Default for KeyboardEventData {
504 fn default() -> Self {
505 Self {
506 key_code: 0,
507 char_code: None,
508 modifiers: KeyModifiers::default(),
509 repeat: false,
510 device_id: 0,
511 seat_id: crate::window::PRIMARY_POINTER_SEAT,
512 }
513 }
514}
515
516/// Type-specific event data for scroll events.
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518pub struct ScrollEventData {
519 /// Scroll delta (dx, dy)
520 pub delta: LogicalPosition,
521 /// How the delta should be interpreted
522 pub delta_mode: ScrollDeltaMode,
523 /// Which pointer SEAT turned the wheel (9b-ii-b); `PRIMARY_POINTER_SEAT`
524 /// for the ordinary mouse. A second cursor's wheel scrolls the node under
525 /// THAT cursor, so the event names the seat the way a mouse event does.
526 pub seat_id: u64,
527}
528
529/// Type-specific event data for touch events.
530#[derive(Debug, Clone, Copy, PartialEq)]
531pub struct TouchEventData {
532 /// Touch identifier
533 pub id: u64,
534 /// Touch position
535 pub position: LogicalPosition,
536 /// Touch force/pressure (0.0 - 1.0)
537 pub force: f32,
538 /// The SEAT whose touchscreen this is (9b-ii-a-i-c); ids are per seat.
539 pub seat_id: u64,
540}
541
542/// Type-specific event data for clipboard events.
543#[derive(Debug, Clone, PartialEq, Eq)]
544pub struct ClipboardEventData {
545 /// The clipboard content (for paste events)
546 pub content: Option<String>,
547}
548
549/// Type-specific event data for lifecycle events.
550#[derive(Debug, Clone, Copy, PartialEq, Eq)]
551pub struct LifecycleEventData {
552 /// Why this lifecycle event was triggered
553 pub reason: LifecycleReason,
554 /// Previous layout bounds (for resize events)
555 pub previous_bounds: Option<LogicalRect>,
556 /// Current layout bounds
557 pub current_bounds: LogicalRect,
558}
559
560/// Type-specific event data for window events.
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub struct WindowEventData {
563 /// Window size (for resize events)
564 pub size: Option<LogicalRect>,
565 /// Window position (for move events)
566 pub position: Option<LogicalPosition>,
567}
568
569/// Type-specific event data for text-input (editing) events.
570///
571/// Data carried by `EventType::RawMouseMotion`.
572///
573/// Raw motion is what a 3D viewport, a map orbit, an infinite-drag number
574/// scrubber and a first-person camera all need, and it is NOT the difference
575/// between two cursor positions. Absolute positions have already had the OS
576/// pointer-acceleration curve applied and are clamped to the screen, so
577/// differencing them gives accelerated motion that stops dead at the edge of
578/// the display. These deltas are pre-acceleration and unbounded.
579/// Where the caret sits INSIDE an open IME preedit.
580///
581/// Both offsets are into the preedit string, not into the document: the
582/// preedit is not committed text and has no position in the document until it
583/// is. An IME uses this to place its candidate window under the segment being
584/// edited, and a `begin != end` pair is a SELECTED segment - several IMEs
585/// (Japanese conversion especially) move a highlighted region through the
586/// preedit as the user cycles candidates.
587///
588/// A named struct rather than the `(usize, usize)` this used to be, because a
589/// non-empty tuple has no C representation - so the accessor could not be
590/// exposed at all and no binding could read an IME's caret.
591#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
592#[repr(C)]
593pub struct CompositionCursor {
594 /// Start offset in the preedit, in bytes.
595 pub begin: usize,
596 /// End offset. Equal to `begin` for a caret with no selected segment.
597 pub end: usize,
598}
599
600azul_css::impl_option!(
601 CompositionCursor,
602 OptionCompositionCursor,
603 [Debug, Copy, Clone, PartialEq, Eq]
604);
605
606/// `#[repr(C)]` because `CallbackInfo::get_raw_mouse_motion` hands this
607/// across the C API. Without it the layout is undefined at the boundary and
608/// the generated bindings read garbage fields.
609#[derive(Debug, Clone, Copy, PartialEq)]
610#[repr(C)]
611pub struct RawMotionEventData {
612 /// Horizontal motion in the device's own units. NOT logical pixels: a
613 /// mouse reports counts, whose size depends on its DPI, which is exactly
614 /// why a sensitivity setting exists.
615 pub dx: f64,
616 /// Vertical motion, same units.
617 pub dy: f64,
618 /// Which device moved, or `0` when the platform does not say.
619 pub device_id: u64,
620}
621
622azul_css::impl_option!(
623 RawMotionEventData,
624 OptionRawMotionEventData,
625 [Debug, Copy, Clone, PartialEq]
626);
627
628/// Data carried by a `MediaControl` event (9h-i-a-i-a, 9h-i-a-i-b): what the
629/// platform's media controls asked for. The URI of an `OpenUri` is not
630/// repeated here - `CallbackInfo::get_media_control_request` carries the
631/// whole request.
632#[derive(Debug, Clone, Copy, PartialEq)]
633#[repr(C)]
634pub struct MediaControlEventData {
635 /// Microseconds; relative for `SeekRelative`, absolute for `SeekAbsolute`.
636 pub position_us: i64,
637 pub kind: crate::media_session::MediaControlKind,
638 /// The requested volume for `SetVolume` (`0.0` silent, `1.0` full), `0.0`
639 /// for every other kind.
640 pub volume: f32,
641}
642
643/// Data carried by a `SystemAudioChange` event (9h-i-a-i-d-i): what the
644/// system did with the audio the app took over. Also readable through
645/// `CallbackInfo::get_system_audio_change`.
646#[derive(Debug, Clone, Copy, PartialEq, Eq)]
647#[repr(C)]
648pub struct SystemAudioEventData {
649 pub change: crate::media_session::SystemAudioChange,
650}
651
652/// Data carried by the three IME composition events.
653///
654/// Every desktop shell already runs a real IME client — Win32 `WM_IME_*` with
655/// candidate-window positioning, macOS `NSTextInputClient`, Wayland
656/// `zwp_text_input_v3` preedit/commit, X11 XIM plus the xkb Compose layer —
657/// and all of it terminated in the engine's text cache. The built-in text
658/// widgets therefore worked, but an app rendering its own composition (a code
659/// editor, a terminal, a chat box with inline candidates) had no way to see
660/// it, because the `Composition*` filters had no event to carry.
661///
662/// The shape is the intersection of what the platforms report, which is also
663/// what W3C `CompositionEvent` and Wayland `preedit_string` agree on.
664#[derive(Debug, Clone, PartialEq, Eq)]
665pub struct CompositionEventData {
666 /// The preedit (composition) text as it currently stands. Empty on
667 /// `CompositionStart`, and on `CompositionEnd` this is the committed
668 /// string — W3C defines `compositionend.data` as what was committed, not
669 /// what was pending.
670 pub data: String,
671 /// Byte offset of the selection/caret start within `data`. Wayland sends
672 /// this directly (`preedit_string(text, cursor_begin, cursor_end)`);
673 /// Win32 derives it from `GCS_CURSORPOS`; macOS from the `selectedRange`
674 /// passed to `setMarkedText:selectedRange:replacementRange:`.
675 pub cursor_begin: usize,
676 /// Byte offset of the selection/caret end within `data`. Equal to
677 /// `cursor_begin` for a collapsed caret.
678 pub cursor_end: usize,
679 /// The SEAT whose input method this is (9b-ii-a-i-d-ii-c-i); the primary
680 /// is `PRIMARY_POINTER_SEAT`. The Focus filter delivers a seat's
681 /// composition to that seat's focused node.
682 pub seat_id: u64,
683}
684
685/// Carried by `EventType::Input` events so that text-input callbacks can read
686/// the edit details directly off the event — matching how mouse/keyboard/scroll
687/// callbacks read their data — instead of having to reach into the
688/// `TextInputManager`'s pending changeset. The edited node is already available
689/// via `SyntheticEvent.target`.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct TextInputEventData {
692 /// The text inserted by this edit (empty for pure deletions).
693 pub inserted_text: String,
694 /// The text content of the node *before* this edit was applied.
695 pub old_text: String,
696}
697
698/// Identifies WHICH pending structural changeset a notification is for.
699///
700/// Carried by `EventType::DocumentEdit` events; the app acks with the same
701/// id via `mark_document_edit_applied`. The full changeset is intentionally
702/// NOT copied onto the event — it stays single-instance in the window
703/// (one-pending-changeset model).
704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
705pub struct DocumentEditEventData {
706 /// The commit-handshake id of the recorded changeset.
707 pub changeset_id: u64,
708}
709
710/// Union of all possible event data types.
711#[derive(Debug, Clone, PartialEq)]
712pub enum EventData {
713 /// Mouse event data
714 Mouse(MouseEventData),
715 /// Keyboard event data
716 Keyboard(KeyboardEventData),
717 /// Scroll event data
718 Scroll(ScrollEventData),
719 /// Touch event data
720 Touch(TouchEventData),
721 /// Clipboard event data
722 Clipboard(ClipboardEventData),
723 /// Text-input (editing) event data
724 TextInput(TextInputEventData),
725 /// Structural document-edit notification data
726 DocumentEdit(DocumentEditEventData),
727 /// Lifecycle event data
728 Lifecycle(LifecycleEventData),
729 /// Window event data
730 Window(WindowEventData),
731 /// No additional data
732 None,
733 /// IME composition data. APPENDED at the end for ABI stability — this is
734 /// `#[repr(C, u8)]`-adjacent, so a variant inserted in the middle would
735 /// renumber every discriminant after it.
736 Composition(CompositionEventData),
737 /// Raw, pre-acceleration pointer motion. APPENDED at the end.
738 RawMotion(RawMotionEventData),
739 /// A media seek (9h-i-a-i-a). APPENDED for ABI stability.
740 MediaControl(MediaControlEventData),
741 SystemAudio(SystemAudioEventData),
742}
743
744/// High-level event type classification.
745///
746/// This enum categorizes all possible events that can occur in the UI.
747/// It extends the existing event system with new event types for
748/// lifecycle, clipboard, media, and form handling.
749#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
750#[repr(C)]
751pub enum EventType {
752 // Mouse Events
753 /// Mouse cursor is over the element
754 MouseOver,
755 /// Mouse cursor entered the element
756 MouseEnter,
757 /// Mouse cursor left the element
758 MouseLeave,
759 /// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
760 MouseOut,
761 /// Mouse button pressed
762 MouseDown,
763 /// Mouse button released
764 MouseUp,
765 /// Mouse click (down + up on same element)
766 Click,
767 /// Mouse double-click
768 DoubleClick,
769 /// Right-click / context menu
770 ContextMenu,
771
772 // Keyboard Events
773 /// Key pressed down
774 KeyDown,
775 /// Key released
776 KeyUp,
777 /// Character input (respects locale/keyboard layout)
778 KeyPress,
779
780 // IME Composition Events
781 /// IME composition started
782 CompositionStart,
783 /// IME composition updated (intermediate text changed)
784 CompositionUpdate,
785 /// IME composition ended (final text committed)
786 CompositionEnd,
787
788 // Focus Events
789 /// Element received focus
790 Focus,
791 /// Element lost focus
792 Blur,
793 /// Focus entered element or its children
794 FocusIn,
795 /// Focus left element and its children
796 FocusOut,
797
798 // Input Events
799 /// Input value is being changed (fires on every keystroke)
800 Input,
801 /// Input value has changed (fires after editing complete)
802 Change,
803 /// Form submitted
804 Submit,
805 /// Form reset
806 Reset,
807 /// Form validation failed
808 Invalid,
809
810 // Scroll Events
811 /// Element is being scrolled
812 Scroll,
813 /// Scroll started
814 ScrollStart,
815 /// Scroll ended
816 ScrollEnd,
817
818 // Drag Events
819 /// Drag operation started
820 DragStart,
821 /// Element is being dragged
822 Drag,
823 /// Drag operation ended
824 DragEnd,
825 /// Dragged element entered drop target
826 DragEnter,
827 /// Dragged element is over drop target
828 DragOver,
829 /// Dragged element left drop target
830 DragLeave,
831 /// Element was dropped
832 Drop,
833
834 // Touch Events
835 /// Touch started
836 TouchStart,
837 /// Touch moved
838 TouchMove,
839 /// Touch ended
840 TouchEnd,
841 /// Touch cancelled
842 TouchCancel,
843
844 // Pen / Stylus Events (W3C PointerEvent, pointerType "pen")
845 /// Pen tip made contact (or pen entered while down)
846 PenDown,
847 /// Pen moved (in contact or hovering in range)
848 PenMove,
849 /// Pen tip lifted
850 PenUp,
851 /// Pen entered hover/sensing range (proximity in)
852 PenEnter,
853 /// Pen left hover/sensing range (proximity out)
854 PenLeave,
855
856 // Gesture Events
857 /// Long press detected (touch or mouse held down)
858 LongPress,
859 /// Swipe gesture to the left
860 SwipeLeft,
861 /// Swipe gesture to the right
862 SwipeRight,
863 /// Swipe gesture upward
864 SwipeUp,
865 /// Swipe gesture downward
866 SwipeDown,
867 /// Pinch-in gesture (zoom out)
868 PinchIn,
869 /// Pinch-out gesture (zoom in)
870 PinchOut,
871 /// Clockwise rotation gesture
872 RotateClockwise,
873 /// Counter-clockwise rotation gesture
874 RotateCounterClockwise,
875
876 // Clipboard Events
877 /// Content copied to clipboard
878 Copy,
879 /// Content cut to clipboard
880 Cut,
881 /// Content pasted from clipboard
882 Paste,
883
884 // Media Events
885 /// Media playback started
886 Play,
887 /// Media playback paused
888 Pause,
889 /// Media playback ended
890 Ended,
891 /// Media time updated
892 TimeUpdate,
893 /// Media volume changed
894 VolumeChange,
895 /// Media error occurred
896 MediaError,
897
898 // Lifecycle Events
899 /// Component was mounted to the DOM
900 Mount,
901 /// Component will be unmounted from the DOM
902 Unmount,
903 /// Component was updated
904 Update,
905 /// Component layout bounds changed
906 Resize,
907 /// A `<transient-window>` was dismissed by the user (outside click or
908 /// Escape). Fired on the transient node so the app can drop its `open`
909 /// flag; the surface is already gone by the time this runs.
910 Dismiss,
911 /// A `<transient-window>` was torn off by the user's drag and is now a
912 /// free toplevel. Fired on the transient node; the lifecycle data's
913 /// `current_bounds` is the toplevel's rect in the parent's coordinates,
914 /// for apps that persist their palette layout.
915 TearOff,
916 /// A torn-off `<transient-window>` was docked back by the user's drag.
917 Dock,
918
919 // Window Events
920 /// Window resized
921 WindowResize,
922 /// Window moved
923 WindowMove,
924 /// Window close requested
925 WindowClose,
926 /// The window's frame state changed — minimized, maximized, restored to
927 /// normal, or entered/left fullscreen.
928 ///
929 /// Read the new state from `flags.frame` on the current window state; the
930 /// event carries no payload of its own because the flag IS the payload and
931 /// a callback that cares will already be reading window state.
932 WindowFrameChanged,
933 /// Window received focus
934 WindowFocusIn,
935 /// Window lost focus
936 WindowFocusOut,
937 /// System theme changed
938 ThemeChange,
939 /// Window DPI/scale factor changed (moved to different monitor)
940 WindowDpiChanged,
941 /// Window moved to a different monitor
942 WindowMonitorChanged,
943
944 // Application Events
945 /// A monitor/display was connected
946 MonitorConnected,
947 /// A monitor/display was disconnected
948 MonitorDisconnected,
949
950 // File Events
951 /// File is being hovered
952 FileHover,
953 /// File was dropped
954 FileDrop,
955 /// File hover cancelled
956 FileHoverCancel,
957
958 // Hardware input-device Events (P6 sensors / gamepad)
959 /// A motion-sensor reading (accelerometer / gyroscope / magnetometer)
960 /// changed. Read the value with `CallbackInfo::get_sensor_reading`.
961 SensorChanged,
962 /// A gamepad's buttons / axes changed, or one was (dis)connected. Read it
963 /// with `CallbackInfo::get_primary_gamepad` / `get_gamepad_state`.
964 GamepadInput,
965
966 // Geolocation Events (MWA-A1 — synthesized by the capability pump's
967 // GeolocationManager EventProvider; both filter enums already carried
968 // the matching variants, only this dispatch type was missing them).
969 /// A new GPS / network location fix arrived. Read it with
970 /// `CallbackInfo::get_geolocation_fix`.
971 GeolocationFix,
972 /// The native geolocation subscription errored, timed out, or was
973 /// revoked.
974 GeolocationError,
975
976 // Async capability outcomes (MWA-A1b — synthesized by the capability
977 // pump's manager EventProviders so idle apps observe prompt results).
978 /// A permission's OS-observed state changed (granted / denied /
979 /// revoked / restricted). Targeted at the capability's most recent
980 /// subscriber node when known, else the root. Read the new state via
981 /// `CallbackInfo` permission accessors.
982 PermissionChanged,
983 /// A biometric authentication prompt completed. Read the outcome via
984 /// `CallbackInfo::get_biometric_result`.
985 BiometricResult,
986 /// The screen eyedropper started by `CallbackInfo::pick_screen_color`
987 /// finished - the user picked a pixel, or cancelled. Window-level (the
988 /// pick is not bound to a node); read the colour via
989 /// `CallbackInfo::get_picked_screen_color` (`None` = cancelled).
990 ScreenColorPicked,
991 /// A keyring store / get / delete operation completed. Read the outcome
992 /// via `CallbackInfo::get_keyring_result`.
993 KeyringResult,
994
995 // Structural document editing (C11 — synthesized once per recorded
996 // changeset by the LayoutWindow's document-edit EventProvider).
997 /// A STRUCTURAL document edit (Enter split / Backspace merge / wrap /
998 /// selection-spanning replace…) was recorded and awaits the app's
999 /// apply-and-ack. Fired ONCE per changeset so the app's apply loop is
1000 /// prompt instead of polling `get_pending_document_edit()` on its next
1001 /// unrelated callback. The changeset id rides on
1002 /// `EventData::DocumentEdit`; the full changeset is read via
1003 /// `CallbackInfo` / `LayoutWindow::get_pending_document_edit()`.
1004 DocumentEdit,
1005
1006 /// The text of an editable node CHANGED - fired once per commit, AFTER
1007 /// the edit is applied (typing, IME commit, deletion, paste, undo/redo:
1008 /// everything that writes the text overlay).
1009 ///
1010 /// `Input` is the `beforeinput` of this pair: it fires BEFORE the
1011 /// pending insertion is applied so a listener can `prevent_default`,
1012 /// which means a model that reads `get_unsynced_text_edits` from an
1013 /// `Input` callback is always one keystroke behind. A listener that
1014 /// wants the committed text - a word counter, a dirty flag, a live
1015 /// preview - listens here instead; the edits are already in the sync
1016 /// channel when this runs. Deletions used to be the only edits notified
1017 /// post-commit (through a second `Input`); this is the uniform spelling.
1018 TextChanged,
1019 /// An input device was attached. APPENDED at the end for ABI stability.
1020 ///
1021 /// `ApplicationEventFilter` has carried `DeviceConnected` since it was
1022 /// introduced, with no `EventType` able to reach it. Hotplug is observable
1023 /// on every backend — gilrs already reports gamepad arrive/leave through
1024 /// the capability pump, Wayland has `wl_seat.capabilities` and the
1025 /// `zwp_tablet_seat_v2` add/remove events, X11 has `XI_HierarchyChanged`,
1026 /// Win32 has `WM_DEVICECHANGE` — so the filter was the only thing missing.
1027 DeviceConnected,
1028 /// An input device was detached. See [`EventType::DeviceConnected`].
1029 DeviceDisconnected,
1030 /// The pen barrel was squeezed. APPENDED at the end for ABI stability.
1031 ///
1032 /// Apple Pencil Pro's squeeze, surfaced by `UIPencilInteraction`. The
1033 /// Hover and Window filter variants have existed with no `EventType` able
1034 /// to reach them.
1035 PenSqueeze,
1036 /// The pen was double-tapped on its barrel — Apple Pencil 2 and later,
1037 /// also `UIPencilInteraction`. See [`EventType::PenSqueeze`].
1038 PenDoubleTap,
1039 /// The pen is in range but not touching. Wayland reports it as
1040 /// `proximity_in` plus `distance`, Win32 as `POINTER_FLAG_INRANGE` without
1041 /// `POINTER_FLAG_INCONTACT`, Android as `ACTION_HOVER_MOVE` with a stylus
1042 /// tool type, macOS as an `NSEventSubtype::TabletProximity` subtype on the
1043 /// ordinary mouse-event path. See [`EventType::PenSqueeze`].
1044 PenHover,
1045 /// A component's default action was invoked — Enter/Space on a focused
1046 /// control, or the accessibility `Default` action. Distinct from `Click`:
1047 /// that is the pointer spelling, this is the component-level one that
1048 /// `ComponentEventFilter::DefaultAction` subscribes to.
1049 DefaultAction,
1050 /// A component's selection changed. `ComponentEventFilter::Selected` has
1051 /// existed with no `EventType` and no match arm.
1052 Selected,
1053 /// A HID device delivered an input report. APPENDED at the end.
1054 ///
1055 /// The escape hatch for devices azul does not model — flight sticks,
1056 /// wheels, 6-DOF mice, pedals, Stream Decks. Read the bytes via
1057 /// `CallbackInfo::get_hid_reports`.
1058 HidReport,
1059 /// A modifier or lock key changed state. APPENDED at the end.
1060 ///
1061 /// Modifiers arrive today only as ordinary key events, so an app tracking
1062 /// "is Shift down" has to watch every KeyDown and KeyUp and reconstruct
1063 /// it — and gets it wrong across a focus loss, because the KeyUp for a
1064 /// modifier released while another window had focus never arrives. This
1065 /// event carries the resolved state instead.
1066 ModifiersChanged,
1067 /// Raw pointer motion, pre-acceleration and unclamped. APPENDED at the
1068 /// end for ABI stability.
1069 ///
1070 /// `MouseState.is_cursor_locked` has existed since the beginning,
1071 /// documented as "important for applications like games", with no event
1072 /// carrying a delta behind it — so pointer lock was a flag that did
1073 /// nothing. This is the event it was waiting for.
1074 RawMouseMotion,
1075 /// A dial or rotary encoder turned. APPENDED at the end for ABI stability.
1076 ///
1077 /// The Surface Dial, a tablet pad's dial, a Wear crown, an Apple Digital
1078 /// Crown. `DialState` (rotation, detents, pressed, contact point) has been
1079 /// readable through `CallbackInfo::get_dial_state()` since the type
1080 /// landed, with no event to subscribe to — so a dial could only be POLLED
1081 /// from an unrelated callback that happened to run.
1082 DialRotate,
1083 /// A dial with a physical click was pressed. APPENDED at the end.
1084 DialClick,
1085 /// The pointer MOVED while over this node. APPENDED at the end.
1086 ///
1087 /// W3C `mousemove`. This is the event `MouseOver` used to be: until
1088 /// now azul had no movement event at all, and `MouseOver` was emitted
1089 /// on every cursor move - `mousemove` semantics under the `mouseover`
1090 /// name. `MouseOver` now means what the spec says (the pointer
1091 /// ENTERED the node) and movement lives here.
1092 MouseMove,
1093 /// The platform's media controls asked for a SEEK (9h-i-a-i-a): a desktop
1094 /// scrubber, a lock-screen slider, `playerctl position`. APPENDED at the
1095 /// end for ABI stability. Unlike Play/Pause/Next, which arrive as media
1096 /// KEY events, a seek carries a position - read it with
1097 /// `CallbackInfo::get_media_control_request`.
1098 MediaControl,
1099 /// The pointer lock changed hands (9d-ii-c): the app took or released it,
1100 /// or the platform ended it - every grab-based backend drops the grab
1101 /// when the window loses focus, and a Wayland compositor may end a
1102 /// constraint at any time. Both directions, like the browser's
1103 /// `pointerlockchange`; read `mouse_state.is_cursor_locked` for which.
1104 /// Nothing re-takes a lost lock on focus return (USER RULING
1105 /// 2026-09-03): an app that wants it back asks again from
1106 /// `WindowFocusReceived`.
1107 PointerLockChange,
1108 /// The system did something with the audio the app took over
1109 /// (9h-i-a-i-d-i): an interruption began or ended, focus was ducked,
1110 /// granted, or lost. Application-level, at the root; the payload is
1111 /// `EventData::SystemAudio` and `CallbackInfo::get_system_audio_change`.
1112 SystemAudioChange,
1113}
1114
1115/// Unified event wrapper (similar to React's `SyntheticEvent`).
1116///
1117/// All events in the system are wrapped in this structure, providing
1118/// a consistent interface and enabling event propagation control.
1119#[derive(Debug, Clone, PartialEq)]
1120#[allow(clippy::struct_excessive_bools)] // four independent propagation flags — W3C's own model
1121pub struct SyntheticEvent {
1122 /// The type of event
1123 pub event_type: EventType,
1124
1125 /// Where the event came from
1126 pub source: EventSource,
1127
1128 /// Current propagation phase
1129 pub phase: EventPhase,
1130
1131 /// Target node that the event was dispatched to
1132 pub target: DomNodeId,
1133
1134 /// Current node in the propagation path
1135 pub current_target: DomNodeId,
1136
1137 /// Timestamp when event was created
1138 pub timestamp: Instant,
1139
1140 /// Type-specific event data
1141 pub data: EventData,
1142
1143 /// Whether propagation has been stopped
1144 pub stopped: bool,
1145
1146 /// Whether immediate propagation has been stopped
1147 pub stopped_immediate: bool,
1148
1149 /// Whether default action has been prevented
1150 pub prevented_default: bool,
1151
1152 /// Deliver at the target ONLY — no capture, no bubble.
1153 ///
1154 /// Set on the release synthesised for the node that was PRESSED when the
1155 /// pointer releases somewhere else (press-target capture, see
1156 /// `HoverManager::apply_press_target_capture`): that node owes a
1157 /// `MouseUp`, but its ancestors already see the real release through the
1158 /// hovered node's propagation path and must not see it twice.
1159 pub at_target_only: bool,
1160}
1161
1162impl SyntheticEvent {
1163 /// Create a new synthetic event.
1164 ///
1165 /// # Parameters
1166 /// - `timestamp`: Current time from `(system_callbacks.get_system_time_fn.cb)()`
1167 #[must_use]
1168 pub const fn new(
1169 event_type: EventType,
1170 source: EventSource,
1171 target: DomNodeId,
1172 timestamp: Instant,
1173 data: EventData,
1174 ) -> Self {
1175 Self {
1176 event_type,
1177 source,
1178 phase: EventPhase::Target,
1179 target,
1180 current_target: target,
1181 timestamp,
1182 data,
1183 stopped: false,
1184 stopped_immediate: false,
1185 prevented_default: false,
1186 at_target_only: false,
1187 }
1188 }
1189
1190 /// This event, delivered at its target only (see [`Self::at_target_only`]).
1191 #[must_use]
1192 pub const fn at_target_only(mut self) -> Self {
1193 self.at_target_only = true;
1194 self
1195 }
1196
1197 /// Stop event propagation after the current phase completes.
1198 ///
1199 /// This prevents the event from reaching handlers in subsequent phases
1200 /// (e.g., stopping during capture prevents bubble phase).
1201 pub const fn stop_propagation(&mut self) {
1202 self.stopped = true;
1203 }
1204
1205 /// Stop event propagation immediately.
1206 ///
1207 /// This prevents any further handlers from being called, even on the
1208 /// current target element.
1209 pub const fn stop_immediate_propagation(&mut self) {
1210 self.stopped_immediate = true;
1211 self.stopped = true;
1212 }
1213
1214 /// Prevent the default action associated with this event.
1215 ///
1216 /// For example, prevents form submission on Enter key, or prevents
1217 /// text selection on drag.
1218 pub const fn prevent_default(&mut self) {
1219 self.prevented_default = true;
1220 }
1221
1222 /// Check if propagation was stopped.
1223 #[must_use]
1224 pub const fn is_propagation_stopped(&self) -> bool {
1225 self.stopped
1226 }
1227
1228 /// Check if immediate propagation was stopped.
1229 #[must_use]
1230 pub const fn is_immediate_propagation_stopped(&self) -> bool {
1231 self.stopped_immediate
1232 }
1233
1234 /// Check if default action was prevented.
1235 #[must_use]
1236 pub const fn is_default_prevented(&self) -> bool {
1237 self.prevented_default
1238 }
1239}
1240
1241/// Result of event propagation through DOM tree.
1242#[derive(Debug, Clone, Default)]
1243pub struct PropagationResult {
1244 /// Callbacks that should be invoked, in order
1245 pub callbacks_to_invoke: Vec<(NodeId, EventFilter)>,
1246 /// Whether default action should be prevented
1247 pub default_prevented: bool,
1248}
1249
1250/// Get the path from root to target node in the DOM tree.
1251///
1252/// This is used for event propagation - we need to know which nodes
1253/// are ancestors of the target to implement capture/bubble phases.
1254///
1255/// Returns nodes in order from root to target (inclusive).
1256#[must_use]
1257pub fn get_dom_path(
1258 node_hierarchy: &crate::id::NodeHierarchy,
1259 target_node: NodeHierarchyItemId,
1260) -> Vec<NodeId> {
1261 let mut path = Vec::new();
1262 let Some(target_node_id) = target_node.into_crate_internal() else {
1263 return path;
1264 };
1265
1266 let hier_ref = node_hierarchy.as_ref();
1267
1268 // Build path from target to root. Bounded by the node count and guarded by a
1269 // visited-set: a corrupt hierarchy with a parent cycle (or a parent chain
1270 // longer than the arena) would otherwise loop forever / OOM here, and this
1271 // runs on every event dispatch.
1272 let node_count = hier_ref.len();
1273 let mut visited: BTreeSet<NodeId> = BTreeSet::new();
1274 let mut current = Some(target_node_id);
1275 while let Some(node_id) = current {
1276 if path.len() > node_count || !visited.insert(node_id) {
1277 // Cycle or overrun detected: stop rather than spin forever.
1278 break;
1279 }
1280 path.push(node_id);
1281 current = hier_ref.get(node_id).and_then(|node| node.parent);
1282 }
1283
1284 // Reverse to get root → target order
1285 path.reverse();
1286 path
1287}
1288
1289/// Propagate event through DOM tree with capture and bubble phases.
1290///
1291/// This implements DOM Level 2 event propagation:
1292/// 1. **Capture Phase**: Event travels from root down to target
1293/// 2. **Target Phase**: Event is at the target element
1294/// 3. **Bubble Phase**: Event travels from target back up to root
1295///
1296/// The event can be stopped at any point via `stopPropagation()` or
1297/// `stopImmediatePropagation()`.
1298///
1299/// # Panics
1300///
1301/// Panics if `path` is empty; it must contain at least the target node.
1302pub fn propagate_event(
1303 event: &mut SyntheticEvent,
1304 node_hierarchy: &crate::id::NodeHierarchy,
1305 callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
1306) -> PropagationResult {
1307 let path = get_dom_path(node_hierarchy, event.target.node);
1308 if path.is_empty() {
1309 return PropagationResult::default();
1310 }
1311
1312 let ancestors = &path[..path.len().saturating_sub(1)];
1313 let target_node_id = *path.last().unwrap();
1314
1315 let mut result = PropagationResult::default();
1316
1317 // A captured release (see `SyntheticEvent::at_target_only`) reaches its
1318 // target and nothing else: the ancestors get the real release through
1319 // the hovered node's path.
1320 if event.at_target_only {
1321 propagate_target_phase(event, target_node_id, callbacks, &mut result);
1322 result.default_prevented = event.prevented_default;
1323 return result;
1324 }
1325
1326 // Phase 1: Capture (root → target)
1327 propagate_phase(
1328 event,
1329 ancestors.iter().copied(),
1330 EventPhase::Capture,
1331 callbacks,
1332 &mut result,
1333 );
1334
1335 // Phase 2: Target
1336 if !event.stopped {
1337 propagate_target_phase(event, target_node_id, callbacks, &mut result);
1338 }
1339
1340 // Phase 3: Bubble (target → root) — unless the event type does not bubble.
1341 if !event.stopped && event.event_type.bubbles() {
1342 propagate_phase(
1343 event,
1344 ancestors.iter().rev().copied(),
1345 EventPhase::Bubble,
1346 callbacks,
1347 &mut result,
1348 );
1349 }
1350
1351 result.default_prevented = event.prevented_default;
1352 result
1353}
1354
1355impl EventType {
1356 /// Whether the event reaches the target's ANCESTORS in the bubble phase.
1357 ///
1358 /// Enter/leave events do not bubble (`W3C` `mouseenter`/`mouseleave`,
1359 /// `pointerenter`/`pointerleave`): each node that gained or lost hover
1360 /// gets its OWN event (`event_determination` generates them per node), so
1361 /// bubbling a child's leave to its parent tells the parent the pointer
1362 /// left IT while it is still inside — which is how a slider's drag ended
1363 /// the moment the pointer slid off the thumb, a map pan ended on every
1364 /// tile crossing, and a split-pane drag ended on its first motion.
1365 /// `dragenter`/`dragleave` DO bubble in the `W3C` model and keep doing so.
1366 #[must_use]
1367 pub const fn bubbles(self) -> bool {
1368 !matches!(
1369 self,
1370 Self::MouseEnter | Self::MouseLeave | Self::PenEnter | Self::PenLeave
1371 )
1372 }
1373}
1374
1375/// Process a single propagation phase (Capture or Bubble)
1376fn propagate_phase(
1377 event: &mut SyntheticEvent,
1378 nodes: impl Iterator<Item = NodeId>,
1379 phase: EventPhase,
1380 callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
1381 result: &mut PropagationResult,
1382) {
1383 event.phase = phase;
1384
1385 for node_id in nodes {
1386 if event.stopped_immediate || event.stopped {
1387 return;
1388 }
1389
1390 event.current_target = DomNodeId {
1391 dom: event.target.dom,
1392 node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
1393 };
1394
1395 collect_matching_callbacks(event, node_id, phase, callbacks, result);
1396 }
1397}
1398
1399/// Process the target phase
1400fn propagate_target_phase(
1401 event: &mut SyntheticEvent,
1402 target_node_id: NodeId,
1403 callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
1404 result: &mut PropagationResult,
1405) {
1406 event.phase = EventPhase::Target;
1407 event.current_target = event.target;
1408
1409 collect_matching_callbacks(event, target_node_id, EventPhase::Target, callbacks, result);
1410}
1411
1412/// Collect callbacks that match the current phase for a node
1413fn collect_matching_callbacks(
1414 event: &SyntheticEvent,
1415 node_id: NodeId,
1416 phase: EventPhase,
1417 callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
1418 result: &mut PropagationResult,
1419) {
1420 let Some(node_callbacks) = callbacks.get(&node_id) else {
1421 return;
1422 };
1423
1424 let matching = node_callbacks
1425 .iter()
1426 .take_while(|_| !event.stopped_immediate)
1427 .filter(|filter| matches_filter_phase(**filter, event, phase))
1428 .map(|filter| (node_id, *filter));
1429
1430 result.callbacks_to_invoke.extend(matching);
1431}
1432
1433// =============================================================================
1434// DEFAULT ACTIONS (W3C UI Events / HTML5 Activation Behavior)
1435// =============================================================================
1436
1437/// Default actions are built-in behaviors that occur in response to events.
1438///
1439/// Per W3C DOM Event specification:
1440/// > A default action is an action that the implementation is expected to take
1441/// > in response to an event, unless that action is cancelled by the script.
1442///
1443/// Examples:
1444/// - Tab key → move focus to next focusable element
1445/// - Enter/Space on button → activate (click) the button
1446/// - Escape → clear focus or close modal
1447/// - Arrow keys in listbox → move selection
1448///
1449/// Default actions are processed AFTER all event callbacks have been invoked,
1450/// and only if `event.prevent_default()` was NOT called.
1451#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1452#[repr(C, u8)]
1453pub enum DefaultAction {
1454 /// Move focus to the next focusable element (Tab key)
1455 FocusNext,
1456 /// Move focus to the previous focusable element (Shift+Tab)
1457 FocusPrevious,
1458 /// Move focus to the first focusable element
1459 FocusFirst,
1460 /// Move focus to the last focusable element
1461 FocusLast,
1462 /// Move focus SPATIALLY — to the nearest focusable element in that
1463 /// direction, rather than the next one in document order.
1464 ///
1465 /// Tab order is a single sequence; a D-pad, a TV remote and spatial
1466 /// navigation all ask a different question ("what is above this?"), which
1467 /// `FocusTarget::Directional` already answered and nothing could request.
1468 ///
1469 /// NOT bound to the arrow keys: those scroll, and taking them
1470 /// unconditionally would break every scroll container. That binding waits
1471 /// on the CSS opt-out (item 9a-i); a D-pad has no such conflict.
1472 FocusUp,
1473 FocusDown,
1474 FocusLeft,
1475 FocusRight,
1476 /// Clear focus from the currently focused element (Escape key)
1477 ClearFocus,
1478 /// Activate the focused element (Enter/Space on activatable elements)
1479 /// This generates a synthetic Click event on the target
1480 ActivateFocusedElement { target: DomNodeId },
1481 /// Submit the form containing the focused element (Enter in form input)
1482 SubmitForm { form_node: DomNodeId },
1483 /// Close the current modal/dialog (Escape key when modal is open)
1484 CloseModal { modal_node: DomNodeId },
1485 /// Scroll the focused scrollable container
1486 ScrollFocusedContainer {
1487 direction: ScrollDirection,
1488 amount: ScrollAmount,
1489 },
1490 /// Select all text in the focused text input (Ctrl+A / Cmd+A)
1491 SelectAllText,
1492 /// Enter in a contenteditable host: record a STRUCTURAL split-block
1493 /// changeset for the app to apply to its model (azul never mutates the
1494 /// DOM). Execution = `LayoutWindow::record_structural_default_action`.
1495 SplitBlockAtCursor { target: DomNodeId },
1496 /// Backspace at block start in a contenteditable host: record a
1497 /// merge-with-previous-block changeset (same record-only semantics).
1498 MergeWithPrevious { target: DomNodeId },
1499 /// Delete at block end in a contenteditable host: record a
1500 /// merge-with-next-block changeset (same record-only semantics).
1501 MergeWithNext { target: DomNodeId },
1502 /// No default action for this event
1503 None,
1504 /// Enter in a PLAIN-TEXT editing context (the editing host's computed
1505 /// `white-space` preserves newlines: pre / pre-wrap / break-spaces /
1506 /// pre-line), and Shift+Enter in ANY contenteditable host: insert a
1507 /// literal `"\n"` through the standard text-input pipeline (which brings
1508 /// veto, undo, and caret-follow along) instead of recording a structural
1509 /// block split. APPENDED at the enum tail for ABI stability.
1510 InsertLineBreakAtCursor { target: DomNodeId },
1511 /// Reset a form to its controls' default values (a `type="reset"` control
1512 /// was activated). APPENDED at the enum tail for ABI stability.
1513 ///
1514 /// The sibling of [`Self::SubmitForm`]: both name the FORM rather than the
1515 /// control that triggered them, because a reset handler belongs on the
1516 /// form and the button is an implementation detail of how the user asked.
1517 ResetForm { form_node: DomNodeId },
1518}
1519
1520/// Amount to scroll for keyboard-based scrolling
1521#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1522#[repr(C)]
1523pub enum ScrollAmount {
1524 /// Scroll by one line (arrow keys)
1525 Line,
1526 /// Scroll by one page (Page Up/Down)
1527 Page,
1528 /// Scroll to start/end (Home/End)
1529 Document,
1530}
1531
1532/// Result of determining what default action should occur for an event.
1533///
1534/// This is computed AFTER event dispatch, based on:
1535/// 1. The event type
1536/// 2. The target element's type/role
1537/// 3. Whether `prevent_default()` was called
1538#[derive(Debug, Clone, Copy)]
1539#[repr(C)]
1540pub struct DefaultActionResult {
1541 /// The default action to perform (if any)
1542 pub action: DefaultAction,
1543 /// Whether the action was prevented by a callback
1544 pub prevented: bool,
1545}
1546
1547impl Default for DefaultActionResult {
1548 fn default() -> Self {
1549 Self {
1550 action: DefaultAction::None,
1551 prevented: false,
1552 }
1553 }
1554}
1555
1556impl DefaultActionResult {
1557 /// Create a new result with a specific action
1558 #[must_use]
1559 pub const fn new(action: DefaultAction) -> Self {
1560 Self {
1561 action,
1562 prevented: false,
1563 }
1564 }
1565
1566 /// Create a prevented result (callback called `prevent_default`)
1567 #[must_use]
1568 pub const fn prevented() -> Self {
1569 Self {
1570 action: DefaultAction::None,
1571 prevented: true,
1572 }
1573 }
1574
1575 /// Check if there's an action to perform
1576 #[must_use]
1577 pub const fn has_action(&self) -> bool {
1578 !self.prevented && !matches!(self.action, DefaultAction::None)
1579 }
1580}
1581
1582/// Trait for elements that have activation behavior (can be "clicked" via keyboard).
1583///
1584/// Per HTML5 spec, elements with activation behavior include:
1585/// - `<button>` elements
1586/// - `<input type="submit">`, `<input type="button">`, `<input type="reset">`
1587/// - `<a>` elements with href
1588/// - `<area>` elements with href
1589/// - Any element with a click handler (implicit activation)
1590///
1591/// When an element with activation behavior is focused and the user presses
1592/// Enter or Space, a synthetic click event is generated.
1593pub trait ActivationBehavior {
1594 /// Returns true if this element can be activated via keyboard (Enter/Space)
1595 fn has_activation_behavior(&self) -> bool;
1596
1597 /// Returns true if this element is currently activatable
1598 /// (e.g., not disabled, not aria-disabled="true")
1599 fn is_activatable(&self) -> bool;
1600}
1601
1602/// Trait to query if a node is focusable for tab navigation
1603pub trait Focusable {
1604 /// Returns the tabindex value for this element (-1, 0, or positive)
1605 fn get_tabindex(&self) -> Option<i32>;
1606
1607 /// Returns true if this element can receive focus
1608 fn is_focusable(&self) -> bool;
1609
1610 /// Returns true if this element should be in the tab order
1611 fn is_in_tab_order(&self) -> bool {
1612 self.get_tabindex()
1613 .map_or_else(|| self.is_naturally_focusable(), |i| i >= 0)
1614 }
1615
1616 /// Returns true if this element type is naturally focusable
1617 /// (button, input, select, textarea, a[href])
1618 fn is_naturally_focusable(&self) -> bool;
1619}
1620
1621/// Check if an event filter matches the given event in the current phase.
1622///
1623/// This is used during event propagation to determine which callbacks
1624/// should be invoked at each phase.
1625fn matches_filter_phase(
1626 filter: EventFilter,
1627 event: &SyntheticEvent,
1628 current_phase: EventPhase,
1629) -> bool {
1630
1631 // azul has no capture-phase listeners (no `addEventListener(…, capture=true)`
1632 // equivalent): every `EventFilter` is a bubble-phase listener, which by the W3C
1633 // model fires only in the Target and Bubble phases — never Capture. Without this
1634 // guard an ancestor node's Hover/Focus callback was collected in BOTH the capture
1635 // and the bubble walk, so it fired TWICE whenever the hit target was a descendant
1636 // (e.g. a menubar item, hit via its text child, opened two stacked popups; any
1637 // button containing a text/child node ran its MouseUp callback twice).
1638 if matches!(current_phase, EventPhase::Capture) {
1639 return false;
1640 }
1641
1642 match filter {
1643 EventFilter::Hover(hover_filter) => {
1644 matches_hover_filter(hover_filter, event, current_phase)
1645 }
1646 EventFilter::Focus(focus_filter) => {
1647 matches_focus_filter(focus_filter, event, current_phase)
1648 }
1649 EventFilter::Window(window_filter) => {
1650 matches_window_filter(window_filter, event, current_phase)
1651 }
1652 EventFilter::Component(component_filter) => {
1653 matches_component_filter(component_filter, event, current_phase)
1654 }
1655 EventFilter::Application(application_filter) => {
1656 matches_application_filter(application_filter, event, current_phase)
1657 }
1658 EventFilter::External(external_filter) => {
1659 matches_external_filter(external_filter, event, current_phase)
1660 }
1661 }
1662}
1663
1664/// Check if a component (lifecycle) filter matches the event.
1665///
1666/// Lifecycle events produced by `diff::reconcile_dom` carry the target node in
1667/// `SyntheticEvent.target`, so dispatchers that bypass `propagate_event` and
1668/// invoke the target directly also need a way to compare. This predicate is
1669/// the single source of truth for that comparison; changing it without
1670/// updating `event_type_to_filters` will de-sync dispatch.
1671const fn matches_component_filter(
1672 filter: ComponentEventFilter,
1673 event: &SyntheticEvent,
1674 _phase: EventPhase,
1675) -> bool {
1676 matches!(
1677 (filter, &event.event_type),
1678 (ComponentEventFilter::AfterMount, EventType::Mount)
1679 | (ComponentEventFilter::BeforeUnmount, EventType::Unmount)
1680 | (ComponentEventFilter::Updated, EventType::Update)
1681 | (ComponentEventFilter::NodeResized, EventType::Resize)
1682 | (ComponentEventFilter::Dismissed, EventType::Dismiss)
1683 | (ComponentEventFilter::TornOff, EventType::TearOff)
1684 | (ComponentEventFilter::Docked, EventType::Dock)
1685 // These two were simply absent from the match. Both filter
1686 // variants shipped in `ComponentEventFilter`, so a component
1687 // subscribing to either was collected and then dropped.
1688 | (ComponentEventFilter::DefaultAction, EventType::DefaultAction)
1689 | (ComponentEventFilter::Selected, EventType::Selected)
1690 )
1691}
1692
1693/// Check if the event data contains a mouse event with the expected button.
1694/// `MouseButton::Other(BACK)` — the thumb "back" button, button 4 on a mouse
1695/// and index 3 zero-based, which is the numbering the web and every shell
1696/// already use (Win32 `XBUTTON1`, X11 button 8, macOS `otherMouseDown:` 3).
1697pub const MOUSE_BUTTON_BACK: u8 = 3;
1698/// `MouseButton::Other(FORWARD)` — the thumb "forward" button.
1699pub const MOUSE_BUTTON_FORWARD: u8 = 4;
1700
1701/// Bit for the back button in [`MouseState::other_down`].
1702pub const MOUSE_OTHER_MASK_BACK: u8 = 1 << 0;
1703/// Bit for the forward button in [`MouseState::other_down`].
1704pub const MOUSE_OTHER_MASK_FORWARD: u8 = 1 << 1;
1705
1706fn check_mouse_button(data: &EventData, expected: MouseButton) -> bool {
1707 if let EventData::Mouse(mouse_data) = data {
1708 mouse_data.button == expected
1709 } else {
1710 false
1711 }
1712}
1713
1714/// Check if a hover filter matches the event.
1715// Exhaustive (filter, event-type) truth table: many distinct pairs share the
1716// `=> true` body. One arm per pair is intentional; merging into giant or-patterns
1717// would destroy the table's readability/maintainability.
1718#[allow(clippy::match_same_arms)]
1719fn matches_hover_filter(
1720 filter: HoverEventFilter,
1721 event: &SyntheticEvent,
1722 _phase: EventPhase,
1723) -> bool {
1724 use HoverEventFilter::{
1725 BiometricResult, DoubleClick, Drag, DragEnd, DragEnter, DragLeave, DragOver, DragStart,
1726 Drop, DroppedFile, GamepadInput, GeolocationError, GeolocationFix, HoveredFile,
1727 HoveredFileCancelled, KeyringResult, LeftMouseDown, LeftMouseUp, MiddleMouseDown,
1728 MiddleMouseUp, MouseDown, MouseEnter, MouseLeave, MouseMove, MouseOver, MouseUp,
1729 PenDoubleTap, PenDown,
1730 PenEnter, PenHover, PenLeave, PenMove, PenSqueeze, PenUp, PermissionChanged, RightMouseDown,
1731 RightMouseUp,
1732 ScreenColorPicked, Scroll, ScrollEnd, ScrollStart, SensorChanged, TextInput, TouchCancel,
1733 TouchEnd, TouchMove, TouchStart, VirtualKeyDown, VirtualKeyUp,
1734 };
1735
1736 match (filter, &event.event_type) {
1737 (MouseOver, EventType::MouseOver) => true,
1738 (MouseMove, EventType::MouseMove) => true,
1739 (MouseDown, EventType::MouseDown) => true,
1740 (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
1741 (RightMouseDown, EventType::MouseDown) => {
1742 check_mouse_button(&event.data, MouseButton::Right)
1743 }
1744 (MiddleMouseDown, EventType::MouseDown) => {
1745 check_mouse_button(&event.data, MouseButton::Middle)
1746 }
1747 (MouseUp, EventType::MouseUp) => true,
1748 // KEYBOARD ACTIVATION (Enter / Space on a focused element) dispatches
1749 // a synthetic `EventType::Click`. Every widget in the toolkit listens
1750 // on MouseUp / LeftMouseUp - the pointer spelling of "activate" - so
1751 // without this arm the synthetic event matched NOTHING and Enter /
1752 // Space silently did nothing on every focusable widget (device
1753 // report, 2026-08-31). Nothing else in the engine emits
1754 // `EventType::Click`, so this cannot double-fire a real pointer
1755 // click: a real click arrives as MouseDown + MouseUp.
1756 //
1757 // A keyboard activation counts as a LEFT activation - it carries no
1758 // button data, so `check_mouse_button` cannot answer for it - which
1759 // is the W3C activation behaviour Enter and Space implement.
1760 (HoverEventFilter::Click, EventType::Click) => true,
1761 (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
1762 (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
1763 (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1764 (MouseEnter, EventType::MouseEnter) => true,
1765 (MouseLeave, EventType::MouseLeave) => true,
1766 (Scroll, EventType::Scroll) => true,
1767 (ScrollStart, EventType::ScrollStart) => true,
1768 (ScrollEnd, EventType::ScrollEnd) => true,
1769 (TextInput, EventType::Input) => true,
1770 // `KeyPress` (a key that produced a character) and `Change` (a value
1771 // committed on blur) both plan onto the `TextInput` filter, which is
1772 // what an app registers for "text arrived here". Planning named that
1773 // filter and this table then rejected it, so both dispatched to
1774 // nothing.
1775 (TextInput, EventType::KeyPress) => true,
1776 (TextInput, EventType::Change) => true,
1777 // A context-menu request is not a `MouseDown`, so the button-payload
1778 // check on the `RightMouseDown` arm cannot answer for it. Routing it
1779 // to the same filter means the Menu/Apps key, Shift+F10 and the
1780 // accessibility `ShowContextMenu` action all reach the handler an app
1781 // already registered for right-click, without inventing a new filter
1782 // variant for each spelling of "open the context menu".
1783 (RightMouseDown, EventType::ContextMenu) => true,
1784 (VirtualKeyDown, EventType::KeyDown) => true,
1785 (VirtualKeyUp, EventType::KeyUp) => true,
1786 (HoveredFile, EventType::FileHover) => true,
1787 (DroppedFile, EventType::FileDrop) => true,
1788 (HoveredFileCancelled, EventType::FileHoverCancel) => true,
1789 (TouchStart, EventType::TouchStart) => true,
1790 (TouchMove, EventType::TouchMove) => true,
1791 (TouchEnd, EventType::TouchEnd) => true,
1792 (TouchCancel, EventType::TouchCancel) => true,
1793 (PenDown, EventType::PenDown) => true,
1794 (PenMove, EventType::PenMove) => true,
1795 (PenUp, EventType::PenUp) => true,
1796 (PenEnter, EventType::PenEnter) => true,
1797 (PenLeave, EventType::PenLeave) => true,
1798 (DragStart, EventType::DragStart) => true,
1799 (Drag, EventType::Drag) => true,
1800 (DragEnd, EventType::DragEnd) => true,
1801 (DragEnter, EventType::DragEnter) => true,
1802 (DragOver, EventType::DragOver) => true,
1803 (DragLeave, EventType::DragLeave) => true,
1804 (Drop, EventType::Drop) => true,
1805 (DoubleClick, EventType::DoubleClick) => true,
1806 (SensorChanged, EventType::SensorChanged) => true,
1807 (GamepadInput, EventType::GamepadInput) => true,
1808 (GeolocationFix, EventType::GeolocationFix) => true,
1809 (GeolocationError, EventType::GeolocationError) => true,
1810 (PermissionChanged, EventType::PermissionChanged) => true,
1811 (BiometricResult, EventType::BiometricResult) => true,
1812 (ScreenColorPicked, EventType::ScreenColorPicked) => true,
1813 (KeyringResult, EventType::KeyringResult) => true,
1814 // Gestures. These filters existed, the detectors produced the events,
1815 // and this table had no arm for them — a `PinchOut` handler on a map
1816 // could never fire, whatever the gesture manager saw.
1817 (HoverEventFilter::LongPress, EventType::LongPress) => true,
1818 (HoverEventFilter::SwipeLeft, EventType::SwipeLeft) => true,
1819 (HoverEventFilter::SwipeRight, EventType::SwipeRight) => true,
1820 (HoverEventFilter::SwipeUp, EventType::SwipeUp) => true,
1821 (HoverEventFilter::SwipeDown, EventType::SwipeDown) => true,
1822 (HoverEventFilter::PinchIn, EventType::PinchIn) => true,
1823 (HoverEventFilter::PinchOut, EventType::PinchOut) => true,
1824 (HoverEventFilter::RotateClockwise, EventType::RotateClockwise) => true,
1825 (HoverEventFilter::RotateCounterClockwise, EventType::RotateCounterClockwise) => true,
1826 (HoverEventFilter::MouseOut, EventType::MouseOut) => true,
1827 (HoverEventFilter::FocusIn, EventType::FocusIn) => true,
1828 (HoverEventFilter::FocusOut, EventType::FocusOut) => true,
1829 (HoverEventFilter::CompositionStart, EventType::CompositionStart) => true,
1830 (HoverEventFilter::CompositionUpdate, EventType::CompositionUpdate) => true,
1831 (HoverEventFilter::CompositionEnd, EventType::CompositionEnd) => true,
1832 // Pen barrel gestures and hover. The filter variants shipped long
1833 // before any `EventType` existed that could reach them.
1834 (PenSqueeze, EventType::PenSqueeze) => true,
1835 (PenDoubleTap, EventType::PenDoubleTap) => true,
1836 (PenHover, EventType::PenHover) => true,
1837 // Thumb buttons. Gated on the payload like every other
1838 // button-specific arm — a payloadless event must not claim a button.
1839 (HoverEventFilter::BackMouseDown, EventType::MouseDown) => {
1840 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
1841 }
1842 (HoverEventFilter::BackMouseUp, EventType::MouseUp) => {
1843 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
1844 }
1845 (HoverEventFilter::ForwardMouseDown, EventType::MouseDown) => {
1846 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
1847 }
1848 (HoverEventFilter::ForwardMouseUp, EventType::MouseUp) => {
1849 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
1850 }
1851 (HoverEventFilter::Submit, EventType::Submit) => true,
1852 (HoverEventFilter::Change, EventType::Change) => true,
1853 (HoverEventFilter::Reset, EventType::Reset) => true,
1854 (HoverEventFilter::Invalid, EventType::Invalid) => true,
1855 // The dial. A NODE-scoped dial event only makes sense for a device
1856 // that reports a contact point (a Surface Dial ON the display); every
1857 // other dial reaches the app through the Window filter.
1858 (HoverEventFilter::DialRotate, EventType::DialRotate) => true,
1859 (HoverEventFilter::DialClick, EventType::DialClick) => true,
1860 _ => false,
1861 }
1862}
1863
1864/// Check if a focus filter matches the event.
1865// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
1866#[allow(clippy::match_same_arms)]
1867fn matches_focus_filter(
1868 filter: FocusEventFilter,
1869 event: &SyntheticEvent,
1870 _phase: EventPhase,
1871) -> bool {
1872 use FocusEventFilter::{
1873 Drag, DragEnd, DragEnter, DragLeave, DragOver, DragStart, Drop, FocusLost, FocusReceived,
1874 LeftMouseDown, LeftMouseUp, MiddleMouseDown, MiddleMouseUp, MouseDown, MouseEnter,
1875 MouseLeave, MouseMove, MouseOver, MouseUp, RightMouseDown, RightMouseUp, Scroll, ScrollEnd,
1876 ScrollStart, TextInput, VirtualKeyDown, VirtualKeyUp,
1877 };
1878
1879 match (filter, &event.event_type) {
1880 (MouseOver, EventType::MouseOver) => true,
1881 (MouseMove, EventType::MouseMove) => true,
1882 (MouseDown, EventType::MouseDown) => true,
1883 (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
1884 (RightMouseDown, EventType::MouseDown) => {
1885 check_mouse_button(&event.data, MouseButton::Right)
1886 }
1887 (MiddleMouseDown, EventType::MouseDown) => {
1888 check_mouse_button(&event.data, MouseButton::Middle)
1889 }
1890 (MouseUp, EventType::MouseUp) => true,
1891 (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
1892 (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
1893 (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
1894 (MouseEnter, EventType::MouseEnter) => true,
1895 (MouseLeave, EventType::MouseLeave) => true,
1896 (Scroll, EventType::Scroll) => true,
1897 (ScrollStart, EventType::ScrollStart) => true,
1898 (ScrollEnd, EventType::ScrollEnd) => true,
1899 (TextInput, EventType::Input) => true,
1900 (FocusEventFilter::DocumentEdit, EventType::DocumentEdit) => true,
1901 (FocusEventFilter::TextChanged, EventType::TextChanged) => true,
1902 (VirtualKeyDown, EventType::KeyDown) => true,
1903 (VirtualKeyUp, EventType::KeyUp) => true,
1904 (FocusReceived, EventType::Focus) => true,
1905 (FocusLost, EventType::Blur) => true,
1906 (DragStart, EventType::DragStart) => true,
1907 (Drag, EventType::Drag) => true,
1908 (DragEnd, EventType::DragEnd) => true,
1909 (DragEnter, EventType::DragEnter) => true,
1910 (DragOver, EventType::DragOver) => true,
1911 (DragLeave, EventType::DragLeave) => true,
1912 (Drop, EventType::Drop) => true,
1913 // MWA-C-clipboard: W3C clipboard events on the focused element
1914 // (qualified paths — `use FocusEventFilter::Copy` would shadow the
1915 // `Copy` trait in this scope).
1916 (FocusEventFilter::Copy, EventType::Copy) => true,
1917 (FocusEventFilter::Cut, EventType::Cut) => true,
1918 (FocusEventFilter::Paste, EventType::Paste) => true,
1919 // Gestures — same gap as the hover table.
1920 (FocusEventFilter::LongPress, EventType::LongPress) => true,
1921 (FocusEventFilter::SwipeLeft, EventType::SwipeLeft) => true,
1922 (FocusEventFilter::SwipeRight, EventType::SwipeRight) => true,
1923 (FocusEventFilter::SwipeUp, EventType::SwipeUp) => true,
1924 (FocusEventFilter::SwipeDown, EventType::SwipeDown) => true,
1925 (FocusEventFilter::PinchIn, EventType::PinchIn) => true,
1926 (FocusEventFilter::PinchOut, EventType::PinchOut) => true,
1927 (FocusEventFilter::RotateClockwise, EventType::RotateClockwise) => true,
1928 (FocusEventFilter::RotateCounterClockwise, EventType::RotateCounterClockwise) => true,
1929 // Pen — `FocusEventFilter` has carried Down/Move/Up since the filter
1930 // was introduced, but this table had no arm for any of them, so a
1931 // focused node could never receive a pen event even once dispatch
1932 // planning named the filter. (Enter/Leave have no Focus twin: a pen
1933 // crossing a node's bounds is not a focus change.)
1934 (FocusEventFilter::PenDown, EventType::PenDown) => true,
1935 (FocusEventFilter::PenMove, EventType::PenMove) => true,
1936 (FocusEventFilter::PenUp, EventType::PenUp) => true,
1937 // Thumb buttons. Gated on the payload like every other
1938 // button-specific arm — a payloadless event must not claim a button.
1939 (FocusEventFilter::BackMouseDown, EventType::MouseDown) => {
1940 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
1941 }
1942 (FocusEventFilter::BackMouseUp, EventType::MouseUp) => {
1943 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
1944 }
1945 (FocusEventFilter::ForwardMouseDown, EventType::MouseDown) => {
1946 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
1947 }
1948 (FocusEventFilter::ForwardMouseUp, EventType::MouseUp) => {
1949 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
1950 }
1951 (FocusEventFilter::Submit, EventType::Submit) => true,
1952 (FocusEventFilter::Change, EventType::Change) => true,
1953 (FocusEventFilter::Reset, EventType::Reset) => true,
1954 (FocusEventFilter::Invalid, EventType::Invalid) => true,
1955 _ => false,
1956 }
1957}
1958
1959/// Check if an application filter matches the event.
1960///
1961/// Application events are not hit-tested and have no meaningful target: a
1962/// device arriving or a monitor being unplugged belongs to the application,
1963/// not to whichever node happened to be under the cursor. They are dispatched
1964/// at the root and reach any subscriber by propagation, which is why this
1965/// table ignores the phase exactly as the window table does.
1966///
1967/// This arm returned a flat `false` — "will be implemented in future" — so all
1968/// four variants were structurally unreachable no matter what a shell emitted.
1969/// Every other layer was already in place: `event_type_to_filters` had named
1970/// `EF::Application(..)` for the monitor pair the whole time.
1971// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
1972#[allow(clippy::match_same_arms)]
1973fn matches_application_filter(
1974 filter: ApplicationEventFilter,
1975 event: &SyntheticEvent,
1976 _phase: EventPhase,
1977) -> bool {
1978 use ApplicationEventFilter::{
1979 DeviceConnected, DeviceDisconnected, MediaControl, MonitorConnected, MonitorDisconnected,
1980 SystemAudioChange,
1981 };
1982
1983 match (filter, &event.event_type) {
1984 (MediaControl, EventType::MediaControl) => true,
1985 (SystemAudioChange, EventType::SystemAudioChange) => true,
1986 (DeviceConnected, EventType::DeviceConnected) => true,
1987 (DeviceDisconnected, EventType::DeviceDisconnected) => true,
1988 (MonitorConnected, EventType::MonitorConnected) => true,
1989 (MonitorDisconnected, EventType::MonitorDisconnected) => true,
1990 _ => false,
1991 }
1992}
1993
1994/// Check if an external filter matches the event (11c).
1995///
1996/// Media events are not hit-tested: the player is not a device with a
1997/// position, so there is no node "under" a `TimeUpdate`. Like the window and
1998/// application tables this therefore ignores the phase; the event's `target`
1999/// still names the media node, for callbacks that serve more than one.
2000// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
2001#[allow(clippy::match_same_arms)]
2002const fn matches_external_filter(
2003 filter: ExternalEventFilter,
2004 event: &SyntheticEvent,
2005 _phase: EventPhase,
2006) -> bool {
2007 matches!(
2008 (filter, &event.event_type),
2009 (ExternalEventFilter::Play, EventType::Play)
2010 | (ExternalEventFilter::Pause, EventType::Pause)
2011 | (ExternalEventFilter::Ended, EventType::Ended)
2012 | (ExternalEventFilter::TimeUpdate, EventType::TimeUpdate)
2013 | (ExternalEventFilter::VolumeChange, EventType::VolumeChange)
2014 | (ExternalEventFilter::MediaError, EventType::MediaError)
2015 )
2016}
2017
2018/// Check if a window filter matches the event.
2019// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
2020#[allow(clippy::match_same_arms)]
2021fn matches_window_filter(
2022 filter: WindowEventFilter,
2023 event: &SyntheticEvent,
2024 _phase: EventPhase,
2025) -> bool {
2026 use WindowEventFilter::{
2027 BiometricResult, CloseRequested, Drag, DragEnd, DragEnter, DragLeave, DragOver, DragStart,
2028 Drop, DroppedFile, FocusLost, FocusReceived, FrameChanged, GamepadInput, GeolocationError,
2029 GeolocationFix, HoveredFile, HoveredFileCancelled, KeyringResult, LeftMouseDown,
2030 LeftMouseUp, MiddleMouseDown, MiddleMouseUp, MouseDown, MouseEnter, MouseLeave, MouseMove,
2031 MouseOver,
2032 MouseUp, Moved, PenDoubleTap, PenDown, PenEnter, PenHover, PenLeave, PenMove, PenSqueeze,
2033 ModifiersChanged, PenUp, PermissionChanged, PointerLockChange, RawMouseMotion, Resized,
2034 RightMouseDown, RightMouseUp, ScreenColorPicked, Scroll, ScrollEnd, ScrollStart,
2035 SensorChanged, TextInput, ThemeChanged, TouchCancel, TouchEnd, TouchMove, TouchStart,
2036 VirtualKeyDown, VirtualKeyUp, WindowFocusLost, WindowFocusReceived,
2037 };
2038
2039 match (filter, &event.event_type) {
2040 (MouseOver, EventType::MouseOver) => true,
2041 (MouseMove, EventType::MouseMove) => true,
2042 (MouseDown, EventType::MouseDown) => true,
2043 (LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
2044 (RightMouseDown, EventType::MouseDown) => {
2045 check_mouse_button(&event.data, MouseButton::Right)
2046 }
2047 (MiddleMouseDown, EventType::MouseDown) => {
2048 check_mouse_button(&event.data, MouseButton::Middle)
2049 }
2050 (MouseUp, EventType::MouseUp) => true,
2051 (LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
2052 (RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
2053 (MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
2054 (MouseEnter, EventType::MouseEnter) => true,
2055 (MouseLeave, EventType::MouseLeave) => true,
2056 (Scroll, EventType::Scroll) => true,
2057 (ScrollStart, EventType::ScrollStart) => true,
2058 (ScrollEnd, EventType::ScrollEnd) => true,
2059 (TextInput, EventType::Input) => true,
2060 // `KeyPress` (a key that produced a character) and `Change` (a value
2061 // committed on blur) both plan onto the `TextInput` filter, which is
2062 // what an app registers for "text arrived here". Planning named that
2063 // filter and this table then rejected it, so both dispatched to
2064 // nothing.
2065 (TextInput, EventType::KeyPress) => true,
2066 (TextInput, EventType::Change) => true,
2067 // A context-menu request is not a `MouseDown`, so the button-payload
2068 // check on the `RightMouseDown` arm cannot answer for it. Routing it
2069 // to the same filter means the Menu/Apps key, Shift+F10 and the
2070 // accessibility `ShowContextMenu` action all reach the handler an app
2071 // already registered for right-click, without inventing a new filter
2072 // variant for each spelling of "open the context menu".
2073 (RightMouseDown, EventType::ContextMenu) => true,
2074 (VirtualKeyDown, EventType::KeyDown) => true,
2075 (VirtualKeyUp, EventType::KeyUp) => true,
2076 (HoveredFile, EventType::FileHover) => true,
2077 (DroppedFile, EventType::FileDrop) => true,
2078 (HoveredFileCancelled, EventType::FileHoverCancel) => true,
2079 (Resized, EventType::WindowResize) => true,
2080 (FrameChanged, EventType::WindowFrameChanged) => true,
2081 (Moved, EventType::WindowMove) => true,
2082 (TouchStart, EventType::TouchStart) => true,
2083 (TouchMove, EventType::TouchMove) => true,
2084 (TouchEnd, EventType::TouchEnd) => true,
2085 (TouchCancel, EventType::TouchCancel) => true,
2086 (PenDown, EventType::PenDown) => true,
2087 (PenMove, EventType::PenMove) => true,
2088 (PenUp, EventType::PenUp) => true,
2089 (PenEnter, EventType::PenEnter) => true,
2090 (PenLeave, EventType::PenLeave) => true,
2091 (FocusReceived, EventType::Focus) => true,
2092 (FocusLost, EventType::Blur) => true,
2093 (CloseRequested, EventType::WindowClose) => true,
2094 (ThemeChanged, EventType::ThemeChange) => true,
2095 (WindowFocusReceived, EventType::WindowFocusIn) => true,
2096 (WindowFocusLost, EventType::WindowFocusOut) => true,
2097 (PointerLockChange, EventType::PointerLockChange) => true,
2098 (SensorChanged, EventType::SensorChanged) => true,
2099 (GamepadInput, EventType::GamepadInput) => true,
2100 (GeolocationFix, EventType::GeolocationFix) => true,
2101 (GeolocationError, EventType::GeolocationError) => true,
2102 (PermissionChanged, EventType::PermissionChanged) => true,
2103 (BiometricResult, EventType::BiometricResult) => true,
2104 (ScreenColorPicked, EventType::ScreenColorPicked) => true,
2105 (KeyringResult, EventType::KeyringResult) => true,
2106 (DragStart, EventType::DragStart) => true,
2107 (Drag, EventType::Drag) => true,
2108 (DragEnd, EventType::DragEnd) => true,
2109 (DragEnter, EventType::DragEnter) => true,
2110 (DragOver, EventType::DragOver) => true,
2111 (DragLeave, EventType::DragLeave) => true,
2112 (Drop, EventType::Drop) => true,
2113 // Gestures — same gap as the hover table.
2114 (WindowEventFilter::LongPress, EventType::LongPress) => true,
2115 (WindowEventFilter::SwipeLeft, EventType::SwipeLeft) => true,
2116 (WindowEventFilter::SwipeRight, EventType::SwipeRight) => true,
2117 (WindowEventFilter::SwipeUp, EventType::SwipeUp) => true,
2118 (WindowEventFilter::SwipeDown, EventType::SwipeDown) => true,
2119 (WindowEventFilter::PinchIn, EventType::PinchIn) => true,
2120 (WindowEventFilter::PinchOut, EventType::PinchOut) => true,
2121 (WindowEventFilter::RotateClockwise, EventType::RotateClockwise) => true,
2122 (WindowEventFilter::RotateCounterClockwise, EventType::RotateCounterClockwise) => true,
2123 // Pen barrel gestures and hover. The filter variants shipped long
2124 // before any `EventType` existed that could reach them.
2125 (PenSqueeze, EventType::PenSqueeze) => true,
2126 (PenDoubleTap, EventType::PenDoubleTap) => true,
2127 (PenHover, EventType::PenHover) => true,
2128 // Thumb buttons. Gated on the payload like every other
2129 // button-specific arm — a payloadless event must not claim a button.
2130 (WindowEventFilter::BackMouseDown, EventType::MouseDown) => {
2131 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
2132 }
2133 (WindowEventFilter::BackMouseUp, EventType::MouseUp) => {
2134 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_BACK))
2135 }
2136 (WindowEventFilter::ForwardMouseDown, EventType::MouseDown) => {
2137 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
2138 }
2139 (WindowEventFilter::ForwardMouseUp, EventType::MouseUp) => {
2140 check_mouse_button(&event.data, MouseButton::Other(MOUSE_BUTTON_FORWARD))
2141 }
2142 (RawMouseMotion, EventType::RawMouseMotion) => true,
2143 (ModifiersChanged, EventType::ModifiersChanged) => true,
2144 (WindowEventFilter::HidReport, EventType::HidReport) => true,
2145 (WindowEventFilter::DialRotate, EventType::DialRotate) => true,
2146 (WindowEventFilter::DialClick, EventType::DialClick) => true,
2147 // Media (11c). These six `EventType`s shipped with no filter in ANY
2148 // family, so they planned an empty list and dispatched to nothing.
2149 (WindowEventFilter::Play, EventType::Play) => true,
2150 (WindowEventFilter::Pause, EventType::Pause) => true,
2151 (WindowEventFilter::Ended, EventType::Ended) => true,
2152 (WindowEventFilter::TimeUpdate, EventType::TimeUpdate) => true,
2153 (WindowEventFilter::VolumeChange, EventType::VolumeChange) => true,
2154 (WindowEventFilter::MediaError, EventType::MediaError) => true,
2155 _ => false,
2156 }
2157}
2158
2159/// Detect lifecycle events by comparing old and new DOM state.
2160///
2161/// This is the simple, index-based lifecycle detection that doesn't account for
2162/// node reordering. For more sophisticated reconciliation that can detect moves,
2163/// use `detect_lifecycle_events_with_reconciliation`.
2164///
2165/// Generates Mount, Unmount, and Resize events by comparing DOM hierarchies.
2166#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
2167#[must_use]
2168pub fn detect_lifecycle_events(
2169 old_dom_id: DomId,
2170 new_dom_id: DomId,
2171 old_hierarchy: Option<&crate::id::NodeHierarchy>,
2172 new_hierarchy: Option<&crate::id::NodeHierarchy>,
2173 old_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
2174 new_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
2175 timestamp: Instant,
2176) -> Vec<SyntheticEvent> {
2177 let old_nodes = collect_node_ids(old_hierarchy);
2178 let new_nodes = collect_node_ids(new_hierarchy);
2179
2180 let mut events = Vec::new();
2181
2182 // Mount events: nodes in new but not in old
2183 if let Some(layout) = new_layout {
2184 for &node_id in new_nodes.difference(&old_nodes) {
2185 events.push(create_mount_event(node_id, new_dom_id, layout, ×tamp));
2186 }
2187 }
2188
2189 // Unmount events: nodes in old but not in new
2190 if let Some(layout) = old_layout {
2191 for &node_id in old_nodes.difference(&new_nodes) {
2192 events.push(create_unmount_event(
2193 node_id, old_dom_id, layout, ×tamp,
2194 ));
2195 }
2196 }
2197
2198 // Resize events: nodes in both with changed bounds
2199 if let (Some(old_l), Some(new_l)) = (old_layout, new_layout) {
2200 for &node_id in old_nodes.intersection(&new_nodes) {
2201 if let Some(ev) = create_resize_event(node_id, new_dom_id, old_l, new_l, ×tamp) {
2202 events.push(ev);
2203 }
2204 }
2205 }
2206
2207 events
2208}
2209
2210fn collect_node_ids(hierarchy: Option<&crate::id::NodeHierarchy>) -> BTreeSet<NodeId> {
2211 hierarchy
2212 .map(|h| h.as_ref().linear_iter().collect())
2213 .unwrap_or_default()
2214}
2215
2216fn create_lifecycle_event(
2217 event_type: EventType,
2218 node_id: NodeId,
2219 dom_id: DomId,
2220 timestamp: &Instant,
2221 data: LifecycleEventData,
2222) -> SyntheticEvent {
2223 let dom_node_id = DomNodeId {
2224 dom: dom_id,
2225 node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
2226 };
2227 SyntheticEvent {
2228 event_type,
2229 source: EventSource::Lifecycle,
2230 phase: EventPhase::Target,
2231 target: dom_node_id,
2232 current_target: dom_node_id,
2233 timestamp: timestamp.clone(),
2234 data: EventData::Lifecycle(data),
2235 stopped: false,
2236 stopped_immediate: false,
2237 prevented_default: false,
2238 at_target_only: false,
2239 }
2240}
2241
2242fn create_mount_event(
2243 node_id: NodeId,
2244 dom_id: DomId,
2245 layout: &BTreeMap<NodeId, LogicalRect>,
2246 timestamp: &Instant,
2247) -> SyntheticEvent {
2248 let current_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
2249 create_lifecycle_event(
2250 EventType::Mount,
2251 node_id,
2252 dom_id,
2253 timestamp,
2254 LifecycleEventData {
2255 reason: LifecycleReason::InitialMount,
2256 previous_bounds: None,
2257 current_bounds,
2258 },
2259 )
2260}
2261
2262fn create_unmount_event(
2263 node_id: NodeId,
2264 dom_id: DomId,
2265 layout: &BTreeMap<NodeId, LogicalRect>,
2266 timestamp: &Instant,
2267) -> SyntheticEvent {
2268 let previous_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
2269 create_lifecycle_event(
2270 EventType::Unmount,
2271 node_id,
2272 dom_id,
2273 timestamp,
2274 LifecycleEventData {
2275 reason: LifecycleReason::Unmount,
2276 previous_bounds: Some(previous_bounds),
2277 current_bounds: LogicalRect::zero(),
2278 },
2279 )
2280}
2281
2282/// Returns `true` iff the two logical sizes differ after fixed-point
2283/// quantization (~0.001 tolerance), treating a dimension that is NaN on *both*
2284/// sides as unchanged so a degenerate layout cannot emit a Resize every frame.
2285fn size_changed(old: crate::geom::LogicalSize, new: crate::geom::LogicalSize) -> bool {
2286 fn dim_changed(a: f32, b: f32) -> bool {
2287 if a.is_nan() && b.is_nan() {
2288 return false;
2289 }
2290 // Fixed-point quantization mirrors `LogicalSize`'s `Ord`/`Hash`.
2291 // `f32 as i64` saturates on overflow (no wasm32 wraparound); a lone
2292 // NaN quantizes to `i64::MIN` and so registers as changed.
2293 #[allow(clippy::cast_possible_truncation)]
2294 // intentional fixed-point quantization; saturates
2295 let q = |v: f32| -> i64 {
2296 if v.is_nan() {
2297 i64::MIN
2298 } else {
2299 (v * 1000.0) as i64
2300 }
2301 };
2302 q(a) != q(b)
2303 }
2304 dim_changed(old.width, new.width) || dim_changed(old.height, new.height)
2305}
2306
2307fn create_resize_event(
2308 node_id: NodeId,
2309 dom_id: DomId,
2310 old_layout: &BTreeMap<NodeId, LogicalRect>,
2311 new_layout: &BTreeMap<NodeId, LogicalRect>,
2312 timestamp: &Instant,
2313) -> Option<SyntheticEvent> {
2314 let old_bounds = *old_layout.get(&node_id)?;
2315 let new_bounds = *new_layout.get(&node_id)?;
2316
2317 // Quantized/tolerance compare with an explicit NaN guard. A raw `==` on
2318 // `LogicalSize` used to compare f32 bit patterns, so a single NaN dimension
2319 // made `old != new` true *every frame forever* -> an endless Resize-event
2320 // loop. `size_changed` treats a NaN dimension present on both sides as
2321 // "unchanged" and otherwise compares fixed-point-quantized values.
2322 if !size_changed(old_bounds.size, new_bounds.size) {
2323 return None;
2324 }
2325
2326 Some(create_lifecycle_event(
2327 EventType::Resize,
2328 node_id,
2329 dom_id,
2330 timestamp,
2331 LifecycleEventData {
2332 reason: LifecycleReason::Resize,
2333 previous_bounds: Some(old_bounds),
2334 current_bounds: new_bounds,
2335 },
2336 ))
2337}
2338
2339/// A `Resize` lifecycle event (`ComponentEventFilter::NodeResized`) for
2340/// `node_id` whose layout box went from `old` to `new`.
2341///
2342/// `None` when its SIZE did not change: a position-only move is not a
2343/// resize, and a NaN dimension present on both sides reads as unchanged (see
2344/// `size_changed`; a raw `!=` once produced a Resize every frame forever).
2345///
2346/// This is the constructor the layout tail uses after EVERY solve (full
2347/// rebuild, pre-cascade relayout, window-resize fast path). The older
2348/// reconcile-time emitter compared layout maps that production always
2349/// passed EMPTY, so `NodeResized` had never fired in a running app.
2350#[must_use]
2351pub fn resize_event_for_bounds(
2352 dom_id: DomId,
2353 node_id: NodeId,
2354 old: LogicalRect,
2355 new: LogicalRect,
2356 timestamp: &Instant,
2357) -> Option<SyntheticEvent> {
2358 if !size_changed(old.size, new.size) {
2359 return None;
2360 }
2361 Some(create_lifecycle_event(
2362 EventType::Resize,
2363 node_id,
2364 dom_id,
2365 timestamp,
2366 LifecycleEventData {
2367 reason: LifecycleReason::Resize,
2368 previous_bounds: Some(old),
2369 current_bounds: new,
2370 },
2371 ))
2372}
2373
2374/// Result of lifecycle event detection with reconciliation.
2375///
2376/// Contains both the generated lifecycle events and a mapping from old to new
2377/// node IDs for state migration (focus, scroll, etc.).
2378#[derive(Debug, Clone, Default)]
2379pub struct LifecycleEventResult {
2380 /// Lifecycle events (Mount, Unmount, Resize, Update)
2381 pub events: Vec<SyntheticEvent>,
2382 /// Maps old `NodeId` -> new `NodeId` for matched nodes.
2383 /// Use this to migrate focus, scroll state, and other node-specific state.
2384 pub node_id_mapping: OrderedMap<NodeId, NodeId>,
2385}
2386
2387/// Detect lifecycle events using reconciliation with stable keys and content hashing.
2388///
2389/// This is the advanced lifecycle detection that can correctly identify:
2390/// - **Moves**: When a node changes position but keeps its identity (via key or hash)
2391/// - **Mounts**: When a new node appears
2392/// - **Unmounts**: When an existing node disappears
2393/// - **Resizes**: When a node's layout bounds change
2394/// - **Updates**: When a keyed node's content changes
2395///
2396/// The reconciliation strategy is:
2397/// 1. **Stable Key Match:** Nodes with `.with_reconciliation_key()` are matched by key (O(1))
2398/// 2. **Hash Match:** Nodes without keys are matched by content hash (enables reorder detection)
2399/// 3. **Fallback:** Unmatched nodes generate Mount/Unmount events
2400///
2401/// # Arguments
2402/// * `dom_id` - The DOM identifier
2403/// * `old_node_data` - Node data from the previous frame
2404/// * `new_node_data` - Node data from the current frame
2405/// * `old_layout` - Layout bounds from the previous frame
2406/// * `new_layout` - Layout bounds from the current frame
2407/// * `timestamp` - Current timestamp for events
2408///
2409/// # Returns
2410/// A `LifecycleEventResult` containing:
2411/// - `events`: Lifecycle events to dispatch
2412/// - `node_id_mapping`: Mapping from old to new `NodeIds` for state migration
2413///
2414/// # Example
2415/// ```rust,ignore
2416/// let result = detect_lifecycle_events_with_reconciliation(
2417/// dom_id,
2418/// &old_node_data,
2419/// &new_node_data,
2420/// &old_layout,
2421/// &new_layout,
2422/// timestamp,
2423/// );
2424///
2425/// // Dispatch lifecycle events
2426/// for event in result.events {
2427/// dispatch_event(event);
2428/// }
2429///
2430/// // Migrate focus to new node ID
2431/// if let Some(focused) = focus_manager.focused_node {
2432/// if let Some(&new_id) = result.node_id_mapping.get(&focused) {
2433/// focus_manager.focused_node = Some(new_id);
2434/// } else {
2435/// // Focused node was unmounted
2436/// focus_manager.focused_node = None;
2437/// }
2438/// }
2439/// ```
2440#[must_use]
2441pub fn detect_lifecycle_events_with_reconciliation(
2442 dom_id: DomId,
2443 old_node_data: &[crate::dom::NodeData],
2444 new_node_data: &[crate::dom::NodeData],
2445 old_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
2446 new_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
2447 old_layout: &OrderedMap<NodeId, LogicalRect>,
2448 new_layout: &OrderedMap<NodeId, LogicalRect>,
2449 timestamp: Instant,
2450) -> LifecycleEventResult {
2451 let diff_result = crate::diff::reconcile_dom(
2452 old_node_data,
2453 new_node_data,
2454 old_hierarchy,
2455 new_hierarchy,
2456 old_layout,
2457 new_layout,
2458 dom_id,
2459 timestamp,
2460 );
2461
2462 LifecycleEventResult {
2463 events: diff_result.events,
2464 node_id_mapping: crate::diff::create_migration_map(&diff_result.node_moves),
2465 }
2466}
2467
2468/// Event filter that only fires when an element is hovered over.
2469#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2470#[repr(C)]
2471pub enum HoverEventFilter {
2472 /// Mouse moved over the hovered element
2473 MouseOver,
2474 /// Any mouse button pressed on the hovered element
2475 MouseDown,
2476 /// Left mouse button pressed on the hovered element
2477 LeftMouseDown,
2478 /// Right mouse button pressed on the hovered element
2479 RightMouseDown,
2480 /// Middle mouse button pressed on the hovered element
2481 MiddleMouseDown,
2482 /// A completed ACTIVATION on this element: press and release on the same
2483 /// node, per W3C `click`. This - not `MouseUp` - is what "the user
2484 /// activated this control" means, and it is what Enter/Space on a focused
2485 /// element and an assistive technology's default action also dispatch.
2486 /// `MouseUp` stays the raw pointer-release event.
2487 Click,
2488 /// Any mouse button released on the hovered element
2489 MouseUp,
2490 /// Left mouse button released on the hovered element
2491 LeftMouseUp,
2492 /// Right mouse button released on the hovered element
2493 RightMouseUp,
2494 /// Middle mouse button released on the hovered element
2495 MiddleMouseUp,
2496 /// Mouse entered the hovered element bounds
2497 MouseEnter,
2498 /// Mouse left the hovered element bounds
2499 MouseLeave,
2500 /// Scroll event on the hovered element
2501 Scroll,
2502 /// Scroll started on the hovered element
2503 ScrollStart,
2504 /// Scroll ended on the hovered element
2505 ScrollEnd,
2506 /// Text input received while element is hovered
2507 TextInput,
2508 /// Virtual key pressed while element is hovered
2509 VirtualKeyDown,
2510 /// Virtual key released while element is hovered
2511 VirtualKeyUp,
2512 /// File is being hovered over the element
2513 HoveredFile,
2514 /// File was dropped onto the element
2515 DroppedFile,
2516 /// File hover was cancelled
2517 HoveredFileCancelled,
2518 /// Touch started on the hovered element
2519 TouchStart,
2520 /// Touch moved on the hovered element
2521 TouchMove,
2522 /// Touch ended on the hovered element
2523 TouchEnd,
2524 /// Touch was cancelled on the hovered element
2525 TouchCancel,
2526 /// Pen/stylus made contact on the hovered element
2527 PenDown,
2528 /// Pen/stylus moved while in contact on the hovered element
2529 PenMove,
2530 /// Pen/stylus lifted from the hovered element
2531 PenUp,
2532 /// Pen/stylus entered proximity of the hovered element
2533 PenEnter,
2534 /// Pen/stylus left proximity of the hovered element
2535 PenLeave,
2536 /// Apple Pencil 2 / Surface Slim Pen 2 barrel squeeze on the hovered
2537 /// element. Fires once per squeeze. The matching W3C primitive is the
2538 /// `PointerEvent` with `pointerType: "pen"` and a transient
2539 /// `tangentialPressure` spike — most apps tie a tool-switch to it.
2540 PenSqueeze,
2541 /// Apple Pencil 2 side double-tap on the hovered element. Fires once
2542 /// per gesture. Usually mapped to "undo" or "switch eraser".
2543 PenDoubleTap,
2544 /// Pen/stylus is hovering above the hovered element (in proximity,
2545 /// not in contact). Continuous: fires per pen-axis update while the
2546 /// stylus is held above the surface. Maps to W3C
2547 /// `PointerEvent('pointermove')` with `buttons: 0` and
2548 /// `pointerType: 'pen'`.
2549 PenHover,
2550 /// New GPS / network location fix arrived for a `GeolocationProbe`
2551 /// in this node's subtree. Payload accessor:
2552 /// `CallbackInfo::get_geolocation_fix()`.
2553 GeolocationFix,
2554 /// Native geolocation subscription errored / was revoked /
2555 /// timed out.
2556 GeolocationError,
2557 /// A motion-sensor reading changed (P6). Window-level mirror:
2558 /// `WindowEventFilter::SensorChanged`. Read via `get_sensor_reading`.
2559 SensorChanged,
2560 /// A gamepad's state changed / it (dis)connected (P6). Read via
2561 /// `get_primary_gamepad` / `get_gamepad_state`.
2562 GamepadInput,
2563 /// Drag started on the hovered element
2564 DragStart,
2565 /// Drag in progress on the hovered element
2566 Drag,
2567 /// Drag ended on the hovered element
2568 DragEnd,
2569 /// Dragged element entered this element (drop target)
2570 DragEnter,
2571 /// Dragged element is over this element (drop target, fires continuously)
2572 DragOver,
2573 /// Dragged element left this element (drop target)
2574 DragLeave,
2575 /// Element was dropped on this element (drop target)
2576 Drop,
2577 /// Double-click detected on the hovered element
2578 DoubleClick,
2579 /// Long press detected on the hovered element
2580 LongPress,
2581 /// Swipe left gesture on the hovered element
2582 SwipeLeft,
2583 /// Swipe right gesture on the hovered element
2584 SwipeRight,
2585 /// Swipe up gesture on the hovered element
2586 SwipeUp,
2587 /// Swipe down gesture on the hovered element
2588 SwipeDown,
2589 /// Pinch-in (zoom out) gesture on the hovered element
2590 PinchIn,
2591 /// Pinch-out (zoom in) gesture on the hovered element
2592 PinchOut,
2593 /// Clockwise rotation gesture on the hovered element
2594 RotateClockwise,
2595 /// Counter-clockwise rotation gesture on the hovered element
2596 RotateCounterClockwise,
2597
2598 // W3C MouseOut event (bubbling version of MouseLeave)
2599 /// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
2600 MouseOut,
2601
2602 // W3C Focus events (bubbling versions)
2603 /// Focus is about to move INTO this element or a descendant (W3C `focusin`, bubbles)
2604 FocusIn,
2605 /// Focus is about to move OUT of this element or a descendant (W3C `focusout`, bubbles)
2606 FocusOut,
2607
2608 // IME Composition events
2609 /// IME composition started (W3C `compositionstart`)
2610 CompositionStart,
2611 /// IME composition updated (W3C `compositionupdate`)
2612 CompositionUpdate,
2613 /// IME composition ended (W3C `compositionend`)
2614 CompositionEnd,
2615
2616 // Internal System Events (not exposed to user callbacks)
2617 #[doc(hidden)]
2618 /// Internal: Single click for text cursor placement
2619 SystemTextSingleClick,
2620 #[doc(hidden)]
2621 /// Internal: Double click for word selection
2622 SystemTextDoubleClick,
2623 #[doc(hidden)]
2624 /// Internal: Triple click for paragraph/line selection
2625 SystemTextTripleClick,
2626
2627 // Async capability outcomes (MWA-A1b)
2628 /// A permission's OS-observed state changed while this node (the
2629 /// capability's most recent subscriber) is in the target chain.
2630 PermissionChanged,
2631 /// A biometric authentication prompt completed.
2632 BiometricResult,
2633 /// The screen eyedropper finished (picked or cancelled).
2634 ScreenColorPicked,
2635 /// A keyring store / get / delete operation completed.
2636 KeyringResult,
2637 /// The thumb "back" button was pressed. APPENDED at the end for ABI
2638 /// stability.
2639 ///
2640 /// Every shell already routes this button — Win32 `WM_XBUTTONDOWN`, macOS
2641 /// `otherMouseDown:`, X11 button 8 — into `MouseButton::Other(3)`, and it
2642 /// died there because no filter named it. Nearly every mouse sold in a
2643 /// decade has the pair, and browsers, IDEs and file managers all bind them
2644 /// to navigate back/forward.
2645 BackMouseDown,
2646 /// The thumb "back" button was released.
2647 BackMouseUp,
2648 /// The thumb "forward" button was pressed.
2649 ForwardMouseDown,
2650 /// The thumb "forward" button was released.
2651 ForwardMouseUp,
2652 /// A form was submitted — Enter on a focused control, or a submit
2653 /// button activated. APPENDED at the end for ABI stability.
2654 Submit,
2655 /// A control's value was COMMITTED, which is not the same as edited.
2656 /// APPENDED at the end.
2657 ///
2658 /// `TextInput` fires on every keystroke; this fires once, when the value
2659 /// settles — on blur, or on Enter. A field that validates on `TextInput`
2660 /// scolds the user halfway through typing an email address; one that
2661 /// validates on `Change` waits until they have finished.
2662 Change,
2663 /// A form was reset to its initial values. APPENDED at the end.
2664 Reset,
2665 /// A control failed validation. APPENDED at the end.
2666 Invalid,
2667 /// A dial turned over this node. APPENDED at the end.
2668 DialRotate,
2669 /// A dial was clicked over this node. APPENDED at the end.
2670 DialClick,
2671 /// The pointer moved while over this node. APPENDED at the end.
2672 MouseMove,
2673}
2674
2675impl HoverEventFilter {
2676 /// Check if this is an internal system event that should not be exposed to user callbacks
2677 #[must_use]
2678 pub const fn is_system_internal(&self) -> bool {
2679 matches!(
2680 self,
2681 Self::SystemTextSingleClick | Self::SystemTextDoubleClick | Self::SystemTextTripleClick
2682 )
2683 }
2684
2685 // Exhaustive On -> Option<FocusEventFilter> mapping table; the several `=> None`
2686 // rows (window-only events) are intentional 1:1 rows — merging would collapse the table.
2687 #[allow(clippy::match_same_arms)]
2688 #[must_use]
2689 pub const fn to_focus_event_filter(&self) -> Option<FocusEventFilter> {
2690 match self {
2691 // No focus mirror: a dial is delivered by POSITION (hover) or to
2692 // the window, never by focus.
2693 Self::DialRotate | Self::DialClick => None,
2694 Self::MouseOver => Some(FocusEventFilter::MouseOver),
2695 Self::MouseMove => Some(FocusEventFilter::MouseMove),
2696 Self::MouseDown => Some(FocusEventFilter::MouseDown),
2697 Self::LeftMouseDown => Some(FocusEventFilter::LeftMouseDown),
2698 Self::RightMouseDown => Some(FocusEventFilter::RightMouseDown),
2699 Self::MiddleMouseDown => Some(FocusEventFilter::MiddleMouseDown),
2700 // Activation is HOVER-scoped: it targets the node that was
2701 // activated, and dispatch already walks that node's ancestors, so
2702 // there is no separate focus-scoped spelling to map onto.
2703 Self::Click => None,
2704 Self::MouseUp => Some(FocusEventFilter::MouseUp),
2705 Self::LeftMouseUp => Some(FocusEventFilter::LeftMouseUp),
2706 Self::RightMouseUp => Some(FocusEventFilter::RightMouseUp),
2707 Self::MiddleMouseUp => Some(FocusEventFilter::MiddleMouseUp),
2708 Self::MouseEnter => Some(FocusEventFilter::MouseEnter),
2709 Self::MouseLeave => Some(FocusEventFilter::MouseLeave),
2710 Self::Scroll => Some(FocusEventFilter::Scroll),
2711 Self::ScrollStart => Some(FocusEventFilter::ScrollStart),
2712 Self::ScrollEnd => Some(FocusEventFilter::ScrollEnd),
2713 Self::TextInput => Some(FocusEventFilter::TextInput),
2714 Self::VirtualKeyDown => Some(FocusEventFilter::VirtualKeyDown),
2715 Self::VirtualKeyUp => Some(FocusEventFilter::VirtualKeyUp),
2716 Self::HoveredFile => None,
2717 Self::DroppedFile => None,
2718 Self::HoveredFileCancelled => None,
2719 Self::TouchStart => None,
2720 Self::TouchMove => None,
2721 Self::TouchEnd => None,
2722 Self::TouchCancel => None,
2723 Self::PenDown => Some(FocusEventFilter::PenDown),
2724 Self::PenMove => Some(FocusEventFilter::PenMove),
2725 Self::PenUp => Some(FocusEventFilter::PenUp),
2726 Self::PenEnter => None,
2727 Self::PenLeave => None,
2728 Self::PenSqueeze => None,
2729 Self::PenDoubleTap => None,
2730 Self::PenHover => None,
2731 Self::GeolocationFix => None,
2732 Self::GeolocationError => None,
2733 Self::SensorChanged => None,
2734 Self::GamepadInput => None,
2735 Self::DragStart => Some(FocusEventFilter::DragStart),
2736 Self::Drag => Some(FocusEventFilter::Drag),
2737 Self::DragEnd => Some(FocusEventFilter::DragEnd),
2738 Self::DragEnter => Some(FocusEventFilter::DragEnter),
2739 Self::DragOver => Some(FocusEventFilter::DragOver),
2740 Self::DragLeave => Some(FocusEventFilter::DragLeave),
2741 Self::Drop => Some(FocusEventFilter::Drop),
2742 Self::DoubleClick => Some(FocusEventFilter::DoubleClick),
2743 Self::LongPress => Some(FocusEventFilter::LongPress),
2744 Self::SwipeLeft => Some(FocusEventFilter::SwipeLeft),
2745 Self::SwipeRight => Some(FocusEventFilter::SwipeRight),
2746 Self::SwipeUp => Some(FocusEventFilter::SwipeUp),
2747 Self::SwipeDown => Some(FocusEventFilter::SwipeDown),
2748 Self::PinchIn => Some(FocusEventFilter::PinchIn),
2749 Self::PinchOut => Some(FocusEventFilter::PinchOut),
2750 Self::RotateClockwise => Some(FocusEventFilter::RotateClockwise),
2751 Self::RotateCounterClockwise => Some(FocusEventFilter::RotateCounterClockwise),
2752 Self::MouseOut => Some(FocusEventFilter::MouseLeave), // mouseout → closest focus equivalent
2753 Self::FocusIn => Some(FocusEventFilter::FocusIn),
2754 Self::FocusOut => Some(FocusEventFilter::FocusOut),
2755 Self::CompositionStart => Some(FocusEventFilter::CompositionStart),
2756 Self::CompositionUpdate => Some(FocusEventFilter::CompositionUpdate),
2757 Self::CompositionEnd => Some(FocusEventFilter::CompositionEnd),
2758 // System internal events - don't convert to focus events
2759 Self::SystemTextSingleClick => None,
2760 Self::SystemTextDoubleClick => None,
2761 Self::SystemTextTripleClick => None,
2762 // Async capability outcomes — no focus-filter equivalents
2763 Self::PermissionChanged => None,
2764 Self::BiometricResult => None,
2765 Self::ScreenColorPicked => None,
2766 Self::KeyringResult => None,
2767 // Thumb buttons have a Focus twin; the rest of the additions do
2768 // not (a HID report and a modifier change belong to the window).
2769 Self::BackMouseDown => Some(FocusEventFilter::BackMouseDown),
2770 Self::BackMouseUp => Some(FocusEventFilter::BackMouseUp),
2771 Self::ForwardMouseDown => Some(FocusEventFilter::ForwardMouseDown),
2772 Self::ForwardMouseUp => Some(FocusEventFilter::ForwardMouseUp),
2773 // Form events have a Focus twin: they belong to the control that
2774 // was being edited, not to whatever is hovered.
2775 Self::Submit => Some(FocusEventFilter::Submit),
2776 Self::Change => Some(FocusEventFilter::Change),
2777 Self::Reset => Some(FocusEventFilter::Reset),
2778 Self::Invalid => Some(FocusEventFilter::Invalid),
2779 }
2780 }
2781}
2782
2783/// Event filter similar to `HoverEventFilter` that only fires when the element is focused.
2784///
2785/// **Important**: In order for this to fire, the item must have a `tabindex` attribute
2786/// (to indicate that the item is focus-able).
2787#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2788#[repr(C)]
2789pub enum FocusEventFilter {
2790 /// Mouse moved over the focused element
2791 MouseOver,
2792 /// Any mouse button pressed on the focused element
2793 MouseDown,
2794 /// Left mouse button pressed on the focused element
2795 LeftMouseDown,
2796 /// Right mouse button pressed on the focused element
2797 RightMouseDown,
2798 /// Middle mouse button pressed on the focused element
2799 MiddleMouseDown,
2800 /// Any mouse button released on the focused element
2801 MouseUp,
2802 /// Left mouse button released on the focused element
2803 LeftMouseUp,
2804 /// Right mouse button released on the focused element
2805 RightMouseUp,
2806 /// Middle mouse button released on the focused element
2807 MiddleMouseUp,
2808 /// Mouse entered the focused element bounds
2809 MouseEnter,
2810 /// Mouse left the focused element bounds
2811 MouseLeave,
2812 /// Scroll event on the focused element
2813 Scroll,
2814 /// Scroll started on the focused element
2815 ScrollStart,
2816 /// Scroll ended on the focused element
2817 ScrollEnd,
2818 /// Text input received while element is focused
2819 TextInput,
2820 /// Virtual key pressed while element is focused
2821 VirtualKeyDown,
2822 /// Virtual key released while element is focused
2823 VirtualKeyUp,
2824 /// Element received keyboard focus
2825 FocusReceived,
2826 /// Element lost keyboard focus
2827 FocusLost,
2828 /// Pen/stylus made contact on the focused element
2829 PenDown,
2830 /// Pen/stylus moved while in contact on the focused element
2831 PenMove,
2832 /// Pen/stylus lifted from the focused element
2833 PenUp,
2834 /// Drag started on the focused element
2835 DragStart,
2836 /// Drag in progress on the focused element
2837 Drag,
2838 /// Drag ended on the focused element
2839 DragEnd,
2840 /// Dragged element entered this focused element (drop target)
2841 DragEnter,
2842 /// Dragged element is over this focused element (drop target)
2843 DragOver,
2844 /// Dragged element left this focused element (drop target)
2845 DragLeave,
2846 /// Element was dropped on this focused element (drop target)
2847 Drop,
2848 /// Double-click detected on the focused element
2849 DoubleClick,
2850 /// Long press detected on the focused element
2851 LongPress,
2852 /// Swipe left gesture on the focused element
2853 SwipeLeft,
2854 /// Swipe right gesture on the focused element
2855 SwipeRight,
2856 /// Swipe up gesture on the focused element
2857 SwipeUp,
2858 /// Swipe down gesture on the focused element
2859 SwipeDown,
2860 /// Pinch-in (zoom out) gesture on the focused element
2861 PinchIn,
2862 /// Pinch-out (zoom in) gesture on the focused element
2863 PinchOut,
2864 /// Clockwise rotation gesture on the focused element
2865 RotateClockwise,
2866 /// Counter-clockwise rotation gesture on the focused element
2867 RotateCounterClockwise,
2868
2869 // W3C Focus events (bubbling versions, fires on focused element when focus changes)
2870 /// Focus moved into this element or a descendant (W3C `focusin`)
2871 FocusIn,
2872 /// Focus moved out of this element or a descendant (W3C `focusout`)
2873 FocusOut,
2874
2875 // IME Composition events
2876 /// IME composition started (W3C `compositionstart`)
2877 CompositionStart,
2878 /// IME composition updated (W3C `compositionupdate`)
2879 CompositionUpdate,
2880 /// IME composition ended (W3C `compositionend`)
2881 CompositionEnd,
2882
2883 // Clipboard events (W3C clipboard-events; MWA-C-clipboard: fire on the
2884 // focused element BEFORE the OS default action, which preventDefault
2885 // suppresses). APPENDED at the end for ABI stability — sync to api.json
2886 // via azul-doc autofix in Phase D.
2887 /// Content is about to be copied from the focused element (W3C `copy`)
2888 Copy,
2889 /// Content is about to be cut from the focused element (W3C `cut`)
2890 Cut,
2891 /// Content is about to be pasted into the focused element (W3C `paste`)
2892 Paste,
2893 /// A structural document edit was recorded on (or under) the focused
2894 /// element and awaits the app's apply-and-ack (see
2895 /// `EventType::DocumentEdit`). APPENDED at the end for ABI stability.
2896 DocumentEdit,
2897 /// The focused editable's text was committed (see
2898 /// `EventType::TextChanged`): the post-commit counterpart of `TextInput`,
2899 /// which fires before the pending insertion is applied. APPENDED at the
2900 /// end for ABI stability.
2901 TextChanged,
2902 /// The thumb "back" button was pressed. APPENDED at the end for ABI
2903 /// stability.
2904 ///
2905 /// Every shell already routes this button — Win32 `WM_XBUTTONDOWN`, macOS
2906 /// `otherMouseDown:`, X11 button 8 — into `MouseButton::Other(3)`, and it
2907 /// died there because no filter named it. Nearly every mouse sold in a
2908 /// decade has the pair, and browsers, IDEs and file managers all bind them
2909 /// to navigate back/forward.
2910 BackMouseDown,
2911 /// The thumb "back" button was released.
2912 BackMouseUp,
2913 /// The thumb "forward" button was pressed.
2914 ForwardMouseDown,
2915 /// The thumb "forward" button was released.
2916 ForwardMouseUp,
2917 /// A form was submitted — Enter on a focused control, or a submit
2918 /// button activated. APPENDED at the end for ABI stability.
2919 Submit,
2920 /// A control's value was COMMITTED, which is not the same as edited.
2921 /// APPENDED at the end.
2922 ///
2923 /// `TextInput` fires on every keystroke; this fires once, when the value
2924 /// settles — on blur, or on Enter. A field that validates on `TextInput`
2925 /// scolds the user halfway through typing an email address; one that
2926 /// validates on `Change` waits until they have finished.
2927 Change,
2928 /// A form was reset to its initial values. APPENDED at the end.
2929 Reset,
2930 /// A control failed validation. APPENDED at the end.
2931 Invalid,
2932 /// The pointer moved while this node had focus. APPENDED at the end.
2933 MouseMove,
2934}
2935
2936/// Event filter that fires when any action fires on the entire window
2937/// (regardless of whether any element is hovered or focused over).
2938#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
2939#[repr(C)]
2940pub enum WindowEventFilter {
2941 /// Mouse moved anywhere in window
2942 MouseOver,
2943 /// Any mouse button pressed anywhere in window
2944 MouseDown,
2945 /// Left mouse button pressed anywhere in window
2946 LeftMouseDown,
2947 /// Right mouse button pressed anywhere in window
2948 RightMouseDown,
2949 /// Middle mouse button pressed anywhere in window
2950 MiddleMouseDown,
2951 /// Any mouse button released anywhere in window
2952 MouseUp,
2953 /// Left mouse button released anywhere in window
2954 LeftMouseUp,
2955 /// Right mouse button released anywhere in window
2956 RightMouseUp,
2957 /// Middle mouse button released anywhere in window
2958 MiddleMouseUp,
2959 /// Mouse entered the window
2960 MouseEnter,
2961 /// Mouse left the window
2962 MouseLeave,
2963 /// Scroll event anywhere in window
2964 Scroll,
2965 /// Scroll started anywhere in window
2966 ScrollStart,
2967 /// Scroll ended anywhere in window
2968 ScrollEnd,
2969 /// Text input received in window
2970 TextInput,
2971 /// Virtual key pressed in window
2972 VirtualKeyDown,
2973 /// Virtual key released in window
2974 VirtualKeyUp,
2975 /// File is being hovered over the window
2976 HoveredFile,
2977 /// File was dropped onto the window
2978 DroppedFile,
2979 /// File hover was cancelled
2980 HoveredFileCancelled,
2981 /// Window was resized
2982 Resized,
2983 /// Window was moved
2984 Moved,
2985 /// Window was minimized, maximized, restored, or toggled fullscreen
2986 FrameChanged,
2987 /// Touch started anywhere in window
2988 TouchStart,
2989 /// Touch moved anywhere in window
2990 TouchMove,
2991 /// Touch ended anywhere in window
2992 TouchEnd,
2993 /// Touch was cancelled
2994 TouchCancel,
2995 /// Window received focus
2996 FocusReceived,
2997 /// Window lost focus
2998 FocusLost,
2999 /// Window close was requested
3000 CloseRequested,
3001 /// System theme changed (light/dark mode)
3002 ThemeChanged,
3003 /// Window received OS-level focus
3004 WindowFocusReceived,
3005 /// Window lost OS-level focus
3006 WindowFocusLost,
3007 /// Pen/stylus made contact anywhere in window
3008 PenDown,
3009 /// Pen/stylus moved while in contact anywhere in window
3010 PenMove,
3011 /// Pen/stylus lifted anywhere in window
3012 PenUp,
3013 /// Pen/stylus entered window proximity
3014 PenEnter,
3015 /// Pen/stylus left window proximity
3016 PenLeave,
3017 /// Pen barrel-squeeze gesture fired in the window. See
3018 /// [`HoverEventFilter::PenSqueeze`].
3019 PenSqueeze,
3020 /// Pen side double-tap gesture fired in the window. See
3021 /// [`HoverEventFilter::PenDoubleTap`].
3022 PenDoubleTap,
3023 /// Pen hover in the window (in proximity, not in contact). See
3024 /// [`HoverEventFilter::PenHover`].
3025 PenHover,
3026 /// New GPS / network location fix arrived. Payload accessor:
3027 /// `CallbackInfo::get_geolocation_fix()`. Window-level rather
3028 /// than per-node because the user's location isn't bound to any
3029 /// particular DOM node — but a node-level mirror
3030 /// (`HoverEventFilter::GeolocationFix`) fires on every
3031 /// `GeolocationProbe` in the tree as well, for the common
3032 /// "redraw this node when the location changes" pattern.
3033 GeolocationFix,
3034 /// Native geolocation subscription dropped or errored (signal
3035 /// lost, no provider, permission revoked mid-session).
3036 GeolocationError,
3037 /// A motion-sensor reading changed (P6). Fires window-level (the device
3038 /// isn't bound to a node); read via `CallbackInfo::get_sensor_reading`.
3039 SensorChanged,
3040 /// A gamepad's buttons / axes changed or it (dis)connected (P6); read via
3041 /// `get_primary_gamepad` / `get_gamepad_state`.
3042 GamepadInput,
3043 /// Drag started anywhere in window
3044 DragStart,
3045 /// Drag in progress anywhere in window
3046 Drag,
3047 /// Drag ended anywhere in window
3048 DragEnd,
3049 /// Dragged element entered a drop target in window
3050 DragEnter,
3051 /// Dragged element is over a drop target in window
3052 DragOver,
3053 /// Dragged element left a drop target in window
3054 DragLeave,
3055 /// Element was dropped on a drop target in window
3056 Drop,
3057 /// Double-click detected anywhere in window
3058 DoubleClick,
3059 /// Long press detected anywhere in window
3060 LongPress,
3061 /// Swipe left gesture anywhere in window
3062 SwipeLeft,
3063 /// Swipe right gesture anywhere in window
3064 SwipeRight,
3065 /// Swipe up gesture anywhere in window
3066 SwipeUp,
3067 /// Swipe down gesture anywhere in window
3068 SwipeDown,
3069 /// Pinch-in (zoom out) gesture anywhere in window
3070 PinchIn,
3071 /// Pinch-out (zoom in) gesture anywhere in window
3072 PinchOut,
3073 /// Clockwise rotation gesture anywhere in window
3074 RotateClockwise,
3075 /// Counter-clockwise rotation gesture anywhere in window
3076 RotateCounterClockwise,
3077 /// The window's DPI scale factor changed (e.g., moved to a monitor with
3078 /// different scaling). The new DPI is available via `CallbackInfo::get_hidpi_factor()`.
3079 DpiChanged,
3080 /// The window moved to a different monitor. The new monitor is available
3081 /// via `CallbackInfo::get_current_monitor()`.
3082 MonitorChanged,
3083
3084 // Async capability outcomes (MWA-A1b) — window-level mirrors (the
3085 // outcome isn't inherently bound to a node).
3086 /// A permission's OS-observed state changed.
3087 PermissionChanged,
3088 /// A biometric authentication prompt completed.
3089 BiometricResult,
3090 /// The screen eyedropper finished (picked or cancelled).
3091 ScreenColorPicked,
3092 /// A keyring store / get / delete operation completed.
3093 KeyringResult,
3094 /// The thumb "back" button was pressed. APPENDED at the end for ABI
3095 /// stability.
3096 ///
3097 /// Every shell already routes this button — Win32 `WM_XBUTTONDOWN`, macOS
3098 /// `otherMouseDown:`, X11 button 8 — into `MouseButton::Other(3)`, and it
3099 /// died there because no filter named it. Nearly every mouse sold in a
3100 /// decade has the pair, and browsers, IDEs and file managers all bind them
3101 /// to navigate back/forward.
3102 BackMouseDown,
3103 /// The thumb "back" button was released.
3104 BackMouseUp,
3105 /// The thumb "forward" button was pressed.
3106 ForwardMouseDown,
3107 /// The thumb "forward" button was released.
3108 ForwardMouseUp,
3109 /// Raw, pre-acceleration pointer motion. APPENDED at the end for ABI
3110 /// stability.
3111 ///
3112 /// Window-scoped rather than hover-scoped because raw motion has no
3113 /// position: there is no node under it to hit-test, and a locked pointer
3114 /// is a window-wide mode.
3115 RawMouseMotion,
3116 /// A modifier or lock key changed state. APPENDED at the end.
3117 ModifiersChanged,
3118 /// A HID device delivered an input report. APPENDED at the end.
3119 ///
3120 /// Window-scoped like `RawMouseMotion`: a HID report has no position, so
3121 /// there is no node it belongs to.
3122 HidReport,
3123 /// A dial turned. APPENDED at the end.
3124 ///
3125 /// Window-scoped as well as node-scoped: a dial used OFF the screen (every
3126 /// device except a Surface Dial on a Surface Studio) reports no contact
3127 /// point, so no node is under it and only the window can receive it.
3128 DialRotate,
3129 /// A dial with a physical click was pressed. APPENDED at the end.
3130 DialClick,
3131 /// The pointer moved anywhere in the window. APPENDED at the end.
3132 MouseMove,
3133 /// The pointer lock was taken, released, or ended by the platform
3134 /// (9d-ii-c). Window-level: the lock belongs to the window, not a node.
3135 PointerLockChange,
3136 /// Media playback started (11c). APPENDED at the end for ABI stability.
3137 ///
3138 /// The window mirror of [`ExternalEventFilter::Play`]. Media state is
3139 /// reported by an EXTERNAL player, not by a device the window hit-tests,
3140 /// so `External` is its home; this pair exists because a media event
3141 /// belongs to the window in exactly the sense a monitor being unplugged
3142 /// does, and an app that already listens window-wide should not have to
3143 /// learn a second family to hear it.
3144 Play,
3145 /// Media playback paused. APPENDED at the end.
3146 Pause,
3147 /// Media playback reached the end. APPENDED at the end.
3148 Ended,
3149 /// The media position advanced (throttled — see
3150 /// `managers::media_player::TIME_UPDATE_INTERVAL_S`). APPENDED at the end.
3151 TimeUpdate,
3152 /// The media volume or mute state changed. APPENDED at the end.
3153 VolumeChange,
3154 /// The media pipeline failed. APPENDED at the end.
3155 MediaError,
3156}
3157
3158impl WindowEventFilter {
3159 // Exhaustive On -> Option<HoverEventFilter> mapping table (see to_focus_event_filter).
3160 #[allow(clippy::match_same_arms)]
3161 #[must_use]
3162 pub const fn to_hover_event_filter(&self) -> Option<HoverEventFilter> {
3163 match self {
3164 Self::DialRotate => Some(HoverEventFilter::DialRotate),
3165 Self::DialClick => Some(HoverEventFilter::DialClick),
3166 Self::MouseOver => Some(HoverEventFilter::MouseOver),
3167 Self::MouseMove => Some(HoverEventFilter::MouseMove),
3168 Self::MouseDown => Some(HoverEventFilter::MouseDown),
3169 Self::LeftMouseDown => Some(HoverEventFilter::LeftMouseDown),
3170 Self::RightMouseDown => Some(HoverEventFilter::RightMouseDown),
3171 Self::MiddleMouseDown => Some(HoverEventFilter::MiddleMouseDown),
3172 Self::MouseUp => Some(HoverEventFilter::MouseUp),
3173 Self::LeftMouseUp => Some(HoverEventFilter::LeftMouseUp),
3174 Self::RightMouseUp => Some(HoverEventFilter::RightMouseUp),
3175 Self::MiddleMouseUp => Some(HoverEventFilter::MiddleMouseUp),
3176 Self::Scroll => Some(HoverEventFilter::Scroll),
3177 Self::ScrollStart => Some(HoverEventFilter::ScrollStart),
3178 Self::ScrollEnd => Some(HoverEventFilter::ScrollEnd),
3179 Self::TextInput => Some(HoverEventFilter::TextInput),
3180 Self::VirtualKeyDown => Some(HoverEventFilter::VirtualKeyDown),
3181 Self::VirtualKeyUp => Some(HoverEventFilter::VirtualKeyUp),
3182 Self::HoveredFile => Some(HoverEventFilter::HoveredFile),
3183 Self::DroppedFile => Some(HoverEventFilter::DroppedFile),
3184 Self::HoveredFileCancelled => Some(HoverEventFilter::HoveredFileCancelled),
3185 // MouseEnter and MouseLeave on the **window** - does not mean a mouseenter
3186 // and a mouseleave on the hovered element
3187 Self::MouseEnter => None,
3188 Self::MouseLeave => None,
3189 Self::Resized => None,
3190 Self::Moved => None,
3191 // A frame transition is a WINDOW fact; there is no per-element
3192 // hover equivalent to map it onto.
3193 Self::FrameChanged => None,
3194 Self::TouchStart => Some(HoverEventFilter::TouchStart),
3195 Self::TouchMove => Some(HoverEventFilter::TouchMove),
3196 Self::TouchEnd => Some(HoverEventFilter::TouchEnd),
3197 Self::TouchCancel => Some(HoverEventFilter::TouchCancel),
3198 Self::FocusReceived => None,
3199 Self::FocusLost => None,
3200 Self::CloseRequested => None,
3201 Self::ThemeChanged => None,
3202 Self::WindowFocusReceived => None, // specific to window!
3203 Self::WindowFocusLost => None, // specific to window!
3204 Self::PointerLockChange => None, // specific to window!
3205 Self::PenDown => Some(HoverEventFilter::PenDown),
3206 Self::PenMove => Some(HoverEventFilter::PenMove),
3207 Self::PenUp => Some(HoverEventFilter::PenUp),
3208 Self::PenEnter => Some(HoverEventFilter::PenEnter),
3209 Self::PenLeave => Some(HoverEventFilter::PenLeave),
3210 Self::PenSqueeze => Some(HoverEventFilter::PenSqueeze),
3211 Self::PenDoubleTap => Some(HoverEventFilter::PenDoubleTap),
3212 Self::PenHover => Some(HoverEventFilter::PenHover),
3213 Self::GeolocationFix => Some(HoverEventFilter::GeolocationFix),
3214 Self::GeolocationError => Some(HoverEventFilter::GeolocationError),
3215 Self::SensorChanged => Some(HoverEventFilter::SensorChanged),
3216 Self::GamepadInput => Some(HoverEventFilter::GamepadInput),
3217 Self::DragStart => Some(HoverEventFilter::DragStart),
3218 Self::Drag => Some(HoverEventFilter::Drag),
3219 Self::DragEnd => Some(HoverEventFilter::DragEnd),
3220 Self::DragEnter => Some(HoverEventFilter::DragEnter),
3221 Self::DragOver => Some(HoverEventFilter::DragOver),
3222 Self::DragLeave => Some(HoverEventFilter::DragLeave),
3223 Self::Drop => Some(HoverEventFilter::Drop),
3224 Self::DoubleClick => Some(HoverEventFilter::DoubleClick),
3225 Self::LongPress => Some(HoverEventFilter::LongPress),
3226 Self::SwipeLeft => Some(HoverEventFilter::SwipeLeft),
3227 Self::SwipeRight => Some(HoverEventFilter::SwipeRight),
3228 Self::SwipeUp => Some(HoverEventFilter::SwipeUp),
3229 Self::SwipeDown => Some(HoverEventFilter::SwipeDown),
3230 Self::PinchIn => Some(HoverEventFilter::PinchIn),
3231 Self::PinchOut => Some(HoverEventFilter::PinchOut),
3232 Self::RotateClockwise => Some(HoverEventFilter::RotateClockwise),
3233 Self::RotateCounterClockwise => Some(HoverEventFilter::RotateCounterClockwise),
3234 // Window-specific events with no hover equivalent
3235 Self::DpiChanged => None,
3236 Self::MonitorChanged => None,
3237 // Async capability outcomes — mirror to the hover twin
3238 Self::PermissionChanged => Some(HoverEventFilter::PermissionChanged),
3239 Self::BiometricResult => Some(HoverEventFilter::BiometricResult),
3240 Self::ScreenColorPicked => Some(HoverEventFilter::ScreenColorPicked),
3241 Self::KeyringResult => Some(HoverEventFilter::KeyringResult),
3242 Self::BackMouseDown => Some(HoverEventFilter::BackMouseDown),
3243 Self::BackMouseUp => Some(HoverEventFilter::BackMouseUp),
3244 Self::ForwardMouseDown => Some(HoverEventFilter::ForwardMouseDown),
3245 Self::ForwardMouseUp => Some(HoverEventFilter::ForwardMouseUp),
3246 // Window-only: no position to hit-test, so no hover twin.
3247 Self::RawMouseMotion | Self::ModifiersChanged | Self::HidReport => None,
3248 // Media state (11c) has no hover twin either: it is reported by an
3249 // external player, and there is no pointer position that could
3250 // decide which node it "happened over".
3251 Self::Play
3252 | Self::Pause
3253 | Self::Ended
3254 | Self::TimeUpdate
3255 | Self::VolumeChange
3256 | Self::MediaError => None,
3257 }
3258 }
3259}
3260
3261/// Defines events related to the lifecycle of a DOM node itself.
3262#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3263#[repr(C)]
3264pub enum ComponentEventFilter {
3265 /// Fired after the component is first mounted into the DOM.
3266 AfterMount,
3267 /// Fired just before the component is removed from the DOM.
3268 BeforeUnmount,
3269 /// Fired when the node's layout rectangle has been resized.
3270 NodeResized,
3271 /// Fired to trigger the default action for an accessibility component.
3272 DefaultAction,
3273 /// Fired when the component becomes selected.
3274 Selected,
3275 /// Fired when a keyed component's content has changed (props/state update).
3276 Updated,
3277 /// Fired on a `<transient-window>` the user dismissed (outside click or
3278 /// Escape). The popup is closed by the engine regardless; this is where
3279 /// the app clears its own `open` state so the next layout agrees.
3280 Dismissed,
3281 /// Fired on a `<transient-window>` the user tore off its anchor by
3282 /// dragging its `-azul-app-region: drag` strip; it is a free toplevel
3283 /// now. The event's `current_bounds` is its rect in the parent.
3284 TornOff,
3285 /// Fired on a torn-off `<transient-window>` the user docked back - onto
3286 /// its anchor, or onto a `tearoff-zone` node, which is its anchor from
3287 /// here on.
3288 Docked,
3289}
3290
3291/// Defines application-level events not tied to a specific window or node.
3292#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3293#[repr(C)]
3294pub enum ApplicationEventFilter {
3295 /// Fired when a new hardware device is connected.
3296 DeviceConnected,
3297 /// Fired when a hardware device is disconnected.
3298 DeviceDisconnected,
3299 /// Fired when a new monitor/display is connected to the system.
3300 /// Callback receives updated monitor list via `CallbackInfo::get_monitors()`.
3301 MonitorConnected,
3302 /// Fired when a monitor/display is disconnected from the system.
3303 MonitorDisconnected,
3304 /// The platform's media controls asked for a seek (9h-i-a-i-a). APPENDED
3305 /// at the end for ABI stability.
3306 MediaControl,
3307 /// The system changed the app's hold on the audio (9h-i-a-i-d-i).
3308 SystemAudioChange,
3309}
3310
3311/// Events reported by something OUTSIDE the input pipeline (11c).
3312///
3313/// The other four families answer "where did this land?" — hovered, focused,
3314/// window-wide, on this component. A media player answers none of those: the
3315/// state change happens in an external player (a decoder thread, a platform
3316/// media service, the app's own transport calls) and is then reported IN. It
3317/// is the same shape as a monitor being unplugged, one level closer to the
3318/// app.
3319///
3320/// Dispatched like a window filter: every node carrying a matching callback
3321/// is invoked, and the event's `target` names the media node so a callback
3322/// serving several players can tell them apart.
3323#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
3324#[repr(C)]
3325pub enum ExternalEventFilter {
3326 /// Media playback started.
3327 Play,
3328 /// Media playback paused.
3329 Pause,
3330 /// Media playback reached the end of its known duration. Fires exactly
3331 /// once per arrival at the end (reaching it also stops the transport).
3332 Ended,
3333 /// The media position advanced. THROTTLED to one event per 250 ms of
3334 /// media time (`managers::media_player::TIME_UPDATE_INTERVAL_S`) — the
3335 /// web's ~4/s budget — because a playing video would otherwise raise one
3336 /// per frame for its entire length.
3337 TimeUpdate,
3338 /// The media volume or mute state changed.
3339 VolumeChange,
3340 /// The media pipeline reported a failure; the transport is stopped.
3341 MediaError,
3342}
3343
3344/// Sets the target for what events can reach the callbacks specifically.
3345///
3346/// This determines the condition under which an event is fired, such as whether
3347/// the node is hovered, focused, or if the event is window-global.
3348#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
3349#[repr(C, u8)]
3350pub enum EventFilter {
3351 /// Calls the attached callback when the mouse is actively over the
3352 /// given element.
3353 Hover(HoverEventFilter),
3354 /// Calls the attached callback when the element is currently focused.
3355 Focus(FocusEventFilter),
3356 /// Calls the callback when anything related to the window is happening.
3357 /// The "hit item" will be the root item of the DOM.
3358 /// For example, this can be useful for tracking the mouse position
3359 /// (in relation to the window). In difference to `Desktop`, this only
3360 /// fires when the window is focused.
3361 ///
3362 /// This can also be good for capturing controller input, touch input
3363 /// (i.e. global gestures that aren't attached to any component, but rather
3364 /// the "window" itself).
3365 Window(WindowEventFilter),
3366 /// API stub: Something happened with the node itself (node resized, created or removed).
3367 Component(ComponentEventFilter),
3368 /// Something happened with the application (started, shutdown, device plugged in).
3369 Application(ApplicationEventFilter),
3370 /// Something was reported by an external source that the hit test cannot
3371 /// place — media playback state, today. APPENDED at the end for ABI
3372 /// stability (`EventFilter` is `repr(C, u8)`; a variant inserted anywhere
3373 /// but the tail would renumber every discriminant after it).
3374 External(ExternalEventFilter),
3375}
3376
3377impl EventFilter {
3378 #[must_use]
3379 pub const fn is_focus_callback(&self) -> bool {
3380 matches!(self, Self::Focus(_))
3381 }
3382 #[must_use]
3383 pub const fn is_window_callback(&self) -> bool {
3384 matches!(self, Self::Window(_))
3385 }
3386}
3387
3388/// Creates a function inside an impl <enum type> block that returns a single
3389/// variant if the enum is that variant.
3390macro_rules! get_single_enum_type {
3391 ($fn_name:ident, $enum_name:ident:: $variant:ident($return_type:ty)) => {
3392 #[must_use]
3393 pub const fn $fn_name(&self) -> Option<$return_type> {
3394 use self::$enum_name::*;
3395 match self {
3396 $variant(e) => Some(*e),
3397 _ => None,
3398 }
3399 }
3400 };
3401}
3402
3403impl EventFilter {
3404 get_single_enum_type!(as_hover_event_filter, EventFilter::Hover(HoverEventFilter));
3405 get_single_enum_type!(as_focus_event_filter, EventFilter::Focus(FocusEventFilter));
3406 get_single_enum_type!(
3407 as_window_event_filter,
3408 EventFilter::Window(WindowEventFilter)
3409 );
3410}
3411
3412/// Convert from `On` enum to `EventFilter`.
3413///
3414/// This determines which specific filter variant is used based on the event type.
3415/// For example, `On::TextInput` becomes a Focus event filter, while `On::VirtualKeyDown`
3416/// becomes a Window event filter (since it's global to the window).
3417impl From<On> for EventFilter {
3418 // Exhaustive On -> EventFilter mapping table; the a11y events (Default/Collapse/
3419 // Expand/Increment/Decrement) all map to MouseUp ("click") as intentional 1:1
3420 // documented rows — merging would drop the per-row rationale comments.
3421 #[allow(clippy::match_same_arms)]
3422 fn from(input: On) -> Self {
3423 use crate::dom::On::{
3424 Collapse, Decrement, Default, DroppedFile, Expand, FocusLost, FocusReceived,
3425 HoveredFile, HoveredFileCancelled, Increment, LeftMouseDown, LeftMouseUp,
3426 MiddleMouseDown, MiddleMouseUp, MouseDown, MouseEnter, MouseLeave, MouseMove,
3427 MouseOver, MouseUp, RightMouseDown, RightMouseUp, Scroll, TextInput, VirtualKeyDown,
3428 VirtualKeyUp,
3429 };
3430 match input {
3431 MouseOver => Self::Hover(HoverEventFilter::MouseOver),
3432 MouseMove => Self::Hover(HoverEventFilter::MouseMove),
3433 MouseDown => Self::Hover(HoverEventFilter::MouseDown),
3434 LeftMouseDown => Self::Hover(HoverEventFilter::LeftMouseDown),
3435 MiddleMouseDown => Self::Hover(HoverEventFilter::MiddleMouseDown),
3436 RightMouseDown => Self::Hover(HoverEventFilter::RightMouseDown),
3437 On::Click => Self::Hover(HoverEventFilter::Click),
3438 MouseUp => Self::Hover(HoverEventFilter::MouseUp),
3439 LeftMouseUp => Self::Hover(HoverEventFilter::LeftMouseUp),
3440 MiddleMouseUp => Self::Hover(HoverEventFilter::MiddleMouseUp),
3441 RightMouseUp => Self::Hover(HoverEventFilter::RightMouseUp),
3442
3443 MouseEnter => Self::Hover(HoverEventFilter::MouseEnter),
3444 MouseLeave => Self::Hover(HoverEventFilter::MouseLeave),
3445 Scroll => Self::Hover(HoverEventFilter::Scroll),
3446 TextInput => Self::Focus(FocusEventFilter::TextInput), // focus!
3447 On::DocumentEdit => Self::Focus(FocusEventFilter::DocumentEdit), // focus!
3448 On::TextChanged => Self::Focus(FocusEventFilter::TextChanged), // focus!
3449 VirtualKeyDown => Self::Window(WindowEventFilter::VirtualKeyDown), // window!
3450 VirtualKeyUp => Self::Window(WindowEventFilter::VirtualKeyUp), // window!
3451 HoveredFile => Self::Hover(HoverEventFilter::HoveredFile),
3452 DroppedFile => Self::Hover(HoverEventFilter::DroppedFile),
3453 HoveredFileCancelled => Self::Hover(HoverEventFilter::HoveredFileCancelled),
3454 FocusReceived => Self::Focus(FocusEventFilter::FocusReceived), // focus!
3455 FocusLost => Self::Focus(FocusEventFilter::FocusLost), // focus!
3456
3457 // Accessibility events are ACTIVATIONS, so they map to `Click` -
3458 // the same filter a pointer click and a keyboard Enter/Space
3459 // reach. They used to map to `MouseUp`, which conflated
3460 // activation with a raw pointer release.
3461 Default => Self::Hover(HoverEventFilter::Click),
3462 Collapse => Self::Hover(HoverEventFilter::Click),
3463 Expand => Self::Hover(HoverEventFilter::Click),
3464 Increment => Self::Hover(HoverEventFilter::Click),
3465 Decrement => Self::Hover(HoverEventFilter::Click),
3466 }
3467 }
3468}
3469
3470// Cross-Platform Event Dispatch System
3471// NOTE: The old dispatch_synthetic_events / CallbackTarget / CallbackToInvoke / EventDispatchResult
3472// pipeline has been removed. Event dispatch now goes through dispatch_events_propagated() in
3473// event_v2.rs which uses propagate_event() for W3C Capture→Target→Bubble propagation.
3474
3475/// Trait for managers to provide their pending events.
3476///
3477/// Each manager (`TextInputManager`, `ScrollManager`, etc.) implements this to
3478/// report what events occurred since the last frame. This enables a unified,
3479/// lazy event determination system.
3480pub trait EventProvider {
3481 /// Get all pending events from this manager.
3482 ///
3483 /// Events should include:
3484 ///
3485 /// - `target`: The `DomNodeId` that was affected
3486 /// - `event_type`: What happened (Input, Scroll, Focus, etc.)
3487 /// - `source`: `EventSource::User` for input, `EventSource::Programmatic` for API calls
3488 /// - `data`: Type-specific event data
3489 ///
3490 /// After calling this, the manager should mark events as "read" so they
3491 /// aren't returned again next frame.
3492 fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent>;
3493}
3494
3495/// Deduplicate synthetic events by (target node, event type).
3496///
3497/// Groups by (target.dom, target.node, `event_type`), keeping the latest timestamp.
3498#[must_use]
3499pub fn deduplicate_synthetic_events(mut events: Vec<SyntheticEvent>) -> Vec<SyntheticEvent> {
3500 if events.len() <= 1 {
3501 return events;
3502 }
3503
3504 // The pointer SEAT is part of the key (9b-ii-b): two cursors pressing the
3505 // same node are two presses, not one. Non-pointer events key on the
3506 // primary and coalesce exactly as before.
3507 let seat_of = |e: &SyntheticEvent| match &e.data {
3508 EventData::Mouse(m) => m.seat_id,
3509 EventData::Scroll(s) => s.seat_id,
3510 EventData::Touch(t) => t.seat_id,
3511 EventData::Composition(c) => c.seat_id,
3512 _ => crate::window::PRIMARY_POINTER_SEAT,
3513 };
3514 events.sort_by_key(|e| (e.target.dom, e.target.node, e.event_type, seat_of(e)));
3515
3516 // Coalesce consecutive events with same target, event_type and seat
3517 let mut result = Vec::with_capacity(events.len());
3518 let mut iter = events.into_iter();
3519
3520 if let Some(mut prev) = iter.next() {
3521 for curr in iter {
3522 if prev.target == curr.target
3523 && prev.event_type == curr.event_type
3524 && seat_of(&prev) == seat_of(&curr)
3525 {
3526 // Keep the one with later timestamp
3527 prev = if curr.timestamp > prev.timestamp {
3528 curr
3529 } else {
3530 prev
3531 };
3532 } else {
3533 result.push(prev);
3534 prev = curr;
3535 }
3536 }
3537 result.push(prev);
3538 }
3539
3540 result
3541}
3542
3543/// Every `HoverEventFilter` variant, so planning can be derived from matching.
3544static ALL_HOVER: &[HoverEventFilter] = &[
3545 HoverEventFilter::MouseOver,
3546 HoverEventFilter::MouseMove,
3547 HoverEventFilter::MouseDown,
3548 HoverEventFilter::LeftMouseDown,
3549 HoverEventFilter::RightMouseDown,
3550 HoverEventFilter::MiddleMouseDown,
3551 HoverEventFilter::Click,
3552 HoverEventFilter::MouseUp,
3553 HoverEventFilter::LeftMouseUp,
3554 HoverEventFilter::RightMouseUp,
3555 HoverEventFilter::MiddleMouseUp,
3556 HoverEventFilter::MouseEnter,
3557 HoverEventFilter::MouseLeave,
3558 HoverEventFilter::Scroll,
3559 HoverEventFilter::ScrollStart,
3560 HoverEventFilter::ScrollEnd,
3561 HoverEventFilter::TextInput,
3562 HoverEventFilter::VirtualKeyDown,
3563 HoverEventFilter::VirtualKeyUp,
3564 HoverEventFilter::HoveredFile,
3565 HoverEventFilter::DroppedFile,
3566 HoverEventFilter::HoveredFileCancelled,
3567 HoverEventFilter::TouchStart,
3568 HoverEventFilter::TouchMove,
3569 HoverEventFilter::TouchEnd,
3570 HoverEventFilter::TouchCancel,
3571 HoverEventFilter::PenDown,
3572 HoverEventFilter::PenMove,
3573 HoverEventFilter::PenUp,
3574 HoverEventFilter::PenEnter,
3575 HoverEventFilter::PenLeave,
3576 HoverEventFilter::PenSqueeze,
3577 HoverEventFilter::PenDoubleTap,
3578 HoverEventFilter::PenHover,
3579 HoverEventFilter::GeolocationFix,
3580 HoverEventFilter::GeolocationError,
3581 HoverEventFilter::SensorChanged,
3582 HoverEventFilter::GamepadInput,
3583 HoverEventFilter::DragStart,
3584 HoverEventFilter::Drag,
3585 HoverEventFilter::DragEnd,
3586 HoverEventFilter::DragEnter,
3587 HoverEventFilter::DragOver,
3588 HoverEventFilter::DragLeave,
3589 HoverEventFilter::Drop,
3590 HoverEventFilter::DoubleClick,
3591 HoverEventFilter::LongPress,
3592 HoverEventFilter::SwipeLeft,
3593 HoverEventFilter::SwipeRight,
3594 HoverEventFilter::SwipeUp,
3595 HoverEventFilter::SwipeDown,
3596 HoverEventFilter::PinchIn,
3597 HoverEventFilter::PinchOut,
3598 HoverEventFilter::RotateClockwise,
3599 HoverEventFilter::RotateCounterClockwise,
3600 HoverEventFilter::MouseOut,
3601 HoverEventFilter::FocusIn,
3602 HoverEventFilter::FocusOut,
3603 HoverEventFilter::CompositionStart,
3604 HoverEventFilter::CompositionUpdate,
3605 HoverEventFilter::CompositionEnd,
3606 HoverEventFilter::SystemTextSingleClick,
3607 HoverEventFilter::SystemTextDoubleClick,
3608 HoverEventFilter::SystemTextTripleClick,
3609 HoverEventFilter::PermissionChanged,
3610 HoverEventFilter::BiometricResult,
3611 HoverEventFilter::ScreenColorPicked,
3612 HoverEventFilter::KeyringResult,
3613 // Form events. These have matcher arms and planning arms, but were absent
3614 // from THIS list — and planning is DERIVED by probing it, so a filter the
3615 // list does not name can never be planned and dispatches to nothing. Same
3616 // dead-filter shape the arc exists to close, reintroduced by the arc.
3617 HoverEventFilter::Submit,
3618 HoverEventFilter::Change,
3619 HoverEventFilter::Reset,
3620 HoverEventFilter::Invalid,
3621 // Planning is DERIVED by probing this list, so a filter missing from it
3622 // can never be planned — the failure this arc has hit repeatedly.
3623 HoverEventFilter::DialRotate,
3624 HoverEventFilter::DialClick,
3625];
3626
3627/// Every `FocusEventFilter` variant, so planning can be derived from matching.
3628static ALL_FOCUS: &[FocusEventFilter] = &[
3629 FocusEventFilter::MouseOver,
3630 FocusEventFilter::MouseMove,
3631 FocusEventFilter::MouseDown,
3632 FocusEventFilter::LeftMouseDown,
3633 FocusEventFilter::RightMouseDown,
3634 FocusEventFilter::MiddleMouseDown,
3635 FocusEventFilter::MouseUp,
3636 FocusEventFilter::LeftMouseUp,
3637 FocusEventFilter::RightMouseUp,
3638 FocusEventFilter::MiddleMouseUp,
3639 FocusEventFilter::MouseEnter,
3640 FocusEventFilter::MouseLeave,
3641 FocusEventFilter::Scroll,
3642 FocusEventFilter::ScrollStart,
3643 FocusEventFilter::ScrollEnd,
3644 FocusEventFilter::TextInput,
3645 FocusEventFilter::VirtualKeyDown,
3646 FocusEventFilter::VirtualKeyUp,
3647 FocusEventFilter::FocusReceived,
3648 FocusEventFilter::FocusLost,
3649 FocusEventFilter::PenDown,
3650 FocusEventFilter::PenMove,
3651 FocusEventFilter::PenUp,
3652 FocusEventFilter::DragStart,
3653 FocusEventFilter::Drag,
3654 FocusEventFilter::DragEnd,
3655 FocusEventFilter::DragEnter,
3656 FocusEventFilter::DragOver,
3657 FocusEventFilter::DragLeave,
3658 FocusEventFilter::Drop,
3659 FocusEventFilter::DoubleClick,
3660 FocusEventFilter::LongPress,
3661 FocusEventFilter::SwipeLeft,
3662 FocusEventFilter::SwipeRight,
3663 FocusEventFilter::SwipeUp,
3664 FocusEventFilter::SwipeDown,
3665 FocusEventFilter::PinchIn,
3666 FocusEventFilter::PinchOut,
3667 FocusEventFilter::RotateClockwise,
3668 FocusEventFilter::RotateCounterClockwise,
3669 FocusEventFilter::FocusIn,
3670 FocusEventFilter::FocusOut,
3671 FocusEventFilter::CompositionStart,
3672 FocusEventFilter::CompositionUpdate,
3673 FocusEventFilter::CompositionEnd,
3674 FocusEventFilter::Copy,
3675 FocusEventFilter::Cut,
3676 FocusEventFilter::Paste,
3677 FocusEventFilter::DocumentEdit,
3678 FocusEventFilter::TextChanged,
3679 // Form events. These have matcher arms and planning arms, but were absent
3680 // from THIS list — and planning is DERIVED by probing it, so a filter the
3681 // list does not name can never be planned and dispatches to nothing. Same
3682 // dead-filter shape the arc exists to close, reintroduced by the arc.
3683 FocusEventFilter::Submit,
3684 FocusEventFilter::Change,
3685 FocusEventFilter::Reset,
3686 FocusEventFilter::Invalid,
3687];
3688
3689/// Every `WindowEventFilter` variant, so planning can be derived from matching.
3690static ALL_WINDOW: &[WindowEventFilter] = &[
3691 WindowEventFilter::MouseOver,
3692 WindowEventFilter::MouseMove,
3693 WindowEventFilter::MouseDown,
3694 WindowEventFilter::LeftMouseDown,
3695 WindowEventFilter::RightMouseDown,
3696 WindowEventFilter::MiddleMouseDown,
3697 WindowEventFilter::MouseUp,
3698 WindowEventFilter::LeftMouseUp,
3699 WindowEventFilter::RightMouseUp,
3700 WindowEventFilter::MiddleMouseUp,
3701 WindowEventFilter::MouseEnter,
3702 WindowEventFilter::MouseLeave,
3703 WindowEventFilter::Scroll,
3704 WindowEventFilter::ScrollStart,
3705 WindowEventFilter::ScrollEnd,
3706 WindowEventFilter::TextInput,
3707 WindowEventFilter::VirtualKeyDown,
3708 WindowEventFilter::VirtualKeyUp,
3709 WindowEventFilter::HoveredFile,
3710 WindowEventFilter::DroppedFile,
3711 WindowEventFilter::HoveredFileCancelled,
3712 WindowEventFilter::Resized,
3713 WindowEventFilter::Moved,
3714 WindowEventFilter::FrameChanged,
3715 WindowEventFilter::TouchStart,
3716 WindowEventFilter::TouchMove,
3717 WindowEventFilter::TouchEnd,
3718 WindowEventFilter::TouchCancel,
3719 WindowEventFilter::FocusReceived,
3720 WindowEventFilter::FocusLost,
3721 WindowEventFilter::CloseRequested,
3722 WindowEventFilter::ThemeChanged,
3723 WindowEventFilter::WindowFocusReceived,
3724 WindowEventFilter::WindowFocusLost,
3725 WindowEventFilter::PointerLockChange,
3726 WindowEventFilter::PenDown,
3727 WindowEventFilter::PenMove,
3728 WindowEventFilter::PenUp,
3729 WindowEventFilter::PenEnter,
3730 WindowEventFilter::PenLeave,
3731 WindowEventFilter::PenSqueeze,
3732 WindowEventFilter::PenDoubleTap,
3733 WindowEventFilter::PenHover,
3734 WindowEventFilter::GeolocationFix,
3735 WindowEventFilter::GeolocationError,
3736 WindowEventFilter::SensorChanged,
3737 WindowEventFilter::GamepadInput,
3738 WindowEventFilter::DragStart,
3739 WindowEventFilter::Drag,
3740 WindowEventFilter::DragEnd,
3741 WindowEventFilter::DragEnter,
3742 WindowEventFilter::DragOver,
3743 WindowEventFilter::DragLeave,
3744 WindowEventFilter::Drop,
3745 WindowEventFilter::DoubleClick,
3746 WindowEventFilter::LongPress,
3747 WindowEventFilter::SwipeLeft,
3748 WindowEventFilter::SwipeRight,
3749 WindowEventFilter::SwipeUp,
3750 WindowEventFilter::SwipeDown,
3751 WindowEventFilter::PinchIn,
3752 WindowEventFilter::PinchOut,
3753 WindowEventFilter::RotateClockwise,
3754 WindowEventFilter::RotateCounterClockwise,
3755 WindowEventFilter::DpiChanged,
3756 WindowEventFilter::MonitorChanged,
3757 WindowEventFilter::PermissionChanged,
3758 WindowEventFilter::BiometricResult,
3759 WindowEventFilter::ScreenColorPicked,
3760 WindowEventFilter::KeyringResult,
3761 WindowEventFilter::DialRotate,
3762 WindowEventFilter::DialClick,
3763 // Media (11c). Planning is DERIVED by probing this list, so a filter the
3764 // list does not name can never be planned, whatever its matcher says.
3765 WindowEventFilter::Play,
3766 WindowEventFilter::Pause,
3767 WindowEventFilter::Ended,
3768 WindowEventFilter::TimeUpdate,
3769 WindowEventFilter::VolumeChange,
3770 WindowEventFilter::MediaError,
3771];
3772
3773/// Every `ComponentEventFilter` variant, so planning can be derived from
3774/// matching. This is the family the derivation FORGOT when it replaced the
3775/// hand-written table: with no component probe, `Mount` planned zero filters
3776/// and every `AfterMount` / `Updated` / `NodeResized` / `Dismissed` /
3777/// `TornOff` / `Docked` callback in the engine went silent — the reconcile
3778/// diff kept emitting the events, the dispatcher's `Component` arm simply
3779/// never saw one (`headless_lifecycle` caught it: mount=0 on the first frame).
3780/// `DefaultAction` and `Selected` have no event type, so probing them is a
3781/// no-op today; they are listed so the table stays the whole enum.
3782static ALL_COMPONENT: &[ComponentEventFilter] = &[
3783 ComponentEventFilter::AfterMount,
3784 ComponentEventFilter::BeforeUnmount,
3785 ComponentEventFilter::NodeResized,
3786 ComponentEventFilter::DefaultAction,
3787 ComponentEventFilter::Selected,
3788 ComponentEventFilter::Updated,
3789 ComponentEventFilter::Dismissed,
3790 ComponentEventFilter::TornOff,
3791 ComponentEventFilter::Docked,
3792];
3793
3794
3795/// Every `ExternalEventFilter`, for planning to probe. See [`ALL_COMPONENT`].
3796static ALL_EXTERNAL: &[ExternalEventFilter] = &[
3797 ExternalEventFilter::Play,
3798 ExternalEventFilter::Pause,
3799 ExternalEventFilter::Ended,
3800 ExternalEventFilter::TimeUpdate,
3801 ExternalEventFilter::VolumeChange,
3802 ExternalEventFilter::MediaError,
3803];
3804
3805/// Every `ApplicationEventFilter`, for planning to probe. See [`ALL_COMPONENT`].
3806static ALL_APPLICATION: &[ApplicationEventFilter] = &[
3807 ApplicationEventFilter::DeviceConnected,
3808 ApplicationEventFilter::DeviceDisconnected,
3809 ApplicationEventFilter::MonitorConnected,
3810 ApplicationEventFilter::MonitorDisconnected,
3811 ApplicationEventFilter::MediaControl,
3812 ApplicationEventFilter::SystemAudioChange,
3813];
3814
3815/// Which listeners an event should reach.
3816///
3817/// DERIVED from `matches_filter_phase`, the phase-matching table, so the two
3818/// can no longer disagree. They used to be two hand-written tables that had
3819/// to be kept in step by discipline, and an exhaustive cross-product found 61
3820/// pairs where they had drifted - in BOTH directions. A filter the matcher
3821/// accepts but planning never emitted produced a listener that could never
3822/// fire (pen enter/leave, scroll start/end, every focus-scoped drag event);
3823/// a filter planned but not matched collected a callback and dropped it. The
3824/// visible symptoms were Enter/Space activating nothing and, briefly, a
3825/// pointer click activating a control TWICE.
3826///
3827/// Dispatch sites use this to decide which callbacks to COLLECT and then ask
3828/// the matcher again per callback, so "collected" and "fired" now answer to
3829/// one table.
3830///
3831/// The universe is the four `ALL_*` tables - Hover, Focus, Window, Component
3832/// - and it has to be ALL of them: a family missing from the probe is a whole
3833/// class of listeners that can never fire, and nothing else in the pipeline
3834/// notices (the events are still produced, the callbacks still registered).
3835/// That is how every lifecycle callback went dead when the derivation first
3836/// shipped without `ALL_COMPONENT`; the cross-product test
3837/// `planning_and_matching_agree_for_every_event_and_filter` now covers all
3838/// four families so that the next omission fails there.
3839///
3840/// Cost: one pass over the filter universe per EVENT (not per node, not per
3841/// callback) - a few hundred `match` arms, nanoseconds, and it happens once
3842/// where the old table was also built once.
3843#[must_use]
3844pub fn event_type_to_filters(event_type: EventType, event_data: &EventData) -> Vec<EventFilter> {
3845 // Built through the public constructor so a new field on `SyntheticEvent`
3846 // cannot silently change what planning probes with.
3847 let probe = SyntheticEvent::new(
3848 event_type,
3849 EventSource::User,
3850 DomNodeId {
3851 dom: DomId::ROOT_ID,
3852 node: crate::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
3853 },
3854 crate::task::Instant::Tick(crate::task::SystemTick::new(0)),
3855 event_data.clone(),
3856 );
3857
3858 let mut out = Vec::new();
3859 for f in ALL_HOVER {
3860 if matches_filter_phase(EventFilter::Hover(*f), &probe, EventPhase::Bubble) {
3861 out.push(EventFilter::Hover(*f));
3862 }
3863 }
3864 for f in ALL_FOCUS {
3865 if matches_filter_phase(EventFilter::Focus(*f), &probe, EventPhase::Bubble) {
3866 out.push(EventFilter::Focus(*f));
3867 }
3868 }
3869 for f in ALL_WINDOW {
3870 if matches_filter_phase(EventFilter::Window(*f), &probe, EventPhase::Bubble) {
3871 out.push(EventFilter::Window(*f));
3872 }
3873 }
3874 // Component and Application were simply not probed here. Their matcher
3875 // arms (`matches_component_filter` / `matches_application_filter`) were
3876 // correct — but planning never asked them anything, so NO lifecycle or
3877 // application event could ever be planned, and every
3878 // `EventFilter::Component(..)` callback in every app was dead.
3879 for f in ALL_COMPONENT {
3880 if matches_filter_phase(EventFilter::Component(*f), &probe, EventPhase::Bubble) {
3881 out.push(EventFilter::Component(*f));
3882 }
3883 }
3884 for f in ALL_APPLICATION {
3885 if matches_filter_phase(EventFilter::Application(*f), &probe, EventPhase::Bubble) {
3886 out.push(EventFilter::Application(*f));
3887 }
3888 }
3889 for f in ALL_EXTERNAL {
3890 if matches_filter_phase(EventFilter::External(*f), &probe, EventPhase::Bubble) {
3891 out.push(EventFilter::External(*f));
3892 }
3893 }
3894 out
3895}
3896
3897// Internal System Event Processing
3898
3899/// Framework-determined side effects (system changes).
3900///
3901/// Unlike `CallbackChange` (from user callbacks), these are determined by the
3902/// framework's event analysis: hit tests, gesture detection, focus rules,
3903/// text selection, keyboard shortcuts, etc.
3904///
3905/// Both `CallbackChange` (user) and `SystemChange` (framework) are processed
3906/// through exhaustive match on `PlatformWindowV2` — adding a new variant
3907/// causes a compile error in `apply_system_change()`.
3908#[derive(Debug, Clone, PartialEq, Eq)]
3909#[must_use = "SystemChange must be processed through apply_system_change()"]
3910pub enum SystemChange {
3911 /// Advance layout animations by an EXACT step, bypassing the wall clock.
3912 ///
3913 /// Only the E2E surface emits this. A headless scenario cannot sample real
3914 /// time: the same test would land on a different point of the curve on a
3915 /// fast machine than a slow one, so any assertion mid-flight would be
3916 /// flaky. Stepping by a fixed `dt` makes the trajectory a pure function of
3917 /// how many times this ran.
3918 ///
3919 /// A `SystemChange` rather than a direct call because `CallbackInfo` hands
3920 /// out `&LayoutWindow` only — mutation is required to go through this
3921 /// channel, which is also where the mutable window actually exists.
3922 // === Text Selection ===
3923
3924 /// Process a mouse click for text selection (single/double/triple click).
3925 TextSelectionClick {
3926 position: LogicalPosition,
3927 timestamp: Instant,
3928 },
3929 /// Extend text selection via mouse drag.
3930 TextSelectionDrag {
3931 start_position: LogicalPosition,
3932 current_position: LogicalPosition,
3933 },
3934 /// Unified selection operation: cursor movement, selection extension, or deletion.
3935 ///
3936 /// Replaces the old `ArrowKeyNavigation` and `DeleteTextSelection` variants.
3937 /// Every keyboard shortcut maps to a single `SelectionOp` — see its docs.
3938 ApplySelectionOp {
3939 target: DomNodeId,
3940 op: SelectionOp,
3941 /// The SEAT whose key this was (9b-ii-a-i-d-ii-b): the op acts on that
3942 /// seat's caret. Seat 0 is the primary.
3943 seat_id: u64,
3944 },
3945
3946 // === Keyboard Shortcuts ===
3947 /// Copy selected text to system clipboard (Ctrl+C / Cmd+C).
3948 CopyToClipboard,
3949 /// Cut selected text to clipboard and delete (Ctrl+X / Cmd+X).
3950 CutToClipboard { target: DomNodeId },
3951 /// Paste text from system clipboard at cursor (Ctrl+V / Cmd+V).
3952 PasteFromClipboard,
3953 /// Select all text in focused node (Ctrl+A / Cmd+A).
3954 SelectAllText,
3955 /// Undo last text edit (Ctrl+Z / Cmd+Z).
3956 UndoTextEdit { target: DomNodeId },
3957 /// Redo last undone edit (Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z).
3958 RedoTextEdit { target: DomNodeId },
3959
3960 // === Multi-Cursor ===
3961 /// Add a cursor at the clicked position (Ctrl+Click).
3962 /// The position will be hit-tested to find the text cursor location.
3963 AddCursorAtClick { position: LogicalPosition },
3964 /// Select the next occurrence of the current selection's text (Ctrl+D).
3965 /// If the primary selection is a cursor, expand it to the word first.
3966 SelectNextOccurrence { target: DomNodeId },
3967 /// A NON-primary seat's editing shortcut (9b-ii-a-i-d-ii-b-i): Copy /
3968 /// Cut / Paste / Select-all / Undo / Redo on THAT seat's focus and caret.
3969 /// The primary's shortcuts stay the dedicated variants above.
3970 SeatShortcut {
3971 seat_id: u64,
3972 target: DomNodeId,
3973 shortcut: KeyboardShortcut,
3974 },
3975
3976 // === Text Input ===
3977 /// Apply pending text input from platform (keyboard/IME).
3978 ApplyPendingTextInput,
3979 /// Apply text changeset (incremental relayout).
3980 ApplyTextChangeset,
3981
3982 // === Drag & Drop ===
3983 /// Activate node drag on a draggable element.
3984 ActivateNodeDrag { dom_id: DomId, node_id: NodeId },
3985 /// Activate window drag (CSD titlebar).
3986 ActivateWindowDrag,
3987 /// Set up drag visual state (:dragging pseudo-state, GPU transform key).
3988 InitDragVisualState,
3989 /// Set :drag-over pseudo-state on a target node.
3990 SetDragOverState { target: DomNodeId, active: bool },
3991 /// Update current drop target in drag context.
3992 UpdateDropTarget { target: DomNodeId },
3993 /// Update GPU transform for active node drag.
3994 UpdateDragGpuTransform,
3995 /// End drag: clear pseudo-states, remove GPU keys, end drag session.
3996 DeactivateDrag,
3997
3998 // === Focus ===
3999 /// Move a NON-primary seat's focus (9b-ii-a-i-d): only that seat's
4000 /// entry changes - no `:focus` restyle, no caret / blink timer, no soft
4001 /// keyboard, which all follow the primary's focus (the text-edit session
4002 /// is one). A primary-seat change is `SetFocus`.
4003 SetSeatFocus {
4004 seat_id: u64,
4005 new_focus: Option<DomNodeId>,
4006 old_focus: Option<DomNodeId>,
4007 },
4008 /// Change focus to a new target (or clear focus if None).
4009 /// Handles: `set_focused_node`, `apply_focus_restyle`, `scroll_node_into_view`,
4010 /// `cursor_blink_timer` start/stop.
4011 SetFocus {
4012 new_focus: Option<DomNodeId>,
4013 old_focus: Option<DomNodeId>,
4014 /// Should this focus be INDICATED - the W3C `:focus-visible` question.
4015 ///
4016 /// It travels WITH the change because the handler restyles and
4017 /// regenerates the display list, and the focus ring is emitted by that
4018 /// regeneration. Setting the flag afterwards (which the shell used to
4019 /// do for the keyboard route) means the frame that was just built did
4020 /// not know focus was indicated, so the ring only appeared on whatever
4021 /// repaint happened NEXT - a click, a resize. On the device that read
4022 /// as "Tab does not ring anything until you click something", while the
4023 /// E2E harness - which happened to set the flag first - showed a ring
4024 /// and passed (device report, 2026-09-01).
4025 visible: bool,
4026 },
4027 /// Clear all text selections.
4028 ClearAllSelections,
4029 /// Finalize pending focus changes (cursor initialization after layout).
4030 FinalizePendingFocusChanges,
4031
4032 // === Scroll ===
4033 /// Scroll cursor/selection into view.
4034 ScrollSelectionIntoView,
4035 /// Scroll a specific node into view.
4036 ScrollNodeIntoView { target: DomNodeId },
4037 /// Scroll cursor into view after text input (needs relayout first).
4038 ScrollCursorIntoViewAfterTextInput,
4039
4040 // === Auto-Scroll Timer ===
4041 /// Start auto-scroll timer for drag-to-scroll (60Hz).
4042 StartAutoScrollTimer,
4043 /// Cancel auto-scroll timer.
4044 StopAutoScrollTimer,
4045}
4046
4047impl_option!(
4048 SystemChange,
4049 OptionSystemChange,
4050 copy = false,
4051 clone = false,
4052 [Debug, Clone, PartialEq, Eq]
4053);
4054
4055impl_vec!(
4056 SystemChange,
4057 SystemChangeVec,
4058 SystemChangeVecDestructor,
4059 SystemChangeVecDestructorType,
4060 SystemChangeVecSlice,
4061 OptionSystemChange
4062);
4063impl_vec_debug!(SystemChange, SystemChangeVec);
4064impl_vec_clone!(SystemChange, SystemChangeVec, SystemChangeVecDestructor);
4065impl_vec_partialeq!(SystemChange, SystemChangeVec);
4066
4067/// Result of pre-callback internal event filtering
4068#[derive(Debug, Clone, PartialEq)]
4069pub struct PreCallbackFilterResult {
4070 /// System changes to process BEFORE user callbacks
4071 pub system_changes: Vec<SystemChange>,
4072 /// Regular events that will be passed to user callbacks
4073 pub user_events: Vec<SyntheticEvent>,
4074}
4075
4076/// Flattened focus/selection state for the input interpreter (replaces trait objects).
4077#[derive(Debug, Clone, Copy)]
4078pub struct InputInterpreterState {
4079 pub focused_node: Option<DomNodeId>,
4080 pub click_count: u8,
4081 pub drag_start_position: Option<LogicalPosition>,
4082 pub has_selection: bool,
4083 /// Whether focus sits in a TEXT-EDITING context (a contenteditable host,
4084 /// a text input) - the only place arrow keys mean "move the caret".
4085 ///
4086 /// Without this the interpreter claimed every arrow key for a caret op
4087 /// and returned `AddAndSkip`, which SWALLOWS the event: it never reached
4088 /// user callbacks at all. A focused Slider or colour picker therefore
4089 /// could not implement arrow keys - the handler was never called (device
4090 /// report, 2026-09-01: "the arrow keys for sliders do not work at all,
4091 /// nor the four arrow keys for navigating the color gradient").
4092 pub focus_is_editable: bool,
4093}
4094
4095/// All context needed by the input interpreter to map events to system changes.
4096///
4097/// Passed to the interpreter callback. Contains references to the current
4098/// events and window state. The interpreter reads this and returns system changes.
4099#[derive(Debug)]
4100pub struct InputInterpreterInfo<'a> {
4101 pub events: &'a [SyntheticEvent],
4102 pub hit_test: Option<&'a FullHitTest>,
4103 pub keyboard_state: &'a crate::window::KeyboardState,
4104 pub mouse_state: &'a crate::window::MouseState,
4105 pub state: InputInterpreterState,
4106 /// The focused node of each NON-primary keyboard seat present in
4107 /// `events` (9b-ii-a-i-d), from `seat_focus_of_events`; a seat missing
4108 /// here resolves to the primary's `state.focused_node`.
4109 pub seat_focus: &'a [(u64, Option<DomNodeId>)],
4110}
4111
4112/// The `extern "C"` callback type for the input interpreter.
4113///
4114/// The first `RefAny` is the user data (vim mode, repeat counter, etc.)
4115/// held in `InputInterpreterCallback.ctx`. The `*const ()` is an opaque
4116/// pointer to `InputInterpreterInfo` — callers use the safe wrapper
4117/// methods to access event data. Returns a `PreCallbackFilterResult`.
4118///
4119/// For C/Python: the trampoline extracts the foreign callable from `RefAny.ctx`.
4120/// For Rust: use `InputInterpreterCallback::from(fn_ptr)` which sets ctx=None.
4121pub type InputInterpreterCallbackType = extern "C" fn(
4122 crate::refany::RefAny,
4123 *const InputInterpreterInfo<'static>, // Opaque; actual lifetime managed by caller
4124) -> PreCallbackFilterResult;
4125
4126/// Configurable input interpreter callback.
4127///
4128/// Maps raw platform events + window state → semantic `SystemChange` actions.
4129/// The default (`default_input_interpreter`) handles standard desktop keybindings.
4130/// Replace this on `LayoutWindow` to implement vim, game controls, etc.
4131///
4132/// ## Pattern
4133/// - **Rust**: `InputInterpreterCallback::from(my_fn_ptr)` — `ctx` is None
4134/// - **Python/C**: Set `cb` to a trampoline, `ctx` to `RefAny` wrapping the foreign callable
4135#[repr(C)]
4136pub struct InputInterpreterCallback {
4137 pub cb: InputInterpreterCallbackType,
4138 pub ctx: crate::refany::OptionRefAny,
4139}
4140
4141impl_callback!(InputInterpreterCallback, InputInterpreterCallbackType);
4142
4143impl Default for InputInterpreterCallback {
4144 fn default() -> Self {
4145 Self {
4146 cb: default_input_interpreter_extern,
4147 ctx: crate::refany::OptionRefAny::None,
4148 }
4149 }
4150}
4151
4152/// The `extern "C"` callback type for the post-callback filter.
4153pub type PostFilterCallbackType = extern "C" fn(
4154 crate::refany::RefAny,
4155 bool, // prevent_default
4156 SystemChangeVecSlice, // pre_changes (immutable slice)
4157 DomNodeId, // old_focus (0xFFFF = None)
4158 DomNodeId, // new_focus (0xFFFF = None)
4159) -> SystemChangeVec;
4160
4161/// Configurable post-callback filter.
4162#[repr(C)]
4163pub struct PostFilterCallback {
4164 pub cb: PostFilterCallbackType,
4165 pub ctx: crate::refany::OptionRefAny,
4166}
4167
4168impl_callback!(PostFilterCallback, PostFilterCallbackType);
4169
4170impl Default for PostFilterCallback {
4171 fn default() -> Self {
4172 Self {
4173 cb: default_post_filter_extern,
4174 ctx: crate::refany::OptionRefAny::None,
4175 }
4176 }
4177}
4178
4179/// What JSON type an op argument expects.
4180///
4181/// Spelled out rather than left to prose, because this is read by machines:
4182/// an agent choosing arguments and a UI validating a macro form both need the
4183/// type, not a sentence describing it.
4184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4185#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
4186#[cfg_attr(feature = "serde-json", serde(rename_all = "lowercase"))]
4187pub enum E2eOpArgType {
4188 String,
4189 Number,
4190 Bool,
4191 Object,
4192 Array,
4193 /// Any JSON value is acceptable.
4194 Any,
4195}
4196
4197/// One argument of one op.
4198#[derive(Debug, Clone, PartialEq, Eq, Default)]
4199#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
4200pub struct E2eOpArg {
4201 pub name: String,
4202 #[cfg_attr(feature = "serde-json", serde(rename = "type"))]
4203 pub arg_type: E2eOpArgType,
4204 pub required: bool,
4205 pub description: String,
4206}
4207
4208impl Default for E2eOpArgType {
4209 fn default() -> Self {
4210 Self::Any
4211 }
4212}
4213
4214/// A worked example: what to send, and what comes back.
4215///
4216/// Both halves matter. The arguments alone tell a caller how to invoke the op;
4217/// the RETURN tells it what it can then assert on, which is what a scenario
4218/// author and an agent each need before committing to a call.
4219#[derive(Debug, Clone, PartialEq, Default)]
4220#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
4221pub struct E2eOpExample {
4222 pub description: String,
4223 /// Example arguments — a real JSON value, not a string containing JSON.
4224 ///
4225 /// These were `String` first, which serialized as escaped JSON inside
4226 /// JSON: every consumer parsed twice and nothing checked the inner text
4227 /// was even well-formed, so a malformed example sat in the schema looking
4228 /// fine.
4229 pub args: crate::json::Json,
4230 /// What the op returns for those arguments.
4231 ///
4232 /// MUST contain a `success` boolean — validated when the schema is
4233 /// installed, see `E2eOpSchema::validate`.
4234 pub returns: crate::json::Json,
4235}
4236
4237/// One op the application answers.
4238#[derive(Debug, Clone, PartialEq, Default)]
4239#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
4240pub struct E2eOpDef {
4241 pub name: String,
4242 /// One line, for a list or a picker.
4243 pub summary: String,
4244 /// The long form, for a tooltip or an agent's context.
4245 pub description: String,
4246 pub args: Vec<E2eOpArg>,
4247 pub examples: Vec<E2eOpExample>,
4248}
4249
4250/// Everything an application advertises about its ops.
4251///
4252/// Built in memory as a normal Rust struct and serialized to `Json` at the
4253/// boundary — on BOTH sides, framework and application. The `Json` hop is a
4254/// deliberate interim bridge so the shape can be iterated on without an ABI
4255/// break each time; these types get exposed through api.json later and the
4256/// bridge goes away.
4257#[derive(Debug, Clone, PartialEq, Default)]
4258#[cfg_attr(feature = "serde-json", derive(serde::Serialize, serde::Deserialize))]
4259pub struct E2eOpSchema {
4260 pub ops: Vec<E2eOpDef>,
4261}
4262
4263/// Does this value have a top-level `success` boolean?
4264///
4265/// Deliberately requires a BOOLEAN, not merely the key: `"success": "yes"` and
4266/// `"success": null` are the shapes a hand-written schema actually produces,
4267/// and each would otherwise pass while telling a consumer nothing.
4268// Only the `not(serde-json)` arm is const-eligible (it is a literal `true`);
4269// the serde arm calls `to_serde_value`, which allocates. Marking the fn `const`
4270// would therefore stop compiling in the configuration that actually parses.
4271#[allow(clippy::missing_const_for_fn)]
4272fn json_has_success_bool(v: &crate::json::Json) -> bool {
4273 #[cfg(feature = "serde-json")]
4274 {
4275 v.to_serde_value()
4276 .get("success")
4277 .is_some_and(serde_json::Value::is_boolean)
4278 }
4279 #[cfg(not(feature = "serde-json"))]
4280 {
4281 // Without serde there is no parser here. Returning TRUE would silently
4282 // pass every schema; the honest fallback is a textual check that can
4283 // only reject things that are definitely wrong.
4284 let _ = v;
4285 true
4286 }
4287}
4288
4289/// Why an advertised schema is unusable.
4290#[derive(Debug, Clone, PartialEq, Eq)]
4291pub enum E2eSchemaError {
4292 /// An op has no name.
4293 UnnamedOp { index: usize },
4294 /// Two ops share a name, so dispatch by name is ambiguous.
4295 DuplicateOpName { name: String },
4296 /// An argument has no name, or no usable type.
4297 UnnamedArg { op: String, index: usize },
4298 /// An example's `returns` has no `success` boolean.
4299 ///
4300 /// The contract is that every op result says whether it worked. An
4301 /// example that omits it is advertising a result shape the runtime is
4302 /// required to reject.
4303 ExampleMissingSuccess { op: String, index: usize },
4304}
4305
4306impl core::fmt::Display for E2eSchemaError {
4307 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
4308 match self {
4309 Self::UnnamedOp { index } => write!(f, "op #{index} has an empty name"),
4310 Self::DuplicateOpName { name } => {
4311 write!(
4312 f,
4313 "two ops are both named '{name}'; dispatch would be ambiguous"
4314 )
4315 }
4316 Self::UnnamedArg { op, index } => {
4317 write!(f, "op '{op}' argument #{index} has an empty name")
4318 }
4319 Self::ExampleMissingSuccess { op, index } => write!(
4320 f,
4321 "op '{op}' example #{index}: `returns` has no `success` boolean. Every op \
4322 result must say whether it worked, or a failure is indistinguishable from a \
4323 success"
4324 ),
4325 }
4326 }
4327}
4328
4329impl E2eOpSchema {
4330 /// Check the schema is usable, BEFORE anything reads or dispatches it.
4331 ///
4332 /// Called when the schema is installed, not on first invocation. The whole
4333 /// point of advertising a schema is that a plugin, an MCP server or an
4334 /// agent reads it before calling anything — a schema that only proves
4335 /// malformed when an op is finally invoked has failed at its one job. An
4336 /// app shipping an unusable advertisement is a bug in the app, and its own
4337 /// startup is the cheapest place to catch it.
4338 ///
4339 /// # Errors
4340 ///
4341 /// Returns [`E2eSchemaError`] if the schema is malformed: a duplicate or
4342 /// empty op name, or an argument whose declared type is not a valid JSON
4343 /// type name.
4344 pub fn validate(&self) -> Result<(), E2eSchemaError> {
4345 let mut seen: Vec<&str> = Vec::new();
4346 for (i, op) in self.ops.iter().enumerate() {
4347 if op.name.trim().is_empty() {
4348 return Err(E2eSchemaError::UnnamedOp { index: i });
4349 }
4350 if seen.contains(&op.name.as_str()) {
4351 return Err(E2eSchemaError::DuplicateOpName {
4352 name: op.name.clone(),
4353 });
4354 }
4355 seen.push(op.name.as_str());
4356 for (a, arg) in op.args.iter().enumerate() {
4357 if arg.name.trim().is_empty() {
4358 return Err(E2eSchemaError::UnnamedArg {
4359 op: op.name.clone(),
4360 index: a,
4361 });
4362 }
4363 }
4364 for (e, ex) in op.examples.iter().enumerate() {
4365 if !json_has_success_bool(&ex.returns) {
4366 return Err(E2eSchemaError::ExampleMissingSuccess {
4367 op: op.name.clone(),
4368 index: e,
4369 });
4370 }
4371 }
4372 }
4373 Ok(())
4374 }
4375
4376 /// Serialize to the `Json` that crosses the C ABI.
4377 #[must_use]
4378 pub fn to_json(&self) -> crate::json::Json {
4379 #[cfg(feature = "serde-json")]
4380 {
4381 // Falling back to an empty-op object rather than to null: a
4382 // consumer must never be handed something that reads as "not
4383 // parseable" when the truth is "serialization failed".
4384 // serde_json preserves declaration order; `Json::parse` does NOT
4385 // (it re-serializes sorted, which buried `name` and `summary`
4386 // under `args`/`description`/`examples`). Keep the serialized text
4387 // and wrap it, rather than round-tripping through the parser.
4388 serde_json::to_string(self).ok().map_or_else(
4389 || crate::json::Json {
4390 value_type: crate::json::JsonType::Object,
4391 internal: crate::json::JsonInternal {
4392 string_value: AzString::from_const_str(r#"{"ops":[]}"#),
4393 ..Default::default()
4394 },
4395 },
4396 |text| crate::json::Json {
4397 value_type: crate::json::JsonType::Object,
4398 internal: crate::json::JsonInternal {
4399 string_value: AzString::from(text),
4400 ..Default::default()
4401 },
4402 },
4403 )
4404 }
4405 #[cfg(not(feature = "serde-json"))]
4406 crate::json::Json {
4407 value_type: crate::json::JsonType::Object,
4408 internal: crate::json::JsonInternal {
4409 string_value: AzString::from_const_str(r#"{"ops":[]}"#),
4410 ..Default::default()
4411 },
4412 }
4413 }
4414}
4415
4416/// Outcome of a user-defined E2E op.
4417#[repr(C)]
4418#[derive(Debug, Clone, PartialEq, Eq)]
4419pub struct CustomE2eOpResult {
4420 /// Whether the application RECOGNISED this op name.
4421 ///
4422 /// This is not "did it succeed" — it is "was this op mine at all". The
4423 /// debug server turns `false` into an explicit unknown-op error rather
4424 /// than an OK. Without it, a hook that returns a result for every name
4425 /// makes a typo'd op in a scenario indistinguishable from one that ran:
4426 /// non-assert ops produce no output of their own, so a silent success and
4427 /// a silent miss look identical from the outside.
4428 pub handled: bool,
4429 /// Result payload, JSON. Reported back to the scenario as-is, so a
4430 /// scenario can assert on it. Empty string is a valid empty result.
4431 pub json: AzString,
4432}
4433
4434impl Default for CustomE2eOpResult {
4435 /// "Not my op": `handled: false` with an empty payload — the value a
4436 /// bridge (e.g. the generated Python trampoline) returns when no user
4437 /// handler can run. Mirrors the debug server's unknown-op semantics.
4438 fn default() -> Self {
4439 Self {
4440 handled: false,
4441 json: AzString::from_const_str(""),
4442 }
4443 }
4444}
4445
4446/// The `extern "C"` callback type for a user-defined E2E op.
4447///
4448/// Receives the op name and its arguments as a JSON string, exactly as they
4449/// appeared in the scenario, and returns a `CustomE2eOpResult`. This is the
4450/// hook for driving application-level actions from a scenario — "now load the
4451/// document" — that the engine has no way to express on the app's behalf.
4452pub type CustomE2eOpCallbackType = extern "C" fn(
4453 crate::refany::RefAny, // ctx
4454 AzString, // op name
4455 AzString, // arguments, JSON
4456) -> CustomE2eOpResult;
4457
4458/// Application-provided handler for E2E ops the engine does not implement.
4459#[repr(C)]
4460pub struct CustomE2eOpCallback {
4461 pub cb: CustomE2eOpCallbackType,
4462 pub ctx: crate::refany::OptionRefAny,
4463 /// Describes every op `cb` answers: name, summary, description, the JSON
4464 /// type each argument expects, usage examples, and example returns.
4465 ///
4466 /// DATA, not a second callback — discovery is a field read, so it needs
4467 /// no invocation and no debug HTTP server. That is what lets a plugin
4468 /// enumerate host capabilities, a locally-spawned MCP server expose the
4469 /// app to an agent that would otherwise drive it by screenshot, or a
4470 /// `script.json` be handed straight to the binary.
4471 ///
4472 /// The examples and types are not documentation garnish: they are what a
4473 /// macro picker renders and what an agent reads in place of a screenshot.
4474 ///
4475 /// `Json` deliberately, not typed structs — the schema can then follow an
4476 /// OpenAPI-style operation shape and gain fields without an ABI break.
4477 /// Typed structs come later.
4478 pub op_schema: crate::json::Json,
4479}
4480
4481// `impl_callback_traits!` only ever reads `self.cb`, so it is correct here.
4482// The full `impl_callback!` is NOT usable: its generated `Clone` and `From`
4483// construct `Self { cb, ctx }` literally, and this struct has a third field.
4484impl_callback_traits!(CustomE2eOpCallback);
4485
4486impl Clone for CustomE2eOpCallback {
4487 fn clone(&self) -> Self {
4488 Self {
4489 cb: self.cb,
4490 ctx: self.ctx.clone(),
4491 op_schema: self.op_schema.clone(),
4492 }
4493 }
4494}
4495
4496impl From<CustomE2eOpCallbackType> for CustomE2eOpCallback {
4497 /// Installs a handler that advertises NOTHING.
4498 ///
4499 /// A bare fn pointer carries no schema, so this cannot invent one. An app
4500 /// converting from a fn pointer gets a working handler whose ops are
4501 /// undiscoverable until it sets `op_schema` — visible in a plugin listing
4502 /// as an empty op list, which is the honest answer rather than a guess.
4503 fn from(cb: CustomE2eOpCallbackType) -> Self {
4504 Self {
4505 cb,
4506 ..Self::default()
4507 }
4508 }
4509}
4510
4511impl Default for CustomE2eOpCallback {
4512 fn default() -> Self {
4513 Self {
4514 cb: default_custom_e2e_op_extern,
4515 ctx: crate::refany::OptionRefAny::None,
4516 // An empty LIST, not an empty string or a null. A consumer must
4517 // be able to tell "this app advertises no ops" from "this app
4518 // returned nothing parseable"; those mean different things to a
4519 // plugin deciding whether the host is usable at all.
4520 // An empty LIST, not an empty string or a null. A consumer must
4521 // be able to tell "this app advertises no ops" from "this app
4522 // returned nothing parseable"; those mean different things to a
4523 // plugin deciding whether the host is usable at all.
4524 op_schema: E2eOpSchema::default().to_json(),
4525 }
4526 }
4527}
4528
4529/// Default handler: recognises NOTHING.
4530///
4531/// `handled: false` is the load-bearing part. An app that has not installed a
4532/// handler must make a scenario referencing a custom op FAIL, not pass
4533/// quietly — the default has to be the safe answer, because it is the one
4534/// that ships when nobody thought about this.
4535#[must_use]
4536pub extern "C" fn default_custom_e2e_op_extern(
4537 _ctx: crate::refany::RefAny,
4538 _op: AzString,
4539 _args: AzString,
4540) -> CustomE2eOpResult {
4541 CustomE2eOpResult {
4542 handled: false,
4543 json: AzString::from_const_str(""),
4544 }
4545}
4546
4547// Keep simpler Rust fn pointer aliases for internal use
4548pub type InputInterpreterFn = fn(info: &InputInterpreterInfo<'_>) -> PreCallbackFilterResult;
4549
4550pub type PostFilterFn = fn(
4551 prevent_default: bool,
4552 pre_changes: &[SystemChange],
4553 old_focus: Option<DomNodeId>,
4554 new_focus: Option<DomNodeId>,
4555) -> Vec<SystemChange>;
4556
4557/// Mouse button state for drag tracking
4558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4559pub struct MouseButtonState {
4560 pub left_down: bool,
4561 pub right_down: bool,
4562 pub middle_down: bool,
4563}
4564
4565/// Arrow key / cursor navigation directions
4566#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4567pub enum ArrowDirection {
4568 Left,
4569 Right,
4570 Up,
4571 Down,
4572 /// Home key: move to start of current line
4573 LineStart,
4574 /// End key: move to end of current line
4575 LineEnd,
4576 /// Ctrl+Home: move to start of document
4577 DocumentStart,
4578 /// Ctrl+End: move to end of document
4579 DocumentEnd,
4580}
4581
4582impl ArrowDirection {
4583 /// Map a `VirtualKeyCode` plus the `ctrl` modifier into an `ArrowDirection`.
4584 /// Returns `None` if the key is not a navigation key.
4585 #[must_use]
4586 pub const fn from_key(vk: crate::window::VirtualKeyCode, ctrl: bool) -> Option<Self> {
4587 use crate::window::VirtualKeyCode::{Down, End, Home, Left, Right, Up};
4588 Some(match vk {
4589 Left => Self::Left,
4590 Right => Self::Right,
4591 Up => Self::Up,
4592 Down => Self::Down,
4593 Home if ctrl => Self::DocumentStart,
4594 Home => Self::LineStart,
4595 End if ctrl => Self::DocumentEnd,
4596 End => Self::LineEnd,
4597 _ => return None,
4598 })
4599 }
4600
4601 /// Convert to a `(SelectionDirection, SelectionStep)` pair for the
4602 /// selection-op interpreter. `ctrl` upgrades arrow keys to word jumps.
4603 #[must_use]
4604 pub const fn to_selection(self, ctrl: bool) -> (SelectionDirection, SelectionStep) {
4605 match self {
4606 Self::Left if ctrl => (SelectionDirection::Backward, SelectionStep::Word),
4607 Self::Right if ctrl => (SelectionDirection::Forward, SelectionStep::Word),
4608 Self::Left => (SelectionDirection::Backward, SelectionStep::Character),
4609 Self::Right => (SelectionDirection::Forward, SelectionStep::Character),
4610 Self::Up => (SelectionDirection::Backward, SelectionStep::VisualLine),
4611 Self::Down => (SelectionDirection::Forward, SelectionStep::VisualLine),
4612 Self::LineStart => (SelectionDirection::Backward, SelectionStep::Line),
4613 Self::LineEnd => (SelectionDirection::Forward, SelectionStep::Line),
4614 Self::DocumentStart => (SelectionDirection::Backward, SelectionStep::Document),
4615 Self::DocumentEnd => (SelectionDirection::Forward, SelectionStep::Document),
4616 }
4617 }
4618}
4619
4620/// Direction of cursor movement or selection expansion.
4621#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4622#[repr(C)]
4623pub enum SelectionDirection {
4624 Forward,
4625 Backward,
4626}
4627
4628/// Granularity of cursor movement or selection expansion.
4629///
4630/// Combined with `SelectionDirection`, determines how far a cursor moves
4631/// or a selection expands. Reused for navigation, deletion, and visual
4632/// selection — a single code path for word boundaries etc.
4633#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4634#[repr(C)]
4635pub enum SelectionStep {
4636 /// One grapheme cluster (arrow keys, Backspace, Delete)
4637 Character,
4638 /// One word boundary (Ctrl+arrow, Ctrl+Backspace, Ctrl+Delete)
4639 Word,
4640 /// To line boundary (Home/End)
4641 Line,
4642 /// One visual line up/down (Up/Down arrows)
4643 VisualLine,
4644 /// To document boundary (Ctrl+Home/End)
4645 Document,
4646}
4647
4648/// What to do with the selection after moving.
4649#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4650#[repr(C)]
4651pub enum SelectionMode {
4652 /// Collapse selection to cursor, then move (plain arrow key).
4653 Move,
4654 /// Extend selection from anchor to new position (Shift+arrow).
4655 Extend,
4656 /// Expand cursor to range in the given direction, then delete the range
4657 /// (Backspace/Delete). If a range already exists, just delete it.
4658 Delete,
4659}
4660
4661/// A unified selection operation that replaces all cursor movement,
4662/// selection extension, and text deletion commands.
4663///
4664/// Every keyboard shortcut for cursor movement or deletion maps to this:
4665/// - Arrow Left = (Backward, Character, Move, 1)
4666/// - Shift+Right = (Forward, Character, Extend, 1)
4667/// - Ctrl+Backspace = (Backward, Word, Delete, 1)
4668/// - Home = (Backward, Line, Move, 1)
4669/// - Ctrl+End = (Forward, Document, Move, 1)
4670///
4671/// The `repeat` field enables vim-style commands: 3w = (Forward, Word, Move, 3).
4672#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4673#[repr(C)]
4674pub struct SelectionOp {
4675 pub direction: SelectionDirection,
4676 pub step: SelectionStep,
4677 pub mode: SelectionMode,
4678 pub repeat: usize,
4679}
4680
4681impl SelectionOp {
4682 #[must_use]
4683 pub const fn new(
4684 direction: SelectionDirection,
4685 step: SelectionStep,
4686 mode: SelectionMode,
4687 ) -> Self {
4688 Self {
4689 direction,
4690 step,
4691 mode,
4692 repeat: 1,
4693 }
4694 }
4695}
4696
4697/// Keyboard shortcuts for text editing
4698#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4699pub enum KeyboardShortcut {
4700 Copy, // Ctrl+C
4701 Cut, // Ctrl+X
4702 Paste, // Ctrl+V
4703 SelectAll, // Ctrl+A
4704 Undo, // Ctrl+Z
4705 Redo, // Ctrl+Y or Ctrl+Shift+Z
4706}
4707
4708impl KeyboardShortcut {
4709 /// Map a `(VirtualKeyCode, primary, shift)` triple to a text-editing
4710 /// shortcut. Returns `None` if the key combination is not a recognized
4711 /// shortcut or if `primary` is not held. `primary` is the platform's
4712 /// primary modifier — Cmd on macOS, Ctrl elsewhere — obtained from
4713 /// `KeyboardState::primary_down()` (MWA-A2: hardcoding Ctrl here made
4714 /// every editing shortcut dead on macOS).
4715 #[must_use]
4716 pub const fn from_key(
4717 vk: crate::window::VirtualKeyCode,
4718 primary: bool,
4719 shift: bool,
4720 ) -> Option<Self> {
4721 use crate::window::VirtualKeyCode::{A, C, V, X, Y, Z};
4722 if !primary {
4723 return None;
4724 }
4725 Some(match vk {
4726 C => Self::Copy,
4727 X => Self::Cut,
4728 V => Self::Paste,
4729 A => Self::SelectAll,
4730 Z if shift => Self::Redo,
4731 Z => Self::Undo,
4732 Y => Self::Redo,
4733 _ => return None,
4734 })
4735 }
4736}
4737
4738/// Default input interpreter: standard desktop keybindings.
4739///
4740/// This is the default `InputInterpreterFn` that handles arrow keys, Home/End,
4741/// Backspace/Delete, Ctrl+C/V/A/Z, mouse clicks, and drag selection.
4742/// Replace it on `LayoutWindow` to implement vim, game controls, etc.
4743/// `extern "C"` trampoline for `default_input_interpreter`.
4744#[allow(clippy::not_unsafe_ptr_arg_deref)]
4745// 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.
4746#[must_use]
4747pub extern "C" fn default_input_interpreter_extern(
4748 _user_data: crate::refany::RefAny,
4749 info_ptr: *const InputInterpreterInfo<'static>,
4750) -> PreCallbackFilterResult {
4751 if info_ptr.is_null() {
4752 return PreCallbackFilterResult {
4753 system_changes: Vec::new(),
4754 user_events: Vec::new(),
4755 };
4756 }
4757 let info = unsafe { &*info_ptr };
4758 default_input_interpreter(info)
4759}
4760
4761/// `extern "C"` trampoline for `default_post_filter`.
4762#[must_use]
4763pub extern "C" fn default_post_filter_extern(
4764 _user_data: crate::refany::RefAny,
4765 prevent_default: bool,
4766 pre_changes: SystemChangeVecSlice,
4767 old_focus: DomNodeId,
4768 new_focus: DomNodeId,
4769) -> SystemChangeVec {
4770 let pre_changes_slice = pre_changes.as_slice();
4771 let old = old_focus.node.into_crate_internal().map(|_| old_focus);
4772 let new = new_focus.node.into_crate_internal().map(|_| new_focus);
4773 default_post_filter(prevent_default, pre_changes_slice, old, new).into()
4774}
4775
4776#[must_use]
4777pub fn default_input_interpreter(info: &InputInterpreterInfo<'_>) -> PreCallbackFilterResult {
4778 let ctx = FilterContext {
4779 hit_test: info.hit_test,
4780 keyboard_state: info.keyboard_state,
4781 mouse_state: info.mouse_state,
4782 click_count: info.state.click_count,
4783 focused_node: info.state.focused_node,
4784 drag_start_position: info.state.drag_start_position,
4785 focus_is_editable: info.state.focus_is_editable,
4786 seat_focus: info.seat_focus,
4787 };
4788
4789 let (system_changes, user_events) = info.events.iter().fold(
4790 (Vec::new(), Vec::new()),
4791 |(mut internal, mut user), event| {
4792 match process_event_for_internal(&ctx, event) {
4793 Some(InternalEventAction::AddAndSkip(evt)) => {
4794 internal.push(evt);
4795 }
4796 Some(InternalEventAction::AddAndPass(evt)) => {
4797 internal.push(evt);
4798 user.push(event.clone());
4799 }
4800 None => {
4801 user.push(event.clone());
4802 }
4803 }
4804 (internal, user)
4805 },
4806 );
4807
4808 PreCallbackFilterResult {
4809 system_changes,
4810 user_events,
4811 }
4812}
4813
4814/// Backward-compatible wrapper that calls `default_input_interpreter`.
4815pub fn pre_callback_filter_internal_events<SM, FM>(
4816 events: &[SyntheticEvent],
4817 hit_test: Option<&FullHitTest>,
4818 keyboard_state: &crate::window::KeyboardState,
4819 mouse_state: &crate::window::MouseState,
4820 selection_manager: &SM,
4821 focus_manager: &FM,
4822 // See `InputInterpreterState::focus_is_editable` - only a text-editing
4823 // focus owns the arrow keys.
4824 focus_is_editable: bool,
4825) -> PreCallbackFilterResult
4826where
4827 SM: SelectionManagerQuery,
4828 FM: FocusManagerQuery,
4829{
4830 let seat_focus = seat_focus_of_events(events, focus_manager);
4831 let info = InputInterpreterInfo {
4832 events,
4833 hit_test,
4834 keyboard_state,
4835 mouse_state,
4836 seat_focus: &seat_focus,
4837 state: InputInterpreterState {
4838 focused_node: focus_manager.get_focused_node_id(),
4839 click_count: selection_manager.get_click_count(),
4840 drag_start_position: selection_manager.get_drag_start_position(),
4841 has_selection: selection_manager.has_selection(),
4842 focus_is_editable,
4843 },
4844 };
4845 default_input_interpreter(&info)
4846}
4847
4848/// Context for filtering internal events (used by `default_input_interpreter`)
4849struct FilterContext<'a> {
4850 hit_test: Option<&'a FullHitTest>,
4851 keyboard_state: &'a crate::window::KeyboardState,
4852 mouse_state: &'a crate::window::MouseState,
4853 click_count: u8,
4854 focused_node: Option<DomNodeId>,
4855 drag_start_position: Option<LogicalPosition>,
4856 /// See `InputInterpreterState::focus_is_editable`.
4857 focus_is_editable: bool,
4858 /// See `InputInterpreterInfo::seat_focus`.
4859 seat_focus: &'a [(u64, Option<DomNodeId>)],
4860}
4861
4862impl FilterContext<'_> {
4863 /// The focus a key event of seat `seat_id` acts on (9b-ii-a-i-d).
4864 fn focused_node_for(&self, seat_id: u64) -> Option<DomNodeId> {
4865 if seat_id == crate::window::PRIMARY_POINTER_SEAT {
4866 return self.focused_node;
4867 }
4868 self.seat_focus
4869 .iter()
4870 .find(|(seat, _)| *seat == seat_id)
4871 .map_or(self.focused_node, |(_, focus)| *focus)
4872 }
4873}
4874
4875/// Process a single event and determine if it generates an internal event
4876fn process_event_for_internal(
4877 ctx: &FilterContext<'_>,
4878 event: &SyntheticEvent,
4879) -> Option<InternalEventAction> {
4880 match event.event_type {
4881 EventType::MouseDown => handle_mouse_down(
4882 event,
4883 ctx.hit_test,
4884 ctx.click_count,
4885 ctx.mouse_state,
4886 ctx.keyboard_state,
4887 ),
4888 // Drag-selection needs MOVEMENT while the button is down, so it
4889 // follows `MouseMove`. Leaving it on `MouseOver` after that event
4890 // became entry-only would have silently killed drag-to-select: the
4891 // handler would fire once on entry and never again.
4892 EventType::MouseMove => handle_mouse_move(
4893 event,
4894 ctx.hit_test,
4895 ctx.mouse_state,
4896 ctx.drag_start_position,
4897 ),
4898 EventType::KeyDown => {
4899 // A seat's key acts on THAT seat's focus (9b-ii-a-i-d).
4900 let seat_id = match &event.data {
4901 EventData::Keyboard(k) => k.seat_id,
4902 _ => crate::window::PRIMARY_POINTER_SEAT,
4903 };
4904 handle_key_down(
4905 event,
4906 ctx.keyboard_state,
4907 ctx.focused_node_for(seat_id),
4908 ctx.focus_is_editable,
4909 )
4910 }
4911 EventType::MouseUp => Some(handle_mouse_up()),
4912 _ => None,
4913 }
4914}
4915
4916/// Releasing the button ends a text-selection drag, so the autoscroll timer
4917/// has to go.
4918///
4919/// `StopAutoScrollTimer` existed and was handled, but NOTHING emitted it:
4920/// teardown relied entirely on the 60Hz callback noticing on its next tick
4921/// that the button had been released and terminating itself. Any path that
4922/// loses the release — a grab broken by the window manager, a crossing that
4923/// swallows it — left a timer running at 60Hz for the life of the window.
4924///
4925/// It passes to callbacks: a `MouseUp` is a user event, and stopping an
4926/// internal timer must not swallow it.
4927const fn handle_mouse_up() -> InternalEventAction {
4928 InternalEventAction::AddAndPass(SystemChange::StopAutoScrollTimer)
4929}
4930
4931/// Action to take after processing an event for internal system events
4932enum InternalEventAction {
4933 /// Add system change and skip passing to user callbacks
4934 AddAndSkip(SystemChange),
4935 /// Add system change but also pass to user callbacks
4936 AddAndPass(SystemChange),
4937}
4938
4939/// Extract the front-most hovered node from a hit test.
4940///
4941/// Picks the node with the minimum `hit_depth` (0 = frontmost/topmost in
4942/// z-order) across every hovered DOM. The previous implementation took the
4943/// first entry of the `BTreeMap` (lowest `NodeId`), which ignored z-order
4944/// entirely and targeted the back-most node under overlapping elements.
4945/// Ties are broken deterministically by (`DomId`, `NodeId`) iteration order.
4946fn get_first_hovered_node(hit_test: Option<&FullHitTest>) -> Option<DomNodeId> {
4947 let ht = hit_test?;
4948 let mut best: Option<(DomId, NodeId, u32)> = None;
4949 for (dom_id, hit_data) in &ht.hovered_nodes {
4950 for (node_id, item) in &hit_data.regular_hit_test_nodes {
4951 let is_better = match best {
4952 None => true,
4953 Some((_, _, best_depth)) => item.hit_depth < best_depth,
4954 };
4955 if is_better {
4956 best = Some((*dom_id, *node_id, item.hit_depth));
4957 }
4958 }
4959 }
4960 let (dom_id, node_id, _) = best?;
4961 Some(DomNodeId {
4962 dom: dom_id,
4963 node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
4964 })
4965}
4966
4967/// Extract mouse position from event data, falling back to `mouse_state` if not available
4968fn get_mouse_position_with_fallback(
4969 event: &SyntheticEvent,
4970 mouse_state: &crate::window::MouseState,
4971) -> LogicalPosition {
4972 match &event.data {
4973 EventData::Mouse(mouse_data) => mouse_data.position,
4974 _ => {
4975 // Fallback: use current cursor position from mouse_state
4976 // This handles synthetic events from debug API and automation
4977 // where EventData may not contain the mouse position
4978 mouse_state
4979 .cursor_position
4980 .get_position()
4981 .unwrap_or(LogicalPosition::zero())
4982 }
4983 }
4984}
4985
4986/// Handle `MouseDown` event - detect text selection clicks and Ctrl+Click for multi-cursor
4987fn handle_mouse_down(
4988 event: &SyntheticEvent,
4989 hit_test: Option<&FullHitTest>,
4990 click_count: u8,
4991 mouse_state: &crate::window::MouseState,
4992 keyboard_state: &crate::window::KeyboardState,
4993) -> Option<InternalEventAction> {
4994 let effective_click_count = if click_count == 0 { 1 } else { click_count };
4995
4996 if effective_click_count > 3 {
4997 return None;
4998 }
4999
5000 let _target = get_first_hovered_node(hit_test)?;
5001 let position = get_mouse_position_with_fallback(event, mouse_state);
5002
5003 // Ctrl+Click (or Cmd+Click on macOS): add cursor at click position.
5004 // Use the platform PRIMARY modifier so this fires on Cmd on macOS
5005 // (where Ctrl+Click is the secondary-click gesture) — `ctrl_down()`
5006 // was wrong there.
5007 if keyboard_state.primary_down() && effective_click_count == 1 {
5008 return Some(InternalEventAction::AddAndPass(
5009 SystemChange::AddCursorAtClick { position },
5010 ));
5011 }
5012
5013 Some(InternalEventAction::AddAndPass(
5014 SystemChange::TextSelectionClick {
5015 position,
5016 timestamp: event.timestamp.clone(),
5017 },
5018 ))
5019}
5020
5021/// Handle `MouseMove` event - detect drag selection
5022fn handle_mouse_move(
5023 event: &SyntheticEvent,
5024 _hit_test: Option<&FullHitTest>,
5025 mouse_state: &crate::window::MouseState,
5026 drag_start_position: Option<LogicalPosition>,
5027) -> Option<InternalEventAction> {
5028 if !mouse_state.left_down {
5029 return None;
5030 }
5031
5032 let start_position = drag_start_position?;
5033
5034 // Deliberately NOT gated on a hovered hit node. A drag that leaves the
5035 // text — into the container's padding, over a gap, past the last line —
5036 // still extends the selection in every native editor, and the endpoint is
5037 // resolved from the pointer position against the ANCHOR block's layout
5038 // (`process_mouse_drag_for_selection`), not from whatever happens to be
5039 // under the cursor. Requiring a hit node froze the selection exactly where
5040 // the user was reaching for more of it.
5041 let current_position = get_mouse_position_with_fallback(event, mouse_state);
5042
5043 Some(InternalEventAction::AddAndPass(
5044 SystemChange::TextSelectionDrag {
5045 start_position,
5046 current_position,
5047 },
5048 ))
5049}
5050
5051/// Handle `KeyDown` event - detect shortcuts, arrow keys, and delete keys
5052fn handle_key_down(
5053 event: &SyntheticEvent,
5054 keyboard_state: &crate::window::KeyboardState,
5055 focused_node: Option<DomNodeId>,
5056 focus_is_editable: bool,
5057) -> Option<InternalEventAction> {
5058 use crate::window::VirtualKeyCode;
5059
5060 let target = focused_node?;
5061 let EventData::Keyboard(kbd) = &event.data else {
5062 return None;
5063 };
5064
5065 // Read the key and modifiers from THIS event's payload, not from the live
5066 // `keyboard_state`. The live state can have advanced (another key pressed /
5067 // released) between when the event was queued and when it is dispatched, so
5068 // reading it here could act on the wrong key/modifiers. `keyboard_state` is
5069 // retained only for the platform where the event does not carry a key.
5070 let _ = keyboard_state;
5071
5072 // MWA-A2: standard shortcuts key off the PRIMARY modifier (Cmd on
5073 // macOS, Ctrl elsewhere); word-jump / word-delete keys off the
5074 // platform's word modifier (Option on macOS, Ctrl elsewhere).
5075 let primary = if cfg!(target_os = "macos") {
5076 kbd.modifiers.meta
5077 } else {
5078 kbd.modifiers.ctrl
5079 };
5080 let word_mod = if cfg!(target_os = "macos") {
5081 kbd.modifiers.alt
5082 } else {
5083 kbd.modifiers.ctrl
5084 };
5085 let shift = kbd.modifiers.shift;
5086 let vk_owned = VirtualKeyCode::from_u32(kbd.key_code)?;
5087 let vk = &vk_owned;
5088
5089 // Check keyboard shortcuts (primary+key) → emit specific SystemChange
5090 // variants. Standard editing shortcuts are routed through the
5091 // `KeyboardShortcut` enum, and a couple of additional Azul-specific
5092 // primary-modifier combos are matched after.
5093 if primary {
5094 if let Some(shortcut) = KeyboardShortcut::from_key(*vk, primary, shift) {
5095 // A second seat's shortcut acts on ITS caret (9b-ii-a-i-d-ii-b-i).
5096 if kbd.seat_id != crate::window::PRIMARY_POINTER_SEAT {
5097 return Some(InternalEventAction::AddAndSkip(SystemChange::SeatShortcut {
5098 seat_id: kbd.seat_id,
5099 target,
5100 shortcut,
5101 }));
5102 }
5103 let change = match shortcut {
5104 KeyboardShortcut::Copy => SystemChange::CopyToClipboard,
5105 KeyboardShortcut::Cut => SystemChange::CutToClipboard { target },
5106 KeyboardShortcut::Paste => SystemChange::PasteFromClipboard,
5107 KeyboardShortcut::SelectAll => SystemChange::SelectAllText,
5108 KeyboardShortcut::Undo => SystemChange::UndoTextEdit { target },
5109 KeyboardShortcut::Redo => SystemChange::RedoTextEdit { target },
5110 };
5111 return Some(InternalEventAction::AddAndSkip(change));
5112 }
5113 if matches!(vk, VirtualKeyCode::D) {
5114 // Ctrl+D adds a multi-cursor, which is the primary's alone
5115 // (9b-ii-a-i-d-ii-b-i): a seat's passes through to callbacks.
5116 if kbd.seat_id != crate::window::PRIMARY_POINTER_SEAT {
5117 return None;
5118 }
5119 return Some(InternalEventAction::AddAndSkip(
5120 SystemChange::SelectNextOccurrence { target },
5121 ));
5122 }
5123 }
5124
5125 // Unified: arrow keys, Home/End, Backspace/Delete all map to SelectionOp.
5126 let mode_for_shift = if shift {
5127 SelectionMode::Extend
5128 } else {
5129 SelectionMode::Move
5130 };
5131 let selection_op = if let Some(arrow) = ArrowDirection::from_key(*vk, word_mod) {
5132 // ONLY a text-editing context owns the arrows. Anywhere else they
5133 // belong to the focused widget (a slider steps its value, a colour
5134 // plane moves its marker) and, failing that, to the scroll default
5135 // action. Claiming them here returned `AddAndSkip`, which swallowed
5136 // the key before any callback could see it.
5137 if !focus_is_editable {
5138 return None;
5139 }
5140 let (direction, step) = arrow.to_selection(word_mod);
5141 SelectionOp::new(direction, step, mode_for_shift)
5142 } else {
5143 match vk {
5144 // Backspace/Delete = Delete mode (word modifier upgrades to
5145 // Word: Option+Backspace on macOS, Ctrl+Backspace elsewhere)
5146 VirtualKeyCode::Back => SelectionOp::new(
5147 SelectionDirection::Backward,
5148 if word_mod {
5149 SelectionStep::Word
5150 } else {
5151 SelectionStep::Character
5152 },
5153 SelectionMode::Delete,
5154 ),
5155 VirtualKeyCode::Delete => SelectionOp::new(
5156 SelectionDirection::Forward,
5157 if word_mod {
5158 SelectionStep::Word
5159 } else {
5160 SelectionStep::Character
5161 },
5162 SelectionMode::Delete,
5163 ),
5164 _ => return None,
5165 }
5166 };
5167
5168 Some(InternalEventAction::AddAndSkip(
5169 SystemChange::ApplySelectionOp {
5170 target,
5171 op: selection_op,
5172 seat_id: kbd.seat_id,
5173 },
5174 ))
5175}
5176
5177/// Trait for querying selection manager state.
5178///
5179/// This allows `pre_callback_filter_internal_events` to query manager state
5180/// without depending on the concrete `SelectionManager` type from layout crate.
5181pub trait SelectionManagerQuery {
5182 /// Get the current click count (1 = single, 2 = double, 3 = triple)
5183 fn get_click_count(&self) -> u8;
5184
5185 /// Get the drag start position if a drag is in progress
5186 fn get_drag_start_position(&self) -> Option<LogicalPosition>;
5187
5188 /// Check if any selection exists (click selection or drag selection)
5189 fn has_selection(&self) -> bool;
5190}
5191
5192/// Trait for querying focus manager state.
5193///
5194/// This allows `pre_callback_filter_internal_events` to query manager state
5195/// without depending on the concrete `FocusManager` type from layout crate.
5196pub trait FocusManagerQuery {
5197 /// Get the currently focused node ID
5198 fn get_focused_node_id(&self) -> Option<DomNodeId>;
5199
5200 /// The node seat `seat_id` focuses (9b-ii-a-i-d). A manager without
5201 /// per-seat focus answers the shared one for every seat.
5202 fn get_focused_node_for_seat(&self, seat_id: u64) -> Option<DomNodeId> {
5203 let _ = seat_id;
5204 self.get_focused_node_id()
5205 }
5206}
5207
5208/// The focused node of every NON-primary keyboard seat that `events` carry
5209/// (9b-ii-a-i-d), for `InputInterpreterInfo::seat_focus`: the interpreter
5210/// resolves a seat's key against that seat's focus, not the primary's.
5211#[must_use]
5212pub fn seat_focus_of_events<FM: FocusManagerQuery + ?Sized>(
5213 events: &[SyntheticEvent],
5214 focus_manager: &FM,
5215) -> Vec<(u64, Option<DomNodeId>)> {
5216 let mut out: Vec<(u64, Option<DomNodeId>)> = Vec::new();
5217 for event in events {
5218 let EventData::Keyboard(k) = &event.data else {
5219 continue;
5220 };
5221 if k.seat_id == crate::window::PRIMARY_POINTER_SEAT
5222 || out.iter().any(|(seat, _)| *seat == k.seat_id)
5223 {
5224 continue;
5225 }
5226 out.push((k.seat_id, focus_manager.get_focused_node_for_seat(k.seat_id)));
5227 }
5228 out
5229}
5230
5231/// Post-callback filter: Determine additional system changes needed after user callbacks.
5232///
5233/// Takes the pre-callback system changes and focus state to determine what
5234/// post-callback system changes are needed (text input, scrolling, timers).
5235/// Default post-callback filter: scroll-into-view after cursor ops, auto-scroll during drag.
5236#[must_use]
5237pub fn default_post_filter(
5238 prevent_default: bool,
5239 pre_changes: &[SystemChange],
5240 old_focus: Option<DomNodeId>,
5241 new_focus: Option<DomNodeId>,
5242) -> Vec<SystemChange> {
5243 post_callback_filter_system_changes(prevent_default, pre_changes, old_focus, new_focus)
5244}
5245
5246// SystemChange dispatch table; a few arms incidentally push the same follow-up
5247// change but are kept as distinct documented cases.
5248#[allow(clippy::match_same_arms)]
5249#[must_use]
5250pub fn post_callback_filter_system_changes(
5251 prevent_default: bool,
5252 pre_changes: &[SystemChange],
5253 old_focus: Option<DomNodeId>,
5254 new_focus: Option<DomNodeId>,
5255) -> Vec<SystemChange> {
5256 let mut changes = Vec::new();
5257
5258 if prevent_default {
5259 // Only focus change passes through preventDefault
5260 if old_focus != new_focus {
5261 changes.push(SystemChange::SetFocus {
5262 new_focus,
5263 old_focus,
5264 // A focus move a callback made (or that survived
5265 // preventDefault) is programmatic, not a keyboard walk: focused
5266 // but not indicated, like `autofocus`.
5267 visible: false,
5268 });
5269 }
5270 return changes;
5271 }
5272
5273 // Always apply pending text input
5274 changes.push(SystemChange::ApplyPendingTextInput);
5275
5276 // Determine post-callback actions based on pre-callback system changes
5277 for change in pre_changes {
5278 match change {
5279 SystemChange::TextSelectionClick { .. }
5280 | SystemChange::ApplySelectionOp { .. }
5281 | SystemChange::AddCursorAtClick { .. }
5282 | SystemChange::SelectNextOccurrence { .. } => {
5283 changes.push(SystemChange::ScrollSelectionIntoView);
5284 }
5285 SystemChange::TextSelectionDrag { .. } => {
5286 changes.push(SystemChange::StartAutoScrollTimer);
5287 }
5288 SystemChange::CutToClipboard { .. }
5289 | SystemChange::PasteFromClipboard
5290 | SystemChange::UndoTextEdit { .. }
5291 | SystemChange::RedoTextEdit { .. }
5292 | SystemChange::SelectAllText => {
5293 changes.push(SystemChange::ScrollSelectionIntoView);
5294 }
5295 // Other system changes don't generate post-callback actions
5296 _ => {}
5297 }
5298 }
5299
5300 // Focus changed during callbacks
5301 if old_focus != new_focus {
5302 changes.push(SystemChange::SetFocus {
5303 new_focus,
5304 old_focus,
5305 visible: false,
5306 });
5307 }
5308
5309 changes
5310}
5311
5312#[cfg(test)]
5313#[path = "events_test.rs"]
5314mod events_test;