1
//! Headless driver for E2E JSON scenarios.
2
//!
3
//! [`run_e2e_test`] runs an [`E2eTest`] end-to-end through the REAL server
4
//! op-dispatch (`super::full::process_debug_event` + the scenario runner
5
//! `resume_e2e_continuation`, pumped via `super::full::e2e_pump_continuation`) —
6
//! no HTTP, no timer, no DLL. It emulates the slice of the platform event loop
7
//! the E2E path needs, and it emulates it by PORTING the DLL, not by
8
//! approximating it:
9
//!
10
//! * [`Runner::apply_user_change`] is a port of `PlatformWindow::apply_user_change`
11
//!   (`dll/src/desktop/shell2/common/event.rs`) for the `CallbackChange`
12
//!   variants the E2E op set can produce — DOM mutation included.
13
//! * [`Runner::regenerate_layout`] / [`Runner::relayout_only`] port
14
//!   `regenerate_layout` / `incremental_relayout`
15
//!   (`dll/src/desktop/shell2/common/layout.rs`) plus the render + damage tail
16
//!   of the headless backend (`dll/src/desktop/shell2/headless/mod.rs`).
17
//! * [`super::cpu_backend::CpuBackend`] is the port of that backend's CPU
18
//!   renderer, which is what fills `LayoutWindow::frame_report` — without it
19
//!   every damage assertion reports "nothing was repainted (stale screen)".
20
//! * The font pipeline mirrors `AppInternal::create` + the font-snapshot block
21
//!   at the top of `regenerate_layout`, because font RESOLUTION differs between
22
//!   "one `FcFontCache::build()` for the whole process" and "an async registry
23
//!   snapshot re-installed on every DOM regeneration" — and the corpus contains
24
//!   a scenario (`mock_font_exact_metrics`) whose verdict depends on exactly
25
//!   that difference.
26
//!
27
//! The window/callback scaffolding mirrors the pattern in
28
//! `tests/contenteditable_e2e.rs` (LayoutWindow + `RendererResources::default()`
29
//! + `ExternalSystemCallbacks::rust_internal()` + `FullWindowState`).
30

            
31
use crate::solver3::layout_tree::LayoutNodeId;
32
use std::collections::BTreeMap;
33
use std::sync::{Arc, Mutex};
34
use std::time::Instant;
35

            
36
use azul_core::{
37
    dom::{DomId, DomNodeId, NodeId},
38
    events::ProcessEventResult,
39
    gl::OptionGlContextPtr,
40
    geom::{LogicalPosition, LogicalRect, LogicalSize},
41
    hit_test::ScrollPosition,
42
    refany::{OptionRefAny, RefAny},
43
    resources::RendererResources,
44
    styled_dom::{NodeHierarchyItemId, StyledDom},
45
    window::{MonitorVec, RawWindowHandle},
46
    xml::ComponentMap,
47
};
48
use azul_css::system::SystemStyle;
49
use rust_fontconfig::FcFontCache;
50

            
51
use azul_layout::{
52
    callbacks::{CallbackChange, CallbackInfo, CallbackInfoRefData, ExternalSystemCallbacks},
53
    window::{LayoutWindow, MAX_EVENT_RECURSION_DEPTH},
54
    window_state::FullWindowState,
55
};
56

            
57
use super::cpu_backend::CpuBackend;
58
use super::full::{
59
    process_debug_event, e2e_pump_continuation, DebugEvent, DebugRequest, DebugResponseData,
60
    E2eSession, E2eStepResult, E2eTest, E2eTestResult, ResponseData,
61
};
62

            
63
// ── Headless window scaffolding ──────────────────────────────────────────────
64

            
65
struct Runner {
66
    layout_window: LayoutWindow,
67
    renderer_resources: RendererResources,
68
    system_callbacks: ExternalSystemCallbacks,
69
    window_state: FullWindowState,
70
    /// The sync baseline the state-diff pass reads, i.e.
71
    /// `CommonWindowState::previous_window_state`. `determine_all_events`
72
    /// derives EVERY synthetic event (MouseDown/Up, KeyDown/Up, WindowFocusIn/
73
    /// Out, WindowMove/Resize) from `current` vs `previous`, so without this
74
    /// field the diff is always empty and no pointer or key event exists at
75
    /// all. Advanced by `ModifyWindowState` / `QueueWindowStateSequence`
76
    /// exactly where the DLL calls `set_previous_window_state`.
77
    previous_window_state: Option<FullWindowState>,
78
    /// Pointer→node resolution, i.e. `CommonWindowState::cpu_hit_tester`.
79
    ///
80
    /// The runner has no WebRender, so this is the same `CpuHitTester` the
81
    /// headless / CPU-mode desktop backends use, rebuilt from the layout
82
    /// results (see [`Runner::rebuild_hit_tester`]).
83
    cpu_hit_tester: azul_layout::headless::CpuHitTester,
84
    /// CPU renderer + retained damage state (port of the headless backend).
85
    cpu_backend: CpuBackend,
86
    /// The app-level font cache, i.e. `AppInternal::fc_cache`. Re-installed on
87
    /// the layout window's font manager at the top of every `regenerate_layout`,
88
    /// exactly like the DLL does.
89
    app_fc_cache: FcFontCache,
90
    /// The async font registry, i.e. `AppInternal::font_registry`.
91
    #[cfg(feature = "font_async_registry")]
92
    font_registry: Option<Arc<azul_layout::FcFontRegistry>>,
93
    /// Set by a `ModifyWindowState` whose size changed. Consumed by
94
    /// [`Runner::service`], which then runs the SAME resize decision the
95
    /// shells run (`LayoutWindow::resize_needs_full_regeneration`): full DOM
96
    /// regeneration only when a recorded window-size query answer flips or a
97
    /// CSS breakpoint / orientation is crossed; otherwise a relayout of the
98
    /// existing `StyledDom` at the new size. This is what lets the corpus
99
    /// assert `dom_regenerations == 0` across a plain resize.
100
    resize_pending: bool,
101
    /// Set by a `ModifyWindowState` whose DPI changed. Always a full
102
    /// regeneration: a scale change invalidates every cached rasterisation
103
    /// and every shaped run measured at the old scale.
104
    dpi_pending: bool,
105
    /// `CallbackChange`s this host could not apply faithfully (see
106
    /// [`Runner::unsupported`]). Non-empty ⇒ the scenario FAILS: it asked the
107
    /// engine to do something the headless runner cannot do, so whatever it
108
    /// asserted afterwards was asserted against a window where that something
109
    /// never happened.
110
    unsupported_changes: Vec<String>,
111
    /// The redraw a rendered frame asked for, i.e. the platform loop's
112
    /// `request_redraw()`.
113
    ///
114
    /// PORT of the tail of every DLL present path (x11 `mod.rs:4031`, wayland
115
    /// `mod.rs:4761`, windows `mod.rs:1062`/`1297`, macos `mod.rs:6334`):
116
    ///
117
    /// ```ignore
118
    /// // If any scrollbar is actively fading (0 < opacity < 1), schedule
119
    /// // another frame so the fade-out animation runs to completion.
120
    /// if lw.gpu_state_manager.scrollbar_fade_active { self.request_redraw(); }
121
    /// ```
122
    ///
123
    /// This host had no such re-arm, so the frame driven by `tick_ms` /
124
    /// `wait_frame` was the LAST one: a `wait` yields with a resume deadline,
125
    /// the pump sleeps, and `service()` then finds no pending change and
126
    /// renders nothing. Every state that settles on ELAPSED TIME — and the
127
    /// scrollbar fade, at `fade_delay` 500 ms + `fade_duration` 200 ms, is the
128
    /// one the corpus exercises — stayed frozen at whatever the last explicit
129
    /// frame left behind, so `scrollbar_fade_active` was still true when the
130
    /// scenario asked whether the window had settled.
131
    pending_redraw: bool,
132
}
133

            
134
impl Runner {
135
57
    fn new(width: f32, height: f32, dpi: u32, animations: bool) -> Self {
136
57
        let mut ws = FullWindowState::default();
137
57
        ws.size.dimensions = LogicalSize::new(width, height);
138
57
        ws.size.dpi = dpi;
139

            
140
        // Port of `AppInternal::create`'s font setup: the app starts with an
141
        // async registry and an EMPTY `FcFontCache` (or a disk-cache snapshot);
142
        // the cache is populated from the registry at the first layout. This is
143
        // NOT the same as handing the window one eagerly-built `FcFontCache`:
144
        // the registry snapshot replaces the window's cache handle, which is
145
        // what makes in-memory (`register_named_font`) families behave the way
146
        // they do in a real app.
147
        #[cfg(feature = "font_async_registry")]
148
57
        let (app_fc_cache, font_registry) = {
149
            // `FcFontRegistry::new()` already returns an `Arc<Self>`.
150
57
            let registry = azul_layout::FcFontRegistry::new();
151
57
            let had_cache = registry.load_from_disk_cache();
152
57
            registry.spawn_scout_and_builders();
153
            // DETERMINISM: block until the scout has published the font set
154
            // (no-op when a disk cache was loaded; 5 s cap inside).
155
            //
156
            // Without this the fonts a DOM resolves depend on HOW FAR the
157
            // background builders happened to get before that particular
158
            // layout ran — so the same scenario resolves a 1-font fallback
159
            // chain when a step services a mount immediately and a 7-font one
160
            // when a `wait` delays it, and any assertion over font resources
161
            // silently measures thread scheduling. A verdict that moves with
162
            // background-thread progress is exactly the flake class this suite
163
            // exists to eliminate.
164
57
            registry.wait_for_scout();
165
57
            let cache = if had_cache.is_some() {
166
                registry.shared_cache()
167
            } else {
168
57
                FcFontCache::default()
169
            };
170
57
            (cache, Some(registry))
171
        };
172
        #[cfg(not(feature = "font_async_registry"))]
173
        let app_fc_cache = FcFontCache::build();
174

            
175
        Self {
176
            layout_window: {
177
57
                let mut lw =
178
57
                    LayoutWindow::new(app_fc_cache.clone()).expect("LayoutWindow::new");
179
                // Tweens OFF unless the scenario asked for them (`setup.animations`).
180
                // The default stays off so a scenario that never drives the clock
181
                // cannot screenshot geometry mid-glide — but "off, always, with no
182
                // flag" is what left the animated caret and the selection tween
183
                // with ZERO e2e coverage. Turning them on is deterministic here:
184
                // `run_e2e_test` freezes this thread's clock and only `tick_ms` /
185
                // `wait` advance it, so a tween's progress is a pure function of
186
                // the ops the scenario ran.
187
57
                lw.system_animations_override = Some(if animations {
188
                    azul_core::resources::SystemAnimations::default()
189
                } else {
190
57
                    azul_core::resources::SystemAnimations::disabled()
191
                });
192
57
                lw
193
            },
194
57
            renderer_resources: RendererResources::default(),
195
57
            system_callbacks: ExternalSystemCallbacks::rust_internal(),
196
57
            window_state: ws,
197
57
            previous_window_state: None,
198
57
            cpu_hit_tester: azul_layout::headless::CpuHitTester::new(),
199
57
            cpu_backend: CpuBackend::new(),
200
57
            app_fc_cache,
201
            #[cfg(feature = "font_async_registry")]
202
57
            font_registry,
203
            resize_pending: false,
204
            dpi_pending: false,
205
57
            unsupported_changes: Vec::new(),
206
            pending_redraw: false,
207
        }
208
57
    }
209

            
210
411
    fn now(&self) -> azul_core::task::Instant {
211
411
        (self.system_callbacks.get_system_time_fn.cb)()
212
411
    }
213

            
214
    /// Build a `CallbackInfo` over the current window/state and run `f` with it.
215
    /// `ref_data` and the transient locals it borrows are dropped when `f`
216
    /// returns, releasing the borrow so the caller can relayout.
217
533
    fn with_callback_info<R>(
218
533
        &mut self,
219
533
        changes: &Arc<Mutex<Vec<CallbackChange>>>,
220
533
        f: impl FnOnce(&mut CallbackInfo) -> R,
221
533
    ) -> R {
222
533
        let previous_window_state: Option<FullWindowState> = None;
223
533
        let gl_context = OptionGlContextPtr::None;
224
533
        let scroll_states: BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> =
225
533
            BTreeMap::new();
226
533
        let window_handle = RawWindowHandle::Unsupported;
227

            
228
533
        let ref_data = CallbackInfoRefData {
229
533
            layout_window: &self.layout_window,
230
533
            renderer_resources: &self.renderer_resources,
231
533
            previous_window_state: &previous_window_state,
232
533
            current_window_state: &self.window_state,
233
533
            gl_context: &gl_context,
234
533
            current_scroll_manager: &scroll_states,
235
533
            current_window_handle: &window_handle,
236
533
            system_callbacks: &self.system_callbacks,
237
533
            system_style: Arc::new(SystemStyle::default()),
238
533
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
239
533
            #[cfg(feature = "icu")]
240
533
            icu_localizer: crate::icu::IcuLocalizerHandle::default(),
241
533
            ctx: OptionRefAny::None,
242
533
        };
243

            
244
533
        let mut callback_info = CallbackInfo::new(
245
533
            &ref_data,
246
533
            changes,
247
533
            DomNodeId { dom: DomId::ROOT_ID, node: NodeHierarchyItemId::NONE },
248
533
            azul_core::geom::OptionLogicalPosition::None,
249
533
            azul_core::geom::OptionLogicalPosition::None,
250
        );
251
533
        f(&mut callback_info)
252
533
    }
253

            
254
    /// Run the full layout pipeline for `styled_dom` and re-register scroll nodes.
255
120
    fn layout(&mut self, styled_dom: StyledDom) {
256
120
        let mut dbg = Some(Vec::new());
257
120
        self.layout_window
258
120
            .layout_and_generate_display_list(
259
120
                styled_dom,
260
120
                &self.window_state,
261
120
                &self.renderer_resources,
262
120
                &self.system_callbacks,
263
120
                &mut dbg,
264
            )
265
120
            .expect("layout_and_generate_display_list");
266
        // Same CRITICAL sync as the DLL paths (regenerate_layout's tail /
267
        // incremental_relayout): the cached state is the "old size" the resize
268
        // decision diffs against. Without it, every resize after the first
269
        // compared against the size of the last FULL layout — conservative
270
        // (extra full regenerations), but wrong.
271
120
        self.layout_window.current_window_state = self.window_state.clone();
272
120
        self.register_scroll_nodes();
273
120
        self.rebuild_hit_tester();
274
120
    }
275

            
276
    /// Rebuild [`Runner::cpu_hit_tester`] from the current layout results.
277
    ///
278
    /// Port of the headless backend's post-`regenerate_layout` rebuild
279
    /// (`dll/.../shell2/headless/mod.rs`), which carries this comment: *without
280
    /// this rebuild that tester stays empty, so every click hit-tests to
281
    /// nothing and widget callbacks never fire*.
282
    ///
283
    /// Called from exactly two places, and both are load-bearing:
284
    ///
285
    /// * [`Runner::layout`] — the single funnel every layout pass goes through
286
    ///   (`regenerate_layout` for mount / remount / resize / DPI, and
287
    ///   `relayout_only` for an in-place DOM or style mutation). Rebuilding
288
    ///   here is what keeps a `click` after a `set_node_text` from testing the
289
    ///   pre-mutation geometry.
290
    /// * the tail of [`Runner::service`] — the paths that produce a new frame
291
    ///   WITHOUT running layout (`ShouldReRenderCurrentWindow`,
292
    ///   `ShouldUpdateDisplayListCurrentWindow`,
293
    ///   `UpdateHitTesterAndProcessAgain` — the last one names it outright) all
294
    ///   land there, and a display-list rebuild can move the `VirtualView`
295
    ///   placements this tester translates child DOMs by. It also guarantees
296
    ///   the invariant that actually matters: at the START of every op the
297
    ///   tester agrees with the frame on screen. A stale tester does not fail
298
    ///   loudly — it silently answers with the WRONG node, which is worse than
299
    ///   the `unsupported` refusal this replaced.
300
653
    fn rebuild_hit_tester(&mut self) {
301
653
        self.cpu_hit_tester
302
653
            .rebuild_from_layout_with_gpu(
303
653
                &self.layout_window.layout_results,
304
653
                Some(&self.layout_window.gpu_state_manager),
305
            );
306
653
    }
307

            
308
    /// Port of `PlatformWindow::update_hit_test_at`
309
    /// (`dll/src/desktop/shell2/common/event.rs`): resolve the pointer position
310
    /// to nodes and publish the result on the hover manager, which is where
311
    /// `determine_all_events` reads the mouse target from and where
312
    /// `CallbackInfo::get_hit_node` / text selection look it up.
313
    ///
314
    /// The CPU→`FullHitTest` conversion is the SAME function the desktop
315
    /// shells' `perform_hit_test` uses
316
    /// ([`azul_layout::headless::convert_cpu_hit_test_to_full`]) — not a
317
    /// re-derivation.
318
12
    fn update_hit_test_at(&mut self, position: LogicalPosition) {
319
        use azul_layout::managers::hover::InputPointId;
320

            
321
12
        let focused_node = self.layout_window.focus_manager.get_focused_node().copied();
322
12
        let hit_test = {
323
12
            let scroll_manager = &self.layout_window.scroll_manager;
324
12
            let gpu = &self.layout_window.gpu_state_manager;
325
12
            let resolve = |d: azul_core::dom::DomId, n: azul_core::dom::NodeId| {
326
6
                scroll_manager.get_current_offset(d, n)
327
6
            };
328
12
            let resolve_tf = |d: azul_core::dom::DomId, n: azul_core::dom::NodeId| {
329
6
                gpu.caches
330
6
                    .get(&d)
331
6
                    .and_then(|c| c.css_current_transform_values.get(&n))
332
6
                    .copied()
333
6
            };
334
12
            let hits = self
335
12
                .cpu_hit_tester
336
12
                .hit_test_scrolled(position, &resolve, &resolve_tf);
337
12
            azul_layout::headless::convert_cpu_hit_test_to_full(
338
12
                &self.cpu_hit_tester,
339
12
                &hits,
340
12
                focused_node,
341
12
                &self.layout_window.layout_results,
342
12
                position,
343
12
                &resolve,
344
12
                &resolve_tf,
345
            )
346
        };
347
12
        self.layout_window
348
12
            .hover_manager
349
12
            .push_hit_test(InputPointId::Mouse, hit_test);
350
12
    }
351

            
352
    /// Publish one hit test per live touch point and drive the gesture
353
    /// manager's per-finger sessions.
354
    ///
355
    /// This is the port of the X11 XI2 touch handler
356
    /// (`dll/src/desktop/shell2/linux/x11/mod.rs`), which does exactly these
357
    /// two things next to writing `touch_state`: it feeds
358
    /// `gesture_drag_manager.touch_down/touch_move/touch_up` (what
359
    /// `detect_pinch` / `detect_rotation` / `detect_swipe_direction` consume)
360
    /// and lets the state-diff pass derive the touch events.
361
    ///
362
    /// DELIBERATE DEVIATION: the shells pass the pointer's SCREEN position as
363
    /// the second coordinate; a headless window has no screen, so the window
364
    /// position is reused. Only multi-window gesture bookkeeping reads it.
365
    fn sync_touch_points(&mut self, old_points: &[azul_core::window::TouchPoint]) {
366
        use azul_layout::managers::hover::InputPointId;
367

            
368
        let now = self.now();
369
        let window_position = self.window_state.position;
370
        let focused_node = self.layout_window.focus_manager.get_focused_node().copied();
371
        let new_points: Vec<azul_core::window::TouchPoint> =
372
            self.window_state.touch_state.touch_points.as_ref().to_vec();
373

            
374
        for point in &new_points {
375
            let hit_test = {
376
                let scroll_manager = &self.layout_window.scroll_manager;
377
                let gpu = &self.layout_window.gpu_state_manager;
378
                let resolve = |d: azul_core::dom::DomId, n: azul_core::dom::NodeId| {
379
                    scroll_manager.get_current_offset(d, n)
380
                };
381
                let resolve_tf = |d: azul_core::dom::DomId, n: azul_core::dom::NodeId| {
382
                    gpu.caches
383
                        .get(&d)
384
                        .and_then(|c| c.css_current_transform_values.get(&n))
385
                        .copied()
386
                };
387
                let hits = self
388
                    .cpu_hit_tester
389
                    .hit_test_scrolled(point.position, &resolve, &resolve_tf);
390
                azul_layout::headless::convert_cpu_hit_test_to_full(
391
                    &self.cpu_hit_tester,
392
                    &hits,
393
                    focused_node,
394
                    &self.layout_window.layout_results,
395
                    point.position,
396
                    &resolve,
397
                    &resolve_tf,
398
                )
399
            };
400
            self.layout_window
401
                .hover_manager
402
                .push_hit_test(InputPointId::Touch(point.id), hit_test);
403

            
404
            match old_points.iter().find(|q| q.id == point.id) {
405
                None => self.layout_window.gesture_drag_manager.touch_down(
406
                    point.id,
407
                    point.position,
408
                    now.clone(),
409
                    window_position,
410
                    point.position,
411
                ),
412
                Some(before) if before.position != point.position => {
413
                    let _ = self.layout_window.gesture_drag_manager.touch_move(
414
                        point.id,
415
                        point.position,
416
                        now.clone(),
417
                        point.position,
418
                    );
419
                }
420
                Some(_) => {}
421
            }
422
        }
423
        for point in old_points {
424
            if !new_points.iter().any(|p| p.id == point.id) {
425
                self.layout_window.gesture_drag_manager.touch_up(
426
                    point.id,
427
                    point.position,
428
                    now.clone(),
429
                    point.position,
430
                );
431
            }
432
        }
433
    }
434

            
435
    /// Drop the hover history of every touch point that is no longer down.
436
    ///
437
    /// Runs at the END of [`Runner::service`], not inside
438
    /// [`Runner::sync_touch_points`]: `determine_all_events` resolves the
439
    /// TARGET of a `TouchEnd` through that history, so purging before the pass
440
    /// would send every touch-up to the mouse target instead of to the node the
441
    /// finger was actually on. Purging afterwards keeps
442
    /// `HoverManager::hover_histories` from growing one entry per touch id
443
    /// forever, which is exactly the kind of per-interaction growth the
444
    /// `[idle/growth]` family exists to catch.
445
533
    fn purge_ended_touch_points(&mut self) {
446
        use azul_layout::managers::hover::InputPointId;
447

            
448
533
        let live: Vec<u64> = self
449
533
            .window_state
450
533
            .touch_state
451
533
            .touch_points
452
533
            .as_ref()
453
533
            .iter()
454
533
            .map(|p| p.id)
455
533
            .collect();
456
533
        let stale: Vec<InputPointId> = self
457
533
            .layout_window
458
533
            .hover_manager
459
533
            .get_active_input_points()
460
533
            .into_iter()
461
533
            .filter(|id| matches!(id, InputPointId::Touch(t) if !live.contains(t)))
462
533
            .collect();
463
533
        for id in stale {
464
            self.layout_window.hover_manager.remove_input_point(&id);
465
        }
466
533
    }
467

            
468
    /// Apply the `CallbackChange`s the runner pushed this pump, then finish the
469
    /// frame the way the platform event loop does.
470
    ///
471
    /// `needs_update` is the debug-op `needs_update` flag: the DLL's debug timer
472
    /// returns `Update::RefreshDom` for it, which the event loop turns into a
473
    /// full `regenerate_layout()`.
474
533
    fn service(&mut self, changes: &Arc<Mutex<Vec<CallbackChange>>>, needs_update: bool) {
475
533
        let drained = changes
476
533
            .lock()
477
533
            .map(|mut c| std::mem::take(&mut *c))
478
533
            .unwrap_or_default();
479

            
480
        // Each change is applied IN ORDER, as it is drained — NOT collapsed into
481
        // "the last one wins". The real shell runs `apply_user_change` once per
482
        // change and takes the MAX of the results; collapsing loses transient
483
        // states (a `key_down`+`key_up` pair that lands in a single continuation
484
        // slice would leave only the key-RELEASED state, and Tab-to-focus-next
485
        // would silently do nothing).
486
        // The redraw the PREVIOUS frame asked for (see `pending_redraw`). The
487
        // platform loops service `request_redraw()` on the next turn of the
488
        // loop, which is exactly here — and it is the only thing that lets a
489
        // time-driven animation advance across a step that pushes no change of
490
        // its own (`wait`).
491
533
        let mut result = if core::mem::take(&mut self.pending_redraw) {
492
4
            ProcessEventResult::ShouldReRenderCurrentWindow
493
        } else {
494
529
            ProcessEventResult::DoNothing
495
        };
496
1035
        for ch in drained {
497
502
            result = result.max(self.apply_user_change(&ch));
498
502
        }
499
        // Timers, AFTER this pass's changes: an op that ARMS a timer (focusing a
500
        // contenteditable arms the caret blink) has to have armed it before the
501
        // pump looks, or the timer would always be one op late.
502
533
        result = result.max(self.pump_timers());
503
533
        if needs_update {
504
152
            result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
505
381
        }
506
        // A pending mount/unmount always needs the DOM rebuilt, even if the op
507
        // that produced it somehow did not set `needs_update`. (`RemountDom`
508
        // already returns `ShouldRegenerateDomCurrentWindow` from
509
        // `apply_user_change`; this covers a mount left dirty by an earlier
510
        // pass that never got to regenerate.)
511
533
        if self.layout_window.e2e_mount.is_dirty() {
512
81
            result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
513
452
        }
514
        // RESIZE POLICY (user ruling 2026-08-08) — the same decision every
515
        // desktop shell makes, through the same LayoutWindow fn, so the corpus
516
        // tests exactly what the shells run. A DPI change is always a full
517
        // regeneration; a size change re-invokes layout() only when a recorded
518
        // window-size query answer flips or a CSS breakpoint / orientation is
519
        // crossed. Everything else re-flows the existing StyledDom.
520
533
        if self.dpi_pending {
521
2
            result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
522
531
        } else if self.resize_pending {
523
9
            let old_logical = self
524
9
                .layout_window
525
9
                .current_window_state
526
9
                .size
527
9
                .get_logical_size();
528
9
            let full = self.layout_window.resize_needs_full_regeneration(
529
9
                old_logical,
530
9
                self.window_state.size.dimensions,
531
            );
532
9
            result = result.max(if full {
533
4
                ProcessEventResult::ShouldRegenerateDomCurrentWindow
534
            } else {
535
5
                ProcessEventResult::ShouldIncrementalRelayout
536
            });
537
522
        }
538

            
539
533
        self.layout_window.sync_frame_report();
540
533
        self.layout_window.frame_report.terminal_result = result as u8;
541

            
542
533
        match result {
543
192
            ProcessEventResult::DoNothing => {}
544
            ProcessEventResult::ShouldRegenerateDomCurrentWindow
545
158
            | ProcessEventResult::ShouldRegenerateDomAllWindows => self.regenerate_layout(),
546
19
            ProcessEventResult::ShouldIncrementalRelayout => self.relayout_only(),
547
            // The name IS the contract. A paint-only restyle (`:hover` /
548
            // `:focus` changing a colour) mutates the styled DOM's property
549
            // cache and asks for exactly this — but the DISPLAY LIST still
550
            // carries the old paint, so rendering without rebuilding it shows
551
            // the pre-restyle pixels and reports zero damage.
552
            //
553
            // This was invisible while every pointer op set `needs_update`: the
554
            // forced `regenerate_layout()` rebuilt the display list as a side
555
            // effect, so `:hover` appeared to work for the wrong reason. With
556
            // the op no longer fabricating that rebuild, the arm has to do the
557
            // work its own name promises.
558
            //
559
            // `UpdateHitTesterAndProcessAgain` is grouped here because it ranks
560
            // ABOVE `ShouldUpdateDisplayListCurrentWindow` in
561
            // `ProcessEventResult`'s order — it may never do less work than the
562
            // result it dominates. (The real shells map it to a full
563
            // regeneration; a display-list rebuild is the floor, not the cap.)
564
            ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
565
42
            | ProcessEventResult::UpdateHitTesterAndProcessAgain => {
566
42
                self.layout_window.regenerate_display_list_for_dom(DomId::ROOT_ID);
567
42
                self.render_and_record();
568
42
            }
569
122
            ProcessEventResult::ShouldReRenderCurrentWindow => self.render_and_record(),
570
        }
571

            
572
533
        self.arm_tween_timer();
573

            
574
        // Keep servicing the redraws the frames themselves ask for, until the
575
        // window stops changing. The platform loops do this across turns of the
576
        // event loop; here it has to happen INSIDE one `service()`, because the
577
        // next thing the pump runs is the scenario's next step — and if that
578
        // step is an idleness assertion, it reads whatever this call left
579
        // behind. The scrollbar fade is 700 ms of WALL CLOCK (`fade_delay` 500 +
580
        // `fade_duration` 200) and each headless frame costs about a
581
        // millisecond, so this is a real-time-paced loop, exactly like a shell
582
        // redrawing at the display's rate — not a spin that fabricates time.
583
533
        self.pump_pending_redraws();
584

            
585
        // The frame is now final for this op. Re-derive the pointer→node map
586
        // from it so the NEXT op's hit test cannot read geometry that a
587
        // display-list-only path (the render-only arms above) just moved. See
588
        // [`Runner::rebuild_hit_tester`].
589
533
        self.rebuild_hit_tester();
590
533
        self.purge_ended_touch_points();
591
533
    }
592

            
593
    /// Arm the caret / selection tween driver if the display-list pass this
594
    /// frame ran left a tween in flight — port of the shared site at the tail
595
    /// of the DLL's `process_window_events`
596
    /// (`dll/src/desktop/shell2/common/event.rs`). The timer self-terminates
597
    /// via its `RefAny`'d flag when the tween finishes, so there is no matching
598
    /// stop call.
599
    ///
600
    /// PLACEMENT DIFFERS FROM THE DLL ON PURPOSE. The DLL arms inside the event
601
    /// pass because a shell's frame ends there; this host's frame ends in
602
    /// [`Runner::service`], and the ops that move a caret (`text_input`,
603
    /// `move_cursor`, `set_text_selection`) reach `apply_user_change` straight
604
    /// from `service` without a state-diff pass. Arming inside
605
    /// `process_window_events` alone would leave every op-driven tween
606
    /// un-driven — which is indistinguishable, from a scenario, from the tween
607
    /// not existing.
608
533
    fn arm_tween_timer(&mut self) {
609
        use azul_core::task::CARET_TWEEN_TIMER_ID;
610

            
611
533
        if !self.layout_window.text_edit_manager.tween.is_active()
612
            || self.layout_window.timers.contains_key(&CARET_TWEEN_TIMER_ID)
613
        {
614
533
            return;
615
        }
616
        let timer = self.layout_window.create_caret_tween_timer();
617
        self.layout_window.add_timer(CARET_TWEEN_TIMER_ID, timer);
618
533
    }
619

            
620
    /// Run every timer that is due, i.e. the timer half of the DLL's
621
    /// `PlatformWindow::process_timers_and_threads` +
622
    /// `PlatformWindow::invoke_expired_timers`
623
    /// (`dll/src/desktop/shell2/common/event.rs`).
624
    ///
625
    /// WHY IT EXISTS: `AddTimer` / `RemoveTimer` / `StartCursorBlinkTimer` /
626
    /// `StopCursorBlinkTimer` were all declared `unsupported("no timer driver")`
627
    /// — every one of `LayoutWindow`'s pieces (`tick_timers`, `run_single_timer`,
628
    /// `time_until_next_timer_ms`) existed, but nothing in this host ever drove
629
    /// them. Caret blink was therefore untestable and any behaviour that only
630
    /// happens on a timer expiry could not be expressed as a scenario.
631
    ///
632
    /// TIME. `Instant::now()` honours the thread-scoped test clock that the
633
    /// `tick_ms` op advances (`azul_core::task::advance_test_clock_ms`), so a
634
    /// scenario drives timers by *asserting* time rather than by sleeping
635
    /// through it: `tick_ms 600` expires a 530 ms blink, deterministically, in
636
    /// microseconds.
637
    ///
638
    /// READINESS is decided by `Timer::invoke`, not here — `tick_timers`
639
    /// deliberately returns every registered timer and `invoke` returns
640
    /// `DoNothing`/`Continue` for one whose delay or interval has not elapsed.
641
    /// That is why pumping on every `service()` is cheap and correct rather
642
    /// than a spin.
643
    ///
644
    /// The `Update` a timer callback returns is NOT the only way a rebuild gets
645
    /// requested (465060f5b): `apply_user_change` runs a whole event pass for
646
    /// `ModifyWindowState` / `CreateTextInput`, and a user callback dispatched
647
    /// inside it can itself return `Update::RefreshDom`, which surfaces as a
648
    /// `ShouldRegenerateDom*` RESULT. Folding both into one `max` is what keeps
649
    /// a requested DOM rebuild from being downgraded to a relayout of the DOM it
650
    /// was supposed to replace — the bug just fixed on the DLL side.
651
533
    fn pump_timers(&mut self) -> ProcessEventResult {
652
        use azul_core::callbacks::Update;
653
        use azul_core::task::TimerId;
654

            
655
533
        if self.layout_window.timers.is_empty() {
656
514
            return ProcessEventResult::DoNothing;
657
19
        }
658

            
659
19
        let frame_start = self.now();
660
19
        let due: Vec<TimerId> = self.layout_window.tick_timers(frame_start.clone());
661

            
662
19
        let window_handle = RawWindowHandle::Unsupported;
663
19
        let gl_context = OptionGlContextPtr::None;
664

            
665
19
        let mut result = ProcessEventResult::DoNothing;
666
19
        let mut needs_dom_regeneration = false;
667

            
668
38
        for timer_id in due {
669
19
            let (changes, update) = {
670
19
                let Self {
671
19
                    layout_window,
672
19
                    window_state,
673
19
                    previous_window_state,
674
19
                    renderer_resources,
675
19
                    system_callbacks,
676
19
                    ..
677
19
                } = self;
678
19
                layout_window.run_single_timer(
679
19
                    timer_id.id,
680
19
                    frame_start.clone(),
681
19
                    &window_handle,
682
19
                    &gl_context,
683
19
                    Arc::new(SystemStyle::default()),
684
19
                    system_callbacks,
685
19
                    previous_window_state,
686
19
                    window_state,
687
19
                    renderer_resources,
688
19
                )
689
19
            };
690

            
691
            // Applied IMMEDIATELY, before the next timer runs, so inter-timer
692
            // visibility works: a timer that removes another timer must actually
693
            // have removed it by the time that one is reached. (A `Timer` that
694
            // asked to terminate arrives here as a `RemoveTimer` change appended
695
            // by `run_single_timer` itself.)
696
26
            for change in &changes {
697
7
                result = result.max(self.apply_user_change(change));
698
7
            }
699
19
            if matches!(update, Update::RefreshDom | Update::RefreshDomAllWindows) {
700
                needs_dom_regeneration = true;
701
19
            }
702
        }
703

            
704
19
        if needs_dom_regeneration {
705
            result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
706
19
        }
707
19
        result
708
533
    }
709

            
710
    /// Service the redraws a rendered frame asked for (`pending_redraw`), until
711
    /// the window stops changing.
712
    ///
713
    /// The cap is a SAFETY NET for a flag that never clears, not the expected
714
    /// exit: the scrollbar fade is driven by a monotonic clock, so it always
715
    /// terminates on its own. Hitting the cap deliberately leaves the state
716
    /// machine visibly un-settled rather than hanging the run — which is the
717
    /// outcome `assert_state_machines_idle` exists to report.
718
533
    fn pump_pending_redraws(&mut self) {
719
        const MAX_REDRAW_FRAMES: usize = 4096;
720
533
        let mut frames = 0usize;
721
16923
        while self.pending_redraw && frames < MAX_REDRAW_FRAMES {
722
16390
            self.render_and_record();
723
16390
            frames += 1;
724
16390
        }
725
533
    }
726

            
727
    /// Port of `PlatformWindow::process_window_events`
728
    /// (`dll/src/desktop/shell2/common/event.rs`) — the state-diff pass, and the
729
    /// thing `FrameReport::relayout_iterations` counts.
730
    ///
731
    /// The DLL records `max(depth + 1)` in an observability wrapper around this
732
    /// function and sets `hit_depth_cap` when the recursion is broken off at
733
    /// `MAX_EVENT_RECURSION_DEPTH`. Both are ported here VERBATIM, because the
734
    /// number an assertion reads has to mean the same thing in both hosts:
735
    ///
736
    /// * `0` — no event pass ran at all. That is what an idle frame looks like:
737
    ///   a clock tick with no state delta produces a repaint and nothing else.
738
    /// * `1` — one pass, converged.
739
    /// * `>1` — the pass had to be re-entered (a callback changed state that
740
    ///   raised new events); this is the invalidation-loop signal.
741
    ///
742
    /// Before this existed the runner hard-coded `1` inside the
743
    /// `ModifyWindowState` arm, so an idle frame reported the same number as a
744
    /// converged event pass and `assert_work_bounded` could not tell "no work"
745
    /// from "one pass of work".
746
    ///
747
    /// The pass itself mirrors the DLL's ordering: determine events → `:hover`
748
    /// restyle → user callbacks → click-to-focus → keyboard default action →
749
    /// focus events → re-entry. It used to run ONLY the keyboard branch, which
750
    /// is why no pointer op could focus a node headlessly.
751
74
    fn process_window_events(&mut self, depth: usize) -> ProcessEventResult {
752
        #[allow(clippy::cast_possible_truncation)]
753
74
        let depth_u32 = depth as u32;
754
74
        self.layout_window.sync_frame_report();
755
74
        let r = &mut self.layout_window.frame_report;
756
74
        r.relayout_iterations = r.relayout_iterations.max(depth_u32 + 1);
757

            
758
74
        if depth >= MAX_EVENT_RECURSION_DEPTH {
759
            // The DLL log_warn's here and returns; the flag is what turns that
760
            // silent cap into a red assertion.
761
            self.layout_window.frame_report.hit_depth_cap = true;
762
            return ProcessEventResult::DoNothing;
763
74
        }
764

            
765
        // ── 1. EVENT DETERMINATION ───────────────────────────────────────
766
        //
767
        // `determine_all_events` is the ONLY thing that turns a window-state
768
        // delta into events. It reads the pointer target off the hover
769
        // manager, which the callers of this pass (`ModifyWindowState`,
770
        // `QueueWindowStateSequence`, `RequestHitTestUpdate`) fill in via
771
        // `update_hit_test_at` — exactly as the DLL's platform layer does
772
        // before calling `process_window_events`.
773
74
        let previous_state = self
774
74
            .previous_window_state
775
74
            .clone()
776
74
            .unwrap_or_else(|| self.window_state.clone());
777
74
        let timestamp = self.now();
778
74
        let wheel_delta = self.layout_window.scroll_manager.pending_wheel_event;
779
74
        let synthetic_events = {
780
74
            let lw = &self.layout_window;
781
74
            let providers: Vec<&dyn azul_core::events::EventProvider> = vec![
782
74
                &lw.text_input_manager,
783
74
                &lw.sensor_manager,
784
74
                &lw.gamepad_manager,
785
74
                &lw.geolocation_manager,
786
74
                &lw.permission_manager,
787
74
                &lw.biometric_manager,
788
74
                &lw.keyring_manager,
789
            ];
790
74
            azul_layout::event_determination::determine_all_events(
791
74
                &self.window_state,
792
74
                &previous_state,
793
74
                &lw.hover_manager,
794
74
                &lw.focus_manager,
795
74
                &lw.file_drop_manager,
796
74
                Some(&lw.gesture_drag_manager),
797
74
                &providers,
798
74
                wheel_delta,
799
74
                timestamp,
800
            )
801
        };
802

            
803
        // Clear the one-shot pending-event flags now that this pass has
804
        // collected them — one event per change, not one per frame (the DLL
805
        // does this at the same point, right after determination).
806
74
        {
807
74
            let lw = &mut self.layout_window;
808
74
            lw.sensor_manager.clear_pending_event();
809
74
            lw.gamepad_manager.clear_pending_event();
810
74
            lw.geolocation_manager.clear_pending_event();
811
74
            lw.permission_manager.clear_pending_changed();
812
74
            lw.biometric_manager.clear_pending_event();
813
74
            lw.keyring_manager.clear_pending_event();
814
74
            lw.gesture_drag_manager.clear_pen_event_pending();
815
74
            lw.gesture_drag_manager.clear_native_gesture();
816
74
        }
817

            
818
74
        if synthetic_events.is_empty() {
819
18
            return ProcessEventResult::DoNothing;
820
56
        }
821

            
822
56
        let mut result = ProcessEventResult::DoNothing;
823

            
824
        // ── 2. INCREMENTAL `:hover` RESTYLE ──────────────────────────────
825
        // Enter/leave targets of THIS pass, restyled now so pure-CSS `:hover`
826
        // rules take effect without a DOM regeneration.
827
        {
828
56
            let mut per_dom: BTreeMap<DomId, azul_core::styled_dom::HoverChange> = BTreeMap::new();
829
128
            for ev in &synthetic_events {
830
72
                let is_enter = ev.event_type == azul_core::events::EventType::MouseEnter;
831
72
                let is_leave = ev.event_type == azul_core::events::EventType::MouseLeave;
832
72
                if !is_enter && !is_leave {
833
59
                    continue;
834
13
                }
835
13
                let Some(node) = ev.target.node.into_crate_internal() else {
836
                    continue;
837
                };
838
13
                let entry = per_dom.entry(ev.target.dom).or_insert_with(|| {
839
4
                    azul_core::styled_dom::HoverChange {
840
4
                        left_nodes: Vec::new(),
841
4
                        entered_nodes: Vec::new(),
842
4
                    }
843
4
                });
844
13
                if is_enter {
845
11
                    entry.entered_nodes.push(node);
846
11
                } else {
847
2
                    entry.left_nodes.push(node);
848
2
                }
849
            }
850
56
            if !per_dom.is_empty() {
851
4
                result = result.max(apply_hover_restyle(&mut self.layout_window, per_dom));
852
52
            }
853
        }
854

            
855
        // The hit test the callbacks (and the click-to-focus pass below) see.
856
56
        let hit_test_for_dispatch = self
857
56
            .layout_window
858
56
            .hover_manager
859
56
            .get_current(&azul_layout::managers::hover::InputPointId::Mouse)
860
56
            .cloned();
861

            
862
        // ── 3. USER CALLBACK DISPATCH (W3C capture → target → bubble) ────
863
56
        let old_focus = self.layout_window.focus_manager.get_focused_node().copied();
864
56
        let (changes_result, callback_update, prevent_default) =
865
56
            self.dispatch_events_propagated(&synthetic_events);
866
56
        result = result.max(changes_result);
867

            
868
        // The wheel delta has now been delivered; clear it so no later pass
869
        // re-fires a stale Scroll event.
870
56
        self.layout_window.scroll_manager.pending_wheel_event = None;
871

            
872
56
        let mut should_recurse = false;
873
56
        if matches!(
874
56
            callback_update,
875
            azul_core::callbacks::Update::RefreshDom
876
                | azul_core::callbacks::Update::RefreshDomAllWindows
877
        ) {
878
            result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
879
            should_recurse = true;
880
56
        }
881

            
882
        // ── 3b. POST-CALLBACK TEXT INPUT ─────────────────────────────────
883
        //
884
        // Port of the DLL's `post_callback_filter_system_changes` →
885
        // `SystemChange::ApplyPendingTextInput` → `ApplyTextChangeset` tail.
886
        // The DECISION is not re-derived here: the same `azul_core` function
887
        // both hosts call answers it.
888
        //
889
        // THIS STAGE DID NOT EXIST. Text recorded but not yet applied — which
890
        // is what a native shell has at this point in the pass, because it
891
        // calls `record_text_input` BEFORE running the pass — was never landed
892
        // by this host, and a callback's `prevent_default()` never killed a
893
        // recorded edit either. The e2e corpus could reach text only through
894
        // `CallbackChange::CreateTextInput`, whose own arm records, dispatches
895
        // and applies in one go; a KeyDown handler's veto is structurally
896
        // invisible to that shape, so no scenario could express the thing every
897
        // shell does on every keystroke.
898
        {
899
            use azul_core::events::SystemChange;
900

            
901
56
            let new_focus_now = self.layout_window.focus_manager.get_focused_node().copied();
902
56
            let post_changes = azul_core::events::post_callback_filter_system_changes(
903
56
                prevent_default,
904
56
                &[],
905
56
                old_focus,
906
56
                new_focus_now,
907
            );
908
56
            if post_changes
909
56
                .iter()
910
56
                .any(|c| matches!(c, SystemChange::ApplyPendingTextInput))
911
            {
912
56
                let changeset_result = self.layout_window.apply_text_changeset();
913
56
                if !changeset_result.dirty_nodes.is_empty() {
914
                    result = result.max(if changeset_result.needs_relayout {
915
                        ProcessEventResult::ShouldIncrementalRelayout
916
                    } else {
917
                        ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
918
                    });
919
                    self.layout_window.scroll_selection_into_view(
920
                        azul_layout::window::SelectionScrollType::Cursor,
921
                        azul_layout::window::ScrollMode::Instant,
922
                    );
923
56
                }
924
            } else if prevent_default {
925
                // A vetoed edit must DIE, not wait: the pending record would
926
                // otherwise survive into the next pass, whose unconditional
927
                // apply would land the vetoed character late.
928
                self.layout_window.text_input_manager.clear_changeset();
929
            }
930
        }
931

            
932
        // ── 4. MOUSE CLICK-TO-FOCUS (W3C default action) ─────────────────
933
        // The deepest focusable ancestor of the deepest hit node takes focus
934
        // on MouseDown. This is the default action that makes `click` able to
935
        // focus anything at all — before the hit tester was wired, the ONLY
936
        // way to move focus headlessly was Tab.
937
56
        let mut mouse_click_focus_changed = false;
938
56
        if !prevent_default
939
56
            && synthetic_events
940
56
                .iter()
941
72
                .any(|e| e.event_type == azul_core::events::EventType::MouseDown)
942
        {
943
4
            let clicked_focusable_node = hit_test_for_dispatch.as_ref().and_then(|hit_test| {
944
4
                let mut found: Option<DomNodeId> = None;
945
8
                for (dom_id, hit_test_data) in &hit_test.hovered_nodes {
946
4
                    let deepest = hit_test_data
947
4
                        .regular_hit_test_nodes
948
4
                        .iter()
949
13
                        .max_by_key(|(_, hit_item)| core::cmp::Reverse(hit_item.hit_depth));
950
4
                    let Some((node_id, _)) = deepest else { continue };
951
4
                    let Some(layout_result) = self.layout_window.layout_results.get(dom_id) else {
952
                        continue;
953
                    };
954
4
                    let node_data = layout_result.styled_dom.node_data.as_container();
955
4
                    let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
956
4
                    let mut current = Some(*node_id);
957
4
                    while let Some(nid) = current {
958
4
                        if node_data.get(nid).is_some_and(azul_core::dom::NodeData::is_focusable) {
959
4
                            found = Some(DomNodeId {
960
4
                                dom: *dom_id,
961
4
                                node: NodeHierarchyItemId::from_crate_internal(Some(nid)),
962
4
                            });
963
4
                            break;
964
                        }
965
                        current = node_hierarchy.get(nid).and_then(|h| h.parent_id());
966
                    }
967
                }
968
4
                found
969
4
            });
970

            
971
4
            if let Some(new_focus_target) = clicked_focusable_node {
972
4
                if old_focus.and_then(|f| f.node.into_crate_internal())
973
4
                    != new_focus_target.node.into_crate_internal()
974
4
                {
975
4
                    result = result.max(self.set_focus(Some(new_focus_target), old_focus));
976
4
                    mouse_click_focus_changed = true;
977
4
                }
978
            }
979
52
        }
980

            
981
        // ── 5. KEYBOARD DEFAULT ACTIONS (Tab / Shift+Tab / Escape) ───────
982
        // Gated on a KeyDown in THIS pass, like the DLL. Before that gate
983
        // existed the runner re-ran the action on every recursion level, so a
984
        // single Tab walked the focus ring MAX_EVENT_RECURSION_DEPTH times and
985
        // set `hit_depth_cap` on every key press (it only produced the right
986
        // answer because 7 steps over 3 focusables is a net +1).
987
56
        let mut default_action_focus_changed = false;
988
56
        if !prevent_default
989
56
            && synthetic_events
990
56
                .iter()
991
72
                .any(|e| e.event_type == azul_core::events::EventType::KeyDown)
992
14
        {
993
14
            let (r, changed) = self.run_keyboard_default_action();
994
14
            result = result.max(r);
995
14
            default_action_focus_changed = changed;
996
42
        }
997

            
998
        // ── 6. FOCUS EVENTS + RE-ENTRY ───────────────────────────────────
999
56
        if (default_action_focus_changed || mouse_click_focus_changed)
18
            && depth + 1 < MAX_EVENT_RECURSION_DEPTH
        {
18
            let new_focus = self.layout_window.focus_manager.get_focused_node().copied();
            // Collapse any selection: standard UI behaviour on focus change.
18
            if let Some(mc) = self.layout_window.text_edit_manager.multi_cursor.as_mut() {
                if let Some(cursor) = mc.get_primary_cursor() {
                    mc.set_single_cursor(cursor);
                }
18
            }
18
            let now = self.now();
18
            let mut focus_events = Vec::new();
18
            if let Some(old_node) = old_focus {
10
                focus_events.push(azul_core::events::SyntheticEvent::new(
10
                    azul_core::events::EventType::Blur,
10
                    azul_core::events::EventSource::User,
10
                    old_node,
10
                    now.clone(),
10
                    azul_core::events::EventData::None,
10
                ));
10
            }
18
            if let Some(new_node) = new_focus {
17
                focus_events.push(azul_core::events::SyntheticEvent::new(
17
                    azul_core::events::EventType::Focus,
17
                    azul_core::events::EventSource::User,
17
                    new_node,
17
                    now,
17
                    azul_core::events::EventData::None,
17
                ));
17
            }
18
            if !focus_events.is_empty() {
18
                let (focus_result, focus_update, _) =
18
                    self.dispatch_events_propagated(&focus_events);
18
                result = result.max(focus_result);
18
                if matches!(
18
                    focus_update,
                    azul_core::callbacks::Update::RefreshDom
                        | azul_core::callbacks::Update::RefreshDomAllWindows
                ) {
                    result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
18
                }
            }
            // CRITICAL (verbatim from the DLL): advance the sync baseline
            // BEFORE recursing, or the SAME MouseDown / Tab is re-detected at
            // depth+1 and its default action fires again on every level.
18
            self.previous_window_state = Some(self.window_state.clone());
18
            result = result.max(self.process_window_events(depth + 1));
38
        } else if should_recurse && depth + 1 < MAX_EVENT_RECURSION_DEPTH {
            self.previous_window_state = Some(self.window_state.clone());
            result = result.max(self.process_window_events(depth + 1));
38
        }
        // TEXT-EDIT NOTIFICATIONS (port of the DLL's drain): edits committed
        // outside the text-input record pipeline dispatch their Input event
        // here, so `On::Input` observes deletions and line breaks too.
        {
56
            let pending = self.layout_window.take_text_edit_notifications();
56
            if !pending.is_empty() {
                let now = self.now();
                let edit_events: Vec<_> = pending
                    .into_iter()
                    .map(|host| {
                        azul_core::events::SyntheticEvent::new(
                            azul_core::events::EventType::Input,
                            azul_core::events::EventSource::User,
                            host,
                            now.clone(),
                            azul_core::events::EventData::None,
                        )
                    })
                    .collect();
                let (edit_result, _edit_update, _) = self.dispatch_events_propagated(&edit_events);
                result = result.max(edit_result);
56
            }
        }
        // Finalize pending focus changes (caret init for contenteditable) —
        // the DLL's end-of-pass `SystemChange::FinalizePendingFocusChanges`.
56
        self.layout_window.finalize_pending_focus_changes();
        // DELIBERATE DEVIATION, floored not faithful: the DLL returns `result`
        // as-is, so a pass whose events changed nothing observable returns
        // DoNothing and the shell skips the frame. This host's damage
        // machinery is fed by `render_and_record` (see `Runner::service`), and
        // an event pass that produced no frame at all leaves the frame report
        // describing the PREVIOUS op. Flooring at "repaint" costs an extra
        // no-damage render and never hides one.
56
        result.max(ProcessEventResult::ShouldReRenderCurrentWindow)
74
    }
    /// Port of the DLL's `apply_system_change(SystemChange::SetFocus { .. })`:
    /// move focus, scroll the new node into view, and apply the `:focus`
    /// restyle so focus styling lands on THIS frame instead of the next resize.
18
    fn set_focus(
18
        &mut self,
18
        new_focus: Option<DomNodeId>,
18
        old_focus: Option<DomNodeId>,
18
    ) -> ProcessEventResult {
        use azul_layout::managers::scroll_into_view::ScrollIntoViewOptions;
18
        let old_focus_node_id = old_focus.and_then(|f| f.node.into_crate_internal());
18
        let new_focus_node_id = new_focus.and_then(|f| f.node.into_crate_internal());
18
        let now = self.now();
18
        let window_state = self.window_state.clone();
18
        let lw = &mut self.layout_window;
18
        lw.focus_manager.set_focused_node(new_focus);
18
        if let Some(focus_node) = new_focus {
17
            lw.scroll_node_into_view(focus_node, ScrollIntoViewOptions::nearest(), now);
17
        }
18
        arm_caret_for_focus(lw, new_focus, &window_state);
18
        let mut result = ProcessEventResult::ShouldReRenderCurrentWindow;
18
        if old_focus_node_id != new_focus_node_id {
18
            result = result.max(apply_focus_restyle(lw, old_focus_node_id, new_focus_node_id));
18
        }
18
        result
18
    }
    /// Port of `PlatformWindow::dispatch_events_propagated`
    /// (`dll/src/desktop/shell2/common/event.rs`): plan the callback
    /// invocations for a batch of `SyntheticEvent`s using the W3C
    /// capture→target→bubble model, invoke them, then apply every
    /// `CallbackChange` they produced through [`Runner::apply_user_change`].
    ///
    /// Returns `(max ProcessEventResult, merged Update, any preventDefault)`.
    /// `preventDefault` is what suppresses the click-to-focus and keyboard
    /// default actions above, so this cannot be short-circuited to "no
    /// callbacks exist in an XML mount" — a scenario that mounts a component
    /// carrying callbacks would then silently take the default action anyway.
    #[allow(clippy::too_many_lines)]
74
    fn dispatch_events_propagated(
74
        &mut self,
74
        events: &[azul_core::events::SyntheticEvent],
74
    ) -> (ProcessEventResult, azul_core::callbacks::Update, bool) {
        use azul_core::{
            callbacks::{CoreCallbackData, Update},
            events::EventFilter,
            id::NodeId as CoreNodeId,
        };
        struct PlannedInvocation {
            dom_id: DomId,
            node_id: NodeId,
            callback_data: CoreCallbackData,
        }
        // Phase 1 — build the dispatch plan (read-only over the layout window).
74
        let planned_callbacks: Vec<PlannedInvocation> = {
74
            let lw = &self.layout_window;
74
            let focused_node = lw.focus_manager.get_focused_node().copied();
74
            let mut planned = Vec::new();
173
            for event in events {
99
                let event_filters =
99
                    azul_core::events::event_type_to_filters(event.event_type, &event.data);
206
                for filter in &event_filters {
107
                    match filter {
                        EventFilter::Hover(_) => {
36
                            let dom_id = event.target.dom;
36
                            let Some(layout_result) = lw.layout_results.get(&dom_id) else {
                                continue;
                            };
36
                            let node_hierarchy = {
36
                                let items = layout_result.styled_dom.node_hierarchy.as_container();
36
                                let nodes: Vec<azul_core::id::Node> = (0..items.len())
632
                                    .map(|i| {
632
                                        let item = &items.internal[i];
632
                                        azul_core::id::Node {
632
                                            parent: CoreNodeId::from_usize(item.parent),
632
                                            previous_sibling: CoreNodeId::from_usize(
632
                                                item.previous_sibling,
632
                                            ),
632
                                            next_sibling: CoreNodeId::from_usize(item.next_sibling),
632
                                            last_child: CoreNodeId::from_usize(item.last_child),
632
                                        }
632
                                    })
36
                                    .collect();
36
                                azul_core::id::NodeHierarchy::new(nodes)
                            };
36
                            let node_data_container =
36
                                layout_result.styled_dom.node_data.as_container();
36
                            let mut callback_map: BTreeMap<CoreNodeId, Vec<EventFilter>> =
36
                                BTreeMap::new();
632
                            for node_idx in 0..node_data_container.len() {
632
                                let node_id = CoreNodeId::new(node_idx);
632
                                if let Some(nd) = node_data_container.get(node_id) {
632
                                    let matching: Vec<EventFilter> = nd
632
                                        .get_callbacks()
632
                                        .as_ref()
632
                                        .iter()
632
                                        .filter(|cb| cb.event == *filter)
632
                                        .map(|cb| cb.event)
632
                                        .collect();
632
                                    if !matching.is_empty() {
                                        callback_map.insert(node_id, matching);
632
                                    }
                                }
                            }
36
                            if callback_map.is_empty() {
36
                                continue;
                            }
                            let mut event_clone = event.clone();
                            let prop_result = azul_core::events::propagate_event(
                                &mut event_clone,
                                &node_hierarchy,
                                &callback_map,
                            );
                            for (node_id, matched_filter) in &prop_result.callbacks_to_invoke {
                                let Some(nd) = node_data_container.get(*node_id) else {
                                    continue;
                                };
                                for cb in nd.get_callbacks().as_ref() {
                                    if cb.event == *matched_filter {
                                        planned.push(PlannedInvocation {
                                            dom_id,
                                            node_id: *node_id,
                                            callback_data: cb.clone(),
                                        });
                                    }
                                }
                            }
                        }
                        EventFilter::Focus(_) => {
                            // Focus events fire on the focused node only.
55
                            let Some(focused) = focused_node else { continue };
47
                            let Some(node_id) = focused.node.into_crate_internal() else {
                                continue;
                            };
47
                            let Some(lr) = lw.layout_results.get(&focused.dom) else {
                                continue;
                            };
47
                            let ndc = lr.styled_dom.node_data.as_container();
47
                            let Some(nd) = ndc.get(node_id) else { continue };
47
                            for cb in nd.get_callbacks().as_ref() {
                                if cb.event == *filter {
                                    planned.push(PlannedInvocation {
                                        dom_id: focused.dom,
                                        node_id,
                                        callback_data: cb.clone(),
                                    });
                                }
                            }
                        }
                        EventFilter::Window(_) | EventFilter::Application(_) => {
                            // Window / Application events fire on EVERY node
                            // carrying a matching callback.
32
                            for (dom_id, lr) in &lw.layout_results {
16
                                let ndc = lr.styled_dom.node_data.as_container();
137
                                for node_idx in 0..ndc.len() {
137
                                    let node_id = CoreNodeId::new(node_idx);
137
                                    let Some(nd) = ndc.get(node_id) else { continue };
137
                                    for cb in nd.get_callbacks().as_ref() {
                                        if cb.event == *filter {
                                            planned.push(PlannedInvocation {
                                                dom_id: *dom_id,
                                                node_id,
                                                callback_data: cb.clone(),
                                            });
                                        }
                                    }
                                }
                            }
                        }
                        EventFilter::Component(_) => {
                            // Lifecycle events carry their target node; no
                            // propagation.
                            let dom_id = event.target.dom;
                            let Some(node_id) = event.target.node.into_crate_internal() else {
                                continue;
                            };
                            let Some(lr) = lw.layout_results.get(&dom_id) else {
                                continue;
                            };
                            let ndc = lr.styled_dom.node_data.as_container();
                            let Some(nd) = ndc.get(node_id) else { continue };
                            for cb in nd.get_callbacks().as_ref() {
                                if cb.event == *filter {
                                    planned.push(PlannedInvocation {
                                        dom_id,
                                        node_id,
                                        callback_data: cb.clone(),
                                    });
                                }
                            }
                        }
                    }
                }
            }
74
            planned
        };
74
        if planned_callbacks.is_empty() {
74
            return (ProcessEventResult::DoNothing, Update::DoNothing, false);
        }
        // Phase 2 — invoke.
        let previous_window_state = self.previous_window_state.clone();
        let gl_context = OptionGlContextPtr::None;
        let window_handle = RawWindowHandle::Unsupported;
        let system_style = Arc::new(SystemStyle::default());
        let mut all_updates: Vec<Update> = Vec::new();
        let mut all_changes: Vec<CallbackChange> = Vec::new();
        let mut any_prevent_default = false;
        let mut propagation_stopped = false;
        let mut propagation_stopped_node: Option<(DomId, NodeId)> = None;
        for planned in planned_callbacks {
            // W3C stopPropagation: remaining handlers on the SAME node still
            // run; the first handler on a different node ends the dispatch.
            if propagation_stopped
                && propagation_stopped_node
                    .is_none_or(|(dom, nid)| dom != planned.dom_id || nid != planned.node_id)
            {
                break;
            }
            let mut callback =
                azul_layout::callbacks::Callback::from_core(planned.callback_data.callback);
            let hit_node = DomNodeId {
                dom: planned.dom_id,
                node: NodeHierarchyItemId::from_crate_internal(Some(planned.node_id)),
            };
            let (changes, update) = {
                let lw = &mut self.layout_window;
                lw.invoke_single_callback_at(
                    hit_node,
                    &mut callback,
                    &mut planned.callback_data.refany.clone(),
                    &window_handle,
                    &gl_context,
                    system_style.clone(),
                    &ExternalSystemCallbacks::rust_internal(),
                    &previous_window_state,
                    &self.window_state,
                    &self.renderer_resources,
                )
            };
            all_updates.push(update);
            let mut should_stop_immediate = false;
            let mut should_stop_propagation = false;
            for change in &changes {
                match change {
                    CallbackChange::PreventDefault => any_prevent_default = true,
                    CallbackChange::StopImmediatePropagation => should_stop_immediate = true,
                    CallbackChange::StopPropagation => should_stop_propagation = true,
                    _ => {}
                }
            }
            all_changes.extend(changes);
            if should_stop_propagation && !propagation_stopped {
                propagation_stopped = true;
                propagation_stopped_node = Some((planned.dom_id, planned.node_id));
            }
            if should_stop_immediate {
                break;
            }
        }
        let mut changes_result = ProcessEventResult::DoNothing;
        for change in &all_changes {
            changes_result = changes_result.max(self.apply_user_change(change));
        }
        let merged_update = all_updates
            .iter()
            .copied()
            .fold(Update::DoNothing, Update::max);
        (changes_result, merged_update, any_prevent_default)
74
    }
    /// Port of `PlatformWindow::apply_user_change`
    /// (`dll/src/desktop/shell2/common/event.rs`) for the `CallbackChange`
    /// variants the E2E op set can produce. Each arm mirrors the DLL's arm —
    /// including its relayout / display-list bookkeeping, which is what makes
    /// the damage the assertions observe the SAME damage the real host produces.
    #[allow(clippy::too_many_lines)]
509
    fn apply_user_change(&mut self, change: &CallbackChange) -> ProcessEventResult {
509
        match change {
            // A script asking to run a script. The headless runner is ALREADY
            // executing a scenario when it gets here, and `E2eSession` has one
            // continuation slot per window — accepting this would overwrite
            // the run in progress with no trace. Refused loudly rather than
            // silently, because a scenario that quietly stops half way is the
            // worst of the available outcomes.
            // Cancelling in the headless runner: nothing here was started by
            // ExecuteE2eJson (it refuses, below), so there is never a handle
            // to cancel. A no-op, not an error — see `stop_e2e_json`.
            // The headless runner is where the animation e2e tests actually
            // execute, so this arm is the one that matters: it steps the
            // integrator by an exact `dt` with no wall clock involved, which is
            // what makes a mid-flight assertion reproducible.
            CallbackChange::SetAnimationMomentum {
                node,
                velocity_x,
                velocity_y,
            } => {
                if let Some(n) = node.node.into_crate_internal() {
                    self.layout_window
                        .apply_animation_momentum(n, *velocity_x, *velocity_y);
                }
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
57
            CallbackChange::TickAnimations { dt_micros, steps } => {
                // Idle-transparent: `tick_ms` routes through here on EVERY
                // scenario (one engine clock), so a tick with nothing to
                // animate must not charge the window a display-list pass —
                // that turned every timer-driven damage assertion
                // (caret blink's "idle frame does no work") into FULL damage.
57
                let had_work = !self.layout_window.animations.is_empty()
32
                    || self.layout_window.has_zombies()
29
                    || self.layout_window.has_track_work();
57
                if !had_work {
22
                    return ProcessEventResult::DoNothing;
35
                }
35
                let dt = *dt_micros as f32 / 1_000_000.0;
1079
                for _ in 0..(*steps).max(1) {
1079
                    self.layout_window.tick_animations(dt);
1079
                }
                // Sample the tracks for THIS frame — may invoke COMPONENT
                // animation functions with a full TimerCallbackInfo; their
                // queued changes apply exactly like timer changes.
35
                let track_changes = {
35
                    let frame_start = self.now();
                    let Self {
35
                        layout_window,
35
                        window_state,
35
                        previous_window_state,
35
                        renderer_resources,
35
                        system_callbacks,
                        ..
35
                    } = self;
35
                    layout_window.run_track_frames(
35
                        dt,
35
                        frame_start,
35
                        &RawWindowHandle::Unsupported,
35
                        &OptionGlContextPtr::None,
35
                        Arc::new(SystemStyle::default()),
35
                        system_callbacks,
35
                        previous_window_state,
35
                        window_state,
35
                        renderer_resources,
                    )
                };
35
                let mut extra = ProcessEventResult::DoNothing;
35
                for ch in &track_changes {
                    extra = extra.max(self.apply_user_change(ch));
                }
                // A layout-affecting `animation` transition (width, margins)
                // must re-solve, not just repaint — the display-list rebuild
                // reads geometry the solver has not recomputed yet.
35
                extra.max(if self.layout_window.take_transition_relayout() {
4
                    ProcessEventResult::ShouldIncrementalRelayout
31
                } else if self.layout_window.take_transition_patched() {
                    // Every transitioning value was PATCHED into the DL in
                    // place: no rebuild, just re-render — the DL diff turns
                    // the patched items into bounded damage.
2
                    ProcessEventResult::ShouldReRenderCurrentWindow
                } else {
29
                    ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
                })
            }
            CallbackChange::StopE2eJson { .. } => ProcessEventResult::DoNothing,
            CallbackChange::ExecuteE2eJson { .. } => {
                crate::e2e::full::log(
                    crate::e2e::full::LogLevel::Warn,
                    crate::e2e::full::LogCategory::Callbacks,
                    "execute_e2e_json ignored: already inside an E2E run. Nested                      scripts would overwrite the outer run's continuation. This holds                      for BOTH execution modes: Sync would additionally block the very                      thread that has to drive the outer run.",
                    None,
                );
                ProcessEventResult::DoNothing
            }
            // === Window State ===
320
            CallbackChange::ModifyWindowState { state } => {
320
                let old = std::mem::replace(&mut self.window_state, state.clone());
320
                let size_changed = self.window_state.size.dimensions != old.size.dimensions;
320
                let dpi_changed = self.window_state.size.dpi != old.size.dpi;
320
                let mouse_state_changed = self.window_state.mouse_state != old.mouse_state;
320
                if size_changed {
9
                    self.resize_pending = true;
311
                }
320
                if dpi_changed {
2
                    self.dpi_pending = true;
318
                }
320
                if state.flags.close_requested {
                    return ProcessEventResult::DoNothing;
320
                }
                // Port of the DLL's `anything_changed` gate: the state-diff pass
                // runs ONCE per ModifyWindowState **that actually changed
                // something**, and NOT AT ALL for a state re-push. That gate is
                // what makes `relayout_iterations` mean what it says — a
                // repaint request (`tick_ms` / `wait_frame`, which re-push the
                // current state) is not an event pass and must not be counted
                // as one.
                // `touch_state` was MISSING from this list, and the string
                // `touch_state` appeared nowhere in this file. A touch op
                // mutated the state, the gate answered "nothing changed", the
                // pass never ran and no touch event was ever determined — with
                // no `unsupported`, no `send_err` and a green `ok`. 48 corpus
                // lines executed nothing and passed.
320
                let touch_state_changed = self.window_state.touch_state != old.touch_state;
                // Captured BEFORE `old` is moved into `previous_window_state`
                // below; `sync_touch_points` needs the previous point set to
                // tell a new finger from a moved one.
320
                let old_touch_points: Vec<azul_core::window::TouchPoint> = if touch_state_changed {
                    old.touch_state.touch_points.as_ref().to_vec()
                } else {
320
                    Vec::new()
                };
320
                let anything_changed = size_changed
311
                    || dpi_changed
309
                    || touch_state_changed
309
                    || self.window_state.mouse_state != old.mouse_state
309
                    || self.window_state.keyboard_state != old.keyboard_state
281
                    || self.window_state.window_focused != old.window_focused
278
                    || self.window_state.flags.has_focus != old.flags.has_focus
278
                    || self.window_state.position != old.position;
320
                let mut result = ProcessEventResult::ShouldReRenderCurrentWindow;
320
                if anything_changed {
44
                    // Advance the sync baseline BEFORE the pass — it is what
44
                    // `determine_all_events` diffs `current` against, so
44
                    // forgetting it makes every event pass see a zero delta
44
                    // and produce nothing.
44
                    self.previous_window_state = Some(old);
276
                }
                // Mouse state changed → re-resolve the pointer target before
                // the pass, exactly where the DLL calls `update_hit_test_at`.
320
                if mouse_state_changed {
                    if let Some(pos) = self.window_state.mouse_state.cursor_position.get_position()
                    {
                        self.update_hit_test_at(pos);
                    }
320
                }
                // Same idea for touch, one hit test PER FINGER — see
                // [`Runner::sync_touch_points`].
320
                if touch_state_changed {
                    self.sync_touch_points(&old_touch_points);
320
                }
320
                if anything_changed {
44
                    result = result.max(self.process_window_events(0));
276
                }
320
                result
            }
            // === Focus ===
4
            CallbackChange::SetFocusTarget { target } => {
                use azul_layout::managers::focus_cursor::{
                    resolve_focus_target_or_defer, FocusResolution,
                };
                use azul_layout::managers::scroll_into_view::ScrollIntoViewOptions;
4
                let now = self.now();
4
                let window_state = self.window_state.clone();
4
                let lw = &mut self.layout_window;
                // `resolve_focus_target` cannot tell "matched nothing" from "no
                // layout exists to match against yet": both are `Ok(None)`, and
                // this arm applied that as CLEAR FOCUS. A `set_focus` issued
                // before the first layout — the ordinary case for a `create`
                // callback — therefore vanished, which is what apps papered
                // over with a short timer. `Deferred` means do NOTHING: the
                // target is parked on the focus manager and re-resolved by
                // `finalize_pending_focus_changes` after the next layout pass.
4
                match resolve_focus_target_or_defer(
4
                    &mut lw.focus_manager,
4
                    target,
4
                    &lw.layout_results,
                ) {
4
                    Ok(FocusResolution::Resolved(Some(new_focus))) => {
4
                        lw.focus_manager.set_focused_node(Some(new_focus));
4
                        lw.scroll_node_into_view(new_focus, ScrollIntoViewOptions::nearest(), now);
4
                        arm_caret_for_focus(lw, Some(new_focus), &window_state);
4
                        lw.finalize_pending_focus_changes();
4
                        ProcessEventResult::ShouldReRenderCurrentWindow
                    }
                    Ok(FocusResolution::Resolved(None)) => {
                        lw.focus_manager.set_focused_node(None);
                        arm_caret_for_focus(lw, None, &window_state);
                        lw.finalize_pending_focus_changes();
                        ProcessEventResult::ShouldReRenderCurrentWindow
                    }
                    Ok(FocusResolution::Deferred) => ProcessEventResult::DoNothing,
                    Err(_) => ProcessEventResult::DoNothing,
                }
            }
            // === Content Modifications ===
5
            CallbackChange::ChangeNodeText { node_id, text } => {
5
                let dom_id = node_id.dom;
5
                let Some(internal_node_id) = node_id.node.into_crate_internal() else {
                    return ProcessEventResult::DoNothing;
                };
5
                let lw = &mut self.layout_window;
                // NO-OP SHORT CIRCUIT. Setting the text to the byte-identical
                // string used to throw away the ENTIRE incremental shaped-text
                // cache and re-shape every run in the DOM, then relayout the
                // whole root — the maximum work in the engine, for a write that
                // changed nothing. It also went green: the re-shape reproduces
                // identical glyphs, so the display list is identical, so the
                // damage is `none` and `assert_damage {"kind":"none"}` passed
                // while the engine did everything. That IS over-invalidation,
                // and it was invisible to every assertion the harness had.
5
                let unchanged = lw
5
                    .layout_results
5
                    .get(&dom_id)
5
                    .is_some_and(|lr| {
5
                        let nodes = lr.styled_dom.node_data.as_container();
5
                        nodes.get(internal_node_id).is_some_and(|node| {
                            matches!(
5
                                node.get_node_type(),
4
                                azul_core::dom::NodeType::Text(existing)
4
                                    if existing.as_str() == text.as_str()
                            )
5
                        })
5
                    });
5
                if unchanged {
                    return ProcessEventResult::DoNothing;
5
                }
5
                if let Some(layout_result) = lw.layout_results.get_mut(&dom_id) {
5
                    let idx = internal_node_id.index();
5
                    if idx < layout_result.styled_dom.node_data.as_ref().len() {
5
                        layout_result.styled_dom.node_data.as_container_mut()[internal_node_id]
5
                            .set_node_type(azul_core::dom::NodeType::Text(
5
                                azul_css::css::BoxOrStatic::heap(text.clone()),
5
                            ));
5
                    }
                }
                // NO cache reset (USER mandate: per-IFC text patching). The
                // reconcile fingerprints node CONTENT, so the changed text
                // node hashes differently, misses its cached shaping, and
                // re-shapes exactly its own IFC — while the warm tree and the
                // previous display list survive, which is what lets the
                // STRUCTURE-PRESERVED DL patch splice every untouched node
                // and re-emit only the edited paragraph. The old
                // `reset_incremental()` here was the hammer that made every
                // text edit a cold full pass and a full-frame repaint.
                // Staleness is guarded by bug_dom_mutation_no_damage's pixel
                // LIVENESS assert; the cheapness by dl_text_patch.
5
                ProcessEventResult::ShouldIncrementalRelayout
            }
            CallbackChange::RecordDocumentEdit { changeset } => {
                self.layout_window.record_document_edit(changeset.clone());
                ProcessEventResult::DoNothing
            }
            CallbackChange::MarkDocumentEditApplied { id } => {
                let _ = self.layout_window.mark_document_edit_applied(*id);
                ProcessEventResult::DoNothing
            }
            CallbackChange::MarkDocumentEditAppliedWithInverse { id, inverse } => {
                let _ = self
                    .layout_window
                    .mark_document_edit_applied_with_inverse(*id, inverse.clone());
                ProcessEventResult::DoNothing
            }
            CallbackChange::UndoStructuralEdit => {
                let _ = self.layout_window.undo_structural_edit();
                ProcessEventResult::DoNothing
            }
            CallbackChange::RedoStructuralEdit => {
                let _ = self.layout_window.redo_structural_edit();
                ProcessEventResult::DoNothing
            }
2
            CallbackChange::ChangeNodeImage { dom_id, node_id, image, update_type: _ } => {
                // The content chokepoint: overlay write + journal + in-place DL
                // patch (paint tier) or incremental-cache reset (relayout
                // tier). The StyledDom is NEVER mutated.
2
                let result = self.layout_window.apply_content_change(
2
                    crate::overlay::ContentChange::Image {
2
                        dom_id: *dom_id,
2
                        node_id: *node_id,
2
                        image: image.clone(),
2
                    },
                );
2
                result.tier.to_process_event_result()
            }
            CallbackChange::ChangeNodeImageMask { dom_id, node_id, mask } => {
                self.layout_window
                    .apply_content_change(crate::overlay::ContentChange::ImageMask {
                        dom_id: *dom_id,
                        node_id: *node_id,
                        mask: mask.clone(),
                    })
                    .tier
                    .to_process_event_result()
            }
7
            CallbackChange::ChangeNodeCssProperties { dom_id, node_id, properties } => {
                // Same one-line delegation as the DLL host — the chokepoint
                // owns inline-vec sync, cascade restyle, DL rebuild and tier.
7
                self.layout_window
7
                    .apply_content_change(crate::overlay::ContentChange::NodeCss {
7
                        dom_id: *dom_id,
7
                        node_id: *node_id,
7
                        props: properties.as_ref().to_vec(),
7
                        override_only: false,
7
                    })
7
                    .tier
7
                    .to_process_event_result()
            }
            CallbackChange::OverrideNodeCssProperties { dom_id, node_id, properties } => {
                self.layout_window
                    .apply_content_change(crate::overlay::ContentChange::NodeCss {
                        dom_id: *dom_id,
                        node_id: *node_id,
                        props: properties.as_ref().to_vec(),
                        override_only: true,
                    })
                    .tier
                    .to_process_event_result()
            }
            CallbackChange::UpdateVirtualView { dom_id, node_id } => {
                let mut updates = BTreeMap::new();
                let mut set = azul_core::FastBTreeSet::new();
                set.insert(*node_id);
                updates.insert(*dom_id, set);
                self.layout_window.queue_virtual_view_updates(updates);
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::UpdateAllVirtualViews => {
                self.layout_window.queue_all_virtual_view_reinvoke();
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::UpdateImageCallback { .. }
            | CallbackChange::UpdateAllImageCallbacks => {
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            // === DOM structure ===
            CallbackChange::InsertChildNode {
2
                dom_id, parent_node_id, node_type_str, position, classes, id,
            } => {
2
                let lw = &mut self.layout_window;
2
                if let Some(layout_result) = lw.layout_results.get_mut(dom_id) {
2
                    let parent_idx = parent_node_id.index();
2
                    if parent_idx < layout_result.styled_dom.node_data.as_ref().len() {
2
                        let node_type = parse_node_type_from_str(node_type_str.as_str());
2
                        let mut dom = azul_core::dom::Dom::create_node(node_type);
2
                        if let Some(id_str) = id.as_ref() {
2
                            dom = dom.with_id(id_str.clone());
2
                        }
2
                        for class in classes.iter() {
1
                            dom = dom.with_class(class.clone());
1
                        }
                        // Style it (empty CSS — the author rules are unavailable
                        // here; they are re-applied by `restyle_retained` below).
2
                        let css = azul_css::css::Css::empty();
2
                        let styled = StyledDom::create(&mut dom, css);
                        // `append_child` always attaches to the DOM's ROOT — the
                        // requested `parent_node_id` was accepted, validated and
                        // then IGNORED, so every inserted node landed as a last
                        // child of <html>. Append first, then RE-PARENT.
2
                        let sd = &mut layout_result.styled_dom;
2
                        let new_id = NodeId::new(sd.node_data.as_ref().len());
2
                        let root_id = sd.root.into_crate_internal().unwrap_or(NodeId::ZERO);
2
                        let root_last_before =
2
                            sd.node_hierarchy.as_container()[root_id].last_child_id();
2
                        sd.append_child(styled);
2
                        if *parent_node_id != root_id {
                            // The hierarchy is a FLAT DFS array whose
                            // `first_child_id(n)` is DERIVED as `n + 1`. A node
                            // appended at the end can therefore only ever be a
                            // LAST child, and only of a parent that already has
                            // children. Anything else needs a full re-index of
                            // the DOM (and every node-keyed manager), so it is
                            // rejected instead of silently corrupting the tree.
2
                            let parent_last =
2
                                sd.node_hierarchy.as_container()[*parent_node_id].last_child_id();
2
                            if let Some(parent_last) = parent_last {
                                // 1. unlink the new node from the root chain
                                {
2
                                    let h = &mut sd.node_hierarchy;
2
                                    h.as_container_mut()[root_id].last_child =
2
                                        NodeId::into_raw(&root_last_before);
2
                                    if let Some(rl) = root_last_before {
2
                                        h.as_container_mut()[rl].next_sibling =
2
                                            NodeId::into_raw(&None);
2
                                    }
                                    // 2. link it as the parent's new last child
2
                                    h.as_container_mut()[parent_last].next_sibling =
2
                                        NodeId::into_raw(&Some(new_id));
2
                                    h.as_container_mut()[new_id].previous_sibling =
2
                                        NodeId::into_raw(&Some(parent_last));
2
                                    h.as_container_mut()[new_id].next_sibling =
2
                                        NodeId::into_raw(&None);
2
                                    h.as_container_mut()[new_id].parent =
2
                                        NodeId::into_raw(&Some(*parent_node_id));
2
                                    h.as_container_mut()[*parent_node_id].last_child =
2
                                        NodeId::into_raw(&Some(new_id));
                                }
                                // 3. keep the cascade bookkeeping consistent
2
                                let sibling_index = {
2
                                    let h = sd.node_hierarchy.as_container();
2
                                    parent_node_id.az_children(&h).count().saturating_sub(1)
                                };
2
                                let ci = sd.cascade_info.as_mut();
2
                                ci[parent_last.index()].is_last_child = false;
2
                                ci[new_id.index()].index_in_parent =
2
                                    u32::try_from(sibling_index).unwrap_or(u32::MAX);
2
                                ci[new_id.index()].is_last_child = true;
2
                                sd.finalize_non_leaf_nodes();
                            }
                        }
2
                        let _ = position; // only append-as-last-child is representable
                        // Re-run the author cascade from the retained stylesheet:
                        // the node was styled with an EMPTY css above, so without
                        // this it would never match rules like `.hot { width: 80px }`
                        // — the "inserted node never gets the author cascade" bug.
2
                        sd.extend_author_scopes_for_appended(new_id, *parent_node_id);
2
                        sd.restyle_retained();
                        // `append_child` composes the trees but does NOT re-run
                        // inheritance or rebuild the compact cache: the appended
                        // node would keep its isolated cascade (no inherited
                        // font-size/color, no UA defaults, no compact-cache entry)
                        // and measure 0×0.
2
                        sd.recompute_inheritance_and_compact_cache();
                    }
                }
                // The tree changed shape: the incremental layout cache (keyed on
                // the DOM pointer) would otherwise reuse the old tree, and the
                // stored display list still describes the OLD tree.
2
                lw.layout_cache.reset_incremental();
2
                lw.regenerate_display_list_for_dom(*dom_id);
2
                ProcessEventResult::ShouldIncrementalRelayout
            }
1
            CallbackChange::DeleteNode { dom_id, node_id } => {
1
                let lw = &mut self.layout_window;
1
                if let Some(layout_result) = lw.layout_results.get_mut(dom_id) {
1
                    let idx = node_id.index();
1
                    let node_count = layout_result.styled_dom.node_data.as_ref().len();
1
                    if idx < node_count && idx != 0 {
                        // Tombstone: set node to empty Div and unlink it.
1
                        layout_result.styled_dom.node_data.as_container_mut()[*node_id]
1
                            .set_node_type(azul_core::dom::NodeType::Div);
1
                        layout_result.styled_dom.node_data.as_container_mut()[*node_id]
1
                            .set_ids_and_classes(Vec::new().into());
1
                        layout_result.styled_dom.node_data.as_container_mut()[*node_id]
1
                            .set_callbacks(Vec::new().into());
1
                        let hierarchy = &mut layout_result.styled_dom.node_hierarchy;
1
                        let prev_sib = hierarchy.as_container()[*node_id].previous_sibling_id();
1
                        let next_sib = hierarchy.as_container()[*node_id].next_sibling_id();
1
                        let parent = hierarchy.as_container()[*node_id].parent_id();
1
                        if let Some(prev) = prev_sib {
1
                            hierarchy.as_container_mut()[prev].next_sibling =
1
                                NodeId::into_raw(&next_sib);
1
                        }
1
                        if let Some(next) = next_sib {
1
                            hierarchy.as_container_mut()[next].previous_sibling =
1
                                NodeId::into_raw(&prev_sib);
1
                        } else if let Some(p) = parent {
                            hierarchy.as_container_mut()[p].last_child =
                                NodeId::into_raw(&prev_sib);
                        }
1
                        hierarchy.as_container_mut()[*node_id].parent = 0;
1
                        hierarchy.as_container_mut()[*node_id].previous_sibling = 0;
1
                        hierarchy.as_container_mut()[*node_id].next_sibling = 0;
1
                        hierarchy.as_container_mut()[*node_id].last_child = 0;
                    }
                }
1
                ProcessEventResult::ShouldIncrementalRelayout
            }
            CallbackChange::SetNodeIdsAndClasses { dom_id, node_id, ids_and_classes } => {
                if let Some(layout_result) = self.layout_window.layout_results.get_mut(dom_id) {
                    let idx = node_id.index();
                    if idx < layout_result.styled_dom.node_data.as_ref().len() {
                        layout_result.styled_dom.node_data.as_container_mut()[*node_id]
                            .set_ids_and_classes(ids_and_classes.clone());
                    }
                }
                ProcessEventResult::ShouldIncrementalRelayout
            }
81
            CallbackChange::RemountDom { xml } => {
                // The E2E `mount` / `unmount` document is per-window state, not
                // a process-global sink: store it on the window and let
                // `regenerate_layout` read it back on the next pass.
81
                self.layout_window
81
                    .e2e_mount
81
                    .set(xml.as_ref().map(|s| s.as_str().to_string()));
81
                ProcessEventResult::ShouldRegenerateDomCurrentWindow
            }
            // === Scroll ===
12
            CallbackChange::ScrollTo { dom_id, node_id, position, unclamped } => {
12
                let now = self.now();
12
                if let Some(internal_node_id) = node_id.into_crate_internal() {
12
                    let lw = &mut self.layout_window;
12
                    if *unclamped {
                        lw.scroll_manager.set_scroll_position_unclamped(
                            *dom_id, internal_node_id, *position, now,
                        );
12
                    } else {
12
                        lw.scroll_manager.scroll_to(
12
                            *dom_id,
12
                            internal_node_id,
12
                            *position,
12
                            std::time::Duration::from_millis(0).into(),
12
                            azul_core::events::EasingFunction::Linear,
12
                            now,
12
                        );
12
                    }
                    // Recalculate scrollbar geometry so CPU-side hit testing has
                    // up-to-date thumb positions.
12
                    lw.scroll_manager.calculate_scrollbar_states();
                }
12
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            // #28 (a): mirror of the DLL arm — full-geometry reconfigure
            // (Some = set, None = keep) via the two stores a VV invoke
            // writes, WITHOUT re-invoking the callback.
            CallbackChange::SetVirtualViewGeometry {
                dom_id,
                node_id,
                materialized,
                virtual_rect,
            } => {
                if let Some(internal_node_id) = node_id.into_crate_internal() {
                    let lw = &mut self.layout_window;
                    let (kept_scroll, kept_virtual) = lw
                        .virtual_view_manager
                        .get_declared_sizes(*dom_id, internal_node_id);
                    let kept_origin = lw
                        .virtual_view_manager
                        .materialized_window_origin(*dom_id, internal_node_id);
                    let new_mat: Option<LogicalRect> = (*materialized).into();
                    let new_virt: Option<LogicalRect> = (*virtual_rect).into();
                    // `None` = keep. The streaming case sets only
                    // `virtual_rect`, so the materialized window (and every
                    // pixel on screen) is untouched while the bar re-scales.
                    let eff_virtual = new_virt.map(|r| r.size).or(kept_virtual);
                    let eff_scroll = new_mat.map(|r| r.size).or(kept_scroll).or(eff_virtual);
                    let eff_origin = new_mat
                        .map(|r| r.origin)
                        .or(kept_origin)
                        .unwrap_or_else(LogicalPosition::zero);
                    if let (Some(s), Some(v)) = (eff_scroll, eff_virtual) {
                        let _ = lw.virtual_view_manager.update_virtual_view_info(
                            *dom_id,
                            internal_node_id,
                            eff_origin,
                            s,
                            v,
                        );
                        lw.scroll_manager.update_virtual_scroll_bounds(
                            *dom_id,
                            internal_node_id,
                            v,
                            Some(eff_origin),
                        );
                        lw.scroll_manager.calculate_scrollbar_states();
                    }
                }
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
2
            CallbackChange::ScrollIntoView { node_id, options } => {
2
                let now = self.now();
2
                let lw = &mut self.layout_window;
2
                let hops = lw.nested_dom_hops();
2
                let hop = move |d: DomId| hops.get(&d).copied();
2
                azul_layout::managers::scroll_into_view::scroll_node_into_view(
2
                    *node_id,
2
                    &lw.layout_results,
2
                    &mut lw.scroll_manager,
2
                    *options,
2
                    now,
2
                    &hop,
                );
2
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            // === Font cache ===
            CallbackChange::ReloadSystemFonts => {
                self.layout_window
                    .font_manager
                    .replace_fc_cache(FcFontCache::build());
                ProcessEventResult::DoNothing
            }
            // === Propagation control (consumed by the dispatch loop) ===
            CallbackChange::StopPropagation
            | CallbackChange::StopImmediatePropagation
            | CallbackChange::PreventDefault => ProcessEventResult::DoNothing,
            // === Window lifetime ===
            CallbackChange::CloseWindow => {
                self.window_state.flags.close_requested = true;
                ProcessEventResult::DoNothing
            }
            // === Text editing ===
            CallbackChange::InsertText { dom_id, node_id, text } => {
                use azul_layout::managers::text_input::TextInputSource;
                let lw = &mut self.layout_window;
                let dom_node_id = DomNodeId {
                    dom: *dom_id,
                    node: NodeHierarchyItemId::from_crate_internal(Some(*node_id)),
                };
                let old_inline_content = lw.get_text_before_textinput(*dom_id, *node_id);
                let old_text = lw.extract_text_from_inline_content(&old_inline_content);
                lw.text_input_manager.record_input(
                    dom_node_id,
                    text.to_string(),
                    old_text,
                    TextInputSource::Programmatic,
                );
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            CallbackChange::DeleteBackward { dom_id, node_id } => {
                self.apply_capi_delete(*dom_id, *node_id, false)
            }
            CallbackChange::DeleteForward { dom_id, node_id } => {
                self.apply_capi_delete(*dom_id, *node_id, true)
            }
            // Same route as every `MoveCursor{Left,Right,…}` arm, and as the
            // DLL's. Setting the cursor straight on the multi-cursor state
            // skips the display-list rebuild `handle_cursor_movement` does, so
            // a programmatic move repainted the OLD caret position — the
            // pre-fix body the DLL already replaced. `extend_selection` is
            // false because this variant carries an absolute cursor, not a
            // movement.
            CallbackChange::MoveCursor { dom_id, node_id, cursor } => {
                self.layout_window
                    .handle_cursor_movement(*dom_id, *node_id, *cursor, false);
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            CallbackChange::SetSelection { dom_id: _, node_id: _, selection } => {
                use azul_core::selection::Selection;
                if let Some(mc) = self.layout_window.text_edit_manager.multi_cursor.as_mut() {
                    match selection {
                        Selection::Cursor(cursor) => mc.set_single_cursor(*cursor),
                        Selection::Range(range) => mc.set_single_range(*range),
                    }
                }
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            CallbackChange::SetTextChangeset { changeset } => {
                self.layout_window.text_input_manager.set_changeset(changeset.clone());
                ProcessEventResult::DoNothing
            }
            // === Cursor movement ===
            CallbackChange::MoveCursorLeft { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_left(*cursor, &mut None)
                })
            }
            CallbackChange::MoveCursorRight { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_right(*cursor, &mut None)
                })
            }
            CallbackChange::MoveCursorUp { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_up(*cursor, &mut None, &mut None)
                })
            }
            CallbackChange::MoveCursorDown { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_down(*cursor, &mut None, &mut None)
                })
            }
            CallbackChange::MoveCursorToLineStart { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_to_line_start(*cursor, &mut None)
                })
            }
            CallbackChange::MoveCursorToLineEnd { dom_id, node_id, extend_selection } => {
                self.move_cursor(*dom_id, *node_id, *extend_selection, |layout, cursor| {
                    layout.move_cursor_to_line_end(*cursor, &mut None)
                })
            }
            // Document start/end are NOT a `move_cursor_in_node` movement in the
            // DLL either — they read the first/last cluster straight off the
            // inline layout.
            CallbackChange::MoveCursorToDocumentStart { dom_id, node_id, extend_selection } => {
                use azul_core::selection::{CursorAffinity, TextCursor};
                let lw = &mut self.layout_window;
                let first = lw
                    .get_inline_layout_for_node(*dom_id, *node_id)
                    .and_then(|layout| layout.items.first().and_then(|i| i.item.as_cluster()))
                    .map(|c| TextCursor {
                        cluster_id: c.source_cluster_id,
                        affinity: CursorAffinity::Leading,
                    });
                if let Some(doc_start) = first {
                    lw.handle_cursor_movement(*dom_id, *node_id, doc_start, *extend_selection);
                }
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            CallbackChange::MoveCursorToDocumentEnd { dom_id, node_id, extend_selection } => {
                use azul_core::selection::{CursorAffinity, TextCursor};
                let lw = &mut self.layout_window;
                let last = lw
                    .get_inline_layout_for_node(*dom_id, *node_id)
                    .and_then(|layout| layout.items.last().and_then(|i| i.item.as_cluster()))
                    .map(|c| TextCursor {
                        cluster_id: c.source_cluster_id,
                        affinity: CursorAffinity::Trailing,
                    });
                if let Some(doc_end) = last {
                    lw.handle_cursor_movement(*dom_id, *node_id, doc_end, *extend_selection);
                }
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            // === Multi-cursor / selection ===
            CallbackChange::AddCursor { dom_id, node_id, cursor } => {
                use azul_core::selection::MultiCursorState;
                let lw = &mut self.layout_window;
                if let Some(mc) = lw.text_edit_manager.multi_cursor.as_mut() {
                    let _ = mc.add_cursor(*cursor);
                } else {
                    let dom_node_id = DomNodeId {
                        dom: *dom_id,
                        node: NodeHierarchyItemId::from_crate_internal(Some(*node_id)),
                    };
                    lw.text_edit_manager.multi_cursor =
                        Some(MultiCursorState::new_with_cursor(*cursor, dom_node_id, 0));
                }
                lw.text_edit_manager.mark_dirty();
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::AddSelectionRange { dom_id, node_id, range } => {
                use azul_core::selection::MultiCursorState;
                let lw = &mut self.layout_window;
                if let Some(mc) = lw.text_edit_manager.multi_cursor.as_mut() {
                    let _ = mc.add_selection(*range);
                } else {
                    let dom_node_id = DomNodeId {
                        dom: *dom_id,
                        node: NodeHierarchyItemId::from_crate_internal(Some(*node_id)),
                    };
                    let mut mc = MultiCursorState::new_with_cursor(range.start, dom_node_id, 0);
                    mc.set_single_range(*range);
                    lw.text_edit_manager.multi_cursor = Some(mc);
                }
                lw.text_edit_manager.mark_dirty();
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::RemoveSelectionById { selection_id } => {
                let lw = &mut self.layout_window;
                if let Some(mc) = lw.text_edit_manager.multi_cursor.as_mut() {
                    let _ = mc.remove_selection(*selection_id);
                    lw.text_edit_manager.mark_dirty();
                }
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::SetSelectAllRange { target: _, range } => {
                if let Some(mc) = self.layout_window.text_edit_manager.multi_cursor.as_mut() {
                    mc.set_single_range(*range);
                }
                ProcessEventResult::DoNothing
            }
            CallbackChange::ProcessTextSelectionClick { position, time_ms } => {
                self.layout_window
                    .process_mouse_click_for_selection(*position, *time_ms);
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            CallbackChange::ScrollActiveCursorIntoView => {
                self.layout_window.scroll_selection_into_view(
                    azul_layout::window::SelectionScrollType::Cursor,
                    azul_layout::window::ScrollMode::Instant,
                );
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
            // === Cursor blink STATE (the blink TIMER is a separate story, below) ===
            CallbackChange::SetCursorVisibility { visible } => {
                let lw = &mut self.layout_window;
                lw.text_edit_manager.blink.set_visibility(*visible);
                if let Some(dom_id) = lw.text_edit_manager.get_editing_dom_id() {
                    lw.regenerate_display_list_for_dom(dom_id);
                }
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::ToggleCursorVisibility => {
5
                let now = self.now();
5
                let lw = &mut self.layout_window;
5
                if lw.text_edit_manager.blink.should_blink(&now) {
3
                    lw.text_edit_manager.blink.toggle_visibility();
3
                } else {
2
                    lw.text_edit_manager.blink.set_visibility(true);
2
                }
5
                if let Some(dom_id) = lw.text_edit_manager.get_editing_dom_id() {
5
                    lw.regenerate_display_list_for_dom(dom_id);
5
                }
5
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            }
            CallbackChange::ResetCursorBlink => {
                let now = self.now();
                self.layout_window
                    .text_edit_manager
                    .blink
                    .reset_blink_on_input(now);
                ProcessEventResult::DoNothing
            }
            // === Drag & drop payload (the GESTURE that starts a drag is input) ===
            CallbackChange::SetDragData { mime_type, data } => {
                if let Some(ctx) = self
                    .layout_window
                    .gesture_drag_manager
                    .get_drag_context_mut()
                {
                    if let Some(node_drag) = ctx.as_node_drag_mut() {
                        node_drag.drag_data.set_data(mime_type.clone(), data.clone());
                    }
                }
                ProcessEventResult::DoNothing
            }
            CallbackChange::AcceptDrop => {
                if let Some(ctx) = self
                    .layout_window
                    .gesture_drag_manager
                    .get_drag_context_mut()
                {
                    if let Some(node_drag) = ctx.as_node_drag_mut() {
                        node_drag.drop_accepted = true;
                    }
                }
                ProcessEventResult::DoNothing
            }
            CallbackChange::SetDropEffect { effect } => {
                if let Some(ctx) = self
                    .layout_window
                    .gesture_drag_manager
                    .get_drag_context_mut()
                {
                    if let Some(node_drag) = ctx.as_node_drag_mut() {
                        node_drag.drop_effect = *effect;
                    }
                }
                ProcessEventResult::DoNothing
            }
            // ── NOT SUPPORTED HEADLESSLY ────────────────────────────────────
            //
            // Everything below needs a facility this host does not have. Each
            // one FAILS THE SCENARIO by name (see `Runner::unsupported`) instead
            // of being dropped on the floor: a change that is silently ignored
            // produces a test that executes nothing and PASSES, which in a
            // generated corpus is indistinguishable from a real pass and would
            // certify thousands of scenarios that never ran.
            //
            // The variants are listed EXPLICITLY, with no `_` arm, so that a new
            // `CallbackChange` in `layout/src/callbacks.rs` is a COMPILE ERROR
            // here and forces a decision: port it (preferred — the reference is
            // `dll/src/desktop/shell2/common/event.rs::apply_user_change`) or
            // declare it unsupported.
            // Synthetic pointer input. `click` / `double_click` / `drag` all
            // land here: a SEQUENCE of window states that has to be applied ONE
            // AT A TIME, each with its own hit test and its own state-diff pass
            // — collapsing them to "the last one wins" would leave only the
            // button-RELEASED state and no MouseDown would ever exist (the same
            // transient-input bug documented in `Runner::service`).
4
            CallbackChange::QueueWindowStateSequence { states } => {
4
                let mut result = ProcessEventResult::DoNothing;
16
                for queued_state in states {
12
                    let old = self.window_state.clone();
12
                    self.previous_window_state = Some(old.clone());
                    // The DLL copies exactly these fields (not the whole
                    // state): the queued states are built from a clone of the
                    // current state, so anything else would be a no-op copy.
12
                    {
12
                        let current = &mut self.window_state;
12
                        current.mouse_state = queued_state.mouse_state;
12
                        current.keyboard_state = queued_state.keyboard_state.clone();
12
                        current.title = queued_state.title.clone();
12
                        current.size = queued_state.size;
12
                        current.position = queued_state.position;
12
                        current.flags = queued_state.flags;
12
                    }
                    // Not in the DLL's arm, which has no equivalent bookkeeping:
                    // this host caches rasterisations per size/DPI, so a queued
                    // size change has to invalidate them or the frame keeps the
                    // old scale (`ModifyWindowState` does the same).
12
                    if self.window_state.size.dimensions != old.size.dimensions {
                        self.resize_pending = true;
12
                    }
12
                    if self.window_state.size.dpi != old.size.dpi {
                        self.dpi_pending = true;
12
                    }
12
                    if let Some(pos) = queued_state.mouse_state.cursor_position.get_position() {
12
                        self.update_hit_test_at(pos);
12
                    }
12
                    result = result.max(self.process_window_events(0));
                }
4
                result
            }
            CallbackChange::RequestHitTestUpdate { position } => {
                self.update_hit_test_at(*position);
                ProcessEventResult::DoNothing
            }
            CallbackChange::InjectNativeGesture { .. } => {
                self.unsupported("InjectNativeGesture", "no platform gesture source")
            }
            // Accessibility action, i.e. what a screen reader asks for.
            //
            // PORT of the DLL's arm, which routes through
            // `PlatformWindow::dispatch_accessibility_actions`: apply the action
            // to the managers, THEN dispatch the synthetic events it mapped to.
            // Doing only the first half is the exact bug the DLL shipped once —
            // AT-SPI `do_action` was accepted, decoded to the right node, and
            // then invoked no callback at all — so this host does both halves or
            // the port is worthless.
            //
            // Not `unsupported`: nothing here needs a platform. The whole action
            // path lives in `LayoutWindow`, which this host owns.
            CallbackChange::PerformAccessibilityAction {
3
                dom_id,
3
                node_id,
3
                action,
            } => {
                use azul_core::events::{
                    EventData, EventFilter, EventSource, EventType, FocusEventFilter,
                    HoverEventFilter, KeyModifiers, MouseButton, MouseEventData, SyntheticEvent,
                };
3
                let affected = self.layout_window.process_accessibility_action(
3
                    *dom_id,
3
                    *node_id,
3
                    action.clone(),
3
                    azul_core::task::Instant::now(),
                );
                // NOT gated on `affected.is_empty()`. Focus / Blur / the
                // Scroll* family / SetTextSelection all mutate manager state and
                // map to NO callback, so their affected map is empty while the
                // screen is genuinely stale — which is why every platform
                // backend calls `request_redraw()` unconditionally after a
                // batch. `ShouldReRenderCurrentWindow` is this host's equivalent.
                {
3
                    let timestamp = self.now();
3
                    let mut events = Vec::new();
3
                    for (node, (filters, _needs_relayout)) in &affected {
                        // Synthetic pointer events carry the node's centre so a
                        // callback reading the cursor position sees an in-bounds
                        // point (same choice the DLL makes).
                        let centre = self
                            .layout_window
                            .get_node_layout_rect(*node)
                            .map_or(LogicalPosition { x: 0.0, y: 0.0 }, |r| LogicalPosition {
                                x: r.origin.x + r.size.width / 2.0,
                                y: r.origin.y + r.size.height / 2.0,
                            });
                        let mouse_data = || {
                            EventData::Mouse(MouseEventData {
                                position: centre,
                                button: MouseButton::Left,
                                buttons: 0,
                                modifiers: KeyModifiers::default(),
                            })
                        };
                        for f in filters {
                            let (event_type, data) = match f {
                                EventFilter::Hover(HoverEventFilter::MouseUp)
                                | EventFilter::Focus(FocusEventFilter::MouseUp) => {
                                    (EventType::MouseUp, mouse_data())
                                }
                                EventFilter::Hover(HoverEventFilter::MouseDown)
                                | EventFilter::Focus(FocusEventFilter::MouseDown) => {
                                    (EventType::MouseDown, mouse_data())
                                }
                                _ => continue,
                            };
                            events.push(SyntheticEvent::new(
                                event_type,
                                EventSource::Synthetic,
                                *node,
                                timestamp.clone(),
                                data,
                            ));
                        }
                    }
                    // The action already moved focus / scroll / cursor state, so
                    // the frame is stale even when it mapped to no callback.
3
                    let mut result = ProcessEventResult::ShouldReRenderCurrentWindow;
3
                    if !events.is_empty() {
                        let (r, _update, _) = self.dispatch_events_propagated(&events);
                        result = result.max(r);
3
                    }
3
                    result
                }
            }
            // === Timers ===
            //
            // Port of the DLL's four arms. There, `lw.timers.insert(..)` records
            // the timer and the platform trait's `start_timer` arms the OS
            // wakeup that will get the loop back to `process_timers_and_threads`.
            // This host has no OS: `LayoutWindow::timers` IS the registry and
            // [`Runner::pump_timers`] IS the loop, so the insert alone is the
            // whole job. Time comes from `Instant::now()`, which honours the
            // thread-scoped test clock the `tick_ms` op advances, so a timer
            // fires when the SCENARIO says it does — no sleeping, no race.
            //
            // HOW THE FIRST TWO ARE REACHED FROM A SCENARIO. `AddTimer` /
            // `RemoveTimer` are produced by `CallbackInfo::add_timer` /
            // `remove_timer`, an APP-callback API — and a scenario is HTML + CSS
            // + ops, so it cannot install the Rust `TimerCallback` fn pointer an
            // `AddTimer` carries. The `add_timer` / `remove_timer` DEBUG OPS
            // (`DebugEvent::AddTimer` / `RemoveTimer` in `full.rs`) close that
            // gap: they build a timer around a callback the e2e module itself
            // owns and push it through the same two `CallbackInfo` methods a
            // real app calls, so these arms run for real.
            // `e2e/op-add-remove-timer.json` is the guard.
1
            CallbackChange::AddTimer { timer_id, timer } => {
1
                self.layout_window.add_timer(*timer_id, timer.clone());
1
                ProcessEventResult::DoNothing
            }
1
            CallbackChange::RemoveTimer { timer_id } => {
1
                self.layout_window.remove_timer(timer_id);
1
                ProcessEventResult::DoNothing
            }
            CallbackChange::StartCursorBlinkTimer => {
                use azul_core::task::CURSOR_BLINK_TIMER_ID;
                // Idempotent, like the DLL's arm: re-arming an already-running
                // blink would reset `last_run` and stall the caret forever under
                // a stream of input events.
                if !self.layout_window.text_edit_manager.blink.is_blink_timer_active() {
                    self.layout_window
                        .text_edit_manager
                        .blink
                        .set_blink_timer_active(true);
                    let window_state = self.window_state.clone();
                    let timer = self.layout_window.create_cursor_blink_timer(&window_state);
                    self.layout_window.add_timer(CURSOR_BLINK_TIMER_ID, timer);
                }
                ProcessEventResult::DoNothing
            }
            CallbackChange::StopCursorBlinkTimer => {
                use azul_core::task::CURSOR_BLINK_TIMER_ID;
                if self.layout_window.text_edit_manager.blink.is_blink_timer_active() {
                    self.layout_window
                        .text_edit_manager
                        .blink
                        .set_blink_timer_active(false);
                }
                self.layout_window.remove_timer(&CURSOR_BLINK_TIMER_ID);
                ProcessEventResult::DoNothing
            }
            // No thread pump: nothing polls thread writebacks.
            CallbackChange::AddThread { .. } => {
                self.unsupported("AddThread", "no thread pump — the writeback would never run")
            }
            CallbackChange::RemoveThread { .. } => {
                self.unsupported("RemoveThread", "no thread pump")
            }
            // No OS integration.
            CallbackChange::SetCopyContent { .. } => {
                self.unsupported("SetCopyContent", "no OS clipboard")
            }
            CallbackChange::SetCutContent { .. } => {
                self.unsupported("SetCutContent", "no OS clipboard")
            }
            CallbackChange::CreateNewWindow { .. } => {
                self.unsupported("CreateNewWindow", "single-window host")
            }
            CallbackChange::BeginInteractiveMove => {
                self.unsupported("BeginInteractiveMove", "no window manager")
            }
            CallbackChange::OpenMenu { .. } => {
                self.unsupported("OpenMenu", "no native menu host")
            }
            CallbackChange::ShowTooltip { .. } => {
                self.unsupported("ShowTooltip", "tooltips are a second platform window")
            }
            CallbackChange::HideTooltip => {
                self.unsupported("HideTooltip", "tooltips are a second platform window")
            }
            // css-id registrations go through the content chokepoint into the
            // LayoutWindow's OWN ImageCache (the single authority) — the DL
            // build resolves `background-image: url(...)` against it, so the
            // returned tier makes the change visible NOW (the old handler was
            // `unsupported`, and the DLL's was `DoNothing`).
1
            CallbackChange::AddImageToCache { id, image } => {
1
                let result = self.layout_window.apply_content_change(
1
                    crate::overlay::ContentChange::ImageById {
1
                        id: id.clone(),
1
                        image: Some(image.clone()),
1
                    },
                );
1
                result.tier.to_process_event_result()
            }
1
            CallbackChange::RemoveImageFromCache { id } => {
1
                let result = self.layout_window.apply_content_change(
1
                    crate::overlay::ContentChange::ImageById {
1
                        id: id.clone(),
1
                        image: None,
1
                    },
                );
1
                result.tier.to_process_event_result()
            }
            // No app data / undo manager: the runner's `RefAny` app data is `()`,
            // so a snapshot or an undo would restore nothing.
            CallbackChange::CommitUndoSnapshot => {
                self.unsupported("CommitUndoSnapshot", "no app-data undo manager")
            }
            CallbackChange::UndoAppState => {
                self.unsupported("UndoAppState", "no app-data undo manager")
            }
            CallbackChange::RedoAppState => {
                self.unsupported("RedoAppState", "no app-data undo manager")
            }
            // Text input. `process_text_input` records the changeset and
            // reports the affected nodes; the host then dispatches one `Input`
            // event per node and only THEN applies the changeset, so an
            // `On::Input` callback observes the pre-edit text exactly as it
            // does in the DLL. Applying only the first half would edit the text
            // while no callback ever fired.
            CallbackChange::CreateTextInput { text } => {
                let affected_nodes = self.layout_window.process_text_input(text.as_str());
                if affected_nodes.is_empty() {
                    return ProcessEventResult::DoNothing;
                }
                let now = self.now();
                let text_events: Vec<_> = affected_nodes
                    .keys()
                    .map(|dom_node_id| {
                        azul_core::events::SyntheticEvent::new(
                            azul_core::events::EventType::Input,
                            azul_core::events::EventSource::User,
                            *dom_node_id,
                            now.clone(),
                            azul_core::events::EventData::None,
                        )
                    })
                    .collect();
                let mut result = ProcessEventResult::DoNothing;
                let (text_changes_result, text_update, text_prevent_default) =
                    self.dispatch_events_propagated(&text_events);
                // A callback veto kills the recorded edit — same as the DLL:
                // clearing it also stops any later apply from landing it late.
                if text_prevent_default {
                    self.layout_window.text_input_manager.clear_changeset();
                    return result.max(text_changes_result);
                }
                result = result.max(text_changes_result);
                if matches!(
                    text_update,
                    azul_core::callbacks::Update::RefreshDom
                        | azul_core::callbacks::Update::RefreshDomAllWindows
                ) {
                    result = result.max(ProcessEventResult::ShouldRegenerateDomCurrentWindow);
                }
                let changeset_result = self.layout_window.apply_text_changeset();
                if !changeset_result.dirty_nodes.is_empty() {
                    result = result.max(if changeset_result.needs_relayout {
                        ProcessEventResult::ShouldIncrementalRelayout
                    } else {
                        ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
                    });
                    self.layout_window.scroll_selection_into_view(
                        azul_layout::window::SelectionScrollType::Cursor,
                        azul_layout::window::ScrollMode::Instant,
                    );
                }
                result
            }
            // The runner mounts XML documents; it never invokes a layout
            // callback, which is the only thing a route switch changes.
            CallbackChange::SwitchRoute { .. } => {
                self.unsupported("SwitchRoute", "no layout callback — the runner mounts XML")
            }
        }
509
    }
    /// Port of `PlatformWindow::apply_capi_delete`
    /// (`dll/src/desktop/shell2/common/event.rs`) — the `DeleteBackward` /
    /// `DeleteForward` arms, routed onto the SAME path Backspace and Delete
    /// take.
    ///
    /// This host used to carry the PRE-FIX body the DLL deleted: primary
    /// CURSOR only via `text3::edit::delete_backward` / `delete_forward`, so a
    /// Range selection was invisible to it (it deleted one grapheme next to the
    /// selection's cursor instead of the selection), nothing was recorded for
    /// undo, and the caret kept blinking through the edit. A scenario that
    /// deleted through this host therefore validated semantics no shell has.
    fn apply_capi_delete(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        forward: bool,
    ) -> ProcessEventResult {
        let target = DomNodeId {
            dom: dom_id,
            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
        };
        let now = self.now();
        let lw = &mut self.layout_window;
        if lw.delete_selection(target, forward).is_none() {
            return ProcessEventResult::DoNothing;
        }
        lw.text_edit_manager.blink.reset_blink_on_input(now);
        ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
    }
    /// Shared body of the eight `MoveCursor*` arms (port of the DLL's, which are
    /// the same call with a different closure).
    fn move_cursor(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        extend_selection: bool,
        f: impl FnOnce(
            &azul_layout::text3::cache::UnifiedLayout,
            &azul_core::selection::TextCursor,
        ) -> azul_core::selection::TextCursor,
    ) -> ProcessEventResult {
        let lw = &mut self.layout_window;
        if let Some(new_cursor) = lw.move_cursor_in_node(dom_id, node_id, f) {
            lw.handle_cursor_movement(dom_id, node_id, new_cursor, extend_selection);
        }
        ProcessEventResult::ShouldReRenderCurrentWindow
    }
    /// Record a `CallbackChange` this host cannot apply faithfully, and FAIL the
    /// scenario for it (`run_e2e_test` turns a non-empty list into a red test).
    ///
    /// This is deliberately not a `log_warn` and not a `DoNothing`: an ignored
    /// change makes a scenario that exercises nothing report the same "pass" as
    /// one that exercised everything.
    fn unsupported(&mut self, variant: &str, why: &str) -> ProcessEventResult {
        self.unsupported_changes.push(format!(
            "e2e runner: CallbackChange::{variant} is not supported by the headless runner \
             ({why}) — this scenario cannot be executed faithfully (port the arm from \
             dll/src/desktop/shell2/common/event.rs::apply_user_change)"
        ));
        ProcessEventResult::DoNothing
    }
    /// Port of `common::layout::regenerate_layout` + the headless backend's
    /// render/damage tail: refresh the font snapshot, install the pending mount
    /// document (or keep the already-mounted, possibly-mutated DOM), re-run
    /// layout and render a frame.
158
    fn regenerate_layout(&mut self) {
158
        self.refresh_font_snapshot();
158
        self.layout_window.sync_frame_report();
158
        self.layout_window.frame_report.dom_regenerations =
158
            self.layout_window.frame_report.dom_regenerations.saturating_add(1);
        // E2E `mount` override: replace the DOM wholesale with the test's inline
        // XML+CSS document, but ONLY when the mount is dirty — otherwise keep the
        // already-mounted DOM (with any debug DOM mutations applied to it).
158
        let mount_change = self
158
            .layout_window
158
            .e2e_mount
158
            .take_dirty()
158
            .then(|| self.layout_window.e2e_mount.xml().map(str::to_string));
        // Whether this pass produces a genuinely NEW tree. Only then is there
        // anything to reconcile: the `None` arm below reuses the SAME
        // `StyledDom` (it is taken back out of `layout_results`), so diffing it
        // against itself would be meaningless work.
158
        let mut is_new_tree = false;
158
        let styled_dom = match mount_change {
81
            Some(Some(xml)) => match azul_layout::xml::parse_xml_to_styled_dom(&xml) {
81
                Ok(sd) => {
81
                    is_new_tree = true;
81
                    Some(sd)
                }
                Err(_) => None,
            },
            Some(None) => {
                // `unmount`: drop the mounted document entirely.
                self.layout_window.layout_results.clear();
                self.cpu_backend.previous_display_list = None;
                self.resize_pending = false;
                self.dpi_pending = false;
                return;
            }
77
            None => self
77
                .layout_window
77
                .layout_results
77
                .remove(&DomId::ROOT_ID)
77
                .map(|lr| lr.styled_dom),
        };
158
        let Some(mut styled_dom) = styled_dom else {
57
            self.resize_pending = false;
57
            self.dpi_pending = false;
57
            return;
        };
        // A DPI or size change invalidates every cached rasterisation and every
        // shaped run measured at the old scale.
101
        if self.resize_pending || self.dpi_pending {
6
            self.layout_window.clear_caches();
6
            self.resize_pending = false;
6
            self.dpi_pending = false;
95
        }
        // Step 3.4 of `regenerate_layout`: re-run inheritance + rebuild the
        // compact cache on the composed tree.
101
        styled_dom.recompute_inheritance_and_compact_cache();
        // RECONCILE. This runner is a hand-port of the desktop
        // `regenerate_layout` and, until now, omitted this step entirely — so
        // nothing keyed on `node_moves` (state transfer, manager remap,
        // CSS-override migration, animation) was exercised by ANY headless
        // test. Both implementations now call the same pair.
101
        let now = self.now();
101
        let pending = if is_new_tree {
81
            Some(
81
                self.layout_window
81
                    .begin_reconciliation(DomId::ROOT_ID, &mut styled_dom, now),
81
            )
        } else {
20
            None
        };
101
        self.layout(styled_dom);
        // Last geometry exists now, so pairs complete and enters start.
101
        if let Some(pending) = pending {
81
            self.layout_window
81
                .finish_reconciliation(DomId::ROOT_ID, &pending);
            // REBUILD the display list once, if anything started animating.
            //
            // Ordering makes this unavoidable: the display list is produced by
            // `layout_and_generate_display_list`, but a FLIP cannot be computed
            // until AFTER layout (it needs Last geometry), so the list above was
            // built while no animation keys existed — and the builder only emits
            // `PushReferenceFrame` for a node that HAS a key. Without this pass
            // the per-frame transform updates have nothing to drive and the
            // element jumps to its destination instead of travelling there.
            //
            // Once per transition START, not per frame: every subsequent frame
            // is a pure GPU-key update, which is the property the whole design
            // rests on.
81
            if !self.layout_window.animations.is_empty() {
14
                // DISPLAY LIST only — the solved layout is reused untouched.
14
                // `regenerate_display_list_for_dom` also hands the builder the
14
                // GPU value cache, which is what lets it see the animation keys
14
                // minted a moment ago and emit the reference frames.
14
                self.layout_window
14
                    .regenerate_display_list_for_dom(DomId::ROOT_ID);
67
            }
20
        }
        // Parity with the DLL's `regenerate_layout` tail: a focus parked
        // before the FIRST layout is applied right after the layout that made
        // it resolvable, so the caret is seeded in this very frame.
101
        if self.layout_window.focus_manager.has_deferred_focus_target() {
            self.layout_window.finalize_pending_focus_changes();
101
        }
101
        self.render_and_record();
158
    }
    /// Port of `common::layout::incremental_relayout` + the headless backend's
    /// render/damage tail: re-run layout on the EXISTING (already mutated)
    /// `StyledDom`, then render.
    ///
    /// This is NOT the same as `regenerate_layout()` for an in-place DOM
    /// mutation: `regenerate_layout` short-circuits on
    /// `is_layout_equivalent(old, new)`, and after an in-place mutation "old"
    /// and "new" are the same DOM — so layout would be skipped and the frame
    /// would keep the pre-mutation shaped text and geometry forever.
19
    fn relayout_only(&mut self) {
        // The resize fast path lands here: the pending size change is consumed
        // by re-laying-out the existing StyledDom at the new
        // `self.window_state` size — WITHOUT `clear_caches()`. Keeping the
        // warm shaping/intrinsics caches is the entire point of the fast path
        // (shaping depends on font size and DPI, not on the viewport); DPI
        // changes never come through here (always a full regeneration).
19
        self.resize_pending = false;
19
        if let Some(layout_result) = self.layout_window.layout_results.remove(&DomId::ROOT_ID) {
19
            self.layout(layout_result.styled_dom);
19
        }
19
        self.render_and_record();
19
    }
    /// CPU-render the current frame and publish its damage onto the
    /// `LayoutWindow`, where `CallbackInfo::get_layout_window()` — and therefore
    /// an E2E assertion — can see it.
16674
    fn render_and_record(&mut self) {
        // The scrollbar thumb transform and fade opacity live in the GPU value
        // cache, which the WebRender builders refresh every frame and the CPU
        // path has to refresh by hand. `LayoutWindow::refresh_scrollbar_gpu_cache_for_cpu_frame`
        // says so in its own doc comment ("before `CpuBackend::render_frame`"),
        // and ALL SEVEN DLL platform loops call it — this host did not. So the
        // cache was only ever advanced by a full relayout: `scrollbar_fade_active`
        // never became true, `has_gpu_damage` never became true from a fade, and
        // NO SCROLLBAR FADE WAS OBSERVABLE IN E2E AT ALL. The `full.rs:5285`
        // leak check for "an idle scrollbar'd window re-presenting forever"
        // could not fire either.
        //
        // `prepare_frame_cpu` is the shared per-frame content preparation
        // (journal frame clock + RenderImageCallback invocation through the
        // content chokepoint + that scrollbar refresh). Before it existed this
        // host never invoked image callbacks at all — every callback image
        // rendered as the announced grey placeholder in E2E.
16674
        let gpu_cache_moved = self.layout_window.prepare_frame_cpu();
16674
        let width = self.window_state.size.dimensions.width;
16674
        let height = self.window_state.size.dimensions.height;
        #[allow(clippy::cast_precision_loss)]
16674
        let dpi = self.window_state.size.dpi as f32 / 96.0;
16674
        self.cpu_backend.render_frame(
16674
            &self.layout_window,
16674
            &self.renderer_resources,
16674
            width,
16674
            height,
16674
            dpi,
        );
16674
        let paint = self.cpu_backend.last_frame_damage.clone();
16674
        let present = self.cpu_backend.last_present_damage.clone();
16674
        self.layout_window.record_frame(paint, present);
        // "If any scrollbar is actively fading (0 < opacity < 1), schedule
        // another frame so the fade-out animation runs to completion." — the
        // tail of every DLL present path, ported. See `Runner::pending_redraw`.
        //
        // `gpu_cache_moved` is the extra term the DLL does not need and this
        // host does: the frame that lands the fade on opacity 0.0 clears
        // `scrollbar_fade_active` and still repaints the strip the scrollbar
        // vacated, so stopping on the flag alone leaves the LAST frame carrying
        // damage. A shell does not care (nothing asks it whether it settled);
        // an idleness assertion reads exactly that frame. One more frame after
        // the last change is what makes "settled" observable.
        self.pending_redraw =
16674
            self.layout_window.gpu_state_manager.scrollbar_fade_active || gpu_cache_moved;
        // Publish the DAMAGE-DRIVEN framebuffer so `assert_damage_sound`'s
        // `pixel_identity` check can compare it against an independent full
        // repaint (`CallbackInfo::take_screenshot`). Only this host can: the DLL
        // presents from the GPU, which is why the op FAILS there rather than
        // silently skipping the check.
        #[cfg(feature = "cpurender")]
16674
        if let Some(frame) = self.cpu_backend.last_frame.as_ref() {
16674
            super::full::e2e_set_presented_frame(&self.layout_window, frame);
16674
        }
16674
    }
    /// Port of the font-snapshot block at the top of `regenerate_layout`: the
    /// window's font cache is re-installed from the async registry (or from the
    /// app-level cache when there is none) before every DOM regeneration.
158
    fn refresh_font_snapshot(&mut self) {
        #[cfg(feature = "font_async_registry")]
158
        if let Some(registry) = self.font_registry.as_ref() {
            // Avoid replacing a complete font cache with an incomplete snapshot
            // while the background builder threads are still parsing fonts.
158
            let current_cache_empty = self.layout_window.font_manager.fc_cache.is_empty();
158
            let build_complete = registry.is_build_complete();
158
            if current_cache_empty || build_complete {
158
                let font_stacks = rust_fontconfig::config::tokenize_common_families(
158
                    rust_fontconfig::OperatingSystem::current(),
158
                );
158
                registry.request_fonts(&font_stacks);
158
                self.layout_window
158
                    .font_manager
158
                    .replace_fc_cache(registry.shared_cache());
158
            }
158
            return;
        }
        // Fallback: use the app-level cache directly.
        self.layout_window
            .font_manager
            .replace_fc_cache(self.app_fc_cache.clone());
158
    }
    /// Port of the DLL event loop's keyboard-default-action pass: Tab →
    /// FocusNext/Previous, Escape → ClearFocus. Runs once per pass that saw a
    /// `KeyDown`, which is the DLL's `has_key_event` gate.
    ///
    /// Returns `(result, focus_changed)`; the caller uses `focus_changed` to
    /// decide whether to dispatch Blur/Focus and re-enter the pass.
14
    fn run_keyboard_default_action(&mut self) -> (ProcessEventResult, bool) {
        use azul_core::events::DefaultAction;
        use azul_layout::default_actions::{
            default_action_to_focus_target, determine_keyboard_default_action_with_editing,
        };
        use azul_layout::managers::focus_cursor::resolve_focus_target;
14
        let ks = self.window_state.keyboard_state.clone();
14
        let focused = self.layout_window.focus_manager.get_focused_node().copied();
14
        let editing_state = self.layout_window.build_editing_query_state(focused);
14
        let action = determine_keyboard_default_action_with_editing(
14
            &ks,
14
            focused,
14
            &self.layout_window.layout_results,
            false,
14
            editing_state.as_ref(),
        );
14
        if !action.has_action() {
            return (ProcessEventResult::DoNothing, false);
14
        }
14
        match &action.action {
            DefaultAction::FocusNext
            | DefaultAction::FocusPrevious
            | DefaultAction::FocusFirst
            | DefaultAction::FocusLast => {
13
                let Some(target) = default_action_to_focus_target(&action.action) else {
                    return (ProcessEventResult::DoNothing, false);
                };
13
                let Ok(resolved) =
13
                    resolve_focus_target(&target, &self.layout_window.layout_results, focused)
                else {
                    return (ProcessEventResult::DoNothing, false);
                };
13
                if resolved == focused {
                    return (ProcessEventResult::DoNothing, false);
13
                }
13
                (self.set_focus(resolved, focused), true)
            }
            DefaultAction::ClearFocus => {
1
                if focused.is_none() {
                    return (ProcessEventResult::DoNothing, false);
1
                }
1
                (self.set_focus(None, focused), true)
            }
            DefaultAction::InsertLineBreakAtCursor { target } => {
                // Same as the DLL shells: plain-text Enter records a literal
                // "\n" and applies it directly (the apply tail already ran
                // this pass). Veto is honored by the !prevent_default gate
                // around default actions.
                if let Some(node_id) = target.node.into_crate_internal() {
                    let old_inline = self
                        .layout_window
                        .get_text_before_textinput(target.dom, node_id);
                    let old_text = self
                        .layout_window
                        .extract_text_from_inline_content(&old_inline);
                    use crate::managers::text_input::TextInputSource;
                    self.layout_window.text_input_manager.record_input(
                        *target,
                        "\n".to_string(),
                        old_text,
                        TextInputSource::Keyboard,
                    );
                    let changeset_result = self.layout_window.apply_text_changeset();
                    let mut r = ProcessEventResult::DoNothing;
                    if !changeset_result.dirty_nodes.is_empty() {
                        r = if changeset_result.needs_relayout {
                            ProcessEventResult::ShouldIncrementalRelayout
                        } else {
                            ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
                        };
                        self.layout_window.scroll_selection_into_view(
                            azul_layout::window::SelectionScrollType::Cursor,
                            azul_layout::window::ScrollMode::Instant,
                        );
                    }
                    // Applied outside the record pipeline's event window —
                    // owe the host its Input dispatch (drained at pass tail).
                    self.layout_window
                        .text_edit_manager
                        .pending_edit_notifications
                        .push(*target);
                    (r, false)
                } else {
                    (ProcessEventResult::DoNothing, false)
                }
            }
            DefaultAction::SplitBlockAtCursor { .. }
            | DefaultAction::MergeWithPrevious { .. }
            | DefaultAction::MergeWithNext { .. } => {
                // Same one-liner as the DLL shells: structural edits record;
                // a materialized preview paints on the next relayout.
                if self
                    .layout_window
                    .record_structural_default_action(&action.action)
                    .is_some()
                {
                    (ProcessEventResult::ShouldIncrementalRelayout, false)
                } else {
                    (ProcessEventResult::DoNothing, false)
                }
            }
            _ => (ProcessEventResult::DoNothing, false),
        }
14
    }
    /// Publish layout's scroll containers into the ScrollManager.
    ///
    /// This used to be a hand-maintained PORT of the dll's copy, so a scroll
    /// bug could be fixed in one host and left standing in the other. Both
    /// call the same function now.
120
    fn register_scroll_nodes(&mut self) {
120
        let now = self.now();
120
        crate::managers::scroll_registration::register_scroll_nodes(
120
            &mut self.layout_window,
120
            &now,
        );
120
    }
}
/// Port of the DLL's `apply_focus_restyle` (`.../common/event.rs`): apply the
/// `:focus` / `:focus-within` state change to the styled DOM and classify how
/// much work the resulting property deltas need.
///
/// Without this a click (or a Tab) moved focus but left the node painted
/// unfocused until the next full DOM regeneration.
18
fn apply_focus_restyle(
18
    layout_window: &mut LayoutWindow,
18
    old_focus: Option<NodeId>,
18
    new_focus: Option<NodeId>,
18
) -> ProcessEventResult {
    use azul_core::{diff::ChangeAccumulator, styled_dom::FocusChange};
18
    let Some((_, layout_result)) = layout_window.layout_results.iter_mut().next() else {
        return ProcessEventResult::ShouldReRenderCurrentWindow;
    };
18
    let restyle_result = layout_result.styled_dom.restyle_on_state_change(
18
        Some(FocusChange {
18
            lost_focus: old_focus,
18
            gained_focus: new_focus,
18
        }),
18
        None, // hover
18
        None, // active
    );
18
    if restyle_result.changed_nodes.is_empty() || restyle_result.gpu_only_changes {
18
        return ProcessEventResult::ShouldReRenderCurrentWindow;
    }
    let mut accumulator = ChangeAccumulator::new();
    accumulator.merge_restyle_result(&restyle_result);
    if accumulator.needs_layout() {
        ProcessEventResult::ShouldIncrementalRelayout
    } else if accumulator.needs_paint_only() {
        ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
    } else {
        ProcessEventResult::ShouldReRenderCurrentWindow
    }
18
}
/// Port of the caret half of the DLL's `SystemChange::SetFocus` /
/// `CallbackChange::SetFocusTarget` handling.
///
/// `handle_focus_change_for_cursor_blink` is what FLAGS a contenteditable focus
/// for caret initialisation; `finalize_pending_focus_changes` (already called at
/// the end of every pass) is what turns that flag into a real cursor. The runner
/// called only the second one, so the flag was never set, no cursor was ever
/// created, and `text_input` went through `record_text_input` (which only needs a
/// focused node) into `apply_text_changeset` (which needs a CURSOR) and produced
/// zero dirty nodes — a silent no-op with focus in place.
///
/// The returned `CursorBlinkTimerAction` is HONOURED: this host now has a timer
/// driver ([`Runner::pump_timers`]), so focusing a contenteditable really
/// registers `CURSOR_BLINK_TIMER_ID` and leaving one really removes it. The
/// action used to be dropped on the floor with "no timer driver, the caret is
/// drawn steady instead of blinking", which made caret blink untestable.
///
/// The DLL splits this over two seams — the platform trait's
/// `start_timer` / `stop_timer` arm the OS wakeup, and
/// `CallbackChange::StartCursorBlinkTimer` is what inserts the `Timer` into
/// `LayoutWindow::timers`. Here the two are the same thing: `timers` IS the
/// driver, so `Start` inserts and `Stop` removes, exactly as the DLL's
/// `StartCursorBlinkTimer` / `StopCursorBlinkTimer` arms do.
22
fn arm_caret_for_focus(
22
    layout_window: &mut LayoutWindow,
22
    new_focus: Option<DomNodeId>,
22
    window_state: &FullWindowState,
22
) {
    use azul_core::task::CURSOR_BLINK_TIMER_ID;
    use azul_layout::CursorBlinkTimerAction;
22
    match layout_window.handle_focus_change_for_cursor_blink(new_focus, window_state) {
2
        CursorBlinkTimerAction::Start(timer) => {
2
            layout_window.add_timer(CURSOR_BLINK_TIMER_ID, timer);
2
        }
        CursorBlinkTimerAction::Restart(timer) => {
            layout_window.remove_timer(&CURSOR_BLINK_TIMER_ID);
            layout_window.add_timer(CURSOR_BLINK_TIMER_ID, timer);
        }
1
        CursorBlinkTimerAction::Stop => {
1
            layout_window.remove_timer(&CURSOR_BLINK_TIMER_ID);
1
        }
19
        CursorBlinkTimerAction::NoChange => {}
    }
22
}
/// Port of the DLL's `apply_hover_restyle` (`.../common/event.rs`): apply this
/// pass's MouseEnter / MouseLeave targets to the styled DOM so pure-CSS
/// `:hover` rules take effect without a DOM regeneration.
4
fn apply_hover_restyle(
4
    layout_window: &mut LayoutWindow,
4
    changes_per_dom: BTreeMap<DomId, azul_core::styled_dom::HoverChange>,
4
) -> ProcessEventResult {
    use azul_core::diff::ChangeAccumulator;
4
    let mut result = ProcessEventResult::DoNothing;
8
    for (dom_id, hover_change) in changes_per_dom {
4
        let Some(layout_result) = layout_window.layout_results.get_mut(&dom_id) else {
            continue;
        };
4
        let restyle_result =
4
            layout_result
4
                .styled_dom
4
                .restyle_on_state_change(None, Some(hover_change), None);
4
        if restyle_result.changed_nodes.is_empty() {
4
            continue;
        }
        let r = if restyle_result.gpu_only_changes {
            ProcessEventResult::ShouldReRenderCurrentWindow
        } else {
            let mut accumulator = ChangeAccumulator::new();
            accumulator.merge_restyle_result(&restyle_result);
            if accumulator.needs_layout() {
                ProcessEventResult::ShouldIncrementalRelayout
            } else if accumulator.needs_paint_only() {
                ProcessEventResult::ShouldUpdateDisplayListCurrentWindow
            } else {
                ProcessEventResult::ShouldReRenderCurrentWindow
            }
        };
        result = result.max(r);
    }
4
    result
4
}
/// Port of `parse_node_type_from_str` (dll/.../common/event.rs) — the `insert_node`
/// op's `node_type` string (`"div"`, `"p"`, `"text:HELLO"`, …) → `NodeType`.
2
fn parse_node_type_from_str(s: &str) -> azul_core::dom::NodeType {
    use azul_core::dom::NodeType;
2
    if let Some(text) = s.strip_prefix("text:") {
1
        return NodeType::Text(azul_css::css::BoxOrStatic::heap(text.to_string().into()));
1
    }
1
    match s.to_lowercase().as_str() {
1
        "html" => NodeType::Html,
1
        "head" => NodeType::Head,
1
        "body" => NodeType::Body,
1
        "p" => NodeType::P,
1
        "article" => NodeType::Article,
1
        "section" => NodeType::Section,
1
        "nav" => NodeType::Nav,
1
        "aside" => NodeType::Aside,
1
        "header" => NodeType::Header,
1
        "footer" => NodeType::Footer,
1
        "main" => NodeType::Main,
1
        "h1" => NodeType::H1,
1
        "h2" => NodeType::H2,
1
        "h3" => NodeType::H3,
1
        "h4" => NodeType::H4,
1
        "h5" => NodeType::H5,
1
        "h6" => NodeType::H6,
1
        "br" => NodeType::Br,
1
        "hr" => NodeType::Hr,
1
        "pre" => NodeType::Pre,
1
        "blockquote" => NodeType::BlockQuote,
1
        "ul" => NodeType::Ul,
1
        "ol" => NodeType::Ol,
1
        "li" => NodeType::Li,
1
        "table" => NodeType::Table,
1
        "thead" => NodeType::THead,
1
        "tbody" => NodeType::TBody,
1
        "tr" => NodeType::Tr,
1
        "th" => NodeType::Th,
1
        "td" => NodeType::Td,
1
        "form" => NodeType::Form,
1
        "label" => NodeType::Label,
1
        "input" => NodeType::Input,
1
        "button" => NodeType::Button,
1
        _ => NodeType::Div,
    }
2
}
fn fail_result(test: &E2eTest, reason: &str) -> E2eTestResult {
    E2eTestResult {
        name: test.name.clone(),
        status: "fail".into(),
        duration_ms: 0,
        step_count: test.steps.len(),
        steps_passed: 0,
        steps_failed: test.steps.len(),
        steps: Vec::new(),
        final_screenshot: Some(format!("[runner] {reason}")),
    }
}
/// Run a single E2E JSON test end-to-end through the REAL server op-dispatch,
/// headlessly. Returns the server's own [`E2eTestResult`] (pass/fail + per-step
/// results) — the same value the HTTP `run_e2e_tests` command produces.
#[must_use]
57
pub fn run_e2e_test(test: &E2eTest) -> E2eTestResult {
57
    if std::env::var_os("AZ_ANIM_DEBUG").is_some() {
        eprintln!("[scenario] {}", test.name);
57
    }
    // Start this scenario on a clean clock. The `tick_ms` / `wait` ops advance a
    // clock scoped to the calling thread, and worker threads are reused across
    // scenarios — without this reset the next scenario scheduled onto this
    // thread would start with the previous one's accumulated offset.
57
    azul_core::task::reset_test_clock();
    // ...and then STOP real time for this thread, so engine time is a pure
    // function of the ops this scenario runs. Otherwise elapsed time is
    // (what the scenario asked for) + (what this build, under this load, spent
    // computing), and the suite runs scenarios 8-wide: that second term is large
    // and varies run to run, which is enough to flip an assertion on a blinking
    // caret's phase while the same scenario passes 10/10 in isolation.
    //
    // Only the ENGINE clock stops. The harness keeps measuring itself with
    // `wall_clock_now()`, so reported step durations stay real.
57
    azul_core::task::freeze_test_clock();
    // This scenario's own scheduler slot. It is a LOCAL, not a `Runner` field,
    // only because `Runner::with_callback_info` takes `&mut self` and the
    // dispatcher needs `&mut` on the session at the same time — borrowck, not
    // ambient state. It has exactly the lifetime of this run.
57
    let mut session = E2eSession::new();
57
    let (w, h, dpi, animations) = match &test.setup {
57
        Some(s) => (
57
            s.window_width as f32,
57
            s.window_height as f32,
57
            s.dpi,
57
            s.animations,
57
        ),
        None => (800.0, 600.0, 96, false),
    };
57
    let mut runner = Runner::new(w, h, dpi, animations);
57
    let (tx, rx) = std::sync::mpsc::channel();
57
    let request = DebugRequest {
57
        request_id: 1,
57
        event: DebugEvent::RunE2eTests { tests: vec![test.clone()], snapshots: None },
57
        window_id: None,
57
        wait_for_render: false,
57
        response_tx: tx,
57
    };
57
    let mut app_data = RefAny::new(());
57
    let component_map = Arc::new(Mutex::new(ComponentMap::default()));
57
    let callback_changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
    // First dispatch: RunE2eTests sets up the continuation and runs it until the
    // first yield (or completion).
57
    let needs_update = runner.with_callback_info(&callback_changes, |ci| {
57
        process_debug_event(&request, ci, &mut app_data, &component_map, &mut session)
57
    });
57
    runner.service(&callback_changes, needs_update);
    // Pump the continuation until it terminates (the result is sent on the final
    // resume). A generous cap guards against a non-terminating scenario.
57
    let mut iterations = 0usize;
    loop {
476
        let (needs_update, still_pending, resume_not_before) = runner
476
            .with_callback_info(&callback_changes, |ci| {
476
                e2e_pump_continuation(ci, &mut session)
476
            });
        // `resume_not_before` is never set to `Some` anywhere in the tree: a
        // `wait` yields with no deadline and advances the injectable clock
        // instead, so scenario time is a pure function of the ops a scenario ran
        // rather than of how fast the build is.
        //
        // This used to `std::thread::sleep` to the deadline. That is now
        // unreachable, and leaving it would be a landmine: the moment anything
        // repopulated the field the whole suite would silently go back to being
        // pinned to realtime — the exact regression that made
        // `bug_font_never_removed` red only on unoptimized builds. It would also
        // reintroduce a `std::time::Instant::now()` here, which panics on
        // wasm32.
        //
        // So it fails loudly instead. If you are here because this fired, the
        // fix is to advance the test clock (`advance_test_clock_ms`), not to
        // sleep.
476
        assert!(
476
            resume_not_before.is_none(),
            "e2e runner: scenario '{}' asked to resume at a wall-clock deadline. Scenario time \
             is virtual — advance the injectable clock instead of sleeping, or the suite is \
             pinned to realtime again.",
            test.name,
        );
476
        runner.service(&callback_changes, needs_update);
476
        if !still_pending {
57
            break;
419
        }
419
        iterations += 1;
419
        assert!(
419
            iterations < 100_000,
            "e2e runner: continuation for '{}' did not terminate",
            test.name
        );
    }
57
    let result = match rx.try_recv() {
57
        Ok(DebugResponseData::Ok { data: Some(ResponseData::E2eResults(r)), .. }) => r
57
            .results
57
            .into_iter()
57
            .next()
57
            .unwrap_or_else(|| fail_result(test, "RunE2eTests returned no results")),
        Ok(DebugResponseData::Ok { .. }) => {
            fail_result(test, "RunE2eTests returned a non-E2eResults response")
        }
        Ok(DebugResponseData::Err(e)) => fail_result(test, &e),
        Err(_) => fail_result(test, "RunE2eTests produced no response"),
    };
    // A scenario that asked the engine for something this host cannot do is
    // RED, no matter what its assertions said: they were evaluated against a
    // window where that something never happened. Reported per unsupported
    // change, by name — see `Runner::unsupported`.
57
    unsupported_to_failure(result, &runner.unsupported_changes)
57
}
/// Fold the runner's unsupported-change log into the scenario result, turning a
/// pass that skipped work into a named failure.
57
fn unsupported_to_failure(mut result: E2eTestResult, unsupported: &[String]) -> E2eTestResult {
57
    if unsupported.is_empty() {
57
        return result;
    }
    // Deduplicate: one line per distinct facility, not one per applied change.
    let mut seen: Vec<&String> = Vec::new();
    for u in unsupported {
        if !seen.contains(&u) {
            seen.push(u);
        }
    }
    let next_index = result.steps.len();
    for (i, message) in seen.iter().enumerate() {
        result.steps.push(E2eStepResult {
            step_index: next_index + i,
            op: "unsupported_callback_change".to_string(),
            status: "fail".to_string(),
            duration_ms: 0,
            logs: Vec::new(),
            screenshot: None,
            error: Some((*message).clone()),
            response: None,
        });
    }
    result.status = "fail".to_string();
    result.steps_failed += seen.len();
    result.step_count = result.steps.len();
    result
57
}
// ── Un-fork pins ─────────────────────────────────────────────────────────────
//
// This host is a PORT of the shells, not a second implementation, so every
// place it re-derived behaviour instead of calling the engine is a place where
// a scenario could go green on semantics no user has. These pin the three that
// had actually drifted.
#[cfg(test)]
mod tests {
    use azul_core::{
        callbacks::{CaretTweenInfo, Update},
        dom::{Dom, NodeId as CoreNodeId},
        events::EventFilter,
        geom::LogicalRect,
        refany::RefAny,
        selection::{CursorAffinity, GraphemeClusterId, SelectionRange, TextCursor},
        task::{advance_test_clock_ms, freeze_test_clock, reset_test_clock, Duration},
        window::{VirtualKeyCode, VirtualKeyCodeVec},
    };
    use azul_layout::{
        callbacks::{CallbackInfo, CallbackType},
        solver3::display_list::DisplayListItem,
    };
    use super::*;
    /// body = 0, div (contenteditable) = 1, text = 2.
    const EDITOR: usize = 1;
    const CSS: &str = "* { margin: 0; padding: 0; } \
                       body { font-size: 16px; width: 600px; }";
    fn cursor(byte: u32) -> azul_core::selection::TextCursor {
        TextCursor {
            cluster_id: GraphemeClusterId {
                source_run: 0,
                start_byte_in_run: byte,
            },
            affinity: CursorAffinity::Leading,
        }
    }
    fn editor_node() -> DomNodeId {
        DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(EDITOR))),
        }
    }
    /// A runner with one contenteditable div laid out and an editing session on
    /// it — the shape every text scenario mounts.
    fn editor_runner(content: &str, animations: bool, on_key_down: Option<CallbackType>) -> Runner {
        reset_test_clock();
        freeze_test_clock();
        let mut editor = Dom::create_div()
            .with_contenteditable(true)
            .with_child(Dom::create_text_do_not_use_without_block_level_wrapper(
                content,
            ));
        if let Some(cb) = on_key_down {
            editor = editor.with_callback(
                EventFilter::Focus(azul_core::events::FocusEventFilter::VirtualKeyDown),
                RefAny::new(()),
                cb as usize,
            );
        }
        let mut dom = Dom::create_body().with_child(editor);
        let (css, _) = azul_css::parser2::new_from_str(CSS);
        let styled_dom = StyledDom::create(&mut dom, css);
        let mut runner = Runner::new(800.0, 600.0, 96, animations);
        runner.layout(styled_dom);
        runner
            .layout_window
            .focus_manager
            .set_focused_node(Some(editor_node()));
        runner.layout_window.text_edit_manager.initialize_editing(
            cursor(0),
            DomId::ROOT_ID,
            NodeId::new(EDITOR),
            0,
        );
        runner.layout_window.text_edit_manager.blink.set_visibility(true);
        runner
            .layout_window
            .regenerate_display_list_for_dom(DomId::ROOT_ID);
        runner
    }
    fn text_of(runner: &Runner) -> String {
        let content = runner
            .layout_window
            .get_text_before_textinput(DomId::ROOT_ID, NodeId::new(EDITOR));
        runner
            .layout_window
            .extract_text_from_inline_content(&content)
    }
    /// The LAST `CursorRect` item = the primary caret (the rule the tween
    /// post-pass itself uses).
    fn caret_rect(runner: &Runner) -> LogicalRect {
        runner
            .layout_window
            .get_layout_result(&DomId::ROOT_ID)
            .expect("layout result")
            .display_list
            .items
            .iter()
            .rev()
            .find_map(|item| match item {
                DisplayListItem::CursorRect { bounds, .. } => Some(bounds.0),
                _ => None,
            })
            .expect("the display list carries a caret")
    }
    fn no_changes() -> Arc<Mutex<Vec<CallbackChange>>> {
        Arc::new(Mutex::new(Vec::new()))
    }
    /// Press a printable key the way a shell does: RECORD the text into the
    /// changeset first, then run the state-diff pass (this is what the
    /// `key_down` op's `text` parameter drives).
    fn press_key_with_text(runner: &mut Runner, key: VirtualKeyCode, text: &str) {
        use azul_layout::managers::text_input::PendingTextEdit;
        let focused = runner
            .layout_window
            .focus_manager
            .get_focused_node()
            .copied()
            .expect("a focused node");
        let node_id = focused.node.into_crate_internal().expect("a real node");
        let old_inline = runner
            .layout_window
            .get_text_before_textinput(focused.dom, node_id);
        let old_text = runner
            .layout_window
            .extract_text_from_inline_content(&old_inline);
        let _ = runner.apply_user_change(&CallbackChange::SetTextChangeset {
            changeset: PendingTextEdit {
                node: focused,
                inserted_text: text.into(),
                old_text: old_text.into(),
            },
        });
        let mut state = runner.window_state.clone();
        state.keyboard_state.current_virtual_keycode = Some(key).into();
        state.keyboard_state.pressed_virtual_keycodes = VirtualKeyCodeVec::from_vec(vec![key]);
        let _ = runner.apply_user_change(&CallbackChange::ModifyWindowState { state });
    }
    extern "C" fn veto_key_down(_data: RefAny, mut info: CallbackInfo) -> Update {
        info.prevent_default();
        Update::DoNothing
    }
    extern "C" fn observe_key_down(_data: RefAny, _info: CallbackInfo) -> Update {
        Update::DoNothing
    }
    // ── 1. The C-API delete arms ─────────────────────────────────────────────
    #[test]
    fn capi_delete_backward_deletes_the_whole_selection() {
        let mut runner = editor_runner("hello world", false, None);
        runner
            .layout_window
            .text_edit_manager
            .multi_cursor
            .as_mut()
            .expect("editing session")
            .set_single_range(SelectionRange {
                start: cursor(0),
                end: cursor(6),
            });
        let _ = runner.apply_user_change(&CallbackChange::DeleteBackward {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(EDITOR),
        });
        // The pre-fix body deleted ONE grapheme at the range's cursor
        // (`get_primary_cursor` answers `range.end`), leaving "hellworld".
        assert_eq!(
            text_of(&runner),
            "world",
            "the whole range goes, not one grapheme at the primary cursor"
        );
    }
    #[test]
    fn capi_delete_forward_deletes_the_whole_selection() {
        let mut runner = editor_runner("hello world", false, None);
        runner
            .layout_window
            .text_edit_manager
            .multi_cursor
            .as_mut()
            .expect("editing session")
            .set_single_range(SelectionRange {
                start: cursor(0),
                end: cursor(6),
            });
        let _ = runner.apply_user_change(&CallbackChange::DeleteForward {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(EDITOR),
        });
        assert_eq!(text_of(&runner), "world");
    }
    #[test]
    fn capi_delete_records_undo_and_holds_the_caret_solid() {
        let mut runner = editor_runner("hello world", false, None);
        // A caret, not a range: the undo record and the blink reset are owed to
        // every delete, not only to the selection case.
        runner
            .layout_window
            .text_edit_manager
            .multi_cursor
            .as_mut()
            .expect("editing session")
            .set_single_cursor(cursor(5));
        runner.layout_window.text_edit_manager.blink.set_visibility(false);
        assert!(
            !runner
                .layout_window
                .undo_redo_manager
                .can_undo(CoreNodeId::new(EDITOR)),
            "premise: nothing is undoable before the delete"
        );
        let _ = runner.apply_user_change(&CallbackChange::DeleteBackward {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(EDITOR),
        });
        assert_eq!(text_of(&runner), "hell world");
        assert!(
            runner
                .layout_window
                .undo_redo_manager
                .can_undo(CoreNodeId::new(EDITOR)),
            "a delete is an undoable edit — the pre-fix body recorded nothing"
        );
        assert!(
            runner.layout_window.text_edit_manager.blink.is_visible,
            "editing keeps the caret solid, same as typing"
        );
    }
    // ── 2. Tweens are reachable, and deterministic on the virtual clock ──────
    #[test]
    fn animations_off_lands_the_caret_immediately() {
        let mut runner = editor_runner("hello world", false, None);
        let before = caret_rect(&runner);
        let _ = runner.apply_user_change(&CallbackChange::MoveCursor {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(EDITOR),
            cursor: cursor(6),
        });
        assert!(
            runner.layout_window.text_edit_manager.tween.caret.is_none(),
            "`setup.animations` defaults to off, so no tween is ever armed"
        );
        assert!(
            (caret_rect(&runner).origin.x - before.origin.x).abs() > 1.0,
            "premise: byte 6 is a different x from byte 0"
        );
    }
    #[test]
    fn animations_on_tweens_the_caret_on_the_virtual_clock() {
        const STEP_MS: u64 = 20;
        const DURATION_MS: u64 = 60;
        let mut runner = editor_runner("hello world", true, None);
        let changes = no_changes();
        let from = caret_rect(&runner);
        let _ = runner.apply_user_change(&CallbackChange::MoveCursor {
            dom_id: DomId::ROOT_ID,
            node_id: NodeId::new(EDITOR),
            cursor: cursor(6),
        });
        let track = runner
            .layout_window
            .text_edit_manager
            .tween
            .caret
            .clone()
            .expect("`setup.animations: true` arms the caret tween");
        assert_eq!(track.from, from, "the tween starts from the RENDERED rect");
        let to = track.to;
        assert!(
            (to.origin.x - from.origin.x).abs() > 1.0,
            "premise: the caret really moved"
        );
        assert_eq!(
            caret_rect(&runner),
            from,
            "at t = 0 the caret is still painted where it was — the move is a glide, not a jump"
        );
        // The driver timer is armed by the frame tail, exactly like the shells'.
        runner.service(&changes, false);
        assert!(
            runner
                .layout_window
                .timers
                .contains_key(&azul_core::task::CARET_TWEEN_TIMER_ID),
            "an in-flight tween arms its 16ms driver"
        );
        // Fixed steps on the FROZEN clock: the geometry at step k is a pure
        // function of k, so this asserts a number, not a race.
        for step in 1..=2u64 {
            let _ = advance_test_clock_ms(STEP_MS);
            runner.service(&changes, false);
            let t = Duration::from_millis(STEP_MS * step).div(&Duration::from_millis(DURATION_MS));
            let expected = (azul_core::resources::SystemAnimations::default().caret_tween.cb)(
                RefAny::new(()),
                CaretTweenInfo {
                    past: from,
                    current: to,
                    t,
                },
            );
            assert_eq!(
                caret_rect(&runner),
                expected,
                "step {step}: the caret sits at the interpolator's answer for t = {t}"
            );
            assert_ne!(caret_rect(&runner), to, "step {step}: still in flight");
        }
        let _ = advance_test_clock_ms(STEP_MS);
        runner.service(&changes, false);
        assert_eq!(
            caret_rect(&runner),
            to,
            "the tween lands exactly on the target at its duration"
        );
        assert!(
            runner.layout_window.text_edit_manager.tween.caret.is_none(),
            "and retires itself"
        );
    }
    // ── 3. One text ingress: the shells' record-then-pass ────────────────────
    #[test]
    fn the_shell_ingress_lands_the_text() {
        let mut runner = editor_runner("ab", false, Some(observe_key_down));
        press_key_with_text(&mut runner, VirtualKeyCode::X, "X");
        assert_eq!(
            text_of(&runner),
            "Xab",
            "text recorded before the pass is applied BY the pass — the stage this host \
             did not have"
        );
    }
    /// The op wiring, end to end through `process_debug_event`: `key_down`
    /// carrying `text` must record BEFORE the pass and the pass must land it.
    /// The tests above drive `apply_user_change` directly and so cannot see a
    /// mistake in the op itself.
    #[test]
    fn the_key_down_op_types_through_the_shell_ingress() {
        let test: super::E2eTest = serde_json::from_value(serde_json::json!({
            "name": "key_down_text_ingress",
            "setup": { "window_width": 400, "window_height": 200, "dpi": 96 },
            "steps": [
                { "op": "mount",
                  "html": ["<div id=\"ed\" contenteditable=\"true\">ab</div>"],
                  "css": ["html, body { margin: 0; padding: 0; }",
                          "body { font-size: 24px; color: black; background: white; }",
                          "#ed { width: 300px; height: 60px; background: white; }"] },
                { "op": "wait_frame" },
                { "op": "focus_node", "selector": "#ed" },
                { "op": "wait_frame" },
                { "op": "key_down", "key": "x", "text": "X" },
                { "op": "key_up", "key": "x" },
                { "op": "wait_frame" },
                { "op": "assert_text", "selector": "#ed", "expected": "abX" }
            ]
        }))
        .expect("scenario json");
        let result = run_e2e_test(&test);
        assert_eq!(
            result.status, "pass",
            "the key_down text ingress must type at the caret focus_node seeded (end of text): {:#?}",
            result.steps
        );
    }
    #[test]
    fn a_keydown_veto_kills_the_recorded_text() {
        let mut runner = editor_runner("ab", false, Some(veto_key_down));
        press_key_with_text(&mut runner, VirtualKeyCode::X, "X");
        assert_eq!(
            text_of(&runner),
            "ab",
            "a KeyDown callback's prevent_default() vetoes the insertion, exactly as on a \
             real platform"
        );
        assert!(
            runner
                .layout_window
                .text_input_manager
                .get_pending_changeset()
                .is_none(),
            "the vetoed record dies now — surviving into the next pass would land it late"
        );
    }
}