rdom_core/event.rs
1//! `Event` — display-agnostic event type, honoring the DOM
2//! `stop_propagation` / `stop_immediate_propagation` / `prevent_default`
3//! flags. Concrete payloads (KeyEvent, MouseEvent, render context) belong
4//! in `rdom-tui`; this core type carries just the routing state.
5//!
6//! Spec: <https://dom.spec.whatwg.org/#events>
7
8use crate::event_detail::EventDetail;
9use crate::node_id::NodeId;
10
11/// Which phase of dispatch is currently running.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum EventPhase {
14 /// No dispatch in progress.
15 None,
16 /// Descending from root toward `target`. Capture-mode listeners fire
17 /// on ancestors.
18 Capturing,
19 /// At the target node. Both capture and bubble listeners fire.
20 AtTarget,
21 /// Ascending from `target` back to root. Non-capture listeners fire
22 /// on ancestors.
23 Bubbling,
24}
25
26/// Minimal event — just routing state. Attach payload via a typed
27/// wrapper in `rdom-tui` or the caller's crate.
28///
29/// Users typically build one with `Event::new("click")`, optionally
30/// call `with_bubbles(false)` / `with_cancelable(true)`, then pass to
31/// `Dom::dispatch_event(target, &mut event)`.
32///
33/// # Cloning
34///
35/// `clone()` is the web's `new Event(e.type, e)`: a fresh,
36/// undispatched event. It copies `event_type`, the init flags
37/// (`bubbles`, `cancelable`) and `detail`; everything a dispatch
38/// writes starts over — `target` and `current_target` are `None`,
39/// `phase` is [`EventPhase::None`], the stop-propagation and canceled
40/// flags are clear, the dispatch flag is unset and
41/// [`redraw_requested`](Event::redraw_requested) is `false`. A copy
42/// is script-made, so it is not [synthetic](Event::is_synthetic)
43/// (a scripted copy has `isTrusted` false on the web). A clone taken
44/// inside a listener can therefore be dispatched or queued; the
45/// in-flight original still cannot (DOM §2.9 step 1).
46///
47/// `Event` has no `timeStamp`, so there is no creation time to copy
48/// or restart; should one be added, a copy takes its own creation
49/// time, as `new Event()` does (DOM §2.2).
50///
51/// Read what a listener needs from the dispatch state (`target`,
52/// `phase`, `default_prevented()`) inside the listener: a clone does
53/// not carry it.
54#[derive(Debug)]
55#[non_exhaustive]
56pub struct Event {
57 /// Event type string — "click", "input", etc. Case-sensitive.
58 pub event_type: String,
59 /// Whether the event bubbles after the target. Default: true.
60 pub bubbles: bool,
61 /// Whether `prevent_default` has meaning for this event. Default: true.
62 pub cancelable: bool,
63 /// The node where dispatch was initiated. Set by `dispatch_event`;
64 /// callers don't need to fill this in.
65 pub target: Option<NodeId>,
66 /// The node currently being visited in dispatch. Updated per node
67 /// so handlers see it.
68 pub current_target: Option<NodeId>,
69 /// Current phase of dispatch.
70 pub phase: EventPhase,
71 /// Typed payload for event types that carry semantic data.
72 /// [`EventDetail::None`] for events that don't carry detail
73 /// (default on `Event::new`); [`EventDetail::String`] for
74 /// `CustomEvent`-style ad-hoc author payloads; typed variants
75 /// for events with structured payloads (transitions, inputs,
76 /// submits, toggles, mouse, keyboard). Listeners read via the
77 /// `as_*` accessors on [`EventDetail`].
78 pub detail: EventDetail,
79
80 /// `true` when the event was synthesized by the runtime (as
81 /// opposed to originating from user input or an explicit
82 /// `dispatch_event` call from application code). Higher layers
83 /// use this to suppress default actions on events they
84 /// themselves created — preventing recursion (e.g., a runtime
85 /// that dispatches synthetic `click` after `mouseup`, then
86 /// would recursively try to dispatch another `click` as that
87 /// event's default action).
88 ///
89 /// Spec-faithful analog to the browser's
90 /// `Event.isTrusted` flag, inverted: browsers set `isTrusted =
91 /// true` for user-originated events and `false` for scripted
92 /// ones; we set `is_synthetic = true` for runtime-originated
93 /// events, which is the flag that's actually useful to
94 /// dispatch logic. The difference is semantic, not
95 /// behavioral.
96 pub(crate) is_synthetic: bool,
97
98 pub(crate) propagation_stopped: bool,
99 pub(crate) immediate_propagation_stopped: bool,
100 pub(crate) default_prevented: bool,
101 /// DOM "dispatch flag": set for the duration of `dispatch_event`.
102 /// Re-dispatching an in-flight event is `InvalidStateError` on the
103 /// web; here it returns `DomError::InvalidState`. Cleared by a drop
104 /// guard on every way out of `dispatch_event`, a listener panic
105 /// unwinding through included (`P7G-DISPATCH-FLAG-1`).
106 pub(crate) dispatching: bool,
107
108 /// Set by [`EventCtx::request_redraw`](crate::EventCtx::request_redraw)
109 /// when a listener mutated state that the host should repaint —
110 /// state the DOM mutation tracker can't see (e.g. a `<canvas>` whose
111 /// paint reads external app state). Accumulates across every listener
112 /// in the dispatch. **`rdom-core` never acts on this** — it's an
113 /// inert intent flag the rendering host reads after dispatch (the
114 /// `rdom-tui` runtime ORs it into its repaint decision). Read via
115 /// [`Event::redraw_requested`].
116 pub(crate) redraw_requested: bool,
117}
118
119/// DOM §2.2 `new Event(e.type, e)` — see [`Event`]'s "Cloning".
120impl Clone for Event {
121 fn clone(&self) -> Self {
122 let mut copy = Event::new(self.event_type.clone())
123 .with_bubbles(self.bubbles)
124 .with_cancelable(self.cancelable);
125 copy.detail = self.detail.clone();
126 copy
127 }
128}
129
130impl Event {
131 pub fn new(event_type: impl Into<String>) -> Self {
132 Self {
133 event_type: event_type.into(),
134 bubbles: true,
135 cancelable: true,
136 target: None,
137 current_target: None,
138 phase: EventPhase::None,
139 detail: EventDetail::None,
140 is_synthetic: false,
141 propagation_stopped: false,
142 immediate_propagation_stopped: false,
143 default_prevented: false,
144 dispatching: false,
145 redraw_requested: false,
146 }
147 }
148
149 pub fn with_bubbles(mut self, bubbles: bool) -> Self {
150 self.bubbles = bubbles;
151 self
152 }
153
154 pub fn with_cancelable(mut self, cancelable: bool) -> Self {
155 self.cancelable = cancelable;
156 self
157 }
158
159 /// Builder-style `detail` setter for string payloads —
160 /// `Event::new("custom").with_detail("hello")`. Produces an
161 /// [`EventDetail::String`]; for typed variants set
162 /// `event.detail` directly to the relevant variant.
163 pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
164 self.detail = EventDetail::String(detail.into());
165 self
166 }
167
168 /// Mark this event as synthesized by the runtime. Default is
169 /// `false` (not synthesized). Use when composing higher-level
170 /// events from lower-level ones — e.g., the runtime creates a
171 /// synthetic `click` after matching `mousedown`+`mouseup`, so
172 /// handlers firing during `click` can distinguish it from a
173 /// handler-scripted `dispatch_event("click", ...)`.
174 pub fn with_synthetic(mut self, synthetic: bool) -> Self {
175 self.is_synthetic = synthetic;
176 self
177 }
178
179 /// `true` iff this event was synthesized by the runtime. See
180 /// [`Event::with_synthetic`].
181 pub fn is_synthetic(&self) -> bool {
182 self.is_synthetic
183 }
184
185 /// Stop bubbling/capturing on subsequent nodes. Listeners still
186 /// registered at the current node *and phase* continue to fire (see
187 /// `stop_immediate_propagation` for the harder stop); at the target,
188 /// calling this from a capture listener suppresses the target's
189 /// bubble listeners, which belong to the next pass. The flag is
190 /// cleared when the dispatch ends (DOM §2.9 step 5.9).
191 pub fn stop_propagation(&mut self) {
192 self.propagation_stopped = true;
193 }
194
195 /// Stop this event immediately: no further listeners run on this
196 /// node, no further propagation.
197 pub fn stop_immediate_propagation(&mut self) {
198 self.propagation_stopped = true;
199 self.immediate_propagation_stopped = true;
200 }
201
202 /// Signal "please skip the default action". Only meaningful if
203 /// `cancelable` is true. The Dom itself has no notion of "default
204 /// action"; higher layers check `default_prevented()` to decide.
205 pub fn prevent_default(&mut self) {
206 if self.cancelable {
207 self.default_prevented = true;
208 }
209 }
210
211 /// `true` if any listener called
212 /// [`EventCtx::request_redraw`](crate::EventCtx::request_redraw)
213 /// during this dispatch. The rendering host reads this after
214 /// `dispatch_event` returns to decide whether to repaint.
215 pub fn redraw_requested(&self) -> bool {
216 self.redraw_requested
217 }
218
219 pub fn is_propagation_stopped(&self) -> bool {
220 self.propagation_stopped
221 }
222
223 pub fn is_immediate_propagation_stopped(&self) -> bool {
224 self.immediate_propagation_stopped
225 }
226
227 pub fn default_prevented(&self) -> bool {
228 self.default_prevented
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn event_defaults() {
238 let e = Event::new("click");
239 assert_eq!(e.event_type, "click");
240 assert!(e.bubbles);
241 assert!(e.cancelable);
242 assert_eq!(e.phase, EventPhase::None);
243 assert!(!e.is_propagation_stopped());
244 assert!(!e.default_prevented());
245 }
246
247 #[test]
248 fn stop_propagation_sets_flag() {
249 let mut e = Event::new("click");
250 e.stop_propagation();
251 assert!(e.is_propagation_stopped());
252 assert!(!e.is_immediate_propagation_stopped());
253 }
254
255 #[test]
256 fn stop_immediate_sets_both_flags() {
257 let mut e = Event::new("click");
258 e.stop_immediate_propagation();
259 assert!(e.is_propagation_stopped());
260 assert!(e.is_immediate_propagation_stopped());
261 }
262
263 #[test]
264 fn prevent_default_only_when_cancelable() {
265 let mut e = Event::new("click");
266 e.prevent_default();
267 assert!(e.default_prevented());
268
269 let mut e2 = Event::new("click").with_cancelable(false);
270 e2.prevent_default();
271 assert!(!e2.default_prevented());
272 }
273
274 #[test]
275 fn synthetic_default_is_false() {
276 let e = Event::new("click");
277 assert!(!e.is_synthetic());
278 }
279
280 #[test]
281 fn with_synthetic_sets_flag() {
282 let e = Event::new("click").with_synthetic(true);
283 assert!(e.is_synthetic());
284
285 let e2 = Event::new("click").with_synthetic(false);
286 assert!(!e2.is_synthetic());
287 }
288
289 #[test]
290 fn synthetic_flag_independent_of_other_state() {
291 // Synthetic is orthogonal to bubbling/cancelable/propagation.
292 let mut e = Event::new("click")
293 .with_synthetic(true)
294 .with_bubbles(false)
295 .with_cancelable(false);
296 e.stop_propagation();
297 e.prevent_default();
298 assert!(e.is_synthetic());
299 assert!(!e.bubbles);
300 assert!(!e.cancelable);
301 assert!(e.is_propagation_stopped());
302 assert!(!e.default_prevented()); // cancelable=false blocks prevent_default
303 }
304
305 // ── Clone is `new Event(e.type, e)` (P7G-EVENT-CLONE-1) ──────────
306
307 use crate::dispatch::ListenerOptions;
308 use crate::{Dom, DomError, EventDetail};
309 use std::cell::RefCell;
310 use std::rc::Rc;
311
312 /// DOM §2.2 / §2.9: a copy is a fresh event — type, init flags and
313 /// detail carry over; the dispatch flag, target, current target,
314 /// phase and the stop / canceled flags do not.
315 #[test]
316 fn clone_taken_mid_dispatch_is_a_fresh_undispatched_event() {
317 let mut dom: Dom = Dom::new();
318 let root = dom.root();
319 let b = dom.create_element("b");
320 dom.append_child(root, b).unwrap();
321 let stash = Rc::new(RefCell::new(Vec::new()));
322 {
323 // Cancel and stop the event first, so the clone has every
324 // flag to (not) copy.
325 let stash = stash.clone();
326 dom.add_event_listener(b, "ping", ListenerOptions::default(), move |ctx| {
327 ctx.event.prevent_default();
328 ctx.event.stop_immediate_propagation();
329 ctx.request_redraw();
330 stash.borrow_mut().push(ctx.event.clone());
331 })
332 .unwrap();
333 }
334 let mut e = Event::new("ping")
335 .with_bubbles(false)
336 .with_cancelable(true)
337 .with_detail("payload");
338 dom.dispatch_event(b, &mut e).unwrap();
339 let copy = stash.borrow_mut().pop().expect("listener ran");
340 assert_eq!(copy.event_type, "ping");
341 assert!(!copy.bubbles);
342 assert!(copy.cancelable);
343 assert_eq!(copy.detail, EventDetail::String("payload".into()));
344 assert_eq!(copy.phase, EventPhase::None);
345 assert_eq!(copy.target, None);
346 assert_eq!(copy.current_target, None);
347 assert!(
348 !copy.default_prevented(),
349 "a copy of a canceled event is not canceled"
350 );
351 assert!(!copy.is_propagation_stopped());
352 assert!(!copy.is_immediate_propagation_stopped());
353 assert!(!copy.redraw_requested());
354 }
355
356 /// The copy dispatches normally, both after the original's dispatch
357 /// and nested inside it, while re-dispatching the original in
358 /// flight is still `InvalidStateError` (DOM §2.9 step 1).
359 #[test]
360 fn clone_dispatches_while_the_original_in_flight_still_cannot() {
361 let mut dom: Dom = Dom::new();
362 let root = dom.root();
363 let a = dom.create_element("a");
364 dom.append_child(root, a).unwrap();
365 let results = Rc::new(RefCell::new(Vec::new()));
366 let fired = Rc::new(RefCell::new(Vec::new()));
367 {
368 let fired = fired.clone();
369 dom.add_event_listener(a, "ping", ListenerOptions::default(), move |ctx| {
370 fired.borrow_mut().push(ctx.event.detail.clone());
371 })
372 .unwrap();
373 }
374 {
375 // A listener is skipped while it is running, so the nested
376 // dispatch reaches only the recorder above.
377 let results = results.clone();
378 dom.add_event_listener(a, "ping", ListenerOptions::default(), move |ctx| {
379 if ctx.event.detail == EventDetail::String("outer".into()) {
380 let original = ctx.dom.dispatch_event(a, ctx.event);
381 let mut copy = ctx.event.clone();
382 copy.detail = EventDetail::String("copy".into());
383 let nested = ctx.dom.dispatch_event(a, &mut copy);
384 results.borrow_mut().push((original, nested, copy));
385 }
386 })
387 .unwrap();
388 }
389 let mut e = Event::new("ping").with_detail("outer");
390 dom.dispatch_event(a, &mut e).unwrap();
391 let (original, nested, mut copy) = results.borrow_mut().pop().unwrap();
392 assert!(matches!(original, Err(DomError::InvalidState(_))));
393 assert_eq!(nested, Ok(()));
394 assert_eq!(
395 *fired.borrow(),
396 vec![
397 EventDetail::String("outer".into()),
398 EventDetail::String("copy".into())
399 ]
400 );
401 dom.dispatch_event(a, &mut copy).unwrap();
402 assert_eq!(
403 fired.borrow().len(),
404 3,
405 "the copy dispatches again afterwards"
406 );
407 }
408
409 /// A copy of a runtime-synthesized event is script-made: untrusted
410 /// on the web (`isTrusted` false), not synthetic here.
411 #[test]
412 fn clone_is_not_synthetic() {
413 let e = Event::new("click").with_synthetic(true);
414 assert!(!e.clone().is_synthetic());
415 }
416}