Skip to main content

lumen_script/
dom_events.rs

1//! Runtime event dispatch for the dynamic DOM API (phase 4).
2//!
3//! These systems turn the input pipeline's typed messages (clicks, pointer
4//! moves, wheel, keys, focus changes, text commits, scroll) into DOM events
5//! and route each one through the capture -> target -> bubble propagation
6//! driver in [`crate::event`], once, whatever the number of script hosts the
7//! app runs. A handler bound with `n.on(type, handler)` runs here, in the host
8//! that bound it or natively; commands it queues are forwarded onto the
9//! [`ScriptCommandEvent`] bus so the normal appliers pick them up.
10//!
11//! The full event set: `click`, `dblclick`, `pointerdown` / `pointerup` /
12//! `pointermove` / `pointerenter` / `pointerleave`, `wheel`, `keydown` /
13//! `keyup`, `input`, `change`, `focus`, `blur`, `submit`, `scroll`.
14//!
15//! Sources and current limitations:
16//! - Pointer events target the entity currently under the cursor (`Hovered`);
17//!   `pointerenter` / `pointerleave` come from the hover marker transitions.
18//! - `keydown` targets the focused entity (from the input router's
19//!   `FocusedKey`); `keyup` targets the focused entity. With nothing focused
20//!   both go to the document root, the way a browser sends them to the body,
21//!   so a listener on the document hears app-wide shortcuts.
22//! - `input` fires per edit, from the `TextEditApplied` signal the text
23//!   pipeline raises (or a page, whose fields the browser edits): one event
24//!   per keystroke, paste, or IME commit that changes the text, at most one
25//!   per entity per tick, carrying the field's text after the edit. A pure
26//!   caret move is not an edit and fires nothing.
27//! - `change` and `submit` fire on commit, from the input router's
28//!   `TextInputCommitted` signal; `submit` is the Enter-commit on a
29//!   single-line input.
30//! - `scroll` comes from a changed scroll offset and does not bubble.
31//!
32//! Default actions: only `click` (link navigation via `<a href>`) and
33//! `submit` (form submission) have one. `prevent_default` on a `click`
34//! records the target so the runtime's anchor-navigation executor skips it;
35//! `submit`'s default is reserved (there is no form-submission model yet).
36
37use bevy_ecs::component::Mutable;
38use bevy_ecs::message::{Message, MessageReader, MessageRegistry, Messages};
39use bevy_ecs::prelude::*;
40use glam::Vec2;
41use lumen_core::prelude::*;
42
43use crate::ScriptHost;
44use crate::event::{self, EventData};
45use crate::runtime::ScriptCommandEvent;
46
47/// Root-first ancestor chain (packed handles) of `entity`, excluding itself,
48/// read from the current DOM snapshot. Empty when `entity` is not (yet) in
49/// the snapshot.
50fn ancestors_root_first(entity: Entity) -> Vec<u64> {
51    let idx = lumen_core::node::dom_index_snapshot();
52    let mut chain: Vec<u64> = idx
53        .ancestors(entity)
54        .into_iter()
55        .map(|e| lumen_core::node::NodeHandle::new(e).pack())
56        .collect();
57    chain.reverse();
58    chain
59}
60
61/// Map a [`PointerButton`] to the web `MouseEvent.button` code
62/// (`0` primary / left, `1` middle, `2` secondary / right).
63fn button_code(button: PointerButton) -> i64 {
64    match button {
65        PointerButton::Primary => 0,
66        PointerButton::Middle => 1,
67        PointerButton::Secondary => 2,
68        PointerButton::Other(code) => code as i64,
69    }
70}
71
72/// W3C-ish key name for the event object's `key`.
73fn key_string(key: &Key) -> String {
74    match key {
75        Key::Character(s) => s.clone(),
76        Key::Named(n) => match n {
77            NamedKey::Tab => "Tab",
78            NamedKey::Enter => "Enter",
79            NamedKey::Escape => "Escape",
80            NamedKey::Backspace => "Backspace",
81            NamedKey::Space => "Space",
82            NamedKey::ArrowUp => "ArrowUp",
83            NamedKey::ArrowDown => "ArrowDown",
84            NamedKey::ArrowLeft => "ArrowLeft",
85            NamedKey::ArrowRight => "ArrowRight",
86            NamedKey::Home => "Home",
87            NamedKey::End => "End",
88            NamedKey::Delete => "Delete",
89        }
90        .to_string(),
91    }
92}
93
94/// The DOM events read from this tick's input, waiting for delivery.
95///
96/// Reading the input and delivering it are two steps because a handler can
97/// live in any of the app's hosts, and reaching a host needs the whole world:
98/// the readers are ordinary systems that run beside the rest of the tick, and
99/// the delivery is one exclusive pass that walks each event's propagation path
100/// once, whatever the number of hosts.
101#[derive(Resource, Default)]
102pub struct PendingDomEvents {
103    input: Vec<(EventData, Entity)>,
104    state: Vec<(EventData, Entity)>,
105}
106
107/// How the dispatch reaches one installed host: offer it a handler token, and
108/// forward the commands its handlers queued.
109#[derive(Clone, Copy)]
110pub struct EventHost {
111    dispatch: fn(&mut World, u64) -> bool,
112    drain: fn(&mut World),
113}
114
115/// The hosts DOM events are delivered into, one entry per installed host.
116/// Empty for an app with no script: native (C-ABI and SDK) handlers still run.
117#[derive(Resource, Default, Clone)]
118pub struct EventHosts(Vec<EventHost>);
119
120/// Put the host stored as the resource `H` on the list DOM events are
121/// delivered into.
122pub fn register_event_host<H: ScriptHost + Resource<Mutability = Mutable>>(world: &mut World) {
123    world
124        .get_resource_or_insert_with(EventHosts::default)
125        .0
126        .push(EventHost {
127            dispatch: dispatch_into::<H>,
128            drain: drain_into::<H>,
129        });
130}
131
132/// Offer `token` to host `H`. A host answers for the tokens it minted and
133/// passes on every other, so the first host that runs a handler is its owner.
134fn dispatch_into<H: ScriptHost + Resource<Mutability = Mutable>>(
135    world: &mut World,
136    token: u64,
137) -> bool {
138    world
139        .get_resource_mut::<H>()
140        .is_some_and(|mut host| !matches!(host.dispatch_event_handler(token), Ok(false)))
141}
142
143/// Forward the commands host `H`'s handlers queued onto the command bus.
144fn drain_into<H: ScriptHost + Resource<Mutability = Mutable>>(world: &mut World) {
145    let commands = match world.get_resource_mut::<H>() {
146        Some(mut host) => host.drain_commands(),
147        None => return,
148    };
149    for c in commands {
150        world.write_message(ScriptCommandEvent(c));
151    }
152}
153
154/// Deliver the pointer, click, wheel and key events read this tick.
155pub fn deliver_input_events(world: &mut World) {
156    // Fresh per-tick prevented-click set for the anchor-nav executor.
157    event::clear_prevented_clicks();
158    deliver_pending(world, |pending| &mut pending.input);
159}
160
161/// Deliver the focus, hover, edit, commit and scroll events read this tick.
162pub fn deliver_state_events(world: &mut World) {
163    deliver_pending(world, |pending| &mut pending.state);
164}
165
166fn deliver_pending(
167    world: &mut World,
168    queue: fn(&mut PendingDomEvents) -> &mut Vec<(EventData, Entity)>,
169) {
170    let Some(mut pending) = world.get_resource_mut::<PendingDomEvents>() else {
171        return;
172    };
173    let events = std::mem::take(queue(&mut pending));
174    if events.is_empty() {
175        return;
176    }
177    let hosts = world
178        .get_resource::<EventHosts>()
179        .cloned()
180        .unwrap_or_default();
181    for (data, target) in events {
182        deliver(world, &hosts, data, target);
183    }
184}
185
186/// Deliver one already-built [`EventData`] targeting `target_entity`:
187/// resolve the propagation path, run the driver once (native callbacks
188/// directly, host closures through whichever host owns the token), forward
189/// queued commands, and record a default-prevented click.
190fn deliver(world: &mut World, hosts: &EventHosts, data: EventData, target_entity: Entity) {
191    if !event::has_bindings_for(&data.event_type) {
192        return;
193    }
194    let ancestors = ancestors_root_first(target_entity);
195    let bubbles = event::event_bubbles(&data.event_type);
196    let etype = data.event_type.clone();
197    let target_handle = data.target;
198    let result = event::dispatch(data, &ancestors, bubbles, |token| {
199        for host in &hosts.0 {
200            if (host.dispatch)(world, token) {
201                break;
202            }
203        }
204    });
205    for host in &hosts.0 {
206        (host.drain)(world);
207    }
208    if etype == "click" && result.default_prevented {
209        event::mark_prevented_click(target_handle);
210    }
211}
212
213/// What a field holds, read by [`field_text`].
214pub(crate) type FieldText = (
215    Option<&'static lumen_core::text_model::TextBuffer>,
216    Option<&'static TextContent>,
217);
218
219/// The text a field holds after an edit: its live buffer where the text
220/// pipeline keeps one, otherwise its text, which is what a backend that edits
221/// the field itself (a page) writes.
222pub(crate) fn field_text(
223    (buffer, text): (
224        Option<&lumen_core::text_model::TextBuffer>,
225        Option<&TextContent>,
226    ),
227) -> Option<String> {
228    buffer
229        .map(ToString::to_string)
230        .or_else(|| text.map(|t| t.0.clone()))
231}
232
233/// Build the base [`EventData`] shell for `entity` and `event_type` with the
234/// packed target handle filled in.
235fn base(entity: Entity, event_type: &str) -> EventData {
236    EventData {
237        event_type: event_type.to_string(),
238        target: lumen_core::node::NodeHandle::new(entity).pack(),
239        ..Default::default()
240    }
241}
242
243/// Fill in where the pointer was: `position` in window coordinates, and
244/// relative to `entity`'s box. A backend that measured the box itself hands
245/// the relative position over as `local`; otherwise it comes from the box
246/// Lumen's layout gave the entity.
247fn with_position(
248    mut data: EventData,
249    transforms: &Query<&Transform>,
250    entity: Entity,
251    position: Vec2,
252    local: Option<Vec2>,
253) -> EventData {
254    let local = local.unwrap_or_else(|| {
255        let origin = transforms
256            .get(entity)
257            .map(|t| t.absolute)
258            .unwrap_or(Vec2::ZERO);
259        position - origin
260    });
261    data.local = (local.x as f64, local.y as f64);
262    data.client = (position.x as f64, position.y as f64);
263    data
264}
265
266fn set_mods(data: &mut EventData, mods: &Modifiers) {
267    data.shift = mods.shift;
268    data.ctrl = mods.ctrl;
269    data.alt = mods.alt;
270    data.super_ = mods.super_;
271}
272
273/// Register every input message the DOM event queues read, where the world
274/// does not have it yet.
275///
276/// A window's input layer registers these; a server render has no input
277/// layer, and its queue systems would fail parameter validation reading a
278/// message nobody registered. Registered, they stay empty.
279pub fn register_dom_event_messages(world: &mut World) {
280    fn register<M: Message>(world: &mut World) {
281        if !world.contains_resource::<Messages<M>>() {
282            MessageRegistry::register_message::<M>(world);
283        }
284    }
285    register::<ClickEvent>(world);
286    register::<DoubleClickEvent>(world);
287    register::<PointerPressed>(world);
288    register::<PointerReleased>(world);
289    register::<PointerMoved>(world);
290    register::<MouseWheel>(world);
291    register::<KeyPressed>(world);
292    register::<KeyReleased>(world);
293    register::<FocusedKey>(world);
294    register::<lumen_core::text_events::TextEditApplied>(world);
295    register::<TextInputCommitted>(world);
296}
297
298/// Read this tick's pointer, click, wheel and key input as DOM events, for
299/// [`deliver_input_events`]. Pointer events target the hovered entity; key
300/// events target the focused entity, or the document root when nothing is
301/// focused.
302#[allow(clippy::too_many_arguments)]
303pub fn queue_pointer_and_key_events(
304    mut pending: ResMut<PendingDomEvents>,
305    mut clicks: MessageReader<ClickEvent>,
306    mut doubles: MessageReader<DoubleClickEvent>,
307    mut presses: MessageReader<PointerPressed>,
308    mut releases: MessageReader<PointerReleased>,
309    mut moves: MessageReader<PointerMoved>,
310    mut wheels: MessageReader<MouseWheel>,
311    mut keyups: MessageReader<KeyReleased>,
312    mut keydowns: MessageReader<FocusedKey>,
313    mut key_presses: MessageReader<KeyPressed>,
314    focus: Option<Res<FocusTracker>>,
315    transforms: Query<&Transform>,
316    hovered: Query<Entity, With<Hovered>>,
317    focused: Query<Entity, With<Focused>>,
318) {
319    let queue = &mut pending.input;
320    let hovered_entity = hovered.iter().next();
321    let focused_entity = focused.iter().next();
322
323    // click (targets the clicked entity directly).
324    for c in clicks.read() {
325        let mut data = with_position(
326            base(c.entity, "click"),
327            &transforms,
328            c.entity,
329            c.position,
330            c.local,
331        );
332        data.button = button_code(c.button);
333        queue.push((data, c.entity));
334    }
335    // dblclick.
336    for d in doubles.read() {
337        let data = with_position(
338            base(d.entity, "dblclick"),
339            &transforms,
340            d.entity,
341            d.position,
342            None,
343        );
344        queue.push((data, d.entity));
345    }
346    // pointerdown / up / move / wheel target the hovered entity.
347    for p in presses.read() {
348        let Some(e) = hovered_entity else { continue };
349        let mut data = with_position(base(e, "pointerdown"), &transforms, e, p.position, p.local);
350        data.button = button_code(p.button);
351        queue.push((data, e));
352    }
353    for p in releases.read() {
354        let Some(e) = hovered_entity else { continue };
355        let mut data = with_position(base(e, "pointerup"), &transforms, e, p.position, p.local);
356        data.button = button_code(p.button);
357        queue.push((data, e));
358    }
359    for p in moves.read() {
360        let Some(e) = hovered_entity else { continue };
361        let data = with_position(base(e, "pointermove"), &transforms, e, p.position, p.local);
362        queue.push((data, e));
363    }
364    for w in wheels.read() {
365        let Some(e) = hovered_entity else { continue };
366        let mut data = with_position(base(e, "wheel"), &transforms, e, w.position, w.local);
367        data.delta = (w.delta.x as f64, w.delta.y as f64);
368        queue.push((data, e));
369    }
370    // keydown targets the entity the input router routed the key to.
371    for k in keydowns.read() {
372        let mut data = base(k.entity, "keydown");
373        data.key = key_string(&k.key);
374        set_mods(&mut data, &k.modifiers);
375        queue.push((data, k.entity));
376    }
377    // The router routes nothing while no element has focus (the same
378    // `FocusTracker` it reads), and Tab is the focus move, not a key for
379    // the page. Those keys go to the document root instead.
380    let nothing_focused = focus.is_none_or(|f| f.0.is_none());
381    let document = || lumen_core::node::dom_index_snapshot().document();
382    for k in key_presses.read() {
383        if !nothing_focused || matches!(k.key, Key::Named(NamedKey::Tab)) {
384            continue;
385        }
386        let Some(doc) = document() else { continue };
387        let mut data = base(doc, "keydown");
388        data.key = key_string(&k.key);
389        set_mods(&mut data, &k.modifiers);
390        queue.push((data, doc));
391    }
392    // keyup targets the focused entity, or the document root.
393    for k in keyups.read() {
394        let Some(e) = focused_entity.or_else(document) else {
395            continue;
396        };
397        let mut data = base(e, "keyup");
398        data.key = key_string(&k.key);
399        set_mods(&mut data, &k.modifiers);
400        queue.push((data, e));
401    }
402}
403
404/// Read this tick's focus, hover, edit, commit and scroll changes as DOM
405/// events, for [`deliver_state_events`].
406#[allow(clippy::too_many_arguments)]
407pub fn queue_state_events(
408    mut pending: ResMut<PendingDomEvents>,
409    mut edits: MessageReader<lumen_core::text_events::TextEditApplied>,
410    mut commits: MessageReader<TextInputCommitted>,
411    gained_focus: Query<Entity, Added<Focused>>,
412    mut lost_focus: RemovedComponents<Focused>,
413    gained_hover: Query<Entity, Added<Hovered>>,
414    mut lost_hover: RemovedComponents<Hovered>,
415    scrolled: Query<Entity, Changed<ScrollOffset>>,
416    fields: Query<FieldText>,
417) {
418    // A binding keys on a node's packed handle (entity + generation), so a
419    // despawned + recycled entity never matches a live binding; the
420    // has-bindings gate inside `deliver` also makes an unbound remove a
421    // no-op. No explicit liveness check is needed here.
422    let queue = &mut pending.state;
423
424    // focus / blur.
425    for e in gained_focus.iter() {
426        queue.push((base(e, "focus"), e));
427    }
428    for e in lost_focus.read() {
429        queue.push((base(e, "blur"), e));
430    }
431    // pointerenter / pointerleave.
432    for e in gained_hover.iter() {
433        queue.push((base(e, "pointerenter"), e));
434    }
435    for e in lost_hover.read() {
436        queue.push((base(e, "pointerleave"), e));
437    }
438    // input, once per edit that changed the text. An entity gets at most
439    // one `input` per tick: an IME commit both mutates the buffer and
440    // raises `TextInputCommitted`, and that is one edit to a handler, not
441    // two. The value is the live buffer, so a handler reads the text as it
442    // stands after the edit it was told about.
443    let mut fired: Vec<Entity> = Vec::new();
444    for ev in edits.read() {
445        if matches!(ev.kind, lumen_core::text_events::AppliedKind::CursorMove)
446            || fired.contains(&ev.entity)
447        {
448            continue;
449        }
450        let Some(value) = fields.get(ev.entity).ok().and_then(field_text) else {
451            continue;
452        };
453        fired.push(ev.entity);
454        let mut data = base(ev.entity, "input");
455        data.value = value;
456        queue.push((data, ev.entity));
457    }
458    // change / submit from the commit signal (Enter on a single-line
459    // input, or focus leaving a committed field).
460    for c in commits.read() {
461        for etype in ["change", "submit"] {
462            let mut data = base(c.entity, etype);
463            data.value = c.text.clone();
464            queue.push((data, c.entity));
465        }
466    }
467    // scroll (does not bubble).
468    for e in scrolled.iter() {
469        queue.push((base(e, "scroll"), e));
470    }
471}
472
473#[cfg(test)]
474pub(crate) mod text_event_tests {
475    use super::*;
476    use bevy_ecs::message::Messages;
477    use bevy_ecs::system::RunSystemOnce;
478    use lumen_core::text_events::{AppliedKind, TextEditApplied};
479    use lumen_core::text_model::TextBuffer;
480    use std::sync::{Arc, Mutex, MutexGuard};
481
482    /// The binding registry and the current-event cell are process-wide,
483    /// so every test that touches them takes the same turn-taking lock.
484    fn serial() -> MutexGuard<'static, ()> {
485        event::TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner())
486    }
487
488    /// A host stub with unimplemented / trivial bodies, for the tests that
489    /// need a type to name a host by. `pub(crate)` so `runtime`'s derivation
490    /// tests can build on it instead of writing a second one.
491    #[derive(Resource)]
492    pub(crate) struct NoHost;
493
494    impl ScriptHost for NoHost {
495        type Closure = ();
496        fn compile_check(&self, _source: &str, _uri: &str) -> Result<(), crate::ScriptError> {
497            unimplemented!("no host in these tests")
498        }
499        fn load(&mut self, _source: &str, _uri: &str) -> Result<(), crate::ScriptError> {
500            unimplemented!("no host in these tests")
501        }
502        fn replace(&mut self, _source: &str, _uri: &str) -> Result<(), crate::ScriptError> {
503            unimplemented!("no host in these tests")
504        }
505        fn reset(&mut self) {
506            unimplemented!("no host in these tests")
507        }
508        fn call(
509            &mut self,
510            _fn_name: &str,
511            _args: &[crate::ScriptValue],
512        ) -> Result<crate::CallOutcome, crate::CallFailure> {
513            unimplemented!("no host in these tests")
514        }
515        fn call_closure(
516            &mut self,
517            _closure: &Self::Closure,
518            _args: &[crate::ScriptValue],
519        ) -> Result<crate::ScriptValue, crate::ScriptError> {
520            unimplemented!("no host in these tests")
521        }
522        fn register_script_fn(&mut self, _f: &crate::ScriptFn) -> Result<(), crate::ScriptError> {
523            unimplemented!("no host in these tests")
524        }
525        fn lang(&self) -> &'static str {
526            "test"
527        }
528    }
529
530    /// Record `(event type, value)` for every event delivered to `node`.
531    fn watch(node: u64, types: &[&str]) -> Arc<Mutex<Vec<(String, String)>>> {
532        let seen: Arc<Mutex<Vec<(String, String)>>> = Arc::new(Mutex::new(Vec::new()));
533        for t in types {
534            let sink = Arc::clone(&seen);
535            event::register_native_binding(
536                node,
537                (*t).to_string(),
538                false,
539                Arc::new(move || {
540                    if let Ok(mut v) = sink.lock() {
541                        v.push((event::event_type(), event::event_value()));
542                    }
543                }),
544            );
545        }
546        seen
547    }
548
549    /// One tick. `run_system_once` builds fresh system state every call, so
550    /// its readers start at the front of the buffer; draining after each
551    /// run is what keeps a tick from re-reading the previous tick's
552    /// messages.
553    fn drive(world: &mut World) {
554        world.init_resource::<PendingDomEvents>();
555        world
556            .run_system_once(queue_state_events)
557            .expect("system ran");
558        deliver_state_events(world);
559        world.resource_mut::<Messages<TextEditApplied>>().clear();
560        world.resource_mut::<Messages<TextInputCommitted>>().clear();
561    }
562
563    #[test]
564    fn input_fires_per_edit_and_change_only_on_commit() {
565        let _guard = serial();
566        event::clear_all_bindings();
567        let mut world = World::new();
568        world.init_resource::<Messages<ScriptCommandEvent>>();
569        world.init_resource::<Messages<TextEditApplied>>();
570        world.init_resource::<Messages<TextInputCommitted>>();
571        let field = world.spawn(TextBuffer::single_line("ab")).id();
572        let node = lumen_core::node::NodeHandle::new(field).pack();
573        let seen = watch(node, &["input", "change", "submit"]);
574
575        // One edit this tick: `input` only, carrying the live buffer.
576        world
577            .resource_mut::<Messages<TextEditApplied>>()
578            .write(TextEditApplied {
579                entity: field,
580                version: 1,
581                kind: AppliedKind::Insert,
582                before_byte: 1,
583                after_byte: 2,
584            });
585        drive(&mut world);
586        assert_eq!(
587            *seen.lock().unwrap(),
588            vec![("input".to_string(), "ab".to_string())],
589        );
590
591        // A caret move is not an edit.
592        seen.lock().unwrap().clear();
593        world
594            .resource_mut::<Messages<TextEditApplied>>()
595            .write(TextEditApplied {
596                entity: field,
597                version: 2,
598                kind: AppliedKind::CursorMove,
599                before_byte: 2,
600                after_byte: 0,
601            });
602        drive(&mut world);
603        assert!(seen.lock().unwrap().is_empty(), "caret moves fire nothing");
604
605        // A commit fires change + submit, and no second `input`.
606        world
607            .resource_mut::<Messages<TextInputCommitted>>()
608            .write(TextInputCommitted {
609                entity: field,
610                text: "ab".to_string(),
611            });
612        drive(&mut world);
613        assert_eq!(
614            *seen.lock().unwrap(),
615            vec![
616                ("change".to_string(), "ab".to_string()),
617                ("submit".to_string(), "ab".to_string()),
618            ],
619        );
620        event::clear_all_bindings();
621    }
622
623    #[test]
624    fn repeated_edits_in_one_tick_fire_input_once() {
625        let _guard = serial();
626        event::clear_all_bindings();
627        let mut world = World::new();
628        world.init_resource::<Messages<ScriptCommandEvent>>();
629        world.init_resource::<Messages<TextEditApplied>>();
630        world.init_resource::<Messages<TextInputCommitted>>();
631        let field = world.spawn(TextBuffer::single_line("hi")).id();
632        let node = lumen_core::node::NodeHandle::new(field).pack();
633        let seen = watch(node, &["input"]);
634
635        // An IME commit mutates the buffer and raises the commit signal in
636        // the same tick; a handler sees one edit, not two.
637        for version in 1..=3 {
638            world
639                .resource_mut::<Messages<TextEditApplied>>()
640                .write(TextEditApplied {
641                    entity: field,
642                    version,
643                    kind: AppliedKind::Insert,
644                    before_byte: 0,
645                    after_byte: 1,
646                });
647        }
648        drive(&mut world);
649        assert_eq!(
650            seen.lock().unwrap().len(),
651            1,
652            "one `input` per entity per tick"
653        );
654        event::clear_all_bindings();
655    }
656
657    /// With nothing focused a key goes to the document root, as a browser
658    /// sends it to the body, so a listener on the document hears it. A
659    /// focused element still takes the key itself.
660    #[test]
661    fn keys_with_nothing_focused_reach_the_document() {
662        use bevy_ecs::system::RunSystemOnce;
663        use lumen_core::node::{DomIndex, DomRecord, publish_dom_index};
664
665        let _guard = serial();
666        event::clear_all_bindings();
667        let mut world = World::new();
668        world.init_resource::<Messages<ScriptCommandEvent>>();
669        register_dom_event_messages(&mut world);
670        world.init_resource::<FocusTracker>();
671        world.init_resource::<PendingDomEvents>();
672        let root = world.spawn_empty().id();
673        let button = world.spawn_empty().id();
674        let record = |entity: Entity, parent: Option<Entity>, children: Vec<Entity>| DomRecord {
675            entity,
676            generation: entity.generation().to_bits(),
677            tag: String::new(),
678            id: None,
679            classes: Vec::new(),
680            parent,
681            children,
682            child_index: 0,
683            sibling_count: 0,
684            doc_order: 0,
685        };
686        publish_dom_index(DomIndex::build(vec![
687            record(root, None, vec![button]),
688            record(button, Some(root), Vec::new()),
689        ]));
690        let doc = lumen_core::node::NodeHandle::new(root).pack();
691        let seen = Arc::new(Mutex::new(Vec::<String>::new()));
692        for t in ["keydown", "keyup"] {
693            let sink = Arc::clone(&seen);
694            event::register_native_binding(
695                doc,
696                t.to_string(),
697                false,
698                Arc::new(move || {
699                    let target = lumen_core::node::NodeHandle::unpack(event::event_target())
700                        .map(|h| h.entity);
701                    sink.lock()
702                        .unwrap()
703                        .push(format!("{} {:?}", event::event_type(), target));
704                }),
705            );
706        }
707        let press = |world: &mut World, key: Key| {
708            world.write_message(KeyPressed {
709                key: key.clone(),
710                modifiers: Modifiers::default(),
711                repeat: false,
712            });
713            world.write_message(KeyReleased {
714                key,
715                modifiers: Modifiers::default(),
716            });
717            world
718                .run_system_once(queue_pointer_and_key_events)
719                .expect("system ran");
720            deliver_input_events(world);
721            world.resource_mut::<Messages<KeyPressed>>().clear();
722            world.resource_mut::<Messages<KeyReleased>>().clear();
723        };
724
725        press(&mut world, Key::Named(NamedKey::Escape));
726        assert_eq!(
727            *seen.lock().unwrap(),
728            vec![
729                format!("keydown {:?}", Some(root)),
730                format!("keyup {:?}", Some(root)),
731            ],
732            "an unfocused key reaches the document"
733        );
734
735        // A focused element takes the key: the router's `FocusedKey` is the
736        // keydown, and the raw press is not delivered a second time.
737        seen.lock().unwrap().clear();
738        world.resource_mut::<FocusTracker>().0 = Some(button);
739        world.entity_mut(button).insert(Focused);
740        world.write_message(FocusedKey {
741            entity: button,
742            key: Key::Named(NamedKey::Escape),
743            modifiers: Modifiers::default(),
744            repeat: false,
745        });
746        press(&mut world, Key::Named(NamedKey::Escape));
747        assert_eq!(
748            *seen.lock().unwrap(),
749            vec![
750                format!("keydown {:?}", Some(button)),
751                format!("keyup {:?}", Some(button)),
752            ],
753            "a focused key bubbles up from the element"
754        );
755        event::clear_all_bindings();
756        publish_dom_index(DomIndex::default());
757    }
758
759    #[test]
760    fn edit_without_a_buffer_fires_nothing() {
761        let _guard = serial();
762        event::clear_all_bindings();
763        let mut world = World::new();
764        world.init_resource::<Messages<ScriptCommandEvent>>();
765        world.init_resource::<Messages<TextEditApplied>>();
766        world.init_resource::<Messages<TextInputCommitted>>();
767        let ghost = world.spawn_empty().id();
768        let node = lumen_core::node::NodeHandle::new(ghost).pack();
769        let seen = watch(node, &["input"]);
770
771        world
772            .resource_mut::<Messages<TextEditApplied>>()
773            .write(TextEditApplied {
774                entity: ghost,
775                version: 1,
776                kind: AppliedKind::Insert,
777                before_byte: 0,
778                after_byte: 1,
779            });
780        drive(&mut world);
781        assert!(seen.lock().unwrap().is_empty());
782        event::clear_all_bindings();
783    }
784}