//! Event and callback filtering module
#[cfg(not(feature = "std"))]
use alloc::string::{String, ToString};
use alloc::{
boxed::Box,
collections::{btree_map::BTreeMap, btree_set::BTreeSet},
vec::Vec,
};
use azul_css::AzString;
use crate::{
callbacks::Update,
dom::{DomId, DomNodeId, On},
geom::{LogicalPosition, LogicalRect},
hit_test::{FullHitTest, HitTestItem},
id::NodeId,
styled_dom::{ChangedCssProperty, NodeHierarchyItemId},
task::Instant,
OrderedMap,
};
/// Easing functions for smooth scroll animations
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EasingFunction {
Linear,
EaseInOut,
EaseOut,
}
pub type RestyleNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
pub type RelayoutNodes = BTreeMap<NodeId, Vec<ChangedCssProperty>>;
pub type RelayoutWords = BTreeMap<NodeId, AzString>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FocusChange {
pub old: Option<DomNodeId>,
pub new: Option<DomNodeId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CallbackToCall {
pub node_id: NodeId,
pub hit_test_item: Option<HitTestItem>,
pub event_filter: EventFilter,
}
impl CallbackToCall {
#[must_use] pub const fn new(
node_id: NodeId,
hit_test_item: Option<HitTestItem>,
event_filter: EventFilter,
) -> Self {
Self { node_id, hit_test_item, event_filter }
}
/// Build a list of `CallbackToCall` entries for every node hit by the
/// given hit test under the given DOM, tagged with `event_filter`.
/// Returns an empty `Vec` when there is no hit test data for the DOM.
#[must_use] pub fn from_hit_test(
hit_test: &FullHitTest,
dom_id: DomId,
event_filter: EventFilter,
) -> Vec<Self> {
let Some(hit) = hit_test.hovered_nodes.get(&dom_id) else {
return Vec::new();
};
hit.regular_hit_test_nodes
.iter()
.map(|(node_id, item)| Self {
node_id: *node_id,
hit_test_item: Some(*item),
event_filter,
})
.collect()
}
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
#[must_use = "ProcessEventResult must be used to determine if relayout/repaint is needed"]
pub enum ProcessEventResult {
DoNothing = 0,
ShouldReRenderCurrentWindow = 1,
ShouldUpdateDisplayListCurrentWindow = 2,
// GPU transforms changed: do another hit-test and recurse
// until nothing has changed anymore
UpdateHitTesterAndProcessAgain = 3,
// Restyle or runtime edit changed layout-affecting properties:
// re-run layout on the EXISTING StyledDom (no DOM rebuild).
ShouldIncrementalRelayout = 4,
// Full DOM rebuild via user's layout_callback()
ShouldRegenerateDomCurrentWindow = 5,
ShouldRegenerateDomAllWindows = 6,
}
impl ProcessEventResult {
#[must_use] pub const fn order(&self) -> usize {
use self::ProcessEventResult::{DoNothing, ShouldReRenderCurrentWindow, ShouldUpdateDisplayListCurrentWindow, UpdateHitTesterAndProcessAgain, ShouldIncrementalRelayout, ShouldRegenerateDomCurrentWindow, ShouldRegenerateDomAllWindows};
match self {
DoNothing => 0,
ShouldReRenderCurrentWindow => 1,
ShouldUpdateDisplayListCurrentWindow => 2,
UpdateHitTesterAndProcessAgain => 3,
ShouldIncrementalRelayout => 4,
ShouldRegenerateDomCurrentWindow => 5,
ShouldRegenerateDomAllWindows => 6,
}
}
}
impl PartialOrd for ProcessEventResult {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
self.order().partial_cmp(&other.order())
}
}
impl Ord for ProcessEventResult {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
self.order().cmp(&other.order())
}
}
impl ProcessEventResult {
pub fn max_self(self, other: Self) -> Self {
self.max(other)
}
}
/// Tracks the origin of an event for proper handling.
///
/// This allows the system to distinguish between user input, programmatic
/// changes, and synthetic events generated by UI components.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum EventSource {
/// Direct user input (mouse, keyboard, touch, gamepad)
User,
/// API call (programmatic scroll, focus change, etc.)
Programmatic,
/// Generated from UI interaction (scrollbar drag, synthetic events)
Synthetic,
/// Generated from lifecycle hooks (mount, unmount, resize)
Lifecycle,
}
/// Event propagation phase (similar to DOM Level 2 Events).
///
/// Events can be intercepted at different phases:
/// - **Capture**: Event travels from root down to target (rarely used)
/// - **Target**: Event is at the target element
/// - **Bubble**: Event travels from target back up to root (most common)
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
#[derive(Default)]
pub enum EventPhase {
/// Event travels from root down to target
Capture,
/// Event is at the target element
Target,
/// Event bubbles from target back up to root
#[default]
Bubble,
}
/// Mouse button identifier for mouse events.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum MouseButton {
Left,
Middle,
Right,
Other(u8),
}
/// Scroll delta mode (how scroll deltas should be interpreted).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum ScrollDeltaMode {
/// Delta is in pixels
Pixel,
/// Delta is in lines (e.g., 3 lines of text)
Line,
/// Delta is in pages
Page,
}
/// Scroll direction for conditional event filtering.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum ScrollDirection {
Up,
Down,
Left,
Right,
}
// ============================================================================
// W3C CSSOM View Module - Scroll Into View Types
// ============================================================================
/// W3C-compliant scroll-into-view options
///
/// These options control how an element is scrolled into view, following
/// the CSSOM View Module specification.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct ScrollIntoViewOptions {
/// Vertical alignment: start, center, end, nearest (default: nearest)
pub block: ScrollLogicalPosition,
/// Horizontal alignment: start, center, end, nearest (default: nearest)
/// Note: Named `inline_axis` to avoid conflict with C keyword `inline`
pub inline_axis: ScrollLogicalPosition,
/// Animation behavior: auto, instant, smooth (default: auto)
pub behavior: ScrollIntoViewBehavior,
}
impl ScrollIntoViewOptions {
/// Create options with "nearest" alignment for both axes
#[must_use] pub const fn nearest() -> Self {
Self {
block: ScrollLogicalPosition::Nearest,
inline_axis: ScrollLogicalPosition::Nearest,
behavior: ScrollIntoViewBehavior::Auto,
}
}
/// Create options with "center" alignment for both axes
#[must_use] pub const fn center() -> Self {
Self {
block: ScrollLogicalPosition::Center,
inline_axis: ScrollLogicalPosition::Center,
behavior: ScrollIntoViewBehavior::Auto,
}
}
/// Create options with "start" alignment for both axes
#[must_use] pub const fn start() -> Self {
Self {
block: ScrollLogicalPosition::Start,
inline_axis: ScrollLogicalPosition::Start,
behavior: ScrollIntoViewBehavior::Auto,
}
}
/// Create options to align the end of the target with the end of the viewport
#[must_use] pub const fn end() -> Self {
Self {
block: ScrollLogicalPosition::End,
inline_axis: ScrollLogicalPosition::End,
behavior: ScrollIntoViewBehavior::Auto,
}
}
/// Set instant scroll behavior
#[must_use] pub const fn with_instant(mut self) -> Self {
self.behavior = ScrollIntoViewBehavior::Instant;
self
}
/// Set smooth scroll behavior
#[must_use] pub const fn with_smooth(mut self) -> Self {
self.behavior = ScrollIntoViewBehavior::Smooth;
self
}
}
/// Scroll alignment for vertical (block) or horizontal (inline) axis
///
/// Determines where the target element should be positioned within
/// the scroll container's visible area.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum ScrollLogicalPosition {
/// Align target's start edge with container's start edge
Start,
/// Center target within container
Center,
/// Align target's end edge with container's end edge
End,
/// Minimum scroll distance to make target fully visible (default)
#[default]
Nearest,
}
/// Scroll animation behavior for scrollIntoView API
///
/// This is distinct from the CSS `scroll-behavior` property, as it also
/// supports the `Instant` option which CSS does not have.
#[repr(C)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
pub enum ScrollIntoViewBehavior {
/// Respect CSS scroll-behavior property (default)
#[default]
Auto,
/// Immediate jump without animation
Instant,
/// Animated smooth scroll
Smooth,
}
/// Reason why a lifecycle event was triggered.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum LifecycleReason {
/// First appearance in DOM
InitialMount,
/// Removed and re-added to DOM
Remount,
/// Layout bounds changed
Resize,
/// Props or state changed
Update,
/// Node was removed from DOM
Unmount,
}
/// Keyboard modifier keys state.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
#[repr(C)]
pub struct KeyModifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
pub meta: bool,
}
impl KeyModifiers {
#[must_use] pub fn new() -> Self {
Self::default()
}
#[must_use] pub const fn with_shift(mut self) -> Self {
self.shift = true;
self
}
#[must_use] pub const fn with_ctrl(mut self) -> Self {
self.ctrl = true;
self
}
#[must_use] pub const fn with_alt(mut self) -> Self {
self.alt = true;
self
}
#[must_use] pub const fn with_meta(mut self) -> Self {
self.meta = true;
self
}
#[must_use] pub const fn is_empty(&self) -> bool {
!self.shift && !self.ctrl && !self.alt && !self.meta
}
}
/// Type-specific event data for mouse events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MouseEventData {
/// Position of the mouse cursor
pub position: LogicalPosition,
/// Which button was pressed/released
pub button: MouseButton,
/// Bitmask of currently pressed buttons
pub buttons: u8,
/// Modifier keys state
pub modifiers: KeyModifiers,
}
/// Type-specific event data for keyboard events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyboardEventData {
/// The virtual key code
pub key_code: u32,
/// The character produced (if any)
pub char_code: Option<char>,
/// Modifier keys state
pub modifiers: KeyModifiers,
/// Whether this is a repeat event
pub repeat: bool,
}
/// Type-specific event data for scroll events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ScrollEventData {
/// Scroll delta (dx, dy)
pub delta: LogicalPosition,
/// How the delta should be interpreted
pub delta_mode: ScrollDeltaMode,
}
/// Type-specific event data for touch events.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TouchEventData {
/// Touch identifier
pub id: u64,
/// Touch position
pub position: LogicalPosition,
/// Touch force/pressure (0.0 - 1.0)
pub force: f32,
}
/// Type-specific event data for clipboard events.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClipboardEventData {
/// The clipboard content (for paste events)
pub content: Option<String>,
}
/// Type-specific event data for lifecycle events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LifecycleEventData {
/// Why this lifecycle event was triggered
pub reason: LifecycleReason,
/// Previous layout bounds (for resize events)
pub previous_bounds: Option<LogicalRect>,
/// Current layout bounds
pub current_bounds: LogicalRect,
}
/// Type-specific event data for window events.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WindowEventData {
/// Window size (for resize events)
pub size: Option<LogicalRect>,
/// Window position (for move events)
pub position: Option<LogicalPosition>,
}
/// Type-specific event data for text-input (editing) events.
///
/// Carried by `EventType::Input` events so that text-input callbacks can read
/// the edit details directly off the event — matching how mouse/keyboard/scroll
/// callbacks read their data — instead of having to reach into the
/// `TextInputManager`'s pending changeset. The edited node is already available
/// via `SyntheticEvent.target`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextInputEventData {
/// The text inserted by this edit (empty for pure deletions).
pub inserted_text: String,
/// The text content of the node *before* this edit was applied.
pub old_text: String,
}
/// Union of all possible event data types.
#[derive(Debug, Clone, PartialEq)]
pub enum EventData {
/// Mouse event data
Mouse(MouseEventData),
/// Keyboard event data
Keyboard(KeyboardEventData),
/// Scroll event data
Scroll(ScrollEventData),
/// Touch event data
Touch(TouchEventData),
/// Clipboard event data
Clipboard(ClipboardEventData),
/// Text-input (editing) event data
TextInput(TextInputEventData),
/// Lifecycle event data
Lifecycle(LifecycleEventData),
/// Window event data
Window(WindowEventData),
/// No additional data
None,
}
/// High-level event type classification.
///
/// This enum categorizes all possible events that can occur in the UI.
/// It extends the existing event system with new event types for
/// lifecycle, clipboard, media, and form handling.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum EventType {
// Mouse Events
/// Mouse cursor is over the element
MouseOver,
/// Mouse cursor entered the element
MouseEnter,
/// Mouse cursor left the element
MouseLeave,
/// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
MouseOut,
/// Mouse button pressed
MouseDown,
/// Mouse button released
MouseUp,
/// Mouse click (down + up on same element)
Click,
/// Mouse double-click
DoubleClick,
/// Right-click / context menu
ContextMenu,
// Keyboard Events
/// Key pressed down
KeyDown,
/// Key released
KeyUp,
/// Character input (respects locale/keyboard layout)
KeyPress,
// IME Composition Events
/// IME composition started
CompositionStart,
/// IME composition updated (intermediate text changed)
CompositionUpdate,
/// IME composition ended (final text committed)
CompositionEnd,
// Focus Events
/// Element received focus
Focus,
/// Element lost focus
Blur,
/// Focus entered element or its children
FocusIn,
/// Focus left element and its children
FocusOut,
// Input Events
/// Input value is being changed (fires on every keystroke)
Input,
/// Input value has changed (fires after editing complete)
Change,
/// Form submitted
Submit,
/// Form reset
Reset,
/// Form validation failed
Invalid,
// Scroll Events
/// Element is being scrolled
Scroll,
/// Scroll started
ScrollStart,
/// Scroll ended
ScrollEnd,
// Drag Events
/// Drag operation started
DragStart,
/// Element is being dragged
Drag,
/// Drag operation ended
DragEnd,
/// Dragged element entered drop target
DragEnter,
/// Dragged element is over drop target
DragOver,
/// Dragged element left drop target
DragLeave,
/// Element was dropped
Drop,
// Touch Events
/// Touch started
TouchStart,
/// Touch moved
TouchMove,
/// Touch ended
TouchEnd,
/// Touch cancelled
TouchCancel,
// Pen / Stylus Events (W3C PointerEvent, pointerType "pen")
/// Pen tip made contact (or pen entered while down)
PenDown,
/// Pen moved (in contact or hovering in range)
PenMove,
/// Pen tip lifted
PenUp,
/// Pen entered hover/sensing range (proximity in)
PenEnter,
/// Pen left hover/sensing range (proximity out)
PenLeave,
// Gesture Events
/// Long press detected (touch or mouse held down)
LongPress,
/// Swipe gesture to the left
SwipeLeft,
/// Swipe gesture to the right
SwipeRight,
/// Swipe gesture upward
SwipeUp,
/// Swipe gesture downward
SwipeDown,
/// Pinch-in gesture (zoom out)
PinchIn,
/// Pinch-out gesture (zoom in)
PinchOut,
/// Clockwise rotation gesture
RotateClockwise,
/// Counter-clockwise rotation gesture
RotateCounterClockwise,
// Clipboard Events
/// Content copied to clipboard
Copy,
/// Content cut to clipboard
Cut,
/// Content pasted from clipboard
Paste,
// Media Events
/// Media playback started
Play,
/// Media playback paused
Pause,
/// Media playback ended
Ended,
/// Media time updated
TimeUpdate,
/// Media volume changed
VolumeChange,
/// Media error occurred
MediaError,
// Lifecycle Events
/// Component was mounted to the DOM
Mount,
/// Component will be unmounted from the DOM
Unmount,
/// Component was updated
Update,
/// Component layout bounds changed
Resize,
// Window Events
/// Window resized
WindowResize,
/// Window moved
WindowMove,
/// Window close requested
WindowClose,
/// Window received focus
WindowFocusIn,
/// Window lost focus
WindowFocusOut,
/// System theme changed
ThemeChange,
/// Window DPI/scale factor changed (moved to different monitor)
WindowDpiChanged,
/// Window moved to a different monitor
WindowMonitorChanged,
// Application Events
/// A monitor/display was connected
MonitorConnected,
/// A monitor/display was disconnected
MonitorDisconnected,
// File Events
/// File is being hovered
FileHover,
/// File was dropped
FileDrop,
/// File hover cancelled
FileHoverCancel,
// Hardware input-device Events (P6 sensors / gamepad)
/// A motion-sensor reading (accelerometer / gyroscope / magnetometer)
/// changed. Read the value with `CallbackInfo::get_sensor_reading`.
SensorChanged,
/// A gamepad's buttons / axes changed, or one was (dis)connected. Read it
/// with `CallbackInfo::get_primary_gamepad` / `get_gamepad_state`.
GamepadInput,
// Geolocation Events (MWA-A1 — synthesized by the capability pump's
// GeolocationManager EventProvider; both filter enums already carried
// the matching variants, only this dispatch type was missing them).
/// A new GPS / network location fix arrived. Read it with
/// `CallbackInfo::get_geolocation_fix`.
GeolocationFix,
/// The native geolocation subscription errored, timed out, or was
/// revoked.
GeolocationError,
// Async capability outcomes (MWA-A1b — synthesized by the capability
// pump's manager EventProviders so idle apps observe prompt results).
/// A permission's OS-observed state changed (granted / denied /
/// revoked / restricted). Targeted at the capability's most recent
/// subscriber node when known, else the root. Read the new state via
/// `CallbackInfo` permission accessors.
PermissionChanged,
/// A biometric authentication prompt completed. Read the outcome via
/// `CallbackInfo::get_biometric_result`.
BiometricResult,
/// A keyring store / get / delete operation completed. Read the outcome
/// via `CallbackInfo::get_keyring_result`.
KeyringResult,
}
/// Unified event wrapper (similar to React's `SyntheticEvent`).
///
/// All events in the system are wrapped in this structure, providing
/// a consistent interface and enabling event propagation control.
#[derive(Debug, Clone, PartialEq)]
pub struct SyntheticEvent {
/// The type of event
pub event_type: EventType,
/// Where the event came from
pub source: EventSource,
/// Current propagation phase
pub phase: EventPhase,
/// Target node that the event was dispatched to
pub target: DomNodeId,
/// Current node in the propagation path
pub current_target: DomNodeId,
/// Timestamp when event was created
pub timestamp: Instant,
/// Type-specific event data
pub data: EventData,
/// Whether propagation has been stopped
pub stopped: bool,
/// Whether immediate propagation has been stopped
pub stopped_immediate: bool,
/// Whether default action has been prevented
pub prevented_default: bool,
}
impl SyntheticEvent {
/// Create a new synthetic event.
///
/// # Parameters
/// - `timestamp`: Current time from `(system_callbacks.get_system_time_fn.cb)()`
#[must_use] pub const fn new(
event_type: EventType,
source: EventSource,
target: DomNodeId,
timestamp: Instant,
data: EventData,
) -> Self {
Self {
event_type,
source,
phase: EventPhase::Target,
target,
current_target: target,
timestamp,
data,
stopped: false,
stopped_immediate: false,
prevented_default: false,
}
}
/// Stop event propagation after the current phase completes.
///
/// This prevents the event from reaching handlers in subsequent phases
/// (e.g., stopping during capture prevents bubble phase).
pub const fn stop_propagation(&mut self) {
self.stopped = true;
}
/// Stop event propagation immediately.
///
/// This prevents any further handlers from being called, even on the
/// current target element.
pub const fn stop_immediate_propagation(&mut self) {
self.stopped_immediate = true;
self.stopped = true;
}
/// Prevent the default action associated with this event.
///
/// For example, prevents form submission on Enter key, or prevents
/// text selection on drag.
pub const fn prevent_default(&mut self) {
self.prevented_default = true;
}
/// Check if propagation was stopped.
#[must_use] pub const fn is_propagation_stopped(&self) -> bool {
self.stopped
}
/// Check if immediate propagation was stopped.
#[must_use] pub const fn is_immediate_propagation_stopped(&self) -> bool {
self.stopped_immediate
}
/// Check if default action was prevented.
#[must_use] pub const fn is_default_prevented(&self) -> bool {
self.prevented_default
}
}
/// Result of event propagation through DOM tree.
#[derive(Debug, Clone)]
#[derive(Default)]
pub struct PropagationResult {
/// Callbacks that should be invoked, in order
pub callbacks_to_invoke: Vec<(NodeId, EventFilter)>,
/// Whether default action should be prevented
pub default_prevented: bool,
}
/// Get the path from root to target node in the DOM tree.
///
/// This is used for event propagation - we need to know which nodes
/// are ancestors of the target to implement capture/bubble phases.
///
/// Returns nodes in order from root to target (inclusive).
#[must_use] pub fn get_dom_path(
node_hierarchy: &crate::id::NodeHierarchy,
target_node: NodeHierarchyItemId,
) -> Vec<NodeId> {
let mut path = Vec::new();
let Some(target_node_id) = target_node.into_crate_internal() else {
return path;
};
let hier_ref = node_hierarchy.as_ref();
// Build path from target to root. Bounded by the node count and guarded by a
// visited-set: a corrupt hierarchy with a parent cycle (or a parent chain
// longer than the arena) would otherwise loop forever / OOM here, and this
// runs on every event dispatch.
let node_count = hier_ref.len();
let mut visited: BTreeSet<NodeId> = BTreeSet::new();
let mut current = Some(target_node_id);
while let Some(node_id) = current {
if path.len() > node_count || !visited.insert(node_id) {
// Cycle or overrun detected: stop rather than spin forever.
break;
}
path.push(node_id);
current = hier_ref.get(node_id).and_then(|node| node.parent);
}
// Reverse to get root → target order
path.reverse();
path
}
/// Propagate event through DOM tree with capture and bubble phases.
///
/// This implements DOM Level 2 event propagation:
/// 1. **Capture Phase**: Event travels from root down to target
/// 2. **Target Phase**: Event is at the target element
/// 3. **Bubble Phase**: Event travels from target back up to root
///
/// The event can be stopped at any point via `stopPropagation()` or
/// `stopImmediatePropagation()`.
///
/// # Panics
///
/// Panics if `path` is empty; it must contain at least the target node.
pub fn propagate_event(
event: &mut SyntheticEvent,
node_hierarchy: &crate::id::NodeHierarchy,
callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
) -> PropagationResult {
let path = get_dom_path(node_hierarchy, event.target.node);
if path.is_empty() {
return PropagationResult::default();
}
let ancestors = &path[..path.len().saturating_sub(1)];
let target_node_id = *path.last().unwrap();
let mut result = PropagationResult::default();
// Phase 1: Capture (root → target)
propagate_phase(
event,
ancestors.iter().copied(),
EventPhase::Capture,
callbacks,
&mut result,
);
// Phase 2: Target
if !event.stopped {
propagate_target_phase(event, target_node_id, callbacks, &mut result);
}
// Phase 3: Bubble (target → root)
if !event.stopped {
propagate_phase(
event,
ancestors.iter().rev().copied(),
EventPhase::Bubble,
callbacks,
&mut result,
);
}
result.default_prevented = event.prevented_default;
result
}
/// Process a single propagation phase (Capture or Bubble)
fn propagate_phase(
event: &mut SyntheticEvent,
nodes: impl Iterator<Item = NodeId>,
phase: EventPhase,
callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
result: &mut PropagationResult,
) {
event.phase = phase;
for node_id in nodes {
if event.stopped_immediate || event.stopped {
return;
}
event.current_target = DomNodeId {
dom: event.target.dom,
node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
};
collect_matching_callbacks(event, node_id, phase, callbacks, result);
}
}
/// Process the target phase
fn propagate_target_phase(
event: &mut SyntheticEvent,
target_node_id: NodeId,
callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
result: &mut PropagationResult,
) {
event.phase = EventPhase::Target;
event.current_target = event.target;
collect_matching_callbacks(event, target_node_id, EventPhase::Target, callbacks, result);
}
/// Collect callbacks that match the current phase for a node
fn collect_matching_callbacks(
event: &SyntheticEvent,
node_id: NodeId,
phase: EventPhase,
callbacks: &BTreeMap<NodeId, Vec<EventFilter>>,
result: &mut PropagationResult,
) {
let Some(node_callbacks) = callbacks.get(&node_id) else {
return;
};
let matching = node_callbacks
.iter()
.take_while(|_| !event.stopped_immediate)
.filter(|filter| matches_filter_phase(**filter, event, phase))
.map(|filter| (node_id, *filter));
result.callbacks_to_invoke.extend(matching);
}
// =============================================================================
// DEFAULT ACTIONS (W3C UI Events / HTML5 Activation Behavior)
// =============================================================================
/// Default actions are built-in behaviors that occur in response to events.
///
/// Per W3C DOM Event specification:
/// > A default action is an action that the implementation is expected to take
/// > in response to an event, unless that action is cancelled by the script.
///
/// Examples:
/// - Tab key → move focus to next focusable element
/// - Enter/Space on button → activate (click) the button
/// - Escape → clear focus or close modal
/// - Arrow keys in listbox → move selection
///
/// Default actions are processed AFTER all event callbacks have been invoked,
/// and only if `event.prevent_default()` was NOT called.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C, u8)]
pub enum DefaultAction {
/// Move focus to the next focusable element (Tab key)
FocusNext,
/// Move focus to the previous focusable element (Shift+Tab)
FocusPrevious,
/// Move focus to the first focusable element
FocusFirst,
/// Move focus to the last focusable element
FocusLast,
/// Clear focus from the currently focused element (Escape key)
ClearFocus,
/// Activate the focused element (Enter/Space on activatable elements)
/// This generates a synthetic Click event on the target
ActivateFocusedElement {
target: DomNodeId,
},
/// Submit the form containing the focused element (Enter in form input)
SubmitForm {
form_node: DomNodeId,
},
/// Close the current modal/dialog (Escape key when modal is open)
CloseModal {
modal_node: DomNodeId,
},
/// Scroll the focused scrollable container
ScrollFocusedContainer {
direction: ScrollDirection,
amount: ScrollAmount,
},
/// Select all text in the focused text input (Ctrl+A / Cmd+A)
SelectAllText,
/// No default action for this event
None,
}
/// Amount to scroll for keyboard-based scrolling
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum ScrollAmount {
/// Scroll by one line (arrow keys)
Line,
/// Scroll by one page (Page Up/Down)
Page,
/// Scroll to start/end (Home/End)
Document,
}
/// Result of determining what default action should occur for an event.
///
/// This is computed AFTER event dispatch, based on:
/// 1. The event type
/// 2. The target element's type/role
/// 3. Whether `prevent_default()` was called
#[derive(Debug, Clone, Copy)]
#[repr(C)]
pub struct DefaultActionResult {
/// The default action to perform (if any)
pub action: DefaultAction,
/// Whether the action was prevented by a callback
pub prevented: bool,
}
impl Default for DefaultActionResult {
fn default() -> Self {
Self {
action: DefaultAction::None,
prevented: false,
}
}
}
impl DefaultActionResult {
/// Create a new result with a specific action
#[must_use] pub const fn new(action: DefaultAction) -> Self {
Self {
action,
prevented: false,
}
}
/// Create a prevented result (callback called `prevent_default`)
#[must_use] pub const fn prevented() -> Self {
Self {
action: DefaultAction::None,
prevented: true,
}
}
/// Check if there's an action to perform
#[must_use] pub const fn has_action(&self) -> bool {
!self.prevented && !matches!(self.action, DefaultAction::None)
}
}
/// Trait for elements that have activation behavior (can be "clicked" via keyboard).
///
/// Per HTML5 spec, elements with activation behavior include:
/// - `<button>` elements
/// - `<input type="submit">`, `<input type="button">`, `<input type="reset">`
/// - `<a>` elements with href
/// - `<area>` elements with href
/// - Any element with a click handler (implicit activation)
///
/// When an element with activation behavior is focused and the user presses
/// Enter or Space, a synthetic click event is generated.
pub trait ActivationBehavior {
/// Returns true if this element can be activated via keyboard (Enter/Space)
fn has_activation_behavior(&self) -> bool;
/// Returns true if this element is currently activatable
/// (e.g., not disabled, not aria-disabled="true")
fn is_activatable(&self) -> bool;
}
/// Trait to query if a node is focusable for tab navigation
pub trait Focusable {
/// Returns the tabindex value for this element (-1, 0, or positive)
fn get_tabindex(&self) -> Option<i32>;
/// Returns true if this element can receive focus
fn is_focusable(&self) -> bool;
/// Returns true if this element should be in the tab order
fn is_in_tab_order(&self) -> bool {
self.get_tabindex().map_or_else(|| self.is_naturally_focusable(), |i| i >= 0)
}
/// Returns true if this element type is naturally focusable
/// (button, input, select, textarea, a[href])
fn is_naturally_focusable(&self) -> bool;
}
/// Check if an event filter matches the given event in the current phase.
///
/// This is used during event propagation to determine which callbacks
/// should be invoked at each phase.
fn matches_filter_phase(
filter: EventFilter,
event: &SyntheticEvent,
current_phase: EventPhase,
) -> bool {
// azul has no capture-phase listeners (no `addEventListener(…, capture=true)`
// equivalent): every `EventFilter` is a bubble-phase listener, which by the W3C
// model fires only in the Target and Bubble phases — never Capture. Without this
// guard an ancestor node's Hover/Focus callback was collected in BOTH the capture
// and the bubble walk, so it fired TWICE whenever the hit target was a descendant
// (e.g. a menubar item, hit via its text child, opened two stacked popups; any
// button containing a text/child node ran its MouseUp callback twice).
if matches!(current_phase, EventPhase::Capture) {
return false;
}
match filter {
EventFilter::Hover(hover_filter) => {
matches_hover_filter(hover_filter, event, current_phase)
}
EventFilter::Focus(focus_filter) => {
matches_focus_filter(focus_filter, event, current_phase)
}
EventFilter::Window(window_filter) => {
matches_window_filter(window_filter, event, current_phase)
}
EventFilter::Component(component_filter) => {
matches_component_filter(component_filter, event, current_phase)
}
EventFilter::Application(_) => {
// Application events - will be implemented in future
false
}
}
}
/// Check if a component (lifecycle) filter matches the event.
///
/// Lifecycle events produced by `diff::reconcile_dom` carry the target node in
/// `SyntheticEvent.target`, so dispatchers that bypass `propagate_event` and
/// invoke the target directly also need a way to compare. This predicate is
/// the single source of truth for that comparison; changing it without
/// updating `event_type_to_filters` will de-sync dispatch.
const fn matches_component_filter(
filter: ComponentEventFilter,
event: &SyntheticEvent,
_phase: EventPhase,
) -> bool {
matches!(
(filter, &event.event_type),
(ComponentEventFilter::AfterMount, EventType::Mount)
| (ComponentEventFilter::BeforeUnmount, EventType::Unmount)
| (ComponentEventFilter::Updated, EventType::Update)
| (ComponentEventFilter::NodeResized, EventType::Resize)
)
}
/// Check if the event data contains a mouse event with the expected button.
fn check_mouse_button(data: &EventData, expected: MouseButton) -> bool {
if let EventData::Mouse(mouse_data) = data {
mouse_data.button == expected
} else {
false
}
}
/// Check if a hover filter matches the event.
// Exhaustive (filter, event-type) truth table: many distinct pairs share the
// `=> true` body. One arm per pair is intentional; merging into giant or-patterns
// would destroy the table's readability/maintainability.
#[allow(clippy::match_same_arms)]
fn matches_hover_filter(
filter: HoverEventFilter,
event: &SyntheticEvent,
_phase: EventPhase,
) -> bool {
use HoverEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, TouchStart, TouchMove, TouchEnd, TouchCancel, PenDown, PenMove, PenUp, PenEnter, PenLeave, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop, DoubleClick, SensorChanged, GamepadInput, GeolocationFix, GeolocationError, PermissionChanged, BiometricResult, KeyringResult};
match (filter, &event.event_type) {
(MouseOver, EventType::MouseOver) => true,
(MouseDown, EventType::MouseDown) => true,
(LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Right)
}
(MiddleMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Middle)
}
(MouseUp, EventType::MouseUp) => true,
(LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
(MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
(MouseEnter, EventType::MouseEnter) => true,
(MouseLeave, EventType::MouseLeave) => true,
(Scroll, EventType::Scroll) => true,
(ScrollStart, EventType::ScrollStart) => true,
(ScrollEnd, EventType::ScrollEnd) => true,
(TextInput, EventType::Input) => true,
(VirtualKeyDown, EventType::KeyDown) => true,
(VirtualKeyUp, EventType::KeyUp) => true,
(HoveredFile, EventType::FileHover) => true,
(DroppedFile, EventType::FileDrop) => true,
(HoveredFileCancelled, EventType::FileHoverCancel) => true,
(TouchStart, EventType::TouchStart) => true,
(TouchMove, EventType::TouchMove) => true,
(TouchEnd, EventType::TouchEnd) => true,
(TouchCancel, EventType::TouchCancel) => true,
(PenDown, EventType::PenDown) => true,
(PenMove, EventType::PenMove) => true,
(PenUp, EventType::PenUp) => true,
(PenEnter, EventType::PenEnter) => true,
(PenLeave, EventType::PenLeave) => true,
(DragStart, EventType::DragStart) => true,
(Drag, EventType::Drag) => true,
(DragEnd, EventType::DragEnd) => true,
(DragEnter, EventType::DragEnter) => true,
(DragOver, EventType::DragOver) => true,
(DragLeave, EventType::DragLeave) => true,
(Drop, EventType::Drop) => true,
(DoubleClick, EventType::DoubleClick) => true,
(SensorChanged, EventType::SensorChanged) => true,
(GamepadInput, EventType::GamepadInput) => true,
(GeolocationFix, EventType::GeolocationFix) => true,
(GeolocationError, EventType::GeolocationError) => true,
(PermissionChanged, EventType::PermissionChanged) => true,
(BiometricResult, EventType::BiometricResult) => true,
(KeyringResult, EventType::KeyringResult) => true,
_ => false,
}
}
/// Check if a focus filter matches the event.
// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
#[allow(clippy::match_same_arms)]
fn matches_focus_filter(
filter: FocusEventFilter,
event: &SyntheticEvent,
_phase: EventPhase,
) -> bool {
use FocusEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, FocusReceived, FocusLost, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop};
match (filter, &event.event_type) {
(MouseOver, EventType::MouseOver) => true,
(MouseDown, EventType::MouseDown) => true,
(LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Right)
}
(MiddleMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Middle)
}
(MouseUp, EventType::MouseUp) => true,
(LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
(MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
(MouseEnter, EventType::MouseEnter) => true,
(MouseLeave, EventType::MouseLeave) => true,
(Scroll, EventType::Scroll) => true,
(ScrollStart, EventType::ScrollStart) => true,
(ScrollEnd, EventType::ScrollEnd) => true,
(TextInput, EventType::Input) => true,
(VirtualKeyDown, EventType::KeyDown) => true,
(VirtualKeyUp, EventType::KeyUp) => true,
(FocusReceived, EventType::Focus) => true,
(FocusLost, EventType::Blur) => true,
(DragStart, EventType::DragStart) => true,
(Drag, EventType::Drag) => true,
(DragEnd, EventType::DragEnd) => true,
(DragEnter, EventType::DragEnter) => true,
(DragOver, EventType::DragOver) => true,
(DragLeave, EventType::DragLeave) => true,
(Drop, EventType::Drop) => true,
// MWA-C-clipboard: W3C clipboard events on the focused element
// (qualified paths — `use FocusEventFilter::Copy` would shadow the
// `Copy` trait in this scope).
(FocusEventFilter::Copy, EventType::Copy) => true,
(FocusEventFilter::Cut, EventType::Cut) => true,
(FocusEventFilter::Paste, EventType::Paste) => true,
_ => false,
}
}
/// Check if a window filter matches the event.
// Exhaustive (filter, event-type) truth table — see matches_hover_filter.
#[allow(clippy::match_same_arms)]
fn matches_window_filter(
filter: WindowEventFilter,
event: &SyntheticEvent,
_phase: EventPhase,
) -> bool {
use WindowEventFilter::{MouseOver, MouseDown, LeftMouseDown, RightMouseDown, MiddleMouseDown, MouseUp, LeftMouseUp, RightMouseUp, MiddleMouseUp, MouseEnter, MouseLeave, Scroll, ScrollStart, ScrollEnd, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, Resized, Moved, TouchStart, TouchMove, TouchEnd, TouchCancel, PenDown, PenMove, PenUp, PenEnter, PenLeave, FocusReceived, FocusLost, CloseRequested, ThemeChanged, WindowFocusReceived, WindowFocusLost, SensorChanged, GamepadInput, GeolocationFix, GeolocationError, PermissionChanged, BiometricResult, KeyringResult, DragStart, Drag, DragEnd, DragEnter, DragOver, DragLeave, Drop};
match (filter, &event.event_type) {
(MouseOver, EventType::MouseOver) => true,
(MouseDown, EventType::MouseDown) => true,
(LeftMouseDown, EventType::MouseDown) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Right)
}
(MiddleMouseDown, EventType::MouseDown) => {
check_mouse_button(&event.data, MouseButton::Middle)
}
(MouseUp, EventType::MouseUp) => true,
(LeftMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Left),
(RightMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Right),
(MiddleMouseUp, EventType::MouseUp) => check_mouse_button(&event.data, MouseButton::Middle),
(MouseEnter, EventType::MouseEnter) => true,
(MouseLeave, EventType::MouseLeave) => true,
(Scroll, EventType::Scroll) => true,
(ScrollStart, EventType::ScrollStart) => true,
(ScrollEnd, EventType::ScrollEnd) => true,
(TextInput, EventType::Input) => true,
(VirtualKeyDown, EventType::KeyDown) => true,
(VirtualKeyUp, EventType::KeyUp) => true,
(HoveredFile, EventType::FileHover) => true,
(DroppedFile, EventType::FileDrop) => true,
(HoveredFileCancelled, EventType::FileHoverCancel) => true,
(Resized, EventType::WindowResize) => true,
(Moved, EventType::WindowMove) => true,
(TouchStart, EventType::TouchStart) => true,
(TouchMove, EventType::TouchMove) => true,
(TouchEnd, EventType::TouchEnd) => true,
(TouchCancel, EventType::TouchCancel) => true,
(PenDown, EventType::PenDown) => true,
(PenMove, EventType::PenMove) => true,
(PenUp, EventType::PenUp) => true,
(PenEnter, EventType::PenEnter) => true,
(PenLeave, EventType::PenLeave) => true,
(FocusReceived, EventType::Focus) => true,
(FocusLost, EventType::Blur) => true,
(CloseRequested, EventType::WindowClose) => true,
(ThemeChanged, EventType::ThemeChange) => true,
(WindowFocusReceived, EventType::WindowFocusIn) => true,
(WindowFocusLost, EventType::WindowFocusOut) => true,
(SensorChanged, EventType::SensorChanged) => true,
(GamepadInput, EventType::GamepadInput) => true,
(GeolocationFix, EventType::GeolocationFix) => true,
(GeolocationError, EventType::GeolocationError) => true,
(PermissionChanged, EventType::PermissionChanged) => true,
(BiometricResult, EventType::BiometricResult) => true,
(KeyringResult, EventType::KeyringResult) => true,
(DragStart, EventType::DragStart) => true,
(Drag, EventType::Drag) => true,
(DragEnd, EventType::DragEnd) => true,
(DragEnter, EventType::DragEnter) => true,
(DragOver, EventType::DragOver) => true,
(DragLeave, EventType::DragLeave) => true,
(Drop, EventType::Drop) => true,
_ => false,
}
}
/// Detect lifecycle events by comparing old and new DOM state.
///
/// This is the simple, index-based lifecycle detection that doesn't account for
/// node reordering. For more sophisticated reconciliation that can detect moves,
/// use `detect_lifecycle_events_with_reconciliation`.
///
/// Generates Mount, Unmount, and Resize events by comparing DOM hierarchies.
#[allow(clippy::needless_pass_by_value)] // owned azul value taken by value (public API / ownership-transfer convention)
#[must_use] pub fn detect_lifecycle_events(
old_dom_id: DomId,
new_dom_id: DomId,
old_hierarchy: Option<&crate::id::NodeHierarchy>,
new_hierarchy: Option<&crate::id::NodeHierarchy>,
old_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
new_layout: Option<&BTreeMap<NodeId, LogicalRect>>,
timestamp: Instant,
) -> Vec<SyntheticEvent> {
let old_nodes = collect_node_ids(old_hierarchy);
let new_nodes = collect_node_ids(new_hierarchy);
let mut events = Vec::new();
// Mount events: nodes in new but not in old
if let Some(layout) = new_layout {
for &node_id in new_nodes.difference(&old_nodes) {
events.push(create_mount_event(node_id, new_dom_id, layout, ×tamp));
}
}
// Unmount events: nodes in old but not in new
if let Some(layout) = old_layout {
for &node_id in old_nodes.difference(&new_nodes) {
events.push(create_unmount_event(
node_id, old_dom_id, layout, ×tamp,
));
}
}
// Resize events: nodes in both with changed bounds
if let (Some(old_l), Some(new_l)) = (old_layout, new_layout) {
for &node_id in old_nodes.intersection(&new_nodes) {
if let Some(ev) = create_resize_event(node_id, new_dom_id, old_l, new_l, ×tamp) {
events.push(ev);
}
}
}
events
}
fn collect_node_ids(hierarchy: Option<&crate::id::NodeHierarchy>) -> BTreeSet<NodeId> {
hierarchy
.map(|h| h.as_ref().linear_iter().collect())
.unwrap_or_default()
}
fn create_lifecycle_event(
event_type: EventType,
node_id: NodeId,
dom_id: DomId,
timestamp: &Instant,
data: LifecycleEventData,
) -> SyntheticEvent {
let dom_node_id = DomNodeId {
dom: dom_id,
node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
};
SyntheticEvent {
event_type,
source: EventSource::Lifecycle,
phase: EventPhase::Target,
target: dom_node_id,
current_target: dom_node_id,
timestamp: timestamp.clone(),
data: EventData::Lifecycle(data),
stopped: false,
stopped_immediate: false,
prevented_default: false,
}
}
fn create_mount_event(
node_id: NodeId,
dom_id: DomId,
layout: &BTreeMap<NodeId, LogicalRect>,
timestamp: &Instant,
) -> SyntheticEvent {
let current_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
create_lifecycle_event(
EventType::Mount,
node_id,
dom_id,
timestamp,
LifecycleEventData {
reason: LifecycleReason::InitialMount,
previous_bounds: None,
current_bounds,
},
)
}
fn create_unmount_event(
node_id: NodeId,
dom_id: DomId,
layout: &BTreeMap<NodeId, LogicalRect>,
timestamp: &Instant,
) -> SyntheticEvent {
let previous_bounds = layout.get(&node_id).copied().unwrap_or(LogicalRect::zero());
create_lifecycle_event(
EventType::Unmount,
node_id,
dom_id,
timestamp,
LifecycleEventData {
reason: LifecycleReason::Unmount,
previous_bounds: Some(previous_bounds),
current_bounds: LogicalRect::zero(),
},
)
}
/// Returns `true` iff the two logical sizes differ after fixed-point
/// quantization (~0.001 tolerance), treating a dimension that is NaN on *both*
/// sides as unchanged so a degenerate layout cannot emit a Resize every frame.
fn size_changed(old: crate::geom::LogicalSize, new: crate::geom::LogicalSize) -> bool {
fn dim_changed(a: f32, b: f32) -> bool {
if a.is_nan() && b.is_nan() {
return false;
}
// Fixed-point quantization mirrors `LogicalSize`'s `Ord`/`Hash`.
// `f32 as i64` saturates on overflow (no wasm32 wraparound); a lone
// NaN quantizes to `i64::MIN` and so registers as changed.
#[allow(clippy::cast_possible_truncation)] // intentional fixed-point quantization; saturates
let q = |v: f32| -> i64 {
if v.is_nan() {
i64::MIN
} else {
(v * 1000.0) as i64
}
};
q(a) != q(b)
}
dim_changed(old.width, new.width) || dim_changed(old.height, new.height)
}
fn create_resize_event(
node_id: NodeId,
dom_id: DomId,
old_layout: &BTreeMap<NodeId, LogicalRect>,
new_layout: &BTreeMap<NodeId, LogicalRect>,
timestamp: &Instant,
) -> Option<SyntheticEvent> {
let old_bounds = *old_layout.get(&node_id)?;
let new_bounds = *new_layout.get(&node_id)?;
// Quantized/tolerance compare with an explicit NaN guard. A raw `==` on
// `LogicalSize` used to compare f32 bit patterns, so a single NaN dimension
// made `old != new` true *every frame forever* -> an endless Resize-event
// loop. `size_changed` treats a NaN dimension present on both sides as
// "unchanged" and otherwise compares fixed-point-quantized values.
if !size_changed(old_bounds.size, new_bounds.size) {
return None;
}
Some(create_lifecycle_event(
EventType::Resize,
node_id,
dom_id,
timestamp,
LifecycleEventData {
reason: LifecycleReason::Resize,
previous_bounds: Some(old_bounds),
current_bounds: new_bounds,
},
))
}
/// Result of lifecycle event detection with reconciliation.
///
/// Contains both the generated lifecycle events and a mapping from old to new
/// node IDs for state migration (focus, scroll, etc.).
#[derive(Debug, Clone, Default)]
pub struct LifecycleEventResult {
/// Lifecycle events (Mount, Unmount, Resize, Update)
pub events: Vec<SyntheticEvent>,
/// Maps old `NodeId` -> new `NodeId` for matched nodes.
/// Use this to migrate focus, scroll state, and other node-specific state.
pub node_id_mapping: OrderedMap<NodeId, NodeId>,
}
/// Detect lifecycle events using reconciliation with stable keys and content hashing.
///
/// This is the advanced lifecycle detection that can correctly identify:
/// - **Moves**: When a node changes position but keeps its identity (via key or hash)
/// - **Mounts**: When a new node appears
/// - **Unmounts**: When an existing node disappears
/// - **Resizes**: When a node's layout bounds change
/// - **Updates**: When a keyed node's content changes
///
/// The reconciliation strategy is:
/// 1. **Stable Key Match:** Nodes with `.with_reconciliation_key()` are matched by key (O(1))
/// 2. **Hash Match:** Nodes without keys are matched by content hash (enables reorder detection)
/// 3. **Fallback:** Unmatched nodes generate Mount/Unmount events
///
/// # Arguments
/// * `dom_id` - The DOM identifier
/// * `old_node_data` - Node data from the previous frame
/// * `new_node_data` - Node data from the current frame
/// * `old_layout` - Layout bounds from the previous frame
/// * `new_layout` - Layout bounds from the current frame
/// * `timestamp` - Current timestamp for events
///
/// # Returns
/// A `LifecycleEventResult` containing:
/// - `events`: Lifecycle events to dispatch
/// - `node_id_mapping`: Mapping from old to new `NodeIds` for state migration
///
/// # Example
/// ```rust,ignore
/// let result = detect_lifecycle_events_with_reconciliation(
/// dom_id,
/// &old_node_data,
/// &new_node_data,
/// &old_layout,
/// &new_layout,
/// timestamp,
/// );
///
/// // Dispatch lifecycle events
/// for event in result.events {
/// dispatch_event(event);
/// }
///
/// // Migrate focus to new node ID
/// if let Some(focused) = focus_manager.focused_node {
/// if let Some(&new_id) = result.node_id_mapping.get(&focused) {
/// focus_manager.focused_node = Some(new_id);
/// } else {
/// // Focused node was unmounted
/// focus_manager.focused_node = None;
/// }
/// }
/// ```
#[must_use] pub fn detect_lifecycle_events_with_reconciliation(
dom_id: DomId,
old_node_data: &[crate::dom::NodeData],
new_node_data: &[crate::dom::NodeData],
old_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
new_hierarchy: &[crate::styled_dom::NodeHierarchyItem],
old_layout: &OrderedMap<NodeId, LogicalRect>,
new_layout: &OrderedMap<NodeId, LogicalRect>,
timestamp: Instant,
) -> LifecycleEventResult {
let diff_result = crate::diff::reconcile_dom(
old_node_data,
new_node_data,
old_hierarchy,
new_hierarchy,
old_layout,
new_layout,
dom_id,
timestamp,
);
LifecycleEventResult {
events: diff_result.events,
node_id_mapping: crate::diff::create_migration_map(&diff_result.node_moves),
}
}
/// Event filter that only fires when an element is hovered over.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum HoverEventFilter {
/// Mouse moved over the hovered element
MouseOver,
/// Any mouse button pressed on the hovered element
MouseDown,
/// Left mouse button pressed on the hovered element
LeftMouseDown,
/// Right mouse button pressed on the hovered element
RightMouseDown,
/// Middle mouse button pressed on the hovered element
MiddleMouseDown,
/// Any mouse button released on the hovered element
MouseUp,
/// Left mouse button released on the hovered element
LeftMouseUp,
/// Right mouse button released on the hovered element
RightMouseUp,
/// Middle mouse button released on the hovered element
MiddleMouseUp,
/// Mouse entered the hovered element bounds
MouseEnter,
/// Mouse left the hovered element bounds
MouseLeave,
/// Scroll event on the hovered element
Scroll,
/// Scroll started on the hovered element
ScrollStart,
/// Scroll ended on the hovered element
ScrollEnd,
/// Text input received while element is hovered
TextInput,
/// Virtual key pressed while element is hovered
VirtualKeyDown,
/// Virtual key released while element is hovered
VirtualKeyUp,
/// File is being hovered over the element
HoveredFile,
/// File was dropped onto the element
DroppedFile,
/// File hover was cancelled
HoveredFileCancelled,
/// Touch started on the hovered element
TouchStart,
/// Touch moved on the hovered element
TouchMove,
/// Touch ended on the hovered element
TouchEnd,
/// Touch was cancelled on the hovered element
TouchCancel,
/// Pen/stylus made contact on the hovered element
PenDown,
/// Pen/stylus moved while in contact on the hovered element
PenMove,
/// Pen/stylus lifted from the hovered element
PenUp,
/// Pen/stylus entered proximity of the hovered element
PenEnter,
/// Pen/stylus left proximity of the hovered element
PenLeave,
/// Apple Pencil 2 / Surface Slim Pen 2 barrel squeeze on the hovered
/// element. Fires once per squeeze. The matching W3C primitive is the
/// `PointerEvent` with `pointerType: "pen"` and a transient
/// `tangentialPressure` spike — most apps tie a tool-switch to it.
PenSqueeze,
/// Apple Pencil 2 side double-tap on the hovered element. Fires once
/// per gesture. Usually mapped to "undo" or "switch eraser".
PenDoubleTap,
/// Pen/stylus is hovering above the hovered element (in proximity,
/// not in contact). Continuous: fires per pen-axis update while the
/// stylus is held above the surface. Maps to W3C
/// `PointerEvent('pointermove')` with `buttons: 0` and
/// `pointerType: 'pen'`.
PenHover,
/// New GPS / network location fix arrived for a `GeolocationProbe`
/// in this node's subtree. Payload accessor:
/// `CallbackInfo::get_geolocation_fix()`.
GeolocationFix,
/// Native geolocation subscription errored / was revoked /
/// timed out.
GeolocationError,
/// A motion-sensor reading changed (P6). Window-level mirror:
/// `WindowEventFilter::SensorChanged`. Read via `get_sensor_reading`.
SensorChanged,
/// A gamepad's state changed / it (dis)connected (P6). Read via
/// `get_primary_gamepad` / `get_gamepad_state`.
GamepadInput,
/// Drag started on the hovered element
DragStart,
/// Drag in progress on the hovered element
Drag,
/// Drag ended on the hovered element
DragEnd,
/// Dragged element entered this element (drop target)
DragEnter,
/// Dragged element is over this element (drop target, fires continuously)
DragOver,
/// Dragged element left this element (drop target)
DragLeave,
/// Element was dropped on this element (drop target)
Drop,
/// Double-click detected on the hovered element
DoubleClick,
/// Long press detected on the hovered element
LongPress,
/// Swipe left gesture on the hovered element
SwipeLeft,
/// Swipe right gesture on the hovered element
SwipeRight,
/// Swipe up gesture on the hovered element
SwipeUp,
/// Swipe down gesture on the hovered element
SwipeDown,
/// Pinch-in (zoom out) gesture on the hovered element
PinchIn,
/// Pinch-out (zoom in) gesture on the hovered element
PinchOut,
/// Clockwise rotation gesture on the hovered element
RotateClockwise,
/// Counter-clockwise rotation gesture on the hovered element
RotateCounterClockwise,
// W3C MouseOut event (bubbling version of MouseLeave)
/// Mouse left the element OR moved to a child element (W3C `mouseout`, bubbles)
MouseOut,
// W3C Focus events (bubbling versions)
/// Focus is about to move INTO this element or a descendant (W3C `focusin`, bubbles)
FocusIn,
/// Focus is about to move OUT of this element or a descendant (W3C `focusout`, bubbles)
FocusOut,
// IME Composition events
/// IME composition started (W3C `compositionstart`)
CompositionStart,
/// IME composition updated (W3C `compositionupdate`)
CompositionUpdate,
/// IME composition ended (W3C `compositionend`)
CompositionEnd,
// Internal System Events (not exposed to user callbacks)
#[doc(hidden)]
/// Internal: Single click for text cursor placement
SystemTextSingleClick,
#[doc(hidden)]
/// Internal: Double click for word selection
SystemTextDoubleClick,
#[doc(hidden)]
/// Internal: Triple click for paragraph/line selection
SystemTextTripleClick,
// Async capability outcomes (MWA-A1b)
/// A permission's OS-observed state changed while this node (the
/// capability's most recent subscriber) is in the target chain.
PermissionChanged,
/// A biometric authentication prompt completed.
BiometricResult,
/// A keyring store / get / delete operation completed.
KeyringResult,
}
impl HoverEventFilter {
/// Check if this is an internal system event that should not be exposed to user callbacks
#[must_use] pub const fn is_system_internal(&self) -> bool {
matches!(
self,
Self::SystemTextSingleClick
| Self::SystemTextDoubleClick
| Self::SystemTextTripleClick
)
}
// Exhaustive On -> Option<FocusEventFilter> mapping table; the several `=> None`
// rows (window-only events) are intentional 1:1 rows — merging would collapse the table.
#[allow(clippy::match_same_arms)]
#[must_use] pub const fn to_focus_event_filter(&self) -> Option<FocusEventFilter> {
match self {
Self::MouseOver => Some(FocusEventFilter::MouseOver),
Self::MouseDown => Some(FocusEventFilter::MouseDown),
Self::LeftMouseDown => Some(FocusEventFilter::LeftMouseDown),
Self::RightMouseDown => Some(FocusEventFilter::RightMouseDown),
Self::MiddleMouseDown => Some(FocusEventFilter::MiddleMouseDown),
Self::MouseUp => Some(FocusEventFilter::MouseUp),
Self::LeftMouseUp => Some(FocusEventFilter::LeftMouseUp),
Self::RightMouseUp => Some(FocusEventFilter::RightMouseUp),
Self::MiddleMouseUp => Some(FocusEventFilter::MiddleMouseUp),
Self::MouseEnter => Some(FocusEventFilter::MouseEnter),
Self::MouseLeave => Some(FocusEventFilter::MouseLeave),
Self::Scroll => Some(FocusEventFilter::Scroll),
Self::ScrollStart => Some(FocusEventFilter::ScrollStart),
Self::ScrollEnd => Some(FocusEventFilter::ScrollEnd),
Self::TextInput => Some(FocusEventFilter::TextInput),
Self::VirtualKeyDown => Some(FocusEventFilter::VirtualKeyDown),
Self::VirtualKeyUp => Some(FocusEventFilter::VirtualKeyUp),
Self::HoveredFile => None,
Self::DroppedFile => None,
Self::HoveredFileCancelled => None,
Self::TouchStart => None,
Self::TouchMove => None,
Self::TouchEnd => None,
Self::TouchCancel => None,
Self::PenDown => Some(FocusEventFilter::PenDown),
Self::PenMove => Some(FocusEventFilter::PenMove),
Self::PenUp => Some(FocusEventFilter::PenUp),
Self::PenEnter => None,
Self::PenLeave => None,
Self::PenSqueeze => None,
Self::PenDoubleTap => None,
Self::PenHover => None,
Self::GeolocationFix => None,
Self::GeolocationError => None,
Self::SensorChanged => None,
Self::GamepadInput => None,
Self::DragStart => Some(FocusEventFilter::DragStart),
Self::Drag => Some(FocusEventFilter::Drag),
Self::DragEnd => Some(FocusEventFilter::DragEnd),
Self::DragEnter => Some(FocusEventFilter::DragEnter),
Self::DragOver => Some(FocusEventFilter::DragOver),
Self::DragLeave => Some(FocusEventFilter::DragLeave),
Self::Drop => Some(FocusEventFilter::Drop),
Self::DoubleClick => Some(FocusEventFilter::DoubleClick),
Self::LongPress => Some(FocusEventFilter::LongPress),
Self::SwipeLeft => Some(FocusEventFilter::SwipeLeft),
Self::SwipeRight => Some(FocusEventFilter::SwipeRight),
Self::SwipeUp => Some(FocusEventFilter::SwipeUp),
Self::SwipeDown => Some(FocusEventFilter::SwipeDown),
Self::PinchIn => Some(FocusEventFilter::PinchIn),
Self::PinchOut => Some(FocusEventFilter::PinchOut),
Self::RotateClockwise => Some(FocusEventFilter::RotateClockwise),
Self::RotateCounterClockwise => {
Some(FocusEventFilter::RotateCounterClockwise)
}
Self::MouseOut => Some(FocusEventFilter::MouseLeave), // mouseout → closest focus equivalent
Self::FocusIn => Some(FocusEventFilter::FocusIn),
Self::FocusOut => Some(FocusEventFilter::FocusOut),
Self::CompositionStart => Some(FocusEventFilter::CompositionStart),
Self::CompositionUpdate => Some(FocusEventFilter::CompositionUpdate),
Self::CompositionEnd => Some(FocusEventFilter::CompositionEnd),
// System internal events - don't convert to focus events
Self::SystemTextSingleClick => None,
Self::SystemTextDoubleClick => None,
Self::SystemTextTripleClick => None,
// Async capability outcomes — no focus-filter equivalents
Self::PermissionChanged => None,
Self::BiometricResult => None,
Self::KeyringResult => None,
}
}
}
/// Event filter similar to `HoverEventFilter` that only fires when the element is focused.
///
/// **Important**: In order for this to fire, the item must have a `tabindex` attribute
/// (to indicate that the item is focus-able).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum FocusEventFilter {
/// Mouse moved over the focused element
MouseOver,
/// Any mouse button pressed on the focused element
MouseDown,
/// Left mouse button pressed on the focused element
LeftMouseDown,
/// Right mouse button pressed on the focused element
RightMouseDown,
/// Middle mouse button pressed on the focused element
MiddleMouseDown,
/// Any mouse button released on the focused element
MouseUp,
/// Left mouse button released on the focused element
LeftMouseUp,
/// Right mouse button released on the focused element
RightMouseUp,
/// Middle mouse button released on the focused element
MiddleMouseUp,
/// Mouse entered the focused element bounds
MouseEnter,
/// Mouse left the focused element bounds
MouseLeave,
/// Scroll event on the focused element
Scroll,
/// Scroll started on the focused element
ScrollStart,
/// Scroll ended on the focused element
ScrollEnd,
/// Text input received while element is focused
TextInput,
/// Virtual key pressed while element is focused
VirtualKeyDown,
/// Virtual key released while element is focused
VirtualKeyUp,
/// Element received keyboard focus
FocusReceived,
/// Element lost keyboard focus
FocusLost,
/// Pen/stylus made contact on the focused element
PenDown,
/// Pen/stylus moved while in contact on the focused element
PenMove,
/// Pen/stylus lifted from the focused element
PenUp,
/// Drag started on the focused element
DragStart,
/// Drag in progress on the focused element
Drag,
/// Drag ended on the focused element
DragEnd,
/// Dragged element entered this focused element (drop target)
DragEnter,
/// Dragged element is over this focused element (drop target)
DragOver,
/// Dragged element left this focused element (drop target)
DragLeave,
/// Element was dropped on this focused element (drop target)
Drop,
/// Double-click detected on the focused element
DoubleClick,
/// Long press detected on the focused element
LongPress,
/// Swipe left gesture on the focused element
SwipeLeft,
/// Swipe right gesture on the focused element
SwipeRight,
/// Swipe up gesture on the focused element
SwipeUp,
/// Swipe down gesture on the focused element
SwipeDown,
/// Pinch-in (zoom out) gesture on the focused element
PinchIn,
/// Pinch-out (zoom in) gesture on the focused element
PinchOut,
/// Clockwise rotation gesture on the focused element
RotateClockwise,
/// Counter-clockwise rotation gesture on the focused element
RotateCounterClockwise,
// W3C Focus events (bubbling versions, fires on focused element when focus changes)
/// Focus moved into this element or a descendant (W3C `focusin`)
FocusIn,
/// Focus moved out of this element or a descendant (W3C `focusout`)
FocusOut,
// IME Composition events
/// IME composition started (W3C `compositionstart`)
CompositionStart,
/// IME composition updated (W3C `compositionupdate`)
CompositionUpdate,
/// IME composition ended (W3C `compositionend`)
CompositionEnd,
// Clipboard events (W3C clipboard-events; MWA-C-clipboard: fire on the
// focused element BEFORE the OS default action, which preventDefault
// suppresses). APPENDED at the end for ABI stability — sync to api.json
// via azul-doc autofix in Phase D.
/// Content is about to be copied from the focused element (W3C `copy`)
Copy,
/// Content is about to be cut from the focused element (W3C `cut`)
Cut,
/// Content is about to be pasted into the focused element (W3C `paste`)
Paste,
}
/// Event filter that fires when any action fires on the entire window
/// (regardless of whether any element is hovered or focused over).
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C)]
pub enum WindowEventFilter {
/// Mouse moved anywhere in window
MouseOver,
/// Any mouse button pressed anywhere in window
MouseDown,
/// Left mouse button pressed anywhere in window
LeftMouseDown,
/// Right mouse button pressed anywhere in window
RightMouseDown,
/// Middle mouse button pressed anywhere in window
MiddleMouseDown,
/// Any mouse button released anywhere in window
MouseUp,
/// Left mouse button released anywhere in window
LeftMouseUp,
/// Right mouse button released anywhere in window
RightMouseUp,
/// Middle mouse button released anywhere in window
MiddleMouseUp,
/// Mouse entered the window
MouseEnter,
/// Mouse left the window
MouseLeave,
/// Scroll event anywhere in window
Scroll,
/// Scroll started anywhere in window
ScrollStart,
/// Scroll ended anywhere in window
ScrollEnd,
/// Text input received in window
TextInput,
/// Virtual key pressed in window
VirtualKeyDown,
/// Virtual key released in window
VirtualKeyUp,
/// File is being hovered over the window
HoveredFile,
/// File was dropped onto the window
DroppedFile,
/// File hover was cancelled
HoveredFileCancelled,
/// Window was resized
Resized,
/// Window was moved
Moved,
/// Touch started anywhere in window
TouchStart,
/// Touch moved anywhere in window
TouchMove,
/// Touch ended anywhere in window
TouchEnd,
/// Touch was cancelled
TouchCancel,
/// Window received focus
FocusReceived,
/// Window lost focus
FocusLost,
/// Window close was requested
CloseRequested,
/// System theme changed (light/dark mode)
ThemeChanged,
/// Window received OS-level focus
WindowFocusReceived,
/// Window lost OS-level focus
WindowFocusLost,
/// Pen/stylus made contact anywhere in window
PenDown,
/// Pen/stylus moved while in contact anywhere in window
PenMove,
/// Pen/stylus lifted anywhere in window
PenUp,
/// Pen/stylus entered window proximity
PenEnter,
/// Pen/stylus left window proximity
PenLeave,
/// Pen barrel-squeeze gesture fired in the window. See
/// [`HoverEventFilter::PenSqueeze`].
PenSqueeze,
/// Pen side double-tap gesture fired in the window. See
/// [`HoverEventFilter::PenDoubleTap`].
PenDoubleTap,
/// Pen hover in the window (in proximity, not in contact). See
/// [`HoverEventFilter::PenHover`].
PenHover,
/// New GPS / network location fix arrived. Payload accessor:
/// `CallbackInfo::get_geolocation_fix()`. Window-level rather
/// than per-node because the user's location isn't bound to any
/// particular DOM node — but a node-level mirror
/// (`HoverEventFilter::GeolocationFix`) fires on every
/// `GeolocationProbe` in the tree as well, for the common
/// "redraw this node when the location changes" pattern.
GeolocationFix,
/// Native geolocation subscription dropped or errored (signal
/// lost, no provider, permission revoked mid-session).
GeolocationError,
/// A motion-sensor reading changed (P6). Fires window-level (the device
/// isn't bound to a node); read via `CallbackInfo::get_sensor_reading`.
SensorChanged,
/// A gamepad's buttons / axes changed or it (dis)connected (P6); read via
/// `get_primary_gamepad` / `get_gamepad_state`.
GamepadInput,
/// Drag started anywhere in window
DragStart,
/// Drag in progress anywhere in window
Drag,
/// Drag ended anywhere in window
DragEnd,
/// Dragged element entered a drop target in window
DragEnter,
/// Dragged element is over a drop target in window
DragOver,
/// Dragged element left a drop target in window
DragLeave,
/// Element was dropped on a drop target in window
Drop,
/// Double-click detected anywhere in window
DoubleClick,
/// Long press detected anywhere in window
LongPress,
/// Swipe left gesture anywhere in window
SwipeLeft,
/// Swipe right gesture anywhere in window
SwipeRight,
/// Swipe up gesture anywhere in window
SwipeUp,
/// Swipe down gesture anywhere in window
SwipeDown,
/// Pinch-in (zoom out) gesture anywhere in window
PinchIn,
/// Pinch-out (zoom in) gesture anywhere in window
PinchOut,
/// Clockwise rotation gesture anywhere in window
RotateClockwise,
/// Counter-clockwise rotation gesture anywhere in window
RotateCounterClockwise,
/// The window's DPI scale factor changed (e.g., moved to a monitor with
/// different scaling). The new DPI is available via `CallbackInfo::get_hidpi_factor()`.
DpiChanged,
/// The window moved to a different monitor. The new monitor is available
/// via `CallbackInfo::get_current_monitor()`.
MonitorChanged,
// Async capability outcomes (MWA-A1b) — window-level mirrors (the
// outcome isn't inherently bound to a node).
/// A permission's OS-observed state changed.
PermissionChanged,
/// A biometric authentication prompt completed.
BiometricResult,
/// A keyring store / get / delete operation completed.
KeyringResult,
}
impl WindowEventFilter {
// Exhaustive On -> Option<HoverEventFilter> mapping table (see to_focus_event_filter).
#[allow(clippy::match_same_arms)]
#[must_use] pub const fn to_hover_event_filter(&self) -> Option<HoverEventFilter> {
match self {
Self::MouseOver => Some(HoverEventFilter::MouseOver),
Self::MouseDown => Some(HoverEventFilter::MouseDown),
Self::LeftMouseDown => Some(HoverEventFilter::LeftMouseDown),
Self::RightMouseDown => Some(HoverEventFilter::RightMouseDown),
Self::MiddleMouseDown => Some(HoverEventFilter::MiddleMouseDown),
Self::MouseUp => Some(HoverEventFilter::MouseUp),
Self::LeftMouseUp => Some(HoverEventFilter::LeftMouseUp),
Self::RightMouseUp => Some(HoverEventFilter::RightMouseUp),
Self::MiddleMouseUp => Some(HoverEventFilter::MiddleMouseUp),
Self::Scroll => Some(HoverEventFilter::Scroll),
Self::ScrollStart => Some(HoverEventFilter::ScrollStart),
Self::ScrollEnd => Some(HoverEventFilter::ScrollEnd),
Self::TextInput => Some(HoverEventFilter::TextInput),
Self::VirtualKeyDown => Some(HoverEventFilter::VirtualKeyDown),
Self::VirtualKeyUp => Some(HoverEventFilter::VirtualKeyUp),
Self::HoveredFile => Some(HoverEventFilter::HoveredFile),
Self::DroppedFile => Some(HoverEventFilter::DroppedFile),
Self::HoveredFileCancelled => Some(HoverEventFilter::HoveredFileCancelled),
// MouseEnter and MouseLeave on the **window** - does not mean a mouseenter
// and a mouseleave on the hovered element
Self::MouseEnter => None,
Self::MouseLeave => None,
Self::Resized => None,
Self::Moved => None,
Self::TouchStart => Some(HoverEventFilter::TouchStart),
Self::TouchMove => Some(HoverEventFilter::TouchMove),
Self::TouchEnd => Some(HoverEventFilter::TouchEnd),
Self::TouchCancel => Some(HoverEventFilter::TouchCancel),
Self::FocusReceived => None,
Self::FocusLost => None,
Self::CloseRequested => None,
Self::ThemeChanged => None,
Self::WindowFocusReceived => None, // specific to window!
Self::WindowFocusLost => None, // specific to window!
Self::PenDown => Some(HoverEventFilter::PenDown),
Self::PenMove => Some(HoverEventFilter::PenMove),
Self::PenUp => Some(HoverEventFilter::PenUp),
Self::PenEnter => Some(HoverEventFilter::PenEnter),
Self::PenLeave => Some(HoverEventFilter::PenLeave),
Self::PenSqueeze => Some(HoverEventFilter::PenSqueeze),
Self::PenDoubleTap => Some(HoverEventFilter::PenDoubleTap),
Self::PenHover => Some(HoverEventFilter::PenHover),
Self::GeolocationFix => Some(HoverEventFilter::GeolocationFix),
Self::GeolocationError => Some(HoverEventFilter::GeolocationError),
Self::SensorChanged => Some(HoverEventFilter::SensorChanged),
Self::GamepadInput => Some(HoverEventFilter::GamepadInput),
Self::DragStart => Some(HoverEventFilter::DragStart),
Self::Drag => Some(HoverEventFilter::Drag),
Self::DragEnd => Some(HoverEventFilter::DragEnd),
Self::DragEnter => Some(HoverEventFilter::DragEnter),
Self::DragOver => Some(HoverEventFilter::DragOver),
Self::DragLeave => Some(HoverEventFilter::DragLeave),
Self::Drop => Some(HoverEventFilter::Drop),
Self::DoubleClick => Some(HoverEventFilter::DoubleClick),
Self::LongPress => Some(HoverEventFilter::LongPress),
Self::SwipeLeft => Some(HoverEventFilter::SwipeLeft),
Self::SwipeRight => Some(HoverEventFilter::SwipeRight),
Self::SwipeUp => Some(HoverEventFilter::SwipeUp),
Self::SwipeDown => Some(HoverEventFilter::SwipeDown),
Self::PinchIn => Some(HoverEventFilter::PinchIn),
Self::PinchOut => Some(HoverEventFilter::PinchOut),
Self::RotateClockwise => Some(HoverEventFilter::RotateClockwise),
Self::RotateCounterClockwise => {
Some(HoverEventFilter::RotateCounterClockwise)
}
// Window-specific events with no hover equivalent
Self::DpiChanged => None,
Self::MonitorChanged => None,
// Async capability outcomes — mirror to the hover twin
Self::PermissionChanged => Some(HoverEventFilter::PermissionChanged),
Self::BiometricResult => Some(HoverEventFilter::BiometricResult),
Self::KeyringResult => Some(HoverEventFilter::KeyringResult),
}
}
}
/// Defines events related to the lifecycle of a DOM node itself.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum ComponentEventFilter {
/// Fired after the component is first mounted into the DOM.
AfterMount,
/// Fired just before the component is removed from the DOM.
BeforeUnmount,
/// Fired when the node's layout rectangle has been resized.
NodeResized,
/// Fired to trigger the default action for an accessibility component.
DefaultAction,
/// Fired when the component becomes selected.
Selected,
/// Fired when a keyed component's content has changed (props/state update).
Updated,
}
/// Defines application-level events not tied to a specific window or node.
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub enum ApplicationEventFilter {
/// Fired when a new hardware device is connected.
DeviceConnected,
/// Fired when a hardware device is disconnected.
DeviceDisconnected,
/// Fired when a new monitor/display is connected to the system.
/// Callback receives updated monitor list via `CallbackInfo::get_monitors()`.
MonitorConnected,
/// Fired when a monitor/display is disconnected from the system.
MonitorDisconnected,
}
/// Sets the target for what events can reach the callbacks specifically.
///
/// This determines the condition under which an event is fired, such as whether
/// the node is hovered, focused, or if the event is window-global.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[repr(C, u8)]
pub enum EventFilter {
/// Calls the attached callback when the mouse is actively over the
/// given element.
Hover(HoverEventFilter),
/// Calls the attached callback when the element is currently focused.
Focus(FocusEventFilter),
/// Calls the callback when anything related to the window is happening.
/// The "hit item" will be the root item of the DOM.
/// For example, this can be useful for tracking the mouse position
/// (in relation to the window). In difference to `Desktop`, this only
/// fires when the window is focused.
///
/// This can also be good for capturing controller input, touch input
/// (i.e. global gestures that aren't attached to any component, but rather
/// the "window" itself).
Window(WindowEventFilter),
/// API stub: Something happened with the node itself (node resized, created or removed).
Component(ComponentEventFilter),
/// Something happened with the application (started, shutdown, device plugged in).
Application(ApplicationEventFilter),
}
impl EventFilter {
#[must_use] pub const fn is_focus_callback(&self) -> bool {
matches!(self, Self::Focus(_))
}
#[must_use] pub const fn is_window_callback(&self) -> bool {
matches!(self, Self::Window(_))
}
}
/// Creates a function inside an impl <enum type> block that returns a single
/// variant if the enum is that variant.
macro_rules! get_single_enum_type {
($fn_name:ident, $enum_name:ident:: $variant:ident($return_type:ty)) => {
#[must_use] pub const fn $fn_name(&self) -> Option<$return_type> {
use self::$enum_name::*;
match self {
$variant(e) => Some(*e),
_ => None,
}
}
};
}
impl EventFilter {
get_single_enum_type!(as_hover_event_filter, EventFilter::Hover(HoverEventFilter));
get_single_enum_type!(as_focus_event_filter, EventFilter::Focus(FocusEventFilter));
get_single_enum_type!(
as_window_event_filter,
EventFilter::Window(WindowEventFilter)
);
}
/// Convert from `On` enum to `EventFilter`.
///
/// This determines which specific filter variant is used based on the event type.
/// For example, `On::TextInput` becomes a Focus event filter, while `On::VirtualKeyDown`
/// becomes a Window event filter (since it's global to the window).
impl From<On> for EventFilter {
// Exhaustive On -> EventFilter mapping table; the a11y events (Default/Collapse/
// Expand/Increment/Decrement) all map to MouseUp ("click") as intentional 1:1
// documented rows — merging would drop the per-row rationale comments.
#[allow(clippy::match_same_arms)]
fn from(input: On) -> Self {
use crate::dom::On::{MouseOver, MouseDown, LeftMouseDown, MiddleMouseDown, RightMouseDown, MouseUp, LeftMouseUp, MiddleMouseUp, RightMouseUp, MouseEnter, MouseLeave, Scroll, TextInput, VirtualKeyDown, VirtualKeyUp, HoveredFile, DroppedFile, HoveredFileCancelled, FocusReceived, FocusLost, Default, Collapse, Expand, Increment, Decrement};
match input {
MouseOver => Self::Hover(HoverEventFilter::MouseOver),
MouseDown => Self::Hover(HoverEventFilter::MouseDown),
LeftMouseDown => Self::Hover(HoverEventFilter::LeftMouseDown),
MiddleMouseDown => Self::Hover(HoverEventFilter::MiddleMouseDown),
RightMouseDown => Self::Hover(HoverEventFilter::RightMouseDown),
MouseUp => Self::Hover(HoverEventFilter::MouseUp),
LeftMouseUp => Self::Hover(HoverEventFilter::LeftMouseUp),
MiddleMouseUp => Self::Hover(HoverEventFilter::MiddleMouseUp),
RightMouseUp => Self::Hover(HoverEventFilter::RightMouseUp),
MouseEnter => Self::Hover(HoverEventFilter::MouseEnter),
MouseLeave => Self::Hover(HoverEventFilter::MouseLeave),
Scroll => Self::Hover(HoverEventFilter::Scroll),
TextInput => Self::Focus(FocusEventFilter::TextInput), // focus!
VirtualKeyDown => Self::Window(WindowEventFilter::VirtualKeyDown), // window!
VirtualKeyUp => Self::Window(WindowEventFilter::VirtualKeyUp), // window!
HoveredFile => Self::Hover(HoverEventFilter::HoveredFile),
DroppedFile => Self::Hover(HoverEventFilter::DroppedFile),
HoveredFileCancelled => Self::Hover(HoverEventFilter::HoveredFileCancelled),
FocusReceived => Self::Focus(FocusEventFilter::FocusReceived), // focus!
FocusLost => Self::Focus(FocusEventFilter::FocusLost), // focus!
// Accessibility events - treat as hover events (element-specific)
Default => Self::Hover(HoverEventFilter::MouseUp), // Default action = click
Collapse => Self::Hover(HoverEventFilter::MouseUp), // Collapse = click
Expand => Self::Hover(HoverEventFilter::MouseUp), // Expand = click
Increment => Self::Hover(HoverEventFilter::MouseUp), // Increment = click
Decrement => Self::Hover(HoverEventFilter::MouseUp), // Decrement = click
}
}
}
// Cross-Platform Event Dispatch System
// NOTE: The old dispatch_synthetic_events / CallbackTarget / CallbackToInvoke / EventDispatchResult
// pipeline has been removed. Event dispatch now goes through dispatch_events_propagated() in
// event_v2.rs which uses propagate_event() for W3C Capture→Target→Bubble propagation.
/// Trait for managers to provide their pending events.
///
/// Each manager (`TextInputManager`, `ScrollManager`, etc.) implements this to
/// report what events occurred since the last frame. This enables a unified,
/// lazy event determination system.
pub trait EventProvider {
/// Get all pending events from this manager.
///
/// Events should include:
///
/// - `target`: The `DomNodeId` that was affected
/// - `event_type`: What happened (Input, Scroll, Focus, etc.)
/// - `source`: `EventSource::User` for input, `EventSource::Programmatic` for API calls
/// - `data`: Type-specific event data
///
/// After calling this, the manager should mark events as "read" so they
/// aren't returned again next frame.
fn get_pending_events(&self, timestamp: Instant) -> Vec<SyntheticEvent>;
}
/// Deduplicate synthetic events by (target node, event type).
///
/// Groups by (target.dom, target.node, `event_type`), keeping the latest timestamp.
#[must_use] pub fn deduplicate_synthetic_events(mut events: Vec<SyntheticEvent>) -> Vec<SyntheticEvent> {
if events.len() <= 1 {
return events;
}
events.sort_by_key(|e| (e.target.dom, e.target.node, e.event_type));
// Coalesce consecutive events with same target and event_type
let mut result = Vec::with_capacity(events.len());
let mut iter = events.into_iter();
if let Some(mut prev) = iter.next() {
for curr in iter {
if prev.target == curr.target && prev.event_type == curr.event_type {
// Keep the one with later timestamp
prev = if curr.timestamp > prev.timestamp {
curr
} else {
prev
};
} else {
result.push(prev);
prev = curr;
}
}
result.push(prev);
}
result
}
/// Convert `EventType` to `EventFilters` (returns multiple filters for generic + specific events)
///
/// For mouse button events, returns both generic (`MouseUp`) AND button-specific (LeftMouseUp/RightMouseUp).
/// The button-specific filter is derived from the `EventData::Mouse` payload.
// Exhaustive EventType -> Vec<EventFilter> mapping table; some event types map to
// the same filter set as intentional 1:1 rows — merging would collapse the table.
#[allow(clippy::match_same_arms)]
#[must_use] pub fn event_type_to_filters(event_type: EventType, event_data: &EventData) -> Vec<EventFilter> {
use EventFilter as EF;
use EventType as E;
use FocusEventFilter as F;
use HoverEventFilter as H;
use WindowEventFilter as W;
// Helper: get the button-specific MouseDown filter from EventData
let button_specific_down = || -> Option<EventFilter> {
match event_data {
EventData::Mouse(m) => match m.button {
MouseButton::Left => Some(EF::Hover(H::LeftMouseDown)),
MouseButton::Right => Some(EF::Hover(H::RightMouseDown)),
MouseButton::Middle => Some(EF::Hover(H::MiddleMouseDown)),
MouseButton::Other(_) => None, // no specific filter for other buttons
},
_ => Some(EF::Hover(H::LeftMouseDown)), // fallback
}
};
let button_specific_up = || -> Option<EventFilter> {
match event_data {
EventData::Mouse(m) => match m.button {
MouseButton::Left => Some(EF::Hover(H::LeftMouseUp)),
MouseButton::Right => Some(EF::Hover(H::RightMouseUp)),
MouseButton::Middle => Some(EF::Hover(H::MiddleMouseUp)),
MouseButton::Other(_) => None, // no specific filter for other buttons
},
_ => Some(EF::Hover(H::LeftMouseUp)), // fallback
}
};
match event_type {
// Mouse button events - return BOTH generic and button-specific
E::MouseDown => {
let mut v = vec![EF::Hover(H::MouseDown)];
if let Some(f) = button_specific_down() { v.push(f); }
v
}
E::MouseUp => {
let mut v = vec![EF::Hover(H::MouseUp)];
if let Some(f) = button_specific_up() { v.push(f); }
v
}
// Click maps to LeftMouseUp: per W3C a `click` completes on button
// *release* over the target (left-button only). Mapping it to
// LeftMouseDown fired synthesized clicks as a duplicate MouseDown
// (press semantics) instead of a completed click.
E::Click => vec![EF::Hover(H::LeftMouseUp)],
// Other mouse events
E::MouseOver => vec![EF::Hover(H::MouseOver)],
E::MouseEnter => vec![EF::Hover(H::MouseEnter)],
E::MouseLeave => vec![EF::Hover(H::MouseLeave)],
E::MouseOut => vec![EF::Hover(H::MouseOut)],
E::DoubleClick => vec![EF::Hover(H::DoubleClick), EF::Window(W::DoubleClick)],
E::ContextMenu => vec![EF::Hover(H::RightMouseDown)],
// Keyboard events
E::KeyDown => vec![EF::Focus(F::VirtualKeyDown)],
E::KeyUp => vec![EF::Focus(F::VirtualKeyUp)],
E::KeyPress => vec![EF::Focus(F::TextInput)],
// IME Composition events
E::CompositionStart => vec![EF::Hover(H::CompositionStart), EF::Focus(F::CompositionStart)],
E::CompositionUpdate => vec![EF::Hover(H::CompositionUpdate), EF::Focus(F::CompositionUpdate)],
E::CompositionEnd => vec![EF::Hover(H::CompositionEnd), EF::Focus(F::CompositionEnd)],
// Focus events
E::Focus => vec![EF::Focus(F::FocusReceived)],
E::Blur => vec![EF::Focus(F::FocusLost)],
E::FocusIn => vec![EF::Hover(H::FocusIn), EF::Focus(F::FocusIn)],
E::FocusOut => vec![EF::Hover(H::FocusOut), EF::Focus(F::FocusOut)],
// Input events
E::Input | E::Change => vec![EF::Focus(F::TextInput)],
// Scroll events
E::Scroll | E::ScrollStart | E::ScrollEnd => vec![EF::Hover(H::Scroll)],
// Drag events
E::DragStart => vec![EF::Hover(H::DragStart), EF::Window(W::DragStart)],
E::Drag => vec![EF::Hover(H::Drag), EF::Window(W::Drag)],
E::DragEnd => vec![EF::Hover(H::DragEnd), EF::Window(W::DragEnd)],
E::DragEnter => vec![EF::Hover(H::DragEnter), EF::Window(W::DragEnter)],
E::DragOver => vec![EF::Hover(H::DragOver), EF::Window(W::DragOver)],
E::DragLeave => vec![EF::Hover(H::DragLeave), EF::Window(W::DragLeave)],
E::Drop => vec![EF::Hover(H::Drop), EF::Window(W::Drop)],
// Touch events
E::TouchStart => vec![EF::Hover(H::TouchStart)],
E::TouchMove => vec![EF::Hover(H::TouchMove)],
E::TouchEnd => vec![EF::Hover(H::TouchEnd)],
E::TouchCancel => vec![EF::Hover(H::TouchCancel)],
// Window events
E::WindowResize => vec![EF::Window(W::Resized)],
E::WindowMove => vec![EF::Window(W::Moved)],
E::WindowClose => vec![EF::Window(W::CloseRequested)],
E::WindowFocusIn => vec![EF::Window(W::WindowFocusReceived)],
E::WindowFocusOut => vec![EF::Window(W::WindowFocusLost)],
E::ThemeChange => vec![EF::Window(W::ThemeChanged)],
E::WindowDpiChanged => vec![EF::Window(W::DpiChanged)],
E::WindowMonitorChanged => vec![EF::Window(W::MonitorChanged)],
// Application events
E::MonitorConnected => vec![EF::Application(ApplicationEventFilter::MonitorConnected)],
E::MonitorDisconnected => vec![EF::Application(ApplicationEventFilter::MonitorDisconnected)],
// File events
// MWA-B7: node-level Hover mirror + the window-level filter. Without
// the Window mirrors, even WindowEventFilter::DroppedFile
// registrations were unreachable (file events dispatched to Hover
// filters only).
E::FileHover => vec![EF::Hover(H::HoveredFile), EF::Window(W::HoveredFile)],
E::FileDrop => vec![EF::Hover(H::DroppedFile), EF::Window(W::DroppedFile)],
E::FileHoverCancel => vec![
EF::Hover(H::HoveredFileCancelled),
EF::Window(W::HoveredFileCancelled),
],
// Lifecycle events — dispatched on the target node via EventFilter::Component.
// Both Mount and Unmount map to their respective Component filters so that
// `.add_callback(EventFilter::Component(ComponentEventFilter::AfterMount))`
// actually fires after reconcile_dom emits a SyntheticEvent{EventType::Mount,..}.
E::Mount => vec![EF::Component(ComponentEventFilter::AfterMount)],
E::Unmount => vec![EF::Component(ComponentEventFilter::BeforeUnmount)],
E::Update => vec![EF::Component(ComponentEventFilter::Updated)],
E::Resize => vec![EF::Component(ComponentEventFilter::NodeResized)],
// Hardware input-device events (P6) — node-level Hover mirror + the
// window-level filter (the device isn't bound to a node).
E::SensorChanged => vec![EF::Hover(H::SensorChanged), EF::Window(W::SensorChanged)],
E::GamepadInput => vec![EF::Hover(H::GamepadInput), EF::Window(W::GamepadInput)],
// Geolocation (MWA-A1): node-level Hover mirror + the window-level
// filter (a fix isn't bound to a node). The fix itself is read via
// CallbackInfo::get_geolocation_fix.
E::GeolocationFix => vec![EF::Hover(H::GeolocationFix), EF::Window(W::GeolocationFix)],
E::GeolocationError => vec![EF::Hover(H::GeolocationError), EF::Window(W::GeolocationError)],
// Async capability outcomes (MWA-A1b): node-level Hover mirror (the
// permission event targets the capability's subscriber node) + the
// window-level filter.
E::PermissionChanged => vec![EF::Hover(H::PermissionChanged), EF::Window(W::PermissionChanged)],
E::BiometricResult => vec![EF::Hover(H::BiometricResult), EF::Window(W::BiometricResult)],
E::KeyringResult => vec![EF::Hover(H::KeyringResult), EF::Window(W::KeyringResult)],
// MWA-C-clipboard: W3C clipboard events — fire on the focused
// element before the OS default action (preventDefault suppresses
// the default copy/cut/paste).
E::Copy => vec![EF::Focus(F::Copy)],
E::Cut => vec![EF::Focus(F::Cut)],
E::Paste => vec![EF::Focus(F::Paste)],
// Unsupported events
_ => vec![],
}
}
// Internal System Event Processing
/// Framework-determined side effects (system changes).
///
/// Unlike `CallbackChange` (from user callbacks), these are determined by the
/// framework's event analysis: hit tests, gesture detection, focus rules,
/// text selection, keyboard shortcuts, etc.
///
/// Both `CallbackChange` (user) and `SystemChange` (framework) are processed
/// through exhaustive match on `PlatformWindowV2` — adding a new variant
/// causes a compile error in `apply_system_change()`.
#[derive(Debug, Clone, PartialEq, Eq)]
#[must_use = "SystemChange must be processed through apply_system_change()"]
pub enum SystemChange {
// === Text Selection ===
/// Process a mouse click for text selection (single/double/triple click).
TextSelectionClick {
position: LogicalPosition,
timestamp: Instant,
},
/// Extend text selection via mouse drag.
TextSelectionDrag {
start_position: LogicalPosition,
current_position: LogicalPosition,
},
/// Unified selection operation: cursor movement, selection extension, or deletion.
///
/// Replaces the old `ArrowKeyNavigation` and `DeleteTextSelection` variants.
/// Every keyboard shortcut maps to a single `SelectionOp` — see its docs.
ApplySelectionOp {
target: DomNodeId,
op: SelectionOp,
},
// === Keyboard Shortcuts ===
/// Copy selected text to system clipboard (Ctrl+C / Cmd+C).
CopyToClipboard,
/// Cut selected text to clipboard and delete (Ctrl+X / Cmd+X).
CutToClipboard { target: DomNodeId },
/// Paste text from system clipboard at cursor (Ctrl+V / Cmd+V).
PasteFromClipboard,
/// Select all text in focused node (Ctrl+A / Cmd+A).
SelectAllText,
/// Undo last text edit (Ctrl+Z / Cmd+Z).
UndoTextEdit { target: DomNodeId },
/// Redo last undone edit (Ctrl+Y / Ctrl+Shift+Z / Cmd+Shift+Z).
RedoTextEdit { target: DomNodeId },
// === Multi-Cursor ===
/// Add a cursor at the clicked position (Ctrl+Click).
/// The position will be hit-tested to find the text cursor location.
AddCursorAtClick {
position: LogicalPosition,
},
/// Select the next occurrence of the current selection's text (Ctrl+D).
/// If the primary selection is a cursor, expand it to the word first.
SelectNextOccurrence {
target: DomNodeId,
},
// === Text Input ===
/// Apply pending text input from platform (keyboard/IME).
ApplyPendingTextInput,
/// Apply text changeset (incremental relayout).
ApplyTextChangeset,
// === Drag & Drop ===
/// Activate node drag on a draggable element.
ActivateNodeDrag {
dom_id: DomId,
node_id: NodeId,
},
/// Activate window drag (CSD titlebar).
ActivateWindowDrag,
/// Set up drag visual state (:dragging pseudo-state, GPU transform key).
InitDragVisualState,
/// Set :drag-over pseudo-state on a target node.
SetDragOverState { target: DomNodeId, active: bool },
/// Update current drop target in drag context.
UpdateDropTarget { target: DomNodeId },
/// Update GPU transform for active node drag.
UpdateDragGpuTransform,
/// End drag: clear pseudo-states, remove GPU keys, end drag session.
DeactivateDrag,
// === Focus ===
/// Change focus to a new target (or clear focus if None).
/// Handles: `set_focused_node`, `apply_focus_restyle`, `scroll_node_into_view`,
/// `cursor_blink_timer` start/stop.
SetFocus {
new_focus: Option<DomNodeId>,
old_focus: Option<DomNodeId>,
},
/// Clear all text selections.
ClearAllSelections,
/// Finalize pending focus changes (cursor initialization after layout).
FinalizePendingFocusChanges,
// === Scroll ===
/// Scroll cursor/selection into view.
ScrollSelectionIntoView,
/// Scroll a specific node into view.
ScrollNodeIntoView { target: DomNodeId },
/// Scroll cursor into view after text input (needs relayout first).
ScrollCursorIntoViewAfterTextInput,
// === Auto-Scroll Timer ===
/// Start auto-scroll timer for drag-to-scroll (60Hz).
StartAutoScrollTimer,
/// Cancel auto-scroll timer.
StopAutoScrollTimer,
}
impl_option!(
SystemChange,
OptionSystemChange,
copy = false,
clone = false,
[Debug, Clone, PartialEq, Eq]
);
impl_vec!(SystemChange, SystemChangeVec, SystemChangeVecDestructor, SystemChangeVecDestructorType, SystemChangeVecSlice, OptionSystemChange);
impl_vec_debug!(SystemChange, SystemChangeVec);
impl_vec_clone!(SystemChange, SystemChangeVec, SystemChangeVecDestructor);
impl_vec_partialeq!(SystemChange, SystemChangeVec);
/// Result of pre-callback internal event filtering
#[derive(Debug, Clone, PartialEq)]
pub struct PreCallbackFilterResult {
/// System changes to process BEFORE user callbacks
pub system_changes: Vec<SystemChange>,
/// Regular events that will be passed to user callbacks
pub user_events: Vec<SyntheticEvent>,
}
/// Flattened focus/selection state for the input interpreter (replaces trait objects).
#[derive(Debug, Clone, Copy)]
pub struct InputInterpreterState {
pub focused_node: Option<DomNodeId>,
pub click_count: u8,
pub drag_start_position: Option<LogicalPosition>,
pub has_selection: bool,
}
/// All context needed by the input interpreter to map events to system changes.
///
/// Passed to the interpreter callback. Contains references to the current
/// events and window state. The interpreter reads this and returns system changes.
#[derive(Debug)]
pub struct InputInterpreterInfo<'a> {
pub events: &'a [SyntheticEvent],
pub hit_test: Option<&'a FullHitTest>,
pub keyboard_state: &'a crate::window::KeyboardState,
pub mouse_state: &'a crate::window::MouseState,
pub state: InputInterpreterState,
}
/// The `extern "C"` callback type for the input interpreter.
///
/// The first `RefAny` is the user data (vim mode, repeat counter, etc.)
/// held in `InputInterpreterCallback.ctx`. The `*const ()` is an opaque
/// pointer to `InputInterpreterInfo` — callers use the safe wrapper
/// methods to access event data. Returns a `PreCallbackFilterResult`.
///
/// For C/Python: the trampoline extracts the foreign callable from `RefAny.ctx`.
/// For Rust: use `InputInterpreterCallback::from(fn_ptr)` which sets ctx=None.
pub type InputInterpreterCallbackType = extern "C" fn(
crate::refany::RefAny,
*const InputInterpreterInfo<'static>, // Opaque; actual lifetime managed by caller
) -> PreCallbackFilterResult;
/// Configurable input interpreter callback.
///
/// Maps raw platform events + window state → semantic `SystemChange` actions.
/// The default (`default_input_interpreter`) handles standard desktop keybindings.
/// Replace this on `LayoutWindow` to implement vim, game controls, etc.
///
/// ## Pattern
/// - **Rust**: `InputInterpreterCallback::from(my_fn_ptr)` — `ctx` is None
/// - **Python/C**: Set `cb` to a trampoline, `ctx` to `RefAny` wrapping the foreign callable
#[repr(C)]
pub struct InputInterpreterCallback {
pub cb: InputInterpreterCallbackType,
pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(InputInterpreterCallback, InputInterpreterCallbackType);
impl Default for InputInterpreterCallback {
fn default() -> Self {
Self {
cb: default_input_interpreter_extern,
ctx: crate::refany::OptionRefAny::None,
}
}
}
/// The `extern "C"` callback type for the post-callback filter.
pub type PostFilterCallbackType = extern "C" fn(
crate::refany::RefAny,
bool, // prevent_default
SystemChangeVecSlice, // pre_changes (immutable slice)
DomNodeId, // old_focus (0xFFFF = None)
DomNodeId, // new_focus (0xFFFF = None)
) -> SystemChangeVec;
/// Configurable post-callback filter.
#[repr(C)]
pub struct PostFilterCallback {
pub cb: PostFilterCallbackType,
pub ctx: crate::refany::OptionRefAny,
}
impl_callback!(PostFilterCallback, PostFilterCallbackType);
impl Default for PostFilterCallback {
fn default() -> Self {
Self {
cb: default_post_filter_extern,
ctx: crate::refany::OptionRefAny::None,
}
}
}
// Keep simpler Rust fn pointer aliases for internal use
pub type InputInterpreterFn = fn(
info: &InputInterpreterInfo<'_>,
) -> PreCallbackFilterResult;
pub type PostFilterFn = fn(
prevent_default: bool,
pre_changes: &[SystemChange],
old_focus: Option<DomNodeId>,
new_focus: Option<DomNodeId>,
) -> Vec<SystemChange>;
/// Mouse button state for drag tracking
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MouseButtonState {
pub left_down: bool,
pub right_down: bool,
pub middle_down: bool,
}
/// Arrow key / cursor navigation directions
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArrowDirection {
Left,
Right,
Up,
Down,
/// Home key: move to start of current line
LineStart,
/// End key: move to end of current line
LineEnd,
/// Ctrl+Home: move to start of document
DocumentStart,
/// Ctrl+End: move to end of document
DocumentEnd,
}
impl ArrowDirection {
/// Map a `VirtualKeyCode` plus the `ctrl` modifier into an `ArrowDirection`.
/// Returns `None` if the key is not a navigation key.
#[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, ctrl: bool) -> Option<Self> {
use crate::window::VirtualKeyCode::{Left, Right, Up, Down, Home, End};
Some(match vk {
Left => Self::Left,
Right => Self::Right,
Up => Self::Up,
Down => Self::Down,
Home if ctrl => Self::DocumentStart,
Home => Self::LineStart,
End if ctrl => Self::DocumentEnd,
End => Self::LineEnd,
_ => return None,
})
}
/// Convert to a `(SelectionDirection, SelectionStep)` pair for the
/// selection-op interpreter. `ctrl` upgrades arrow keys to word jumps.
#[must_use] pub const fn to_selection(self, ctrl: bool) -> (SelectionDirection, SelectionStep) {
match self {
Self::Left if ctrl => (SelectionDirection::Backward, SelectionStep::Word),
Self::Right if ctrl => (SelectionDirection::Forward, SelectionStep::Word),
Self::Left => (SelectionDirection::Backward, SelectionStep::Character),
Self::Right => (SelectionDirection::Forward, SelectionStep::Character),
Self::Up => (SelectionDirection::Backward, SelectionStep::VisualLine),
Self::Down => (SelectionDirection::Forward, SelectionStep::VisualLine),
Self::LineStart => (SelectionDirection::Backward, SelectionStep::Line),
Self::LineEnd => (SelectionDirection::Forward, SelectionStep::Line),
Self::DocumentStart => (SelectionDirection::Backward, SelectionStep::Document),
Self::DocumentEnd => (SelectionDirection::Forward, SelectionStep::Document),
}
}
}
/// Direction of cursor movement or selection expansion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionDirection {
Forward,
Backward,
}
/// Granularity of cursor movement or selection expansion.
///
/// Combined with `SelectionDirection`, determines how far a cursor moves
/// or a selection expands. Reused for navigation, deletion, and visual
/// selection — a single code path for word boundaries etc.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionStep {
/// One grapheme cluster (arrow keys, Backspace, Delete)
Character,
/// One word boundary (Ctrl+arrow, Ctrl+Backspace, Ctrl+Delete)
Word,
/// To line boundary (Home/End)
Line,
/// One visual line up/down (Up/Down arrows)
VisualLine,
/// To document boundary (Ctrl+Home/End)
Document,
}
/// What to do with the selection after moving.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub enum SelectionMode {
/// Collapse selection to cursor, then move (plain arrow key).
Move,
/// Extend selection from anchor to new position (Shift+arrow).
Extend,
/// Expand cursor to range in the given direction, then delete the range
/// (Backspace/Delete). If a range already exists, just delete it.
Delete,
}
/// A unified selection operation that replaces all cursor movement,
/// selection extension, and text deletion commands.
///
/// Every keyboard shortcut for cursor movement or deletion maps to this:
/// - Arrow Left = (Backward, Character, Move, 1)
/// - Shift+Right = (Forward, Character, Extend, 1)
/// - Ctrl+Backspace = (Backward, Word, Delete, 1)
/// - Home = (Backward, Line, Move, 1)
/// - Ctrl+End = (Forward, Document, Move, 1)
///
/// The `repeat` field enables vim-style commands: 3w = (Forward, Word, Move, 3).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[repr(C)]
pub struct SelectionOp {
pub direction: SelectionDirection,
pub step: SelectionStep,
pub mode: SelectionMode,
pub repeat: usize,
}
impl SelectionOp {
#[must_use] pub const fn new(direction: SelectionDirection, step: SelectionStep, mode: SelectionMode) -> Self {
Self { direction, step, mode, repeat: 1 }
}
}
/// Keyboard shortcuts for text editing
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum KeyboardShortcut {
Copy, // Ctrl+C
Cut, // Ctrl+X
Paste, // Ctrl+V
SelectAll, // Ctrl+A
Undo, // Ctrl+Z
Redo, // Ctrl+Y or Ctrl+Shift+Z
}
impl KeyboardShortcut {
/// Map a `(VirtualKeyCode, primary, shift)` triple to a text-editing
/// shortcut. Returns `None` if the key combination is not a recognized
/// shortcut or if `primary` is not held. `primary` is the platform's
/// primary modifier — Cmd on macOS, Ctrl elsewhere — obtained from
/// `KeyboardState::primary_down()` (MWA-A2: hardcoding Ctrl here made
/// every editing shortcut dead on macOS).
#[must_use] pub const fn from_key(vk: crate::window::VirtualKeyCode, primary: bool, shift: bool) -> Option<Self> {
use crate::window::VirtualKeyCode::{C, X, V, A, Z, Y};
if !primary {
return None;
}
Some(match vk {
C => Self::Copy,
X => Self::Cut,
V => Self::Paste,
A => Self::SelectAll,
Z if shift => Self::Redo,
Z => Self::Undo,
Y => Self::Redo,
_ => return None,
})
}
}
/// Default input interpreter: standard desktop keybindings.
///
/// This is the default `InputInterpreterFn` that handles arrow keys, Home/End,
/// Backspace/Delete, Ctrl+C/V/A/Z, mouse clicks, and drag selection.
/// Replace it on `LayoutWindow` to implement vim, game controls, etc.
/// `extern "C"` trampoline for `default_input_interpreter`.
#[allow(clippy::not_unsafe_ptr_arg_deref)] // SAFETY/FFI: `*const T` is the C-ABI signature; the fn null-checks then derefs under the documented caller contract (C guarantees a valid ptr/len). Marking it `unsafe fn` would force unsafe blocks into the generated dll bindings.
#[must_use] pub extern "C" fn default_input_interpreter_extern(
_user_data: crate::refany::RefAny,
info_ptr: *const InputInterpreterInfo<'static>,
) -> PreCallbackFilterResult {
if info_ptr.is_null() {
return PreCallbackFilterResult {
system_changes: Vec::new(),
user_events: Vec::new(),
};
}
let info = unsafe { &*info_ptr };
default_input_interpreter(info)
}
/// `extern "C"` trampoline for `default_post_filter`.
#[must_use] pub extern "C" fn default_post_filter_extern(
_user_data: crate::refany::RefAny,
prevent_default: bool,
pre_changes: SystemChangeVecSlice,
old_focus: DomNodeId,
new_focus: DomNodeId,
) -> SystemChangeVec {
let pre_changes_slice = pre_changes.as_slice();
let old = old_focus.node.into_crate_internal().map(|_| old_focus);
let new = new_focus.node.into_crate_internal().map(|_| new_focus);
default_post_filter(prevent_default, pre_changes_slice, old, new).into()
}
#[must_use] pub fn default_input_interpreter(
info: &InputInterpreterInfo<'_>,
) -> PreCallbackFilterResult {
let ctx = FilterContext {
hit_test: info.hit_test,
keyboard_state: info.keyboard_state,
mouse_state: info.mouse_state,
click_count: info.state.click_count,
focused_node: info.state.focused_node,
drag_start_position: info.state.drag_start_position,
};
let (system_changes, user_events) = info.events.iter().fold(
(Vec::new(), Vec::new()),
|(mut internal, mut user), event| {
match process_event_for_internal(&ctx, event) {
Some(InternalEventAction::AddAndSkip(evt)) => {
internal.push(evt);
}
Some(InternalEventAction::AddAndPass(evt)) => {
internal.push(evt);
user.push(event.clone());
}
None => {
user.push(event.clone());
}
}
(internal, user)
},
);
PreCallbackFilterResult {
system_changes,
user_events,
}
}
/// Backward-compatible wrapper that calls `default_input_interpreter`.
pub fn pre_callback_filter_internal_events<SM, FM>(
events: &[SyntheticEvent],
hit_test: Option<&FullHitTest>,
keyboard_state: &crate::window::KeyboardState,
mouse_state: &crate::window::MouseState,
selection_manager: &SM,
focus_manager: &FM,
) -> PreCallbackFilterResult
where
SM: SelectionManagerQuery,
FM: FocusManagerQuery,
{
let info = InputInterpreterInfo {
events,
hit_test,
keyboard_state,
mouse_state,
state: InputInterpreterState {
focused_node: focus_manager.get_focused_node_id(),
click_count: selection_manager.get_click_count(),
drag_start_position: selection_manager.get_drag_start_position(),
has_selection: selection_manager.has_selection(),
},
};
default_input_interpreter(&info)
}
/// Context for filtering internal events (used by `default_input_interpreter`)
struct FilterContext<'a> {
hit_test: Option<&'a FullHitTest>,
keyboard_state: &'a crate::window::KeyboardState,
mouse_state: &'a crate::window::MouseState,
click_count: u8,
focused_node: Option<DomNodeId>,
drag_start_position: Option<LogicalPosition>,
}
/// Process a single event and determine if it generates an internal event
fn process_event_for_internal(
ctx: &FilterContext<'_>,
event: &SyntheticEvent,
) -> Option<InternalEventAction> {
match event.event_type {
EventType::MouseDown => handle_mouse_down(event, ctx.hit_test, ctx.click_count, ctx.mouse_state, ctx.keyboard_state),
EventType::MouseOver => handle_mouse_over(
event,
ctx.hit_test,
ctx.mouse_state,
ctx.drag_start_position,
),
EventType::KeyDown => handle_key_down(
event,
ctx.keyboard_state,
ctx.focused_node,
),
_ => None,
}
}
/// Action to take after processing an event for internal system events
enum InternalEventAction {
/// Add system change and skip passing to user callbacks
AddAndSkip(SystemChange),
/// Add system change but also pass to user callbacks
AddAndPass(SystemChange),
}
/// Extract the front-most hovered node from a hit test.
///
/// Picks the node with the minimum `hit_depth` (0 = frontmost/topmost in
/// z-order) across every hovered DOM. The previous implementation took the
/// first entry of the `BTreeMap` (lowest `NodeId`), which ignored z-order
/// entirely and targeted the back-most node under overlapping elements.
/// Ties are broken deterministically by (`DomId`, `NodeId`) iteration order.
fn get_first_hovered_node(hit_test: Option<&FullHitTest>) -> Option<DomNodeId> {
let ht = hit_test?;
let mut best: Option<(DomId, NodeId, u32)> = None;
for (dom_id, hit_data) in &ht.hovered_nodes {
for (node_id, item) in &hit_data.regular_hit_test_nodes {
let is_better = match best {
None => true,
Some((_, _, best_depth)) => item.hit_depth < best_depth,
};
if is_better {
best = Some((*dom_id, *node_id, item.hit_depth));
}
}
}
let (dom_id, node_id, _) = best?;
Some(DomNodeId {
dom: dom_id,
node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
})
}
/// Extract mouse position from event data, falling back to `mouse_state` if not available
fn get_mouse_position_with_fallback(
event: &SyntheticEvent,
mouse_state: &crate::window::MouseState,
) -> LogicalPosition {
match &event.data {
EventData::Mouse(mouse_data) => mouse_data.position,
_ => {
// Fallback: use current cursor position from mouse_state
// This handles synthetic events from debug API and automation
// where EventData may not contain the mouse position
mouse_state.cursor_position.get_position().unwrap_or(LogicalPosition::zero())
}
}
}
/// Handle `MouseDown` event - detect text selection clicks and Ctrl+Click for multi-cursor
fn handle_mouse_down(
event: &SyntheticEvent,
hit_test: Option<&FullHitTest>,
click_count: u8,
mouse_state: &crate::window::MouseState,
keyboard_state: &crate::window::KeyboardState,
) -> Option<InternalEventAction> {
let effective_click_count = if click_count == 0 { 1 } else { click_count };
if effective_click_count > 3 {
return None;
}
let _target = get_first_hovered_node(hit_test)?;
let position = get_mouse_position_with_fallback(event, mouse_state);
// Ctrl+Click (or Cmd+Click on macOS): add cursor at click position.
// Use the platform PRIMARY modifier so this fires on Cmd on macOS
// (where Ctrl+Click is the secondary-click gesture) — `ctrl_down()`
// was wrong there.
if keyboard_state.primary_down() && effective_click_count == 1 {
return Some(InternalEventAction::AddAndPass(
SystemChange::AddCursorAtClick { position },
));
}
Some(InternalEventAction::AddAndPass(
SystemChange::TextSelectionClick {
position,
timestamp: event.timestamp.clone(),
},
))
}
/// Handle `MouseOver` event - detect drag selection
fn handle_mouse_over(
event: &SyntheticEvent,
hit_test: Option<&FullHitTest>,
mouse_state: &crate::window::MouseState,
drag_start_position: Option<LogicalPosition>,
) -> Option<InternalEventAction> {
if !mouse_state.left_down {
return None;
}
let start_position = drag_start_position?;
let _target = get_first_hovered_node(hit_test)?;
let current_position = get_mouse_position_with_fallback(event, mouse_state);
Some(InternalEventAction::AddAndPass(
SystemChange::TextSelectionDrag {
start_position,
current_position,
},
))
}
/// Handle `KeyDown` event - detect shortcuts, arrow keys, and delete keys
fn handle_key_down(
event: &SyntheticEvent,
keyboard_state: &crate::window::KeyboardState,
focused_node: Option<DomNodeId>,
) -> Option<InternalEventAction> {
use crate::window::VirtualKeyCode;
let target = focused_node?;
let EventData::Keyboard(kbd) = &event.data else {
return None;
};
// Read the key and modifiers from THIS event's payload, not from the live
// `keyboard_state`. The live state can have advanced (another key pressed /
// released) between when the event was queued and when it is dispatched, so
// reading it here could act on the wrong key/modifiers. `keyboard_state` is
// retained only for the platform where the event does not carry a key.
let _ = keyboard_state;
// MWA-A2: standard shortcuts key off the PRIMARY modifier (Cmd on
// macOS, Ctrl elsewhere); word-jump / word-delete keys off the
// platform's word modifier (Option on macOS, Ctrl elsewhere).
let primary = if cfg!(target_os = "macos") {
kbd.modifiers.meta
} else {
kbd.modifiers.ctrl
};
let word_mod = if cfg!(target_os = "macos") {
kbd.modifiers.alt
} else {
kbd.modifiers.ctrl
};
let shift = kbd.modifiers.shift;
let vk_owned = VirtualKeyCode::from_u32(kbd.key_code)?;
let vk = &vk_owned;
// Check keyboard shortcuts (primary+key) → emit specific SystemChange
// variants. Standard editing shortcuts are routed through the
// `KeyboardShortcut` enum, and a couple of additional Azul-specific
// primary-modifier combos are matched after.
if primary {
if let Some(shortcut) = KeyboardShortcut::from_key(*vk, primary, shift) {
let change = match shortcut {
KeyboardShortcut::Copy => SystemChange::CopyToClipboard,
KeyboardShortcut::Cut => SystemChange::CutToClipboard { target },
KeyboardShortcut::Paste => SystemChange::PasteFromClipboard,
KeyboardShortcut::SelectAll => SystemChange::SelectAllText,
KeyboardShortcut::Undo => SystemChange::UndoTextEdit { target },
KeyboardShortcut::Redo => SystemChange::RedoTextEdit { target },
};
return Some(InternalEventAction::AddAndSkip(change));
}
if matches!(vk, VirtualKeyCode::D) {
return Some(InternalEventAction::AddAndSkip(
SystemChange::SelectNextOccurrence { target },
));
}
}
// Unified: arrow keys, Home/End, Backspace/Delete all map to SelectionOp.
let mode_for_shift = if shift { SelectionMode::Extend } else { SelectionMode::Move };
let selection_op = if let Some(arrow) = ArrowDirection::from_key(*vk, word_mod) {
let (direction, step) = arrow.to_selection(word_mod);
SelectionOp::new(direction, step, mode_for_shift)
} else {
match vk {
// Backspace/Delete = Delete mode (word modifier upgrades to
// Word: Option+Backspace on macOS, Ctrl+Backspace elsewhere)
VirtualKeyCode::Back => SelectionOp::new(
SelectionDirection::Backward,
if word_mod { SelectionStep::Word } else { SelectionStep::Character },
SelectionMode::Delete,
),
VirtualKeyCode::Delete => SelectionOp::new(
SelectionDirection::Forward,
if word_mod { SelectionStep::Word } else { SelectionStep::Character },
SelectionMode::Delete,
),
_ => return None,
}
};
Some(InternalEventAction::AddAndSkip(
SystemChange::ApplySelectionOp { target, op: selection_op },
))
}
/// Trait for querying selection manager state.
///
/// This allows `pre_callback_filter_internal_events` to query manager state
/// without depending on the concrete `SelectionManager` type from layout crate.
pub trait SelectionManagerQuery {
/// Get the current click count (1 = single, 2 = double, 3 = triple)
fn get_click_count(&self) -> u8;
/// Get the drag start position if a drag is in progress
fn get_drag_start_position(&self) -> Option<LogicalPosition>;
/// Check if any selection exists (click selection or drag selection)
fn has_selection(&self) -> bool;
}
/// Trait for querying focus manager state.
///
/// This allows `pre_callback_filter_internal_events` to query manager state
/// without depending on the concrete `FocusManager` type from layout crate.
pub trait FocusManagerQuery {
/// Get the currently focused node ID
fn get_focused_node_id(&self) -> Option<DomNodeId>;
}
/// Post-callback filter: Determine additional system changes needed after user callbacks.
///
/// Takes the pre-callback system changes and focus state to determine what
/// post-callback system changes are needed (text input, scrolling, timers).
/// Default post-callback filter: scroll-into-view after cursor ops, auto-scroll during drag.
#[must_use] pub fn default_post_filter(
prevent_default: bool,
pre_changes: &[SystemChange],
old_focus: Option<DomNodeId>,
new_focus: Option<DomNodeId>,
) -> Vec<SystemChange> {
post_callback_filter_system_changes(prevent_default, pre_changes, old_focus, new_focus)
}
// SystemChange dispatch table; a few arms incidentally push the same follow-up
// change but are kept as distinct documented cases.
#[allow(clippy::match_same_arms)]
#[must_use] pub fn post_callback_filter_system_changes(
prevent_default: bool,
pre_changes: &[SystemChange],
old_focus: Option<DomNodeId>,
new_focus: Option<DomNodeId>,
) -> Vec<SystemChange> {
let mut changes = Vec::new();
if prevent_default {
// Only focus change passes through preventDefault
if old_focus != new_focus {
changes.push(SystemChange::SetFocus { new_focus, old_focus });
}
return changes;
}
// Always apply pending text input
changes.push(SystemChange::ApplyPendingTextInput);
// Determine post-callback actions based on pre-callback system changes
for change in pre_changes {
match change {
SystemChange::TextSelectionClick { .. }
| SystemChange::ApplySelectionOp { .. }
| SystemChange::AddCursorAtClick { .. }
| SystemChange::SelectNextOccurrence { .. } => {
changes.push(SystemChange::ScrollSelectionIntoView);
}
SystemChange::TextSelectionDrag { .. } => {
changes.push(SystemChange::StartAutoScrollTimer);
}
SystemChange::CutToClipboard { .. }
| SystemChange::PasteFromClipboard
| SystemChange::UndoTextEdit { .. }
| SystemChange::RedoTextEdit { .. }
| SystemChange::SelectAllText => {
changes.push(SystemChange::ScrollSelectionIntoView);
}
// Other system changes don't generate post-callback actions
_ => {}
}
}
// Focus changed during callbacks
if old_focus != new_focus {
changes.push(SystemChange::SetFocus { new_focus, old_focus });
}
changes
}
#[cfg(test)]
mod tests {
use super::*;
use azul_css::AzString;
use crate::dom::{DomId, DomNodeId};
use crate::styled_dom::NodeHierarchyItemId;
use crate::id::NodeId;
use crate::window::{KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec, OptionVirtualKeyCode};
use crate::geom::LogicalPosition;
use crate::task::{Instant, SystemTick};
struct MockSelectionManager {
click_count: u8,
has_sel: bool,
}
impl SelectionManagerQuery for MockSelectionManager {
fn get_click_count(&self) -> u8 { self.click_count }
fn get_drag_start_position(&self) -> Option<LogicalPosition> { None }
fn has_selection(&self) -> bool { self.has_sel }
}
struct MockFocusManager(Option<DomNodeId>);
impl FocusManagerQuery for MockFocusManager {
fn get_focused_node_id(&self) -> Option<DomNodeId> { self.0 }
}
fn focused_node(node_idx: usize) -> DomNodeId {
DomNodeId {
dom: DomId { inner: 0 },
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node_idx))),
}
}
fn make_keyboard_state(vk: VirtualKeyCode) -> KeyboardState {
KeyboardState {
current_virtual_keycode: OptionVirtualKeyCode::Some(vk),
pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![vk]),
..KeyboardState::default()
}
}
fn make_keydown_event(target: DomNodeId) -> SyntheticEvent {
SyntheticEvent::new(
EventType::KeyDown,
EventSource::User,
target,
Instant::Tick(SystemTick::new(0)),
EventData::Keyboard(KeyboardEventData {
key_code: VirtualKeyCode::Back as u32,
char_code: None,
modifiers: KeyModifiers::default(),
repeat: false,
}),
)
}
#[test]
fn backspace_generates_delete_text_selection() {
let target = focused_node(2);
let events = vec![make_keydown_event(target)];
let kb = make_keyboard_state(VirtualKeyCode::Back);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(
&events, None, &kb, &mouse, &sel, &focus,
);
let ops: Vec<_> = result.system_changes.iter()
.filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
.collect();
assert_eq!(ops.len(), 1, "Backspace should generate ApplySelectionOp");
match &ops[0] {
SystemChange::ApplySelectionOp { op, .. } => {
assert_eq!(op.direction, SelectionDirection::Backward);
assert_eq!(op.step, SelectionStep::Character);
assert_eq!(op.mode, SelectionMode::Delete);
}
_ => unreachable!(),
}
}
#[test]
fn delete_key_generates_forward_deletion() {
let target = focused_node(2);
let event = SyntheticEvent::new(
EventType::KeyDown, EventSource::User, target,
Instant::Tick(SystemTick::new(0)),
EventData::Keyboard(KeyboardEventData {
key_code: VirtualKeyCode::Delete as u32,
char_code: None, modifiers: KeyModifiers::default(), repeat: false,
}),
);
let kb = make_keyboard_state(VirtualKeyCode::Delete);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
let ops: Vec<_> = result.system_changes.iter()
.filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
.collect();
assert_eq!(ops.len(), 1);
match &ops[0] {
SystemChange::ApplySelectionOp { op, .. } => {
assert_eq!(op.direction, SelectionDirection::Forward);
assert_eq!(op.step, SelectionStep::Character);
assert_eq!(op.mode, SelectionMode::Delete);
}
_ => unreachable!(),
}
}
#[test]
fn arrow_left_generates_navigation() {
let target = focused_node(2);
let event = SyntheticEvent::new(
EventType::KeyDown, EventSource::User, target,
Instant::Tick(SystemTick::new(0)),
EventData::Keyboard(KeyboardEventData {
key_code: VirtualKeyCode::Left as u32,
char_code: None, modifiers: KeyModifiers::default(), repeat: false,
}),
);
let kb = make_keyboard_state(VirtualKeyCode::Left);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(&[event], None, &kb, &mouse, &sel, &focus);
let ops: Vec<_> = result.system_changes.iter()
.filter(|c| matches!(c, SystemChange::ApplySelectionOp { .. }))
.collect();
assert_eq!(ops.len(), 1, "Left arrow should generate ApplySelectionOp");
match &ops[0] {
SystemChange::ApplySelectionOp { op, .. } => {
assert_eq!(op.direction, SelectionDirection::Backward);
assert_eq!(op.step, SelectionStep::Character);
assert_eq!(op.mode, SelectionMode::Move);
}
_ => unreachable!(),
}
}
#[test]
fn no_focused_node_means_no_keyboard_system_changes() {
let target = focused_node(2);
let event = make_keydown_event(target);
let kb = make_keyboard_state(VirtualKeyCode::Back);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(None); // No focus!
let result = pre_callback_filter_internal_events(
&[event], None, &kb, &mouse, &sel, &focus,
);
assert!(result.system_changes.is_empty(),
"No system changes should be generated without focused node");
}
#[test]
fn keydown_without_keyboard_data_generates_no_system_change() {
let target = focused_node(2);
let event = SyntheticEvent::new(
EventType::KeyDown,
EventSource::User,
target,
Instant::Tick(SystemTick::new(0)),
EventData::None, // Bug: missing keyboard data
);
let kb = make_keyboard_state(VirtualKeyCode::Back);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(
&[event], None, &kb, &mouse, &sel, &focus,
);
// This test documents the bug we just fixed: EventData::None causes
// the handle_key_down function to return None (early exit at line 2737)
assert!(result.system_changes.is_empty(),
"EventData::None should not generate system changes (documents the old bug)");
}
#[test]
fn ctrl_c_generates_copy() {
// MWA-A2/MWA-D: shortcuts key off the PRIMARY modifier — Cmd on
// macOS, Ctrl elsewhere — so this test presses the platform's
// primary key. (The old version hardcoded LControl and correctly
// started failing on macOS hosts once primary_down() landed:
// Ctrl+C must NOT copy on macOS, Cmd+C does.)
let primary_key = if cfg!(target_os = "macos") {
VirtualKeyCode::LWin
} else {
VirtualKeyCode::LControl
};
let target = focused_node(2);
let event = SyntheticEvent::new(
EventType::KeyDown,
EventSource::User,
target,
Instant::Tick(SystemTick::new(0)),
EventData::Keyboard(KeyboardEventData {
key_code: VirtualKeyCode::C as u32,
char_code: Some('c'),
modifiers: KeyModifiers {
ctrl: !cfg!(target_os = "macos"),
shift: false,
alt: false,
meta: cfg!(target_os = "macos"),
},
repeat: false,
}),
);
let mut kb = make_keyboard_state(VirtualKeyCode::C);
kb.pressed_virtual_keycodes = VirtualKeyCodeVec::from_vec(
vec![VirtualKeyCode::C, primary_key]
);
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 0, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(
&[event], None, &kb, &mouse, &sel, &focus,
);
let copy_changes = result.system_changes.iter()
.filter(|c| matches!(c, SystemChange::CopyToClipboard))
.count();
assert_eq!(copy_changes, 1, "primary+C should generate CopyToClipboard");
}
fn make_hit_test_with_node(node_idx: usize) -> FullHitTest {
use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
use crate::dom::OptionDomNodeId;
use std::collections::BTreeMap;
let node_id = NodeId::new(node_idx);
let dom_id = DomId { inner: 0 };
let mut regular = BTreeMap::new();
regular.insert(node_id, HitTestItem {
point_in_viewport: LogicalPosition::new(100.0, 200.0),
point_relative_to_item: LogicalPosition::new(50.0, 30.0),
is_focusable: true,
is_virtual_view_hit: None,
hit_depth: 0,
});
let mut hovered = BTreeMap::new();
hovered.insert(dom_id, HitTest {
regular_hit_test_nodes: regular,
scroll_hit_test_nodes: BTreeMap::new(),
scrollbar_hit_test_nodes: BTreeMap::new(),
cursor_hit_test_nodes: BTreeMap::new(),
});
FullHitTest {
hovered_nodes: hovered,
focused_node: OptionDomNodeId::None,
}
}
#[test]
fn mousedown_generates_text_selection_click() {
let target = focused_node(2);
let event = SyntheticEvent::new(
EventType::MouseDown,
EventSource::User,
target,
Instant::Tick(SystemTick::new(0)),
EventData::Mouse(MouseEventData {
position: LogicalPosition::new(100.0, 200.0),
button: MouseButton::Left,
buttons: 1,
modifiers: KeyModifiers::default(),
}),
);
let hit_test = make_hit_test_with_node(2);
let kb = KeyboardState::default();
let mouse = MouseState::default();
let sel = MockSelectionManager { click_count: 1, has_sel: false };
let focus = MockFocusManager(Some(target));
let result = pre_callback_filter_internal_events(
&[event], Some(&hit_test), &kb, &mouse, &sel, &focus,
);
let click_changes = result.system_changes.iter()
.filter(|c| matches!(c, SystemChange::TextSelectionClick { .. }))
.count();
assert_eq!(click_changes, 1, "MouseDown with hit_test should generate TextSelectionClick");
}
#[test]
fn process_event_result_max_self_picks_higher_variant() {
let lo = ProcessEventResult::ShouldReRenderCurrentWindow;
let hi = ProcessEventResult::ShouldRegenerateDomCurrentWindow;
assert_eq!(lo.max_self(hi), hi);
assert_eq!(hi.max_self(lo), hi);
assert_eq!(lo.max_self(lo), lo);
}
#[test]
fn keyboard_shortcut_keys_off_primary_modifier() {
use crate::window::VirtualKeyCode::{A, C, V, X, Z};
// No primary modifier → never a shortcut (MWA-A2).
assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
assert_eq!(KeyboardShortcut::from_key(Z, false, true), None);
// Primary held → the standard editing set.
assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
}
#[test]
fn primary_modifier_is_platform_correct() {
use crate::window::{KeyboardState, VirtualKeyCode};
let cmd_held = KeyboardState {
pressed_virtual_keycodes: vec![VirtualKeyCode::LWin].into(),
..Default::default()
};
// Cmd/super is primary ONLY on macOS.
assert_eq!(cmd_held.primary_down(), cfg!(target_os = "macos"));
let ctrl_held = KeyboardState {
pressed_virtual_keycodes: vec![VirtualKeyCode::LControl].into(),
..Default::default()
};
// Ctrl is primary everywhere EXCEPT macOS.
assert_eq!(ctrl_held.primary_down(), !cfg!(target_os = "macos"));
}
#[test]
fn arrow_direction_from_key_maps_arrows_and_home_end() {
use crate::window::VirtualKeyCode::*;
assert_eq!(ArrowDirection::from_key(Left, false), Some(ArrowDirection::Left));
assert_eq!(ArrowDirection::from_key(Right, false), Some(ArrowDirection::Right));
assert_eq!(ArrowDirection::from_key(Up, false), Some(ArrowDirection::Up));
assert_eq!(ArrowDirection::from_key(Down, false), Some(ArrowDirection::Down));
assert_eq!(ArrowDirection::from_key(Home, false), Some(ArrowDirection::LineStart));
assert_eq!(ArrowDirection::from_key(End, false), Some(ArrowDirection::LineEnd));
assert_eq!(ArrowDirection::from_key(Home, true), Some(ArrowDirection::DocumentStart));
assert_eq!(ArrowDirection::from_key(End, true), Some(ArrowDirection::DocumentEnd));
assert_eq!(ArrowDirection::from_key(C, false), None);
}
#[test]
fn arrow_direction_to_selection_respects_ctrl() {
let (d, s) = ArrowDirection::Left.to_selection(false);
assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Character));
let (d, s) = ArrowDirection::Left.to_selection(true);
assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::Word));
let (d, s) = ArrowDirection::Up.to_selection(false);
assert_eq!((d, s), (SelectionDirection::Backward, SelectionStep::VisualLine));
let (d, s) = ArrowDirection::DocumentEnd.to_selection(false);
assert_eq!((d, s), (SelectionDirection::Forward, SelectionStep::Document));
}
#[test]
fn keyboard_shortcut_from_key_recognizes_editing_combos() {
use crate::window::VirtualKeyCode::*;
assert_eq!(KeyboardShortcut::from_key(C, true, false), Some(KeyboardShortcut::Copy));
assert_eq!(KeyboardShortcut::from_key(X, true, false), Some(KeyboardShortcut::Cut));
assert_eq!(KeyboardShortcut::from_key(V, true, false), Some(KeyboardShortcut::Paste));
assert_eq!(KeyboardShortcut::from_key(A, true, false), Some(KeyboardShortcut::SelectAll));
assert_eq!(KeyboardShortcut::from_key(Z, true, false), Some(KeyboardShortcut::Undo));
assert_eq!(KeyboardShortcut::from_key(Z, true, true), Some(KeyboardShortcut::Redo));
assert_eq!(KeyboardShortcut::from_key(Y, true, false), Some(KeyboardShortcut::Redo));
// Non-ctrl combos must not match
assert_eq!(KeyboardShortcut::from_key(C, false, false), None);
// Unknown keys
assert_eq!(KeyboardShortcut::from_key(D, true, false), None);
}
#[test]
fn mouse_button_state_round_trips_from_mouse_state() {
let ms = MouseState {
left_down: true,
middle_down: true,
..MouseState::default()
};
let bs: MouseButtonState = (&ms).into();
assert!(bs.left_down);
assert!(!bs.right_down);
assert!(bs.middle_down);
assert!(bs.any_down());
let none = MouseButtonState { left_down: false, right_down: false, middle_down: false };
assert!(!none.any_down());
}
#[test]
fn callback_to_call_collects_hits_for_dom() {
let dom_id = DomId { inner: 0 };
let hit_test = make_hit_test_with_node(2);
let filter = EventFilter::Hover(HoverEventFilter::MouseDown);
let calls = CallbackToCall::from_hit_test(&hit_test, dom_id, filter);
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].node_id, NodeId::new(2));
assert_eq!(calls[0].event_filter, filter);
assert!(calls[0].hit_test_item.is_some());
// Unknown DOM id => empty list
let other = CallbackToCall::from_hit_test(
&hit_test,
DomId { inner: 999 },
EventFilter::Hover(HoverEventFilter::MouseUp),
);
assert!(other.is_empty());
// Direct constructor builds expected fields
let direct = CallbackToCall::new(
NodeId::new(7),
None,
EventFilter::Focus(FocusEventFilter::FocusReceived),
);
assert_eq!(direct.node_id, NodeId::new(7));
assert!(direct.hit_test_item.is_none());
}
#[test]
fn restyle_relayout_aliases_are_btreemap_compatible() {
// RestyleNodes / RelayoutNodes are aliases for BTreeMap<NodeId, Vec<ChangedCssProperty>>.
// Confirm we can construct empty ones via the alias and that they accept the same keys.
let restyle: RestyleNodes = BTreeMap::new();
let relayout: RelayoutNodes = BTreeMap::new();
assert!(restyle.is_empty());
assert!(relayout.is_empty());
// RelayoutWords is BTreeMap<NodeId, AzString>.
let mut words: RelayoutWords = BTreeMap::new();
words.insert(NodeId::new(1), AzString::from_const_str("hello"));
assert_eq!(words.get(&NodeId::new(1)).map(azul_css::AzString::as_str), Some("hello"));
}
#[test]
fn detect_lifecycle_events_with_reconciliation_is_callable() {
// Smoke test: empty old/new node data must produce no events and an
// empty migration map. This proves the function is callable from
// the public API and threads through `crate::diff::reconcile_dom`.
let dom_id = DomId { inner: 0 };
let old_data: Vec<crate::dom::NodeData> = Vec::new();
let new_data: Vec<crate::dom::NodeData> = Vec::new();
let old_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
let new_hier: Vec<crate::styled_dom::NodeHierarchyItem> = Vec::new();
let old_layout = OrderedMap::default();
let new_layout = OrderedMap::default();
let result: LifecycleEventResult = detect_lifecycle_events_with_reconciliation(
dom_id,
&old_data,
&new_data,
&old_hier,
&new_hier,
&old_layout,
&new_layout,
Instant::Tick(SystemTick::new(0)),
);
assert!(result.events.is_empty());
assert!(result.node_id_mapping.is_empty());
}
#[test]
fn nodedata_focusable_and_activation_traits_are_wired() {
use crate::dom::{NodeData, NodeType};
use crate::events::{ActivationBehavior as _, Focusable as _};
// <button> is naturally focusable and has activation behavior.
let btn = NodeData::create_node(NodeType::Button);
assert!(<NodeData as Focusable>::is_naturally_focusable(&btn));
assert!(<NodeData as Focusable>::is_focusable(&btn));
assert!(<NodeData as ActivationBehavior>::has_activation_behavior(&btn));
assert!(<NodeData as ActivationBehavior>::is_activatable(&btn));
// A plain <div> is neither naturally focusable nor activatable.
let div = NodeData::create_node(NodeType::Div);
assert!(!<NodeData as Focusable>::is_naturally_focusable(&div));
assert!(!<NodeData as ActivationBehavior>::has_activation_behavior(&div));
// <input> is naturally focusable.
let input = NodeData::create_node(NodeType::Input);
assert!(<NodeData as Focusable>::is_naturally_focusable(&input));
}
#[test]
fn first_hovered_node_picks_frontmost_by_depth() {
use crate::hit_test::{FullHitTest, HitTest, HitTestItem};
use crate::dom::OptionDomNodeId;
use std::collections::BTreeMap;
let item = |depth: u32| HitTestItem {
point_in_viewport: LogicalPosition::zero(),
point_relative_to_item: LogicalPosition::zero(),
is_focusable: true,
is_virtual_view_hit: None,
hit_depth: depth,
};
// Front-most node (depth 0) has the HIGHER NodeId; back node (depth 5)
// has the lower id. The old `.next()` logic returned the lowest id
// (node 2, the back one). We must now return the front-most (node 5).
let mut regular = BTreeMap::new();
regular.insert(NodeId::new(2), item(5));
regular.insert(NodeId::new(5), item(0));
let mut hovered = BTreeMap::new();
hovered.insert(DomId { inner: 0 }, HitTest {
regular_hit_test_nodes: regular,
scroll_hit_test_nodes: BTreeMap::new(),
scrollbar_hit_test_nodes: BTreeMap::new(),
cursor_hit_test_nodes: BTreeMap::new(),
});
let ht = FullHitTest { hovered_nodes: hovered, focused_node: OptionDomNodeId::None };
let got = get_first_hovered_node(Some(&ht)).unwrap();
assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
}
#[test]
fn size_changed_nan_guard_stops_resize_loop() {
use crate::geom::LogicalSize;
// A NaN dimension present on BOTH frames must read as "unchanged" so no
// Resize is emitted every frame.
let a = LogicalSize::new(f32::NAN, 100.0);
let b = LogicalSize::new(f32::NAN, 100.0);
assert!(!size_changed(a, b));
// A real change is still detected.
assert!(size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.0, 120.0)));
// Sub-quantum jitter is ignored.
assert!(!size_changed(LogicalSize::new(100.0, 100.0), LogicalSize::new(100.00005, 100.0)));
}
#[test]
fn dom_path_terminates_on_parent_cycle() {
use crate::id::{Node, NodeHierarchy};
// Two nodes whose parents point at each other -> a cycle.
let nodes = vec![
Node { parent: Some(NodeId::new(1)), ..Node::ROOT },
Node { parent: Some(NodeId::new(0)), ..Node::ROOT },
];
let hier = NodeHierarchy::new(nodes);
let target = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0)));
// Must not hang / OOM; bounded by node count + visited-set.
let path = get_dom_path(&hier, target);
assert!(path.len() <= 2);
}
#[test]
fn click_event_maps_to_left_mouse_up() {
let filters = event_type_to_filters(EventType::Click, &EventData::None);
assert!(filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseUp)));
assert!(!filters.contains(&EventFilter::Hover(HoverEventFilter::LeftMouseDown)));
}
}
#[cfg(test)]
#[allow(clippy::float_cmp, clippy::too_many_lines)]
mod autotest_generated {
use super::*;
use crate::{
dom::{DomId, DomNodeId, OptionDomNodeId},
geom::{LogicalPosition, LogicalRect, LogicalSize},
hit_test::{FullHitTest, HitTest, HitTestItem},
id::{Node, NodeHierarchy, NodeId},
styled_dom::NodeHierarchyItemId,
task::{Instant, SystemTick},
window::{CursorPosition, KeyboardState, MouseState, VirtualKeyCode, VirtualKeyCodeVec},
};
// ---------------------------------------------------------------- helpers
fn tick(n: u64) -> Instant {
Instant::Tick(SystemTick::new(n))
}
fn dnid(dom: usize, node: usize) -> DomNodeId {
DomNodeId {
dom: DomId { inner: dom },
node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(node))),
}
}
/// A `DomNodeId` whose node slot is the `None` sentinel (raw inner == 0).
fn dnid_none(dom: usize) -> DomNodeId {
DomNodeId {
dom: DomId { inner: dom },
node: NodeHierarchyItemId::NONE,
}
}
fn hit_item(depth: u32) -> HitTestItem {
HitTestItem {
point_in_viewport: LogicalPosition::new(1.0, 2.0),
point_relative_to_item: LogicalPosition::new(3.0, 4.0),
is_focusable: true,
is_virtual_view_hit: None,
hit_depth: depth,
}
}
/// Hit test containing `(node_index, hit_depth)` pairs, all under one DOM.
fn hit_test_with(dom: usize, nodes: &[(usize, u32)]) -> FullHitTest {
let mut regular = BTreeMap::new();
for (idx, depth) in nodes {
regular.insert(NodeId::new(*idx), hit_item(*depth));
}
let mut hovered = BTreeMap::new();
hovered.insert(
DomId { inner: dom },
HitTest {
regular_hit_test_nodes: regular,
scroll_hit_test_nodes: BTreeMap::new(),
scrollbar_hit_test_nodes: BTreeMap::new(),
cursor_hit_test_nodes: BTreeMap::new(),
},
);
FullHitTest {
hovered_nodes: hovered,
focused_node: OptionDomNodeId::None,
}
}
fn empty_hit_test() -> FullHitTest {
FullHitTest {
hovered_nodes: BTreeMap::new(),
focused_node: OptionDomNodeId::None,
}
}
fn mouse_event(ty: EventType, button: MouseButton, pos: LogicalPosition) -> SyntheticEvent {
SyntheticEvent::new(
ty,
EventSource::User,
dnid(0, 0),
tick(0),
EventData::Mouse(MouseEventData {
position: pos,
button,
buttons: 1,
modifiers: KeyModifiers::default(),
}),
)
}
fn key_event(key_code: u32, modifiers: KeyModifiers) -> SyntheticEvent {
SyntheticEvent::new(
EventType::KeyDown,
EventSource::User,
dnid(0, 0),
tick(0),
EventData::Keyboard(KeyboardEventData {
key_code,
char_code: None,
modifiers,
repeat: false,
}),
)
}
/// Straight parent chain: node 0 = root, node i's parent = node i-1.
fn hierarchy_chain(len: usize) -> NodeHierarchy {
let nodes = (0..len)
.map(|i| Node {
parent: if i == 0 { None } else { Some(NodeId::new(i - 1)) },
..Node::ROOT
})
.collect::<Vec<_>>();
NodeHierarchy::new(nodes)
}
/// Modifiers with the platform's PRIMARY modifier held (Cmd on macOS, Ctrl elsewhere).
fn primary_modifiers() -> KeyModifiers {
if cfg!(target_os = "macos") {
KeyModifiers::new().with_meta()
} else {
KeyModifiers::new().with_ctrl()
}
}
fn keyboard_with_primary_held() -> KeyboardState {
let key = if cfg!(target_os = "macos") {
VirtualKeyCode::LWin
} else {
VirtualKeyCode::LControl
};
KeyboardState {
pressed_virtual_keycodes: VirtualKeyCodeVec::from_vec(vec![key]),
..KeyboardState::default()
}
}
// ============================================================ numeric edge
// size_changed / quantization
#[test]
fn size_changed_zero_and_identity() {
assert!(!size_changed(LogicalSize::zero(), LogicalSize::zero()));
assert!(!size_changed(
LogicalSize::new(0.0, 0.0),
LogicalSize::new(-0.0, -0.0)
));
// 0 -> any real size is a change.
assert!(size_changed(LogicalSize::zero(), LogicalSize::new(0.0, 1.0)));
assert!(size_changed(LogicalSize::zero(), LogicalSize::new(1.0, 0.0)));
}
#[test]
fn size_changed_single_sided_nan_is_a_change() {
// NaN on ONE side only must register as changed (the both-sides NaN case
// is the loop-guard covered by `size_changed_nan_guard_stops_resize_loop`).
assert!(size_changed(
LogicalSize::new(f32::NAN, 10.0),
LogicalSize::new(10.0, 10.0)
));
assert!(size_changed(
LogicalSize::new(10.0, 10.0),
LogicalSize::new(10.0, f32::NAN)
));
// NaN on both sides in *different* dimensions is still a change in the
// other dimension only if that dimension actually differs.
assert!(!size_changed(
LogicalSize::new(f32::NAN, f32::NAN),
LogicalSize::new(f32::NAN, f32::NAN)
));
}
#[test]
fn size_changed_negative_and_infinite_do_not_panic() {
// Negative sizes (degenerate layouts) must be handled deterministically.
assert!(size_changed(
LogicalSize::new(-100.0, 0.0),
LogicalSize::new(100.0, 0.0)
));
assert!(!size_changed(
LogicalSize::new(-100.0, -50.0),
LogicalSize::new(-100.0, -50.0)
));
// f32 * 1000.0 overflows to +/-inf, and `inf as i64` SATURATES (it does
// not wrap or UB). So the comparison stays total and panic-free.
assert!(!size_changed(
LogicalSize::new(f32::INFINITY, f32::INFINITY),
LogicalSize::new(f32::INFINITY, f32::INFINITY)
));
assert!(size_changed(
LogicalSize::new(f32::INFINITY, 0.0),
LogicalSize::new(f32::NEG_INFINITY, 0.0)
));
// Finite-but-huge values saturate into the same bucket as infinity: the
// documented quantization trade-off, asserted here so a future change of
// the quantizer (e.g. to i128 or a float compare) is a deliberate one.
assert!(!size_changed(
LogicalSize::new(f32::MAX, 0.0),
LogicalSize::new(f32::INFINITY, 0.0)
));
}
#[test]
fn size_changed_ignores_sub_quantum_jitter_but_sees_one_quantum() {
// The quantizer is 1/1000, so a 0.0005 wobble must be ignored...
assert!(!size_changed(
LogicalSize::new(50.0, 50.0),
LogicalSize::new(50.0004, 50.0)
));
// ...but a full quantum must be seen.
assert!(size_changed(
LogicalSize::new(50.0, 50.0),
LogicalSize::new(50.002, 50.0)
));
}
// ------------------------------------------------- create_*_event numerics
#[test]
fn create_mount_event_without_layout_entry_falls_back_to_zero_rect() {
let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
let ev = create_mount_event(NodeId::new(3), DomId { inner: 0 }, &layout, &tick(7));
assert_eq!(ev.event_type, EventType::Mount);
assert_eq!(ev.source, EventSource::Lifecycle);
assert_eq!(ev.phase, EventPhase::Target);
assert_eq!(ev.target, ev.current_target);
assert_eq!(ev.target.node.into_crate_internal(), Some(NodeId::new(3)));
match ev.data {
EventData::Lifecycle(d) => {
assert_eq!(d.reason, LifecycleReason::InitialMount);
assert!(d.previous_bounds.is_none());
assert_eq!(d.current_bounds, LogicalRect::zero());
}
_ => panic!("mount event must carry lifecycle data"),
}
}
#[test]
fn create_unmount_event_reports_previous_bounds_and_zero_current() {
let mut layout = BTreeMap::new();
let rect = LogicalRect::new(LogicalPosition::new(1.0, 2.0), LogicalSize::new(3.0, 4.0));
layout.insert(NodeId::new(1), rect);
let ev = create_unmount_event(NodeId::new(1), DomId { inner: 2 }, &layout, &tick(9));
assert_eq!(ev.event_type, EventType::Unmount);
match ev.data {
EventData::Lifecycle(d) => {
assert_eq!(d.reason, LifecycleReason::Unmount);
assert_eq!(d.previous_bounds, Some(rect));
assert_eq!(d.current_bounds, LogicalRect::zero());
}
_ => panic!("unmount event must carry lifecycle data"),
}
}
#[test]
fn create_lifecycle_event_survives_extreme_node_ids() {
// The largest NodeId that survives the 1-based (`n + 1`) FFI encoding.
// (NodeId::new(usize::MAX) would overflow that encoding — out of scope here.)
let huge = NodeId::new(usize::MAX - 1);
let layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
let ev = create_mount_event(huge, DomId { inner: usize::MAX }, &layout, &tick(0));
assert_eq!(ev.target.node.into_crate_internal(), Some(huge));
assert_eq!(ev.target.dom, DomId { inner: usize::MAX });
// NodeId 0 (the root) must round-trip too — the 1-based encoding makes
// 0 the value most likely to collide with the `None` sentinel.
let root = create_mount_event(NodeId::ZERO, DomId { inner: 0 }, &layout, &tick(0));
assert_eq!(
root.target.node.into_crate_internal(),
Some(NodeId::ZERO),
"NodeId 0 must not decode as `None`"
);
}
#[test]
fn create_resize_event_returns_none_for_missing_or_unchanged_layout() {
let dom = DomId { inner: 0 };
let node = NodeId::new(1);
let rect = LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
let empty: BTreeMap<NodeId, LogicalRect> = BTreeMap::new();
let mut one = BTreeMap::new();
one.insert(node, rect);
// Missing in old, missing in new, missing in both -> None (no panic).
assert!(create_resize_event(node, dom, &empty, &one, &tick(0)).is_none());
assert!(create_resize_event(node, dom, &one, &empty, &tick(0)).is_none());
assert!(create_resize_event(node, dom, &empty, &empty, &tick(0)).is_none());
// Present on both sides but unchanged -> None.
assert!(create_resize_event(node, dom, &one, &one, &tick(0)).is_none());
}
#[test]
fn create_resize_event_ignores_pure_origin_moves() {
// Only the SIZE is compared: moving a node without resizing it must not
// emit a Resize event.
let dom = DomId { inner: 0 };
let node = NodeId::new(0);
let size = LogicalSize::new(10.0, 10.0);
let mut old = BTreeMap::new();
old.insert(node, LogicalRect::new(LogicalPosition::new(0.0, 0.0), size));
let mut new = BTreeMap::new();
new.insert(
node,
LogicalRect::new(LogicalPosition::new(500.0, 500.0), size),
);
assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
}
#[test]
fn create_resize_event_nan_size_does_not_loop_forever() {
// Regression guard: a NaN dimension on BOTH frames must NOT emit a Resize
// every frame (a raw f32 `!=` would, since NaN != NaN).
let dom = DomId { inner: 0 };
let node = NodeId::new(0);
let nan_rect = LogicalRect::new(
LogicalPosition::zero(),
LogicalSize::new(f32::NAN, 100.0),
);
let mut old = BTreeMap::new();
old.insert(node, nan_rect);
let mut new = BTreeMap::new();
new.insert(node, nan_rect);
assert!(create_resize_event(node, dom, &old, &new, &tick(0)).is_none());
}
#[test]
fn create_resize_event_reports_both_bounds_on_real_change() {
let dom = DomId { inner: 0 };
let node = NodeId::new(0);
let old_rect =
LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 10.0));
let new_rect =
LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, 20.0));
let mut old = BTreeMap::new();
old.insert(node, old_rect);
let mut new = BTreeMap::new();
new.insert(node, new_rect);
let ev = create_resize_event(node, dom, &old, &new, &tick(3))
.expect("a real size change must emit a Resize");
assert_eq!(ev.event_type, EventType::Resize);
match ev.data {
EventData::Lifecycle(d) => {
assert_eq!(d.reason, LifecycleReason::Resize);
assert_eq!(d.previous_bounds, Some(old_rect));
assert_eq!(d.current_bounds, new_rect);
}
_ => panic!("resize event must carry lifecycle data"),
}
}
// ------------------------------------------------- detect_lifecycle_events
#[test]
fn detect_lifecycle_events_all_none_is_empty() {
let events = detect_lifecycle_events(
DomId { inner: 0 },
DomId { inner: 0 },
None,
None,
None,
None,
tick(0),
);
assert!(events.is_empty());
}
#[test]
fn detect_lifecycle_events_without_layout_emits_nothing() {
// Hierarchies differ, but no layout maps -> the fn must not fabricate events.
let old = hierarchy_chain(1);
let new = hierarchy_chain(4);
let events = detect_lifecycle_events(
DomId { inner: 0 },
DomId { inner: 0 },
Some(&old),
Some(&new),
None,
None,
tick(0),
);
assert!(events.is_empty());
}
#[test]
fn detect_lifecycle_events_emits_mounts_unmounts_and_resizes() {
let dom = DomId { inner: 0 };
let old_hier = hierarchy_chain(2); // nodes 0,1
let new_hier = hierarchy_chain(3); // nodes 0,1,2
let r = |h: f32| LogicalRect::new(LogicalPosition::zero(), LogicalSize::new(10.0, h));
let mut old_layout = BTreeMap::new();
old_layout.insert(NodeId::new(0), r(10.0));
old_layout.insert(NodeId::new(1), r(10.0));
let mut new_layout = BTreeMap::new();
new_layout.insert(NodeId::new(0), r(10.0)); // unchanged
new_layout.insert(NodeId::new(1), r(99.0)); // resized
new_layout.insert(NodeId::new(2), r(10.0)); // mounted
let events = detect_lifecycle_events(
dom,
dom,
Some(&old_hier),
Some(&new_hier),
Some(&old_layout),
Some(&new_layout),
tick(5),
);
let mounts: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Mount)
.collect();
let resizes: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Resize)
.collect();
assert_eq!(mounts.len(), 1, "only node 2 is new");
assert_eq!(
mounts[0].target.node.into_crate_internal(),
Some(NodeId::new(2))
);
assert_eq!(resizes.len(), 1, "only node 1 changed size");
assert_eq!(
resizes[0].target.node.into_crate_internal(),
Some(NodeId::new(1))
);
assert!(
!events.iter().any(|e| e.event_type == EventType::Unmount),
"nothing was removed"
);
assert!(events.iter().all(|e| e.source == EventSource::Lifecycle));
// Reverse direction: the removed node must unmount.
let events = detect_lifecycle_events(
dom,
dom,
Some(&new_hier),
Some(&old_hier),
Some(&new_layout),
Some(&old_layout),
tick(6),
);
let unmounts: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Unmount)
.collect();
assert_eq!(unmounts.len(), 1);
assert_eq!(
unmounts[0].target.node.into_crate_internal(),
Some(NodeId::new(2))
);
}
#[test]
fn detect_lifecycle_events_mount_of_node_missing_from_layout_uses_zero_rect() {
let dom = DomId { inner: 0 };
let new_hier = hierarchy_chain(2);
let new_layout: BTreeMap<NodeId, LogicalRect> = BTreeMap::new(); // empty!
let events = detect_lifecycle_events(
dom,
dom,
None,
Some(&new_hier),
None,
Some(&new_layout),
tick(0),
);
assert_eq!(events.len(), 2);
for ev in &events {
match ev.data {
EventData::Lifecycle(d) => assert_eq!(d.current_bounds, LogicalRect::zero()),
_ => panic!("expected lifecycle data"),
}
}
}
#[test]
fn collect_node_ids_handles_none_and_empty_hierarchies() {
assert!(collect_node_ids(None).is_empty());
let empty = NodeHierarchy::new(Vec::new());
assert!(collect_node_ids(Some(&empty)).is_empty());
let three = hierarchy_chain(3);
let ids = collect_node_ids(Some(&three));
assert_eq!(ids.len(), 3);
assert!(ids.contains(&NodeId::ZERO));
assert!(ids.contains(&NodeId::new(2)));
}
// ======================================================== getters/predicates
#[test]
fn process_event_result_order_is_dense_and_monotonic() {
let all = [
ProcessEventResult::DoNothing,
ProcessEventResult::ShouldReRenderCurrentWindow,
ProcessEventResult::ShouldUpdateDisplayListCurrentWindow,
ProcessEventResult::UpdateHitTesterAndProcessAgain,
ProcessEventResult::ShouldIncrementalRelayout,
ProcessEventResult::ShouldRegenerateDomCurrentWindow,
ProcessEventResult::ShouldRegenerateDomAllWindows,
];
for (i, r) in all.iter().enumerate() {
assert_eq!(r.order(), i, "order() must match declaration index");
}
// Ord/PartialOrd must agree with order(), and max_self must be the join.
for a in all {
for b in all {
assert_eq!(a < b, a.order() < b.order());
let joined = a.max_self(b);
assert_eq!(joined.order(), a.order().max(b.order()));
assert_eq!(joined, b.max_self(a), "max_self must be commutative");
assert_eq!(a.max_self(a), a, "max_self must be idempotent");
}
}
}
#[test]
fn key_modifiers_builders_are_orthogonal_and_is_empty_tracks_them() {
let empty = KeyModifiers::new();
assert!(empty.is_empty());
assert_eq!(empty, KeyModifiers::default());
// Each builder sets exactly one flag.
assert_eq!(
KeyModifiers::new().with_shift(),
KeyModifiers { shift: true, ctrl: false, alt: false, meta: false }
);
assert_eq!(
KeyModifiers::new().with_ctrl(),
KeyModifiers { shift: false, ctrl: true, alt: false, meta: false }
);
assert_eq!(
KeyModifiers::new().with_alt(),
KeyModifiers { shift: false, ctrl: false, alt: true, meta: false }
);
assert_eq!(
KeyModifiers::new().with_meta(),
KeyModifiers { shift: false, ctrl: false, alt: false, meta: true }
);
// Any single flag defeats is_empty; builders are idempotent and composable.
assert!(!KeyModifiers::new().with_shift().is_empty());
assert!(!KeyModifiers::new().with_ctrl().is_empty());
assert!(!KeyModifiers::new().with_alt().is_empty());
assert!(!KeyModifiers::new().with_meta().is_empty());
assert_eq!(
KeyModifiers::new().with_ctrl().with_ctrl(),
KeyModifiers::new().with_ctrl()
);
let all = KeyModifiers::new().with_shift().with_ctrl().with_alt().with_meta();
assert!(!all.is_empty());
assert!(all.shift && all.ctrl && all.alt && all.meta);
}
#[test]
fn scroll_into_view_options_presets_and_behavior_setters() {
assert_eq!(
ScrollIntoViewOptions::default(),
ScrollIntoViewOptions::nearest(),
"Default must be the `nearest`/`auto` preset"
);
for (opts, expected) in [
(ScrollIntoViewOptions::nearest(), ScrollLogicalPosition::Nearest),
(ScrollIntoViewOptions::center(), ScrollLogicalPosition::Center),
(ScrollIntoViewOptions::start(), ScrollLogicalPosition::Start),
(ScrollIntoViewOptions::end(), ScrollLogicalPosition::End),
] {
assert_eq!(opts.block, expected);
assert_eq!(opts.inline_axis, expected, "both axes must be aligned alike");
assert_eq!(opts.behavior, ScrollIntoViewBehavior::Auto);
// The behavior setters must not disturb the axes, and last-writer-wins.
let instant = opts.with_instant();
assert_eq!(instant.behavior, ScrollIntoViewBehavior::Instant);
assert_eq!(instant.block, opts.block);
assert_eq!(instant.inline_axis, opts.inline_axis);
let smooth = opts.with_smooth();
assert_eq!(smooth.behavior, ScrollIntoViewBehavior::Smooth);
assert_eq!(
opts.with_instant().with_smooth().behavior,
ScrollIntoViewBehavior::Smooth
);
assert_eq!(
opts.with_smooth().with_instant().behavior,
ScrollIntoViewBehavior::Instant
);
}
}
#[test]
fn default_action_result_has_action_predicate() {
assert!(!DefaultActionResult::default().has_action());
assert!(!DefaultActionResult::prevented().has_action());
assert!(DefaultActionResult::prevented().prevented);
assert_eq!(DefaultActionResult::prevented().action, DefaultAction::None);
// `None` action => nothing to do, even though it was not prevented.
let none = DefaultActionResult::new(DefaultAction::None);
assert!(!none.prevented);
assert!(!none.has_action());
// Any real action => has_action.
for action in [
DefaultAction::FocusNext,
DefaultAction::FocusPrevious,
DefaultAction::FocusFirst,
DefaultAction::FocusLast,
DefaultAction::ClearFocus,
DefaultAction::SelectAllText,
DefaultAction::ActivateFocusedElement { target: dnid(0, 1) },
DefaultAction::SubmitForm { form_node: dnid(0, 1) },
DefaultAction::CloseModal { modal_node: dnid(0, 1) },
DefaultAction::ScrollFocusedContainer {
direction: ScrollDirection::Down,
amount: ScrollAmount::Page,
},
] {
let r = DefaultActionResult::new(action);
assert_eq!(r.action, action);
assert!(!r.prevented);
assert!(r.has_action(), "{action:?} must be reported as actionable");
}
}
#[test]
fn synthetic_event_constructor_invariants_and_flag_transitions() {
let target = dnid(3, 7);
let mut ev = SyntheticEvent::new(
EventType::Click,
EventSource::Programmatic,
target,
tick(42),
EventData::None,
);
// Post-construction invariants.
assert_eq!(ev.event_type, EventType::Click);
assert_eq!(ev.source, EventSource::Programmatic);
assert_eq!(ev.phase, EventPhase::Target);
assert_eq!(ev.target, target);
assert_eq!(ev.current_target, target);
assert_eq!(ev.timestamp, tick(42));
assert!(!ev.is_propagation_stopped());
assert!(!ev.is_immediate_propagation_stopped());
assert!(!ev.is_default_prevented());
// stop_propagation does NOT imply stop_immediate_propagation...
ev.stop_propagation();
assert!(ev.is_propagation_stopped());
assert!(!ev.is_immediate_propagation_stopped());
// ...but the reverse implication MUST hold, or propagate_phase's
// `stopped_immediate` check could be bypassed by the `stopped` fast path.
let mut ev2 = SyntheticEvent::new(
EventType::Click,
EventSource::User,
target,
tick(0),
EventData::None,
);
ev2.stop_immediate_propagation();
assert!(ev2.is_immediate_propagation_stopped());
assert!(
ev2.is_propagation_stopped(),
"immediate stop must also stop normal propagation"
);
// All three flags are idempotent and independent.
let mut ev3 = ev2.clone();
ev3.stop_immediate_propagation();
ev3.prevent_default();
ev3.prevent_default();
assert!(ev3.is_default_prevented());
assert!(!ev.is_default_prevented(), "flags must not leak across events");
}
#[test]
fn hover_filter_is_system_internal_only_for_system_text_clicks() {
for f in [
HoverEventFilter::SystemTextSingleClick,
HoverEventFilter::SystemTextDoubleClick,
HoverEventFilter::SystemTextTripleClick,
] {
assert!(f.is_system_internal(), "{f:?} is internal");
assert!(
f.to_focus_event_filter().is_none(),
"internal filters must never be exposed as focus callbacks"
);
}
for f in [
HoverEventFilter::MouseOver,
HoverEventFilter::MouseDown,
HoverEventFilter::Drop,
HoverEventFilter::KeyringResult,
HoverEventFilter::MouseOut,
] {
assert!(!f.is_system_internal(), "{f:?} is a user-visible filter");
}
}
#[test]
fn event_filter_kind_predicates_are_mutually_exclusive() {
let hover = EventFilter::Hover(HoverEventFilter::MouseDown);
let focus = EventFilter::Focus(FocusEventFilter::FocusReceived);
let window = EventFilter::Window(WindowEventFilter::Resized);
let component = EventFilter::Component(ComponentEventFilter::AfterMount);
let app = EventFilter::Application(ApplicationEventFilter::DeviceConnected);
assert!(focus.is_focus_callback());
assert!(window.is_window_callback());
for f in [hover, window, component, app] {
assert!(!f.is_focus_callback(), "{f:?} is not a focus callback");
}
for f in [hover, focus, component, app] {
assert!(!f.is_window_callback(), "{f:?} is not a window callback");
}
// The `as_*` accessors must agree with the predicates.
assert_eq!(hover.as_hover_event_filter(), Some(HoverEventFilter::MouseDown));
assert_eq!(hover.as_focus_event_filter(), None);
assert_eq!(hover.as_window_event_filter(), None);
assert_eq!(focus.as_focus_event_filter(), Some(FocusEventFilter::FocusReceived));
assert_eq!(window.as_window_event_filter(), Some(WindowEventFilter::Resized));
assert_eq!(component.as_hover_event_filter(), None);
}
// ============================================================== round-trips
#[test]
fn window_to_hover_filter_mapping_never_yields_an_internal_filter() {
// Every window filter that has a hover twin must map onto a filter the
// user is actually allowed to register (never a SystemText* internal).
for w in [
WindowEventFilter::MouseOver,
WindowEventFilter::MouseDown,
WindowEventFilter::LeftMouseDown,
WindowEventFilter::RightMouseDown,
WindowEventFilter::MiddleMouseDown,
WindowEventFilter::MouseUp,
WindowEventFilter::LeftMouseUp,
WindowEventFilter::RightMouseUp,
WindowEventFilter::MiddleMouseUp,
WindowEventFilter::Scroll,
WindowEventFilter::TextInput,
WindowEventFilter::VirtualKeyDown,
WindowEventFilter::VirtualKeyUp,
WindowEventFilter::HoveredFile,
WindowEventFilter::DroppedFile,
WindowEventFilter::HoveredFileCancelled,
WindowEventFilter::TouchStart,
WindowEventFilter::TouchEnd,
WindowEventFilter::PenDown,
WindowEventFilter::DragStart,
WindowEventFilter::Drop,
WindowEventFilter::DoubleClick,
WindowEventFilter::PermissionChanged,
WindowEventFilter::BiometricResult,
WindowEventFilter::KeyringResult,
] {
let hover = w
.to_hover_event_filter()
.unwrap_or_else(|| panic!("{w:?} should have a hover twin"));
assert!(
!hover.is_system_internal(),
"{w:?} must not map onto an internal filter"
);
}
// Window-only events have deliberately NO hover twin.
for w in [
WindowEventFilter::MouseEnter,
WindowEventFilter::MouseLeave,
WindowEventFilter::Resized,
WindowEventFilter::Moved,
WindowEventFilter::FocusReceived,
WindowEventFilter::FocusLost,
WindowEventFilter::CloseRequested,
WindowEventFilter::ThemeChanged,
WindowEventFilter::WindowFocusReceived,
WindowEventFilter::WindowFocusLost,
WindowEventFilter::DpiChanged,
WindowEventFilter::MonitorChanged,
] {
assert_eq!(
w.to_hover_event_filter(),
None,
"{w:?} is window-specific and must not map to a hover filter"
);
}
}
#[test]
fn window_hover_focus_filter_names_round_trip() {
// Window -> Hover -> Focus must preserve the *identity* of the event for
// the shared (mouse / key / drag) subset — a mismatched row here means a
// callback registered as Focus(X) would fire for hover event Y.
let pairs = [
(
WindowEventFilter::MouseOver,
HoverEventFilter::MouseOver,
Some(FocusEventFilter::MouseOver),
),
(
WindowEventFilter::LeftMouseDown,
HoverEventFilter::LeftMouseDown,
Some(FocusEventFilter::LeftMouseDown),
),
(
WindowEventFilter::RightMouseUp,
HoverEventFilter::RightMouseUp,
Some(FocusEventFilter::RightMouseUp),
),
(
WindowEventFilter::TextInput,
HoverEventFilter::TextInput,
Some(FocusEventFilter::TextInput),
),
(
WindowEventFilter::VirtualKeyDown,
HoverEventFilter::VirtualKeyDown,
Some(FocusEventFilter::VirtualKeyDown),
),
(
WindowEventFilter::DragStart,
HoverEventFilter::DragStart,
Some(FocusEventFilter::DragStart),
),
(
WindowEventFilter::Drop,
HoverEventFilter::Drop,
Some(FocusEventFilter::Drop),
),
// File events exist on window + hover, but have no focus twin.
(
WindowEventFilter::DroppedFile,
HoverEventFilter::DroppedFile,
None,
),
(
WindowEventFilter::TouchStart,
HoverEventFilter::TouchStart,
None,
),
];
for (w, h, f) in pairs {
assert_eq!(w.to_hover_event_filter(), Some(h), "window->hover for {w:?}");
assert_eq!(h.to_focus_event_filter(), f, "hover->focus for {h:?}");
}
}
#[test]
fn on_to_event_filter_conversion_is_stable() {
use crate::dom::On;
// On::TextInput / FocusReceived / FocusLost are FOCUS filters, and the
// virtual-key events are WINDOW filters — everything else is Hover.
assert_eq!(
EventFilter::from(On::TextInput),
EventFilter::Focus(FocusEventFilter::TextInput)
);
assert_eq!(
EventFilter::from(On::VirtualKeyDown),
EventFilter::Window(WindowEventFilter::VirtualKeyDown)
);
assert_eq!(
EventFilter::from(On::MouseOver),
EventFilter::Hover(HoverEventFilter::MouseOver)
);
// The a11y actions all collapse onto "click" (= MouseUp).
for on in [On::Default, On::Collapse, On::Expand, On::Increment, On::Decrement] {
assert_eq!(
EventFilter::from(on),
EventFilter::Hover(HoverEventFilter::MouseUp),
"{on:?} must map to the click filter"
);
}
assert!(EventFilter::from(On::TextInput).is_focus_callback());
assert!(EventFilter::from(On::VirtualKeyUp).is_window_callback());
}
#[test]
fn virtual_keycode_round_trips_for_every_key_events_rs_interprets() {
// handle_key_down decodes `KeyboardEventData.key_code` with `from_u32`,
// while producers write `vk as u32`. If that round-trip ever breaks, every
// shortcut silently dies — so pin it for the keys this module interprets.
for vk in [
VirtualKeyCode::Left,
VirtualKeyCode::Right,
VirtualKeyCode::Up,
VirtualKeyCode::Down,
VirtualKeyCode::Home,
VirtualKeyCode::End,
VirtualKeyCode::Back,
VirtualKeyCode::Delete,
VirtualKeyCode::A,
VirtualKeyCode::C,
VirtualKeyCode::D,
VirtualKeyCode::V,
VirtualKeyCode::X,
VirtualKeyCode::Y,
VirtualKeyCode::Z,
] {
assert_eq!(
VirtualKeyCode::from_u32(vk as u32),
Some(vk),
"{vk:?} must survive the as-u32 / from_u32 round trip"
);
}
// Out-of-range key codes must decode to None rather than index out of bounds.
assert_eq!(VirtualKeyCode::from_u32(u32::MAX), None);
assert_eq!(VirtualKeyCode::from_u32(100_000), None);
}
// ============================================ ArrowDirection / KeyboardShortcut
#[test]
fn arrow_direction_from_key_is_total_over_every_decodable_key() {
// Fuzz every decodable key code (plus the undecodable tail) through both
// key mappers: they must never panic and must only claim the nav keys.
let nav = [
VirtualKeyCode::Left,
VirtualKeyCode::Right,
VirtualKeyCode::Up,
VirtualKeyCode::Down,
VirtualKeyCode::Home,
VirtualKeyCode::End,
];
for raw in 0u32..1024 {
let Some(vk) = VirtualKeyCode::from_u32(raw) else {
continue;
};
for ctrl in [false, true] {
let got = ArrowDirection::from_key(vk, ctrl);
assert_eq!(
got.is_some(),
nav.contains(&vk),
"{vk:?} (ctrl={ctrl}) must map to an ArrowDirection iff it is a nav key"
);
if let Some(dir) = got {
// to_selection is total and never panics for any (dir, ctrl).
let (_d, _s) = dir.to_selection(ctrl);
}
}
}
}
#[test]
fn arrow_direction_ctrl_only_upgrades_horizontal_arrows_to_words() {
// ctrl must upgrade Left/Right to Word steps, and must NOT change the
// step for Up/Down/Home/End (those are already line/document scoped).
for (dir, expect_no_ctrl, expect_ctrl) in [
(
ArrowDirection::Left,
(SelectionDirection::Backward, SelectionStep::Character),
(SelectionDirection::Backward, SelectionStep::Word),
),
(
ArrowDirection::Right,
(SelectionDirection::Forward, SelectionStep::Character),
(SelectionDirection::Forward, SelectionStep::Word),
),
(
ArrowDirection::Up,
(SelectionDirection::Backward, SelectionStep::VisualLine),
(SelectionDirection::Backward, SelectionStep::VisualLine),
),
(
ArrowDirection::Down,
(SelectionDirection::Forward, SelectionStep::VisualLine),
(SelectionDirection::Forward, SelectionStep::VisualLine),
),
(
ArrowDirection::LineStart,
(SelectionDirection::Backward, SelectionStep::Line),
(SelectionDirection::Backward, SelectionStep::Line),
),
(
ArrowDirection::DocumentEnd,
(SelectionDirection::Forward, SelectionStep::Document),
(SelectionDirection::Forward, SelectionStep::Document),
),
] {
assert_eq!(dir.to_selection(false), expect_no_ctrl, "{dir:?} plain");
assert_eq!(dir.to_selection(true), expect_ctrl, "{dir:?} + ctrl");
}
// Ctrl+Home/End are distinct DIRECTIONS (not a step upgrade).
assert_eq!(
ArrowDirection::from_key(VirtualKeyCode::Home, true),
Some(ArrowDirection::DocumentStart)
);
assert_eq!(
ArrowDirection::from_key(VirtualKeyCode::End, true),
Some(ArrowDirection::DocumentEnd)
);
}
#[test]
fn keyboard_shortcut_from_key_requires_primary_for_every_key() {
// Without the primary modifier NO key may produce a shortcut — otherwise
// typing plain "c" into a text field would copy.
for raw in 0u32..1024 {
let Some(vk) = VirtualKeyCode::from_u32(raw) else {
continue;
};
for shift in [false, true] {
assert_eq!(
KeyboardShortcut::from_key(vk, false, shift),
None,
"{vk:?} (shift={shift}) must need the primary modifier"
);
}
}
// With primary held, exactly the editing set is recognised.
assert_eq!(
KeyboardShortcut::from_key(VirtualKeyCode::Z, true, true),
Some(KeyboardShortcut::Redo),
"primary+shift+Z is Redo, not Undo"
);
assert_eq!(
KeyboardShortcut::from_key(VirtualKeyCode::Y, true, true),
Some(KeyboardShortcut::Redo),
"shift must not disturb primary+Y"
);
assert_eq!(
KeyboardShortcut::from_key(VirtualKeyCode::C, true, true),
Some(KeyboardShortcut::Copy),
"shift must not disturb primary+C"
);
// D is handled separately (SelectNextOccurrence), not as a KeyboardShortcut.
assert_eq!(KeyboardShortcut::from_key(VirtualKeyCode::D, true, false), None);
}
#[test]
fn selection_op_new_defaults_to_a_single_repeat() {
let op = SelectionOp::new(
SelectionDirection::Forward,
SelectionStep::Word,
SelectionMode::Delete,
);
assert_eq!(op.direction, SelectionDirection::Forward);
assert_eq!(op.step, SelectionStep::Word);
assert_eq!(op.mode, SelectionMode::Delete);
assert_eq!(op.repeat, 1, "a fresh op must apply exactly once");
}
// ================================================== filter/phase matching
#[test]
fn capture_phase_never_matches_any_filter() {
// Regression guard: azul has no capture listeners. If this breaks, every
// ancestor callback fires TWICE (once capturing, once bubbling).
let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
for filter in [
EventFilter::Hover(HoverEventFilter::MouseDown),
EventFilter::Hover(HoverEventFilter::LeftMouseDown),
EventFilter::Focus(FocusEventFilter::MouseDown),
EventFilter::Window(WindowEventFilter::MouseDown),
EventFilter::Component(ComponentEventFilter::AfterMount),
EventFilter::Application(ApplicationEventFilter::DeviceConnected),
] {
assert!(
!matches_filter_phase(filter, &ev, EventPhase::Capture),
"{filter:?} must not match in the capture phase"
);
}
// ...but the same filter DOES match at Target and Bubble.
for phase in [EventPhase::Target, EventPhase::Bubble] {
assert!(matches_filter_phase(
EventFilter::Hover(HoverEventFilter::MouseDown),
&ev,
phase
));
}
}
#[test]
fn application_filters_never_match_yet() {
// Documented stub: Application events are not routed through propagation.
let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
for phase in [EventPhase::Capture, EventPhase::Target, EventPhase::Bubble] {
assert!(!matches_filter_phase(
EventFilter::Application(ApplicationEventFilter::MonitorConnected),
&ev,
phase
));
}
}
#[test]
fn check_mouse_button_is_false_for_every_non_mouse_payload() {
for data in [
EventData::None,
EventData::Keyboard(KeyboardEventData {
key_code: 0,
char_code: None,
modifiers: KeyModifiers::default(),
repeat: false,
}),
EventData::Touch(TouchEventData {
id: u64::MAX,
position: LogicalPosition::zero(),
force: f32::NAN,
}),
EventData::Clipboard(ClipboardEventData { content: None }),
] {
for button in [MouseButton::Left, MouseButton::Right, MouseButton::Middle] {
assert!(
!check_mouse_button(&data, button),
"non-mouse payload must never claim a button"
);
}
}
// Exotic button ids compare by value, including the u8 boundary.
let other_max = EventData::Mouse(MouseEventData {
position: LogicalPosition::zero(),
button: MouseButton::Other(u8::MAX),
buttons: u8::MAX,
modifiers: KeyModifiers::default(),
});
assert!(check_mouse_button(&other_max, MouseButton::Other(u8::MAX)));
assert!(!check_mouse_button(&other_max, MouseButton::Other(0)));
assert!(!check_mouse_button(&other_max, MouseButton::Left));
}
#[test]
fn button_specific_filters_require_the_matching_button() {
let left = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
let right = mouse_event(EventType::MouseDown, MouseButton::Right, LogicalPosition::zero());
let middle = mouse_event(EventType::MouseDown, MouseButton::Middle, LogicalPosition::zero());
// The generic filter fires for every button...
for ev in [&left, &right, &middle] {
assert!(matches_hover_filter(
HoverEventFilter::MouseDown,
ev,
EventPhase::Target
));
}
// ...the specific ones only for theirs.
assert!(matches_hover_filter(HoverEventFilter::LeftMouseDown, &left, EventPhase::Target));
assert!(!matches_hover_filter(HoverEventFilter::LeftMouseDown, &right, EventPhase::Target));
assert!(matches_hover_filter(HoverEventFilter::RightMouseDown, &right, EventPhase::Target));
assert!(!matches_hover_filter(HoverEventFilter::MiddleMouseDown, &right, EventPhase::Target));
assert!(matches_hover_filter(HoverEventFilter::MiddleMouseDown, &middle, EventPhase::Target));
// A MouseDown filter must never fire on a MouseUp event and vice versa.
let up = mouse_event(EventType::MouseUp, MouseButton::Left, LogicalPosition::zero());
assert!(!matches_hover_filter(HoverEventFilter::MouseDown, &up, EventPhase::Target));
assert!(!matches_hover_filter(HoverEventFilter::MouseUp, &left, EventPhase::Target));
assert!(matches_focus_filter(FocusEventFilter::LeftMouseUp, &up, EventPhase::Target));
assert!(matches_window_filter(WindowEventFilter::LeftMouseUp, &up, EventPhase::Target));
// A MouseDown event carrying a NON-mouse payload cannot satisfy a
// button-specific filter (there is no button to compare against).
let payloadless = SyntheticEvent::new(
EventType::MouseDown,
EventSource::Synthetic,
dnid(0, 0),
tick(0),
EventData::None,
);
assert!(matches_hover_filter(HoverEventFilter::MouseDown, &payloadless, EventPhase::Target));
assert!(!matches_hover_filter(
HoverEventFilter::LeftMouseDown,
&payloadless,
EventPhase::Target
));
}
#[test]
fn component_filter_matches_only_its_own_lifecycle_event() {
let lifecycle = |ty: EventType| {
SyntheticEvent::new(ty, EventSource::Lifecycle, dnid(0, 0), tick(0), EventData::None)
};
let pairs = [
(ComponentEventFilter::AfterMount, EventType::Mount),
(ComponentEventFilter::BeforeUnmount, EventType::Unmount),
(ComponentEventFilter::Updated, EventType::Update),
(ComponentEventFilter::NodeResized, EventType::Resize),
];
for (filter, ty) in pairs {
let ev = lifecycle(ty);
assert!(
matches_component_filter(filter, &ev, EventPhase::Target),
"{filter:?} must match {ty:?}"
);
// ...and must NOT match any of the other lifecycle event types.
for (_, other_ty) in pairs.iter().filter(|(_, t)| *t != ty) {
assert!(
!matches_component_filter(filter, &lifecycle(*other_ty), EventPhase::Target),
"{filter:?} must not match {other_ty:?}"
);
}
}
// DefaultAction / Selected have no EventType twin: they must never match
// a lifecycle event (they are driven by the a11y layer instead).
for filter in [ComponentEventFilter::DefaultAction, ComponentEventFilter::Selected] {
for (_, ty) in pairs {
assert!(!matches_component_filter(filter, &lifecycle(ty), EventPhase::Target));
}
}
}
#[test]
fn event_type_to_filters_never_panics_and_stays_synced_with_the_hover_matcher() {
// ROUND-TRIP INVARIANT: a Hover filter emitted by `event_type_to_filters`
// is later re-checked by `matches_filter_phase` inside `propagate_event`
// (see shell2/common/event.rs). If the two tables disagree, the callback
// is collected and then silently dropped — a dead filter.
//
// KNOWN_DESYNC records the pairs that are ALREADY broken today (reported
// separately). The assertion is a *subset* check, so fixing one of them
// keeps this test green while any NEW desync fails it.
const KNOWN_DESYNC: &[EventType] = &[
EventType::Click, // -> Hover(LeftMouseUp), matcher wants EventType::MouseUp
EventType::ContextMenu, // -> Hover(RightMouseDown), matcher wants MouseDown
EventType::MouseOut, // -> Hover(MouseOut), matcher has no MouseOut arm
EventType::ScrollStart, // -> Hover(Scroll), matcher wants EventType::Scroll
EventType::ScrollEnd, // -> Hover(Scroll), matcher wants EventType::Scroll
EventType::FocusIn, // -> Hover(FocusIn), matcher has no FocusIn arm
EventType::FocusOut, // -> Hover(FocusOut), matcher has no FocusOut arm
EventType::CompositionStart, // -> Hover(CompositionStart), no arm
EventType::CompositionUpdate, // -> Hover(CompositionUpdate), no arm
EventType::CompositionEnd, // -> Hover(CompositionEnd), no arm
];
let mouse_data = EventData::Mouse(MouseEventData {
position: LogicalPosition::new(1.0, 1.0),
button: MouseButton::Left,
buttons: 1,
modifiers: KeyModifiers::default(),
});
let cases: Vec<(EventType, EventData)> = vec![
(EventType::MouseOver, EventData::None),
(EventType::MouseEnter, EventData::None),
(EventType::MouseLeave, EventData::None),
(EventType::MouseOut, EventData::None),
(EventType::MouseDown, mouse_data.clone()),
(EventType::MouseUp, mouse_data.clone()),
(EventType::Click, mouse_data.clone()),
(EventType::DoubleClick, mouse_data.clone()),
(EventType::ContextMenu, mouse_data.clone()),
(EventType::KeyDown, EventData::None),
(EventType::KeyUp, EventData::None),
(EventType::KeyPress, EventData::None),
(EventType::CompositionStart, EventData::None),
(EventType::CompositionUpdate, EventData::None),
(EventType::CompositionEnd, EventData::None),
(EventType::Focus, EventData::None),
(EventType::Blur, EventData::None),
(EventType::FocusIn, EventData::None),
(EventType::FocusOut, EventData::None),
(EventType::Input, EventData::None),
(EventType::Change, EventData::None),
(EventType::Scroll, EventData::None),
(EventType::ScrollStart, EventData::None),
(EventType::ScrollEnd, EventData::None),
(EventType::DragStart, EventData::None),
(EventType::Drag, EventData::None),
(EventType::DragEnd, EventData::None),
(EventType::DragEnter, EventData::None),
(EventType::DragOver, EventData::None),
(EventType::DragLeave, EventData::None),
(EventType::Drop, EventData::None),
(EventType::TouchStart, EventData::None),
(EventType::TouchMove, EventData::None),
(EventType::TouchEnd, EventData::None),
(EventType::TouchCancel, EventData::None),
(EventType::Mount, EventData::None),
(EventType::Unmount, EventData::None),
(EventType::Update, EventData::None),
(EventType::Resize, EventData::None),
(EventType::WindowResize, EventData::None),
(EventType::WindowMove, EventData::None),
(EventType::WindowClose, EventData::None),
(EventType::ThemeChange, EventData::None),
(EventType::FileHover, EventData::None),
(EventType::FileDrop, EventData::None),
(EventType::FileHoverCancel, EventData::None),
(EventType::Copy, EventData::None),
(EventType::Cut, EventData::None),
(EventType::Paste, EventData::None),
(EventType::SensorChanged, EventData::None),
(EventType::GamepadInput, EventData::None),
(EventType::GeolocationFix, EventData::None),
(EventType::GeolocationError, EventData::None),
(EventType::PermissionChanged, EventData::None),
(EventType::BiometricResult, EventData::None),
(EventType::KeyringResult, EventData::None),
(EventType::LongPress, EventData::None),
(EventType::Play, EventData::None),
];
for (ty, data) in cases {
let filters = event_type_to_filters(ty, &data);
let ev = SyntheticEvent::new(ty, EventSource::User, dnid(0, 0), tick(0), data);
// No duplicate filters — a duplicate would invoke the callback twice.
let mut seen = BTreeSet::new();
for f in &filters {
assert!(seen.insert(*f), "{ty:?} emitted {f:?} twice");
}
for f in &filters {
if !matches!(f, EventFilter::Hover(_)) {
continue; // only Hover filters are re-checked by propagate_event
}
if matches_filter_phase(*f, &ev, EventPhase::Target) {
continue;
}
assert!(
KNOWN_DESYNC.contains(&ty),
"NEW DESYNC: event_type_to_filters({ty:?}) emits {f:?}, but \
matches_filter_phase rejects it at the Target phase, so the \
callback would be collected and then silently dropped"
);
}
}
}
#[test]
fn event_type_to_filters_omits_button_specific_filter_for_exotic_buttons() {
// MouseButton::Other(n) has no dedicated filter: only the generic one.
let data = EventData::Mouse(MouseEventData {
position: LogicalPosition::zero(),
button: MouseButton::Other(u8::MAX),
buttons: 0,
modifiers: KeyModifiers::default(),
});
let down = event_type_to_filters(EventType::MouseDown, &data);
assert_eq!(down, vec![EventFilter::Hover(HoverEventFilter::MouseDown)]);
let up = event_type_to_filters(EventType::MouseUp, &data);
assert_eq!(up, vec![EventFilter::Hover(HoverEventFilter::MouseUp)]);
// Unmapped event types produce an empty filter list (never a panic).
for ty in [
EventType::Submit,
EventType::Reset,
EventType::Invalid,
EventType::Play,
EventType::Pause,
EventType::Ended,
EventType::TimeUpdate,
EventType::VolumeChange,
EventType::MediaError,
EventType::PinchIn,
EventType::RotateClockwise,
EventType::SwipeLeft,
] {
assert!(
event_type_to_filters(ty, &EventData::None).is_empty(),
"{ty:?} is unmapped and must yield no filters"
);
}
}
// ================================================== DOM path / propagation
#[test]
fn get_dom_path_none_target_yields_empty_path() {
let hier = hierarchy_chain(3);
assert!(get_dom_path(&hier, NodeHierarchyItemId::NONE).is_empty());
// An empty hierarchy with a real target must not index out of bounds.
let empty = NodeHierarchy::new(Vec::new());
let path = get_dom_path(&empty, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
assert_eq!(path, vec![NodeId::ZERO], "unknown nodes still path to themselves");
}
#[test]
fn get_dom_path_out_of_range_target_does_not_panic() {
let hier = hierarchy_chain(3);
let huge = NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(usize::MAX - 1)));
let path = get_dom_path(&hier, huge);
assert_eq!(path, vec![NodeId::new(usize::MAX - 1)]);
}
#[test]
fn get_dom_path_returns_root_to_target_order() {
let hier = hierarchy_chain(4); // 0 <- 1 <- 2 <- 3
let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(3))));
assert_eq!(
path,
vec![NodeId::new(0), NodeId::new(1), NodeId::new(2), NodeId::new(3)],
"path must run root -> target"
);
// The root itself paths to a single-element vec.
let root_path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
assert_eq!(root_path, vec![NodeId::ZERO]);
}
#[test]
fn get_dom_path_terminates_on_a_self_parent_cycle() {
// A node that is its own parent must not spin forever.
let hier = NodeHierarchy::new(vec![Node {
parent: Some(NodeId::ZERO),
..Node::ROOT
}]);
let path = get_dom_path(&hier, NodeHierarchyItemId::from_crate_internal(Some(NodeId::ZERO)));
assert_eq!(path, vec![NodeId::ZERO]);
}
#[test]
fn get_dom_path_handles_a_deep_chain_without_recursing() {
// 5000 levels deep: an iterative walk copes, a recursive one would blow
// the stack.
let hier = hierarchy_chain(5000);
let path = get_dom_path(
&hier,
NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(4999))),
);
assert_eq!(path.len(), 5000);
assert_eq!(path[0], NodeId::ZERO);
assert_eq!(path[4999], NodeId::new(4999));
}
#[test]
fn propagate_event_visits_each_node_exactly_once() {
// Regression guard for the double-fire bug: with capture + bubble both
// walking the ancestors, a node's callback used to be collected TWICE.
let hier = hierarchy_chain(3); // 0 <- 1 <- 2
let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
for i in 0..3 {
callbacks.insert(
NodeId::new(i),
vec![EventFilter::Hover(HoverEventFilter::MouseDown)],
);
}
let mut ev = SyntheticEvent::new(
EventType::MouseDown,
EventSource::User,
dnid(0, 2),
tick(0),
EventData::Mouse(MouseEventData {
position: LogicalPosition::zero(),
button: MouseButton::Left,
buttons: 1,
modifiers: KeyModifiers::default(),
}),
);
let result = propagate_event(&mut ev, &hier, &callbacks);
let nodes: Vec<NodeId> = result.callbacks_to_invoke.iter().map(|(n, _)| *n).collect();
assert_eq!(
nodes,
vec![NodeId::new(2), NodeId::new(1), NodeId::new(0)],
"target first, then bubbling up to the root — each node once"
);
assert!(!result.default_prevented);
}
#[test]
fn propagate_event_on_a_dangling_target_is_a_no_op() {
let hier = hierarchy_chain(2);
let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
// Target = the `None` sentinel: the doc comment claims a panic, but the
// implementation returns a default result. Pin the safe behavior.
let mut ev = SyntheticEvent::new(
EventType::MouseDown,
EventSource::User,
dnid_none(0),
tick(0),
EventData::None,
);
let result = propagate_event(&mut ev, &hier, &callbacks);
assert!(result.callbacks_to_invoke.is_empty());
assert!(!result.default_prevented);
// Target = a node id far outside the hierarchy: also a no-op, no panic.
let mut ev = SyntheticEvent::new(
EventType::MouseDown,
EventSource::User,
dnid(0, 10_000),
tick(0),
EventData::None,
);
let result = propagate_event(&mut ev, &hier, &callbacks);
assert!(result.callbacks_to_invoke.is_empty());
}
#[test]
fn propagate_event_respects_a_pre_stopped_event() {
let hier = hierarchy_chain(3);
let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
for i in 0..3 {
callbacks.insert(
NodeId::new(i),
vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
);
}
let base = SyntheticEvent::new(
EventType::MouseOver,
EventSource::User,
dnid(0, 2),
tick(0),
EventData::None,
);
// stopped => neither target nor bubble collect anything.
let mut stopped = base.clone();
stopped.stop_propagation();
let r = propagate_event(&mut stopped, &hier, &callbacks);
assert!(r.callbacks_to_invoke.is_empty(), "a stopped event collects nothing");
// stopped_immediate => likewise (and it implies `stopped`).
let mut immediate = base.clone();
immediate.stop_immediate_propagation();
let r = propagate_event(&mut immediate, &hier, &callbacks);
assert!(r.callbacks_to_invoke.is_empty());
// prevented_default is faithfully reported back out.
let mut prevented = base;
prevented.prevent_default();
let r = propagate_event(&mut prevented, &hier, &callbacks);
assert!(r.default_prevented);
assert_eq!(r.callbacks_to_invoke.len(), 3, "preventDefault must not stop dispatch");
}
#[test]
fn propagate_event_ignores_filters_that_do_not_match_the_event() {
let hier = hierarchy_chain(2);
let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
callbacks.insert(
NodeId::new(1),
vec![
EventFilter::Hover(HoverEventFilter::MouseUp), // wrong event type
EventFilter::Hover(HoverEventFilter::RightMouseDown), // wrong button
EventFilter::Hover(HoverEventFilter::LeftMouseDown), // match
],
);
let mut ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
ev.target = dnid(0, 1);
ev.current_target = ev.target;
let r = propagate_event(&mut ev, &hier, &callbacks);
assert_eq!(
r.callbacks_to_invoke,
vec![(
NodeId::new(1),
EventFilter::Hover(HoverEventFilter::LeftMouseDown)
)]
);
// The event is left in the state of the LAST walked phase: bubble, ending
// on the root ancestor. (`current_target` is only meaningful while a
// callback is running, so this pins the post-walk residue rather than
// asserting it is reset.)
assert_eq!(ev.phase, EventPhase::Bubble);
assert_eq!(ev.current_target, dnid(0, 0));
assert_eq!(ev.target, dnid(0, 1), "the target itself must never be rewritten");
}
#[test]
fn collect_matching_callbacks_collects_nothing_once_immediate_stop_is_set() {
let mut result = PropagationResult::default();
let mut callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
callbacks.insert(
NodeId::ZERO,
vec![EventFilter::Hover(HoverEventFilter::MouseOver)],
);
let mut ev = SyntheticEvent::new(
EventType::MouseOver,
EventSource::User,
dnid(0, 0),
tick(0),
EventData::None,
);
ev.stop_immediate_propagation();
collect_matching_callbacks(&ev, NodeId::ZERO, EventPhase::Target, &callbacks, &mut result);
assert!(result.callbacks_to_invoke.is_empty());
// A node with no registered callbacks is simply skipped.
let mut fresh = PropagationResult::default();
let clean = SyntheticEvent::new(
EventType::MouseOver,
EventSource::User,
dnid(0, 0),
tick(0),
EventData::None,
);
collect_matching_callbacks(&clean, NodeId::new(9), EventPhase::Target, &callbacks, &mut fresh);
assert!(fresh.callbacks_to_invoke.is_empty());
}
#[test]
fn propagate_phase_over_an_empty_iterator_only_sets_the_phase() {
let mut result = PropagationResult::default();
let callbacks: BTreeMap<NodeId, Vec<EventFilter>> = BTreeMap::new();
let mut ev = SyntheticEvent::new(
EventType::MouseOver,
EventSource::User,
dnid(0, 0),
tick(0),
EventData::None,
);
propagate_phase(
&mut ev,
core::iter::empty(),
EventPhase::Bubble,
&callbacks,
&mut result,
);
assert_eq!(ev.phase, EventPhase::Bubble);
assert!(result.callbacks_to_invoke.is_empty());
// propagate_target_phase resets phase + current_target to the target.
propagate_target_phase(&mut ev, NodeId::ZERO, &callbacks, &mut result);
assert_eq!(ev.phase, EventPhase::Target);
assert_eq!(ev.current_target, ev.target);
}
// ================================================================== dedup
#[test]
fn deduplicate_synthetic_events_handles_empty_and_single() {
assert!(deduplicate_synthetic_events(Vec::new()).is_empty());
let one = vec![SyntheticEvent::new(
EventType::Scroll,
EventSource::User,
dnid(0, 0),
tick(1),
EventData::None,
)];
assert_eq!(deduplicate_synthetic_events(one).len(), 1);
}
#[test]
fn deduplicate_synthetic_events_keeps_the_latest_timestamp_per_target_and_type() {
let mk = |node: usize, ty: EventType, t: u64| {
SyntheticEvent::new(ty, EventSource::User, dnid(0, node), tick(t), EventData::None)
};
// Same (target, type), out-of-order timestamps -> keep the newest.
let events = vec![
mk(1, EventType::Scroll, 5),
mk(1, EventType::Scroll, 99),
mk(1, EventType::Scroll, 1),
];
let out = deduplicate_synthetic_events(events);
assert_eq!(out.len(), 1);
assert_eq!(out[0].timestamp, tick(99), "the newest event must survive");
// Different node OR different type -> both survive.
let events = vec![
mk(1, EventType::Scroll, 1),
mk(2, EventType::Scroll, 1),
mk(1, EventType::MouseOver, 1),
];
assert_eq!(deduplicate_synthetic_events(events).len(), 3);
// Different DOM with the same node index -> distinct targets.
let a = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(0, 1), tick(0), EventData::None);
let b = SyntheticEvent::new(EventType::Scroll, EventSource::User, dnid(1, 1), tick(0), EventData::None);
assert_eq!(deduplicate_synthetic_events(vec![a, b]).len(), 2);
}
#[test]
fn deduplicate_synthetic_events_collapses_a_large_duplicate_burst() {
// 10k identical events (e.g. a scroll storm) must collapse to one, and
// the result must be the newest — no quadratic blowup, no overflow.
let events: Vec<SyntheticEvent> = (0..10_000u64)
.map(|t| {
SyntheticEvent::new(
EventType::Scroll,
EventSource::User,
dnid(0, 0),
tick(t),
EventData::None,
)
})
.collect();
let out = deduplicate_synthetic_events(events);
assert_eq!(out.len(), 1);
assert_eq!(out[0].timestamp, tick(9_999));
}
#[test]
fn deduplicate_synthetic_events_preserves_unicode_payloads() {
// Deduplication keys off (target, event_type) only — the payload must
// survive untouched, including multi-byte / combining / RTL text.
let text = "🦀 グラフ é\u{0301} مرحبا \u{1F1E6}\u{1F1F9}".repeat(200);
let ev = SyntheticEvent::new(
EventType::Input,
EventSource::User,
dnid(0, 0),
tick(1),
EventData::TextInput(TextInputEventData {
inserted_text: text.clone(),
old_text: String::new(),
}),
);
let newer = SyntheticEvent::new(
EventType::Input,
EventSource::User,
dnid(0, 0),
tick(2),
EventData::TextInput(TextInputEventData {
inserted_text: text.clone(),
old_text: text.clone(),
}),
);
let out = deduplicate_synthetic_events(vec![ev, newer]);
assert_eq!(out.len(), 1);
match &out[0].data {
EventData::TextInput(d) => {
assert_eq!(d.inserted_text, text);
assert_eq!(d.old_text, text, "the newer event won");
}
_ => panic!("payload must be preserved"),
}
}
// ====================================================== hit-test extraction
#[test]
fn get_first_hovered_node_on_empty_input() {
assert!(get_first_hovered_node(None).is_none());
assert!(
get_first_hovered_node(Some(&empty_hit_test())).is_none(),
"a hit test with no hovered DOMs has no front-most node"
);
// A DOM entry that is present but has zero hit nodes is also `None`.
let ht = hit_test_with(0, &[]);
assert!(get_first_hovered_node(Some(&ht)).is_none());
}
#[test]
fn get_first_hovered_node_picks_minimum_depth_and_breaks_ties_deterministically() {
// Front-most (depth 0) has the HIGHER node id — a naive `.next()` on the
// BTreeMap would wrongly return node 2.
let ht = hit_test_with(0, &[(2, 5), (5, 0), (9, 3)]);
let got = get_first_hovered_node(Some(&ht)).unwrap();
assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(5)));
// Equal depths: the first in (DomId, NodeId) iteration order wins, and the
// choice must be stable across calls.
let ht = hit_test_with(0, &[(7, 2), (3, 2), (11, 2)]);
let a = get_first_hovered_node(Some(&ht)).unwrap();
let b = get_first_hovered_node(Some(&ht)).unwrap();
assert_eq!(a, b, "tie-breaking must be deterministic");
assert_eq!(a.node.into_crate_internal(), Some(NodeId::new(3)));
// u32::MAX depth is still a valid (and only) candidate.
let ht = hit_test_with(0, &[(1, u32::MAX)]);
let got = get_first_hovered_node(Some(&ht)).unwrap();
assert_eq!(got.node.into_crate_internal(), Some(NodeId::new(1)));
assert_eq!(got.dom, DomId { inner: 0 });
}
#[test]
fn get_mouse_position_with_fallback_prefers_the_event_payload() {
let mouse = MouseState {
cursor_position: CursorPosition::InWindow(LogicalPosition::new(9.0, 9.0)),
..MouseState::default()
};
let ev = mouse_event(
EventType::MouseDown,
MouseButton::Left,
LogicalPosition::new(1.0, 2.0),
);
assert_eq!(
get_mouse_position_with_fallback(&ev, &mouse),
LogicalPosition::new(1.0, 2.0),
"the event's own payload wins over the live cursor"
);
// Non-mouse payload -> fall back to the live cursor...
let keyless = SyntheticEvent::new(
EventType::MouseDown,
EventSource::Synthetic,
dnid(0, 0),
tick(0),
EventData::None,
);
assert_eq!(
get_mouse_position_with_fallback(&keyless, &mouse),
LogicalPosition::new(9.0, 9.0)
);
// ...and if the cursor is Uninitialized or OutOfWindow, fall back to zero
// (`CursorPosition::get_position` only yields InWindow positions).
for cursor in [
CursorPosition::Uninitialized,
CursorPosition::OutOfWindow(LogicalPosition::new(-5.0, -5.0)),
] {
let ms = MouseState { cursor_position: cursor, ..MouseState::default() };
assert_eq!(
get_mouse_position_with_fallback(&keyless, &ms),
LogicalPosition::zero()
);
}
}
#[test]
fn get_mouse_position_with_fallback_passes_through_extreme_coordinates() {
let mouse = MouseState::default();
for pos in [
LogicalPosition::new(f32::NAN, f32::NAN),
LogicalPosition::new(f32::INFINITY, f32::NEG_INFINITY),
LogicalPosition::new(f32::MAX, f32::MIN),
LogicalPosition::new(-0.0, 0.0),
] {
let ev = mouse_event(EventType::MouseDown, MouseButton::Left, pos);
let got = get_mouse_position_with_fallback(&ev, &mouse);
// Compare bitwise so NaN == NaN holds: the value must be forwarded
// verbatim, never sanitized or panicked on.
assert_eq!(got.x.to_bits(), pos.x.to_bits());
assert_eq!(got.y.to_bits(), pos.y.to_bits());
}
}
// ============================================= input-interpreter handlers
#[test]
fn handle_mouse_down_treats_zero_click_count_as_one() {
let ht = hit_test_with(0, &[(0, 0)]);
let mouse = MouseState::default();
let kb = KeyboardState::default();
let ev = mouse_event(
EventType::MouseDown,
MouseButton::Left,
LogicalPosition::new(4.0, 5.0),
);
// click_count 0 is normalised to 1 -> a plain text-selection click.
let action = handle_mouse_down(&ev, Some(&ht), 0, &mouse, &kb)
.expect("click_count 0 must be treated as a single click");
match action {
InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { position, .. }) => {
assert_eq!(position, LogicalPosition::new(4.0, 5.0));
}
_ => panic!("expected a passed-through TextSelectionClick"),
}
}
#[test]
fn handle_mouse_down_saturates_above_a_triple_click() {
let ht = hit_test_with(0, &[(0, 0)]);
let mouse = MouseState::default();
let kb = KeyboardState::default();
let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
// 1..=3 are real clicks.
for count in 1u8..=3 {
assert!(
handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_some(),
"click_count {count} must produce a selection click"
);
}
// 4 and above (up to the u8 boundary) are dropped — no wraparound, no panic.
for count in [4u8, 5, 100, u8::MAX] {
assert!(
handle_mouse_down(&ev, Some(&ht), count, &mouse, &kb).is_none(),
"click_count {count} must be ignored"
);
}
}
#[test]
fn handle_mouse_down_without_a_hit_test_is_a_no_op() {
let mouse = MouseState::default();
let kb = KeyboardState::default();
let ev = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
assert!(handle_mouse_down(&ev, None, 1, &mouse, &kb).is_none());
assert!(handle_mouse_down(&ev, Some(&empty_hit_test()), 1, &mouse, &kb).is_none());
}
#[test]
fn handle_mouse_down_with_primary_held_adds_a_cursor_only_on_a_single_click() {
let ht = hit_test_with(0, &[(0, 0)]);
let mouse = MouseState::default();
let kb = keyboard_with_primary_held();
let ev = mouse_event(
EventType::MouseDown,
MouseButton::Left,
LogicalPosition::new(7.0, 8.0),
);
// primary + single click -> multi-cursor add.
match handle_mouse_down(&ev, Some(&ht), 1, &mouse, &kb) {
Some(InternalEventAction::AddAndPass(SystemChange::AddCursorAtClick { position })) => {
assert_eq!(position, LogicalPosition::new(7.0, 8.0));
}
_ => panic!("primary+click must add a cursor at the click position"),
}
// primary + double click -> NOT a cursor add (falls back to selection).
match handle_mouse_down(&ev, Some(&ht), 2, &mouse, &kb) {
Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionClick { .. })) => {}
_ => panic!("primary+double-click must not add a cursor"),
}
}
#[test]
fn handle_mouse_over_requires_a_held_button_and_a_drag_origin() {
let ht = hit_test_with(0, &[(0, 0)]);
let start = LogicalPosition::new(1.0, 1.0);
let ev = mouse_event(
EventType::MouseOver,
MouseButton::Left,
LogicalPosition::new(50.0, 60.0),
);
// Button up -> never a drag, even with a drag origin.
let up = MouseState::default();
assert!(handle_mouse_over(&ev, Some(&ht), &up, Some(start)).is_none());
// Button down but no drag origin -> not a drag either.
let down = MouseState { left_down: true, ..MouseState::default() };
assert!(handle_mouse_over(&ev, Some(&ht), &down, None).is_none());
// Button down + origin but nothing under the cursor -> no drag.
assert!(handle_mouse_over(&ev, None, &down, Some(start)).is_none());
assert!(handle_mouse_over(&ev, Some(&empty_hit_test()), &down, Some(start)).is_none());
// All three present -> a drag selection from origin to the current point.
match handle_mouse_over(&ev, Some(&ht), &down, Some(start)) {
Some(InternalEventAction::AddAndPass(SystemChange::TextSelectionDrag {
start_position,
current_position,
})) => {
assert_eq!(start_position, start);
assert_eq!(current_position, LogicalPosition::new(50.0, 60.0));
}
_ => panic!("expected a TextSelectionDrag"),
}
}
#[test]
fn handle_key_down_needs_a_focused_node_and_a_keyboard_payload() {
let kb = KeyboardState::default();
let ev = key_event(VirtualKeyCode::Back as u32, KeyModifiers::default());
assert!(
handle_key_down(&ev, &kb, None).is_none(),
"no focus => no keyboard system change"
);
// Focused, but the event carries no keyboard payload.
let payloadless = SyntheticEvent::new(
EventType::KeyDown,
EventSource::User,
dnid(0, 1),
tick(0),
EventData::None,
);
assert!(handle_key_down(&payloadless, &kb, Some(dnid(0, 1))).is_none());
}
#[test]
fn handle_key_down_rejects_undecodable_key_codes() {
let kb = KeyboardState::default();
let target = Some(dnid(0, 1));
// u32::MAX / out-of-table codes must fall out via `from_u32` -> None,
// never index a table or panic.
for code in [u32::MAX, u32::MAX - 1, 100_000, 9_999] {
let ev = key_event(code, KeyModifiers::default());
assert!(
handle_key_down(&ev, &kb, target).is_none(),
"key_code {code} must decode to None"
);
}
}
#[test]
fn handle_key_down_reads_modifiers_from_the_event_not_the_live_keyboard() {
// The live KeyboardState is deliberately EMPTY here: the handler must key
// off the event payload's modifiers (the live state may have advanced
// between queueing and dispatch).
let kb = KeyboardState::default();
let target = dnid(0, 1);
let ev = key_event(VirtualKeyCode::C as u32, primary_modifiers());
match handle_key_down(&ev, &kb, Some(target)) {
Some(InternalEventAction::AddAndSkip(SystemChange::CopyToClipboard)) => {}
_ => panic!("primary+C in the payload must copy, regardless of the live state"),
}
// ...and conversely, a live primary key must NOT rewrite an unmodified event.
let live = keyboard_with_primary_held();
let plain = key_event(VirtualKeyCode::C as u32, KeyModifiers::default());
assert!(
handle_key_down(&plain, &live, Some(target)).is_none(),
"an unmodified C is plain text input, not a copy"
);
}
#[test]
fn handle_key_down_maps_backspace_and_delete_to_selection_ops() {
let kb = KeyboardState::default();
let target = dnid(0, 1);
let expect_op = |ev: &SyntheticEvent| -> SelectionOp {
match handle_key_down(ev, &kb, Some(target)) {
Some(InternalEventAction::AddAndSkip(SystemChange::ApplySelectionOp {
target: t,
op,
})) => {
assert_eq!(t, target);
op
}
_ => panic!("expected an ApplySelectionOp"),
}
};
let back = expect_op(&key_event(VirtualKeyCode::Back as u32, KeyModifiers::default()));
assert_eq!(back.direction, SelectionDirection::Backward);
assert_eq!(back.step, SelectionStep::Character);
assert_eq!(back.mode, SelectionMode::Delete);
let del = expect_op(&key_event(VirtualKeyCode::Delete as u32, KeyModifiers::default()));
assert_eq!(del.direction, SelectionDirection::Forward);
assert_eq!(del.step, SelectionStep::Character);
assert_eq!(del.mode, SelectionMode::Delete);
// Shift+arrow extends instead of moving.
let shift_right = expect_op(&key_event(
VirtualKeyCode::Right as u32,
KeyModifiers::new().with_shift(),
));
assert_eq!(shift_right.mode, SelectionMode::Extend);
assert_eq!(shift_right.step, SelectionStep::Character);
// The word modifier upgrades Backspace to a word delete.
let word_mod = if cfg!(target_os = "macos") {
KeyModifiers::new().with_alt()
} else {
KeyModifiers::new().with_ctrl()
};
let word_back = expect_op(&key_event(VirtualKeyCode::Back as u32, word_mod));
assert_eq!(word_back.step, SelectionStep::Word);
assert_eq!(word_back.mode, SelectionMode::Delete);
}
#[test]
fn handle_key_down_ignores_keys_it_does_not_interpret() {
let kb = KeyboardState::default();
let target = Some(dnid(0, 1));
// Ordinary text keys must pass through to the user callbacks untouched.
for vk in [VirtualKeyCode::B, VirtualKeyCode::Q, VirtualKeyCode::Space, VirtualKeyCode::F5] {
let ev = key_event(vk as u32, KeyModifiers::default());
assert!(
handle_key_down(&ev, &kb, target).is_none(),
"{vk:?} must not generate a system change"
);
}
}
// ================================================ default_input_interpreter
#[test]
fn default_input_interpreter_with_no_events_produces_nothing() {
let kb = KeyboardState::default();
let mouse = MouseState::default();
let info = InputInterpreterInfo {
events: &[],
hit_test: None,
keyboard_state: &kb,
mouse_state: &mouse,
state: InputInterpreterState {
focused_node: None,
click_count: 0,
drag_start_position: None,
has_selection: false,
},
};
let r = default_input_interpreter(&info);
assert!(r.system_changes.is_empty());
assert!(r.user_events.is_empty());
}
#[test]
fn default_input_interpreter_skips_shortcut_events_but_passes_clicks_through() {
let kb = KeyboardState::default();
let mouse = MouseState::default();
let ht = hit_test_with(0, &[(0, 0)]);
let target = dnid(0, 1);
// A primary+C shortcut is consumed (AddAndSkip) — the user callback must
// NOT also see the raw key event...
let copy = key_event(VirtualKeyCode::C as u32, primary_modifiers());
// ...while a MouseDown is consumed AND forwarded (AddAndPass).
let click = mouse_event(EventType::MouseDown, MouseButton::Left, LogicalPosition::zero());
// ...and an unhandled event type is forwarded untouched.
let scroll = SyntheticEvent::new(
EventType::Scroll,
EventSource::User,
target,
tick(0),
EventData::None,
);
let events = vec![copy, click, scroll];
let info = InputInterpreterInfo {
events: &events,
hit_test: Some(&ht),
keyboard_state: &kb,
mouse_state: &mouse,
state: InputInterpreterState {
focused_node: Some(target),
click_count: 1,
drag_start_position: None,
has_selection: false,
},
};
let r = default_input_interpreter(&info);
assert_eq!(r.system_changes.len(), 2, "copy + selection click");
assert!(r.system_changes.contains(&SystemChange::CopyToClipboard));
assert!(r
.system_changes
.iter()
.any(|c| matches!(c, SystemChange::TextSelectionClick { .. })));
assert_eq!(r.user_events.len(), 2, "the consumed KeyDown must not be forwarded");
assert!(!r.user_events.iter().any(|e| e.event_type == EventType::KeyDown));
assert!(r.user_events.iter().any(|e| e.event_type == EventType::MouseDown));
assert!(r.user_events.iter().any(|e| e.event_type == EventType::Scroll));
}
#[test]
fn default_input_interpreter_extern_survives_a_null_info_pointer() {
// The C-ABI trampoline must null-check rather than deref garbage.
let user_data = crate::refany::RefAny::new(0u8);
let r = default_input_interpreter_extern(user_data, core::ptr::null());
assert!(r.system_changes.is_empty());
assert!(r.user_events.is_empty());
}
// ==================================================== post-callback filter
#[test]
fn post_filter_with_prevent_default_only_lets_focus_changes_through() {
let old = Some(dnid(0, 1));
let new = Some(dnid(0, 2));
let pre = vec![
SystemChange::TextSelectionClick {
position: LogicalPosition::zero(),
timestamp: tick(0),
},
SystemChange::PasteFromClipboard,
];
// prevent_default + no focus change -> absolutely nothing (not even the
// usual ApplyPendingTextInput).
let out = default_post_filter(true, &pre, old, old);
assert!(out.is_empty(), "preventDefault must suppress every side effect");
// prevent_default + a focus change -> ONLY the focus change.
let out = default_post_filter(true, &pre, old, new);
assert_eq!(out, vec![SystemChange::SetFocus { new_focus: new, old_focus: old }]);
}
#[test]
fn post_filter_maps_pre_changes_to_their_follow_ups() {
// No pre-changes, no focus change -> just the text-input flush.
let out = default_post_filter(false, &[], None, None);
assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
// Cursor-moving ops schedule a scroll-into-view.
for change in [
SystemChange::TextSelectionClick {
position: LogicalPosition::zero(),
timestamp: tick(0),
},
SystemChange::ApplySelectionOp {
target: dnid(0, 1),
op: SelectionOp::new(
SelectionDirection::Forward,
SelectionStep::Character,
SelectionMode::Move,
),
},
SystemChange::AddCursorAtClick { position: LogicalPosition::zero() },
SystemChange::SelectNextOccurrence { target: dnid(0, 1) },
SystemChange::CutToClipboard { target: dnid(0, 1) },
SystemChange::PasteFromClipboard,
SystemChange::UndoTextEdit { target: dnid(0, 1) },
SystemChange::RedoTextEdit { target: dnid(0, 1) },
SystemChange::SelectAllText,
] {
let out = default_post_filter(false, core::slice::from_ref(&change), None, None);
assert!(
out.contains(&SystemChange::ScrollSelectionIntoView),
"{change:?} must schedule a scroll-into-view"
);
assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
}
// A drag starts the auto-scroll timer instead.
let drag = SystemChange::TextSelectionDrag {
start_position: LogicalPosition::zero(),
current_position: LogicalPosition::new(1.0, 1.0),
};
let out = default_post_filter(false, core::slice::from_ref(&drag), None, None);
assert!(out.contains(&SystemChange::StartAutoScrollTimer));
assert!(!out.contains(&SystemChange::ScrollSelectionIntoView));
// Changes with no follow-up add nothing beyond the text-input flush.
let out = default_post_filter(false, &[SystemChange::CopyToClipboard], None, None);
assert_eq!(out, vec![SystemChange::ApplyPendingTextInput]);
}
#[test]
fn post_filter_emits_set_focus_only_when_focus_actually_moved() {
let a = Some(dnid(0, 1));
let b = Some(dnid(0, 2));
// Unchanged (both Some, both None) -> no SetFocus.
for (old, new) in [(a, a), (None, None)] {
let out = default_post_filter(false, &[], old, new);
assert!(!out.iter().any(|c| matches!(c, SystemChange::SetFocus { .. })));
}
// Changed (including to/from None) -> exactly one SetFocus, and it is last.
for (old, new) in [(a, b), (None, a), (a, None)] {
let out = default_post_filter(false, &[], old, new);
assert_eq!(
out.last(),
Some(&SystemChange::SetFocus { new_focus: new, old_focus: old })
);
assert_eq!(
out.iter()
.filter(|c| matches!(c, SystemChange::SetFocus { .. }))
.count(),
1
);
}
}
#[test]
fn post_filter_handles_a_large_pre_change_list_without_blowing_up() {
// 5000 cursor ops -> 1 flush + 5000 scroll-into-views. Bounded, no overflow.
let pre: Vec<SystemChange> = (0..5000)
.map(|_| SystemChange::AddCursorAtClick { position: LogicalPosition::zero() })
.collect();
let out = default_post_filter(false, &pre, None, None);
assert_eq!(out.len(), 5001);
assert_eq!(out[0], SystemChange::ApplyPendingTextInput);
assert!(out[1..]
.iter()
.all(|c| *c == SystemChange::ScrollSelectionIntoView));
}
#[test]
fn default_post_filter_delegates_to_post_callback_filter_system_changes() {
let pre = vec![
SystemChange::TextSelectionDrag {
start_position: LogicalPosition::zero(),
current_position: LogicalPosition::new(2.0, 2.0),
},
SystemChange::SelectAllText,
];
for prevent in [false, true] {
for (old, new) in [(None, None), (Some(dnid(0, 1)), Some(dnid(0, 2)))] {
assert_eq!(
default_post_filter(prevent, &pre, old, new),
post_callback_filter_system_changes(prevent, &pre, old, new),
"the two entry points must stay in lock-step"
);
}
}
}
#[test]
fn default_post_filter_extern_decodes_the_none_focus_sentinel() {
// old_focus = the `None` sentinel, new_focus = a real node => a focus change.
let pre: Vec<SystemChange> = Vec::new();
let slice = SystemChangeVecSlice {
ptr: pre.as_ptr(),
len: pre.len(),
};
let out = default_post_filter_extern(
crate::refany::RefAny::new(0u8),
false,
slice,
dnid_none(0),
dnid(0, 4),
);
let changes = out.as_slice();
assert_eq!(changes.first(), Some(&SystemChange::ApplyPendingTextInput));
assert_eq!(
changes.last(),
Some(&SystemChange::SetFocus {
new_focus: Some(dnid(0, 4)),
old_focus: None,
}),
"a `NONE` node id must decode to `None`, not to node 0"
);
// An empty C-slice must be accepted (ptr may be dangling-but-aligned).
let out = default_post_filter_extern(
crate::refany::RefAny::new(0u8),
true,
SystemChangeVecSlice::empty(),
dnid_none(0),
dnid_none(0),
);
assert!(out.as_slice().is_empty());
}
}