htui 0.2.0

A terminal HTML/CSS renderer with an embedded JavaScript engine — build interactive TUIs from HTML, CSS and JS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
//! Script-thread bridge: message protocol between the input loop and the
//! dedicated script thread that owns the [`ScriptEngine`].
//!
//! The input loop never runs JS directly. DOM reads happen against the latest
//! arena snapshot it keeps (`ScriptResponse::Event.dirty`), while writes and
//! event dispatch travel as [`ScriptMsg`]s to the script thread, which owns
//! the engine *and* the hover state machine. A blocking host function then
//! stalls the script thread only — scrolling, quitting and mouse tracking on
//! the input loop stay responsive, mirroring how a browser keeps its UI
//! thread free of user command code.
//!
//! [`apply_message`] is the single executor shared by the script thread and
//! the synchronous `handle_*` / `trigger_refresh` wrappers used by tests, so
//! both paths are guaranteed to behave identically.

use std::sync::Arc;
use std::sync::Mutex;
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
use std::time::Duration;

use compact_str::CompactString;

use crate::events::{
    ElementRef, EventBindings, EventType, HitMap, HitRegion, HoverState, KeyEventInfo,
    SharedHitMap, interaction,
};
use crate::html::html_parse::ParsedDocument;
use crate::layout::Rect;
use crate::script::ScriptEngine;
use crate::script::{SharedUiState, set_hit_map, set_selection_text, set_ui_state};
use crate::source::SharedSourceText;
use crate::tdom::node::{NodeArena, NodeId, NodeType, SharedNodeArena};
use crate::tui::graph_api::{DirtyInfo, RenderCmd};

/// An action the input loop hands to the script thread.
///
/// Each variant bundles a *complete* browser interaction so the events it
/// dispatches keep their relative order (e.g. `blur` before `change`).
#[derive(Debug)]
pub enum ScriptMsg {
    /// Pointer move with no button held: hover enter / leave + `MouseOver` /
    /// `MouseOut` dispatch.
    Move { x: u32, y: u32 },
    /// Completed click: hover sync + `Click` dispatch.
    Click { x: u32, y: u32 },
    /// Mouse button pressed at `(x, y)` (`MouseDown` dispatch).
    MouseDown { x: u32, y: u32 },
    /// Held mouse button released at `(x, y)` (`MouseUp` dispatch).
    MouseUp { x: u32, y: u32 },
    /// Pointer moved to `(x, y)` with the button held (`MouseMove` dispatch).
    MouseMove { x: u32, y: u32 },
    /// Second click of a double click (`DblClick` dispatch on the click
    /// target).
    DblClick { x: u32, y: u32 },
    /// Right-button release without a drag (`ContextMenu` dispatch).
    ContextMenu { x: u32, y: u32 },
    /// A wheel tick at `(x, y)` (`wheel` dispatch). `gate_id` correlates the
    /// response back to the input loop's gate wait: a `preventDefault()`
    /// verdict cancels the built-in container / page scroll for this tick.
    /// Only sent when the page registered a `wheel` listener.
    Wheel {
        gate_id: u32,
        x: u32,
        y: u32,
        up: bool,
    },
    /// Focus an `<input>` field (fire `focus`).
    FocusInput { node_id: NodeId },
    /// Blur `node_id`, committing the edited `value` into the DOM and firing
    /// `blur` (+ `change` when the value differs from the committed one).
    BlurInput {
        node_id: NodeId,
        value: CompactString,
    },
    /// Editing keystroke on a focused `<input>` (fire `input`).
    InputKeystroke { node_id: NodeId },
    /// A range slider committed a snapped value: write the DOM `value`, then
    /// fire `input` (and `change` when the value actually changed).
    RangeInput {
        node_id: NodeId,
        value: CompactString,
    },
    /// A `<select>` option was picked: `set_value_dirty` + fire `change` when
    /// the value actually changed.
    SelectPick {
        node_id: NodeId,
        value: CompactString,
    },
    /// An `<input type=checkbox|radio>` was clicked: toggle its `checked`
    /// attribute (clearing same-`name` radio siblings), then fire `input` /
    /// `change`.
    ToggleCheck { node_id: NodeId },
    /// The focused text field's containing `<form>` was submitted via Enter
    /// (the browser's implicit submission): fire `submit` on the form. The
    /// engine performs no default navigation — pages handle it in JS.
    FormSubmit { form_id: NodeId },
    /// A key was pressed (`KeyDown`). `target` is the focused `<input>` node
    /// (the browser's key event target); `None` means the document `<body>`.
    /// `key_id` correlates the response back to the input loop's gate wait, so
    /// a JS `preventDefault()` can cancel the built-in shortcut for this key.
    KeyDown {
        key_id: u32,
        target: Option<NodeId>,
        key: KeyEventInfo,
    },
    /// A key was released (`KeyUp`); same target semantics as `KeyDown`.
    KeyUp {
        target: Option<NodeId>,
        key: KeyEventInfo,
    },
    /// The terminal viewport changed size. Carries no data — the script
    /// thread reads the live values from the shared [`SharedUiState`]; the
    /// message's job is dispatching the `resize` event and re-evaluating
    /// `matchMedia` lists.
    Resize,
    /// The page scroll offset changed (sent by the input loop after a
    /// wheel / key / animation / `scrollTo` frame). Dispatches the `scroll`
    /// event; handlers read `window.scrollY`.
    Scroll,
    /// A page or field selection changed. Dispatches the browser-shaped
    /// document-level `selectionchange` notification (no payload).
    SelectionChange,
    /// Replace the whole document owned by the script engine.
    ///
    /// Used by the debug hot-reload watcher. The engine re-parses the HTML,
    /// runs its scripts, and reports a fresh arena plus the new document CSS;
    /// the render thread never independently parses this source.
    ReloadHtml { html: SharedSourceText },
    /// Replace and re-run session-level JS while retaining the live DOM.
    ///
    /// The DOM arena is not replaced, so node ids and interaction state stay
    /// valid; any DOM writes performed by the source are reported as dirty.
    ReloadJs { js: SharedSourceText },
    /// Shut the script thread down.
    Quit,
}

/// A fresh DOM snapshot after JS mutated it, for the input loop's reads and
/// the render thread's `RenderArena`.
#[derive(Debug)]
pub struct ScriptDirty {
    /// Latest live arena (slotmap clone keeps the same node ids).
    pub arena: SharedNodeArena,
    pub root_id: NodeId,
    /// Nodes JS touched since the last reported batch.
    pub dirty: DirtyInfo,
    /// Present only when the engine replaced its whole document, carrying the
    /// new document-embedded CSS so the render thread can rebuild its rules.
    pub document_css: Option<SharedSourceText>,
}

/// Outcome of processing one [`ScriptMsg`] (or a due timer tick).
#[derive(Debug)]
pub struct ScriptEvent {
    /// Render commands pushed by host functions / hover repaints.
    pub cmds: Vec<RenderCmd>,
    /// Fresh DOM snapshot when JS mutated the arena (`None` = untouched).
    pub dirty: Option<ScriptDirty>,
    /// Current hover target after processing (feeds `RenderArena.hovered`).
    pub hovered: Option<NodeId>,
    /// Non-`None` only when the message was a gated `KeyDown`: the echoed
    /// `key_id` plus whether a JS listener called `preventDefault()` (the
    /// input loop uses the pair to decide whether the built-in shortcut runs).
    pub key_gate: Option<(u32, bool)>,
    /// Non-`None` only when the message was a gated [`ScriptMsg::Wheel`]:
    /// the echoed gate id plus the `preventDefault()` verdict (the input
    /// loop runs the built-in container / page scroll when it is `false`).
    pub wheel_gate: Option<(u32, bool)>,
}

/// One-shot or per-message reply from the script thread.
#[derive(Debug)]
pub enum ScriptResponse {
    /// Engine constructed; initial arena for the first frame.
    Ready {
        arena: SharedNodeArena,
        root_id: NodeId,
    },
    /// Engine construction failed (event handling is disabled afterwards).
    Failed(String),
    /// Result of processing one message / tick.
    Event(ScriptEvent),
}

/// The `<details>` a click on `node_id` should toggle, if any.
///
/// The browser's disclosure-widget default action: clicking a `<summary>`
/// (or one of its descendants) toggles the `open` attribute of the summary's
/// parent `<details>`. Anything else produces no default action.
fn summary_details(arena: &NodeArena, node_id: NodeId) -> Option<NodeId> {
    let mut current = Some(node_id);
    while let Some(id) = current {
        // `node_id` can be stale (a hit region referencing a node an
        // `innerHTML` sweep already freed) — treat it as "no target".
        let node = arena.get(id)?;
        if node.node_type == NodeType::Summary {
            return node
                .parent
                .filter(|p| arena[*p].node_type == NodeType::Details);
        }
        current = node.parent;
    }
    None
}

/// The `<dialog>` a click on `node_id` should close via the
/// `form[method=dialog]` default action, if any.
///
/// The browser's native dialog flow: a submit control (`<button type=submit>`
/// by default, or `<input type=submit>`) in a `<form method="dialog">` that
/// lives inside a `<dialog>` closes the dialog when clicked. `type=button` and
/// disabled controls are inert, exactly like the form submit rule in
/// [`button_form_action`].
fn dialog_form_action(arena: &NodeArena, node_id: NodeId) -> Option<NodeId> {
    let node = arena.get(node_id)?;
    if node.attributes.contains_key("disabled") {
        return None;
    }
    let is_submit = match node.node_type {
        NodeType::Button => !matches!(
            node.attributes
                .get("type")
                .map(|t| t.to_ascii_lowercase())
                .as_deref(),
            Some("button")
        ),
        NodeType::Input => matches!(
            node.attributes
                .get("type")
                .map(|t| t.to_ascii_lowercase())
                .as_deref(),
            Some("submit") | Some("image")
        ),
        _ => false,
    };
    if !is_submit {
        return None;
    }
    // Find the containing form; it must declare `method=dialog`.
    let mut current = node.parent;
    while let Some(pid) = current {
        if arena[pid].node_type == NodeType::Form {
            if !arena[pid]
                .attributes
                .get("method")
                .is_some_and(|m| m.eq_ignore_ascii_case("dialog"))
            {
                return None;
            }
            // The form itself must live inside a dialog.
            let mut up = arena[pid].parent;
            while let Some(uid) = up {
                if arena[uid].node_type == NodeType::Dialog {
                    return Some(uid);
                }
                up = arena[uid].parent;
            }
            return None;
        }
        current = arena[pid].parent;
    }
    None
}

/// The form event a submit / reset control click should produce:
/// `(form_id, EventType::Submit | EventType::Reset)`. `<button>` defaults to
/// `submit`; `<input type=submit|reset>` activates its form, while
/// `type=button` (both elements) and disabled controls produce nothing, and a
/// control outside any `<form>` has no form to fire on.
fn button_form_action(arena: &NodeArena, node_id: NodeId) -> Option<(NodeId, EventType)> {
    let node = arena.get(node_id)?;
    if node.attributes.contains_key("disabled") {
        return None;
    }
    let input_type = node.attributes.get("type").map(|t| t.to_ascii_lowercase());
    let action = match node.node_type {
        NodeType::Button => match input_type.as_deref() {
            Some("reset") => EventType::Reset,
            // `type=button` is inert: no submit, no reset.
            Some("button") => return None,
            _ => EventType::Submit,
        },
        // `<input type=submit|image|reset>` fire on the containing form; every
        // other input kind (text / checkbox / …) has no form default action.
        NodeType::Input => match input_type.as_deref() {
            Some("submit") | Some("image") => EventType::Submit,
            Some("reset") => EventType::Reset,
            _ => return None,
        },
        _ => return None,
    };
    let mut current = node.parent;
    while let Some(pid) = current {
        if arena[pid].node_type == NodeType::Form {
            return Some((pid, action));
        }
        current = arena[pid].parent;
    }
    None
}

/// Execute one [`ScriptMsg`] against an engine and the hover state machine.
///
/// Used by [`run_script_thread`] for production and by the synchronous
/// `handle_*` / `trigger_refresh` wrappers (tests / single-threaded callers),
/// so both paths share identical event semantics.
pub fn apply_message(
    engine: &mut ScriptEngine,
    hover: &mut HoverState,
    hit_map: &HitMap,
    msg: ScriptMsg,
) -> ScriptEvent {
    let (cmds, key_gate, wheel_gate) = match &msg {
        ScriptMsg::Move { x, y } => (
            interaction::on_move(hover, hit_map, engine, *x, *y),
            None,
            None,
        ),
        ScriptMsg::MouseDown { x, y } => {
            let cmds = interaction::on_mouse_down(hit_map, engine, *x, *y);
            (cmds, None, None)
        }
        ScriptMsg::MouseUp { x, y } => {
            let cmds = interaction::on_mouse_up(hit_map, engine, *x, *y);
            (cmds, None, None)
        }
        ScriptMsg::MouseMove { x, y } => {
            let cmds = interaction::on_mouse_move_dispatch(hit_map, engine, *x, *y);
            (cmds, None, None)
        }
        ScriptMsg::DblClick { x, y } => match hit_map.hit_test(*x, *y) {
            Some(region) => (engine.fire(region, EventType::DblClick, *x, *y), None, None),
            None => (Vec::new(), None, None),
        },
        ScriptMsg::ContextMenu { x, y } => match hit_map.hit_test(*x, *y) {
            Some(region) => (
                engine.fire(region, EventType::ContextMenu, *x, *y),
                None,
                None,
            ),
            None => (Vec::new(), None, None),
        },
        ScriptMsg::Wheel { gate_id, x, y, up } => {
            let (cmds, prevented) = engine.fire_wheel_gate(hit_map, *x, *y, *up);
            (cmds, None, Some((*gate_id, prevented)))
        }
        ScriptMsg::Click { x, y } => {
            let mut cmds = interaction::on_click(hover, hit_map, engine, *x, *y);
            // The hit test cannot change between the default actions below
            // (JS mutations rebuild the hit map only on the next layout), so
            // it runs once here.
            let clicked = hit_map.hit_test(*x, *y).map(|r| r.node_id);
            // `<summary>` default action: after the click handlers, clicking
            // a summary (or its content) toggles the owning `<details>`'s
            // `open` attribute and fires a `toggle` event on it — the
            // browser's disclosure widget.
            if let Some(details_id) =
                clicked.and_then(|node_id| summary_details(&engine.arena(), node_id))
            {
                engine.toggle_details(details_id);
                let region = region_for(&engine.arena(), details_id);
                cmds.extend(engine.fire(&region, EventType::Toggle, 0, 0));
            }
            // `<button type=submit|reset>` default action: after the click
            // handlers, fire the matching event on the containing `<form>`.
            // The engine performs no default navigation / control reset —
            // pages handle it in JS.
            if let Some((form_id, et)) =
                clicked.and_then(|node_id| button_form_action(&engine.arena(), node_id))
            {
                let region = region_for(&engine.arena(), form_id);
                cmds.extend(engine.fire(&region, et, 0, 0));
            }
            // `form[method=dialog]` default action: after the submit handlers,
            // a submit inside a dialog closes it (removes `open`) and fires a
            // `close` event on the dialog — the browser's native dialog flow.
            if let Some(dialog_id) =
                clicked.and_then(|node_id| dialog_form_action(&engine.arena(), node_id))
                && engine.close_dialog(dialog_id)
            {
                let region = region_for(&engine.arena(), dialog_id);
                cmds.extend(engine.fire(&region, EventType::Close, 0, 0));
            }
            (cmds, None, None)
        }
        ScriptMsg::FocusInput { node_id } => {
            let region = region_for(&engine.arena(), *node_id);
            (engine.fire(&region, EventType::Focus, 0, 0), None, None)
        }
        ScriptMsg::Resize => {
            let mut cmds = engine.fire_resize();
            engine.evaluate_media_changes();
            let mut st_cmds = engine.take_pending_cmds();
            cmds.append(&mut st_cmds);
            (cmds, None, None)
        }
        ScriptMsg::Scroll => (engine.fire_scroll(), None, None),
        ScriptMsg::SelectionChange => (engine.fire_selection_change(), None, None),
        ScriptMsg::InputKeystroke { node_id } => {
            let region = region_for(&engine.arena(), *node_id);
            (engine.fire(&region, EventType::Input, 0, 0), None, None)
        }
        ScriptMsg::RangeInput { node_id, value } => {
            let node_id = *node_id;
            let old = engine
                .arena()
                .get(node_id)
                .and_then(|n| n.attributes.get("value"))
                .cloned()
                .unwrap_or_default();
            engine.set_value_dirty(node_id, value);
            let region = region_for(&engine.arena(), node_id);
            let mut cmds = engine.fire(&region, EventType::Input, 0, 0);
            if old != *value {
                cmds.extend(engine.fire(&region, EventType::Change, 0, 0));
            }
            (cmds, None, None)
        }
        ScriptMsg::BlurInput { node_id, value } => {
            let arena = engine.arena();
            let is_textarea = arena
                .get(*node_id)
                .is_some_and(|n| n.node_type == NodeType::Textarea);
            // `<input>` fields commit to the `value` attribute; a textarea's
            // DOM value is its child text content.
            let old = if is_textarea {
                arena
                    .get(*node_id)
                    .map(|n| n.text_content(&arena))
                    .unwrap_or_default()
            } else {
                arena
                    .get(*node_id)
                    .and_then(|n| n.attributes.get("value"))
                    .cloned()
                    .unwrap_or_default()
            };
            if is_textarea {
                engine.set_textarea_value(*node_id, value);
            } else {
                engine.set_value(*node_id, value);
            }
            let region = region_for(&engine.arena(), *node_id);
            let mut cmds = engine.fire(&region, EventType::Blur, 0, 0);
            if old != *value {
                cmds.extend(engine.fire(&region, EventType::Change, 0, 0));
            }
            (cmds, None, None)
        }
        ScriptMsg::SelectPick { node_id, value } => {
            let arena = engine.arena();
            let old = arena
                .get(*node_id)
                .and_then(|n| n.attributes.get("value"))
                .cloned()
                .unwrap_or_default();
            if old != *value {
                engine.set_value_dirty(*node_id, value);
                let region = region_for(&engine.arena(), *node_id);
                (engine.fire(&region, EventType::Change, 0, 0), None, None)
            } else {
                (Vec::new(), None, None)
            }
        }
        ScriptMsg::ToggleCheck { node_id } => {
            // Checkbox toggles every click; a radio always checks (clicking an
            // already-selected radio is a no-op). Only actual state flips fire
            // `input` / `change`, matching browser default-action timing (the
            // `Click` dispatch ran first).
            let node_id = *node_id;
            let is_radio = engine.arena().get(node_id).is_some_and(|n| {
                n.get_type()
                    .is_some_and(|t| t.eq_ignore_ascii_case("radio"))
            });
            let should_check = if is_radio {
                true
            } else {
                !engine
                    .arena()
                    .get(node_id)
                    .is_some_and(|n| n.attributes.contains_key("checked"))
            };
            let changed = engine.set_checked(node_id, should_check);
            if changed {
                let region = region_for(&engine.arena(), node_id);
                let mut cmds = engine.fire(&region, EventType::Input, 0, 0);
                cmds.extend(engine.fire(&region, EventType::Change, 0, 0));
                (cmds, None, None)
            } else {
                (Vec::new(), None, None)
            }
        }
        ScriptMsg::FormSubmit { form_id } => {
            // Enter in a focused text field submits its containing form (the
            // browser's implicit submission). No default navigation — pages
            // handle the `submit` event in JS.
            let region = region_for(&engine.arena(), *form_id);
            (engine.fire(&region, EventType::Submit, 0, 0), None, None)
        }
        ScriptMsg::KeyDown {
            key_id,
            target,
            key,
        } => {
            let region = key_region(engine, *target);
            let (cmds, prevented) = engine.fire_key_gated(&region, EventType::KeyDown, key);
            (cmds, Some((*key_id, prevented)), None)
        }
        ScriptMsg::KeyUp { target, key } => {
            let region = key_region(engine, *target);
            (engine.fire_key(&region, EventType::KeyUp, key), None, None)
        }
        ScriptMsg::ReloadHtml { html } => {
            // Node ids belong to the replaced arena, so hover state cannot be
            // carried across a whole-document reload.
            *hover = HoverState::new();
            if let Err(e) = engine.reload(html) {
                log::error!("Hot reload failed; keeping the previous document: {e}");
            }
            (Vec::new(), None, None)
        }
        ScriptMsg::ReloadJs { js } => {
            if let Err(e) = engine.reload_js(js) {
                log::error!("JS hot reload failed: {e}");
            }
            (Vec::new(), None, None)
        }
        ScriptMsg::Quit => (Vec::new(), None, None),
    };

    let document_reload = engine.take_document_reload();
    if document_reload {
        // A whole-document reload can happen in the middle of an event; clear
        // the same hover state that an explicit `ReloadHtml` clears.
        *hover = HoverState::new();
    }
    let dirty = {
        let dirty = engine.take_dirty();
        if dirty.is_empty() {
            None
        } else {
            Some(ScriptDirty {
                arena: Arc::new(engine.arena()),
                root_id: engine.root_id(),
                dirty,
                document_css: document_reload.then(|| engine.document_css()),
            })
        }
    };
    let hovered = hover.current().map(|r| r.node_id);
    ScriptEvent {
        cmds,
        dirty,
        hovered,
        key_gate,
        wheel_gate,
    }
}

/// Run the page's JS engine with required initial session-level JS.
///
/// Build the engine, run top-level scripts, then handle `ScriptMsg` requests,
/// timers, and fetches until `Quit` or channel disconnect. `ui` is the
/// input loop's shared UI state (viewport / scroll offset / focused field)
/// that the `window.innerWidth` & co getters read; `hit_map` is the live
/// hit-map channel the geometry-read natives consult; `selection_text` is
/// the render thread's selection text `window.getSelection()` reads.
pub fn run_script_thread(
    document: ParsedDocument,
    bindings: EventBindings,
    session_js: SharedSourceText,
    rx: Receiver<ScriptMsg>,
    tx: Sender<ScriptResponse>,
    hit_map: SharedHitMap,
    ui: Arc<SharedUiState>,
    selection_text: Arc<Mutex<String>>,
) {
    set_ui_state(ui);
    set_hit_map(Arc::clone(&hit_map));
    set_selection_text(selection_text);
    let mut engine = match ScriptEngine::new_from_document(document, &session_js, bindings) {
        Ok(engine) => engine,
        Err(err) => {
            log::error!("Failed to initialize script engine: {err}");
            if tx.send(ScriptResponse::Failed(err.to_string())).is_err() {
                log::debug!("Script response channel closed during init: {err}");
            }
            return;
        }
    };
    let mut hover = HoverState::new();

    if tx
        .send(ScriptResponse::Ready {
            arena: Arc::new(engine.arena()),
            root_id: engine.root_id(),
        })
        .is_err()
    {
        log::debug!("Script response channel closed before first frame");
        return;
    }

    // Browser load sequence: every document-level script has run, so the DOM
    // is ready — fire `DOMContentLoaded` then `load`. Handlers' DOM writes
    // and render commands ride a follow-up Event batch (silent when no
    // listener produced anything).
    let boot = engine.fire_document_loaded();
    if (!boot.cmds.is_empty() || boot.dirty.is_some())
        && tx.send(ScriptResponse::Event(boot)).is_err()
    {
        log::debug!("Script response channel closed after load events");
        return;
    }

    loop {
        let wait = match engine.next_timer_timeout() {
            Some(deadline) => Some(deadline),
            None if engine.has_pending_fetch() => Some(Duration::ZERO),
            None => None,
        };
        let msg = match wait {
            Some(wait) => match rx.recv_timeout(wait) {
                Ok(msg) => Some(msg),
                Err(RecvTimeoutError::Timeout) => None,
                Err(RecvTimeoutError::Disconnected) => break,
            },
            None => match rx.recv() {
                Ok(msg) => Some(msg),
                Err(_) => break,
            },
        };

        // Whether this iteration woke on a real message (vs a timer / fetch
        // wake). Real messages always get a reply — the input loop's key gate
        // correlates its verdict on them.
        let has_msg = msg.is_some();
        let mut event = match msg {
            Some(ScriptMsg::Quit) => break,
            Some(msg) => {
                let map = match hit_map.lock() {
                    Ok(guard) => Arc::clone(&guard),
                    Err(e) => {
                        log::error!("Hit map lock poisoned: {}", e);
                        Arc::new(HitMap::new())
                    }
                };
                apply_message(&mut engine, &mut hover, &map, msg)
            }
            // No message arrived in time — a timer must be due.
            None => ScriptEvent {
                cmds: Vec::new(),
                dirty: None,
                hovered: hover.current().map(|r| r.node_id),
                key_gate: None,
                wheel_gate: None,
            },
        };

        // Fire timers that came due while waiting / processing the message.
        // Their callbacks may rewrite the DOM, so the dirty snapshot must
        // merge whatever they produced after the message's own batch.
        event.cmds.extend(engine.run_due_timers());

        // Settle completed `fetch` loads and run the microtask queue. This
        // must happen after timers so a timer callback that starts a fetch and
        // the fetch's `.then` chain both drain in the same tick.
        engine.poll_fetches();
        // Flush render commands queued outside a dispatch (e.g. `el.focus()`
        // from a top-level `<script>`): `dispatch` only drains the queue for
        // its own calls, so without this a load-time command would wait for
        // the first user event.
        event.cmds.extend(engine.take_pending_cmds());
        let extra_reload = engine.take_document_reload();
        let extra = engine.take_dirty();
        if !extra.is_empty() {
            let snapshot = ScriptDirty {
                arena: Arc::new(engine.arena()),
                root_id: engine.root_id(),
                dirty: extra,
                document_css: extra_reload.then(|| engine.document_css()),
            };
            event.dirty = match event.dirty {
                Some(mut d) => {
                    d.dirty.nodes.extend(snapshot.dirty.nodes);
                    // Later writes win per host (IndexMap keeps the first
                    // insertion position, matching document order).
                    d.dirty.injected_css.extend(snapshot.dirty.injected_css);
                    d.document_css = d.document_css.or(snapshot.document_css);
                    d.arena = snapshot.arena;
                    d.root_id = snapshot.root_id;
                    Some(d)
                }
                None => Some(snapshot),
            };
        }

        // A timeout wake that produced nothing (a timer fired without visible
        // effect, or a fetch settled nothing) stays silent: the empty event
        // carries no information for the input loop, and sending it would
        // wake that loop for nothing.
        if !has_msg
            && event.cmds.is_empty()
            && event.dirty.is_none()
            && event.key_gate.is_none()
            && event.wheel_gate.is_none()
        {
            continue;
        }
        if tx.send(ScriptResponse::Event(event)).is_err() {
            log::debug!("Script response channel closed");
            break;
        }
    }
    log::debug!("Script thread exiting");
}

/// Build the hit region a keyboard event targets: the focused `<input>` when
/// it still exists in the live arena (JS may have removed it since the input
/// loop's snapshot), otherwise the document `<body>` — the browser's fallback
/// for key events with no focused element, so a `<body onkeydown>` handler
/// catches every key the page does not intercept.
pub(crate) fn key_region(engine: &ScriptEngine, target: Option<NodeId>) -> HitRegion {
    let arena = engine.arena();
    let id = match target {
        Some(id) if arena.contains_key(id) => id,
        _ => arena
            .iter()
            .find(|(_, n)| n.node_type == NodeType::Body)
            .map(|(id, _)| id)
            .unwrap_or_else(|| engine.root_id()),
    };
    region_for(&arena, id)
}

/// Build a minimal hit region for `node_id` (no geometry) so the script
/// thread can fire focus / blur / input / change events along the DOM
/// ancestor chain, exactly like a pointer-driven region from the hit map.
pub(crate) fn region_for(arena: &NodeArena, node_id: NodeId) -> HitRegion {
    let mut path = Vec::new();
    let mut current = Some(node_id);
    while let Some(id) = current {
        // A stale id (the node was freed by an `innerHTML` sweep between the
        // hit test and this call) yields a truncated/empty path, which fires
        // no listeners — the same outcome as a removed browser node.
        let Some(node) = arena.get(id) else {
            break;
        };
        path.push(ElementRef {
            id: node.get_id().cloned(),
            node_id: id,
        });
        current = node.parent;
    }
    HitRegion {
        node_id,
        rect: Rect::new(0, 0, 0, 0),
        client: Rect::new(0, 0, 0, 0),
        path: path.into(),
    }
}