Skip to main content

azul_layout/
window.rs

1//! Window layout management for solver3/text3
2//!
3//! This module provides the high-level API for managing layout
4//! state across frames, including caching, incremental updates,
5//! and display list generation.
6//!
7//! The main entry point is `LayoutWindow`, which encapsulates all
8//! the state needed to perform layout and maintain consistency
9//! across window resizes and DOM updates.
10//!
11//! Key subsystems managed by `LayoutWindow`:
12//! - **Text editing**: cursor/selection management, IME preedit,
13//!   undo/redo, and incremental text relayout
14//! - **Accessibility**: tree construction and incremental updates
15//!   for screen readers via accesskit
16//! - **VirtualView**: callback invocation and recursive layout for
17//!   virtualized scrollable content
18//! - **Scrolling**: scroll state, scrollbar opacity, and
19//!   scroll-into-view for cursors and selections
20
21use std::{
22    collections::{BTreeMap, BTreeSet, HashMap},
23    sync::{
24        atomic::{AtomicUsize, Ordering},
25        Arc,
26    },
27};
28
29use azul_core::{
30    resources::UpdateImageType,
31    callbacks::{FocusTarget, HidpiAdjustedBounds, VirtualViewCallbackReason, Update},
32    dom::{
33        AccessibilityAction, AttributeType, Dom, DomId, DomIdVec, DomNodeId, NodeId, NodeType, On,
34    },
35    events::{EasingFunction, EventFilter, FocusEventFilter, HoverEventFilter},
36    geom::{LogicalPosition, LogicalRect, LogicalSize, OptionLogicalPosition},
37    gl::OptionGlContextPtr,
38    gpu::{GpuScrollbarOpacityEvent, GpuValueCache},
39    hit_test::{DocumentId, ScrollPosition, ScrollbarHitId},
40    refany::{OptionRefAny, RefAny},
41    resources::{
42        Epoch, FontKey, GlTextureCache, IdNamespace, ImageCache, ImageMask, ImageRef, ImageRefHash,
43        OpacityKey, RendererResources,
44    },
45    selection::{
46        CursorAffinity, GraphemeClusterId, Selection, SelectionAnchor, SelectionFocus,
47        SelectionRange, SelectionState, TextCursor, TextSelection,
48    },
49    styled_dom::{
50        collect_nodes_in_document_order, is_before_in_document_order, NodeHierarchyItemId,
51        StyledDom,
52    },
53    task::{
54        Duration, Instant, SystemTickDiff, SystemTimeDiff, TerminateTimer, ThreadId, ThreadIdVec,
55        ThreadSendMsg, TimerId, TimerIdVec,
56    },
57    window::{CursorPosition, MonitorVec, RawWindowHandle, RendererType},
58    FastBTreeSet, OrderedMap,
59};
60use azul_css::{
61    css::Css,
62    props::{
63        basic::FontRef,
64        property::{CssProperty, CssPropertyVec},
65    },
66    AzString, LayoutDebugMessage, OptionString,
67};
68use rust_fontconfig::FcFontCache;
69
70#[cfg(feature = "icu")]
71use crate::icu::IcuLocalizerHandle;
72use crate::{
73    callbacks::{
74        Callback, ExternalSystemCallbacks, MenuCallback,
75    },
76    managers::{
77        gpu_state::GpuStateManager,
78        virtual_view::VirtualViewManager,
79        scroll_state::ScrollManager,
80    },
81    solver3::{
82        self, cache::LayoutCache as Solver3LayoutCache, display_list::DisplayList,
83        layout_tree::LayoutTree,
84    },
85    text3::{
86        cache::{
87            FontManager, FontSelector, FontStyle, InlineContent, TextShapingCache as TextLayoutCache,
88            LayoutError, ShapedItem, StyleProperties, StyledRun, UnifiedConstraints,
89            UnifiedLayout,
90        },
91        default::PathLoader,
92    },
93    thread::{OptionThreadReceiveMsg, Thread, ThreadReceiveMsg, ThreadWriteBackMsg},
94    timer::Timer,
95    window_state::{FullWindowState, WindowCreateOptions},
96};
97
98// Global atomic counters for generating unique IDs
99static DOCUMENT_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
100static ID_NAMESPACE_COUNTER: AtomicUsize = AtomicUsize::new(0);
101
102/// Helper function to create a unique `DocumentId`
103#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
104fn new_document_id() -> DocumentId {
105    let namespace_id = new_id_namespace();
106    let id = DOCUMENT_ID_COUNTER.fetch_add(1, Ordering::Relaxed) as u32;
107    DocumentId { namespace_id, id }
108}
109
110/// Action to take for the cursor blink timer when focus changes
111///
112/// This enum is returned by `LayoutWindow::handle_focus_change_for_cursor_blink()`
113/// to tell the platform layer what timer action to take.
114#[derive(Debug, Clone)]
115// short-lived platform-action enum: the Start variant intentionally carries the Timer payload
116// and the value is constructed then immediately matched by the platform layer.
117#[allow(clippy::large_enum_variant)]
118pub enum CursorBlinkTimerAction {
119    /// Start the cursor blink timer with the given timer configuration
120    Start(Timer),
121    /// Stop the cursor blink timer
122    Stop,
123    /// No change needed (timer already in correct state)
124    NoChange,
125}
126
127/// Action for the tooltip-delay timer, returned by
128/// `LayoutWindow::handle_hover_change_for_tooltip()`. Platform layer translates
129/// these to `start_timer` / `stop_timer` calls on `TOOLTIP_DELAY_TIMER_ID`.
130#[derive(Debug, Clone)]
131// short-lived platform-action enum: the Start variant intentionally carries the Timer payload
132// and the value is constructed then immediately matched by the platform layer.
133#[allow(clippy::large_enum_variant)]
134pub enum TooltipTimerAction {
135    /// Start the tooltip-delay timer with the given configuration
136    Start(Timer),
137    /// Stop the tooltip-delay timer and hide the tooltip if shown
138    Stop,
139    /// No change needed (timer already in correct state)
140    NoChange,
141}
142
143/// Helper function to create a unique `IdNamespace`
144#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
145fn new_id_namespace() -> IdNamespace {
146    let id = ID_NAMESPACE_COUNTER.fetch_add(1, Ordering::Relaxed) as u32;
147    IdNamespace(id)
148}
149
150/// Trampoline for `VirtualViewCallbackInfo::measure_dom` (headless item
151/// sizing): `ctx` is the invoking `LayoutWindow`, `dom` was `ManuallyDrop`'d
152/// by the caller and is moved out here exactly once.
153#[cfg(feature = "std")]
154extern "C" fn virtual_view_measure_dom_trampoline(
155    ctx: *mut core::ffi::c_void,
156    dom: *mut Dom,
157    available: LogicalSize,
158) -> LogicalSize {
159    if ctx.is_null() || dom.is_null() {
160        return LogicalSize::zero();
161    }
162    // SAFETY: ctx is the LayoutWindow that constructed the callback info
163    // (same liveness contract as CallbackInfo's internal window pointer);
164    // measure_dom only needs &self and works on scratch caches.
165    let lw = unsafe { &*(ctx as *const LayoutWindow) };
166    let dom = unsafe { core::ptr::read(dom) };
167    lw.measure_dom(dom, available)
168}
169
170// ============================================================================
171// Cursor Blink Timer Callback
172// ============================================================================
173
174/// Destructor for cursor blink timer `RefAny` (no-op since we use null pointer)
175extern "C" fn cursor_blink_timer_destructor(_: RefAny) {
176    // No cleanup needed - we use a null pointer RefAny
177}
178
179/// Callback for the cursor blink timer
180///
181/// This function is called every ~530ms to toggle cursor visibility.
182/// It checks if enough time has passed since the last user input before blinking,
183/// to avoid blinking while the user is actively typing.
184///
185/// The callback returns:
186/// - `TerminateTimer::Continue` + `Update::RefreshDom` if cursor toggled
187/// - `TerminateTimer::Terminate` if focus is no longer on a contenteditable element
188#[must_use] pub extern "C" fn cursor_blink_timer_callback(
189    _data: RefAny,
190    mut info: crate::timer::TimerCallbackInfo,
191) -> azul_core::callbacks::TimerCallbackReturn {
192    use azul_core::callbacks::{TimerCallbackReturn, Update};
193    use azul_core::task::TerminateTimer;
194
195    // Get current time
196    let now = info.get_current_time();
197
198    // We need to access the LayoutWindow through the info
199    // The timer callback needs to:
200    // 1. Check if focus is still on a contenteditable element
201    // 2. Check time since last input
202    // 3. Toggle visibility or keep solid
203
204    // For now, we'll queue changes via the CallbackInfo system
205    // The actual state modification happens in apply_user_change
206
207    // Check if we should blink or stay solid
208    // This is done by checking TextEditManager.blink.should_blink(now) in the layout window
209
210    // Since we can't access LayoutWindow directly here (it's not passed to timer callbacks),
211    // we use a different approach: the timer callback always toggles, and the visibility
212    // check is done in display_list.rs based on BlinkState.
213
214    // Simply toggle cursor visibility
215    info.set_cursor_visibility_toggle();
216
217    // Continue the timer and request a redraw.
218    // DoNothing here because the SetCursorVisibility change (queued above)
219    // already toggles blink state and returns ShouldUpdateDisplayListCurrentWindow,
220    // which sets display_list_dirty. RefreshDom would trigger a full DOM rebuild
221    // from the user callback; since the DOM is structurally unchanged (only cursor
222    // visibility differs), is_layout_equivalent() returns LayoutUnchanged and the
223    // display list change is lost.
224    TimerCallbackReturn {
225        should_update: Update::DoNothing,
226        should_terminate: TerminateTimer::Continue,
227    }
228}
229
230// ============================================================================
231// Tooltip Delay Timer Callback
232// ============================================================================
233
234/// Callback for the tooltip-delay timer.
235///
236/// Fires once after `InputMetrics::hover_time_ms` has elapsed while a node with
237/// a tooltip-bearing attribute was continuously hovered. The callback looks up
238/// the `title` / `aria-label` / `alt` attribute on the currently-hovered node,
239/// emits a `ShowTooltip` `CallbackChange`, and terminates — a single-shot timer.
240/// Movement to a different node (or any hover loss) removes and re-adds the
241/// timer from the platform layer, so the callback itself never needs to
242/// reschedule.
243#[must_use] pub extern "C" fn tooltip_delay_timer_callback(
244    _data: RefAny,
245    mut info: crate::timer::TimerCallbackInfo,
246) -> azul_core::callbacks::TimerCallbackReturn {
247    use azul_core::callbacks::{TimerCallbackReturn, Update};
248    use azul_core::task::TerminateTimer;
249
250    let layout_window = info.callback_info.get_layout_window();
251    let hover_node_id = layout_window
252        .hover_manager
253        .current_hover_node()
254        .map(|node_id| DomNodeId {
255            dom: DomId { inner: 0 },
256            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
257        });
258
259    if let Some(dom_node_id) = hover_node_id {
260        // Priority: aria-label > alt > title (mirrors DOM get_accessible_label).
261        let tooltip_text = info
262            .callback_info
263            .get_node_attribute(dom_node_id, "aria-label")
264            .or_else(|| info.callback_info.get_node_attribute(dom_node_id, "alt"))
265            .or_else(|| info.callback_info.get_node_attribute(dom_node_id, "title"));
266
267        if let Some(text) = tooltip_text {
268            info.callback_info.show_tooltip(text);
269        }
270    }
271
272    TimerCallbackReturn {
273        should_update: Update::DoNothing,
274        should_terminate: TerminateTimer::Terminate,
275    }
276}
277
278/// Outcome of a single CPU `render_frame` call — the seed of the unified
279/// `DamageRegion` type described in `DAMAGE_REGION_PLAN.md`.
280///
281/// Lives in `azul-layout` (rather than in the dll's headless backend, where it
282/// was originally defined) so that it can be stored on [`LayoutWindow`] and
283/// therefore be reachable from a `CallbackInfo` — i.e. from an E2E assertion.
284/// `dll::desktop::shell2::headless::FrameDamage` is a re-export of this type.
285#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub enum FrameDamage {
287    /// Nothing changed; render was skipped, the previous frame is still valid.
288    #[default]
289    None,
290    /// Incremental repaint of exactly these logical rects.
291    Rects(Vec<LogicalRect>),
292    /// Full repaint (first frame, structural change, or shrink-resize).
293    Full,
294}
295
296impl FrameDamage {
297    /// `true` if no pixel was repainted at all.
298    #[must_use]
299    pub const fn is_none(&self) -> bool {
300        matches!(self, Self::None)
301    }
302
303    /// `true` if the whole window was repainted.
304    #[must_use]
305    pub const fn is_full(&self) -> bool {
306        matches!(self, Self::Full)
307    }
308
309    /// Number of damage rects (`None` → 0, `Full` → 1).
310    #[must_use]
311    pub const fn rect_count(&self) -> usize {
312        match self {
313            Self::None => 0,
314            Self::Full => 1,
315            Self::Rects(r) => r.len(),
316        }
317    }
318
319    /// The damage rects, if this is an incremental repaint.
320    #[must_use]
321    pub fn rects(&self) -> Option<&[LogicalRect]> {
322        match self {
323            Self::Rects(r) => Some(r),
324            _ => None,
325        }
326    }
327
328    /// Total damaged area in logical px². `None` → 0.0, `Full` → the full
329    /// `window_area` passed in (the caller knows the window size).
330    #[must_use]
331    pub fn area(&self, window_area: f32) -> f32 {
332        match self {
333            Self::None => 0.0,
334            Self::Full => window_area,
335            Self::Rects(r) => r.iter().map(|r| r.size.width * r.size.height).sum(),
336        }
337    }
338
339    /// Convert this damage record into physical-pixel present rects for a
340    /// `buf_w`×`buf_h` buffer at `dpi_factor` — the ONE conversion every
341    /// platform presenter should use to hand damage to its compositor
342    /// (`XPutImage` sub-rects / `wl_surface_damage` / partial `StretchDIBits`
343    /// / `setNeedsDisplayInRect:`).
344    ///
345    /// - `None` → returns `None`: the previous frame is still on screen and
346    ///   valid — present nothing. Callers must STILL present in full when the
347    ///   OS asked for a re-present (Expose / WM_PAINT-from-uncover / drawRect)
348    ///   — pass `force_full = true` there.
349    /// - `Rects` → `Some(rects)` as `(x, y, w, h)` physical px, rounded
350    ///   OUTWARD (floor origin / ceil far edge — truncation would under-cover
351    ///   fractional edges and leave 1px stale seams), clamped to the buffer.
352    ///   More than 16 rects collapses to one full-buffer rect (bounded cost,
353    ///   per `DAMAGE_REGION_PLAN` §3).
354    /// - `Full` → one full-buffer rect.
355    ///
356    /// "Present must never silently be empty when a present is required" —
357    /// when in doubt, callers should treat errors/unknowns as `Full`.
358    #[must_use]
359    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
360    pub fn to_present_rects_physical(
361        &self,
362        dpi_factor: f32,
363        buf_w: u32,
364        buf_h: u32,
365        force_full: bool,
366    ) -> Option<Vec<(u32, u32, u32, u32)>> {
367        const MAX_PRESENT_RECTS: usize = 16;
368        if buf_w == 0 || buf_h == 0 {
369            return None;
370        }
371        let full = || Some(vec![(0u32, 0u32, buf_w, buf_h)]);
372        if force_full {
373            // OS-driven expose: the on-screen content may be stale/undefined
374            // regardless of what we last painted — push the whole retained
375            // frame.
376            return full();
377        }
378        match self {
379            Self::None => None,
380            Self::Full => full(),
381            Self::Rects(rects) => {
382                if rects.is_empty() {
383                    return None;
384                }
385                if rects.len() > MAX_PRESENT_RECTS {
386                    return full();
387                }
388                let mut out = Vec::with_capacity(rects.len());
389                for r in rects {
390                    let x0 = ((r.origin.x * dpi_factor).floor() as i64).clamp(0, i64::from(buf_w));
391                    let y0 = ((r.origin.y * dpi_factor).floor() as i64).clamp(0, i64::from(buf_h));
392                    let x1 = (((r.origin.x + r.size.width) * dpi_factor).ceil() as i64)
393                        .clamp(0, i64::from(buf_w));
394                    let y1 = (((r.origin.y + r.size.height) * dpi_factor).ceil() as i64)
395                        .clamp(0, i64::from(buf_h));
396                    if x1 > x0 && y1 > y0 {
397                        out.push((x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32));
398                    }
399                }
400                if out.is_empty() {
401                    None
402                } else {
403                    Some(out)
404                }
405            }
406        }
407    }
408}
409
410/// Per-frame observability record hung off [`LayoutWindow`].
411///
412/// This is what makes damage + frame-work counters visible to an E2E assertion:
413/// `CallbackInfo::get_layout_window().frame_report`. The CPU backend writes
414/// `paint_damage` / `present_damage` / `frame_index` after every `render_frame`;
415/// the event loop writes the work counters.
416///
417/// The `*_since_reset` counters are STICKY: they are never cleared automatically
418/// (a per-tick reset would race the assertion that wants to read them). Use the
419/// `reset_frame_counters` debug op to zero them at a known point in a test.
420#[derive(Debug, Clone, Default, PartialEq, Eq)]
421pub struct FrameReport {
422    /// Monotonic index of the last CPU-rendered frame.
423    pub frame_index: u64,
424    /// PAINT damage of the last frame — the pixels actually re-rasterised.
425    pub paint_damage: FrameDamage,
426    /// PRESENT damage of the last frame — the pixels that changed on screen
427    /// (⊇ paint damage; a scroll memmoves a large region but paints a strip).
428    pub present_damage: FrameDamage,
429    /// UNION of the paint damage of every frame since the last counter reset.
430    ///
431    /// This is what a test must assert on: between the step that changed
432    /// something and the assertion, the engine may render further (idle) frames
433    /// whose damage is `None`, which would clobber `paint_damage`. The
434    /// accumulated damage is stable across those.
435    pub accumulated_paint_damage: FrameDamage,
436    /// UNION of the present damage of every frame since the last counter reset.
437    pub accumulated_present_damage: FrameDamage,
438    /// Frames rendered since the last counter reset.
439    pub frames_since_reset: u32,
440    /// Generation of the last observed reset request (see [`request_frame_report_reset`]).
441    pub reset_generation: u64,
442    /// Highest `process_window_events` recursion depth reached since the last
443    /// counter reset. > 1 means the frame did not converge in one pass.
444    ///
445    /// THIS IS AN EVENT-PASS COUNTER AND NOT A LAYOUT COUNTER — the name is
446    /// historical and has misled every reader of it. `0` means "no state delta
447    /// was processed", which is what an idle frame looks like; it does NOT mean
448    /// "no layout ran". A mutation that arrives through the CALLBACK API
449    /// (`set_node_css_override`, `set_node_text`, `set_node_classes`, …) never
450    /// enters `process_window_events` at all, yet routes through
451    /// `ShouldIncrementalRelayout` → a FULL relayout of the root DOM. Asserting
452    /// `max_relayouts: 0` over such a step therefore passes while the engine
453    /// re-laid-out the whole tree. Use [`Self::layout_passes`] for that
454    /// question.
455    pub relayout_iterations: u32,
456    /// Number of `regenerate_layout()` (i.e. `layout_callback`) runs since the
457    /// last counter reset.
458    pub dom_regenerations: u32,
459    /// Number of times LAYOUT ACTUALLY RAN since the last counter reset.
460    ///
461    /// Incremented in [`LayoutWindow::layout_and_generate_display_list`], the
462    /// one funnel both hosts and both paths go through — the shells'
463    /// `regenerate_layout` / `incremental_relayout` and the E2E runner's
464    /// `regenerate_layout()` / `relayout_only()`. So it counts layout work
465    /// regardless of what scheduled it, which is exactly what the other two
466    /// counters each miss on their own:
467    ///
468    /// | step                    | `relayout_iterations` | `dom_regenerations` | `layout_passes` |
469    /// |-------------------------|-----------------------|---------------------|-----------------|
470    /// | idle tick               | 0                     | 0                   | 0               |
471    /// | inert pointer event     | 1                     | 0                   | 0               |
472    /// | `set_node_css_override` | 0                     | 0                   | 1               |
473    /// | resize / mount          | 1 / 0                 | 1                   | 1               |
474    ///
475    /// The second and third rows are the two directions the over-invalidation
476    /// families were reading wrongly: an input-free timeline never reaches the
477    /// event pass (counter `0`, a vacuous pass even while a manager rebuilds the
478    /// display list every frame), and a callback-API mutation runs a full
479    /// relayout while the counter stays `0`.
480    pub layout_passes: u32,
481    /// `true` if `MAX_EVENT_RECURSION_DEPTH` was ever hit since the last reset.
482    /// Today the engine only `log_warn`s on this; this flag is what lets a test
483    /// turn an invalidation loop into a red assertion instead of a silent cap.
484    pub hit_depth_cap: bool,
485    /// The last terminal `ProcessEventResult` (as its `u8` discriminant order).
486    pub terminal_result: u8,
487}
488
489/// Maximum recursion depth for event processing.
490///
491/// An event pass whose callbacks regenerate the DOM (which raises new events) is
492/// re-entered at most this many times before the loop is broken and
493/// [`FrameReport::hit_depth_cap`] is set.
494///
495/// Lives here rather than in the shell because BOTH event loops must agree: the
496/// real one (`dll/src/desktop/shell2/common/event.rs`) and the headless E2E port
497/// of it ([`crate::e2e`]), whose whole purpose is to report the same numbers.
498pub const MAX_EVENT_RECURSION_DEPTH: usize = 7;
499
500/// How many extra layout passes a single frame may run because a lifecycle
501/// callback asked for one.
502///
503/// A `Mount`/`AfterMount` callback that seeds derived state and returns
504/// `Update::RefreshDom` needs the DOM rebuilt before the frame is presented, or
505/// the user sees the pre-seed layout until some unrelated event happens to force
506/// another pass. `PlatformWindow::regenerate_layout` therefore loops.
507///
508/// Deliberately SMALLER than [`MAX_EVENT_RECURSION_DEPTH`]. Each pass is a full
509/// layout callback + cascade + flex solve + display-list build — the most
510/// expensive thing the engine does — so seven of them inside one paint is a
511/// visible stall. A convergent seed-on-mount needs exactly 2; 3 leaves one pass
512/// of slack for a widget that seeds in two stages. Exhausting it is a BUG in the
513/// callback (it is asking to refresh forever), so the cap logs and sets
514/// `FrameReport::hit_depth_cap` rather than silently truncating.
515pub const MAX_LIFECYCLE_REGEN_PASSES: usize = 3;
516
517impl FrameReport {
518    /// Zero the work counters + accumulated damage if `requested_generation`
519    /// (this window's [`LayoutWindow::frame_report_reset_request`]) has moved
520    /// since this report last observed it. Called by every writer of the report
521    /// before it writes.
522    pub fn sync_generation_to(&mut self, requested_generation: u64) {
523        if requested_generation != self.reset_generation {
524            self.reset_generation = requested_generation;
525            self.reset_counters();
526        }
527    }
528
529    /// This report as a READER must see it: the counters and accumulated damage
530    /// as of `requested_generation`.
531    ///
532    /// The reset is applied lazily (a writer zeroes the fields on its next
533    /// write), because the op that requests it — and the assertion that reads
534    /// the result — only ever hold `&LayoutWindow` through `CallbackInfo`.
535    /// Without this, every read between `reset_frame_counters` and the next
536    /// frame returned the counters and damage from BEFORE the reset: an
537    /// assertion placed right after the reset silently measured the previous
538    /// checkpoint's work.
539    #[must_use]
540    pub fn as_of_generation(&self, requested_generation: u64) -> Self {
541        let mut out = self.clone();
542        out.sync_generation_to(requested_generation);
543        out
544    }
545
546    /// Zero the sticky work counters + accumulated damage.
547    pub fn reset_counters(&mut self) {
548        self.relayout_iterations = 0;
549        self.dom_regenerations = 0;
550        self.layout_passes = 0;
551        self.hit_depth_cap = false;
552        self.frames_since_reset = 0;
553        self.accumulated_paint_damage = FrameDamage::None;
554        self.accumulated_present_damage = FrameDamage::None;
555    }
556
557    /// Record the damage of a freshly rendered frame: it becomes the last-frame
558    /// damage AND is merged into the accumulated damage since the last reset.
559    ///
560    /// Prefer [`LayoutWindow::record_frame`], which supplies the window's reset
561    /// generation; this form is for tests that hold a bare report.
562    pub fn record_frame_at_generation(
563        &mut self,
564        requested_generation: u64,
565        paint: FrameDamage,
566        present: FrameDamage,
567    ) {
568        self.sync_generation_to(requested_generation);
569        self.frame_index = self.frame_index.wrapping_add(1);
570        self.frames_since_reset = self.frames_since_reset.saturating_add(1);
571        Self::merge_into(&mut self.accumulated_paint_damage, &paint);
572        Self::merge_into(&mut self.accumulated_present_damage, &present);
573        self.paint_damage = paint;
574        self.present_damage = present;
575    }
576
577    fn merge_into(acc: &mut FrameDamage, next: &FrameDamage) {
578        match (&mut *acc, next) {
579            (_, FrameDamage::None) | (FrameDamage::Full, _) => {}
580            (_, FrameDamage::Full) => *acc = FrameDamage::Full,
581            (FrameDamage::None, FrameDamage::Rects(r)) => *acc = FrameDamage::Rects(r.clone()),
582            (FrameDamage::Rects(a), FrameDamage::Rects(b)) => a.extend(b.iter().copied()),
583        }
584    }
585}
586
587/// Result of a layout pass for a single DOM, before display list generation
588#[derive(Debug)]
589pub struct DomLayoutResult {
590    /// The styled DOM that was laid out
591    pub styled_dom: StyledDom,
592    /// The layout tree with computed sizes and positions
593    pub layout_tree: LayoutTree,
594    /// Absolute positions of all nodes
595    pub calculated_positions: solver3::PositionVec,
596    /// The viewport used for this layout
597    pub viewport: LogicalRect,
598    /// The generated display list for this DOM.
599    pub display_list: DisplayList,
600    /// Stable scroll IDs computed from `node_data_hash`
601    /// Maps layout node index -> external scroll ID
602    pub scroll_ids: HashMap<usize, u64>,
603    /// Mapping from scroll IDs to DOM `NodeIds` for hit testing
604    /// This allows us to map `WebRender` scroll IDs back to DOM nodes
605    pub scroll_id_to_node_id: HashMap<u64, NodeId>,
606}
607
608/// State for tracking scrollbar drag interaction
609#[derive(Copy, Debug, Clone)]
610pub struct ScrollbarDragState {
611    pub hit_id: ScrollbarHitId,
612    pub initial_mouse_pos: LogicalPosition,
613    pub initial_scroll_offset: LogicalPosition,
614}
615
616/// Information about the last text edit operation
617/// Allows callbacks to query what changed during text input
618// Re-export PendingTextEdit from text_input manager
619pub use crate::managers::text_input::PendingTextEdit;
620
621/// Cached text layout constraints for a node
622/// These are the layout parameters that were used to shape the text
623#[derive(Debug, Clone)]
624#[derive(Default)]
625pub struct TextConstraintsCache {
626    /// Map from (`dom_id`, `node_id`) to their layout constraints
627    pub constraints: BTreeMap<(DomId, NodeId), UnifiedConstraints>,
628}
629
630
631/// A text node that has been edited since the last full layout.
632/// This allows us to perform lightweight relayout without rebuilding the entire DOM.
633#[derive(Debug, Clone)]
634pub struct DirtyTextNode {
635    /// The new inline content (text + images) after editing
636    pub content: Vec<InlineContent>,
637    /// The new cursor position after editing
638    pub cursor: Option<TextCursor>,
639    /// Whether this edit requires ancestor relayout (e.g., text grew taller)
640    pub needs_ancestor_relayout: bool,
641}
642
643/// Result of applying a text changeset
644#[derive(Debug)]
645pub struct TextChangesetResult {
646    /// Nodes that need dirty marking
647    pub dirty_nodes: Vec<DomNodeId>,
648    /// Whether the text size changed enough to require full re-layout
649    /// (e.g., for scroll container recomputation)
650    pub needs_relayout: bool,
651}
652
653/// The E2E `mount` override for one window: the XML+CSS document the debug
654/// `mount` op installed, plus a "must be (re-)parsed" flag.
655///
656/// `regenerate_layout` swaps the parsed document in for the app's own DOM. The
657/// dirty flag exists because after the FIRST regeneration the mounted DOM is
658/// kept as-is and cloned forward — otherwise every `RefreshDom` would rebuild it
659/// from the XML and silently discard the DOM-mutation ops (`insert_node`,
660/// `set_node_css_override`, …) the test just applied, which makes every damage
661/// assertion see "nothing changed".
662///
663/// This lives on the window (and is written through
664/// [`crate::callbacks::CallbackChange::RemountDom`]) rather than in a process
665/// -global, so two windows cannot share one mounted document and a parallel
666/// headless run cannot have one scenario overwrite another's DOM.
667#[derive(Debug, Default, Clone)]
668pub struct E2eMountOverride {
669    xml: Option<String>,
670    dirty: bool,
671}
672
673impl E2eMountOverride {
674    /// Install (`Some`) or clear (`None`) the override; marks it dirty.
675    pub fn set(&mut self, xml: Option<String>) {
676        self.xml = xml;
677        self.dirty = true;
678    }
679
680    /// The currently mounted document, if any.
681    #[must_use]
682    pub fn xml(&self) -> Option<&str> {
683        self.xml.as_deref()
684    }
685
686    /// Whether a `mount` / `unmount` landed since the last [`Self::take_dirty`].
687    #[must_use]
688    pub const fn is_dirty(&self) -> bool {
689        self.dirty
690    }
691
692    /// Take the "must be (re-)parsed" flag, clearing it.
693    pub const fn take_dirty(&mut self) -> bool {
694        core::mem::replace(&mut self.dirty, false)
695    }
696}
697
698/// A window-level layout manager that encapsulates all layout state and caching.
699///
700/// This struct owns the layout and text caches, and provides methods `dir_to`:
701/// - Perform initial layout
702/// - Incrementally update layout on DOM changes
703/// - Generate display lists for rendering
704/// - Handle window resizes efficiently
705/// - Manage multiple DOMs (for `VirtualViews`)
706#[derive(Debug)]
707pub struct LayoutWindow {
708    /// E2E `mount` override for this window (debug-server `mount` / `unmount`).
709    /// Empty and untouched in every normal build — see [`E2eMountOverride`].
710    pub e2e_mount: E2eMountOverride,
711    /// Scratch state for the E2E ops that remember something between the steps
712    /// of one scenario (named frame / resource snapshots, the composition stage
713    /// trace, the last presented framebuffer). Per-window, so a parallel
714    /// headless run cannot have one scenario read another's snapshots.
715    /// See [`crate::e2e::E2eScratch`].
716    #[cfg(feature = "e2e-server")]
717    pub e2e_scratch: std::sync::Mutex<crate::e2e::E2eScratch>,
718    /// M12.7 web/headless: skip the GPU transform/opacity sync in
719    /// `layout_dom_recursive`. That sync only feeds the display list (which
720    /// the web backend skips), has no GPU, and `GpuValueCache::synchronize`
721    /// currently mis-lifts to wasm (out-of-bounds). Gated via this heap field
722    /// (a normal struct read — reliable in the lift, unlike the
723    /// `SKIP_DISPLAY_LIST` `__bss` static, whose store/load is inconsistent
724    /// in the lifted wasm). Default false → desktop is unaffected.
725    pub skip_gpu_sync: bool,
726    /// Per-frame damage + frame-work observability record. Written by the CPU
727    /// backend after each `render_frame` and by the event loop; read by E2E
728    /// assertions through `CallbackInfo::get_layout_window()`.
729    pub frame_report: FrameReport,
730    /// "Please zero this window's frame-report counters" generation, bumped by
731    /// [`Self::request_frame_report_reset`] (the `reset_frame_counters` op).
732    ///
733    /// An atomic on the WINDOW rather than a process-global static: the op that
734    /// requests the reset only ever holds `&LayoutWindow` (through
735    /// `CallbackInfo`), which is why the request cannot simply zero the fields —
736    /// but "global" was never the right scope. With a process-global generation
737    /// one scenario's `reset_frame_counters` zeroed every OTHER concurrently
738    /// running scenario's counters, so no scenario that measured frame work
739    /// could run in parallel.
740    pub frame_report_reset_request: core::sync::atomic::AtomicU64,
741    /// Fragmentation context for this window (continuous for screen, paged for print)
742    #[cfg(feature = "pdf")]
743    pub fragmentation_context: crate::paged::FragmentationContext,
744    /// Layout cache for solver3 (incremental layout tree) - for the root DOM
745    pub layout_cache: Solver3LayoutCache,
746    /// Text layout cache for text3 (shaped glyphs, line breaks, etc.)
747    pub text_cache: TextLayoutCache,
748    /// Font manager for loading and caching fonts
749    pub font_manager: FontManager<FontRef>,
750    /// Cache to store decoded images
751    pub image_cache: ImageCache,
752    /// CPU-backend resolution of `RenderImageCallback` images: the produced
753    /// image for each callback-image node, keyed by the ORIGINAL callback
754    /// image's hash. Populated by [`LayoutWindow::invoke_cpu_image_callbacks`]
755    /// before each CPU `render_frame`; consumed by cpurender (which otherwise
756    /// draws a grey placeholder for `DecodedImage::Callback`). Empty on the GPU
757    /// path (`WebRender` invokes callbacks itself via `process_image_callback_updates`).
758    pub cpu_image_callback_results: BTreeMap<ImageRefHash, ImageRef>,
759    /// Cached layout results for all DOMs (root + virtualized views)
760    pub layout_results: BTreeMap<DomId, DomLayoutResult>,
761    /// Scroll state manager for all nodes across all DOMs
762    pub scroll_manager: ScrollManager,
763    /// Gesture and drag manager for multi-frame interactions (moved from `FullWindowState`)
764    pub gesture_drag_manager: crate::managers::gesture::GestureAndDragManager,
765    /// Focus manager for keyboard focus and tab navigation
766    pub focus_manager: crate::managers::focus_cursor::FocusManager,
767    /// Unified text editing manager (cursor + selection + dirty flag)
768    pub text_edit_manager: crate::managers::text_edit::TextEditManager,
769    /// File drop manager for cursor state and file drag-drop
770    pub file_drop_manager: crate::managers::file_drop::FileDropManager,
771    /// Clipboard manager for system clipboard integration
772    pub clipboard_manager: crate::managers::clipboard::ClipboardManager,
773    /// Hover manager for tracking hit test history over multiple frames
774    pub hover_manager: crate::managers::hover::HoverManager,
775    /// `VirtualView` manager for all nodes across all DOMs
776    pub virtual_view_manager: VirtualViewManager,
777    /// GPU state manager for all nodes across all DOMs
778    pub gpu_state_manager: GpuStateManager,
779    /// Accessibility manager for screen reader support
780    pub a11y_manager: crate::managers::a11y::A11yManager,
781    /// Permission manager — cross-platform capability state for camera /
782    /// microphone / geolocation / biometric / sensors / photo-library /
783    /// notifications / etc. The platform backend drains
784    /// `take_pending_permission_events` once per frame and routes each
785    /// `Subscribe` / `Release` through `dll::desktop::extra::permission::apply_diff_events`.
786    /// See `SUPER_PLAN_2.md` §1.5 + research/08 for the architecture.
787    pub permission_manager: crate::managers::permission::PermissionManager,
788    /// Geolocation manager — `LocationFix` storage + per-frame diff
789    /// against the `NodeType::GeolocationProbe`s in the styled DOM.
790    /// The platform backend (`dll::desktop::extra::geolocation`)
791    /// drains diff events and starts / stops native
792    /// `CLLocationManager` / `LocationManager` / `geoclue`
793    /// subscriptions.
794    pub geolocation_manager: crate::managers::geolocation::GeolocationManager,
795    /// Cross-platform biometric-auth state — latest result + sync
796    /// availability. The platform backend (`dll::desktop::extra::biometric`)
797    /// shows the OS prompt and parks results in the async channel that the
798    /// layout pass folds into this manager (request-driven; no probe node).
799    pub biometric_manager: crate::managers::biometric::BiometricManager,
800    /// Cross-platform keyring state — outcome of the last secret-store op.
801    /// The platform backend (`dll::desktop::extra::keyring`) reads/writes
802    /// the OS keyring (Keychain / `KeyStore` / libsecret / `CredentialLocker`)
803    /// and parks results in the async channel the layout pass folds in here.
804    pub keyring_manager: crate::managers::keyring::KeyringManager,
805    /// Cross-platform motion-sensor state — latest accel / gyro / mag
806    /// reading. The platform backend (`dll::desktop::extra::sensors`)
807    /// subscribes to `CoreMotion` / Android `SensorManager` and parks
808    /// readings in the async channel the layout pass folds in here.
809    pub sensor_manager: crate::managers::sensors::SensorManager,
810    /// Cross-platform gamepad / controller state. The dll's platform backend
811    /// (gilrs / `GCController` / `InputDevice`) parks per-pad states in the async
812    /// channel the layout pass folds in here.
813    pub gamepad_manager: crate::managers::gamepad::GamepadManager,
814    /// Safe-area insets (notch / system-UI margins) for this window, in logical
815    /// px. Set by the platform shell (macOS NSScreen.safeAreaInsets, iOS
816    /// UIView.safeAreaInsets, Android `WindowInsets`); zero where none.
817    pub safe_area_insets: azul_css::system::SafeAreaInsets,
818    /// Timers associated with this window
819    pub timers: BTreeMap<TimerId, Timer>,
820    /// Threads running in the background for this window
821    pub threads: BTreeMap<ThreadId, Thread>,
822    /// Currently loaded fonts and images present in this renderer (window)
823    pub renderer_resources: RendererResources,
824    /// Renderer type: Hardware-with-software-fallback, pure software or pure hardware renderer?
825    pub renderer_type: Option<RendererType>,
826    /// Windows state of the window of (current frame - 1): initialized to None on startup
827    pub previous_window_state: Option<FullWindowState>,
828    /// Window state of this current window (current frame): initialized to the state of
829    /// `WindowCreateOptions`
830    pub current_window_state: FullWindowState,
831    /// A "document" in `WebRender` usually corresponds to one tab (i.e. in Azuls case, the whole
832    /// window).
833    pub document_id: DocumentId,
834    /// ID namespace under which every font / image for this window is registered
835    pub id_namespace: IdNamespace,
836    /// The "epoch" is a frame counter, to remove outdated images, fonts and OpenGL textures when
837    /// they're not in use anymore.
838    pub epoch: Epoch,
839    /// Currently GL textures inside the active `CachedDisplayList`
840    pub gl_texture_cache: GlTextureCache,
841    /// State for tracking scrollbar drag interaction
842    currently_dragging_thumb: Option<ScrollbarDragState>,
843    /// Text input manager - centralizes all text editing logic
844    pub text_input_manager: crate::managers::text_input::TextInputManager,
845    /// Undo/Redo manager for text editing operations
846    pub undo_redo_manager: crate::managers::undo_redo::UndoRedoManager,
847    /// Cached text layout constraints for each node
848    /// This allows us to re-layout text with the same constraints after edits
849    pub text_constraints_cache: TextConstraintsCache,
850    /// Tracks which nodes have been edited since last full layout.
851    /// Key: (`DomId`, `NodeId` of IFC root)
852    /// Value: The edited inline content that should be used for relayout
853    pub dirty_text_nodes: BTreeMap<(DomId, NodeId), DirtyTextNode>,
854    /// Pending `VirtualView` updates from callbacks (processed in next frame)
855    /// Map of `DomId` -> Set of `NodeIds` that need re-rendering
856    /// MWA-C-virtual_view: pending re-invocations now carry the QUEUE-TIME
857    /// reason so the user callback receives EdgeScrolled/BoundsExpanded/
858    /// `DomRecreated` instead of everything collapsing to `InitialRender`.
859    pub pending_virtual_view_updates: BTreeMap<DomId, BTreeMap<NodeId, VirtualViewCallbackReason>>,
860    /// Lifecycle events produced by DOM reconciliation, waiting to be dispatched.
861    ///
862    /// `regenerate_layout` appends `diff::reconcile_dom`'s `DiffResult.events` here
863    /// (Mount / Update / Resize `SyntheticEvents` — note: NOT Unmount; see
864    /// `pending_unmount_invocations`). The shell's event loop drains and
865    /// dispatches them via `dispatch_events_propagated`, which routes
866    /// `EventFilter::Component(_)` filters through `matches_component_filter`.
867    /// Drain-and-clear is the caller's responsibility; nothing inside
868    /// `LayoutWindow` ages or discards these on its own.
869    pub pending_lifecycle_events: Vec<azul_core::events::SyntheticEvent>,
870    /// Resolved `BeforeUnmount` invocations queued for dispatch.
871    ///
872    /// Unmount events target OLD `NodeIds` that disappear once the new layout
873    /// is committed to `layout_results`, so the shell cannot resolve them
874    /// via DOM lookup at dispatch time. `regenerate_layout` resolves the
875    /// callback against the OLD node data while it still has access, then
876    /// pushes a `(CoreCallbackData, SyntheticEvent)` pair here. The shell's
877    /// dispatcher invokes each pair directly.
878    pub pending_unmount_invocations: Vec<(
879        azul_core::callbacks::CoreCallbackData,
880        azul_core::events::SyntheticEvent,
881    )>,
882    /// System style (colors, fonts, metrics) for resolving system color keywords
883    /// Set via `set_system_style()` from the shell after window creation
884    pub system_style: Option<Arc<azul_css::system::SystemStyle>>,
885    /// Shared monitor list — initialized once at app start, updated by the platform
886    /// layer on monitor topology changes. Arc<Mutex> allows zero-cost sharing
887    /// across all `CallbackInfoRefData` without cloning the Vec each time.
888    pub monitors: Arc<std::sync::Mutex<MonitorVec>>,
889    /// XOR of all `tier2b.font_family_hash` values from the last resolved DOM.
890    /// Used to skip font chain resolution on frames where the font requirements
891    /// haven't changed (e.g. scroll-only frames).
892    font_stacks_hash: u64,
893    /// Snapshot of inline content before IME preedit injection.
894    /// Saved on first setMarkedText so each subsequent call injects into
895    /// clean original text instead of accumulating old preedits.
896    pre_preedit_content: Option<Vec<InlineContent>>,
897    /// Configurable input interpreter: maps raw events → `SystemChange` actions.
898    /// Default: `default_input_interpreter` (standard desktop keybindings).
899    /// Replace to implement vim, game controls, accessibility remaps, etc.
900    pub input_interpreter: azul_core::events::InputInterpreterCallback,
901    /// Configurable post-callback filter.
902    /// Default: `default_post_filter` (scroll-into-view after cursor ops).
903    pub post_filter: azul_core::events::PostFilterCallback,
904    /// Registered routes from `AppConfig`.  Set once at window creation.
905    /// Used by `CallbackChange::SwitchRoute` to look up layout callbacks.
906    pub routes: azul_core::resources::RouteVec,
907    /// ICU4X localizer handle for internationalized formatting (numbers, dates, lists, plurals)
908    /// Initialized from system language at startup, can be overridden
909    #[cfg(feature = "icu")]
910    pub icu_localizer: IcuLocalizerHandle,
911}
912
913const fn default_duration_500ms() -> Duration {
914    Duration::System(SystemTimeDiff::from_millis(500))
915}
916
917const fn default_duration_200ms() -> Duration {
918    Duration::System(SystemTimeDiff::from_millis(200))
919}
920
921/// Helper function to convert Duration to milliseconds.
922///
923/// Both variants go through `Duration::as_millis_u64`, which converts ticks at
924/// the nominal frame rate. The Tick arm used to assume "tick = 1ms", so a
925/// 5-frame span reported itself as 5ms instead of ~83ms — a 16x under-report in
926/// every scheduling decision that consumed it.
927const fn duration_to_millis(duration: Duration) -> u64 {
928    duration.as_millis_u64()
929}
930
931impl LayoutWindow {
932    /// Ask this window to zero its frame-report counters + accumulated damage
933    /// before the next write (the `reset_frame_counters` E2E op).
934    ///
935    /// Takes `&self` because the op that calls it only has `&LayoutWindow`
936    /// through `CallbackInfo`.
937    pub fn request_frame_report_reset(&self) {
938        self.frame_report_reset_request
939            .fetch_add(1, Ordering::SeqCst);
940    }
941
942    /// Apply a pending [`Self::request_frame_report_reset`]. Every writer of
943    /// [`Self::frame_report`] calls this before it writes.
944    pub fn sync_frame_report(&mut self) {
945        let generation = self.frame_report_reset_request.load(Ordering::SeqCst);
946        self.frame_report.sync_generation_to(generation);
947    }
948
949    /// The frame report as a READER must see it — with any pending reset
950    /// already applied. This is what every E2E assertion reads; reading
951    /// [`Self::frame_report`] directly reports the counters and accumulated
952    /// damage from BEFORE the last `reset_frame_counters`.
953    #[must_use]
954    pub fn frame_report_synced(&self) -> FrameReport {
955        let generation = self.frame_report_reset_request.load(Ordering::SeqCst);
956        self.frame_report.as_of_generation(generation)
957    }
958
959    /// Record the damage of a freshly rendered frame on this window's report,
960    /// applying any pending counter reset first.
961    pub fn record_frame(&mut self, paint: FrameDamage, present: FrameDamage) {
962        let generation = self.frame_report_reset_request.load(Ordering::SeqCst);
963        self.frame_report
964            .record_frame_at_generation(generation, paint, present);
965    }
966
967    /// Create a new layout window with empty caches.
968    ///
969    /// For full initialization with `WindowInternal` compatibility, use `new_full()`.
970    /// The single place every `LayoutWindow` field is initialized; the public
971    /// constructors below are thin wrappers over this (deduplicated 2026-05-21,
972    /// so adding a field touches one site instead of three).
973    fn from_font_manager(font_manager: FontManager<FontRef>) -> Self {
974        Self {
975            e2e_mount: E2eMountOverride::default(),
976            #[cfg(feature = "e2e-server")]
977            e2e_scratch: std::sync::Mutex::new(crate::e2e::E2eScratch::default()),
978            // M12.7 web/headless GPU-sync skip (default false → desktop unaffected)
979            skip_gpu_sync: false,
980            frame_report: FrameReport::default(),
981            frame_report_reset_request: core::sync::atomic::AtomicU64::new(0),
982            #[cfg(feature = "pdf")]
983            fragmentation_context: crate::paged::FragmentationContext::new_continuous(800.0),
984            layout_cache: Solver3LayoutCache {
985                tree: None,
986                calculated_positions: Vec::new(),
987                viewport: None,
988                scroll_ids: HashMap::new(),
989                scroll_id_to_node_id: HashMap::new(),
990                counters: HashMap::new(),
991                float_cache: HashMap::new(),
992                cache_map: solver3::cache::LayoutCacheMap::default(),
993                previous_positions: Vec::new(),
994                cached_display_list: None,
995                prev_dom_ptr: 0,
996                prev_viewport: LogicalRect::zero(),
997            },
998            text_cache: TextLayoutCache::new(),
999            font_manager,
1000            image_cache: ImageCache::default(),
1001            cpu_image_callback_results: BTreeMap::new(),
1002            layout_results: BTreeMap::new(),
1003            scroll_manager: ScrollManager::new(),
1004            gesture_drag_manager: crate::managers::gesture::GestureAndDragManager::new(),
1005            focus_manager: crate::managers::focus_cursor::FocusManager::new(),
1006            text_edit_manager: crate::managers::text_edit::TextEditManager::new(),
1007            file_drop_manager: crate::managers::file_drop::FileDropManager::new(),
1008            clipboard_manager: crate::managers::clipboard::ClipboardManager::new(),
1009            hover_manager: crate::managers::hover::HoverManager::new(),
1010            virtual_view_manager: VirtualViewManager::new(),
1011            gpu_state_manager: GpuStateManager::new(
1012                default_duration_500ms(),
1013                default_duration_200ms(),
1014            ),
1015            a11y_manager: crate::managers::a11y::A11yManager::new(),
1016            permission_manager: crate::managers::permission::PermissionManager::new(),
1017            geolocation_manager: crate::managers::geolocation::GeolocationManager::new(),
1018            biometric_manager: crate::managers::biometric::BiometricManager::new(),
1019            keyring_manager: crate::managers::keyring::KeyringManager::new(),
1020            sensor_manager: crate::managers::sensors::SensorManager::new(),
1021            gamepad_manager: crate::managers::gamepad::GamepadManager::new(),
1022            safe_area_insets: azul_css::system::SafeAreaInsets::default(),
1023            timers: BTreeMap::new(),
1024            threads: BTreeMap::new(),
1025            renderer_resources: RendererResources::default(),
1026            renderer_type: None,
1027            previous_window_state: None,
1028            current_window_state: FullWindowState::default(),
1029            document_id: new_document_id(),
1030            id_namespace: new_id_namespace(),
1031            epoch: Epoch::new(),
1032            gl_texture_cache: GlTextureCache::default(),
1033            currently_dragging_thumb: None,
1034            text_input_manager: crate::managers::text_input::TextInputManager::new(),
1035            undo_redo_manager: crate::managers::undo_redo::UndoRedoManager::new(),
1036            text_constraints_cache: TextConstraintsCache {
1037                constraints: BTreeMap::new(),
1038            },
1039            dirty_text_nodes: BTreeMap::new(),
1040            pending_virtual_view_updates: BTreeMap::new(),
1041            pending_lifecycle_events: Vec::new(),
1042            pending_unmount_invocations: Vec::new(),
1043            system_style: None,
1044            monitors: Arc::new(std::sync::Mutex::new(MonitorVec::from_const_slice(&[]))),
1045            font_stacks_hash: 0,
1046            pre_preedit_content: None,
1047            input_interpreter: azul_core::events::InputInterpreterCallback::default(),
1048            post_filter: azul_core::events::PostFilterCallback::default(),
1049            routes: azul_core::resources::RouteVec::from_const_slice(&[]),
1050            #[cfg(feature = "icu")]
1051            icu_localizer: IcuLocalizerHandle::default(),
1052        }
1053    }
1054
1055    /// Create a new layout window with empty caches.
1056    ///
1057    /// For full initialization with `WindowInternal` compatibility, use `new_full()`.
1058    /// # Errors
1059    ///
1060    /// Returns a `LayoutError` if the layout window cannot be initialized.
1061    pub fn new(fc_cache: FcFontCache) -> Result<Self, solver3::LayoutError> {
1062        Ok(Self::from_font_manager(FontManager::new(fc_cache)?))
1063    }
1064
1065    /// Create a new layout window that shares already-parsed fonts with
1066    /// Create a `LayoutWindow` from a `FontContext` — shares all font data,
1067    /// starts with fresh layout cache, text cache, and all other state.
1068    /// # Errors
1069    ///
1070    /// Returns a `LayoutError` if the layout window cannot be initialized.
1071    pub fn from_font_context(ctx: &crate::text3::cache::FontContext) -> Result<Self, solver3::LayoutError> {
1072        let fm = ctx.to_font_manager();
1073        let fc_cache = fm.fc_cache.clone();
1074        let parsed_fonts = fm.parsed_fonts.clone();
1075        let mut lw = Self::new_with_shared_fonts(fc_cache, parsed_fonts)?;
1076        lw.font_manager = fm;
1077        Ok(lw)
1078    }
1079
1080    /// Create from shared `fc_cache` + `parsed_fonts` Arcs.
1081    /// # Errors
1082    ///
1083    /// Returns a `LayoutError` if the layout window cannot be initialized.
1084    pub fn new_with_shared_fonts(
1085        fc_cache: FcFontCache,
1086        parsed_fonts: Arc<std::sync::Mutex<HashMap<rust_fontconfig::FontId, FontRef>>>,
1087    ) -> Result<Self, solver3::LayoutError> {
1088        Ok(Self::from_font_manager(FontManager::from_arc_shared(
1089            fc_cache,
1090            parsed_fonts,
1091        )?))
1092    }
1093
1094    /// Create a new layout window for paged media (PDF generation).
1095    ///
1096    /// This constructor initializes the layout window with a paged fragmentation context,
1097    /// which will cause content to flow across multiple pages instead of a single continuous
1098    /// scrollable container.
1099    ///
1100    /// # Arguments
1101    /// - `fc_cache`: Font configuration cache for font loading
1102    /// - `page_size`: The logical size of each page
1103    ///
1104    /// # Returns
1105    /// A new `LayoutWindow` configured for paged output, or an error if initialization fails.
1106    #[cfg(feature = "pdf")]
1107    pub fn new_paged(
1108        fc_cache: FcFontCache,
1109        page_size: LogicalSize,
1110    ) -> Result<Self, crate::solver3::LayoutError> {
1111        let mut lw = Self::from_font_manager(FontManager::new(fc_cache)?);
1112        lw.fragmentation_context = crate::paged::FragmentationContext::new_paged(page_size);
1113        Ok(lw)
1114    }
1115
1116    /// Perform layout on a styled DOM and generate a display list.
1117    ///
1118    /// This is the main entry point for layout. It handles:
1119    /// - Incremental layout updates using the cached layout tree
1120    /// - Text shaping and line breaking
1121    /// - `VirtualView` callback invocation and recursive layout
1122    /// - Display list generation for rendering
1123    /// - Accessibility tree synchronization
1124    ///
1125    /// # Arguments
1126    /// - `styled_dom`: The styled DOM to layout
1127    /// - `window_state`: Current window dimensions and state
1128    /// - `renderer_resources`: Resources for image sizing etc.
1129    /// - `debug_messages`: Optional vector to collect debug/warning messages
1130    ///
1131    /// # Returns
1132    /// The display list ready for rendering, or an error if layout fails.
1133    /// # Errors
1134    ///
1135    /// Returns a `LayoutError` if layout fails.
1136    pub fn layout_and_generate_display_list(
1137        &mut self,
1138        root_dom: StyledDom,
1139        window_state: &FullWindowState,
1140        renderer_resources: &RendererResources,
1141        system_callbacks: &ExternalSystemCallbacks,
1142        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1143    ) -> Result<(), solver3::LayoutError> {
1144        // E2E observability: this is the ONE funnel every layout pass goes
1145        // through — `regenerate_layout` and `incremental_relayout` in the
1146        // shells, `regenerate_layout()` and `relayout_only()` in the headless
1147        // E2E runner. Counting here (rather than at either caller) is what
1148        // makes `FrameReport::layout_passes` mean "layout ran", independently
1149        // of which scheduler decided it should.
1150        self.sync_frame_report();
1151        self.frame_report.layout_passes = self.frame_report.layout_passes.saturating_add(1);
1152
1153        // Clear previous results for a full relayout
1154        self.layout_results.clear();
1155
1156        // CRITICAL: Reset VirtualView invocation flags so check_reinvoke() returns
1157        // InitialRender for every tracked VirtualView. Without this, the VirtualViewManager
1158        // still has was_invoked=true from the previous frame, so it skips
1159        // re-invocation — but the child DOM was just destroyed by clear().
1160        self.virtual_view_manager.reset_all_invocation_flags();
1161
1162        if let Some(msgs) = debug_messages.as_mut() {
1163            msgs.push(LayoutDebugMessage::info(format!(
1164                "[layout_and_generate_display_list] Starting layout for DOM with {} nodes",
1165                root_dom.node_data.len()
1166            )));
1167        }
1168
1169        // Start recursive layout from the root DOM. Passes ownership — the
1170        // StyledDom ends up inside `layout_results` without a clone.
1171        let result = self.layout_dom_recursive(
1172            root_dom,
1173            window_state,
1174            renderer_resources,
1175            system_callbacks,
1176            debug_messages,
1177        );
1178
1179        if let Err(ref e) = result {
1180            if let Some(msgs) = debug_messages.as_mut() {
1181                msgs.push(LayoutDebugMessage::error(format!(
1182                    "[layout_and_generate_display_list] Layout FAILED: {e:?}"
1183                )));
1184            }
1185        } else if let Some(msgs) = debug_messages.as_mut() {
1186            msgs.push(LayoutDebugMessage::info(format!(
1187                "[layout_and_generate_display_list] Layout SUCCESS, layout_results count: {}",
1188                self.layout_results.len()
1189            )));
1190        }
1191
1192        // After successful layout, update the accessibility tree
1193        #[cfg(feature = "a11y")]
1194        if result.is_ok() {
1195            self.update_a11y_tree();
1196        }
1197
1198        // After layout, automatically scroll cursor into view if there's a focused text input
1199        if result.is_ok() {
1200            self.scroll_focused_cursor_into_view();
1201        }
1202
1203        result
1204    }
1205
1206    /// Run the real layout solver for a single `StyledDom` + viewport
1207    /// (taffy block/flex/grid → `layout_cache.calculated_positions`).
1208    ///
1209    /// Made `pub` for the web backend (`AzStartup_solveLayoutReal`),
1210    /// which lifts this from ARM to wasm to position the headless
1211    /// `StyledDom`. On web the display-list step inside `layout_document`
1212    /// is hot-patched out at lift time (web emits TLV patches, not a
1213    /// display list); positions are written to the cache *before* that
1214    /// step, so the lifted path still produces correct geometry.
1215    /// # Errors
1216    ///
1217    /// Returns a `LayoutError` if recursive layout fails.
1218    /// Measure a DOM headlessly: style + lay it out against `available`
1219    /// constraints using this window's fonts, images and system style,
1220    /// WITHOUT touching the window's live layout state (fresh scratch
1221    /// caches; nothing is written to `layout_results` / `layout_cache`).
1222    /// Returns the union of all laid-out node bounds — the DOM's actual
1223    /// content extent, even when the root is viewport-clamped.
1224    ///
1225    /// Primary use: `VirtualView` item sizing — lay out one item's DOM at
1226    /// the target width with a very tall `available.height` (e.g.
1227    /// `1_000_000.0`) and read back the height to derive per-item extents
1228    /// and the virtual scroll size. Cost: a full cold style+layout pass per
1229    /// call — cache results per item template where possible.
1230    #[cfg(feature = "std")]
1231    pub fn measure_dom(&self, dom: Dom, available: LogicalSize) -> LogicalSize {
1232        let styled_dom = StyledDom::create_from_dom(dom);
1233        self.measure_styled_dom(&styled_dom, available)
1234    }
1235
1236    /// [`Self::measure_dom`] for an already-styled DOM.
1237    #[cfg(feature = "std")]
1238    pub fn measure_styled_dom(
1239        &self,
1240        styled_dom: &StyledDom,
1241        available: LogicalSize,
1242    ) -> LogicalSize {
1243        let mut scratch_cache = Solver3LayoutCache {
1244            tree: None,
1245            calculated_positions: Vec::new(),
1246            viewport: None,
1247            scroll_ids: HashMap::new(),
1248            scroll_id_to_node_id: HashMap::new(),
1249            counters: HashMap::new(),
1250            float_cache: HashMap::new(),
1251            cache_map: solver3::cache::LayoutCacheMap::default(),
1252            previous_positions: Vec::new(),
1253            cached_display_list: None,
1254            prev_dom_ptr: 0,
1255            prev_viewport: LogicalRect::zero(),
1256        };
1257        let mut scratch_text = TextLayoutCache::new();
1258        let viewport = LogicalRect::new(LogicalPosition::zero(), available);
1259        let external = ExternalSystemCallbacks::rust_internal();
1260
1261        let layout_result = solver3::layout_document(
1262            &mut scratch_cache,
1263            &mut scratch_text,
1264            styled_dom,
1265            viewport,
1266            &self.font_manager,
1267            &BTreeMap::new(),
1268            &BTreeMap::new(),
1269            &mut None,
1270            None, // gpu cache: render-time only, geometry doesn't need it
1271            &self.renderer_resources,
1272            self.id_namespace,
1273            styled_dom.dom_id,
1274            false,
1275            Vec::new(),
1276            None,
1277            &self.image_cache,
1278            self.system_style.clone(),
1279            external.get_system_time_fn,
1280        );
1281        if layout_result.is_err() {
1282            return LogicalSize::zero();
1283        }
1284
1285        // Union of every node's absolute bounds = true content extent
1286        // (root.used_size alone can be clamped to the viewport).
1287        let Some(tree) = scratch_cache.tree.as_ref() else {
1288            return LogicalSize::zero();
1289        };
1290        let mut max_x = 0.0f32;
1291        let mut max_y = 0.0f32;
1292        for (idx, node) in tree.nodes.iter().enumerate() {
1293            let Some(size) = node.used_size else { continue };
1294            let pos = solver3::pos_get(&scratch_cache.calculated_positions, idx)
1295                .unwrap_or(LogicalPosition::zero());
1296            max_x = max_x.max(pos.x + size.width);
1297            max_y = max_y.max(pos.y + size.height);
1298        }
1299        LogicalSize::new(max_x, max_y)
1300    }
1301
1302    /// # Errors
1303    ///
1304    /// Returns a [`solver3::LayoutError`] if the solver fails to lay out the
1305    /// root DOM or any child (`VirtualView` / iframe) DOM.
1306    pub fn layout_dom_recursive(
1307        &mut self,
1308        styled_dom: StyledDom,
1309        window_state: &FullWindowState,
1310        renderer_resources: &RendererResources,
1311        system_callbacks: &ExternalSystemCallbacks,
1312        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1313    ) -> Result<(), solver3::LayoutError> {
1314        // Child DOMs (VirtualView / iframe) must NOT lay out into the root's
1315        // live cache: the impl below writes tree + calculated_positions into
1316        // `self.layout_cache`, so a child pass CLOBBERS the root's geometry —
1317        // `get_node_layout_rect` and the next incremental relayout then read
1318        // the child's tree instead of the root's (live bug: azul-maps' header
1319        // laid out 640x0/None → toolbar invisible and unclickable while the
1320        // map child DOM rendered fine). Children lay out cold by design, so
1321        // give a child a fresh scratch cache and restore the root's cache
1322        // afterwards; the per-DOM snapshot lives in `layout_results`. Nested
1323        // children stack their swaps.
1324        let is_child_dom = styled_dom.dom_id.inner != 0;
1325        if is_child_dom {
1326            let saved_root_cache = core::mem::take(&mut self.layout_cache);
1327            let result = self.layout_dom_recursive_impl(
1328                styled_dom,
1329                window_state,
1330                renderer_resources,
1331                system_callbacks,
1332                debug_messages,
1333            );
1334            self.layout_cache = saved_root_cache;
1335            return result;
1336        }
1337        self.layout_dom_recursive_impl(
1338            styled_dom,
1339            window_state,
1340            renderer_resources,
1341            system_callbacks,
1342            debug_messages,
1343        )
1344    }
1345
1346    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
1347    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1348    fn layout_dom_recursive_impl(
1349        &mut self,
1350        styled_dom: StyledDom,
1351        window_state: &FullWindowState,
1352        renderer_resources: &RendererResources,
1353        system_callbacks: &ExternalSystemCallbacks,
1354        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1355    ) -> Result<(), solver3::LayoutError> {
1356        // Optional memory-breakdown print for the CSS property cache.
1357        // Gated on AZ_MEM_BREAKDOWN=1; off costs one env-var read on
1358        // the first call (`OnceLock`-cached) and nothing after.
1359        static MEM_BREAKDOWN_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1360        // Optional AZ_PROFILE=cpu dump: per-phase wall-clock timings from
1361        // `Probe::span` spans (layout, style, cascade, paint, text-shape,
1362        // callbacks, …). Drains the thread-local buffer once per pass so
1363        // the printout reflects ONE layout/relayout frame — which makes it
1364        // easy to see which phase spiked during a stuttering frame.
1365        static CPU_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1366        // Optional AZ_PROFILE=cascade dump: top-N CSS properties by
1367        // cascade-walk count per layout pass. Narrow diagnostic for
1368        // prop-cache triage — not a general CPU profile.
1369        static CASCADE_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1370
1371        let dom_id = if styled_dom.dom_id.inner == 0 {
1372            DomId::ROOT_ID
1373        } else {
1374            styled_dom.dom_id
1375        };
1376
1377        // Children enter with a fresh scratch cache (see the wrapper above);
1378        // reset_incremental() is kept as belt-and-braces for any direct
1379        // callers and is a no-op on a fresh cache.
1380        if dom_id != DomId::ROOT_ID {
1381            self.layout_cache.reset_incremental();
1382        }
1383
1384        let viewport = LogicalRect {
1385            origin: LogicalPosition::zero(),
1386            size: window_state.size.dimensions,
1387        };
1388
1389        // Get the platform from system_style, falling back to compile-time detection
1390        let platform = self.system_style.as_ref().map_or_else(azul_css::system::Platform::current, |s| s.platform.clone());
1391
1392        // Font Resolution And Loading
1393        // This must happen BEFORE layout_document() is called
1394        {
1395            use crate::{
1396                solver3::getters::collect_and_resolve_font_chains_with_registration,
1397                text3::default::PathLoader,
1398            };
1399
1400            // Per-node font dirty tracking (P4):
1401            // Check font_dirty_nodes populated by build_compact_cache(),
1402            // which compares each node's font_family_hash against the
1403            // previous frame. This replaces the collision-prone global XOR
1404            // approach: XOR(a,b,a,b) == 0 even though fonts changed.
1405            //
1406            // Additional guard: compute an FxHash signature of
1407            // `prev_font_hashes` and compare against the one we stashed
1408            // after the last successful chain resolution. If it matches,
1409            // the DOM's font stacks are identical to what's already in
1410            // `font_chain_cache` — no resolver call needed. This catches
1411            // the common "repeated layout on unchanged DOM" case that
1412            // `font_dirty_nodes.len() == 0` misses, because the dirty
1413            // list is only re-computed inside `build_compact_cache`,
1414            // which most layouts do NOT re-run.
1415            let compact_cache_ref = styled_dom.css_property_cache.ptr.compact_cache.as_ref();
1416            let font_dirty_count = compact_cache_ref
1417                .map_or(1, |cc| cc.font_dirty_nodes.len()); // if no compact cache, treat as dirty
1418
1419            let font_stacks_sig = compact_cache_ref.map(|cc| {
1420                // Fast polynomial rolling hash over the `prev_font_hashes`
1421                // slice. Mixes each u64 with a multiplier + bit-rotation,
1422                // which is collision-resistant enough for our one-at-a-time
1423                // "did this DOM's font stacks change" comparison and an
1424                // order of magnitude cheaper than SipHash for ~300 nodes.
1425                let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1426                for &fh in &cc.prev_font_hashes {
1427                    h = h.rotate_left(13) ^ fh;
1428                    h = h.wrapping_mul(0x0100_0000_01b3);
1429                }
1430                h
1431            });
1432
1433            // Skip all font resolution steps only if the DOM's font stacks are
1434            // PROVABLY the same ones we last resolved: the signature over
1435            // `prev_font_hashes` must match the one stashed after the last
1436            // successful resolution, no node may be font-dirty, and the chain
1437            // cache must be populated.
1438            //
1439            // The `font_dirty_count == 0` clause used to be sufficient on its
1440            // own. That was wrong for a WHOLESALE DOM SWAP (`Update::RefreshDom`,
1441            // an e2e `mount`, a route change): the incoming StyledDom is a fresh
1442            // object whose compact cache has no dirty nodes relative to itself,
1443            // so the check said "nothing changed" and font resolution was skipped
1444            // ENTIRELY — a node with a brand-new `font-family` never got its font
1445            // loaded (parsed_fonts stayed flat even with eight distinct families
1446            // on screen) and silently rendered in the previous DOM's font. The
1447            // signature is what actually detects "these are different font
1448            // stacks", so it is now required, not merely an alternative.
1449            let font_requirements_unchanged = font_dirty_count == 0
1450                && font_stacks_sig.is_some()
1451                && font_stacks_sig == self.font_manager.last_resolved_font_stacks_sig
1452                && !self.font_manager.font_chain_cache.is_empty();
1453
1454            if font_requirements_unchanged {
1455                if let Some(msgs) = debug_messages.as_mut() {
1456                    msgs.push(LayoutDebugMessage::info(
1457                        "[FontLoading] Font requirements unchanged, skipping resolution (cached)".to_string(),
1458                    ));
1459                }
1460            } else {
1461                if let Some(msgs) = debug_messages.as_mut() {
1462                    msgs.push(LayoutDebugMessage::info(
1463                        "[FontLoading] Starting font resolution for DOM".to_string(),
1464                    ));
1465                }
1466
1467                // Merge font hash→families from compact cache into FontManager
1468                // so the reverse map accumulates across DOMs.
1469                if let Some(cc) = styled_dom.css_property_cache.ptr.compact_cache.as_ref() {
1470                    for (k, v) in &cc.font_hash_to_families {
1471                        self.font_manager.font_hash_to_families.insert(*k, v.clone());
1472                    }
1473                }
1474
1475                // Resolve chains (including the coverage-based prune
1476                // and the per-document scripts_hint), then delegate
1477                // the load-the-missing-ones dance to FontManager's
1478                // shared helper. Same logic that lives at
1479                // `FontContext::load_fonts_for_chains` and the CPU
1480                // rasterizer's preview pre-fill — one implementation,
1481                // three callers.
1482                crate::probe::sample_peak_rss("rss:before_font_chain");
1483                let mut chains = {
1484                    let _p = crate::probe::Probe::span("font_chain_resolve");
1485                    collect_and_resolve_font_chains_with_registration(
1486                        &styled_dom, &self.font_manager.fc_cache, &self.font_manager, &platform,
1487                    )
1488                };
1489                // [g80] localize where font_chain_cache drops to 0: chains right after collect_and_resolve.
1490                unsafe { crate::az_mark(0x60770_u32, chains.chains.len() as u32); }
1491                // WEB-LIFT last resort (the DEFINITIVE spot — the layout's own `chains` that
1492                // feed load_missing_for_chains below): the lifted font-query path can leave a
1493                // chain with NO fonts even when a fallback IS registered (generic→OS-name +
1494                // token/unicode query is lift-fragile). Append the first registered font to any
1495                // empty chain so load_missing loads it + text shapes instead of measuring 0.
1496                // Done here (azul-layout), NOT rust-fontconfig (which re-codegens the fragile
1497                // with_memory_fonts into a trapping shape).
1498                for chain in chains.chains.values_mut() {
1499                    let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
1500                        + chain.unicode_fallbacks.len();
1501                    if total == 0 {
1502                        if let Some((pattern, id)) = self.font_manager.fc_cache.list().first() {
1503                            chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
1504                                id: *id,
1505                                unicode_ranges: pattern.unicode_ranges.clone(),
1506                                fallbacks: Vec::new(),
1507                            });
1508                        }
1509                    }
1510                }
1511                // [g80] chains after the window.rs last-resort loop (values_mut path).
1512                unsafe { crate::az_mark(0x60774_u32, chains.chains.len() as u32); }
1513                // [az-web-lift 2026-06-05] REMOVED a WASM-ONLY diagnostic probe that computed
1514                // nchains/total_fonts/nreg here purely to write debug markers. Its
1515                // `chains.chains.values().map(|c| …).sum()` closure-iterator chain (and/or the
1516                // `fc_cache.list()` call) MIS-LIFTS on the web backend → memory-access-OOB → a
1517                // slice panic whose abort path spins in the OUTLINED_FUNCTION_2 dispatch (localized
1518                // via the 0x406C0=0xC0DE0007 marker: the explicit `for …values_mut()` loop ABOVE
1519                // lifts fine, only this closure-iterator form traps — same class as the css.rs
1520                // `map+collect → for-loop` lift fix). It was revert-able scaffolding; the chains
1521                // are sound (the for-loop iterated them), so load_missing_for_chains below proceeds.
1522                crate::probe::sample_peak_rss("rss:after_font_chain");
1523
1524                // Phase 3 (scout-on-demand): no snapshot-refresh
1525                // step is needed any more. rust-fontconfig 4.1
1526                // made `FcFontCache` a shared-state handle backed
1527                // by `Arc<RwLock<_>>`, so builder writes performed
1528                // during the `request_and_resolve_with_scripts`
1529                // call above are immediately visible to every
1530                // downstream `FontFallbackChain::resolve_char`
1531                // lookup without any explicit refresh.
1532                if let Some(msgs) = debug_messages.as_mut() {
1533                    msgs.push(LayoutDebugMessage::info(format!(
1534                        "[FontLoading] Resolved {} font chains",
1535                        chains.len()
1536                    )));
1537                }
1538
1539                let loader = PathLoader::new();
1540                crate::probe::sample_peak_rss("rss:before_font_load");
1541                let failed = {
1542                    let _p = crate::probe::Probe::span("font_load_missing");
1543                    self.font_manager.load_missing_for_chains(
1544                        &chains,
1545                        |bytes, index| loader.load_font_shared(bytes, index),
1546                    )
1547                };
1548                crate::probe::sample_peak_rss("rss:after_font_load");
1549                if let Some(msgs) = debug_messages.as_mut() {
1550                    for (font_id, error) in &failed {
1551                        msgs.push(LayoutDebugMessage::warning(format!(
1552                            "[FontLoading] Failed to load font {font_id:?}: {error}"
1553                        )));
1554                    }
1555                }
1556
1557                // Step 5b (FONT GC): everything the fonts of this document do NOT
1558                // reference is garbage — the node that pulled it in is gone. The
1559                // font tables used to be append-only (`AUDIT-TODO` in
1560                // `azul_core::resources`), so a window that cycled fonts never
1561                // gave one back. Evict here, where the definitive keep-set (the
1562                // chains just resolved + the family hashes in this DOM's property
1563                // cache) is in hand. Anything wrongly evicted is re-loaded by the
1564                // next `load_missing_for_chains`.
1565                //
1566                // Only for the single-DOM case: `font_chain_cache` is REPLACED per
1567                // DOM (see `set_font_chain_cache_with_sig` below), so with iframes
1568                // the keep-set describes just the DOM being laid out, and evicting
1569                // on it could drop a sibling DOM's font between its layout and its
1570                // raster.
1571                let single_dom = self
1572                    .layout_results
1573                    .keys()
1574                    .all(|d| *d == dom_id);
1575                if single_dom {
1576                    let keep_ids =
1577                        solver3::getters::collect_font_ids_from_chains(&chains);
1578                    let keep_hashes: std::collections::HashSet<u64> = styled_dom
1579                        .css_property_cache
1580                        .ptr
1581                        .compact_cache
1582                        .as_ref()
1583                        .map(|cc| cc.font_hash_to_families.keys().copied().collect())
1584                        .unwrap_or_default();
1585                    let evicted = self
1586                        .font_manager
1587                        .garbage_collect_fonts(&keep_ids, &keep_hashes);
1588                    if evicted > 0 {
1589                        if let Some(msgs) = debug_messages.as_mut() {
1590                            msgs.push(LayoutDebugMessage::info(format!(
1591                                "[FontLoading] GC evicted {evicted} unreferenced font(s)"
1592                            )));
1593                        }
1594                    }
1595                }
1596
1597                // Step 5: Update font chain cache (and stash the
1598                // `prev_font_hashes` signature so the next layout with
1599                // an identical DOM skips the resolver entirely).
1600                let fc_chains = chains.into_fontconfig_chains();
1601                // [g80] fc_chains after into_fontconfig_chains (the BTreeMap rebuild) — does it drop them?
1602                unsafe { crate::az_mark(0x60778_u32, fc_chains.len() as u32); }
1603                self.font_manager.set_font_chain_cache_with_sig(
1604                    fc_chains,
1605                    font_stacks_sig,
1606                );
1607                // [g80] font_chain_cache right after set (does set_font_chain_cache_with_sig persist it?).
1608                unsafe { crate::az_mark(0x6077C_u32, (self.font_manager.font_chain_cache.len() as u32)); }
1609            }
1610        }
1611        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
1612
1613        // Synchronize CSS transform / opacity keys with the current StyledDom
1614        // BEFORE building the display list. `display_list.rs` reads
1615        // `css_transform_keys` / `css_current_transform_values` (and the
1616        // opacity equivalents) to emit reference frames and opacity stacking
1617        // contexts — these maps are only populated by
1618        // `GpuValueCache::synchronize`. The returned events are merged into
1619        // `gpu_state_manager.pending_changes` so the renderer can later push
1620        // matching WebRender transactions alongside scrollbar transform
1621        // events.
1622        // The GPU transform/opacity sync only feeds the display list
1623        // (reference frames + opacity stacking contexts read by
1624        // display_list.rs). The web backend skips the display list
1625        // (SKIP_DISPLAY_LIST) and has no GPU, so skip this too — layout
1626        // geometry never depends on it (transforms are render-time). This
1627        // also avoids GpuValueCache::synchronize, which currently mis-lifts
1628        // to wasm (out-of-bounds access). Desktop is unaffected.
1629        if !self.skip_gpu_sync {
1630            let mut transform_opacity_events = self
1631                .gpu_state_manager
1632                .get_or_create_cache(dom_id)
1633                .synchronize(&styled_dom);
1634            // MWA-C-gpu_state: drop the PREVIOUS pass's events before
1635            // merging this one's. `pending_changes` has zero drain call
1636            // sites (both renderers re-read cache values via
1637            // synchronize_gpu_values / from_gpu_cache instead), and
1638            // merge() appends Vecs — so this accumulated every layout's
1639            // events forever, an unbounded leak in any long-running app.
1640            // The field stays as a same-pass event record until a consumer
1641            // exists (see FOLLOW-UPS).
1642            drop(self.gpu_state_manager.take_pending_changes());
1643            self.gpu_state_manager
1644                .pending_changes
1645                .merge(&mut transform_opacity_events);
1646        }
1647        // M12.7: in the headless web path the GPU cache is empty (sync skipped),
1648        // and `.clone()` of an empty hashbrown table drives RawTable::clone's
1649        // RawIterRange — which mis-lifts to wasm and loops forever. Use a fresh
1650        // empty cache instead (geometry doesn't use it). Desktop unchanged.
1651        let gpu_cache = if self.skip_gpu_sync {
1652            GpuValueCache::default()
1653        } else {
1654            self.gpu_state_manager.get_or_create_cache(dom_id).clone()
1655        };
1656
1657        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
1658        let cursor_locations = self.text_edit_manager.build_cursor_locations();
1659
1660        let mut display_list = {
1661            let _p = crate::probe::Probe::span("solver3_layout_document");
1662            solver3::layout_document(
1663                &mut self.layout_cache,
1664                &mut self.text_cache,
1665                &styled_dom,
1666                viewport,
1667                &self.font_manager,
1668                &scroll_offsets,
1669                &BTreeMap::new(),
1670                debug_messages,
1671                Some(&gpu_cache),
1672                &self.renderer_resources,
1673                self.id_namespace,
1674                dom_id,
1675                cursor_is_visible,
1676                cursor_locations,
1677                self.text_edit_manager.preedit_text.clone(),
1678                &self.image_cache,
1679                self.system_style.clone(),
1680                system_callbacks.get_system_time_fn,
1681            )?
1682        };
1683
1684        // Hint the allocator to return freed pages after the layout pass
1685        // drops its transient allocations (intrinsic sizing Vecs, etc.).
1686        crate::probe::hint_purge_allocator();
1687
1688        // M12.7: the headless web path needs the per-node geometry. Everything below —
1689        // scrollbar TransformKey registration, GPU-cache opacity/transform sync,
1690        // update_scrollbar_transforms — is webrender/display-list bookkeeping that web
1691        // doesn't use, and it contains an ARM loop whose lift to wasm never terminates
1692        // (an opt-folded `br self`; routing value resolves to a webrender code pointer).
1693        // So publish the geometry (tree + calculated_positions) to `layout_results` HERE
1694        // — the same DomLayoutResult the code below would store at the tail — so the
1695        // headless extractor (get_node_size / get_node_position, which read
1696        // layout_results via dom_to_layout) finds it; then skip the GPU bookkeeping.
1697        // Desktop (skip_gpu_sync == false) is unchanged.
1698        if self.skip_gpu_sync {
1699            if let Some(tree) = self.layout_cache.tree.clone() {
1700                self.layout_results.insert(
1701                    dom_id,
1702                    DomLayoutResult {
1703                        styled_dom,
1704                        layout_tree: tree,
1705                        calculated_positions: self.layout_cache.calculated_positions.clone(),
1706                        viewport,
1707                        display_list: DisplayList::default(),
1708                        scroll_ids: self.layout_cache.scroll_ids.clone(),
1709                        scroll_id_to_node_id: self.layout_cache.scroll_id_to_node_id.clone(),
1710                    },
1711                );
1712            }
1713            return Ok(());
1714        }
1715
1716        if *MEM_BREAKDOWN_ENABLED.get_or_init(azul_core::profile::memory_enabled) {
1717            let sr = styled_dom.memory_report();
1718            eprintln!("[MEM] StyledDom ({} nodes) total={} KiB", sr.node_count, sr.total_bytes() / 1024);
1719            eprintln!("[MEM]   node_hierarchy    {:>7} KiB", sr.node_hierarchy_bytes / 1024);
1720            eprintln!("[MEM]   node_data         {:>7} KiB", sr.node_data_bytes / 1024);
1721            eprintln!("[MEM]   styled_nodes      {:>7} KiB", sr.styled_nodes_bytes / 1024);
1722            eprintln!("[MEM]   cascade_info      {:>7} KiB", sr.cascade_info_bytes / 1024);
1723            eprintln!("[MEM]   tag_ids           {:>7} KiB", sr.tag_ids_bytes / 1024);
1724            eprintln!("[MEM]   non_leaf_nodes    {:>7} KiB", sr.non_leaf_nodes_bytes / 1024);
1725            let bd = &sr.css_property_cache;
1726            eprintln!("[MEM]   CssPropertyCache  {:>7} KiB", bd.total_bytes() / 1024);
1727            eprintln!("[MEM]     cascaded_props   {:>6} KiB", bd.cascaded_props_bytes / 1024);
1728            eprintln!("[MEM]     css_props        {:>6} KiB", bd.css_props_bytes / 1024);
1729            eprintln!("[MEM]   computed_values   {:>7} KiB", bd.computed_values_bytes / 1024);
1730            eprintln!("[MEM]   user_overridden   {:>7} KiB", bd.user_overridden_bytes / 1024);
1731            eprintln!("[MEM]   global_css_props  {:>7} KiB", bd.global_css_props_bytes / 1024);
1732            eprintln!("[MEM]   compact_cache     {:>7} KiB", bd.compact_cache_bytes / 1024);
1733            eprintln!("[MEM]   resolved_font_sz  {:>7} KiB", bd.resolved_font_sizes_bytes / 1024);
1734
1735            // solver3 LayoutCache breakdown
1736            let sc = self.layout_cache.memory_report();
1737            eprintln!("[MEM] Solver3 LayoutCache total={} KiB", sc.total_bytes() / 1024);
1738            if let Some(tr) = &sc.tree_report {
1739                eprintln!("[MEM]   LayoutTree        {:>7} KiB  ({} nodes)", sc.tree_bytes / 1024, tr.node_count);
1740                eprintln!("[MEM]     hot              {:>6} KiB", tr.hot_bytes / 1024);
1741                eprintln!("[MEM]     warm             {:>6} KiB", tr.warm_bytes / 1024);
1742                eprintln!("[MEM]     warm.inline      {:>6} KiB  (shaped text in CachedInlineLayout)", tr.warm_inline_layout_bytes / 1024);
1743                eprintln!("[MEM]     warm.taffy       {:>6} KiB", tr.warm_taffy_cache_bytes / 1024);
1744                eprintln!("[MEM]     cold             {:>6} KiB", tr.cold_bytes / 1024);
1745                eprintln!("[MEM]     children_arena   {:>6} KiB", tr.children_arena_bytes / 1024);
1746                eprintln!("[MEM]     dom_to_layout    {:>6} KiB", tr.dom_to_layout_bytes / 1024);
1747            }
1748            eprintln!("[MEM]   cache_map         {:>7} KiB  (Taffy-style 9+1 slots per node)", sc.cache_map_bytes / 1024);
1749            eprintln!("[MEM]   calculated_pos    {:>7} KiB", sc.calculated_positions_bytes / 1024);
1750            eprintln!("[MEM]   previous_pos      {:>7} KiB", sc.previous_positions_bytes / 1024);
1751            eprintln!("[MEM]   float_cache       {:>7} KiB", sc.float_cache_bytes / 1024);
1752            eprintln!("[MEM]   counters          {:>7} KiB", sc.counters_bytes / 1024);
1753            eprintln!("[MEM]   scroll_ids        {:>7} KiB", sc.scroll_ids_bytes / 1024);
1754            eprintln!("[MEM]   cached_display    {:>7} KiB", sc.cached_display_list_bytes / 1024);
1755
1756            // text shaping cache breakdown
1757            let tc = self.text_cache.memory_report();
1758            eprintln!("[MEM] TextShapingCache total={} KiB", tc.total_bytes() / 1024);
1759            eprintln!("[MEM]   logical_items     {:>7} KiB  ({} entries)", tc.logical_items_bytes / 1024, tc.logical_items_entries);
1760            eprintln!("[MEM]   visual_items      {:>7} KiB  ({} entries)", tc.visual_items_bytes / 1024, tc.visual_items_entries);
1761            eprintln!("[MEM]   shaped_items      {:>7} KiB  ({} entries)", tc.shaped_items_bytes / 1024, tc.shaped_items_entries);
1762            eprintln!("[MEM]     glyph_bytes     {:>7} KiB", tc.shaped_glyph_bytes / 1024);
1763            eprintln!("[MEM]     cluster_text    {:>7} KiB", tc.shaped_cluster_text_bytes / 1024);
1764            eprintln!("[MEM]   per_item_shaped   {:>7} KiB  ({} entries)", tc.per_item_shaped_bytes / 1024, tc.per_item_shaped_entries);
1765
1766            let grand_total = sr.total_bytes() + sc.total_bytes() + tc.total_bytes();
1767            eprintln!("[MEM] --- GRAND TOTAL (StyledDom + Solver3 + TextCache) = {} KiB = {:.2} MiB ---",
1768                grand_total / 1024, grand_total as f64 / 1_048_576.0);
1769
1770            #[cfg(feature = "probe")]
1771            {
1772                let (rss, _virt) = crate::probe::current_rss_bytes();
1773                let peak = crate::probe::peak_rss_bytes_pub();
1774                eprintln!("[MEM] after layout: current rss={:.1} MiB  peak rss={:.1} MiB  (unreturned={:.1} MiB)",
1775                    rss as f64 / 1048576.0, peak as f64 / 1048576.0,
1776                    (peak.saturating_sub(rss)) as f64 / 1048576.0);
1777                eprintln!("[MEM] accounted / rss = {:.1}% — the gap is allocator overhead + unreturned transient pages + fonts/images + misc",
1778                    grand_total as f64 * 100.0 / (rss as f64).max(1.0));
1779            }
1780        }
1781
1782        if *CPU_ENABLED.get_or_init(azul_core::profile::cpu_enabled) {
1783            let events = crate::probe::Probe::drain();
1784            crate::probe::print_drained_events("layout pass", &events);
1785        }
1786
1787        if *CASCADE_ENABLED.get_or_init(azul_core::profile::cascade_enabled) {
1788            let counts = azul_core::prop_cache::drain_css_prop_counts();
1789            let total: usize = counts.iter().map(|(_, n)| *n).sum();
1790            if total > 0 {
1791                eprintln!("[CASCADE] cascade-walks this pass: {total} total");
1792                for (label, n) in counts.iter().take(20) {
1793                    eprintln!("[CASCADE]   {n:>8}  {label}");
1794                }
1795            }
1796        }
1797
1798        let tree = self
1799            .layout_cache
1800            .tree
1801            .clone()
1802            .ok_or(solver3::LayoutError::InvalidTree)?;
1803
1804        // Get scroll IDs from cache (they were computed during layout_document)
1805        let scroll_ids = self.layout_cache.scroll_ids.clone();
1806        let scroll_id_to_node_id = self.layout_cache.scroll_id_to_node_id.clone();
1807
1808        // Register scrollbar thumb TransformKeys from the display list into the GPU cache.
1809        // paint_scrollbars() creates TransformKey::unique() for each thumb. We need to
1810        // register those keys in the GPU cache so that update_scrollbar_transforms() can
1811        // update the values during GPU-only scroll (without display list rebuild).
1812        // Also register opacity keys from the display list the same way.
1813        {
1814            use crate::solver3::display_list::{DisplayListItem, ScrollbarDrawInfo};
1815            let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id);
1816            for item in &display_list.items {
1817                if let DisplayListItem::ScrollBarStyled { info } = item {
1818                    if let Some(hit_id) = &info.hit_id {
1819                        // Register transform keys
1820                        if let Some(transform_key) = info.thumb_transform_key {
1821                            match hit_id {
1822                                ScrollbarHitId::VerticalThumb(_, nid) => {
1823                                    if !gpu_cache.transform_keys.contains_key(nid) {
1824                                        gpu_cache.transform_keys.insert(*nid, transform_key);
1825                                        gpu_cache.current_transform_values.insert(*nid, info.thumb_initial_transform);
1826                                    }
1827                                }
1828                                ScrollbarHitId::HorizontalThumb(_, nid) => {
1829                                    if !gpu_cache.h_transform_keys.contains_key(nid) {
1830                                        gpu_cache.h_transform_keys.insert(*nid, transform_key);
1831                                        gpu_cache.h_current_transform_values.insert(*nid, info.thumb_initial_transform);
1832                                    }
1833                                }
1834                                _ => {}
1835                            }
1836                        }
1837
1838                        // Register opacity keys (same pattern as transform keys).
1839                        // The display list always generates an OpacityKey for each
1840                        // scrollbar. We mirror these into the GPU cache so that
1841                        // synchronize_scrollbar_opacity can update the values and
1842                        // synchronize_gpu_values can push them to WebRender.
1843                        //
1844                        // Initial opacity depends on visibility mode:
1845                        //   Always       → 1.0 (legacy scrollbar, always visible)
1846                        //   WhenScrolling → 0.0 (overlay scrollbar, hidden until scroll)
1847                        //   Auto         → 0.0 (same as WhenScrolling)
1848                        let initial_opacity = if info.visibility == azul_css::props::style::scrollbar::ScrollbarVisibilityMode::Always {
1849                            1.0
1850                        } else {
1851                            0.0
1852                        };
1853                        if let Some(opacity_key) = info.opacity_key {
1854                            match hit_id {
1855                                ScrollbarHitId::VerticalThumb(_, nid) => {
1856                                    let key = (dom_id, *nid);
1857                                    if let std::collections::hash_map::Entry::Vacant(e) = gpu_cache.scrollbar_v_opacity_keys.entry(key) {
1858                                        e.insert(opacity_key);
1859                                        gpu_cache.scrollbar_v_opacity_values.insert(key, initial_opacity);
1860                                    }
1861                                }
1862                                ScrollbarHitId::HorizontalThumb(_, nid) => {
1863                                    let key = (dom_id, *nid);
1864                                    if let std::collections::hash_map::Entry::Vacant(e) = gpu_cache.scrollbar_h_opacity_keys.entry(key) {
1865                                        e.insert(opacity_key);
1866                                        gpu_cache.scrollbar_h_opacity_values.insert(key, initial_opacity);
1867                                    }
1868                                }
1869                                _ => {}
1870                            }
1871                        }
1872                    }
1873                }
1874            }
1875        }
1876
1877        // Synchronize scrollbar transforms AFTER layout
1878        self.gpu_state_manager
1879            .update_scrollbar_transforms(dom_id, &self.scroll_manager, &tree);
1880
1881        // Scan for VirtualViews *after* the initial layout pass
1882        // Pass styled_dom directly — layout_results isn't populated yet at this point
1883        let vviews = Self::scan_for_virtual_views(&styled_dom, &tree, &self.layout_cache.calculated_positions);
1884
1885        if std::env::var("AZ_MAP_DEBUG").is_ok() {
1886            eprintln!("[vview] scan found {} VirtualView node(s): {:?}", vviews.len(),
1887                vviews.iter().map(|(n, b)| (n.index(), b.origin.x, b.origin.y, b.size.width, b.size.height)).collect::<Vec<_>>());
1888        }
1889
1890        for (node_id, bounds) in vviews {
1891            if let Some(child_dom_id) = self.invoke_virtual_view_callback_with_dom(
1892                dom_id,
1893                node_id,
1894                bounds,
1895                Some(&styled_dom),
1896                window_state,
1897                renderer_resources,
1898                system_callbacks,
1899                debug_messages,
1900            ) {
1901                // Replace the VirtualViewPlaceholder with the real VirtualView item.
1902                // The placeholder was emitted by generate_display_list() at the
1903                // correct position (outside any scroll frame, inside the parent clip).
1904                let mut replaced = false;
1905                for item in &mut display_list.items {
1906                    if let solver3::display_list::DisplayListItem::VirtualViewPlaceholder {
1907                        node_id: ref placeholder_nid,
1908                        bounds: ref placeholder_bounds,
1909                        clip_rect: ref placeholder_clip,
1910                        ..
1911                    } = item
1912                    {
1913                        if *placeholder_nid == node_id {
1914                            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1915                                eprintln!(
1916                                    "[vview] placeholder swap: node={} placeholder_bounds={:?} scan_bounds={:?}",
1917                                    node_id.index(), placeholder_bounds.inner(), bounds
1918                                );
1919                            }
1920                            *item = solver3::display_list::DisplayListItem::VirtualView {
1921                                child_dom_id,
1922                                bounds: *placeholder_bounds,
1923                                clip_rect: *placeholder_clip,
1924                            };
1925                            replaced = true;
1926                            break;
1927                        }
1928                    }
1929                }
1930
1931                if !replaced {
1932                    // Fallback: if no placeholder found (shouldn't happen), append at end
1933                    display_list
1934                        .items
1935                        .push(solver3::display_list::DisplayListItem::VirtualView {
1936                            child_dom_id,
1937                            bounds: bounds.into(),
1938                            clip_rect: bounds.into(),
1939                        });
1940                }
1941            }
1942        }
1943
1944        // Store the final layout result for this DOM. `styled_dom` was passed
1945        // in by value, so we move it into the map without cloning.
1946        self.layout_results.insert(
1947            dom_id,
1948            DomLayoutResult {
1949                styled_dom,
1950                layout_tree: tree,
1951                calculated_positions: self.layout_cache.calculated_positions.clone(),
1952                viewport,
1953                display_list,
1954                scroll_ids,
1955                scroll_id_to_node_id,
1956            },
1957        );
1958
1959        // Clear scroll dirty flag — the new display list has
1960        // up-to-date scroll offsets embedded in PushScrollFrame items.
1961        self.scroll_manager.clear_scroll_dirty();
1962
1963        // Same contract for the text-edit manager: `display_list_dirty` means
1964        // "the caret / selection / preedit changed, so the display list is
1965        // stale". The display list has just been rebuilt WITH that state
1966        // (`cursor_is_visible` / `cursor_locations` / `text_selections` feed
1967        // `LayoutContext` above), so the repaint it was asking for has been
1968        // delivered.
1969        //
1970        // Nothing in the engine cleared it before: `mark_dirty` had four
1971        // callers (`clear_editing`, `set_preedit`, `clear_preedit`, and the
1972        // remap-on-unmount path) and NO consumer anywhere in the workspace, so
1973        // the first focus change latched it true for the rest of the window's
1974        // life. `assert_state_machines_idle` reads it as "a permanently dirty
1975        // flag is a permanent repaint", and that is exactly what it was.
1976        self.text_edit_manager.display_list_dirty = false;
1977
1978        Ok(())
1979    }
1980
1981    fn scan_for_virtual_views(
1982        styled_dom: &StyledDom,
1983        layout_tree: &LayoutTree,
1984        calculated_positions: &solver3::PositionVec,
1985    ) -> Vec<(NodeId, LogicalRect)> {
1986        let node_data_container = styled_dom.node_data.as_container();
1987        layout_tree
1988            .nodes
1989            .iter()
1990            .enumerate()
1991            .filter_map(|(idx, node)| {
1992                let node_dom_id = node.dom_node_id?;
1993                let node_data = node_data_container.get(node_dom_id)?;
1994                if matches!(node_data.get_node_type(), NodeType::VirtualView) {
1995                    let pos = calculated_positions.get(idx).copied().unwrap_or_default();
1996                    let size = node.used_size.unwrap_or_default();
1997                    Some((node_dom_id, LogicalRect::new(pos, size)))
1998                } else {
1999                    None
2000                }
2001            })
2002            .collect()
2003    }
2004
2005    /// Invoke every `RenderImageCallback` image once and cache the produced
2006    /// image, keyed by the ORIGINAL callback image's hash.
2007    ///
2008    /// The CPU renderer (`cpurender`) cannot invoke image callbacks itself — it
2009    /// draws a grey placeholder for `DecodedImage::Callback` (e.g. the `AzulPaint`
2010    /// canvas: an `<img>` whose data is a callback). The GPU path handles this
2011    /// in `process_image_callback_updates` (producing `WebRender` textures); this
2012    /// is the CPU equivalent, producing images that `render_frame` blits via
2013    /// [`crate::cpurender`]'s image path.
2014    ///
2015    /// Pass the backend's GL context. In CPU render mode it is effectively
2016    /// `None`/unusable, so a callback like `AzulPaint`'s `render_canvas` takes its
2017    /// CPU branch and returns a raw `RawImage`. The result is stored in
2018    /// [`Self::cpu_image_callback_results`] and threaded into `CpuRenderState`.
2019    ///
2020    /// No-op (clears the cache) when there are no callback images, so normal
2021    /// apps pay nothing.
2022    pub fn invoke_cpu_image_callbacks(&mut self, gl_context: &OptionGlContextPtr) {
2023        use azul_core::resources::DecodedImage;
2024
2025        // Phase 1: collect every callback-image node + its laid-out size.
2026        let hidpi_factor = self.current_window_state.size.get_hidpi_factor();
2027        let mut to_invoke: Vec<(DomId, NodeId, ImageRefHash, HidpiAdjustedBounds, ImageRef)> =
2028            Vec::new();
2029        for (dom_id, lr) in &self.layout_results {
2030            let node_data_container = lr.styled_dom.node_data.as_container();
2031            for (idx, node) in lr.layout_tree.nodes.iter().enumerate() {
2032                let Some(node_dom_id) = node.dom_node_id else {
2033                    continue;
2034                };
2035                let Some(node_data) = node_data_container.get(node_dom_id) else {
2036                    continue;
2037                };
2038                if let NodeType::Image(image_ref) = node_data.get_node_type() {
2039                    if !matches!(image_ref.get_data(), DecodedImage::Callback(_)) {
2040                        continue;
2041                    }
2042                    let _ = idx;
2043                    let size = node.used_size.unwrap_or_default();
2044                    let bounds = HidpiAdjustedBounds {
2045                        logical_size: size,
2046                        hidpi_factor,
2047                    };
2048                    to_invoke.push((
2049                        *dom_id,
2050                        node_dom_id,
2051                        image_ref.get_hash(),
2052                        bounds,
2053                        // NodeType::Image wraps the ImageRef in BoxOrStatic; deref
2054                        // to clone the inner ImageRef (cheap, refcounted).
2055                        (**image_ref).clone(),
2056                    ));
2057                }
2058            }
2059        }
2060
2061        if to_invoke.is_empty() {
2062            self.cpu_image_callback_results.clear();
2063            return;
2064        }
2065
2066        // Phase 2: invoke each callback, collecting the produced image by the
2067        // ORIGINAL callback image's hash (so cpurender can look it up from the
2068        // unchanged display-list `Image` item). Results go into a local map so
2069        // the immutable borrows of image_cache/fc_cache don't conflict with the
2070        // mutable store at the end.
2071        let mut results: BTreeMap<ImageRefHash, ImageRef> = BTreeMap::new();
2072        for (dom_id, node_id, hash, bounds, image_ref) in to_invoke {
2073            let domnode_id = DomNodeId {
2074                dom: dom_id,
2075                node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
2076            };
2077            let info = crate::callbacks::RenderImageCallbackInfo::new(
2078                domnode_id,
2079                bounds,
2080                gl_context,
2081                &self.image_cache,
2082                &self.font_manager.fc_cache,
2083            );
2084            let produced = match image_ref.get_data() {
2085                DecodedImage::Callback(core_callback) if core_callback.callback.cb != 0 => {
2086                    let cb = crate::callbacks::RenderImageCallback::from_core(&core_callback.callback);
2087                    let refany = core_callback.refany.clone();
2088                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (cb.cb)(refany, info)))
2089                        .ok()
2090                }
2091                _ => None,
2092            };
2093            if let Some(img) = produced {
2094                results.insert(hash, img);
2095            }
2096        }
2097        self.cpu_image_callback_results = results;
2098    }
2099
2100    /// Handle a window resize by updating the cached layout.
2101    ///
2102    /// This method leverages solver3's incremental layout system to efficiently
2103    /// relayout only the affected parts of the tree when the window size changes.
2104    ///
2105    /// Returns the new display list after the resize.
2106    /// # Errors
2107    ///
2108    /// Returns a `LayoutError` if relayout on resize fails.
2109    pub fn resize_window(
2110        &mut self,
2111        styled_dom: StyledDom,
2112        new_size: LogicalSize,
2113        renderer_resources: &RendererResources,
2114        system_callbacks: &ExternalSystemCallbacks,
2115        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2116    ) -> Result<DisplayList, solver3::LayoutError> {
2117        // Create a temporary FullWindowState with the new size
2118        let mut window_state = FullWindowState::default();
2119        window_state.size.dimensions = new_size;
2120
2121        let dom_id = styled_dom.dom_id;
2122
2123        self.layout_and_generate_display_list(
2124            styled_dom,
2125            &window_state,
2126            renderer_resources,
2127            system_callbacks,
2128            debug_messages,
2129        )?;
2130
2131        // Retrieve the display list from the layout result
2132        // We need to take ownership of the display list, so we replace it with an empty one
2133        self.layout_results
2134            .get_mut(&dom_id)
2135            .map(|result| std::mem::take(&mut result.display_list))
2136            .ok_or(solver3::LayoutError::InvalidTree)
2137    }
2138
2139    /// Clear all caches (useful for testing or when switching documents).
2140    pub fn clear_caches(&mut self) {
2141        self.layout_cache = Solver3LayoutCache {
2142            tree: None,
2143            calculated_positions: Vec::new(),
2144            viewport: None,
2145            scroll_ids: HashMap::new(),
2146            scroll_id_to_node_id: HashMap::new(),
2147            counters: HashMap::new(),
2148            float_cache: HashMap::new(),
2149            cache_map: solver3::cache::LayoutCacheMap::default(),
2150            previous_positions: Vec::new(),
2151                cached_display_list: None,
2152                prev_dom_ptr: 0,
2153                prev_viewport: LogicalRect::zero(),
2154        };
2155        self.text_cache = TextLayoutCache::new();
2156        self.layout_results.clear();
2157        self.scroll_manager = ScrollManager::new();
2158    }
2159
2160    /// Set scroll position for a node
2161    pub fn set_scroll_position(&mut self, dom_id: DomId, node_id: NodeId, scroll: ScrollPosition) {
2162        // Convert ScrollPosition to the internal representation
2163        #[cfg(feature = "std")]
2164        let now = Instant::now();
2165        #[cfg(not(feature = "std"))]
2166        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
2167
2168        self.scroll_manager.update_node_bounds(
2169            dom_id,
2170            node_id,
2171            scroll.parent_rect,
2172            scroll.children_rect,
2173            now.clone(),
2174        );
2175        self.scroll_manager
2176            .set_scroll_position(dom_id, node_id, scroll.children_rect.origin, now);
2177    }
2178
2179    /// Get scroll position for a node
2180    pub fn get_scroll_position(&self, dom_id: DomId, node_id: NodeId) -> Option<ScrollPosition> {
2181        let states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
2182        states.get(&node_id).copied()
2183    }
2184
2185    /// Set selection state for a DOM (no-op: `selection_manager` removed, `multi_cursor` handles this)
2186    pub fn set_selection(&mut self, _dom_id: DomId, _selection: SelectionState) {
2187        // no-op: selection_manager removed
2188    }
2189
2190    /// Get selection state for a DOM (always None: `selection_manager` removed)
2191    pub const fn get_selection(&self, _dom_id: DomId) -> Option<&SelectionState> {
2192        None
2193    }
2194
2195    /// Invoke a `VirtualView` callback and perform layout on the returned DOM.
2196    ///
2197    /// This is the entry point that looks up the necessary `VirtualViewNode` data before
2198    /// delegating to the core implementation logic.
2199    /// Invoke a `VirtualView` callback for a node. Returns the child `DomId` if the
2200    /// callback was invoked and the child DOM was laid out.
2201    ///
2202    /// This calls the `VirtualView`'s own `RefAny` callback (NOT the main `layout()` callback),
2203    /// swaps the child `StyledDom`, and re-layouts only the `VirtualView` sub-tree.
2204    pub fn invoke_virtual_view_callback(
2205        &mut self,
2206        parent_dom_id: DomId,
2207        node_id: NodeId,
2208        bounds: LogicalRect,
2209        window_state: &FullWindowState,
2210        renderer_resources: &RendererResources,
2211        system_callbacks: &ExternalSystemCallbacks,
2212        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2213    ) -> Option<DomId> {
2214        self.invoke_virtual_view_callback_with_dom(
2215            parent_dom_id, node_id, bounds, None,
2216            window_state, renderer_resources, system_callbacks, debug_messages,
2217        )
2218    }
2219
2220    /// Invoke a `VirtualView` callback. If `styled_dom_override` is provided, use it
2221    /// instead of reading from `self.layout_results` (needed during initial
2222    /// layout when `layout_results` isn't populated yet).
2223    fn invoke_virtual_view_callback_with_dom(
2224        &mut self,
2225        parent_dom_id: DomId,
2226        node_id: NodeId,
2227        bounds: LogicalRect,
2228        styled_dom_override: Option<&StyledDom>,
2229        window_state: &FullWindowState,
2230        renderer_resources: &RendererResources,
2231        system_callbacks: &ExternalSystemCallbacks,
2232        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2233    ) -> Option<DomId> {
2234        if let Some(msgs) = debug_messages {
2235            msgs.push(LayoutDebugMessage::info(format!(
2236                "invoke_virtual_view_callback called for node {node_id:?}"
2237            )));
2238        }
2239
2240        // Use the override styled_dom if provided, otherwise read from layout_results
2241        let virtual_view_node = if let Some(styled_dom) = styled_dom_override {
2242            let node_data_container = styled_dom.node_data.as_container();
2243            let node_data = node_data_container.get(node_id)?;
2244            node_data.get_virtual_view_node_ref()?.clone()
2245        } else {
2246            let layout_result = self.layout_results.get(&parent_dom_id)?;
2247            if let Some(msgs) = debug_messages {
2248                msgs.push(LayoutDebugMessage::info(format!(
2249                    "Got layout result for parent DOM {parent_dom_id:?}"
2250                )));
2251            }
2252            let node_data_container = layout_result.styled_dom.node_data.as_container();
2253            let node_data = node_data_container.get(node_id)?;
2254            if let Some(vv) = node_data.get_virtual_view_node_ref() { vv.clone() } else {
2255                if let Some(msgs) = debug_messages {
2256                    msgs.push(LayoutDebugMessage::info(format!(
2257                        "Node is NOT VirtualView, type = {:?}",
2258                        node_data.get_node_type()
2259                    )));
2260                }
2261                return None;
2262            }
2263        };
2264
2265        if let Some(msgs) = debug_messages {
2266            msgs.push(LayoutDebugMessage::info("Node is VirtualView type".to_string()));
2267        }
2268
2269        // Call the actual implementation with all necessary data
2270        self.invoke_virtual_view_callback_impl(
2271            parent_dom_id,
2272            node_id,
2273            &virtual_view_node,
2274            bounds,
2275            window_state,
2276            renderer_resources,
2277            system_callbacks,
2278            debug_messages,
2279        )
2280    }
2281
2282    /// Core implementation for invoking a `VirtualView` callback and managing the recursive layout.
2283    ///
2284    /// This method implements the 5 conditional re-invocation rules by coordinating
2285    /// with the `VirtualViewManager` and `ScrollManager`.
2286    ///
2287    /// # Returns
2288    ///
2289    /// `Some(child_dom_id)` if the callback was invoked and the child DOM was laid out.
2290    /// The parent's display list generator will then use this ID to reference the child's
2291    /// display list. Returns `None` if the callback was not invoked.
2292    #[allow(clippy::too_many_lines)] // 5 re-invocation rules + recursive layout in one flow
2293    fn invoke_virtual_view_callback_impl(
2294        &mut self,
2295        parent_dom_id: DomId,
2296        node_id: NodeId,
2297        virtual_view_node: &azul_core::dom::VirtualViewNode,
2298        bounds: LogicalRect,
2299        window_state: &FullWindowState,
2300        renderer_resources: &RendererResources,
2301        system_callbacks: &ExternalSystemCallbacks,
2302        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2303    ) -> Option<DomId> {
2304        // Get current time from system callbacks for state updates
2305        let now = (system_callbacks.get_system_time_fn.cb)();
2306
2307        // Update node bounds in the scroll manager. This is necessary for the VirtualViewManager
2308        // to correctly detect edge scroll conditions.
2309        self.scroll_manager.update_node_bounds(
2310            parent_dom_id,
2311            node_id,
2312            bounds,
2313            LogicalRect::new(LogicalPosition::zero(), bounds.size), // Initial content_rect
2314            now,
2315        );
2316
2317        // Check with the VirtualViewManager to see if re-invocation is necessary.
2318        // It handles all 5 conditional rules.
2319        let Some(reason) = self.virtual_view_manager.check_reinvoke(
2320            parent_dom_id,
2321            node_id,
2322            &self.scroll_manager,
2323            bounds,
2324        ) else {
2325            // No re-invocation needed, but we still need the child_dom_id for the display list.
2326            return self
2327                .virtual_view_manager
2328                .get_nested_dom_id(parent_dom_id, node_id);
2329        };
2330
2331        if let Some(msgs) = debug_messages {
2332            msgs.push(LayoutDebugMessage::info(format!(
2333                "VirtualView ({parent_dom_id:?}, {node_id:?}) - Reason: {reason:?}"
2334            )));
2335        }
2336
2337        let scroll_offset = self
2338            .scroll_manager
2339            .get_current_offset(parent_dom_id, node_id)
2340            .unwrap_or_default();
2341
2342        let hidpi_factor = window_state.size.get_hidpi_factor();
2343
2344        // Create VirtualViewCallbackInfo with the most up-to-date state
2345        let mut callback_info = azul_core::callbacks::VirtualViewCallbackInfo::new(
2346            reason,
2347            &self.font_manager.fc_cache,
2348            &self.image_cache,
2349            window_state.theme,
2350            HidpiAdjustedBounds {
2351                logical_size: bounds.size,
2352                hidpi_factor,
2353            },
2354            bounds.size,
2355            scroll_offset,
2356            bounds.size,
2357            LogicalPosition::zero(),
2358        );
2359        // Inject the headless-measure hook so the VirtualView callback can
2360        // size item DOMs (VirtualViewCallbackInfo::measure_dom → the
2361        // trampoline below → LayoutWindow::measure_dom on scratch caches).
2362        // Same raw-window-pointer liveness contract as CallbackInfo.
2363        #[cfg(feature = "std")]
2364        callback_info.set_measure_dom_fn(
2365            virtual_view_measure_dom_trampoline,
2366            core::ptr::from_mut::<Self>(self).cast(),
2367        );
2368
2369        // Clone the user data for the callback
2370        let callback_data = virtual_view_node.refany.clone();
2371
2372        // Invoke the user's VirtualView callback
2373        let callback_return = (virtual_view_node.callback.cb)(callback_data, callback_info);
2374
2375        // Mark the VirtualView as invoked to prevent duplicate InitialRender calls
2376        self.virtual_view_manager
2377            .mark_invoked(parent_dom_id, node_id, reason);
2378
2379        // Get the child Dom from the callback's return value, then convert to StyledDom
2380        let mut child_styled_dom = match callback_return.dom {
2381            azul_core::dom::OptionDom::Some(dom) => {
2382                // Convert Dom → StyledDom (single deferred cascade pass)
2383                StyledDom::create_from_dom(dom)
2384            },
2385            azul_core::dom::OptionDom::None => {
2386                // If the callback returns None, it's an optimization hint.
2387                if reason == VirtualViewCallbackReason::InitialRender {
2388                    // For the very first render, create an empty div as a fallback.
2389                    let mut empty_dom = Dom::create_div();
2390                    let empty_css = Css::empty();
2391                    StyledDom::create(&mut empty_dom, empty_css)
2392                } else {
2393                    // For subsequent calls, returning None means "keep the old DOM".
2394                    // We just need to update the scroll info and return the existing child ID.
2395                    self.virtual_view_manager.update_virtual_view_info(
2396                        parent_dom_id,
2397                        node_id,
2398                        callback_return.scroll_size,
2399                        callback_return.virtual_scroll_size,
2400                    );
2401                    // Propagate virtual scroll bounds to ScrollManager
2402                    self.scroll_manager.update_virtual_scroll_bounds(
2403                        parent_dom_id,
2404                        node_id,
2405                        callback_return.virtual_scroll_size,
2406                        Some(callback_return.scroll_offset),
2407                    );
2408                    return self
2409                        .virtual_view_manager
2410                        .get_nested_dom_id(parent_dom_id, node_id);
2411                }
2412            }
2413        };
2414
2415        // Get or create a unique DomId for the VirtualView's content
2416        let child_dom_id = self
2417            .virtual_view_manager
2418            .get_or_create_nested_dom_id(parent_dom_id, node_id);
2419        child_styled_dom.dom_id = child_dom_id;
2420
2421        // Update the VirtualViewManager with the new scroll sizes from the callback
2422        self.virtual_view_manager.update_virtual_view_info(
2423            parent_dom_id,
2424            node_id,
2425            callback_return.scroll_size,
2426            callback_return.virtual_scroll_size,
2427        );
2428        // Propagate virtual scroll bounds to ScrollManager
2429        self.scroll_manager.update_virtual_scroll_bounds(
2430            parent_dom_id,
2431            node_id,
2432            callback_return.virtual_scroll_size,
2433            Some(callback_return.scroll_offset),
2434        );
2435
2436        // **RECURSIVE LAYOUT STEP**
2437        // Perform a full layout pass on the child DOM. This will recursively handle
2438        // any VirtualViews within this VirtualView. Ownership of the child DOM
2439        // is transferred into `layout_results`.
2440        self.layout_dom_recursive(
2441            child_styled_dom,
2442            window_state,
2443            renderer_resources,
2444            system_callbacks,
2445            debug_messages,
2446        )
2447        .ok()?;
2448
2449        Some(child_dom_id)
2450    }
2451
2452    // Query methods for callbacks
2453
2454    /// Get the size of a laid-out node
2455    pub fn get_node_size(&self, node_id: DomNodeId) -> Option<LogicalSize> {
2456        let layout_result = self.layout_results.get(&node_id.dom)?;
2457        let nid = node_id.node.into_crate_internal()?;
2458        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
2459        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
2460        let layout_index = *layout_indices.first()?;
2461        let layout_node = layout_result.layout_tree.get(layout_index)?;
2462        layout_node.used_size
2463    }
2464
2465    /// Get the position of a laid-out node
2466    pub fn get_node_position(&self, node_id: DomNodeId) -> Option<LogicalPosition> {
2467        let layout_result = self.layout_results.get(&node_id.dom)?;
2468        let nid = node_id.node.into_crate_internal()?;
2469        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
2470        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
2471        let layout_index = *layout_indices.first()?;
2472        let position = layout_result.calculated_positions.get(layout_index)?;
2473        Some(*position)
2474    }
2475
2476    /// Get the hit test bounds of a node from the display list
2477    ///
2478    /// This is more reliable than `get_node_position` + `get_node_size` because
2479    /// the display list always contains the correct final rendered positions,
2480    /// including for nodes that may not have entries in `calculated_positions`.
2481    pub fn get_node_hit_test_bounds(&self, node_id: DomNodeId) -> Option<LogicalRect> {
2482        use crate::solver3::display_list::DisplayListItem;
2483
2484        let layout_result = self.layout_results.get(&node_id.dom)?;
2485        let nid = node_id.node.into_crate_internal()?;
2486
2487        // Look up tag_id from the authoritative tag_ids_to_node_ids mapping
2488        let nid_encoded = NodeHierarchyItemId::from_crate_internal(Some(nid));
2489        let tag_id = layout_result.styled_dom.tag_ids_to_node_ids.iter()
2490            .find(|m| m.node_id == nid_encoded)?
2491            .tag_id
2492            .inner;
2493
2494        // Search the display list for a HitTestArea with matching tag
2495        // Note: tag is now (u64, u16) tuple where tag.0 is the TagId.inner
2496        for item in &layout_result.display_list.items {
2497            if let DisplayListItem::HitTestArea { bounds, tag } = item {
2498                if tag.0 == tag_id && bounds.0.size.width > 0.0 && bounds.0.size.height > 0.0 {
2499                    return Some(bounds.0);
2500                }
2501            }
2502        }
2503        None
2504    }
2505
2506    /// Get the parent of a node
2507    pub fn get_parent(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2508        let layout_result = self.layout_results.get(&node_id.dom)?;
2509        let nid = node_id.node.into_crate_internal()?;
2510        let parent_id = layout_result
2511            .styled_dom
2512            .node_hierarchy
2513            .as_container()
2514            .get(nid)?
2515            .parent_id()?;
2516        Some(DomNodeId {
2517            dom: node_id.dom,
2518            node: NodeHierarchyItemId::from_crate_internal(Some(parent_id)),
2519        })
2520    }
2521
2522    /// Get the first child of a node
2523    pub fn get_first_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2524        let layout_result = self.layout_results.get(&node_id.dom)?;
2525        let nid = node_id.node.into_crate_internal()?;
2526        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
2527        let hierarchy_item = node_hierarchy.get(nid)?;
2528        let first_child_id = hierarchy_item.first_child_id(nid)?;
2529        Some(DomNodeId {
2530            dom: node_id.dom,
2531            node: NodeHierarchyItemId::from_crate_internal(Some(first_child_id)),
2532        })
2533    }
2534
2535    /// Get the next sibling of a node
2536    pub fn get_next_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2537        let layout_result = self.layout_results.get(&node_id.dom)?;
2538        let nid = node_id.node.into_crate_internal()?;
2539        let next_sibling_id = layout_result
2540            .styled_dom
2541            .node_hierarchy
2542            .as_container()
2543            .get(nid)?
2544            .next_sibling_id()?;
2545        Some(DomNodeId {
2546            dom: node_id.dom,
2547            node: NodeHierarchyItemId::from_crate_internal(Some(next_sibling_id)),
2548        })
2549    }
2550
2551    /// Get the previous sibling of a node
2552    pub fn get_previous_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2553        let layout_result = self.layout_results.get(&node_id.dom)?;
2554        let nid = node_id.node.into_crate_internal()?;
2555        let prev_sibling_id = layout_result
2556            .styled_dom
2557            .node_hierarchy
2558            .as_container()
2559            .get(nid)?
2560            .previous_sibling_id()?;
2561        Some(DomNodeId {
2562            dom: node_id.dom,
2563            node: NodeHierarchyItemId::from_crate_internal(Some(prev_sibling_id)),
2564        })
2565    }
2566
2567    /// Get the last child of a node
2568    pub fn get_last_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2569        let layout_result = self.layout_results.get(&node_id.dom)?;
2570        let nid = node_id.node.into_crate_internal()?;
2571        let last_child_id = layout_result
2572            .styled_dom
2573            .node_hierarchy
2574            .as_container()
2575            .get(nid)?
2576            .last_child_id()?;
2577        Some(DomNodeId {
2578            dom: node_id.dom,
2579            node: NodeHierarchyItemId::from_crate_internal(Some(last_child_id)),
2580        })
2581    }
2582
2583    /// Scan all fonts referenced in the current display lists (for resource GC).
2584    ///
2585    /// Iterates every `Text` and `TextLayout` item in each DOM's display list
2586    /// and collects the deterministic `FontKey` derived from the font hash.
2587    /// Callers can diff the result against `renderer_resources.currently_registered_fonts`
2588    /// to find fonts that are no longer used.
2589    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2590    pub fn scan_used_fonts(&self) -> BTreeSet<FontKey> {
2591        use crate::solver3::display_list::DisplayListItem;
2592
2593        let mut fonts = BTreeSet::new();
2594        for layout_result in self.layout_results.values() {
2595            for item in &layout_result.display_list.items {
2596                let hash = match item {
2597                    DisplayListItem::Text { font_hash, .. } => font_hash.font_hash,
2598                    DisplayListItem::TextLayout { font_hash, .. } => font_hash.font_hash,
2599                    _ => continue,
2600                };
2601                // Deterministic FontKey from hash (same algorithm as wr_translate2)
2602                let ns = (hash >> 32) as u32;
2603                let ns = if ns == 0 { 1 } else { ns };
2604                fonts.insert(FontKey {
2605                    namespace: IdNamespace(ns),
2606                    key: hash,
2607                });
2608            }
2609        }
2610        fonts
2611    }
2612
2613    /// Scan all images referenced in the current display lists (for resource GC).
2614    ///
2615    /// Iterates every `Image` and `PushImageMaskClip` item and collects
2616    /// their `ImageRefHash`.  Callers can diff the result against the
2617    /// currently loaded images to find unused ones.
2618    pub fn scan_used_images(&self, _css_image_cache: &ImageCache) -> BTreeSet<ImageRefHash> {
2619        use crate::solver3::display_list::DisplayListItem;
2620
2621        let mut images = BTreeSet::new();
2622        for layout_result in self.layout_results.values() {
2623            for item in &layout_result.display_list.items {
2624                match item {
2625                    DisplayListItem::Image { image, .. } => {
2626                        images.insert(image.get_hash());
2627                    }
2628                    DisplayListItem::PushImageMaskClip { mask_image, .. } => {
2629                        images.insert(mask_image.get_hash());
2630                    }
2631                    _ => {}
2632                }
2633            }
2634        }
2635        images
2636    }
2637
2638    /// Helper function to convert `ScrollManager` to nested format for `CallbackInfo`
2639    fn get_nested_scroll_states(
2640        &self,
2641        dom_id: DomId,
2642    ) -> BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> {
2643        let mut nested = BTreeMap::new();
2644        let scroll_states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
2645        let mut inner = BTreeMap::new();
2646        for (node_id, scroll_pos) in scroll_states {
2647            inner.insert(
2648                NodeHierarchyItemId::from_crate_internal(Some(node_id)),
2649                scroll_pos,
2650            );
2651        }
2652        nested.insert(dom_id, inner);
2653        nested
2654    }
2655
2656    // Scroll Into View
2657
2658    /// Scroll a DOM node into view
2659    ///
2660    /// This is the main API for scrolling elements into view. It handles:
2661    /// - Finding scroll ancestors
2662    /// - Calculating scroll deltas
2663    /// - Applying scroll animations
2664    ///
2665    /// # Arguments
2666    ///
2667    /// * `node_id` - The DOM node to scroll into view
2668    /// * `options` - Scroll alignment and animation options
2669    /// * `now` - Current timestamp for animations
2670    ///
2671    /// # Returns
2672    ///
2673    /// A vector of scroll adjustments that were applied
2674    pub fn scroll_node_into_view(
2675        &mut self,
2676        node_id: DomNodeId,
2677        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
2678        now: Instant,
2679    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
2680        crate::managers::scroll_into_view::scroll_node_into_view(
2681            node_id,
2682            &self.layout_results,
2683            &mut self.scroll_manager,
2684            options,
2685            now,
2686        )
2687    }
2688
2689    /// Scroll a text cursor into view
2690    ///
2691    /// Used when the cursor moves within a contenteditable element.
2692    /// The cursor rect should be in node-local coordinates.
2693    pub fn scroll_cursor_into_view(
2694        &mut self,
2695        cursor_rect: LogicalRect,
2696        node_id: DomNodeId,
2697        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
2698        now: Instant,
2699    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
2700        crate::managers::scroll_into_view::scroll_cursor_into_view(
2701            cursor_rect,
2702            node_id,
2703            &self.layout_results,
2704            &mut self.scroll_manager,
2705            options,
2706            now,
2707        )
2708    }
2709
2710    // Timer Management
2711
2712    /// Add a timer to this window
2713    pub fn add_timer(&mut self, timer_id: TimerId, timer: Timer) {
2714        self.timers.insert(timer_id, timer);
2715    }
2716
2717    /// Remove a timer from this window
2718    pub fn remove_timer(&mut self, timer_id: &TimerId) -> Option<Timer> {
2719        self.timers.remove(timer_id)
2720    }
2721
2722    /// Get a reference to a timer
2723    pub fn get_timer(&self, timer_id: &TimerId) -> Option<&Timer> {
2724        self.timers.get(timer_id)
2725    }
2726
2727    /// Get a mutable reference to a timer
2728    pub fn get_timer_mut(&mut self, timer_id: &TimerId) -> Option<&mut Timer> {
2729        self.timers.get_mut(timer_id)
2730    }
2731
2732    /// Get all timer IDs
2733    pub fn get_timer_ids(&self) -> TimerIdVec {
2734        self.timers.keys().copied().collect::<Vec<_>>().into()
2735    }
2736
2737    /// Tick all timers (called once per frame)
2738    /// Returns a list of timer IDs that are ready to run
2739    // Instant is a ref-counted FFI clock handle; called by every dll backend's event loop by value.
2740    #[allow(clippy::needless_pass_by_value)]
2741    pub fn tick_timers(&mut self, current_time: Instant) -> Vec<TimerId> {
2742        let mut ready_timers = Vec::new();
2743
2744        for (timer_id, timer) in &mut self.timers {
2745            // Check if timer is ready to run
2746            // This logic should match the timer's internal state
2747            // For now, we'll just collect all timer IDs
2748            // The actual readiness check will be done when invoking
2749            ready_timers.push(*timer_id);
2750        }
2751
2752        ready_timers
2753    }
2754
2755    /// Calculate milliseconds until the next timer needs to fire.
2756    ///
2757    /// Returns `None` if there are no timers, meaning the caller can block indefinitely.
2758    /// Returns `Some(0)` if a timer is already overdue.
2759    /// Otherwise returns the minimum time in milliseconds until any timer fires.
2760    ///
2761    /// Reads `now` through the injectable `GetSystemTimeCallback`, so it follows
2762    /// a frozen/virtual clock rather than wall time.
2763    ///
2764    /// NOTE: no platform backend calls this today. The X11 and Wayland loops use
2765    /// a hardcoded 16ms/-1 `poll` timeout (`x11/mod.rs`, `wayland/mod.rs`), and
2766    /// the per-timer wake-up is armed once from `Timer::tick_millis` via
2767    /// `timerfd` / `SetTimer` / `NSTimer`. The comment that used to sit here
2768    /// claimed X11 and Wayland used this function; they do not.
2769    pub fn time_until_next_timer_ms(
2770        &self,
2771        get_system_time_fn: &azul_core::task::GetSystemTimeCallback,
2772    ) -> Option<u64> {
2773        if self.timers.is_empty() {
2774            return None; // No timers - can block indefinitely
2775        }
2776
2777        let now = (get_system_time_fn.cb)();
2778        let mut min_ms: Option<u64> = None;
2779
2780        for timer in self.timers.values() {
2781            let next_run = timer.instant_of_next_run();
2782
2783            // Calculate time difference in milliseconds
2784            let ms_until = if next_run < now {
2785                0 // Timer is overdue
2786            } else {
2787                duration_to_millis(next_run.duration_since(&now))
2788            };
2789
2790            min_ms = Some(min_ms.map_or(ms_until, |current_min| current_min.min(ms_until)));
2791        }
2792
2793        min_ms
2794    }
2795
2796    // Thread Management
2797
2798    /// Add a thread to this window
2799    pub fn add_thread(&mut self, thread_id: ThreadId, thread: Thread) {
2800        self.threads.insert(thread_id, thread);
2801    }
2802
2803    /// Remove a thread from this window
2804    pub fn remove_thread(&mut self, thread_id: &ThreadId) -> Option<Thread> {
2805        self.threads.remove(thread_id)
2806    }
2807
2808    /// Get a reference to a thread
2809    pub fn get_thread(&self, thread_id: &ThreadId) -> Option<&Thread> {
2810        self.threads.get(thread_id)
2811    }
2812
2813    /// Get a mutable reference to a thread
2814    pub fn get_thread_mut(&mut self, thread_id: &ThreadId) -> Option<&mut Thread> {
2815        self.threads.get_mut(thread_id)
2816    }
2817
2818    /// Get all thread IDs
2819    pub fn get_thread_ids(&self) -> ThreadIdVec {
2820        self.threads.keys().copied().collect::<Vec<_>>().into()
2821    }
2822
2823    // Cursor Blinking Timer
2824
2825    /// Create the cursor blink timer
2826    ///
2827    /// This timer toggles cursor visibility at the interval currently held by
2828    /// `text_edit_manager.blink` — [`crate::managers::text_edit::CURSOR_BLINK_INTERVAL`]
2829    /// (530ms) unless the focused node's `caret-animation-duration` overrode it.
2830    /// It checks if enough time has passed since the last user input before blinking,
2831    /// to avoid blinking while the user is actively typing.
2832    ///
2833    /// The interval is copied as a whole [`Duration`], UNIT INCLUDED, so a
2834    /// stylesheet's `5t` reaches `Timer::invoke` as five frames rather than as a
2835    /// millisecond count someone already rounded.
2836    pub fn create_cursor_blink_timer(&self, _window_state: &FullWindowState) -> Timer {
2837        use crate::timer::{Timer, TimerCallback};
2838        use azul_core::refany::RefAny;
2839
2840        let interval = self.text_edit_manager.blink.blink_interval;
2841
2842        // Create a RefAny with a unit type - the timer callback doesn't need any data
2843        // The actual cursor state is in LayoutWindow.text_edit_manager.multi_cursor / blink
2844        let refany = RefAny::new(());
2845
2846        Timer {
2847            refany,
2848            node_id: None.into(),
2849            created: Instant::now(),
2850            run_count: 0,
2851            last_run: azul_core::task::OptionInstant::None,
2852            delay: azul_core::task::OptionDuration::None,
2853            interval: azul_core::task::OptionDuration::Some(interval),
2854            timeout: azul_core::task::OptionDuration::None,
2855            callback: TimerCallback::create(cursor_blink_timer_callback),
2856        }
2857    }
2858
2859    // Tooltip-Delay Timer
2860
2861    /// Create a one-shot tooltip-delay timer.
2862    ///
2863    /// Fires exactly once after `hover_time_ms` elapsed. On expiry the callback
2864    /// looks up the currently-hovered node's `title` / `alt` / `aria-label`
2865    /// attribute and emits a `ShowTooltip` `CallbackChange`, then terminates.
2866    pub fn create_tooltip_delay_timer(&self, hover_time_ms: u32) -> Timer {
2867        use azul_core::task::{Duration, SystemTimeDiff};
2868        use crate::timer::{Timer, TimerCallback};
2869        use azul_core::refany::RefAny;
2870
2871        Timer {
2872            refany: RefAny::new(()),
2873            node_id: None.into(),
2874            created: Instant::now(),
2875            run_count: 0,
2876            last_run: azul_core::task::OptionInstant::None,
2877            delay: azul_core::task::OptionDuration::Some(Duration::System(
2878                SystemTimeDiff::from_millis(u64::from(hover_time_ms)),
2879            )),
2880            interval: azul_core::task::OptionDuration::None,
2881            timeout: azul_core::task::OptionDuration::None,
2882            callback: TimerCallback::create(tooltip_delay_timer_callback),
2883        }
2884    }
2885
2886    /// Determine what tooltip-timer action the shell should take given a hover
2887    /// transition.
2888    ///
2889    /// The platform event loop calls this once per event-dispatch cycle (after
2890    /// hit-testing has updated `hover_manager`). It compares the current and
2891    /// previous deepest hovered nodes and returns:
2892    ///
2893    /// - `Start` if the user just hovered onto a node that has a tooltip
2894    ///   source (`title` / `alt` / `aria-label`) — the shell should (re)start
2895    ///   `TOOLTIP_DELAY_TIMER_ID` with the returned Timer.
2896    /// - `Stop` if the hover moved off a tooltip-bearing node (or left the
2897    ///   window) — the shell should stop `TOOLTIP_DELAY_TIMER_ID` and hide
2898    ///   any currently-visible tooltip.
2899    /// - `NoChange` if the hovered node hasn't changed between frames.
2900    pub fn handle_hover_change_for_tooltip(&self, hover_time_ms: u32) -> TooltipTimerAction {
2901        let current_hover = self.hover_manager.current_hover_node();
2902        let previous_hover = self.hover_manager.previous_hover_node();
2903
2904        if current_hover == previous_hover {
2905            return TooltipTimerAction::NoChange;
2906        }
2907
2908        let dom_id = DomId { inner: 0 };
2909        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2910            return TooltipTimerAction::Stop;
2911        };
2912        let node_data_cont = layout_result.styled_dom.node_data.as_container();
2913
2914        let node_has_tooltip = |node_id: NodeId| -> bool {
2915            node_data_cont
2916                .get(node_id)
2917                .is_some_and(|n| n.get_accessible_label().is_some())
2918        };
2919
2920        match current_hover {
2921            Some(node) if node_has_tooltip(node) => {
2922                TooltipTimerAction::Start(self.create_tooltip_delay_timer(hover_time_ms))
2923            }
2924            _ => TooltipTimerAction::Stop,
2925        }
2926    }
2927
2928    /// Check if a node is contenteditable (internal version using `NodeId`)
2929    fn is_node_contenteditable_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
2930        use crate::solver3::getters::is_node_contenteditable;
2931
2932        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2933            return false;
2934        };
2935
2936        is_node_contenteditable(&layout_result.styled_dom, node_id)
2937    }
2938
2939    /// Check if a node is contenteditable with W3C-conformant inheritance.
2940    ///
2941    /// This traverses up the DOM tree to check if the node or any ancestor
2942    /// has `contenteditable="true"` set, respecting `contenteditable="false"`
2943    /// to stop inheritance.
2944    fn is_node_contenteditable_inherited_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
2945        use crate::solver3::getters::is_node_contenteditable_inherited;
2946
2947        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2948            return false;
2949        };
2950
2951        is_node_contenteditable_inherited(&layout_result.styled_dom, node_id)
2952    }
2953
2954    /// The caret blink interval for a node, from its `caret-animation-duration`.
2955    ///
2956    /// Returns a [`Duration`] rather than milliseconds so the stylesheet's UNIT
2957    /// survives: `caret-animation-duration: 5t` yields `Duration::Tick(5)` (five
2958    /// frames, no clock involved) and `500ms` yields `Duration::System(500ms)`.
2959    ///
2960    /// Falls back to [`crate::managers::text_edit::CURSOR_BLINK_INTERVAL`] when
2961    /// the node's layout is not available, and when the resolved interval is
2962    /// ZERO. Zero is documented in `CaretStyle` as "no blink", which is not the
2963    /// same thing as "blink every frame" — until suppression is actually
2964    /// implemented, honouring it literally would turn `0` into a strobing caret,
2965    /// so the default is the conservative reading.
2966    #[must_use]
2967    pub fn caret_blink_interval_for(&self, dom_id: DomId, node_id: NodeId) -> Duration {
2968        use crate::{managers::text_edit::CURSOR_BLINK_INTERVAL, solver3::getters::get_caret_style};
2969
2970        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2971            return CURSOR_BLINK_INTERVAL;
2972        };
2973
2974        let interval: Duration = get_caret_style(&layout_result.styled_dom, Some(node_id))
2975            .animation_duration
2976            .into();
2977
2978        if interval.as_nanos() == 0 {
2979            CURSOR_BLINK_INTERVAL
2980        } else {
2981            interval
2982        }
2983    }
2984
2985    /// Handle focus change for cursor blink timer management (W3C "flag and defer" pattern)
2986    ///
2987    /// This method implements the W3C focus/selection model:
2988    /// 1. Focus change is handled immediately (timer start/stop)
2989    /// 2. Cursor initialization is DEFERRED until after layout (via flag)
2990    ///
2991    /// The cursor is NOT initialized here because text layout may not be available
2992    /// during focus event handling. Instead, we set a flag that is consumed by
2993    /// `finalize_pending_focus_changes()` after the layout pass.
2994    ///
2995    /// # Parameters
2996    ///
2997    /// * `new_focus` - The newly focused node (None if focus is being cleared)
2998    /// * `current_window_state` - Current window state for timer creation
2999    ///
3000    /// # Returns
3001    ///
3002    /// A `CursorBlinkTimerAction` indicating what timer action the platform
3003    /// layer should take.
3004    pub fn handle_focus_change_for_cursor_blink(
3005        &mut self,
3006        new_focus: Option<DomNodeId>,
3007        current_window_state: &FullWindowState,
3008    ) -> CursorBlinkTimerAction {
3009        // Check if the new focus is on a contenteditable element
3010        // Use the inherited check for W3C conformance
3011        let contenteditable_info = new_focus.and_then(|focus_node| {
3012            focus_node.node.into_crate_internal().and_then(|node_id| {
3013                // Check if this node or any ancestor is contenteditable
3014                if self.is_node_contenteditable_inherited_internal(focus_node.dom, node_id) {
3015                    // Find the text node where the cursor should be placed
3016                    let text_node_id = self.find_last_text_child(focus_node.dom, node_id)
3017                        .unwrap_or(node_id);
3018                    Some((focus_node.dom, node_id, text_node_id))
3019                } else {
3020                    None
3021                }
3022            })
3023        });
3024
3025        // Determine the action based on current state and new focus
3026        let timer_was_active = self.text_edit_manager.blink.is_blink_timer_active();
3027
3028        if let Some((dom_id, container_node_id, text_node_id)) = contenteditable_info {
3029
3030            // W3C "flag and defer" pattern:
3031            // Set flag for cursor initialization AFTER layout pass
3032            self.focus_manager.set_pending_contenteditable_focus(
3033                dom_id,
3034                container_node_id,
3035                text_node_id,
3036            );
3037
3038            // Adopt the focused node's `caret-animation-duration` BEFORE arming
3039            // the timer, so `create_cursor_blink_timer` builds the timer with the
3040            // stylesheet's interval — in the stylesheet's UNIT. A `5t` value stays
3041            // five FRAMES here; collapsing it to milliseconds would put the wall
3042            // clock back in the path.
3043            //
3044            // Bound to a local first: the `&self` read and the `&mut` receiver
3045            // would otherwise overlap in one expression.
3046            let blink_interval = self.caret_blink_interval_for(dom_id, container_node_id);
3047            self.text_edit_manager
3048                .blink
3049                .set_blink_interval(blink_interval);
3050
3051            // Make cursor visible and record current time (even before actual initialization)
3052            let now = Instant::now();
3053            self.text_edit_manager.blink.reset_blink_on_input(now);
3054            self.text_edit_manager.blink.set_blink_timer_active(true);
3055
3056            if timer_was_active {
3057                // Timer already active, just continue
3058                CursorBlinkTimerAction::NoChange
3059            } else {
3060                // Need to start the timer
3061                let timer = self.create_cursor_blink_timer(current_window_state);
3062                CursorBlinkTimerAction::Start(timer)
3063            }
3064        } else {
3065            // Focus is moving away from contenteditable or being cleared
3066
3067            // Clear the cursor AND the pending focus flag
3068            self.text_edit_manager.clear_editing();
3069            self.focus_manager.clear_pending_contenteditable_focus();
3070
3071            if timer_was_active {
3072                // Need to stop the timer
3073                self.text_edit_manager.blink.set_blink_timer_active(false);
3074                CursorBlinkTimerAction::Stop
3075            } else {
3076                CursorBlinkTimerAction::NoChange
3077            }
3078        }
3079    }
3080
3081    /// Finalize pending focus changes after layout pass (W3C "flag and defer" pattern)
3082    ///
3083    /// This method should be called AFTER the layout pass completes. It checks if
3084    /// there's a pending contenteditable focus and initializes the cursor now that
3085    /// text layout information is available.
3086    ///
3087    /// # W3C Conformance
3088    ///
3089    /// In the W3C model:
3090    /// 1. Focus event fires during event handling (layout may not be ready)
3091    /// 2. Selection/cursor placement happens after layout is computed
3092    /// 3. The cursor is drawn at the position specified by the Selection
3093    ///
3094    /// This function implements step 2+3 by:
3095    /// - Checking the `cursor_needs_initialization` flag
3096    /// - Getting the (now available) text layout
3097    /// - Initializing the cursor at the correct position
3098    ///
3099    /// # Returns
3100    ///
3101    /// `true` if cursor was initialized, `false` if no pending focus or initialization failed.
3102    pub fn finalize_pending_focus_changes(&mut self) -> bool {
3103        // Take the pending focus info (this clears the flag)
3104        let Some(pending) = self.focus_manager.take_pending_contenteditable_focus() else {
3105            return false;
3106        };
3107
3108        // Bug B+H fix: If process_mouse_click_for_selection already positioned
3109        // the cursor in this node during the same event cycle, don't override it
3110        // with initialize_cursor_at_end. The click handler sets cursor on the IFC
3111        // root node (may differ from text_node_id), so check both.
3112        if self.text_edit_manager.multi_cursor.as_ref().is_some_and(|mc| mc.node_id.dom == pending.dom_id && mc.node_id.node.into_crate_internal() == Some(pending.text_node_id))
3113            || self.text_edit_manager.multi_cursor.as_ref().is_some_and(|mc| mc.node_id.dom == pending.dom_id && mc.node_id.node.into_crate_internal() == Some(pending.container_node_id))
3114        {
3115            return true;
3116        }
3117
3118        // Now we can safely get the text layout (layout pass has completed)
3119        let text_layout = self.get_inline_layout_for_node(pending.dom_id, pending.text_node_id).cloned();
3120
3121        // Initialize cursor at end of text
3122        // Get the last cluster cursor from text layout
3123        let cursor = text_layout.as_ref()
3124            .and_then(|layout| {
3125                layout.items.iter().rev()
3126                    .find_map(|item| if let ShapedItem::Cluster(c) = &item.item {
3127                        Some(TextCursor {
3128                            cluster_id: c.source_cluster_id,
3129                            affinity: CursorAffinity::Trailing,
3130                        })
3131                    } else { None })
3132            })
3133            .unwrap_or(TextCursor {
3134                cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
3135                affinity: CursorAffinity::Trailing,
3136            });
3137        self.text_edit_manager.initialize_editing(cursor, pending.dom_id, pending.text_node_id, 0);
3138        true
3139    }
3140
3141    /// Helper: Get inline layout for a node
3142    ///
3143    /// For text nodes that participate in an IFC, the inline layout is stored
3144    /// on the IFC root node (the block container), not on the text node itself.
3145    /// This method handles both cases:
3146    /// 1. The node has its own `inline_layout_result` (IFC root)
3147    /// 2. The node has `ifc_membership` pointing to the IFC root
3148    ///
3149    /// This is a thin wrapper around `LayoutTree::get_inline_layout_for_node`.
3150    pub fn get_inline_layout_for_node(
3151        &self,
3152        dom_id: DomId,
3153        node_id: NodeId,
3154    ) -> Option<&Arc<UnifiedLayout>> {
3155        let layout_result = self.layout_results.get(&dom_id)?;
3156
3157        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
3158        let layout_index = *layout_indices.first()?;
3159
3160        // Use the centralized LayoutTree method that handles IFC membership
3161        layout_result.layout_tree.get_inline_layout_for_node(layout_index)
3162    }
3163
3164    /// Single dispatch: (direction, step) → `UnifiedLayout` cursor movement.
3165    fn resolve_step_static(
3166        layout: &UnifiedLayout,
3167        cursor: &TextCursor,
3168        direction: azul_core::events::SelectionDirection,
3169        step: azul_core::events::SelectionStep,
3170    ) -> TextCursor {
3171        use azul_core::events::{SelectionDirection as D, SelectionStep as S};
3172        match (direction, step) {
3173            (D::Backward, S::Character) => layout.move_cursor_left(*cursor, &mut None),
3174            (D::Forward, S::Character) => layout.move_cursor_right(*cursor, &mut None),
3175            (D::Backward, S::Word) => layout.move_cursor_to_prev_word(*cursor, &mut None),
3176            (D::Forward, S::Word) => layout.move_cursor_to_next_word(*cursor, &mut None),
3177            (D::Backward, S::VisualLine) => layout.move_cursor_up(*cursor, &mut None, &mut None),
3178            (D::Forward, S::VisualLine) => layout.move_cursor_down(*cursor, &mut None, &mut None),
3179            (D::Backward, S::Line) => layout.move_cursor_to_line_start(*cursor, &mut None),
3180            (D::Forward, S::Line) => layout.move_cursor_to_line_end(*cursor, &mut None),
3181            (D::Backward, S::Document) => layout.get_first_cluster_cursor().unwrap_or(*cursor),
3182            (D::Forward, S::Document) => layout.get_last_cluster_cursor().unwrap_or(*cursor),
3183        }
3184    }
3185
3186    /// Apply a unified selection operation (navigation, extend, or delete).
3187    ///
3188    /// Single entry point that replaces the separate `ArrowKeyNavigation` and
3189    /// `DeleteTextSelection` handlers, as well as `handle_cursor_movement` and
3190    /// `handle_multi_cursor_movement`.
3191    pub fn apply_selection_op(
3192        &mut self,
3193        target: DomNodeId,
3194        op: &azul_core::events::SelectionOp,
3195    ) -> bool {
3196        use azul_core::events::{SelectionMode, SelectionStep, SelectionDirection};
3197
3198        let dom_id = target.dom;
3199        let Some(node_id) = target.node.into_crate_internal() else {
3200            return false;
3201        };
3202
3203        let layout = match self.get_inline_layout_for_node(dom_id, node_id) {
3204            Some(l) => l.clone(),
3205            None => return false,
3206        };
3207
3208        match op.mode {
3209            SelectionMode::Move | SelectionMode::Extend => {
3210                let extend = matches!(op.mode, SelectionMode::Extend);
3211                if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3212                    for _ in 0..op.repeat.max(1) {
3213                        mc.move_all_cursors(extend, |c| {
3214                            Self::resolve_step_static(&layout, c, op.direction, op.step)
3215                        });
3216                    }
3217                }
3218                self.regenerate_display_list_for_dom(dom_id);
3219                true
3220            }
3221            SelectionMode::Delete => {
3222                // Step 1: if step > Character, expand cursors to ranges first
3223                if !matches!(op.step, SelectionStep::Character) {
3224                    if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3225                        for _ in 0..op.repeat.max(1) {
3226                            mc.move_all_cursors(true, |c| {
3227                                Self::resolve_step_static(&layout, c, op.direction, op.step)
3228                            });
3229                        }
3230                    }
3231                }
3232                // Step 2: delete the expanded ranges (or single char for Character step)
3233                let forward = matches!(op.direction, SelectionDirection::Forward);
3234                self.delete_selection(target, forward).is_some()
3235            }
3236        }
3237    }
3238
3239    /// Helper: Move cursor using a movement function and return the new cursor if it changed
3240    pub fn move_cursor_in_node<F>(
3241        &self,
3242        dom_id: DomId,
3243        node_id: NodeId,
3244        movement_fn: F,
3245    ) -> Option<TextCursor>
3246    where
3247        F: FnOnce(&UnifiedLayout, &TextCursor) -> TextCursor,
3248    {
3249        let current_cursor = self.text_edit_manager.get_primary_cursor()?;
3250        let layout = self.get_inline_layout_for_node(dom_id, node_id)?;
3251
3252        let new_cursor = movement_fn(layout, &current_cursor);
3253
3254        // Only return if cursor actually moved
3255        if new_cursor == current_cursor {
3256            None
3257        } else {
3258            Some(new_cursor)
3259        }
3260    }
3261
3262    /// Helper: Handle cursor movement with optional selection extension.
3263    ///
3264    /// Updates the primary cursor in `TextEditManager.multi_cursor` to the given
3265    /// position and triggers a display list regeneration.
3266    pub fn handle_cursor_movement(
3267        &mut self,
3268        dom_id: DomId,
3269        node_id: NodeId,
3270        new_cursor: TextCursor,
3271        extend_selection: bool,
3272    ) {
3273        // Update multi_cursor with the new cursor position
3274        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3275            mc.set_single_cursor(new_cursor);
3276        }
3277
3278        self.regenerate_display_list_for_dom(dom_id);
3279    }
3280
3281    /// Move all cursors in a `MultiCursorState` using a movement function.
3282    /// This is the multi-cursor version of `handle_cursor_movement`.
3283    pub fn handle_multi_cursor_movement(
3284        &mut self,
3285        dom_id: DomId,
3286        node_id: NodeId,
3287        extend_selection: bool,
3288        move_fn: impl Fn(&TextCursor) -> TextCursor,
3289    ) {
3290        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3291            mc.move_all_cursors(extend_selection, &move_fn);
3292        } else {
3293            // Single cursor fallback via get_primary_cursor
3294            if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
3295                let new_cursor = move_fn(&cursor);
3296                self.handle_cursor_movement(dom_id, node_id, new_cursor, extend_selection);
3297                return;
3298            }
3299        }
3300
3301        self.regenerate_display_list_for_dom(dom_id);
3302    }
3303
3304    // Gpu Value Cache Management
3305
3306    /// Get the GPU value cache for a specific DOM
3307    pub fn get_gpu_cache(&self, dom_id: &DomId) -> Option<&GpuValueCache> {
3308        self.gpu_state_manager.caches.get(dom_id)
3309    }
3310
3311    /// Get a mutable reference to the GPU value cache for a specific DOM
3312    pub fn get_gpu_cache_mut(&mut self, dom_id: &DomId) -> Option<&mut GpuValueCache> {
3313        self.gpu_state_manager.caches.get_mut(dom_id)
3314    }
3315
3316    /// Get or create a GPU value cache for a specific DOM
3317    pub fn get_or_create_gpu_cache(&mut self, dom_id: DomId) -> &mut GpuValueCache {
3318        self.gpu_state_manager.get_or_create_cache(dom_id)
3319    }
3320
3321    // Layout Result Access
3322
3323    /// Get a layout result for a specific DOM
3324    pub fn get_layout_result(&self, dom_id: &DomId) -> Option<&DomLayoutResult> {
3325        self.layout_results.get(dom_id)
3326    }
3327
3328    /// Get a mutable layout result for a specific DOM
3329    pub fn get_layout_result_mut(&mut self, dom_id: &DomId) -> Option<&mut DomLayoutResult> {
3330        self.layout_results.get_mut(dom_id)
3331    }
3332
3333    /// Get all DOM IDs that have layout results
3334    pub fn get_dom_ids(&self) -> DomIdVec {
3335        self.layout_results
3336            .keys()
3337            .copied()
3338            .collect::<Vec<_>>()
3339            .into()
3340    }
3341
3342    // Hit-Test Computation
3343
3344    /// Compute the cursor type hit-test from a full hit-test
3345    ///
3346    /// This determines which mouse cursor to display based on the CSS cursor
3347    /// properties of the hovered nodes.
3348    pub fn compute_cursor_type_hit_test(
3349        &self,
3350        hit_test: &crate::hit_test::FullHitTest,
3351    ) -> crate::hit_test::CursorTypeHitTest {
3352        crate::hit_test::CursorTypeHitTest::new(hit_test, self)
3353    }
3354
3355    /// Helper function to calculate scrollbar opacity based on activity time
3356    // Instant is a ref-counted FFI clock handle threaded through the scrollbar-fade path by value.
3357    #[allow(clippy::needless_pass_by_value)]
3358    fn calculate_scrollbar_opacity(
3359        last_activity: Option<Instant>,
3360        now: Instant,
3361        fade_delay: Duration,
3362        fade_duration: Duration,
3363    ) -> f32 {
3364        let Some(last_activity) = last_activity else {
3365            return 0.0;
3366        };
3367
3368        let time_since_activity = now.duration_since(&last_activity);
3369
3370        // Phase 1: Scrollbar stays fully visible during fade_delay
3371        if time_since_activity.div(&fade_delay) < 1.0 {
3372            return 1.0;
3373        }
3374
3375        // Phase 2: Fade out over fade_duration
3376        let time_into_fade = time_since_activity.div(&fade_delay) - 1.0;
3377        let fade_progress = (time_into_fade * fade_delay.div(&fade_duration)).min(1.0);
3378
3379        // Phase 3: Fully faded
3380        (1.0 - fade_progress).max(0.0)
3381    }
3382
3383    /// Synchronize scrollbar opacity values with the GPU value cache.
3384    ///
3385    /// This method updates GPU opacity keys for all scrollbars based on scroll activity
3386    /// tracked by the `ScrollManager`. It enables smooth scrollbar fading without
3387    /// requiring display list regeneration. Static method that takes individual
3388    /// components instead of `&mut self` to avoid borrow conflicts.
3389    ///
3390    /// # Arguments
3391    ///
3392    /// * `dom_id` - The DOM to synchronize scrollbar opacity for
3393    /// * `layout_tree` - The layout tree containing scrollbar information
3394    /// * `now` - Current timestamp for calculating fade progress
3395    /// * `fade_delay` - Delay before scrollbar starts fading (e.g., 500ms)
3396    /// * `fade_duration` - Duration of the fade animation (e.g., 200ms)
3397    ///
3398    /// # Returns
3399    ///
3400    /// A vector of GPU scrollbar opacity change events
3401    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3402    /// MWA-C-gpu_state: per-frame scrollbar GPU-cache refresh for the CPU
3403    /// render path. The `WebRender` transaction builders run
3404    /// `update_scrollbar_transforms` + `synchronize_scrollbar_opacity` every
3405    /// frame, but the CPU branches only ticked the scroll manager — overlay
3406    /// scrollbar thumb transforms and fade opacity in the cache refreshed
3407    /// only on full relayout, and `scrollbar_fade_active` could keep
3408    /// requesting redraws that changed nothing. Call before
3409    /// `CpuBackend::render_frame`. Uses the manager's own
3410    /// `fade_delay`/`fade_duration` (the WR paths still pass literals — see
3411    /// FOLLOW-UPS note).
3412    ///
3413    /// Returns `true` if the refresh MOVED the cache — a thumb transform or a
3414    /// fade opacity actually changed value this frame. That is a strictly
3415    /// stronger signal than [`GpuStateManager::scrollbar_fade_active`], which
3416    /// only says "a fade is mid-flight RIGHT NOW": the frame that lands the
3417    /// fade on its final value (opacity 1.0 -> 0.0) clears `scrollbar_fade_active`
3418    /// and still repaints, so a loop that stops on the flag alone stops one
3419    /// frame BEFORE the window is quiescent. Callers that need "keep presenting
3420    /// until nothing moves" — the E2E host, which has to reach a fixpoint
3421    /// before an idleness assertion reads the frame — want this.
3422    #[cfg(feature = "std")]
3423    pub fn refresh_scrollbar_gpu_cache_for_cpu_frame(&mut self) -> bool {
3424        let system_callbacks = ExternalSystemCallbacks::rust_internal();
3425        let mut moved = false;
3426        {
3427            let Self {
3428                ref layout_results,
3429                ref scroll_manager,
3430                ref mut gpu_state_manager,
3431                ..
3432            } = *self;
3433            for (dom_id, layout_result) in layout_results {
3434                moved |= !gpu_state_manager
3435                    .update_scrollbar_transforms(
3436                        *dom_id,
3437                        scroll_manager,
3438                        &layout_result.layout_tree,
3439                    )
3440                    .is_empty();
3441            }
3442        }
3443        let fade_delay = self.gpu_state_manager.fade_delay;
3444        let fade_duration = self.gpu_state_manager.fade_duration;
3445        let Self {
3446            ref layout_results,
3447            ref scroll_manager,
3448            ref mut gpu_state_manager,
3449            ..
3450        } = *self;
3451        for (dom_id, layout_result) in layout_results {
3452            moved |= !Self::synchronize_scrollbar_opacity(
3453                gpu_state_manager,
3454                scroll_manager,
3455                *dom_id,
3456                &layout_result.layout_tree,
3457                &system_callbacks,
3458                fade_delay,
3459                fade_duration,
3460            )
3461            .is_empty();
3462        }
3463        moved
3464    }
3465
3466    #[allow(clippy::too_many_lines)] // one cohesive fade state machine per scrollbar; no natural split
3467    pub fn synchronize_scrollbar_opacity(
3468        gpu_state_manager: &mut GpuStateManager,
3469        scroll_manager: &ScrollManager,
3470        dom_id: DomId,
3471        layout_tree: &LayoutTree,
3472        system_callbacks: &ExternalSystemCallbacks,
3473        fade_delay: Duration,
3474        fade_duration: Duration,
3475    ) -> Vec<GpuScrollbarOpacityEvent> {
3476        let mut events = Vec::new();
3477        let mut any_opacity_nonzero = false;
3478        let gpu_cache = gpu_state_manager.caches.entry(dom_id).or_default();
3479
3480        // Get current time from system callbacks
3481        let now = (system_callbacks.get_system_time_fn.cb)();
3482
3483        // Iterate over all nodes with scrollbar info
3484        for (node_idx, node) in layout_tree.nodes.iter().enumerate() {
3485            // Check if node needs scrollbars
3486            let warm = layout_tree.warm(node_idx);
3487            let Some(scrollbar_info) = warm.and_then(|w| w.scrollbar_info.as_ref()) else {
3488                continue;
3489            };
3490
3491            let Some(node_id) = node.dom_node_id else {
3492                continue; // Skip anonymous boxes
3493            };
3494
3495            // Calculate current opacity from ScrollManager
3496            let vertical_opacity = if scrollbar_info.needs_vertical {
3497                Self::calculate_scrollbar_opacity(
3498                    scroll_manager.get_last_activity_time(dom_id, node_id),
3499                    now.clone(),
3500                    fade_delay,
3501                    fade_duration,
3502                )
3503            } else {
3504                0.0
3505            };
3506
3507            let horizontal_opacity = if scrollbar_info.needs_horizontal {
3508                Self::calculate_scrollbar_opacity(
3509                    scroll_manager.get_last_activity_time(dom_id, node_id),
3510                    now.clone(),
3511                    fade_delay,
3512                    fade_duration,
3513                )
3514            } else {
3515                0.0
3516            };
3517
3518            // Track whether any scrollbar is actively fading (0 < opacity < 1).
3519            // We do NOT count fully-visible scrollbars (opacity == 1.0) because
3520            // those are driven by the scroll physics timer already. We only need
3521            // extra frames for the fade-out interpolation phase. Including
3522            // opacity == 1.0 here causes an infinite repaint loop.
3523            if (vertical_opacity > 0.0 && vertical_opacity < 1.0)
3524                || (horizontal_opacity > 0.0 && horizontal_opacity < 1.0)
3525            {
3526                any_opacity_nonzero = true;
3527            }
3528
3529            // Handle vertical scrollbar
3530            // IMPORTANT: Always pre-register the opacity key when the node needs a
3531            // vertical scrollbar, even if the current opacity is 0.  The display list
3532            // generator reads the key from the GPU cache to embed a PropertyBinding
3533            // in the ScrollBarStyled item.  If we only create the key when opacity > 0,
3534            // the first display list won't have the binding, and GPU-only scroll
3535            // updates (build_image_only_transaction) can never make the scrollbar
3536            // visible because WebRender doesn't know about the binding.
3537            let key = (dom_id, node_id);
3538            if scrollbar_info.needs_vertical {
3539                let existing = gpu_cache.scrollbar_v_opacity_values.get(&key);
3540
3541                match existing {
3542                    None => {
3543                        let opacity_key = OpacityKey::unique();
3544                        gpu_cache.scrollbar_v_opacity_keys.insert(key, opacity_key);
3545                        gpu_cache
3546                            .scrollbar_v_opacity_values
3547                            .insert(key, vertical_opacity);
3548                        events.push(GpuScrollbarOpacityEvent::VerticalAdded(
3549                            dom_id,
3550                            node_id,
3551                            opacity_key,
3552                            vertical_opacity,
3553                        ));
3554                    }
3555                    Some(&old_opacity) if (old_opacity - vertical_opacity).abs() > 0.001 => {
3556                        let opacity_key = gpu_cache.scrollbar_v_opacity_keys[&key];
3557                        gpu_cache
3558                            .scrollbar_v_opacity_values
3559                            .insert(key, vertical_opacity);
3560                        events.push(GpuScrollbarOpacityEvent::VerticalChanged(
3561                            dom_id,
3562                            node_id,
3563                            opacity_key,
3564                            old_opacity,
3565                            vertical_opacity,
3566                        ));
3567                    }
3568                    _ => {}
3569                }
3570            } else {
3571                // Remove if scrollbar no longer needed
3572                if let Some(opacity_key) = gpu_cache.scrollbar_v_opacity_keys.remove(&key) {
3573                    gpu_cache.scrollbar_v_opacity_values.remove(&key);
3574                    events.push(GpuScrollbarOpacityEvent::VerticalRemoved(
3575                        dom_id,
3576                        node_id,
3577                        opacity_key,
3578                    ));
3579                }
3580            }
3581
3582            // Handle horizontal scrollbar (same logic as vertical above)
3583            if scrollbar_info.needs_horizontal {
3584                let existing = gpu_cache.scrollbar_h_opacity_values.get(&key);
3585
3586                match existing {
3587                    None => {
3588                        let opacity_key = OpacityKey::unique();
3589                        gpu_cache.scrollbar_h_opacity_keys.insert(key, opacity_key);
3590                        gpu_cache
3591                            .scrollbar_h_opacity_values
3592                            .insert(key, horizontal_opacity);
3593                        events.push(GpuScrollbarOpacityEvent::HorizontalAdded(
3594                            dom_id,
3595                            node_id,
3596                            opacity_key,
3597                            horizontal_opacity,
3598                        ));
3599                    }
3600                    Some(&old_opacity) if (old_opacity - horizontal_opacity).abs() > 0.001 => {
3601                        let opacity_key = gpu_cache.scrollbar_h_opacity_keys[&key];
3602                        gpu_cache
3603                            .scrollbar_h_opacity_values
3604                            .insert(key, horizontal_opacity);
3605                        events.push(GpuScrollbarOpacityEvent::HorizontalChanged(
3606                            dom_id,
3607                            node_id,
3608                            opacity_key,
3609                            old_opacity,
3610                            horizontal_opacity,
3611                        ));
3612                    }
3613                    _ => {}
3614                }
3615            } else {
3616                // Remove if scrollbar no longer needed
3617                if let Some(opacity_key) = gpu_cache.scrollbar_h_opacity_keys.remove(&key) {
3618                    gpu_cache.scrollbar_h_opacity_values.remove(&key);
3619                    events.push(GpuScrollbarOpacityEvent::HorizontalRemoved(
3620                        dom_id,
3621                        node_id,
3622                        opacity_key,
3623                    ));
3624                }
3625            }
3626        }
3627
3628        // Signal to the platform render loop that more frames are needed
3629        // to complete the scrollbar fade animation. The caller should
3630        // schedule a redraw while this flag is true.
3631        gpu_state_manager.scrollbar_fade_active = any_opacity_nonzero;
3632
3633        events
3634    }
3635
3636    /// Compute stable scroll IDs for all scrollable nodes in a layout tree
3637    ///
3638    /// This should be called after layout but before display list generation.
3639    /// It creates stable IDs based on `node_data_hash` that persist across frames.
3640    ///
3641    /// Returns:
3642    /// - `scroll_ids`: Map from layout node index -> external scroll ID
3643    /// - `scroll_id_to_node_id`: Map from scroll ID -> DOM `NodeId` (for hit testing)
3644    #[must_use] pub fn compute_scroll_ids(
3645        layout_tree: &LayoutTree,
3646        styled_dom: &StyledDom,
3647    ) -> (HashMap<usize, u64>, HashMap<u64, NodeId>) {
3648        use azul_css::props::layout::LayoutOverflow;
3649
3650        use crate::solver3::getters::{get_overflow_x, get_overflow_y};
3651
3652        let mut scroll_ids = HashMap::new();
3653        let mut scroll_id_to_node_id = HashMap::new();
3654
3655        // Iterate through all layout nodes
3656        for (layout_idx, node) in layout_tree.nodes.iter().enumerate() {
3657            let Some(dom_node_id) = node.dom_node_id else {
3658                continue;
3659            };
3660
3661            // Get the node state
3662            let styled_node_state = styled_dom
3663                .styled_nodes
3664                .as_container()
3665                .get(dom_node_id)
3666                .map(|n| n.styled_node_state)
3667                .unwrap_or_default();
3668
3669            // Check if this node has scroll overflow
3670            let overflow_x = get_overflow_x(styled_dom, dom_node_id, &styled_node_state);
3671            let overflow_y = get_overflow_y(styled_dom, dom_node_id, &styled_node_state);
3672
3673            let is_scrollable = overflow_x.is_scroll() || overflow_y.is_scroll();
3674
3675            if !is_scrollable {
3676                continue;
3677            }
3678
3679            // Generate stable scroll ID from node_data_fingerprint
3680            // Use a combined hash of the fingerprint fields to create a stable ID
3681            let scroll_id = {
3682                use std::hash::{Hash, Hasher, DefaultHasher};
3683                let mut h = DefaultHasher::new();
3684                if let Some(cold) = layout_tree.cold(layout_idx) {
3685                    cold.node_data_fingerprint.hash(&mut h);
3686                }
3687                h.finish()
3688            };
3689
3690            scroll_ids.insert(layout_idx, scroll_id);
3691            scroll_id_to_node_id.insert(scroll_id, dom_node_id);
3692        }
3693
3694        (scroll_ids, scroll_id_to_node_id)
3695    }
3696
3697    /// Get the layout rectangle for a specific DOM node in logical coordinates
3698    ///
3699    /// This is useful in callbacks to get the position and size of the hit node
3700    /// for positioning menus, tooltips, or other overlays.
3701    ///
3702    /// Returns None if the node is not currently laid out (e.g., display:none)
3703    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
3704    pub fn get_node_layout_rect(
3705        &self,
3706        node_id: DomNodeId,
3707    ) -> Option<LogicalRect> {
3708        // Get the layout tree from cache
3709        let layout_tree = self.layout_cache.tree.as_ref()?;
3710        { let _ = (0xE5_000002u32 | ((layout_tree.nodes.len() as u32 & 0xff) << 8)); }
3711
3712        // Find the layout node index corresponding to this DOM node
3713        // Convert NodeHierarchyItemId to Option<NodeId> for comparison
3714        let target_node_id = node_id.node.into_crate_internal();
3715        let Some(layout_idx) = layout_tree.nodes.iter().position(|node| node.dom_node_id == target_node_id) else { { let _ = (0xE5_0000FFu32); } return None; };
3716        { let _ = (0xE5_000003u32 | ((self.layout_cache.calculated_positions.len() as u32 & 0xfff) << 8)); }
3717
3718        // Get the calculated layout position from cache (already in logical units)
3719        let Some(calc_pos) = self.layout_cache.calculated_positions.get(layout_idx) else { { let _ = (0xE5_0000FEu32); } return None; };
3720
3721        // Get the layout node for size information
3722        let layout_node = layout_tree.nodes.get(layout_idx)?;
3723
3724        // Get the used size (the actual laid-out size)
3725        let Some(used_size) = layout_node.used_size else { { let _ = (0xE5_0000FDu32); } return None; };
3726        { let _ = (0xE5_000004u32); }
3727
3728        // Convert size to logical coordinates
3729        let hidpi_factor = self
3730            .current_window_state
3731            .size
3732            .get_hidpi_factor()
3733            .inner
3734            .get();
3735
3736        Some(LogicalRect::new(
3737            LogicalPosition::new(calc_pos.x, calc_pos.y),
3738            LogicalSize::new(
3739                used_size.width / hidpi_factor,
3740                used_size.height / hidpi_factor,
3741            ),
3742        ))
3743    }
3744
3745    /// Get the cursor rect for the currently focused text input node in ABSOLUTE coordinates.
3746    ///
3747    /// This returns the cursor position in absolute window coordinates (not accounting for
3748    /// scroll offsets). This is used for scroll-into-view calculations where you need to
3749    /// compare the cursor position with the scrollable container's bounds.
3750    ///
3751    /// Returns None if:
3752    /// - No node is focused
3753    /// - Focused node has no text cursor
3754    /// - Focused node has no layout
3755    /// - Text cache cannot find cursor position
3756    ///
3757    /// For IME positioning (viewport-relative coordinates), use
3758    /// `get_focused_cursor_rect_viewport()`.
3759    /// Rebuild the accessibility tree from the current layout results, focus,
3760    /// and cursor state.  Called after full layout AND after display-list-only
3761    /// regeneration so that screen readers see up-to-date bounds, cursor, and
3762    /// focus information.
3763    #[cfg(feature = "a11y")]
3764    pub fn update_a11y_tree(&mut self) {
3765        let cursor_a11y_info = self.text_edit_manager.multi_cursor.as_ref().and_then(|mc| {
3766            let node_id = mc.node_id.node.into_crate_internal()?;
3767            let primary = mc.get_primary()?;
3768            let (anchor_offset, focus_offset) = match &primary.selection {
3769                Selection::Cursor(c) => {
3770                    let off = c.cluster_id.start_byte_in_run as usize;
3771                    (off, off)
3772                }
3773                Selection::Range(r) => (
3774                    r.start.cluster_id.start_byte_in_run as usize,
3775                    r.end.cluster_id.start_byte_in_run as usize,
3776                ),
3777            };
3778            Some(crate::managers::a11y::CursorA11yInfo {
3779                dom_id: mc.node_id.dom,
3780                node_id,
3781                anchor_offset,
3782                focus_offset,
3783            })
3784        });
3785
3786        // Build text overrides from dirty_text_nodes so the a11y tree
3787        // reads the current (edited) text, not the stale StyledDom text.
3788        let mut dirty_text_overrides: BTreeMap<(DomId, NodeId), String> = BTreeMap::new();
3789        for (&(dom_id, node_id), dirty_node) in &self.dirty_text_nodes {
3790            dirty_text_overrides.insert(
3791                (dom_id, node_id),
3792                self.extract_text_from_inline_content(&dirty_node.content),
3793            );
3794        }
3795
3796        let a11y_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3797            crate::managers::a11y::A11yManager::update_tree(
3798                self.a11y_manager.root_id,
3799                &self.layout_results,
3800                &self.scroll_manager,
3801                &self.current_window_state.title,
3802                self.current_window_state.size.dimensions,
3803                self.focus_manager.get_focused_node().copied(),
3804                self.current_window_state.size.get_hidpi_factor().inner.get(),
3805                &dirty_text_overrides,
3806                cursor_a11y_info,
3807            )
3808        }));
3809
3810        if let Ok(tree_update) = a11y_result {
3811            self.a11y_manager.last_tree_update = Some(tree_update);
3812            self.a11y_manager.tree_initialized = true;
3813        }
3814    }
3815
3816    /// Build the platform-neutral accessibility snapshot.
3817    ///
3818    /// The counterpart of [`Self::update_a11y_tree`] for the two shells
3819    /// `accesskit` has no backend for: iOS (`UIKit`) and Android
3820    /// (`AccessibilityNodeProvider`). Both need the same information the
3821    /// accesskit tree carries — label, value, role, bounds, supported actions,
3822    /// parent/child links — expressed in Azul's own types, because neither can
3823    /// consume an `accesskit::TreeUpdate`.
3824    ///
3825    /// Cheap enough to call once per layout: it is one pass over the exposed
3826    /// nodes, no platform round trip.
3827    #[cfg(feature = "a11y")]
3828    #[must_use]
3829    pub fn build_a11y_snapshot(&self) -> crate::managers::a11y_snapshot::A11ySnapshot {
3830        crate::managers::a11y_snapshot::A11ySnapshot::build(
3831            &self.layout_results,
3832            &self.scroll_manager,
3833            self.focus_manager.get_focused_node().copied(),
3834            self.current_window_state.title.as_str(),
3835            self.current_window_state.size.dimensions,
3836        )
3837    }
3838
3839    /// Incremental a11y update: only push the focused contenteditable node's
3840    /// updated value + cursor/selection.  Falls back to full rebuild if the
3841    /// tree hasn't been initialized yet or there's no active editing.
3842    #[cfg(feature = "a11y")]
3843    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
3844    pub fn update_a11y_tree_incremental(&mut self) {
3845        if !self.a11y_manager.tree_initialized {
3846            // First time — need full tree
3847            return self.update_a11y_tree();
3848        }
3849
3850        // Only worth doing incremental if we have an active editing node
3851        let Some(mc) = self.text_edit_manager.multi_cursor.as_ref() else {
3852            return; // No cursor — nothing to update incrementally
3853        };
3854
3855        let dom_node_id = mc.node_id;
3856        let Some(node_id) = dom_node_id.node.into_crate_internal() else {
3857            return;
3858        };
3859        let dom_id = dom_node_id.dom;
3860
3861        // Get current text content (from dirty overrides or StyledDom)
3862        let text_content = if let Some(dirty) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
3863            self.extract_text_from_inline_content(&dirty.content)
3864        } else {
3865            // Fall back to StyledDom text
3866            let Some(lr) = self.layout_results.get(&dom_id) else {
3867                return self.update_a11y_tree();
3868            };
3869            let node_data = lr.styled_dom.node_data.as_ref();
3870            let hierarchy = lr.styled_dom.node_hierarchy.as_ref();
3871            let mut text = String::new();
3872            if let Some(item) = hierarchy.get(node_id.index()) {
3873                let mut child = item.first_child_id(node_id);
3874                while let Some(child_id) = child {
3875                    if let Some(cd) = node_data.get(child_id.index()) {
3876                        if let NodeType::Text(t) = &cd.node_type {
3877                            if !text.is_empty() { text.push(' '); }
3878                            text.push_str(t.as_str());
3879                        }
3880                    }
3881                    if child_id.index() >= hierarchy.len() { break; }
3882                    child = hierarchy[child_id.index()].next_sibling_id();
3883                }
3884            }
3885            text
3886        };
3887
3888        // Build the a11y node ID (same encoding as update_tree)
3889        let a11y_node_id = accesskit::NodeId(
3890            ((dom_id.inner as u64) << 32) | ((node_id.index() as u64) + 1),
3891        );
3892
3893        // Get the node data to determine role
3894        let role = self.layout_results.get(&dom_id)
3895            .and_then(|lr| lr.styled_dom.node_data.as_ref().get(node_id.index()))
3896            .map_or(accesskit::Role::GenericContainer, |nd| {
3897                if nd.is_contenteditable() || matches!(nd.node_type, NodeType::TextArea) {
3898                    accesskit::Role::MultilineTextInput
3899                } else if matches!(nd.node_type, NodeType::Input) {
3900                    accesskit::Role::TextInput
3901                } else {
3902                    accesskit::Role::GenericContainer
3903                }
3904            });
3905
3906        let mut node = accesskit::Node::new(role);
3907        node.set_value(text_content.as_str());
3908        node.add_action(accesskit::Action::SetTextSelection);
3909        node.add_action(accesskit::Action::ReplaceSelectedText);
3910        node.add_action(accesskit::Action::SetValue);
3911
3912        // Set cursor/selection
3913        let primary = mc.get_primary();
3914        if let Some(identified) = primary {
3915            let (anchor_off, focus_off) = match &identified.selection {
3916                Selection::Cursor(c) => {
3917                    let off = c.cluster_id.start_byte_in_run as usize;
3918                    (off, off)
3919                }
3920                Selection::Range(r) => (
3921                    r.start.cluster_id.start_byte_in_run as usize,
3922                    r.end.cluster_id.start_byte_in_run as usize,
3923                ),
3924            };
3925
3926            let char_lengths: Vec<u8> = text_content.chars()
3927                .map(|c| c.len_utf16() as u8)
3928                .collect();
3929            node.set_character_lengths(char_lengths.clone());
3930
3931            let byte_to_char = |byte_off: usize| -> usize {
3932                text_content.char_indices()
3933                    .take_while(|(b, _)| *b < byte_off)
3934                    .count()
3935                    .min(char_lengths.len())
3936            };
3937
3938            node.set_text_selection(accesskit::TextSelection {
3939                anchor: accesskit::TextPosition {
3940                    node: a11y_node_id,
3941                    character_index: byte_to_char(anchor_off),
3942                },
3943                focus: accesskit::TextPosition {
3944                    node: a11y_node_id,
3945                    character_index: byte_to_char(focus_off),
3946                },
3947            });
3948        }
3949
3950        // Focus: use the current focused node or root
3951        let focus = self.focus_manager.get_focused_node().copied()
3952            .and_then(|dn| {
3953                let idx = dn.node.into_crate_internal()?.index();
3954                Some(accesskit::NodeId(((dn.dom.inner as u64) << 32) | ((idx as u64) + 1)))
3955            })
3956            .unwrap_or(self.a11y_manager.root_id);
3957
3958        self.a11y_manager.last_tree_update = Some(accesskit::TreeUpdate {
3959            nodes: vec![(a11y_node_id, node)],
3960            tree: None, // Incremental — tree structure unchanged
3961            focus,
3962            tree_id: accesskit::TreeId::ROOT,
3963        });
3964    }
3965
3966    pub fn get_focused_cursor_rect(&self) -> Option<LogicalRect> {
3967        // Get the focused node
3968        let focused_node = self.focus_manager.focused_node?;
3969
3970        // Get the text cursor
3971        let cursor = self.text_edit_manager.get_primary_cursor()?;
3972
3973        // Get the layout tree from cache
3974        let layout_tree = self.layout_cache.tree.as_ref()?;
3975
3976        // Find the layout node index corresponding to the focused DOM node
3977        let target_node_id = focused_node.node.into_crate_internal();
3978        let layout_idx = layout_tree
3979            .nodes
3980            .iter()
3981            .position(|node| node.dom_node_id == target_node_id)?;
3982
3983        // Get the text layout result for this node (warm data)
3984        let warm_node = layout_tree.warm(layout_idx)?;
3985        let cached_layout = warm_node.inline_layout_result.as_ref()?;
3986        let inline_layout = &cached_layout.layout;
3987
3988        // Get the cursor rect in node-relative coordinates
3989        let mut cursor_rect = inline_layout.get_cursor_rect(&cursor)?;
3990
3991        // Get the calculated layout position from cache (already in logical units)
3992        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
3993
3994        // Add layout position to cursor rect (both already in logical units)
3995        cursor_rect.origin.x += calc_pos.x;
3996        cursor_rect.origin.y += calc_pos.y;
3997
3998        // Return ABSOLUTE position (no scroll correction)
3999        Some(cursor_rect)
4000    }
4001
4002    /// Compute the bounding rect of all selection ranges in the focused node.
4003    /// Returns the union of all selection rects in absolute coordinates.
4004    pub fn calculate_selection_bounding_rect(&self) -> Option<LogicalRect> {
4005        let focused_node = self.focus_manager.focused_node?;
4006        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
4007
4008        // Collect Range selections
4009        let ranges: Vec<_> = mc.selections.iter().filter_map(|s| {
4010            if let Selection::Range(ref r) = s.selection {
4011                Some(*r)
4012            } else {
4013                None
4014            }
4015        }).collect();
4016
4017        if ranges.is_empty() {
4018            return None;
4019        }
4020
4021        // Get the inline layout for the focused node
4022        let target_node_id = focused_node.node.into_crate_internal();
4023        let layout_tree = self.layout_cache.tree.as_ref()?;
4024        let layout_idx = layout_tree.nodes.iter()
4025            .position(|n| n.dom_node_id == target_node_id)?;
4026        let warm = layout_tree.warm(layout_idx)?;
4027        let inline_layout = &warm.inline_layout_result.as_ref()?.layout;
4028        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
4029
4030        let mut min_x = f32::MAX;
4031        let mut min_y = f32::MAX;
4032        let mut max_x = f32::MIN;
4033        let mut max_y = f32::MIN;
4034        let mut found_any = false;
4035
4036        for range in &ranges {
4037            for rect in inline_layout.get_selection_rects(range) {
4038                found_any = true;
4039                let abs_x = rect.origin.x + calc_pos.x;
4040                let abs_y = rect.origin.y + calc_pos.y;
4041                min_x = min_x.min(abs_x);
4042                min_y = min_y.min(abs_y);
4043                max_x = max_x.max(abs_x + rect.size.width);
4044                max_y = max_y.max(abs_y + rect.size.height);
4045            }
4046        }
4047
4048        if !found_any {
4049            return None;
4050        }
4051
4052        Some(LogicalRect::new(
4053            LogicalPosition { x: min_x, y: min_y },
4054            LogicalSize { width: max_x - min_x, height: max_y - min_y },
4055        ))
4056    }
4057
4058    /// Ctrl+D: select the next occurrence of the current selection/word.
4059    ///
4060    /// If the primary selection is a cursor (no range), first expand it to a word.
4061    /// Then search forward in the text for the next occurrence and add it as a
4062    /// new multi-cursor selection.
4063    ///
4064    /// Returns true if a new selection was added.
4065    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
4066    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
4067    /// # Panics
4068    ///
4069    /// Panics if there is no active multi-cursor.
4070    pub fn select_next_occurrence(&mut self) -> bool {
4071        use crate::text3::selection::select_word_at_cursor;
4072
4073        let Some(mc) = self.text_edit_manager.multi_cursor.as_mut() else {
4074            return false;
4075        };
4076        let node_id = mc.node_id;
4077        let Some(dom_node_id) = node_id.node.into_crate_internal() else {
4078            return false;
4079        };
4080
4081        // Get primary selection text (or word at cursor)
4082        let primary = match mc.selections.first() {
4083            Some(s) => *s,
4084            None => return false,
4085        };
4086
4087        let (search_range, need_word_expand) = match &primary.selection {
4088            Selection::Range(r) => (*r, false),
4089            Selection::Cursor(c) => {
4090                // Need to expand to word first
4091                (SelectionRange { start: *c, end: *c }, true)
4092            }
4093        };
4094
4095        // Get the inline layout
4096        let Some(inline_layout) = self.get_node_inline_layout(node_id.dom, dom_node_id) else {
4097            return false;
4098        };
4099
4100        // If no range yet, expand to word
4101        let word_range = if need_word_expand {
4102            match select_word_at_cursor(&search_range.start, &inline_layout) {
4103                Some(r) => r,
4104                None => return false,
4105            }
4106        } else {
4107            search_range
4108        };
4109
4110        // Extract the search text from inline content
4111        let content = self.get_text_before_textinput(node_id.dom, dom_node_id);
4112        let full_text = self.extract_text_from_inline_content(&content);
4113
4114        // Extract the selected word text using byte offsets
4115        let start_byte = word_range.start.cluster_id.start_byte_in_run as usize;
4116        let end_byte = word_range.end.cluster_id.start_byte_in_run as usize;
4117        let search_text = if word_range.start.cluster_id.source_run == word_range.end.cluster_id.source_run {
4118            if let Some(InlineContent::Text(run)) = content.get(word_range.start.cluster_id.source_run as usize) {
4119                if start_byte <= end_byte && end_byte <= run.text.len() {
4120                    run.text[start_byte..end_byte].to_string()
4121                } else {
4122                    return false;
4123                }
4124            } else {
4125                return false;
4126            }
4127        } else {
4128            return false; // Multi-run selection search not yet supported
4129        };
4130
4131        if search_text.is_empty() {
4132            return false;
4133        }
4134
4135        // Search forward from the end of the last selection
4136        let mc = self.text_edit_manager.multi_cursor.as_ref().unwrap();
4137        let last_end_byte = mc.selections.last()
4138            .map_or(0, |s| match &s.selection {
4139                Selection::Range(r) => r.end.cluster_id.start_byte_in_run as usize,
4140                Selection::Cursor(c) => c.cluster_id.start_byte_in_run as usize,
4141            });
4142
4143        let search_run = word_range.start.cluster_id.source_run;
4144
4145        // Find next occurrence in the same run's text
4146        if let Some(InlineContent::Text(run)) = content.get(search_run as usize) {
4147            let search_in = &run.text;
4148            // Search from after the last selection end
4149            if let Some(offset) = search_in[last_end_byte..].find(&search_text) {
4150                let match_start = last_end_byte + offset;
4151                let match_end = match_start + search_text.len();
4152
4153                let new_range = SelectionRange {
4154                    start: TextCursor {
4155                        cluster_id: GraphemeClusterId {
4156                            source_run: search_run,
4157                            start_byte_in_run: match_start as u32,
4158                        },
4159                        affinity: CursorAffinity::Leading,
4160                    },
4161                    end: TextCursor {
4162                        cluster_id: GraphemeClusterId {
4163                            source_run: search_run,
4164                            start_byte_in_run: match_end as u32,
4165                        },
4166                        affinity: CursorAffinity::Trailing,
4167                    },
4168                };
4169
4170                // If primary was a cursor, convert it to a word selection first
4171                let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
4172                if need_word_expand {
4173                    if let Some(first) = mc.selections.first_mut() {
4174                        first.selection = Selection::Range(word_range);
4175                    }
4176                }
4177                let _ = mc.add_selection(new_range);
4178                self.text_edit_manager.mark_dirty();
4179                return true;
4180            } else if last_end_byte > 0 {
4181                // Wrap around: search from the beginning
4182                if let Some(offset) = search_in[..start_byte].find(&search_text) {
4183                    let match_start = offset;
4184                    let match_end = match_start + search_text.len();
4185
4186                    let new_range = SelectionRange {
4187                        start: TextCursor {
4188                            cluster_id: GraphemeClusterId {
4189                                source_run: search_run,
4190                                start_byte_in_run: match_start as u32,
4191                            },
4192                            affinity: CursorAffinity::Leading,
4193                        },
4194                        end: TextCursor {
4195                            cluster_id: GraphemeClusterId {
4196                                source_run: search_run,
4197                                start_byte_in_run: match_end as u32,
4198                            },
4199                            affinity: CursorAffinity::Trailing,
4200                        },
4201                    };
4202
4203                    let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
4204                    if need_word_expand {
4205                        if let Some(first) = mc.selections.first_mut() {
4206                            first.selection = Selection::Range(word_range);
4207                        }
4208                    }
4209                    let _ = mc.add_selection(new_range);
4210                    self.text_edit_manager.mark_dirty();
4211                    return true;
4212                }
4213            }
4214        }
4215
4216        // If primary was cursor and we expanded to word but found no other occurrence,
4217        // still mark the word selection
4218        if need_word_expand {
4219            let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
4220            if let Some(first) = mc.selections.first_mut() {
4221                first.selection = Selection::Range(word_range);
4222            }
4223            self.text_edit_manager.mark_dirty();
4224            return true;
4225        }
4226
4227        false
4228    }
4229
4230    /// Get the cursor rect for the currently focused text input node in VIEWPORT coordinates.
4231    ///
4232    /// This returns the cursor position accounting for:
4233    /// 1. Scroll offsets from all scrollable ancestors
4234    /// 2. GPU transforms (CSS transforms, animations) from all transformed ancestors
4235    ///
4236    /// The returned position is viewport-relative (what the user actually sees on screen).
4237    /// This is used for IME window positioning, where the IME popup needs to appear at the
4238    /// visible cursor location, not the absolute layout position.
4239    ///
4240    /// Returns None if:
4241    /// - No node is focused
4242    /// - Focused node has no text cursor
4243    /// - Focused node has no layout
4244    /// - Text cache cannot find cursor position
4245    ///
4246    /// For scroll-into-view calculations (absolute coordinates), use `get_focused_cursor_rect()`.
4247    pub fn get_focused_cursor_rect_viewport(&self) -> Option<LogicalRect> {
4248        // Start with absolute position
4249        let mut cursor_rect = self.get_focused_cursor_rect()?;
4250
4251        // Get the focused node
4252        let focused_node = self.focus_manager.focused_node?;
4253
4254        // Get the layout tree from cache
4255        let layout_tree = self.layout_cache.tree.as_ref()?;
4256
4257        // Find the layout node index corresponding to the focused DOM node
4258        let target_node_id = focused_node.node.into_crate_internal();
4259        let layout_idx = layout_tree
4260            .nodes
4261            .iter()
4262            .position(|node| node.dom_node_id == target_node_id)?;
4263
4264        // Get the GPU cache for this DOM (if it exists)
4265        let gpu_cache = self.gpu_state_manager.caches.get(&focused_node.dom);
4266
4267        // CRITICAL STEP 1: Apply scroll offsets from all scrollable ancestors
4268        // CRITICAL STEP 2: Apply inverse GPU transforms from all transformed ancestors
4269        // Walk up the tree and apply both corrections
4270        let mut current_layout_idx = layout_idx;
4271
4272        while let Some(parent_idx) = layout_tree.nodes.get(current_layout_idx)?.parent {
4273            // Get the DOM node ID of the parent (if it's not anonymous)
4274            if let Some(parent_dom_node_id) = layout_tree.nodes.get(parent_idx)?.dom_node_id {
4275                // STEP 1: Check if this parent is scrollable and has scroll state
4276                if let Some(scroll_state) = self
4277                    .scroll_manager
4278                    .get_scroll_state(focused_node.dom, parent_dom_node_id)
4279                {
4280                    // Subtract scroll offset (scrolling down = positive offset, moves content up)
4281                    cursor_rect.origin.x -= scroll_state.current_offset.x;
4282                    cursor_rect.origin.y -= scroll_state.current_offset.y;
4283                }
4284
4285                // STEP 2: Check if this parent has a GPU transform applied
4286                if let Some(cache) = gpu_cache {
4287                    if let Some(transform) = cache.current_transform_values.get(&parent_dom_node_id)
4288                    {
4289                        // Apply the INVERSE transform to get back to viewport coordinates
4290                        // The transform moves the element, so we need to reverse it for the cursor
4291                        let inverse = transform.inverse();
4292                        if let Some(transformed_origin) =
4293                            inverse.transform_point2d(cursor_rect.origin)
4294                        {
4295                            cursor_rect.origin = transformed_origin;
4296                        }
4297                        // Note: We don't transform the size, only the position
4298                    }
4299                }
4300            }
4301
4302            // Move to parent for next iteration
4303            current_layout_idx = parent_idx;
4304        }
4305
4306        Some(cursor_rect)
4307    }
4308
4309    /// Find the nearest scrollable ancestor for a given node
4310    /// Returns (`DomId`, `NodeId`) of the scrollable container, or None if no scrollable ancestor
4311    /// exists
4312    pub fn find_scrollable_ancestor(
4313        &self,
4314        mut node_id: DomNodeId,
4315    ) -> Option<DomNodeId> {
4316        // Get the layout tree
4317        let layout_tree = self.layout_cache.tree.as_ref()?;
4318
4319        // Convert to internal NodeId
4320        let mut current_node_id = node_id.node.into_crate_internal();
4321
4322        // Walk up the tree looking for a scrollable node
4323        loop {
4324            // Find layout node index
4325            let layout_idx = layout_tree
4326                .nodes
4327                .iter()
4328                .position(|node| node.dom_node_id == current_node_id)?;
4329
4330            // Check if this node has scrollbar info (meaning it's scrollable)
4331            if layout_tree.warm(layout_idx).and_then(|w| w.scrollbar_info.as_ref()).is_some() {
4332                // Check if it actually has a scroll state registered
4333                let check_node_id = current_node_id?;
4334                if self
4335                    .scroll_manager
4336                    .get_scroll_state(node_id.dom, check_node_id)
4337                    .is_some()
4338                {
4339                    // Found a scrollable ancestor
4340                    return Some(DomNodeId {
4341                        dom: node_id.dom,
4342                        node: NodeHierarchyItemId::from_crate_internal(
4343                            Some(check_node_id),
4344                        ),
4345                    });
4346                }
4347            }
4348
4349            // Move to parent
4350            let parent_idx = layout_tree.get(layout_idx)?.parent?;
4351            let parent_node = layout_tree.get(parent_idx)?;
4352            current_node_id = parent_node.dom_node_id;
4353        }
4354    }
4355
4356    /// Scroll selection or cursor into view with distance-based acceleration.
4357    ///
4358    /// **Unified Scroll System**: This method handles both cursor (0-size selection)
4359    /// and full selection scrolling with a single implementation. For drag-to-scroll,
4360    /// scroll speed increases with distance from container edge.
4361    ///
4362    /// ## Algorithm
4363    /// 1. Get bounds to scroll (cursor rect, selection rect, or mouse position)
4364    /// 2. Find scrollable ancestor container
4365    /// 3. Calculate distance from bounds to container edges
4366    /// 4. Compute scroll delta (instant with padding, or accelerated with zones)
4367    /// 5. Apply scroll with appropriate animation
4368    ///
4369    /// ## Distance-Based Acceleration (`ScrollMode::Accelerated`)
4370    /// ```text
4371    /// Distance from edge:  Scroll speed per frame:
4372    /// 0-20px              Dead zone (no scroll)
4373    /// 20-50px             Slow (2px/frame)
4374    /// 50-100px            Medium (4px/frame)
4375    /// 100-200px           Fast (8px/frame)
4376    /// 200+px              Very fast (16px/frame)
4377    /// ```
4378    ///
4379    /// ## Returns
4380    /// `true` if scrolling was applied, `false` if already visible
4381    pub fn scroll_selection_into_view(
4382        &mut self,
4383        scroll_type: SelectionScrollType,
4384        scroll_mode: ScrollMode,
4385    ) -> bool {
4386        // Get bounds to scroll into view
4387        let bounds = match scroll_type {
4388            SelectionScrollType::Cursor => {
4389                // Cursor is 0-size selection at insertion point
4390                match self.get_focused_cursor_rect() {
4391                    Some(rect) => rect,
4392                    None => return false, // No cursor to scroll
4393                }
4394            }
4395            SelectionScrollType::Selection => {
4396                // Compute bounding rect of all selection ranges via the text layout.
4397                // Falls back to cursor rect if no ranges exist.
4398                match self.calculate_selection_bounding_rect()
4399                    .or_else(|| self.get_focused_cursor_rect())
4400                {
4401                    Some(rect) => rect,
4402                    None => return false,
4403                }
4404            }
4405            SelectionScrollType::DragSelection { mouse_position } => {
4406                // For drag: use mouse position to determine scroll direction/speed
4407                LogicalRect::new(mouse_position, LogicalSize::zero())
4408            }
4409        };
4410
4411        // Get the focused node (or bail if no focus)
4412        let Some(focused_node) = self.focus_manager.focused_node else {
4413            return false;
4414        };
4415
4416        // Find scrollable ancestor
4417        let Some(scroll_container) = self.find_scrollable_ancestor(focused_node) else {
4418            return false; // No scrollable ancestor
4419        };
4420
4421        // Get container bounds and current scroll state
4422        let Some(layout_tree) = self.layout_cache.tree.as_ref() else {
4423            return false;
4424        };
4425
4426        let Some(scrollable_node_internal) = scroll_container.node.into_crate_internal() else {
4427            return false;
4428        };
4429
4430        let Some(layout_idx) = layout_tree
4431            .nodes
4432            .iter()
4433            .position(|n| n.dom_node_id == Some(scrollable_node_internal))
4434        else {
4435            return false;
4436        };
4437
4438        let Some(scrollable_layout_node) = layout_tree.nodes.get(layout_idx) else {
4439            return false;
4440        };
4441
4442        let container_pos = self
4443            .layout_cache
4444            .calculated_positions
4445            .get(layout_idx)
4446            .copied()
4447            .unwrap_or_default();
4448
4449        let container_size = scrollable_layout_node.used_size.unwrap_or_default();
4450
4451        let container_rect = LogicalRect {
4452            origin: container_pos,
4453            size: container_size,
4454        };
4455
4456        // Get current scroll state
4457        let Some(scroll_state) = self
4458            .scroll_manager
4459            .get_scroll_state(scroll_container.dom, scrollable_node_internal)
4460        else {
4461            return false;
4462        };
4463
4464        // Calculate visible area (container rect adjusted by scroll offset)
4465        let visible_area = LogicalRect::new(
4466            LogicalPosition::new(
4467                container_rect.origin.x + scroll_state.current_offset.x,
4468                container_rect.origin.y + scroll_state.current_offset.y,
4469            ),
4470            container_rect.size,
4471        );
4472
4473        // Calculate scroll delta based on mode
4474        let scroll_delta = match scroll_mode {
4475            ScrollMode::Instant => {
4476                // For typing/clicking: instant scroll with fixed padding
4477                calculate_instant_scroll_delta(bounds, visible_area)
4478            }
4479            ScrollMode::Accelerated => {
4480                // For drag: accelerated scroll based on distance from edge
4481                let distance = calculate_edge_distance(bounds, visible_area);
4482                calculate_accelerated_scroll_delta(distance)
4483            }
4484        };
4485
4486        // Apply scroll if needed
4487        if scroll_delta.x != 0.0 || scroll_delta.y != 0.0 {
4488            let duration = match scroll_mode {
4489                ScrollMode::Instant => Duration::System(SystemTimeDiff { secs: 0, nanos: 0 }),
4490                ScrollMode::Accelerated => Duration::System(SystemTimeDiff {
4491                    secs: 0,
4492                    nanos: 16_666_667,
4493                }), // 60fps
4494            };
4495
4496            let external = ExternalSystemCallbacks::rust_internal();
4497            let now = (external.get_system_time_fn.cb)();
4498
4499            // Calculate new scroll target
4500            let new_target = LogicalPosition {
4501                x: scroll_state.current_offset.x + scroll_delta.x,
4502                y: scroll_state.current_offset.y + scroll_delta.y,
4503            };
4504
4505            self.scroll_manager.scroll_to(
4506                scroll_container.dom,
4507                scrollable_node_internal,
4508                new_target,
4509                duration,
4510                EasingFunction::Linear,
4511                now,
4512            );
4513
4514            true // Scrolled
4515        } else {
4516            false // Already visible
4517        }
4518    }
4519
4520    /// Scrolls the focused cursor into view after layout.
4521    ///
4522    /// Delegates to `scroll_selection_into_view` with cursor mode.
4523    /// Called internally from `layout_and_generate_display_list()`.
4524    fn scroll_focused_cursor_into_view(&mut self) {
4525        // Redirect to unified scroll system
4526        self.scroll_selection_into_view(SelectionScrollType::Cursor, ScrollMode::Instant);
4527    }
4528}
4529
4530/// Type of selection bounds to scroll into view
4531#[derive(Debug, Clone, Copy)]
4532pub enum SelectionScrollType {
4533    /// Scroll cursor (0-size selection) into view
4534    Cursor,
4535    /// Scroll current selection bounds into view
4536    Selection,
4537    /// Scroll for drag selection (use mouse position for direction/speed)
4538    DragSelection { mouse_position: LogicalPosition },
4539}
4540
4541/// Scroll animation mode
4542#[derive(Debug, Clone, Copy)]
4543pub enum ScrollMode {
4544    /// Instant scroll with fixed padding (for typing, arrow keys)
4545    Instant,
4546    /// Accelerated scroll based on distance from edge (for drag-to-scroll)
4547    Accelerated,
4548}
4549
4550/// Distance from rect edges to container edges (for acceleration calculation)
4551#[derive(Debug, Clone, Copy)]
4552struct EdgeDistance {
4553    left: f32,
4554    right: f32,
4555    top: f32,
4556    bottom: f32,
4557}
4558
4559/// Calculate distance from rect to container edges
4560fn calculate_edge_distance(rect: LogicalRect, container: LogicalRect) -> EdgeDistance {
4561    EdgeDistance {
4562        // Distance from rect's left edge to container's left edge
4563        left: (rect.origin.x - container.origin.x).max(0.0),
4564        // Distance from container's right edge to rect's right edge
4565        right: ((container.origin.x + container.size.width) - (rect.origin.x + rect.size.width))
4566            .max(0.0),
4567        // Distance from rect's top edge to container's top edge
4568        top: (rect.origin.y - container.origin.y).max(0.0),
4569        // Distance from container's bottom edge to rect's bottom edge
4570        bottom: ((container.origin.y + container.size.height) - (rect.origin.y + rect.size.height))
4571            .max(0.0),
4572    }
4573}
4574
4575/// Calculate scroll delta with fixed padding (instant scroll mode)
4576fn calculate_instant_scroll_delta(
4577    bounds: LogicalRect,
4578    visible_area: LogicalRect,
4579) -> LogicalPosition {
4580    const PADDING: f32 = 5.0;
4581    let mut delta = LogicalPosition::zero();
4582
4583    // Horizontal scrolling
4584    if bounds.origin.x < visible_area.origin.x + PADDING {
4585        delta.x = bounds.origin.x - visible_area.origin.x - PADDING;
4586    } else if bounds.origin.x + bounds.size.width
4587        > visible_area.origin.x + visible_area.size.width - PADDING
4588    {
4589        delta.x = (bounds.origin.x + bounds.size.width)
4590            - (visible_area.origin.x + visible_area.size.width)
4591            + PADDING;
4592    }
4593
4594    // Vertical scrolling
4595    if bounds.origin.y < visible_area.origin.y + PADDING {
4596        delta.y = bounds.origin.y - visible_area.origin.y - PADDING;
4597    } else if bounds.origin.y + bounds.size.height
4598        > visible_area.origin.y + visible_area.size.height - PADDING
4599    {
4600        delta.y = (bounds.origin.y + bounds.size.height)
4601            - (visible_area.origin.y + visible_area.size.height)
4602            + PADDING;
4603    }
4604
4605    delta
4606}
4607
4608/// Calculate scroll delta with distance-based acceleration (drag-to-scroll mode)
4609fn calculate_accelerated_scroll_delta(distance: EdgeDistance) -> LogicalPosition {
4610    // Acceleration zones (in pixels from edge)
4611    const DEAD_ZONE: f32 = 20.0;
4612    const SLOW_ZONE: f32 = 50.0;
4613    const MEDIUM_ZONE: f32 = 100.0;
4614    const FAST_ZONE: f32 = 200.0;
4615
4616    // Scroll speeds (pixels per frame at 60fps)
4617    const SLOW_SPEED: f32 = 2.0;
4618    const MEDIUM_SPEED: f32 = 4.0;
4619    const FAST_SPEED: f32 = 8.0;
4620    const VERY_FAST_SPEED: f32 = 16.0;
4621
4622    // Helper to calculate speed for one direction
4623    let speed_for_distance = |dist: f32| -> f32 {
4624        if dist < DEAD_ZONE {
4625            0.0
4626        } else if dist < SLOW_ZONE {
4627            SLOW_SPEED
4628        } else if dist < MEDIUM_ZONE {
4629            MEDIUM_SPEED
4630        } else if dist < FAST_ZONE {
4631            FAST_SPEED
4632        } else {
4633            VERY_FAST_SPEED
4634        }
4635    };
4636
4637    // Calculate horizontal scroll (left vs right)
4638    let scroll_x = if distance.left < distance.right {
4639        // Closer to left edge - scroll left
4640        -speed_for_distance(distance.left)
4641    } else {
4642        // Closer to right edge - scroll right
4643        speed_for_distance(distance.right)
4644    };
4645
4646    // Calculate vertical scroll (top vs bottom)
4647    let scroll_y = if distance.top < distance.bottom {
4648        // Closer to top edge - scroll up
4649        -speed_for_distance(distance.top)
4650    } else {
4651        // Closer to bottom edge - scroll down
4652        speed_for_distance(distance.bottom)
4653    };
4654
4655    LogicalPosition::new(scroll_x, scroll_y)
4656}
4657
4658/// Result of a layout operation
4659#[derive(Debug)]
4660pub struct LayoutResult {
4661    pub display_list: DisplayList,
4662    pub warnings: Vec<String>,
4663}
4664
4665impl LayoutResult {
4666    #[must_use] pub const fn new(display_list: DisplayList, warnings: Vec<String>) -> Self {
4667        Self {
4668            display_list,
4669            warnings,
4670        }
4671    }
4672}
4673
4674impl LayoutWindow {
4675    /// Runs a single timer, similar to `CallbacksOfHitTest.call()`
4676    ///
4677    /// NOTE: The timer has to be selected first by the calling code and verified
4678    /// that it is ready to run
4679    #[cfg(feature = "std")]
4680    /// Run a single timer callback and return raw changes + update.
4681    ///
4682    /// If the timer should terminate, a `RemoveTimer` change is appended.
4683    // Instant is a ref-counted FFI clock handle threaded through the event loop by value.
4684    #[allow(clippy::needless_pass_by_value)]
4685    /// # Panics
4686    ///
4687    /// Panics if `timer_id` does not correspond to a registered timer.
4688    pub fn run_single_timer(
4689        &mut self,
4690        timer_id: usize,
4691        frame_start: Instant,
4692        current_window_handle: &RawWindowHandle,
4693        gl_context: &OptionGlContextPtr,
4694        system_style: Arc<azul_css::system::SystemStyle>,
4695        system_callbacks: &ExternalSystemCallbacks,
4696        previous_window_state: &Option<FullWindowState>,
4697        current_window_state: &FullWindowState,
4698        renderer_resources: &RendererResources,
4699    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4700        use crate::callbacks::{CallbackInfo, CallbackChange};
4701
4702        let mut update = Update::DoNothing;
4703        let mut all_changes = Vec::new();
4704        let mut should_terminate = TerminateTimer::Continue;
4705
4706        let current_scroll_states_nested = self.get_nested_scroll_states(DomId::ROOT_ID);
4707
4708        let timer_exists = self.timers.contains_key(&TimerId { id: timer_id });
4709        let timer_node_id = self
4710            .timers
4711            .get(&TimerId { id: timer_id })
4712            .and_then(|t| t.node_id.into_option());
4713
4714        if timer_exists {
4715            let hit_dom_node = timer_node_id.map_or_else(|| DomNodeId {
4716                    dom: DomId::ROOT_ID,
4717                    node: NodeHierarchyItemId::from_crate_internal(None),
4718                }, |s| s);
4719            let cursor_relative_to_item = OptionLogicalPosition::None;
4720            let cursor_in_viewport = OptionLogicalPosition::None;
4721
4722            let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4723
4724            let timer_ctx = self
4725                .timers
4726                .get(&TimerId { id: timer_id })
4727                .map_or(OptionRefAny::None, |t| t.callback.ctx.clone());
4728
4729            let ref_data = crate::callbacks::CallbackInfoRefData {
4730                layout_window: self,
4731                renderer_resources,
4732                previous_window_state,
4733                current_window_state,
4734                gl_context,
4735                current_scroll_manager: &current_scroll_states_nested,
4736                current_window_handle,
4737                system_callbacks,
4738                system_style,
4739                monitors: self.monitors.clone(),
4740                #[cfg(feature = "icu")]
4741                icu_localizer: self.icu_localizer.clone(),
4742                ctx: timer_ctx,
4743            };
4744
4745            let callback_info = CallbackInfo::new(
4746                &ref_data,
4747                &callback_changes,
4748                hit_dom_node,
4749                cursor_relative_to_item,
4750                cursor_in_viewport,
4751            );
4752
4753            let timer = self.timers.get_mut(&TimerId { id: timer_id }).unwrap();
4754            let tcr = timer.invoke(&callback_info, &system_callbacks.get_system_time_fn);
4755
4756            update = tcr.should_update;
4757            should_terminate = tcr.should_terminate;
4758
4759            all_changes = callback_changes
4760                .lock()
4761                .map(|mut guard| core::mem::take(&mut *guard))
4762                .unwrap_or_default();
4763        }
4764
4765        if should_terminate == TerminateTimer::Terminate {
4766            all_changes.push(CallbackChange::RemoveTimer {
4767                timer_id: TimerId { id: timer_id },
4768            });
4769        }
4770
4771        (all_changes, update)
4772    }
4773
4774    #[cfg(feature = "std")]
4775    /// Run all thread writeback callbacks and return raw changes + update.
4776    // system_style is an Arc<SystemStyle> handed to this layout entry point by every dll backend;
4777    // taking the Arc by value (one refcount) matches that boundary and avoids a cross-backend &-ripple.
4778    #[allow(clippy::needless_pass_by_value)]
4779    pub fn run_all_threads(
4780        &mut self,
4781        data: &mut RefAny,
4782        current_window_handle: &RawWindowHandle,
4783        gl_context: &OptionGlContextPtr,
4784        system_style: Arc<azul_css::system::SystemStyle>,
4785        system_callbacks: &ExternalSystemCallbacks,
4786        previous_window_state: &Option<FullWindowState>,
4787        current_window_state: &FullWindowState,
4788        renderer_resources: &RendererResources,
4789    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4790        use std::collections::BTreeSet;
4791
4792        use crate::{
4793            callbacks::{CallbackInfo, CallbackChange},
4794            thread::{OptionThreadReceiveMsg, ThreadReceiveMsg, ThreadWriteBackMsg},
4795        };
4796
4797        let mut update = Update::DoNothing;
4798        let mut all_changes = Vec::new();
4799
4800        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
4801
4802        let thread_ids: Vec<ThreadId> = self.threads.keys().copied().collect();
4803
4804        for thread_id in thread_ids {
4805            let Some(thread) = self.threads.get_mut(&thread_id) else {
4806                continue;
4807            };
4808
4809            let hit_dom_node = DomNodeId {
4810                dom: DomId::ROOT_ID,
4811                node: NodeHierarchyItemId::from_crate_internal(None),
4812            };
4813            let cursor_relative_to_item = OptionLogicalPosition::None;
4814            let cursor_in_viewport = OptionLogicalPosition::None;
4815
4816            let (msg, writeback_data_ptr, is_finished) = {
4817                let thread_inner = &mut *if let Ok(s) = thread.ptr.lock() { s } else {
4818                    all_changes.push(CallbackChange::RemoveThread { thread_id });
4819                    continue;
4820                };
4821
4822                let _ = thread_inner.sender_send(ThreadSendMsg::Tick);
4823                let recv = thread_inner.receiver_try_recv();
4824                let msg = match recv {
4825                    OptionThreadReceiveMsg::None => continue,
4826                    OptionThreadReceiveMsg::Some(s) => s,
4827                };
4828
4829                let writeback_data_ptr: *mut RefAny = &raw mut thread_inner.writeback_data;
4830                let is_finished = thread_inner.is_finished();
4831
4832                (msg, writeback_data_ptr, is_finished)
4833            };
4834
4835            let ThreadWriteBackMsg {
4836                refany: mut data_inner,
4837                callback,
4838            } = match msg {
4839                ThreadReceiveMsg::Update(update_screen) => {
4840                    update.max_self(update_screen);
4841                    continue;
4842                }
4843                ThreadReceiveMsg::WriteBack(t) => t,
4844            };
4845
4846            let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4847
4848            let ref_data = crate::callbacks::CallbackInfoRefData {
4849                layout_window: self,
4850                renderer_resources,
4851                previous_window_state,
4852                current_window_state,
4853                gl_context,
4854                current_scroll_manager: &current_scroll_states,
4855                current_window_handle,
4856                system_callbacks,
4857                system_style: system_style.clone(),
4858                monitors: self.monitors.clone(),
4859                #[cfg(feature = "icu")]
4860                icu_localizer: self.icu_localizer.clone(),
4861                ctx: callback.ctx.clone(),
4862            };
4863
4864            let callback_info = CallbackInfo::new(
4865                &ref_data,
4866                &callback_changes,
4867                hit_dom_node,
4868                cursor_relative_to_item,
4869                cursor_in_viewport,
4870            );
4871
4872            let callback_update = (callback.cb)(
4873                unsafe { (*writeback_data_ptr).clone() },
4874                data_inner.clone(),
4875                callback_info,
4876            );
4877            update.max_self(callback_update);
4878
4879            let collected_changes = callback_changes
4880                .lock()
4881                .map(|mut guard| core::mem::take(&mut *guard))
4882                .unwrap_or_default();
4883
4884            all_changes.extend(collected_changes);
4885
4886            if is_finished {
4887                all_changes.push(CallbackChange::RemoveThread { thread_id });
4888            }
4889        }
4890
4891        (all_changes, update)
4892    }
4893
4894    /// Invokes a single callback and returns the raw changes + update signal.
4895    ///
4896    /// Caller is responsible for processing each `CallbackChange` via
4897    /// `PlatformWindowV2::apply_user_change()`.
4898    pub fn invoke_single_callback(
4899        &mut self,
4900        callback: &mut Callback,
4901        data: &mut RefAny,
4902        current_window_handle: &RawWindowHandle,
4903        gl_context: &OptionGlContextPtr,
4904        system_style: Arc<azul_css::system::SystemStyle>,
4905        system_callbacks: &ExternalSystemCallbacks,
4906        previous_window_state: &Option<FullWindowState>,
4907        current_window_state: &FullWindowState,
4908        renderer_resources: &RendererResources,
4909    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4910        // No specific event target (create / layout / timer / unmount callbacks):
4911        // `info.get_hit_node()` resolves to the root with a null node.
4912        let hit_dom_node = DomNodeId {
4913            dom: DomId::ROOT_ID,
4914            node: NodeHierarchyItemId::from_crate_internal(None),
4915        };
4916        self.invoke_single_callback_at(
4917            hit_dom_node,
4918            callback,
4919            data,
4920            current_window_handle,
4921            gl_context,
4922            system_style,
4923            system_callbacks,
4924            previous_window_state,
4925            current_window_state,
4926            renderer_resources,
4927        )
4928    }
4929
4930    /// Like [`invoke_single_callback`], but sets the callback's hit node (the
4931    /// event target) so `info.get_hit_node()` / `open_menu_for_hit_node()` /
4932    /// `get_hit_node_rect()` resolve to the node the event was dispatched to.
4933    /// Used by the W3C event-propagation dispatcher; without it those queries
4934    /// returned a null node (menus/dropdowns opened nowhere).
4935    pub fn invoke_single_callback_at(
4936        &mut self,
4937        hit_dom_node: DomNodeId,
4938        callback: &mut Callback,
4939        data: &mut RefAny,
4940        current_window_handle: &RawWindowHandle,
4941        gl_context: &OptionGlContextPtr,
4942        system_style: Arc<azul_css::system::SystemStyle>,
4943        system_callbacks: &ExternalSystemCallbacks,
4944        previous_window_state: &Option<FullWindowState>,
4945        current_window_state: &FullWindowState,
4946        renderer_resources: &RendererResources,
4947    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4948        use crate::callbacks::{CallbackInfo, CallbackChange};
4949
4950        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
4951
4952        // Resolve the cursor position *local to the dispatched node* from the
4953        // current mouse hit test (the same `point_relative_to_item` the hit
4954        // tester computed, and that text selection consumes). Without this
4955        // `info.get_cursor_relative_to_node()` was always `None`, so any
4956        // callback needing a node-local cursor (map pan/drag, custom hit
4957        // logic) silently bailed. Falls back to `None` when the node isn't in
4958        // the current hit test (e.g. non-pointer events).
4959        let cursor_relative_to_item = match hit_dom_node.node.into_crate_internal() {
4960            Some(node_id) => self
4961                .hover_manager
4962                .get_current(&crate::managers::hover::InputPointId::Mouse)
4963                .and_then(|ht| ht.hovered_nodes.get(&hit_dom_node.dom))
4964                .and_then(|hit| hit.regular_hit_test_nodes.get(&node_id))
4965                .map_or(OptionLogicalPosition::None, |item| OptionLogicalPosition::Some(item.point_relative_to_item)),
4966            None => OptionLogicalPosition::None,
4967        };
4968        let cursor_in_viewport = current_window_state.mouse_state.cursor_position.get_position().map_or(OptionLogicalPosition::None, OptionLogicalPosition::Some);
4969
4970        // Create changes container for callback transaction system
4971        let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4972
4973        // Create reference data container.
4974        //
4975        // `ctx` carries the callback's stored OptionRefAny (host-handle for
4976        // managed FFIs, PyCallableWrapper for Python, None for native Rust)
4977        // so `info.get_ctx()` reaches it. Without this the host-invoker
4978        // thunk in libazul sees `OptionRefAny::None` and bails out with
4979        // `Update::DoNothing` — and clicks would silently do nothing.
4980        let ref_data = crate::callbacks::CallbackInfoRefData {
4981            layout_window: self,
4982            renderer_resources,
4983            previous_window_state,
4984            current_window_state,
4985            gl_context,
4986            current_scroll_manager: &current_scroll_states,
4987            current_window_handle,
4988            system_callbacks,
4989            system_style,
4990            monitors: self.monitors.clone(),
4991            #[cfg(feature = "icu")]
4992            icu_localizer: self.icu_localizer.clone(),
4993            ctx: callback.ctx.clone(),
4994        };
4995
4996        let callback_info = CallbackInfo::new(
4997            &ref_data,
4998            &callback_changes,
4999            hit_dom_node,
5000            cursor_relative_to_item,
5001            cursor_in_viewport,
5002        );
5003
5004        let update = (callback.cb)(data.clone(), callback_info);
5005
5006        // Extract changes from the Arc<Mutex>
5007        let collected_changes = callback_changes
5008            .lock()
5009            .map(|mut guard| core::mem::take(&mut *guard))
5010            .unwrap_or_default();
5011
5012        (collected_changes, update)
5013    }
5014
5015    /// Set the system style for resolving system color keywords in CSS.
5016    ///
5017    /// This should be called during window initialization and whenever the system
5018    /// theme changes (dark/light mode switch, accent color change).
5019    ///
5020    /// The system style is used to resolve CSS system colors like `selection-background`,
5021    /// `selection-text`, `accent`, etc. If not set, hard-coded fallback values are used.
5022    pub fn set_system_style(&mut self, system_style: Arc<azul_css::system::SystemStyle>) {
5023        #[cfg(feature = "icu")]
5024        {
5025            self.icu_localizer = crate::icu::IcuLocalizerHandle::from_system_language(&system_style.language);
5026        }
5027        self.system_style = Some(system_style);
5028    }
5029}
5030
5031// --- ICU4X Internationalization API ---
5032
5033#[cfg(feature = "icu")]
5034impl LayoutWindow {
5035    /// Initialize the ICU localizer with the system's detected language.
5036    ///
5037    /// This should be called during window initialization, passing the language
5038    /// from `SystemStyle::language`.
5039    ///
5040    /// # Arguments
5041    /// * `locale` - The BCP 47 language tag (e.g., "en-US", "de-DE")
5042    pub fn set_icu_locale(&mut self, locale: &str) {
5043        self.icu_localizer.set_locale(locale);
5044    }
5045
5046    /// Initialize the ICU localizer from a SystemStyle.
5047    ///
5048    /// This is a convenience method that extracts the language from the system style.
5049    pub fn init_icu_from_system_style(&mut self, system_style: &azul_css::system::SystemStyle) {
5050        self.icu_localizer = IcuLocalizerHandle::from_system_language(&system_style.language);
5051    }
5052
5053    /// Get a clone of the ICU localizer handle.
5054    ///
5055    /// This can be used to perform locale-aware formatting outside of callbacks.
5056    pub fn get_icu_localizer(&self) -> IcuLocalizerHandle {
5057        self.icu_localizer.clone()
5058    }
5059
5060    /// Load additional ICU locale data from a binary blob.
5061    ///
5062    /// The blob should be generated using `icu4x-datagen` with the `--format blob` flag.
5063    /// This allows supporting locales that aren't compiled into the binary.
5064    pub fn load_icu_data_blob(&mut self, data: Vec<u8>) -> bool {
5065        self.icu_localizer.load_data_blob(&data)
5066    }
5067}
5068
5069#[cfg(test)]
5070mod tests {
5071    use super::*;
5072    use crate::{thread::Thread, timer::Timer};
5073
5074    #[test]
5075    fn test_timer_add_remove() {
5076        let fc_cache = FcFontCache::default();
5077        let mut window = LayoutWindow::new(fc_cache).unwrap();
5078
5079        let timer_id = TimerId { id: 1 };
5080        let timer = Timer::default();
5081
5082        // Add timer
5083        window.add_timer(timer_id, timer);
5084        assert!(window.get_timer(&timer_id).is_some());
5085        assert_eq!(window.get_timer_ids().len(), 1);
5086
5087        // Remove timer
5088        let removed = window.remove_timer(&timer_id);
5089        assert!(removed.is_some());
5090        assert!(window.get_timer(&timer_id).is_none());
5091        assert_eq!(window.get_timer_ids().len(), 0);
5092    }
5093
5094    #[test]
5095    fn test_timer_get_mut() {
5096        let fc_cache = FcFontCache::default();
5097        let mut window = LayoutWindow::new(fc_cache).unwrap();
5098
5099        let timer_id = TimerId { id: 1 };
5100        let timer = Timer::default();
5101
5102        window.add_timer(timer_id, timer);
5103
5104        // Get mutable reference
5105        let timer_mut = window.get_timer_mut(&timer_id);
5106        assert!(timer_mut.is_some());
5107    }
5108
5109    #[test]
5110    fn test_multiple_timers() {
5111        let fc_cache = FcFontCache::default();
5112        let mut window = LayoutWindow::new(fc_cache).unwrap();
5113
5114        let timer1 = TimerId { id: 1 };
5115        let timer2 = TimerId { id: 2 };
5116        let timer3 = TimerId { id: 3 };
5117
5118        window.add_timer(timer1, Timer::default());
5119        window.add_timer(timer2, Timer::default());
5120        window.add_timer(timer3, Timer::default());
5121
5122        assert_eq!(window.get_timer_ids().len(), 3);
5123
5124        window.remove_timer(&timer2);
5125        assert_eq!(window.get_timer_ids().len(), 2);
5126        assert!(window.get_timer(&timer1).is_some());
5127        assert!(window.get_timer(&timer2).is_none());
5128        assert!(window.get_timer(&timer3).is_some());
5129    }
5130
5131    // Thread management tests removed - Thread::default() not available
5132    // and threads require complex setup. Thread management is tested
5133    // through integration tests instead.
5134
5135    #[test]
5136    fn test_gpu_cache_management() {
5137        let fc_cache = FcFontCache::default();
5138        let mut window = LayoutWindow::new(fc_cache).unwrap();
5139
5140        let dom_id = DomId { inner: 0 };
5141
5142        // Initially empty
5143        assert!(window.get_gpu_cache(&dom_id).is_none());
5144
5145        // Get or create
5146        let cache = window.get_or_create_gpu_cache(dom_id);
5147        assert!(cache.transform_keys.is_empty());
5148
5149        // Now exists
5150        assert!(window.get_gpu_cache(&dom_id).is_some());
5151
5152        // Can get mutable reference
5153        let cache_mut = window.get_gpu_cache_mut(&dom_id);
5154        assert!(cache_mut.is_some());
5155    }
5156
5157    #[test]
5158    fn test_gpu_cache_multiple_doms() {
5159        let fc_cache = FcFontCache::default();
5160        let mut window = LayoutWindow::new(fc_cache).unwrap();
5161
5162        let dom1 = DomId { inner: 0 };
5163        let dom2 = DomId { inner: 1 };
5164
5165        window.get_or_create_gpu_cache(dom1);
5166        window.get_or_create_gpu_cache(dom2);
5167
5168        assert!(window.get_gpu_cache(&dom1).is_some());
5169        assert!(window.get_gpu_cache(&dom2).is_some());
5170    }
5171
5172    #[test]
5173    fn test_compute_cursor_type_empty_hit_test() {
5174        use crate::hit_test::FullHitTest;
5175
5176        let fc_cache = FcFontCache::default();
5177        let window = LayoutWindow::new(fc_cache).unwrap();
5178
5179        let empty_hit = FullHitTest::empty(None);
5180        let cursor_test = window.compute_cursor_type_hit_test(&empty_hit);
5181
5182        // Empty hit test should result in default cursor
5183        assert_eq!(
5184            cursor_test.cursor_icon,
5185            azul_core::window::MouseCursorType::Default
5186        );
5187        assert!(cursor_test.cursor_node.is_none());
5188    }
5189
5190    #[test]
5191    fn test_layout_result_access() {
5192        let fc_cache = FcFontCache::default();
5193        let window = LayoutWindow::new(fc_cache).unwrap();
5194
5195        let dom_id = DomId { inner: 0 };
5196
5197        // Initially no layout results
5198        assert!(window.get_layout_result(&dom_id).is_none());
5199        assert_eq!(window.get_dom_ids().len(), 0);
5200    }
5201
5202    // ScrollManager and VirtualView Integration Tests
5203
5204    #[test]
5205    fn test_scroll_manager_initialization() {
5206        let fc_cache = FcFontCache::default();
5207        let window = LayoutWindow::new(fc_cache).unwrap();
5208
5209        let dom_id = DomId::ROOT_ID;
5210        let node_id = NodeId::new(0);
5211
5212        // Initially no scroll states
5213        let scroll_offsets = window.scroll_manager.get_scroll_states_for_dom(dom_id);
5214        assert!(scroll_offsets.is_empty());
5215
5216        // No current offset
5217        let offset = window.scroll_manager.get_current_offset(dom_id, node_id);
5218        assert_eq!(offset, None);
5219    }
5220
5221    #[test]
5222    fn test_scroll_manager_tick_updates_activity() {
5223        let fc_cache = FcFontCache::default();
5224        let mut window = LayoutWindow::new(fc_cache).unwrap();
5225
5226        let dom_id = DomId::ROOT_ID;
5227        let node_id = NodeId::new(0);
5228
5229        // Create a scroll input
5230        #[cfg(feature = "std")]
5231        let now = Instant::now();
5232        #[cfg(not(feature = "std"))]
5233        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
5234
5235        let scroll_input = crate::managers::scroll_state::ScrollInput {
5236            dom_id,
5237            node_id,
5238            delta: LogicalPosition::new(10.0, 20.0),
5239            timestamp: now,
5240            source: crate::managers::scroll_state::ScrollInputSource::WheelDiscrete,
5241        };
5242
5243        let should_start_timer = window
5244            .scroll_manager
5245            .record_scroll_input(scroll_input);
5246
5247        // record_scroll_input should return true (timer was not running)
5248        assert!(should_start_timer);
5249    }
5250
5251    #[test]
5252    fn test_scroll_manager_programmatic_scroll() {
5253        let fc_cache = FcFontCache::default();
5254        let mut window = LayoutWindow::new(fc_cache).unwrap();
5255
5256        let dom_id = DomId::ROOT_ID;
5257        let node_id = NodeId::new(0);
5258
5259        #[cfg(feature = "std")]
5260        let now = Instant::now();
5261        #[cfg(not(feature = "std"))]
5262        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
5263
5264        // Programmatic scroll with animation
5265        window.scroll_manager.scroll_to(
5266            dom_id,
5267            node_id,
5268            LogicalPosition::new(100.0, 200.0),
5269            Duration::System(SystemTimeDiff::from_millis(300)),
5270            EasingFunction::EaseOut,
5271            now.clone(),
5272        );
5273
5274        let tick_result = window.scroll_manager.tick(now);
5275
5276        // Programmatic scroll should start animation
5277        assert!(tick_result.needs_repaint);
5278    }
5279
5280
5281
5282    #[test]
5283    fn test_gpu_cache_scrollbar_opacity_keys() {
5284        let fc_cache = FcFontCache::default();
5285        let mut window = LayoutWindow::new(fc_cache).unwrap();
5286
5287        let dom_id = DomId::ROOT_ID;
5288        let node_id = NodeId::new(0);
5289
5290        // Get or create GPU cache
5291        let gpu_cache = window.get_or_create_gpu_cache(dom_id);
5292
5293        // Initially no scrollbar opacity keys
5294        assert!(gpu_cache.scrollbar_v_opacity_keys.is_empty());
5295        assert!(gpu_cache.scrollbar_h_opacity_keys.is_empty());
5296
5297        // Add a vertical scrollbar opacity key
5298        let opacity_key = OpacityKey::unique();
5299        gpu_cache
5300            .scrollbar_v_opacity_keys
5301            .insert((dom_id, node_id), opacity_key);
5302        gpu_cache
5303            .scrollbar_v_opacity_values
5304            .insert((dom_id, node_id), 1.0);
5305
5306        // Verify it was added
5307        assert_eq!(gpu_cache.scrollbar_v_opacity_keys.len(), 1);
5308        assert_eq!(
5309            gpu_cache.scrollbar_v_opacity_values.get(&(dom_id, node_id)),
5310            Some(&1.0)
5311        );
5312    }
5313
5314
5315}
5316
5317// --- Cross-Paragraph Cursor Navigation API ---
5318impl LayoutWindow {
5319    /// Finds the next text node in the DOM tree after the given node.
5320    ///
5321    /// This function performs a depth-first traversal to find the next node
5322    /// that contains text content and is selectable (user-select != none).
5323    ///
5324    /// # Arguments
5325    /// * `dom_id` - The ID of the DOM containing the current node
5326    /// * `current_node` - The current node ID to start searching from
5327    ///
5328    /// # Returns
5329    /// * `Some((DomId, NodeId))` - The next text node if found
5330    /// * `None` - If no next text node exists
5331    pub fn find_next_text_node(
5332        &self,
5333        dom_id: &DomId,
5334        current_node: NodeId,
5335    ) -> Option<(DomId, NodeId)> {
5336        let layout_result = self.get_layout_result(dom_id)?;
5337        let styled_dom = &layout_result.styled_dom;
5338
5339        // Start from the next node in document order
5340        let start_idx = current_node.index() + 1;
5341        let node_hierarchy = &styled_dom.node_hierarchy;
5342
5343        for i in start_idx..node_hierarchy.len() {
5344            let node_id = NodeId::new(i);
5345
5346            // Check if node has text content
5347            if Self::node_has_text_content(styled_dom, node_id) {
5348                // Check if text is selectable
5349                if Self::is_text_selectable(styled_dom, node_id) {
5350                    return Some((*dom_id, node_id));
5351                }
5352            }
5353        }
5354
5355        None
5356    }
5357
5358    /// Finds the previous text node in the DOM tree before the given node.
5359    ///
5360    /// This function performs a reverse depth-first traversal to find the previous node
5361    /// that contains text content and is selectable.
5362    ///
5363    /// # Arguments
5364    /// * `dom_id` - The ID of the DOM containing the current node
5365    /// * `current_node` - The current node ID to start searching from
5366    ///
5367    /// # Returns
5368    /// * `Some((DomId, NodeId))` - The previous text node if found
5369    /// * `None` - If no previous text node exists
5370    pub fn find_prev_text_node(
5371        &self,
5372        dom_id: &DomId,
5373        current_node: NodeId,
5374    ) -> Option<(DomId, NodeId)> {
5375        let layout_result = self.get_layout_result(dom_id)?;
5376        let styled_dom = &layout_result.styled_dom;
5377
5378        // Start from the previous node in reverse document order
5379        let current_idx = current_node.index();
5380
5381        for i in (0..current_idx).rev() {
5382            let node_id = NodeId::new(i);
5383
5384            // Check if node has text content
5385            if Self::node_has_text_content(styled_dom, node_id) {
5386                // Check if text is selectable
5387                if Self::is_text_selectable(styled_dom, node_id) {
5388                    return Some((*dom_id, node_id));
5389                }
5390            }
5391        }
5392
5393        None
5394    }
5395
5396    /// Find the last text child node of a given node.
5397    ///
5398    /// For contenteditable elements, the text is usually in a child Text node,
5399    /// not the contenteditable div itself. This function finds the last Text node
5400    /// so the cursor defaults to the end position.
5401    fn find_last_text_child(&self, dom_id: DomId, parent_node_id: NodeId) -> Option<NodeId> {
5402        let layout_result = self.layout_results.get(&dom_id)?;
5403        let styled_dom = &layout_result.styled_dom;
5404        let node_data_container = styled_dom.node_data.as_container();
5405        let hierarchy_container = styled_dom.node_hierarchy.as_container();
5406
5407        // Check if parent itself is a text node
5408        let parent_type = node_data_container[parent_node_id].get_node_type();
5409        if matches!(parent_type, NodeType::Text(_)) {
5410            return Some(parent_node_id);
5411        }
5412
5413        // Find the last text child by iterating through all children
5414        let parent_item = &hierarchy_container[parent_node_id];
5415        let mut last_text_child: Option<NodeId> = None;
5416        let mut current_child = parent_item.first_child_id(parent_node_id);
5417        while let Some(child_id) = current_child {
5418            let child_type = node_data_container[child_id].get_node_type();
5419            if matches!(child_type, NodeType::Text(_)) {
5420                last_text_child = Some(child_id);
5421            }
5422            current_child = hierarchy_container[child_id].next_sibling_id();
5423        }
5424
5425        last_text_child
5426    }
5427
5428    /// Checks if a node has text content.
5429    fn node_has_text_content(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5430        // Check if node itself is a text node
5431        let node_data_container = styled_dom.node_data.as_container();
5432        let node_type = node_data_container[node_id].get_node_type();
5433        if matches!(node_type, NodeType::Text(_)) {
5434            return true;
5435        }
5436
5437        // Check if node has text children
5438        let hierarchy_container = styled_dom.node_hierarchy.as_container();
5439        let node_item = &hierarchy_container[node_id];
5440
5441        // Iterate through children
5442        let mut current_child = node_item.first_child_id(node_id);
5443        while let Some(child_id) = current_child {
5444            let child_type = node_data_container[child_id].get_node_type();
5445            if matches!(child_type, NodeType::Text(_)) {
5446                return true;
5447            }
5448
5449            // Move to next sibling
5450            current_child = hierarchy_container[child_id].next_sibling_id();
5451        }
5452
5453        false
5454    }
5455
5456    /// Checks if text in a node is selectable based on CSS user-select property.
5457    fn is_text_selectable(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5458        let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
5459        solver3::getters::is_text_selectable(styled_dom, node_id, node_state)
5460    }
5461
5462    /// Process an accessibility action from an assistive technology.
5463    ///
5464    /// This method dispatches actions to the appropriate managers (scroll, focus, etc.)
5465    /// and returns information about which nodes were affected and how.
5466    ///
5467    /// # Arguments
5468    /// * `dom_id` - The DOM containing the target node
5469    /// * `node_id` - The target node for the action
5470    /// * `action` - The accessibility action to perform
5471    /// * `now` - Current timestamp for animations
5472    ///
5473    /// # Returns
5474    /// A `BTreeMap` of affected nodes with:
5475    /// - Key: `DomNodeId` that was affected
5476    /// - Value: (Vec<EventFilter> synthetic events to dispatch, bool indicating if node needs
5477    ///   re-layout)
5478    ///
5479    /// Empty map = action was not applicable or nothing changed
5480    #[cfg(feature = "a11y")]
5481    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
5482    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5483    #[allow(clippy::needless_pass_by_value)] // public action-dispatch API called across the dll shell backends; by-value AccessibilityAction is the natural shape and avoids churning every platform caller for a perf-neutral change
5484    pub fn process_accessibility_action(
5485        &mut self,
5486        dom_id: DomId,
5487        node_id: NodeId,
5488        action: AccessibilityAction,
5489        now: Instant,
5490    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
5491        use crate::managers::text_input::TextInputSource;
5492
5493        let mut affected_nodes = BTreeMap::new();
5494
5495        match action {
5496            // Focus actions
5497            AccessibilityAction::Focus => {
5498                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5499                let dom_node_id = DomNodeId {
5500                    dom: dom_id,
5501                    node: hierarchy_id,
5502                };
5503                self.focus_manager.set_focused_node(Some(dom_node_id));
5504
5505                // Check if node is contenteditable - if so, initialize cursor at end of text
5506                if let Some(layout_result) = self.layout_results.get(&dom_id) {
5507                    if let Some(styled_node) = layout_result
5508                        .styled_dom
5509                        .node_data
5510                        .as_ref()
5511                        .get(node_id.index())
5512                    {
5513                        // Check BOTH: the contenteditable boolean field AND the attribute
5514                        // NodeData has a direct `contenteditable: bool` field that should be
5515                        // checked in addition to the attribute for robustness
5516                        let is_contenteditable = styled_node.is_contenteditable()
5517                            || styled_node.attributes().as_ref().iter().any(|attr| {
5518                                matches!(attr, AttributeType::ContentEditable(_))
5519                            });
5520
5521                        if is_contenteditable {
5522                            // Get inline layout for cursor positioning
5523                            // Clone the Arc to avoid borrow conflict
5524                            let inline_layout = self.get_inline_layout_for_node(dom_id, node_id).cloned();
5525                            if let Some(ref layout) = inline_layout {
5526                                let cursor = layout.items.iter().rev()
5527                                    .find_map(|item| if let ShapedItem::Cluster(c) = &item.item {
5528                                        Some(TextCursor {
5529                                            cluster_id: c.source_cluster_id,
5530                                            affinity: CursorAffinity::Trailing,
5531                                        })
5532                                    } else { None })
5533                                    .unwrap_or(TextCursor {
5534                                        cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
5535                                        affinity: CursorAffinity::Trailing,
5536                                    });
5537                                self.text_edit_manager.initialize_editing(cursor, dom_id, node_id, 0);
5538
5539                                // Scroll cursor into view if necessary
5540                                self.scroll_cursor_into_view_if_needed(dom_id, node_id, now.clone());
5541                            }
5542                        } else {
5543                            // Not editable - clear cursor
5544                            self.text_edit_manager.clear_editing();
5545                        }
5546                    }
5547                }
5548
5549                // Optionally scroll into view
5550                self.scroll_to_node_if_needed(dom_id, node_id, now);
5551            }
5552            AccessibilityAction::Blur => {
5553                self.focus_manager.clear_focus();
5554                self.text_edit_manager.clear_editing();
5555            }
5556            AccessibilityAction::SetSequentialFocusNavigationStartingPoint => {
5557                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5558                let dom_node_id = DomNodeId {
5559                    dom: dom_id,
5560                    node: hierarchy_id,
5561                };
5562                self.focus_manager.set_focused_node(Some(dom_node_id));
5563                // Clear cursor for focus navigation
5564                self.text_edit_manager.clear_editing();
5565            }
5566
5567            // Scroll actions
5568            AccessibilityAction::ScrollIntoView => {
5569                self.scroll_to_node_if_needed(dom_id, node_id, now);
5570            }
5571            AccessibilityAction::ScrollLeft |
5572            AccessibilityAction::ScrollRight |
5573            AccessibilityAction::ScrollUp |
5574            AccessibilityAction::ScrollDown => {
5575                // Find the scrollable ancestor (or the node itself if scrollable)
5576                let dom_node_id = DomNodeId {
5577                    dom: dom_id,
5578                    node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
5579                };
5580                let (scroll_dom, scroll_nid) = self.find_scrollable_ancestor(dom_node_id)
5581                    .and_then(|a| Some((a.dom, a.node.into_crate_internal()?)))
5582                    .unwrap_or((dom_id, node_id));
5583
5584                // Use viewport-relative scroll amounts (75% of viewport dimension)
5585                let bounds = self.get_node_bounds(scroll_dom, scroll_nid);
5586                let vp_h = bounds.map_or(600.0, |b| b.size.height as f32);
5587                let vp_w = bounds.map_or(800.0, |b| b.size.width as f32);
5588
5589                let (dx, dy) = match action {
5590                    AccessibilityAction::ScrollLeft  => (-vp_w * 0.75, 0.0),
5591                    AccessibilityAction::ScrollRight => ( vp_w * 0.75, 0.0),
5592                    AccessibilityAction::ScrollUp    => (0.0, -vp_h * 0.75),
5593                    AccessibilityAction::ScrollDown  => (0.0,  vp_h * 0.75),
5594                    _ => unreachable!(),
5595                };
5596
5597                self.scroll_manager.scroll_by(
5598                    scroll_dom,
5599                    scroll_nid,
5600                    LogicalPosition { x: dx, y: dy },
5601                    std::time::Duration::from_millis(250).into(),
5602                    EasingFunction::EaseOut,
5603                    now,
5604                );
5605            }
5606            AccessibilityAction::SetScrollOffset(pos) => {
5607                self.scroll_manager.scroll_to(
5608                    dom_id,
5609                    node_id,
5610                    pos,
5611                    std::time::Duration::from_millis(0).into(),
5612                    EasingFunction::Linear,
5613                    now,
5614                );
5615            }
5616            AccessibilityAction::ScrollToPoint(pos) => {
5617                self.scroll_manager.scroll_to(
5618                    dom_id,
5619                    node_id,
5620                    pos,
5621                    std::time::Duration::from_millis(300).into(),
5622                    EasingFunction::EaseInOut,
5623                    now,
5624                );
5625            }
5626
5627            // Actions that should trigger element callbacks if they exist
5628            // These generate synthetic EventFilters that go through the normal
5629            // callback system
5630            AccessibilityAction::Default => {
5631                // Default action → synthetic Click event
5632                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5633                let dom_node_id = DomNodeId {
5634                    dom: dom_id,
5635                    node: hierarchy_id,
5636                };
5637
5638                // Default action maps to a synthetic MouseUp (click) event
5639                let event_filter = EventFilter::Hover(HoverEventFilter::MouseUp);
5640
5641                affected_nodes.insert(dom_node_id, (vec![event_filter], false));
5642            }
5643
5644            AccessibilityAction::Increment | AccessibilityAction::Decrement => {
5645                // Increment/Decrement work by:
5646                // 1. Reading the current value (from "value" attribute or text content)
5647                // 2. Parsing it as a number
5648                // 3. Incrementing/decrementing by 1
5649                // 4. Converting back to string
5650                // 5. Recording as text input (fires TextInput event)
5651                //
5652                // This allows user callbacks to intercept via On::TextInput
5653
5654                let is_increment = matches!(action, AccessibilityAction::Increment);
5655
5656                // Get the current value
5657                let current_value = self.layout_results.get(&dom_id).and_then(|layout_result| {
5658                    layout_result
5659                        .styled_dom
5660                        .node_data
5661                        .as_ref()
5662                        .get(node_id.index())
5663                        .and_then(|styled_node| {
5664                            // Try "value" attribute first
5665                            styled_node
5666                                .attributes()
5667                                .as_ref()
5668                                .iter()
5669                                .find_map(|attr| {
5670                                    if let AttributeType::Value(v) = attr {
5671                                        Some(v.as_str().to_string())
5672                                    } else {
5673                                        None
5674                                    }
5675                                })
5676                                .or_else(|| {
5677                                    // Fallback to text content
5678                                    if let NodeType::Text(text) = styled_node.get_node_type() {
5679                                        Some(text.as_str().to_string())
5680                                    } else {
5681                                        None
5682                                    }
5683                                })
5684                        })
5685                });
5686
5687                // Parse as number, increment/decrement, convert back to string
5688                if let Some(value_str) = current_value {
5689                    let parsed: Result<f64, _> = value_str.trim().parse();
5690
5691                    let new_value_str = parsed.map_or_else(|_| if is_increment {
5692                            "1".to_string()
5693                        } else {
5694                            "-1".to_string()
5695                        }, |num| {
5696                        // Successfully parsed as number
5697                        let new_num = if is_increment { num + 1.0 } else { num - 1.0 };
5698                        // Format with same precision as input if possible
5699                        if num.fract() == 0.0 {
5700                            format!("{}", new_num as i64)
5701                        } else {
5702                            format!("{new_num}")
5703                        }
5704                    });
5705
5706                    // Record as text input (will fire On::TextInput callbacks)
5707                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5708                    let dom_node_id = DomNodeId {
5709                        dom: dom_id,
5710                        node: hierarchy_id,
5711                    };
5712
5713                    // Get old text for changeset
5714                    let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
5715                    let old_text = self.extract_text_from_inline_content(&old_inline_content);
5716
5717                    // Record the text input
5718                    self.text_input_manager.record_input(
5719                        dom_node_id,
5720                        new_value_str,
5721                        old_text,
5722                        TextInputSource::Accessibility,
5723                    );
5724
5725                    // Add TextInput event to affected nodes
5726                    affected_nodes.insert(
5727                        dom_node_id,
5728                        (vec![EventFilter::Focus(FocusEventFilter::TextInput)], false),
5729                    );
5730                }
5731            }
5732
5733            AccessibilityAction::Collapse | AccessibilityAction::Expand => {
5734                // Map to corresponding On:: events
5735                let event_type = match action {
5736                    AccessibilityAction::Collapse => On::Collapse,
5737                    AccessibilityAction::Expand => On::Expand,
5738                    _ => unreachable!(),
5739                };
5740
5741                // Check if node has a callback for this event type
5742                if let Some(layout_result) = self.layout_results.get(&dom_id) {
5743                    if let Some(styled_node) = layout_result
5744                        .styled_dom
5745                        .node_data
5746                        .as_ref()
5747                        .get(node_id.index())
5748                    {
5749                        // Check if any callback matches this event type
5750                        let has_callback = styled_node
5751                            .callbacks
5752                            .as_ref()
5753                            .iter()
5754                            .any(|cb| cb.event == event_type.into());
5755
5756                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5757                        let dom_node_id = DomNodeId {
5758                            dom: dom_id,
5759                            node: hierarchy_id,
5760                        };
5761
5762                        if has_callback {
5763                            // Generate EventFilter for this specific callback
5764                            affected_nodes.insert(dom_node_id, (vec![event_type.into()], false));
5765                        } else {
5766                            // No specific callback - fallback to regular Click
5767                            affected_nodes.insert(
5768                                dom_node_id,
5769                                (vec![EventFilter::Hover(HoverEventFilter::MouseUp)], false),
5770                            );
5771                        }
5772                    }
5773                }
5774            }
5775
5776            // Context menu - check if node has a menu and trigger right-click event
5777            AccessibilityAction::ShowContextMenu => {
5778                // Check if the node has a context menu attached
5779                let Some(layout_result) = self.layout_results.get(&dom_id) else {
5780                    return affected_nodes;
5781                };
5782
5783                // Get the node from the styled DOM
5784                let Some(styled_node) = layout_result
5785                    .styled_dom
5786                    .node_data
5787                    .as_ref()
5788                    .get(node_id.index())
5789                else {
5790                    return affected_nodes;
5791                };
5792
5793                // Check if node has context menu
5794                let has_context_menu = styled_node.get_context_menu().is_some();
5795
5796                if has_context_menu {
5797                    // Return a synthetic right-click so the caller's event dispatcher
5798                    // triggers the normal context-menu code path (platform-specific).
5799                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5800                    let dom_node_id = DomNodeId { dom: dom_id, node: hierarchy_id };
5801                    affected_nodes.insert(
5802                        dom_node_id,
5803                        (vec![EventFilter::Hover(
5804                            HoverEventFilter::RightMouseDown,
5805                        )], false),
5806                    );
5807                }
5808            }
5809
5810            // Text editing actions - use text3/edit.rs
5811            AccessibilityAction::ReplaceSelectedText(ref text) => {
5812                let nodes = self.edit_text_node(
5813                    dom_id,
5814                    node_id,
5815                    &TextEditType::ReplaceSelection(text.as_str().to_string()),
5816                );
5817                for node in nodes {
5818                    affected_nodes.insert(node, (Vec::new(), true)); // true = needs re-layout
5819                }
5820            }
5821            AccessibilityAction::SetValue(ref text) => {
5822                let nodes = self.edit_text_node(
5823                    dom_id,
5824                    node_id,
5825                    &TextEditType::SetValue(text.as_str().to_string()),
5826                );
5827                for node in nodes {
5828                    affected_nodes.insert(node, (Vec::new(), true));
5829                }
5830            }
5831            AccessibilityAction::SetNumericValue(value) => {
5832                let nodes = self.edit_text_node(
5833                    dom_id,
5834                    node_id,
5835                    &TextEditType::SetNumericValue(f64::from(value.get())),
5836                );
5837                for node in nodes {
5838                    affected_nodes.insert(node, (Vec::new(), true));
5839                }
5840            }
5841            AccessibilityAction::SetTextSelection(selection) => {
5842                // Get the text layout for this node from the layout tree
5843                let text_layout = self.get_node_inline_layout(dom_id, node_id);
5844
5845                if let Some(inline_layout) = text_layout {
5846                    // Convert byte offsets to TextCursor positions
5847                    let start_cursor = Self::byte_offset_to_cursor(
5848                        inline_layout.as_ref(),
5849                        selection.selection_start as u32,
5850                    );
5851                    let end_cursor = Self::byte_offset_to_cursor(
5852                        inline_layout.as_ref(),
5853                        selection.selection_end as u32,
5854                    );
5855
5856                    {
5857                        let (start, end) = (start_cursor, end_cursor);
5858                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5859                        let dom_node_id = DomNodeId {
5860                            dom: dom_id,
5861                            node: hierarchy_id,
5862                        };
5863
5864                        // A collapsed selection (start == end) and a ranged one
5865                        // both place the cursor at the selection start.
5866                        let _ = end;
5867                        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
5868                            mc.set_single_cursor(start);
5869                        }
5870                    }
5871                } else {
5872                    // No text layout available for node - silently ignore
5873                }
5874            }
5875
5876            // Tooltip actions
5877            AccessibilityAction::ShowTooltip | AccessibilityAction::HideTooltip => {
5878                // TODO: Integrate with tooltip manager when implemented
5879            }
5880
5881            AccessibilityAction::CustomAction(_id) => {
5882                // TODO: Allow custom action handlers
5883            }
5884        }
5885
5886        affected_nodes
5887    }
5888
5889    /// Process text input from keyboard using cursor/selection/focus managers.
5890    ///
5891    /// This is the new unified text input handling. The framework manages text editing
5892    /// internally using managers, then fires callbacks (`On::TextInput`, `On::Changed`)
5893    /// after the internal state is already updated.
5894    ///
5895    /// ## Workflow
5896    /// 1. Check if focus manager has a focused contenteditable node
5897    /// 2. Get cursor/selection from managers
5898    /// 3. Call `edit_text_node` to apply the edit and update cache
5899    /// 4. Collect affected nodes that need dirty marking
5900    /// 5. Return map for re-layout triggering
5901    ///
5902    /// ## Parameters
5903    /// * `text_input` - The text that was typed (can be multiple chars for IME)
5904    ///
5905    /// ## Returns
5906    /// `BTreeMap` of affected nodes with:
5907    /// - Key: `DomNodeId` that was affected
5908    /// - Value: (Vec<EventFilter> synthetic events, bool `needs_relayout`)
5909    /// - Empty map = no focused contenteditable node
5910    pub fn record_text_input(
5911        &mut self,
5912        text_input: &str,
5913    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
5914        use std::collections::BTreeMap;
5915
5916        use crate::managers::text_input::TextInputSource;
5917
5918        let mut affected_nodes = BTreeMap::new();
5919
5920        if text_input.is_empty() {
5921            return affected_nodes;
5922        }
5923
5924        // Get focused node
5925        let Some(focused_node) = self.focus_manager.get_focused_node().copied() else {
5926            return affected_nodes;
5927        };
5928
5929        let Some(node_id) = focused_node.node.into_crate_internal() else {
5930            return affected_nodes;
5931        };
5932
5933        // Get the OLD text before any changes
5934        let old_inline_content = self.get_text_before_textinput(focused_node.dom, node_id);
5935        let old_text = self.extract_text_from_inline_content(&old_inline_content);
5936
5937        // Record the changeset in TextInputManager (but DON'T apply changes yet)
5938        self.text_input_manager.record_input(
5939            focused_node,
5940            text_input.to_string(),
5941            old_text,
5942            TextInputSource::Keyboard, // Assuming keyboard for now
5943        );
5944
5945        // Return affected nodes with TextInput event so callbacks can be invoked
5946        let text_input_event = vec![EventFilter::Focus(FocusEventFilter::TextInput)];
5947
5948        affected_nodes.insert(focused_node, (text_input_event, false)); // false = no re-layout yet
5949
5950        affected_nodes
5951    }
5952
5953    /// Apply the recorded text changeset to the text cache
5954    ///
5955    /// This is called AFTER user callbacks, if preventDefault was not set.
5956    /// This is where we actually compute the new text and update the cache.
5957    ///
5958    /// Also updates the cursor position to reflect the edit.
5959    ///
5960    /// Returns the nodes that need to be marked dirty for re-layout,
5961    /// and whether a full re-layout is needed (text size changed).
5962    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5963    pub fn apply_text_changeset(&mut self) -> TextChangesetResult {
5964        use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
5965        use crate::text3::edit::{edit_text, TextEdit};
5966        static CHANGESET_COUNTER: AtomicUsize = AtomicUsize::new(0);
5967
5968        // Get the changeset from TextInputManager
5969        let empty = TextChangesetResult { dirty_nodes: Vec::new(), needs_relayout: false };
5970
5971        let changeset = match self.text_input_manager.get_pending_changeset() {
5972            Some(cs) => {
5973                cs.clone()
5974            }
5975            None => {
5976                return empty;
5977            }
5978        };
5979
5980        let Some(node_id) = changeset.node.node.into_crate_internal() else {
5981            self.text_input_manager.clear_changeset();
5982            return empty;
5983        };
5984
5985        let dom_id = changeset.node.dom;
5986
5987        // Check if node is contenteditable
5988        let Some(layout_result) = self.layout_results.get(&dom_id) else {
5989            self.text_input_manager.clear_changeset();
5990            return empty;
5991        };
5992
5993        let Some(styled_node) = layout_result
5994            .styled_dom
5995            .node_data
5996            .as_ref()
5997            .get(node_id.index()) else {
5998            self.text_input_manager.clear_changeset();
5999            return empty;
6000        };
6001
6002        // Check BOTH: the contenteditable boolean field AND the attribute
6003        // NodeData has a direct `contenteditable: bool` field that should be
6004        // checked in addition to the attribute for robustness
6005        let is_contenteditable = styled_node.is_contenteditable()
6006            || styled_node.attributes().as_ref().iter().any(|attr| {
6007                matches!(attr, AttributeType::ContentEditable(_))
6008            });
6009
6010        if !is_contenteditable {
6011            self.text_input_manager.clear_changeset();
6012            return empty;
6013        }
6014
6015        // Get the current inline content from cache
6016        let content = self.get_text_before_textinput(dom_id, node_id);
6017
6018        // Get current cursor/selection — prefer non-empty MultiCursorState, fall back to legacy
6019        let mc_selections = self.text_edit_manager.multi_cursor.as_ref()
6020            .map(azul_core::selection::MultiCursorState::to_selections)
6021            .unwrap_or_default();
6022        let current_selection = if !mc_selections.is_empty() {
6023            mc_selections
6024        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
6025            vec![Selection::Cursor(cursor)]
6026        } else {
6027            vec![Selection::Cursor(TextCursor {
6028                cluster_id: GraphemeClusterId {
6029                    source_run: 0,
6030                    start_byte_in_run: 0,
6031                },
6032                affinity: CursorAffinity::Leading,
6033            })]
6034        };
6035
6036        // Capture pre-state for undo/redo BEFORE mutation
6037        let old_text = self.extract_text_from_inline_content(&content);
6038        let old_cursor = current_selection.first().and_then(|sel| {
6039            if let Selection::Cursor(c) = sel {
6040                Some(*c)
6041            } else {
6042                None
6043            }
6044        });
6045        let old_selection_range = current_selection.first().and_then(|sel| {
6046            if let Selection::Range(r) = sel {
6047                Some(*r)
6048            } else {
6049                None
6050            }
6051        });
6052
6053        let pre_state = crate::managers::undo_redo::NodeStateSnapshot {
6054            node_id: NodeId::new(node_id.index()),
6055            text_content: old_text.into(),
6056            cursor_position: old_cursor.into(),
6057            selection_range: old_selection_range.into(),
6058            #[cfg(feature = "std")]
6059            timestamp: Instant::now(),
6060            #[cfg(not(feature = "std"))]
6061            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
6062        };
6063
6064        // Apply the edit using text3::edit - this is a pure function
6065        let text_edit = TextEdit::Insert(changeset.inserted_text.as_str().to_string());
6066        let (new_content, new_selections) = edit_text(&content, &current_selection, &text_edit);
6067
6068        // Update cursors from edit result
6069        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
6070            mc.update_from_edit_result(&new_selections);
6071        }
6072        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
6073
6074        // MWA-C-undo_redo: styled pre/post snapshots so undo/redo restore
6075        // the REAL styled content instead of rebuilding with
6076        // StyleProperties::default() (which stripped all styling).
6077        let pre_content_snapshot = content;
6078        let post_content_snapshot = new_content.clone();
6079
6080        // Update the text cache with the new inline content
6081        self.update_text_cache_after_edit(dom_id, node_id, new_content);
6082
6083        // Record this operation to the undo/redo manager AFTER successful mutation
6084
6085        // Get the new cursor position after edit using the layout's cursor rect
6086        let new_cursor = self
6087            .get_focused_cursor_rect()
6088            .map_or(CursorPosition::Uninitialized, |r| CursorPosition::InWindow(r.origin));
6089
6090        let old_cursor_pos = old_cursor
6091            .as_ref()
6092            .map_or(CursorPosition::Uninitialized, |_| {
6093                // The old cursor position was before the edit — the layout may
6094                // have already updated so we use the same rect as new_cursor.
6095                // This is acceptable for undo: the exact pre-edit position is
6096                // approximated; what matters is restoring focus to the node.
6097                self.get_focused_cursor_rect()
6098                    .map_or(CursorPosition::Uninitialized, |r| CursorPosition::InWindow(r.origin))
6099            });
6100
6101        // Generate a unique changeset ID
6102        let changeset_id = CHANGESET_COUNTER.fetch_add(1, Ordering::SeqCst);
6103
6104        let undo_changeset = TextChangeset {
6105            id: changeset_id,
6106            target: changeset.node,
6107            operation: TextOperation::InsertText(TextOpInsertText {
6108                text: changeset.inserted_text,
6109                position: old_cursor_pos,
6110                new_cursor,
6111            }),
6112            #[cfg(feature = "std")]
6113            timestamp: Instant::now(),
6114            #[cfg(not(feature = "std"))]
6115            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
6116        };
6117        self.undo_redo_manager
6118            .store_content_snapshot(changeset_id, pre_content_snapshot, post_content_snapshot);
6119        self.undo_redo_manager
6120            .record_operation(undo_changeset, pre_state);
6121
6122        // Clear the changeset now that it's been applied
6123        self.text_input_manager.clear_changeset();
6124
6125        // MWA-C-text_edit: typing resets the blink phase so the caret is
6126        // solid while the user types (W3C/native behavior) — previously the
6127        // caret kept blinking mid-keystroke because reset ran only on
6128        // click/focus/user-API.
6129        let now = Instant::now();
6130        self.text_edit_manager.blink.reset_blink_on_input(now);
6131
6132        // Check if any dirty text node needs ancestor relayout (text size changed)
6133        let needs_relayout = self.dirty_text_nodes.values()
6134            .any(|d| d.needs_ancestor_relayout);
6135
6136        // Return nodes that need dirty marking
6137        let dirty_nodes = self.determine_dirty_text_nodes(dom_id, node_id);
6138        TextChangesetResult { dirty_nodes, needs_relayout }
6139    }
6140
6141    /// Determine which nodes need to be marked dirty after a text edit
6142    ///
6143    /// Returns the edited node + its parent (if it exists)
6144    fn determine_dirty_text_nodes(
6145        &self,
6146        dom_id: DomId,
6147        node_id: NodeId,
6148    ) -> Vec<DomNodeId> {
6149        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6150            return Vec::new();
6151        };
6152
6153        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
6154        let node_dom_id = DomNodeId {
6155            dom: dom_id,
6156            node: hierarchy_id,
6157        };
6158
6159        // Get parent node ID
6160        let parent_id = layout_result
6161            .styled_dom
6162            .node_hierarchy
6163            .as_container()
6164            .get(node_id)
6165            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
6166            .map(|parent_node_id| {
6167                let parent_hierarchy_id =
6168                    NodeHierarchyItemId::from_crate_internal(Some(parent_node_id));
6169                DomNodeId {
6170                    dom: dom_id,
6171                    node: parent_hierarchy_id,
6172                }
6173            });
6174
6175        // Return node + parent (if exists)
6176        parent_id.map_or_else(|| vec![node_dom_id], |parent| vec![node_dom_id, parent])
6177    }
6178
6179    /// Legacy name for backward compatibility
6180    #[inline]
6181    pub fn process_text_input(
6182        &mut self,
6183        text_input: &str,
6184    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
6185        self.record_text_input(text_input)
6186    }
6187
6188    /// Get the last text changeset (what was changed in the last text input)
6189    pub const fn get_last_text_changeset(&self) -> Option<&PendingTextEdit> {
6190        self.text_input_manager.get_pending_changeset()
6191    }
6192
6193    /// Get the current inline content (text before text input is applied)
6194    ///
6195    /// This is a query function that retrieves the current text state from the node.
6196    /// Returns `InlineContent` vector if the node has text.
6197    ///
6198    /// # Implementation Note
6199    /// This function FIRST checks `dirty_text_nodes` for optimistic state (edits not yet
6200    /// committed to `StyledDom`), then falls back to the `StyledDom`. This is critical for
6201    /// correct text input handling - without this, each keystroke would read stale state.
6202    pub fn get_text_before_textinput(&self, dom_id: DomId, node_id: NodeId) -> Vec<InlineContent> {
6203        // CRITICAL FIX: Check dirty_text_nodes first!
6204        // If the node has been edited since last full layout, its most up-to-date
6205        // content is in dirty_text_nodes, NOT in the StyledDom.
6206        // Without this check, every keystroke reads the ORIGINAL text instead of
6207        // the accumulated edits, causing bugs like double-input and wrong node affected.
6208        if let Some(dirty_node) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
6209            return dirty_node.content.clone();
6210        }
6211
6212        // Fallback to committed state from StyledDom
6213        // Get the layout result for this DOM
6214        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6215            return Vec::new();
6216        };
6217
6218        // Get the node data
6219        let Some(node_data) = layout_result
6220            .styled_dom
6221            .node_data
6222            .as_ref()
6223            .get(node_id.index())
6224        else {
6225            return Vec::new();
6226        };
6227
6228        // Extract text content from the node
6229        match node_data.get_node_type() {
6230            NodeType::Text(text) => {
6231                // Simple text node - create a single StyledRun
6232                let style = self.get_text_style_for_node(dom_id, node_id);
6233
6234                vec![InlineContent::Text(StyledRun {
6235                    text: text.as_str().to_string(),
6236                    style,
6237                    logical_start_byte: 0,
6238                    source_node_id: Some(node_id),
6239                })]
6240            }
6241            NodeType::Div | NodeType::Body | NodeType::VirtualView => {
6242                // Container nodes - recursively collect text from children
6243                self.collect_text_from_children(dom_id, node_id)
6244            }
6245            _ => {
6246                // Other node types (Image, etc.) don't contribute text
6247                Vec::new()
6248            }
6249        }
6250    }
6251
6252    /// Get the font style for a text node from CSS
6253    fn get_text_style_for_node(
6254        &self,
6255        dom_id: DomId,
6256        node_id: NodeId,
6257    ) -> Arc<StyleProperties> {
6258        use alloc::sync::Arc;
6259
6260        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6261            return Arc::new(StyleProperties::default());
6262        };
6263
6264        // Use the proper CSS property resolution from solver3::getters
6265        let vp = layout_result.viewport.size;
6266        let props = solver3::getters::get_style_properties(
6267            &layout_result.styled_dom,
6268            node_id,
6269            self.system_style.as_ref(),
6270            azul_css::props::basic::PhysicalSize::new(vp.width, vp.height),
6271        );
6272
6273        Arc::new(props)
6274    }
6275
6276    /// Recursively collect text content from child nodes
6277    fn collect_text_from_children(
6278        &self,
6279        dom_id: DomId,
6280        parent_node_id: NodeId,
6281    ) -> Vec<InlineContent> {
6282        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6283            return Vec::new();
6284        };
6285
6286        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
6287        let Some(parent_item) = node_hierarchy.get(parent_node_id.index()) else {
6288            return Vec::new();
6289        };
6290
6291        let mut result = Vec::new();
6292
6293        // Traverse all children
6294        let mut current_child = parent_item.first_child_id(parent_node_id);
6295        while let Some(child_id) = current_child {
6296            // Get content from this child (recursive)
6297            let child_content = self.get_text_before_textinput(dom_id, child_id);
6298            result.extend(child_content);
6299
6300            // Move to next sibling
6301            let Some(child_item) = node_hierarchy.get(child_id.index()) else {
6302                break;
6303            };
6304            current_child = child_item.next_sibling_id();
6305        }
6306
6307        result
6308    }
6309
6310    /// Extract plain text string from inline content
6311    ///
6312    /// This is a helper for building the changeset's `resulting_text` field.
6313    // `&self` is only reached via the recursive Ruby arm; it is kept because this is a public
6314    // method called as `lw.extract_text_from_inline_content(..)` across dll and layout, and
6315    // converting to an associated fn would break that API at every call site.
6316    #[allow(clippy::only_used_in_recursion)]
6317    pub fn extract_text_from_inline_content(&self, content: &[InlineContent]) -> String {
6318        let mut result = String::new();
6319
6320        for item in content {
6321            match item {
6322                InlineContent::Text(text_run) => {
6323                    result.push_str(&text_run.text);
6324                }
6325                InlineContent::Space(_) => {
6326                    result.push(' ');
6327                }
6328                InlineContent::LineBreak(_) => {
6329                    result.push('\n');
6330                }
6331                InlineContent::Tab { .. } => {
6332                    result.push('\t');
6333                }
6334                InlineContent::Ruby { base, .. } => {
6335                    // For Ruby annotations, include the base text
6336                    result.push_str(&self.extract_text_from_inline_content(base));
6337                }
6338                InlineContent::Marker { run, .. } => {
6339                    // Markers contribute their text
6340                    result.push_str(&run.text);
6341                }
6342                // Images and shapes don't contribute to plain text
6343                InlineContent::Image(_) | InlineContent::Shape(_) => {}
6344            }
6345        }
6346
6347        result
6348    }
6349
6350    /// Update the text cache after a text edit
6351    ///
6352    /// This is the ONLY place where we mutate the text cache.
6353    /// All other functions are pure queries or transformations.
6354    ///
6355    /// This function:
6356    /// 1. Stores the new content in `dirty_text_nodes` for tracking
6357    /// 2. Re-runs the text3 layout pipeline (`create_logical_items` -> reorder -> shape -> fragment)
6358    /// 3. Updates the `inline_layout_result` on the IFC root node in the layout tree
6359    // called by the dll text-edit backends (event.rs/macos) with freshly-built content;
6360    // it is both cloned into the dirty-node cache and re-read for relayout, so it is taken
6361    // owned at this boundary rather than rippling a &[InlineContent] across the backends.
6362    #[allow(clippy::needless_pass_by_value)]
6363    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6364    pub fn update_text_cache_after_edit(
6365        &mut self,
6366        dom_id: DomId,
6367        node_id: NodeId,
6368        new_inline_content: Vec<InlineContent>,
6369    ) {
6370        use crate::solver3::layout_tree::CachedInlineLayout;
6371
6372        // 1. Store the new content in dirty_text_nodes for tracking
6373        let cursor = self.text_edit_manager.get_primary_cursor();
6374        self.dirty_text_nodes.insert(
6375            (dom_id, node_id),
6376            DirtyTextNode {
6377                content: new_inline_content.clone(),
6378                cursor,
6379                needs_ancestor_relayout: false, // Will be set if size changes
6380            },
6381        );
6382
6383        // 2. Get the cached constraints from the existing inline layout result.
6384        // We need to find the IFC root node. The layout tree uses its own indices
6385        // (different from DOM node IDs), so we must go through dom_to_layout.
6386        // The IFC may be on this node OR a child — search all mapped layout nodes
6387        // and their children for one with inline_layout_result.
6388        let (mut constraints, ifc_layout_index) = {
6389            let Some(layout_result) = self.layout_results.get(&dom_id) else {
6390                return;
6391            };
6392
6393            // Find the layout node with inline_layout_result via dom_to_layout
6394            let mut found: Option<(usize, &CachedInlineLayout)> = None;
6395
6396            // First check layout nodes mapped to this DOM node
6397            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
6398                for &idx in layout_indices {
6399                    if let Some(w) = layout_result.layout_tree.warm(idx) {
6400                        if let Some(ref cached) = w.inline_layout_result {
6401                            found = Some((idx, cached));
6402                            break;
6403                        }
6404                    }
6405                }
6406            }
6407
6408            // If not found on this node, check child DOM nodes (text children of contenteditable)
6409            if found.is_none() {
6410                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
6411                if let Some(parent_item) = node_hierarchy.get(node_id.index()) {
6412                    let mut child = parent_item.first_child_id(node_id);
6413                    while let Some(child_id) = child {
6414                        if let Some(child_indices) = layout_result.layout_tree.dom_to_layout.get(&child_id) {
6415                            for &idx in child_indices {
6416                                if let Some(w) = layout_result.layout_tree.warm(idx) {
6417                                    if let Some(ref cached) = w.inline_layout_result {
6418                                        found = Some((idx, cached));
6419                                        break;
6420                                    }
6421                                }
6422                            }
6423                        }
6424                        if found.is_some() { break; }
6425                        child = node_hierarchy.get(child_id.index()).and_then(azul_core::styled_dom::NodeHierarchyItem::next_sibling_id);
6426                    }
6427                }
6428            }
6429
6430            let Some((ifc_idx, cached_layout)) = found else {
6431                return;
6432            };
6433
6434            match &cached_layout.constraints {
6435                Some(c) => (c.clone(), ifc_idx),
6436                None => {
6437                    return;
6438                }
6439            }
6440        };
6441
6442        // 2b. Refresh available_width from the containing block's used_size.
6443        //
6444        // The IFC root's `.parent` in the layout tree may point to a grandparent
6445        // (e.g. body) rather than the actual CSS containing block (the contenteditable
6446        // div) — layout tree parentage doesn't always match DOM parentage.
6447        //
6448        // Use `node_id` (the contenteditable DOM element) via dom_to_layout to find
6449        // the correct containing block. Its content-box width is what constrains text.
6450        if let Some(layout_result) = self.layout_results.get(&dom_id) {
6451            let mut found_width = false;
6452
6453            // Look up the contenteditable div's layout node directly via DOM mapping
6454            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
6455                for &idx in layout_indices {
6456                    if let Some(container_node) = layout_result.layout_tree.get(idx) {
6457                        if let Some(container_size) = container_node.used_size {
6458                            let bp = container_node.box_props.unpack();
6459                            let content_width = container_size.width
6460                                - bp.padding.left - bp.padding.right
6461                                - bp.border.left - bp.border.right;
6462                            if content_width > 0.0 {
6463                                constraints.available_width =
6464                                    crate::text3::cache::AvailableSpace::Definite(content_width);
6465                                found_width = true;
6466                            }
6467                            break;
6468                        }
6469                    }
6470                }
6471            }
6472
6473            // Fallback: walk up the IFC's ancestors in the layout tree
6474            if !found_width {
6475                if let Some(parent_idx) = layout_result.layout_tree.get(ifc_layout_index)
6476                    .and_then(|n| n.parent)
6477                {
6478                    if let Some(parent_node) = layout_result.layout_tree.get(parent_idx) {
6479                        if let Some(parent_size) = parent_node.used_size {
6480                            let bp = parent_node.box_props.unpack();
6481                            let content_width = parent_size.width
6482                                - bp.padding.left - bp.padding.right
6483                                - bp.border.left - bp.border.right;
6484                            if content_width > 0.0 {
6485                                constraints.available_width =
6486                                    crate::text3::cache::AvailableSpace::Definite(content_width);
6487                            }
6488                        }
6489                    }
6490                }
6491            }
6492        }
6493
6494        // 3. Re-run the text3 layout pipeline.
6495        //
6496        // Try the incremental path first: it runs stages 1-3 (logical items,
6497        // bidi, shape) on the new content and, if the cached layout is
6498        // still reusable (same item count, no overflow, line breaks cached),
6499        // skips stage 4 (line-breaking + positioning). For edits whose new
6500        // advances fall into GlyphSwap/LineShift territory, this turns a
6501        // full IFC relayout into a glyph + x-position patch.
6502        let cached_snapshot = self
6503            .layout_results
6504            .get(&dom_id)
6505            .and_then(|lr| lr.layout_tree.warm(ifc_layout_index))
6506            .and_then(|w| w.inline_layout_result.as_ref())
6507            .cloned();
6508
6509        let new_layout = cached_snapshot.map_or_else(|| self.relayout_text_node_internal(&new_inline_content, &constraints), |cached| self.try_incremental_text_relayout(
6510                &new_inline_content,
6511                &constraints,
6512                &cached,
6513                node_id,
6514            )
6515            .map(|(layout, _skipped_fragment)| layout));
6516
6517        let Some(new_layout) = new_layout else {
6518            return;
6519        };
6520
6521        // 4. Update the layout cache with the new layout
6522        // Use the ifc_layout_index we found earlier (correct layout tree index)
6523        if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
6524            let old_size = layout_result.layout_tree.get(ifc_layout_index).and_then(|n| n.used_size);
6525            let new_bounds = new_layout.bounds();
6526            let new_size = Some(LogicalSize {
6527                width: new_bounds.width,
6528                height: new_bounds.height,
6529            });
6530
6531            // Check if we need to propagate layout shift
6532            if let (Some(old), Some(new)) = (old_size, new_size) {
6533                if (old.height - new.height).abs() > 0.5 || (old.width - new.width).abs() > 0.5 {
6534                    // Mark that ancestor relayout is needed
6535                    if let Some(dirty_node) = self.dirty_text_nodes.get_mut(&(dom_id, node_id)) {
6536                        dirty_node.needs_ancestor_relayout = true;
6537                    }
6538                }
6539            }
6540
6541            // Update the inline layout result with the new layout but preserve constraints (warm data)
6542            if let Some(warm_node) = layout_result.layout_tree.warm_mut(ifc_layout_index) {
6543                warm_node.inline_layout_result = Some(CachedInlineLayout::new_with_constraints(
6544                    Arc::new(new_layout),
6545                    constraints.available_width,
6546                    false, // No floats in quick relayout
6547                    constraints,
6548                ));
6549            }
6550        }
6551
6552        // CRITICAL: Regenerate the display list after updating the inline layout.
6553        // Without this, the old display list (with old text glyphs) is sent to WebRender,
6554        // so the screen still shows the old text even though the layout tree is updated.
6555        self.regenerate_display_list_for_dom(dom_id);
6556    }
6557
6558    /// Re-apply a dirty text node's content to the layout cache after a full DOM rebuild.
6559    ///
6560    /// Called by `regenerate_layout()` after `layout_and_generate_display_list()`.
6561    /// The layout just ran on the stale DOM text, so we re-shape the edited text
6562    /// from `dirty_text_nodes` and update the inline layout result + display list.
6563    /// Inject preedit text into the text cache and regenerate the display list.
6564    ///
6565    /// Called from the platform IME handler (setMarkedText). Gets the current
6566    /// text content, splices the preedit string at the cursor position, then
6567    /// re-shapes and regenerates the display list so the preedit glyphs appear
6568    /// inline with an underline.
6569    /// # Panics
6570    ///
6571    /// Panics if there is no saved pre-preedit content to restore.
6572    pub fn apply_preedit_to_text_cache(&mut self, dom_id: DomId, node_id: NodeId) {
6573        let preedit = match &self.text_edit_manager.preedit_text {
6574            Some(p) if !p.is_empty() => p.clone(),
6575            _ => {
6576                // No preedit — restore original text and clear snapshot
6577                self.pre_preedit_content = None;
6578                self.reapply_dirty_text_node(dom_id, node_id);
6579                return;
6580            }
6581        };
6582
6583        let Some(cursor) = self.text_edit_manager.get_primary_cursor() else {
6584            return;
6585        };
6586
6587        // Save the original content on the FIRST preedit call so we always
6588        // inject into clean text (prevents accumulation of old preedits).
6589        if self.pre_preedit_content.is_none() {
6590            let original = self.get_text_before_textinput(dom_id, node_id);
6591            self.pre_preedit_content = Some(original);
6592        }
6593
6594        // Clone the saved original — never modify it in place
6595        let mut content = self.pre_preedit_content.clone().unwrap();
6596
6597        // Insert preedit at cursor position
6598        let run_idx = cursor.cluster_id.source_run as usize;
6599        let byte_pos = cursor.cluster_id.start_byte_in_run as usize;
6600        if let Some(InlineContent::Text(run)) = content.get_mut(run_idx) {
6601            let clamped_pos = byte_pos.min(run.text.len());
6602            run.text.insert_str(clamped_pos, &preedit);
6603        }
6604
6605        // Re-shape text with preedit injected — font fallback handles CJK
6606        self.update_text_cache_after_edit(dom_id, node_id, content);
6607        self.regenerate_display_list_for_dom(dom_id);
6608    }
6609
6610    pub fn reapply_dirty_text_node(&mut self, dom_id: DomId, node_id: NodeId) {
6611        let content = match self.dirty_text_nodes.get(&(dom_id, node_id)) {
6612            Some(dirty) => dirty.content.clone(),
6613            None => return,
6614        };
6615        // Re-run text shaping and update layout cache
6616        self.update_text_cache_after_edit(dom_id, node_id, content);
6617        // Regenerate display list with updated text
6618        self.regenerate_display_list_for_dom(dom_id);
6619    }
6620
6621    /// Regenerate the display list for a specific DOM from the current layout tree.
6622    ///
6623    /// This is the critical missing piece for text input: after `update_text_cache_after_edit`
6624    /// updates the `inline_layout_result` on layout tree nodes, the `DomLayoutResult.display_list`
6625    /// must be regenerated. Otherwise, `generate_frame()` sends the OLD display list to `WebRender`
6626    /// and the screen shows stale text.
6627    ///
6628    /// This method creates a temporary `LayoutContext` from the existing `LayoutWindow` state
6629    /// and calls `generate_display_list` on the already-computed layout tree and positions.
6630    pub fn regenerate_display_list_for_dom(&mut self, dom_id: DomId) {
6631        use crate::solver3::{
6632            display_list::generate_display_list,
6633            LayoutContext,
6634        };
6635
6636        // Get all the data we need from the layout result
6637        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6638            return;
6639        };
6640
6641        let tree = &layout_result.layout_tree;
6642        let calculated_positions = &layout_result.calculated_positions;
6643        let scroll_ids = &layout_result.scroll_ids;
6644        let styled_dom = &layout_result.styled_dom;
6645        let viewport = layout_result.viewport;
6646
6647        // Get scroll offsets from scroll manager
6648        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
6649
6650        // Get GPU cache for this DOM
6651        let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id).clone();
6652
6653        // Get cursor state for display list generation
6654        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
6655        let cursor_locations = self.text_edit_manager.build_cursor_locations();
6656        let text_selections_map = self.text_edit_manager.build_text_selections_map();
6657
6658        // Build a temporary LayoutContext with all the state we need
6659        let mut counter_values = HashMap::new();
6660        let mut debug_messages: Option<Vec<LayoutDebugMessage>> = None;
6661        let cache_map = std::mem::take(&mut self.layout_cache.cache_map);
6662
6663        let mut ctx = LayoutContext {
6664            scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
6665            styled_dom,
6666            font_manager: &self.font_manager,
6667            text_selections: &text_selections_map,
6668            debug_messages: &mut debug_messages,
6669            counters: &mut counter_values,
6670            viewport_size: viewport.size,
6671            fragmentation_context: None,
6672            cursor_is_visible,
6673            cursor_locations,
6674            preedit_text: self.text_edit_manager.preedit_text.clone(),
6675            cache_map,
6676            image_cache: &self.image_cache,
6677            system_style: self.system_style.clone(),
6678            get_system_time_fn: azul_core::task::GetSystemTimeCallback {
6679                cb: azul_core::task::get_system_time_libstd,
6680            },
6681            dirty_text_overrides: BTreeMap::new(),
6682        };
6683
6684        // Generate the new display list from the existing layout tree
6685        let new_display_list = generate_display_list(
6686            &mut ctx,
6687            tree,
6688            calculated_positions,
6689            &scroll_offsets,
6690            scroll_ids,
6691            Some(&gpu_cache),
6692            &self.renderer_resources,
6693            self.id_namespace,
6694            dom_id,
6695        );
6696
6697        // Restore the cache_map back to layout_cache
6698        self.layout_cache.cache_map = std::mem::take(&mut ctx.cache_map);
6699
6700        match new_display_list {
6701            Ok(display_list) => {
6702                if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
6703                    layout_result.display_list = display_list;
6704                }
6705                // The repaint `TextEditManager::mark_dirty` asked for has now
6706                // been delivered — this is the display-list-only path that
6707                // exists FOR caret / selection / preedit updates. Leaving the
6708                // flag latched makes every later "is this window idle?" check
6709                // answer "no, it owes a repaint" forever. See the twin clear at
6710                // the end of `layout_and_generate_display_list`.
6711                self.text_edit_manager.display_list_dirty = false;
6712                // Incremental a11y update: only push the edited node's
6713                // updated value + cursor, not the entire tree.
6714                #[cfg(feature = "a11y")]
6715                self.update_a11y_tree_incremental();
6716            }
6717            Err(_e) => {
6718            }
6719        }
6720    }
6721
6722    /// Internal helper to re-run the text3 layout pipeline on new content
6723    fn relayout_text_node_internal(
6724        &self,
6725        content: &[InlineContent],
6726        constraints: &UnifiedConstraints,
6727    ) -> Option<UnifiedLayout> {
6728        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
6729
6730        if logical_items.is_empty() {
6731            return Some(UnifiedLayout {
6732                items: Vec::new(),
6733                overflow: crate::text3::cache::OverflowInfo::default(),
6734            });
6735        }
6736
6737        self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)
6738    }
6739
6740    /// Stages 1-3 of the text3 pipeline (logical items, bidi reorder, shape).
6741    /// Returned separately so an incremental relayout path can skip stage 4
6742    /// (line breaking + positioning) when the cached layout is reusable.
6743    fn shape_text_for_relayout(
6744        &self,
6745        content: &[InlineContent],
6746        constraints: &UnifiedConstraints,
6747    ) -> Option<(
6748        Vec<crate::text3::cache::LogicalItem>,
6749        Vec<ShapedItem>,
6750    )> {
6751        use crate::text3::cache::{
6752            create_logical_items, reorder_logical_items, shape_visual_items, BidiDirection,
6753        };
6754
6755        let logical_items = create_logical_items(content, &[], &mut None);
6756        if logical_items.is_empty() {
6757            return Some((logical_items, Vec::new()));
6758        }
6759
6760        let base_direction = constraints.direction.unwrap_or(BidiDirection::Ltr);
6761        let visual_items = reorder_logical_items(
6762            &logical_items,
6763            base_direction,
6764            crate::text3::cache::UnicodeBidi::Normal,
6765            &mut None,
6766        )
6767        .ok()?;
6768
6769        let loaded_fonts = self.font_manager.get_loaded_fonts();
6770        let shaped_items = shape_visual_items(
6771            &visual_items,
6772            self.font_manager.get_font_chain_cache(),
6773            &self.font_manager.fc_cache,
6774            &loaded_fonts,
6775            &mut None,
6776        )
6777        .ok()?;
6778
6779        Some((logical_items, shaped_items))
6780    }
6781
6782    /// Stage 4 of the text3 pipeline: line breaking + positioning.
6783    fn fragment_layout_from_shaped(
6784        &self,
6785        logical_items: &[crate::text3::cache::LogicalItem],
6786        shaped_items: &[ShapedItem],
6787        constraints: &UnifiedConstraints,
6788    ) -> Option<UnifiedLayout> {
6789        use crate::text3::cache::{perform_fragment_layout, BreakCursor};
6790
6791        let loaded_fonts = self.font_manager.get_loaded_fonts();
6792        let mut cursor = BreakCursor::new(shaped_items);
6793        perform_fragment_layout(&mut cursor, logical_items, constraints, &mut None, &loaded_fonts).ok()
6794    }
6795
6796    /// Attempt an incremental IFC relayout for a text edit.
6797    ///
6798    /// Runs stages 1-3 (logical items, bidi, shape) on the new content, then
6799    /// checks whether the cached `UnifiedLayout` can be patched without
6800    /// re-running line-breaking (stage 4).
6801    ///
6802    /// Returns `Some((new_layout, skipped_fragment_layout))`:
6803    ///   - `skipped_fragment_layout == true` means we took the incremental
6804    ///     fast path and returned a patched cached layout.
6805    ///   - `skipped_fragment_layout == false` means we fell back to full
6806    ///     `fragment_layout` (stage 4) but reused shape output from stages 1-3.
6807    ///
6808    /// Returns `None` only if `logical_items` + reorder + shape itself fails.
6809    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6810    fn try_incremental_text_relayout(
6811        &self,
6812        content: &[InlineContent],
6813        constraints: &UnifiedConstraints,
6814        cached: &solver3::layout_tree::CachedInlineLayout,
6815        edited_node_id: NodeId,
6816    ) -> Option<(UnifiedLayout, bool)> {
6817        use crate::text3::cache::{
6818            try_incremental_relayout as decide_incremental,
6819            IncrementalRelayoutResult, PositionedItem, ShapedItem,
6820        };
6821
6822        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
6823
6824        if logical_items.is_empty() {
6825            return Some((
6826                UnifiedLayout {
6827                    items: Vec::new(),
6828                    overflow: crate::text3::cache::OverflowInfo::default(),
6829                },
6830                true,
6831            ));
6832        }
6833
6834        // Incremental patching requires:
6835        //   - The cached layout came with line-break metadata.
6836        //   - No overflow in the cached layout (patching positions around
6837        //     overflow is not supported).
6838        //   - The new shape output has the same number of items as the
6839        //     cached positioned items, so we can zip 1:1.
6840        let incremental_ok = cached.line_breaks.is_some()
6841            && cached.layout.overflow.overflow_items.is_empty()
6842            && shaped_items.len() == cached.layout.items.len();
6843
6844        if incremental_ok {
6845            let line_breaks = cached.line_breaks.as_ref().unwrap();
6846
6847            let old_advances: Vec<f32> =
6848                cached.item_metrics.iter().map(|m| m.advance_width).collect();
6849            let new_advances: Vec<f32> =
6850                shaped_items.iter().map(|si| si.bounds().width).collect();
6851
6852            // An item is dirty if its advance width changed OR it originates
6853            // from the edited DOM node. The latter is needed so GlyphSwap
6854            // (same-width edits) still invalidates glyph data, not just
6855            // positions.
6856            let mut dirty_indices: Vec<usize> = Vec::new();
6857            for (i, (old_a, new_a)) in old_advances.iter().zip(new_advances.iter()).enumerate() {
6858                if (new_a - old_a).abs() > 0.01 {
6859                    dirty_indices.push(i);
6860                }
6861            }
6862            for (i, si) in shaped_items.iter().enumerate() {
6863                if let ShapedItem::Cluster(c) = si {
6864                    if c.source_node_id == Some(edited_node_id)
6865                        && !dirty_indices.contains(&i)
6866                    {
6867                        dirty_indices.push(i);
6868                    }
6869                }
6870            }
6871            dirty_indices.sort_unstable();
6872            dirty_indices.dedup();
6873
6874            let decision =
6875                decide_incremental(&dirty_indices, &old_advances, &new_advances, line_breaks);
6876
6877            match decision {
6878                IncrementalRelayoutResult::GlyphSwap => {
6879                    // Widths unchanged — keep cached positions and line
6880                    // assignments, swap in the new shaped items so their
6881                    // glyph data reflects the edit.
6882                    let items: Vec<PositionedItem> = cached
6883                        .layout
6884                        .items
6885                        .iter()
6886                        .zip(shaped_items)
6887                        .map(|(old_positioned, new_shaped)| PositionedItem {
6888                            item: new_shaped,
6889                            position: old_positioned.position,
6890                            line_index: old_positioned.line_index,
6891                        })
6892                        .collect();
6893                    return Some((
6894                        UnifiedLayout {
6895                            items,
6896                            overflow: cached.layout.overflow.clone(),
6897                        },
6898                        true,
6899                    ));
6900                }
6901                IncrementalRelayoutResult::LineShift {
6902                    affected_item,
6903                    delta,
6904                } => {
6905                    // Width changed but the line still fits — shift x
6906                    // positions of items after `affected_item` on the same
6907                    // line. Items on later lines keep their positions.
6908                    let affected_line = cached.layout.items[affected_item].line_index;
6909                    let items: Vec<PositionedItem> = cached
6910                        .layout
6911                        .items
6912                        .iter()
6913                        .zip(shaped_items)
6914                        .enumerate()
6915                        .map(|(i, (old_positioned, new_shaped))| {
6916                            let mut position = old_positioned.position;
6917                            if i > affected_item && old_positioned.line_index == affected_line {
6918                                position.x += delta;
6919                            }
6920                            PositionedItem {
6921                                item: new_shaped,
6922                                position,
6923                                line_index: old_positioned.line_index,
6924                            }
6925                        })
6926                        .collect();
6927                    return Some((
6928                        UnifiedLayout {
6929                            items,
6930                            overflow: cached.layout.overflow.clone(),
6931                        },
6932                        true,
6933                    ));
6934                }
6935                IncrementalRelayoutResult::PartialReflow { .. }
6936                | IncrementalRelayoutResult::FullRelayout => {
6937                    // Fall through to full fragment layout.
6938                }
6939            }
6940        }
6941
6942        // Fall-back: run stage 4 (line breaking + positioning) with the
6943        // already-computed logical + shaped items. Still cheaper than the
6944        // plain full path because stages 1-3 aren't repeated.
6945        let layout = self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)?;
6946        Some((layout, false))
6947    }
6948
6949    /// Helper to get node `used_size` for accessibility actions
6950    #[cfg(feature = "a11y")]
6951    fn get_node_used_size_a11y(
6952        &self,
6953        dom_id: DomId,
6954        node_id: NodeId,
6955    ) -> Option<LogicalSize> {
6956        let layout_result = self.layout_results.get(&dom_id)?;
6957        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
6958        let idx = *layout_indices.first()?;
6959        let node = layout_result.layout_tree.get(idx)?;
6960        node.used_size
6961    }
6962
6963    /// Get the layout bounds (position and size) of a specific node
6964    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
6965    pub fn get_node_bounds(
6966        &self,
6967        dom_id: DomId,
6968        node_id: NodeId,
6969    ) -> Option<azul_css::props::basic::LayoutRect> {
6970        use azul_css::props::basic::LayoutRect;
6971
6972        let layout_result = self.layout_results.get(&dom_id)?;
6973        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
6974        let idx = *layout_indices.first()?;
6975        let node = layout_result.layout_tree.get(idx)?;
6976
6977        // Get size from used_size
6978        let size = node.used_size?;
6979
6980        // Get position from calculated_positions — uses layout tree index, not DOM node index
6981        let position = layout_result.calculated_positions.get(idx)?;
6982
6983        Some(LayoutRect {
6984            origin: azul_css::props::basic::LayoutPoint {
6985                x: position.x as isize,
6986                y: position.y as isize,
6987            },
6988            size: azul_css::props::basic::LayoutSize {
6989                width: size.width as isize,
6990                height: size.height as isize,
6991            },
6992        })
6993    }
6994
6995    /// Scroll a node into view if it's not currently visible in the viewport
6996    #[cfg(feature = "a11y")]
6997    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
6998    fn scroll_to_node_if_needed(
6999        &mut self,
7000        dom_id: DomId,
7001        node_id: NodeId,
7002        now: Instant,
7003    ) {
7004        // 1. Get target node bounds
7005        let Some(target_bounds) = self.get_node_bounds(dom_id, node_id) else {
7006            return;
7007        };
7008
7009        // 2. Find nearest scrollable ancestor
7010        let dom_node_id = DomNodeId {
7011            dom: dom_id,
7012            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
7013        };
7014        let Some(scroll_ancestor) = self.find_scrollable_ancestor(dom_node_id) else {
7015            return;
7016        };
7017        let Some(scroll_node_id) = scroll_ancestor.node.into_crate_internal() else {
7018            return;
7019        };
7020        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
7021            return;
7022        };
7023
7024        let current_scroll = self
7025            .scroll_manager
7026            .get_current_offset(dom_id, scroll_node_id)
7027            .unwrap_or_default();
7028
7029        // 3. Check if target is already visible in the ancestor viewport
7030        let vp_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
7031        let vp_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
7032        let vp_w = ancestor_bounds.size.width as f32;
7033        let vp_h = ancestor_bounds.size.height as f32;
7034
7035        let target_x = target_bounds.origin.x as f32;
7036        let target_y = target_bounds.origin.y as f32;
7037        let target_w = target_bounds.size.width as f32;
7038        let target_h = target_bounds.size.height as f32;
7039
7040        let visible_x = target_x >= vp_x && (target_x + target_w) <= (vp_x + vp_w);
7041        let visible_y = target_y >= vp_y && (target_y + target_h) <= (vp_y + vp_h);
7042
7043        if visible_x && visible_y {
7044            return; // Already visible
7045        }
7046
7047        // 4. Calculate scroll offset to bring target into view
7048        let mut scroll_x = current_scroll.x;
7049        let mut scroll_y = current_scroll.y;
7050
7051        if target_x < vp_x {
7052            scroll_x = target_x - ancestor_bounds.origin.x as f32;
7053        } else if (target_x + target_w) > (vp_x + vp_w) {
7054            scroll_x = (target_x + target_w) - ancestor_bounds.origin.x as f32 - vp_w;
7055        }
7056
7057        if target_y < vp_y {
7058            scroll_y = target_y - ancestor_bounds.origin.y as f32;
7059        } else if (target_y + target_h) > (vp_y + vp_h) {
7060            scroll_y = (target_y + target_h) - ancestor_bounds.origin.y as f32 - vp_h;
7061        }
7062
7063        self.scroll_manager.scroll_to(
7064            dom_id,
7065            scroll_node_id,
7066            LogicalPosition { x: scroll_x, y: scroll_y },
7067            std::time::Duration::from_millis(300).into(),
7068            EasingFunction::EaseOut,
7069            now,
7070        );
7071    }
7072
7073    /// Scroll the cursor into view if it's not currently visible
7074    ///
7075    /// This is automatically called when:
7076    /// - Focus lands on a contenteditable element
7077    /// - Cursor is moved programmatically
7078    /// - Text is inserted/deleted
7079    ///
7080    /// The function:
7081    /// 1. Gets the cursor rectangle from the text layout
7082    /// 2. Checks if the cursor is visible in the current viewport
7083    /// 3. If not, calculates the minimum scroll offset needed
7084    /// 4. Animates the scroll to bring the cursor into view
7085    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
7086    fn scroll_cursor_into_view_if_needed(
7087        &mut self,
7088        dom_id: DomId,
7089        node_id: NodeId,
7090        now: Instant,
7091    ) {
7092        // Get the cursor from multi_cursor
7093        let Some(cursor) = self.text_edit_manager.get_primary_cursor() else {
7094            return;
7095        };
7096
7097        // Get the inline layout for this node
7098        let Some(inline_layout) = self.get_node_inline_layout(dom_id, node_id) else {
7099            return;
7100        };
7101
7102        // Get the cursor rectangle from the text layout
7103        let Some(cursor_rect) = inline_layout.get_cursor_rect(&cursor) else {
7104            return;
7105        };
7106
7107        // Get the node bounds
7108        let Some(node_bounds) = self.get_node_bounds(dom_id, node_id) else {
7109            return;
7110        };
7111
7112        // Calculate the cursor's absolute position
7113        let cursor_abs_x = node_bounds.origin.x as f32 + cursor_rect.origin.x;
7114        let cursor_abs_y = node_bounds.origin.y as f32 + cursor_rect.origin.y;
7115
7116        // Walk up the DOM tree to find the nearest scrollable ancestor
7117        let dom_node_id = DomNodeId {
7118            dom: dom_id,
7119            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
7120        };
7121        let Some(scroll_ancestor) = self.find_scrollable_ancestor(dom_node_id) else {
7122            return; // No scrollable container
7123        };
7124        let Some(scroll_node_id) = scroll_ancestor.node.into_crate_internal() else {
7125            return;
7126        };
7127
7128        // Get the scrollable ancestor's bounds and scroll offset
7129        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
7130            return;
7131        };
7132        let current_scroll = self
7133            .scroll_manager
7134            .get_current_offset(dom_id, scroll_node_id)
7135            .unwrap_or_default();
7136
7137        // Calculate visible viewport from the scrollable ancestor
7138        let viewport_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
7139        let viewport_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
7140        let viewport_width = ancestor_bounds.size.width as f32;
7141        let viewport_height = ancestor_bounds.size.height as f32;
7142
7143        // Check if cursor is visible
7144        let cursor_visible_x = cursor_abs_x >= viewport_x
7145            && cursor_abs_x <= viewport_x + viewport_width;
7146        let cursor_visible_y = cursor_abs_y >= viewport_y
7147            && cursor_abs_y <= viewport_y + viewport_height;
7148
7149        if cursor_visible_x && cursor_visible_y {
7150            // Cursor is already visible
7151            return;
7152        }
7153
7154        // Calculate scroll offset to make cursor visible
7155        let mut target_scroll_x = current_scroll.x;
7156        let mut target_scroll_y = current_scroll.y;
7157
7158        // Adjust horizontal scroll if needed
7159        if cursor_abs_x < viewport_x {
7160            target_scroll_x = cursor_abs_x - ancestor_bounds.origin.x as f32;
7161        } else if cursor_abs_x > viewport_x + viewport_width {
7162            target_scroll_x = cursor_abs_x - ancestor_bounds.origin.x as f32 - viewport_width
7163                + cursor_rect.size.width;
7164        }
7165
7166        // Adjust vertical scroll if needed
7167        if cursor_abs_y < viewport_y {
7168            target_scroll_y = cursor_abs_y - ancestor_bounds.origin.y as f32;
7169        } else if cursor_abs_y > viewport_y + viewport_height {
7170            target_scroll_y = cursor_abs_y - ancestor_bounds.origin.y as f32 - viewport_height
7171                + cursor_rect.size.height;
7172        }
7173
7174        // Animate scroll on the scrollable ancestor
7175        self.scroll_manager.scroll_to(
7176            dom_id,
7177            scroll_node_id,
7178            LogicalPosition {
7179                x: target_scroll_x,
7180                y: target_scroll_y,
7181            },
7182            std::time::Duration::from_millis(200).into(),
7183            EasingFunction::EaseOut,
7184            now,
7185        );
7186    }
7187
7188    /// Convert a byte offset in the text to a `TextCursor` position
7189    ///
7190    /// This is used for accessibility `SetTextSelection` action, which provides
7191    /// byte offsets rather than grapheme cluster IDs.
7192    ///
7193    /// # Arguments
7194    ///
7195    /// * `text_layout` - The text layout containing the shaped runs
7196    /// * `byte_offset` - The byte offset in the UTF-8 text
7197    ///
7198    /// # Returns
7199    ///
7200    /// A `TextCursor` positioned at the given byte offset, or None if the offset
7201    /// is out of bounds.
7202    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
7203    fn byte_offset_to_cursor(
7204        text_layout: &UnifiedLayout,
7205        byte_offset: u32,
7206    ) -> TextCursor {
7207        // Handle offset 0 as special case (start of text)
7208        if byte_offset == 0 {
7209            // Find first cluster in items
7210            for item in &text_layout.items {
7211                if let ShapedItem::Cluster(cluster) = &item.item {
7212                    return TextCursor {
7213                        cluster_id: cluster.source_cluster_id,
7214                        affinity: CursorAffinity::Trailing,
7215                    };
7216                }
7217            }
7218            // No clusters found - return default
7219            return TextCursor {
7220                cluster_id: GraphemeClusterId {
7221                    source_run: 0,
7222                    start_byte_in_run: 0,
7223                },
7224                affinity: CursorAffinity::Trailing,
7225            };
7226        }
7227
7228        // Iterate through items to find which cluster contains this byte offset
7229        let mut current_byte_offset = 0u32;
7230
7231        for item in &text_layout.items {
7232            if let ShapedItem::Cluster(cluster) = &item.item {
7233                // Calculate byte length of this cluster from its text
7234                let cluster_byte_length = cluster.text.len() as u32;
7235                let cluster_end_byte = current_byte_offset + cluster_byte_length;
7236
7237                // Check if our target byte offset falls within this cluster
7238                if byte_offset >= current_byte_offset && byte_offset <= cluster_end_byte {
7239                    // Found the cluster
7240                    return TextCursor {
7241                        cluster_id: cluster.source_cluster_id,
7242                        affinity: CursorAffinity::Trailing,
7243                    };
7244                }
7245
7246                current_byte_offset = cluster_end_byte;
7247            }
7248        }
7249
7250        // Offset is beyond the end of all text - return cursor at end of last cluster
7251        for item in text_layout.items.iter().rev() {
7252            if let ShapedItem::Cluster(cluster) = &item.item {
7253                return TextCursor {
7254                    cluster_id: cluster.source_cluster_id,
7255                    affinity: CursorAffinity::Trailing,
7256                };
7257            }
7258        }
7259
7260        // No clusters at all - return default position
7261        TextCursor {
7262            cluster_id: GraphemeClusterId {
7263                source_run: 0,
7264                start_byte_in_run: 0,
7265            },
7266            affinity: CursorAffinity::Trailing,
7267        }
7268    }
7269
7270    /// Get the inline layout result for a specific node
7271    ///
7272    /// This looks up the node in the layout tree and returns its inline layout result
7273    /// if it exists.
7274    fn get_node_inline_layout(
7275        &self,
7276        dom_id: DomId,
7277        node_id: NodeId,
7278    ) -> Option<Arc<UnifiedLayout>> {
7279        // Get the layout tree from cache
7280        let layout_tree = self.layout_cache.tree.as_ref()?;
7281
7282        // Find the layout node index corresponding to the DOM node
7283        let layout_idx = layout_tree
7284            .nodes
7285            .iter()
7286            .position(|node| node.dom_node_id == Some(node_id))?;
7287
7288        // Return the inline layout result (warm data)
7289        layout_tree.warm(layout_idx)?
7290            .inline_layout_result
7291            .as_ref()
7292            .map(solver3::layout_tree::CachedInlineLayout::clone_layout)
7293    }
7294
7295    /// Edit the text content of a node (used for text input actions)
7296    ///
7297    /// This function applies text edits to nodes that contain text content.
7298    /// The DOM node itself is NOT modified - instead, the text cache is updated
7299    /// with the new shaped text that reflects the edit, cursor, and selection.
7300    ///
7301    /// It handles:
7302    /// - `ReplaceSelectedText`: Replaces the current selection with new text
7303    /// - `SetValue`: Sets the entire text value
7304    /// - `SetNumericValue`: Converts number to string and sets value
7305    ///
7306    /// # Returns
7307    ///
7308    /// Returns a Vec of `DomNodeIds` (node + parent) that need to be marked dirty
7309    /// for re-layout. The caller MUST use this return value to trigger layout.
7310    #[must_use = "Returned nodes must be marked dirty for re-layout"]
7311    #[cfg(feature = "a11y")]
7312    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7313    pub fn edit_text_node(
7314        &mut self,
7315        dom_id: DomId,
7316        node_id: NodeId,
7317        edit_type: &TextEditType,
7318    ) -> Vec<DomNodeId> {
7319        use crate::managers::text_input::TextInputSource;
7320
7321        // Convert TextEditType to string
7322        let text_input = match edit_type {
7323            TextEditType::ReplaceSelection(text) => text.clone(),
7324            TextEditType::SetValue(text) => text.clone(),
7325            TextEditType::SetNumericValue(value) => value.to_string(),
7326        };
7327
7328        // Get the OLD text before any changes
7329        let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
7330        let old_text = self.extract_text_from_inline_content(&old_inline_content);
7331
7332        // Create DomNodeId
7333        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
7334        let dom_node_id = DomNodeId {
7335            dom: dom_id,
7336            node: hierarchy_id,
7337        };
7338
7339        // Record the changeset in TextInputManager
7340        self.text_input_manager.record_input(
7341            dom_node_id,
7342            text_input,
7343            old_text,
7344            TextInputSource::Accessibility, // A11y source
7345        );
7346
7347        // Immediately apply the changeset (A11y doesn't go through callbacks)
7348        self.apply_text_changeset().dirty_nodes
7349    }
7350
7351    #[cfg(not(feature = "a11y"))]
7352    pub fn process_accessibility_action(
7353        &mut self,
7354        _dom_id: DomId,
7355        _node_id: NodeId,
7356        _action: azul_core::dom::AccessibilityAction,
7357        _now: azul_core::task::Instant,
7358    ) -> BTreeMap<DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
7359        // No-op when accessibility is disabled
7360        BTreeMap::new()
7361    }
7362
7363    /// Process mouse click for text selection.
7364    ///
7365    /// This method handles:
7366    /// - Single click: Place cursor at click position
7367    /// - Double click: Select word at click position
7368    /// - Triple click: Select paragraph (line) at click position
7369    ///
7370    /// ## Workflow
7371    /// 1. Use `HoverManager`'s hit test to find hit nodes
7372    /// 2. Find the IFC layout via `inline_layout_result` (IFC root) or `ifc_membership` (text node)
7373    /// 3. Use `point_relative_to_item` for local cursor position
7374    /// 4. Hit-test the text layout to get logical cursor
7375    /// 5. Apply appropriate selection based on click count
7376    /// 6. Update `SelectionManager` with new selection
7377    ///
7378    /// ## IFC Architecture
7379    /// Text nodes don't store `inline_layout_result` directly. Instead:
7380    /// - IFC root nodes (e.g., `<p>`) have `inline_layout_result` with the complete text layout
7381    /// - Text nodes have `ifc_membership` pointing back to their IFC root
7382    /// - This allows efficient lookup without iterating all nodes
7383    ///
7384    /// ## Parameters
7385    /// * `position` - Click position in logical coordinates (for click count tracking)
7386    /// * `time_ms` - Current time in milliseconds (for multi-click detection)
7387    ///
7388    /// ## Returns
7389    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering, None if click didn't hit text
7390    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7391    pub fn process_mouse_click_for_selection(
7392        &mut self,
7393        position: LogicalPosition,
7394        time_ms: u64,
7395    ) -> Option<Vec<DomNodeId>> {
7396        use crate::managers::hover::InputPointId;
7397        use crate::text3::selection::{select_paragraph_at_cursor, select_word_at_cursor};
7398
7399        // found_selection stores: (dom_id, ifc_root_node_id, selection_range, local_pos)
7400        // IMPORTANT: We always store the IFC root NodeId, not the text node NodeId,
7401        // because selections are rendered via inline_layout_result which lives on the IFC root.
7402        let mut found_selection: Option<(DomId, NodeId, SelectionRange, LogicalPosition)> = None;
7403
7404        // Try to get hit test from HoverManager first (fast path, uses WebRender's point_relative_to_item)
7405        if let Some(hit_test) = self.hover_manager.get_current(&InputPointId::Mouse) {
7406            // Iterate through hit nodes from the HoverManager
7407            for (dom_id, hit) in &hit_test.hovered_nodes {
7408                let Some(layout_result) = self.layout_results.get(dom_id) else {
7409                    continue;
7410                };
7411                // Use layout tree from layout_result, not layout_cache
7412                let tree = &layout_result.layout_tree;
7413
7414                // Sort by DOM depth (deepest first) to prefer specific text nodes over containers.
7415                // We count the actual number of parents to determine DOM depth properly.
7416                // Secondary sort by NodeId for deterministic ordering within the same depth.
7417                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
7418                let get_dom_depth = |node_id: &NodeId| -> usize {
7419                    let mut depth = 0;
7420                    let mut current = *node_id;
7421                    while let Some(parent) = node_hierarchy.get(current).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id) {
7422                        depth += 1;
7423                        current = parent;
7424                    }
7425                    depth
7426                };
7427
7428                let mut sorted_hits: Vec<_> = hit.regular_hit_test_nodes.iter().collect();
7429                sorted_hits.sort_by(|(a_id, _), (b_id, _)| {
7430                    let depth_a = get_dom_depth(a_id);
7431                    let depth_b = get_dom_depth(b_id);
7432                    // Higher depth = deeper in DOM = should come first
7433                    // Then sort by NodeId for deterministic order within same depth
7434                    depth_b.cmp(&depth_a).then_with(|| a_id.index().cmp(&b_id.index()))
7435                });
7436
7437                for (node_id, hit_item) in sorted_hits {
7438                    // Check if text is selectable
7439                    if !Self::is_text_selectable(&layout_result.styled_dom, *node_id) {
7440                        continue;
7441                    }
7442
7443                    // Find the layout node for this DOM node
7444                    let layout_node_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(*node_id));
7445                    let Some(layout_node_idx) = layout_node_idx else {
7446                        continue;
7447                    };
7448                    let Some(warm_node) = tree.warm(layout_node_idx) else {
7449                        continue;
7450                    };
7451
7452                    // Get the IFC layout and IFC root NodeId
7453                    // Selection must be stored on the IFC root, not on text nodes
7454                    let (cached_layout, ifc_root_node_id) = if let Some(ref cached) = warm_node.inline_layout_result {
7455                        // This node IS an IFC root - use its own NodeId
7456                        (cached, *node_id)
7457                    } else if let Some(ref membership) = warm_node.ifc_membership {
7458                        // This node participates in an IFC - get layout and NodeId from IFC root
7459                        match tree.warm(membership.ifc_root_layout_index) {
7460                            Some(ifc_root_warm) => match (ifc_root_warm.inline_layout_result.as_ref(), tree.get(membership.ifc_root_layout_index).and_then(|n| n.dom_node_id)) {
7461                                (Some(cached), Some(root_dom_id)) => (cached, root_dom_id),
7462                                _ => continue,
7463                            },
7464                            None => continue,
7465                        }
7466                    } else {
7467                        // No IFC involvement - not a text node
7468                        continue;
7469                    };
7470
7471                    let layout = &cached_layout.layout;
7472
7473                    // Use point_relative_to_item - this is the local position within the hit node
7474                    // provided by WebRender's hit test
7475                    let local_pos = hit_item.point_relative_to_item;
7476
7477                    // Hit-test the cursor in this text layout
7478                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
7479                        // Store selection with IFC root NodeId, not the hit text node
7480                        found_selection = Some((*dom_id, ifc_root_node_id, SelectionRange {
7481                            start: cursor,
7482                            end: cursor,
7483                        }, local_pos));
7484                        break;
7485                    }
7486                }
7487
7488                if found_selection.is_some() {
7489                    break;
7490                }
7491            }
7492        }
7493
7494        // Fallback: If HoverManager has no hit test (e.g., debug server),
7495        // search through IFC roots using global position
7496        if found_selection.is_none() {
7497            for (dom_id, layout_result) in &self.layout_results {
7498                // Use the layout tree from layout_result, not layout_cache
7499                // layout_cache.tree is for the root DOM only; layout_result.layout_tree
7500                // is the correct tree for each DOM (including virtualized views)
7501                let tree = &layout_result.layout_tree;
7502
7503                // Only iterate IFC roots (nodes with inline_layout_result)
7504                for (node_idx, layout_node) in tree.nodes.iter().enumerate() {
7505                    let Some(warm) = tree.warm(node_idx) else {
7506                        continue;
7507                    };
7508                    let Some(cached_layout) = warm.inline_layout_result.as_ref() else {
7509                        continue; // Skip non-IFC-root nodes
7510                    };
7511
7512                    let Some(node_id) = layout_node.dom_node_id else {
7513                        continue;
7514                    };
7515
7516                    // Check if text is selectable
7517                    if !Self::is_text_selectable(&layout_result.styled_dom, node_id) {
7518                        continue;
7519                    }
7520
7521                    // Get the node's absolute position
7522                    // Use layout_result.calculated_positions for the correct DOM
7523                    let node_pos = layout_result.calculated_positions
7524            .get(node_idx)
7525                        .copied()
7526                        .unwrap_or_default();
7527
7528                    // Check if position is within node bounds
7529                    let node_size = layout_node.used_size.unwrap_or_else(|| {
7530                        let bounds = cached_layout.layout.bounds();
7531                        LogicalSize::new(bounds.width, bounds.height)
7532                    });
7533
7534                    if position.x < node_pos.x || position.x > node_pos.x + node_size.width ||
7535                       position.y < node_pos.y || position.y > node_pos.y + node_size.height {
7536                        continue;
7537                    }
7538
7539                    // Convert global position to node-local coordinates
7540                    let local_pos = LogicalPosition {
7541                        x: position.x - node_pos.x,
7542                        y: position.y - node_pos.y,
7543                    };
7544
7545                    let layout = &cached_layout.layout;
7546
7547                    // Hit-test the cursor in this text layout
7548                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
7549                        found_selection = Some((*dom_id, node_id, SelectionRange {
7550                            start: cursor,
7551                            end: cursor,
7552                        }, local_pos));
7553                        break;
7554                    }
7555                }
7556
7557                if found_selection.is_some() {
7558                    break;
7559                }
7560            }
7561        }
7562
7563        let (dom_id, ifc_root_node_id, initial_range, _local_pos) = found_selection?;
7564
7565        // Create DomNodeId for click state tracking - use IFC root's NodeId
7566        // Selection state is keyed by IFC root because that's where inline_layout_result lives
7567        let node_hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(ifc_root_node_id));
7568        let dom_node_id = DomNodeId {
7569            dom: dom_id,
7570            node: node_hierarchy_id,
7571        };
7572
7573        // Derive click count from the gesture manager's session history
7574        // (timestamps + positions), no mutable click state needed.
7575        let click_count = self.gesture_drag_manager.detect_click_count();
7576
7577        // Get the text layout again for word/paragraph selection
7578        let final_range = if click_count > 1 {
7579            // Use layout_results for the correct DOM's tree
7580            let layout_result = self.layout_results.get(&dom_id)?;
7581            let tree = &layout_result.layout_tree;
7582
7583            // Find layout node - ifc_root_node_id is always the IFC root, so it has inline_layout_result
7584            let layout_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(ifc_root_node_id))?;
7585            let cached_layout = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
7586            let layout = &cached_layout.layout;
7587
7588            match click_count {
7589                2 => select_word_at_cursor(&initial_range.start, layout.as_ref())
7590                    .unwrap_or(initial_range),
7591                3 => select_paragraph_at_cursor(&initial_range.start, layout.as_ref())
7592                    .unwrap_or(initial_range),
7593                _ => initial_range,
7594            }
7595        } else {
7596            initial_range
7597        };
7598
7599        // CRITICAL FIX 1: Set focus on the clicked node
7600        // Without this, clicking on a contenteditable element shows a cursor but
7601        // text input doesn't work because record_text_input() checks focus_manager.get_focused_node()
7602        // and returns early if there's no focus.
7603        //
7604        // Check if the node OR ANY ANCESTOR is contenteditable before setting focus
7605        // The contenteditable attribute is typically on a parent div, not on the IFC root or text node
7606        let is_contenteditable = self.layout_results.get(&dom_id)
7607            .is_some_and(|lr| {
7608                let node_hierarchy = lr.styled_dom.node_hierarchy.as_container();
7609                let node_data = lr.styled_dom.node_data.as_ref();
7610
7611                // Walk up the DOM tree to check if any ancestor has contenteditable
7612                let mut current_node = Some(ifc_root_node_id);
7613                while let Some(node_id) = current_node {
7614                    if let Some(styled_node) = node_data.get(node_id.index()) {
7615                        // Check BOTH: the contenteditable boolean field AND the attribute
7616                        // NodeData has a direct `contenteditable: bool` field that should be
7617                        // checked in addition to the attribute for robustness
7618                        if styled_node.is_contenteditable() {
7619                            return true;
7620                        }
7621
7622                        // Also check the attribute (for backwards compatibility)
7623                        let has_contenteditable_attr = styled_node.attributes().as_ref().iter().any(|attr| {
7624                            matches!(attr, AttributeType::ContentEditable(_))
7625                        });
7626                        if has_contenteditable_attr {
7627                            return true;
7628                        }
7629                    }
7630                    // Move to parent
7631                    current_node = node_hierarchy.get(node_id).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
7632                }
7633                false
7634            });
7635
7636        // NOTE: Do NOT call focus_manager.set_focused_node() here!
7637        // The click-to-focus system in event.rs (process_window_events) handles
7638        // focus via SetFocus which also triggers apply_focus_restyle for :focus CSS.
7639        // Setting focus directly here bypasses that, causing the blue border to not
7640        // appear until the next full layout (e.g., resize).
7641
7642        // Initialize editing at the clicked position via unified API.
7643        let ce_key = self.layout_results.get(&dom_id).map_or(0, |lr| {
7644            azul_core::diff::calculate_contenteditable_key(
7645                lr.styled_dom.node_data.as_ref(),
7646                lr.styled_dom.node_hierarchy.as_ref(),
7647                ifc_root_node_id,
7648            )
7649        });
7650        self.text_edit_manager.initialize_editing(
7651            final_range.start, dom_id, ifc_root_node_id, ce_key,
7652        );
7653        // MWA-C-text_edit: double/triple-click computed the word/paragraph
7654        // range above but then threw it away — initialize_editing only
7655        // places a collapsed caret at range.start, so word/paragraph select
7656        // never actually selected anything. Apply the full range.
7657        if click_count > 1 && final_range.start != final_range.end {
7658            if let Some(mc) = self.text_edit_manager.multi_cursor.as_mut() {
7659                mc.set_single_range(final_range);
7660            }
7661        }
7662        let now = Instant::now();
7663        self.text_edit_manager.blink.reset_blink_on_input(now);
7664        self.text_edit_manager.blink.set_blink_timer_active(true);
7665        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
7666
7667        // Regenerate display list so cursor appears at the clicked position
7668        // (same pattern as handle_cursor_movement and apply_text_changeset)
7669        self.regenerate_display_list_for_dom(dom_id);
7670
7671        // Return the affected node for dirty tracking
7672        Some(vec![dom_node_id])
7673    }
7674
7675    /// Process mouse drag for text selection extension.
7676    ///
7677    /// This method handles drag-to-select by extending the selection from
7678    /// the anchor (mousedown position) to the current focus (drag position).
7679    ///
7680    /// Uses the anchor/focus model:
7681    /// - Anchor is fixed at the initial click position (set by `process_mouse_click_for_selection`)
7682    /// - Focus moves with the mouse during drag
7683    /// - Affected nodes between anchor and focus are computed in DOM order
7684    ///
7685    /// ## Parameters
7686    /// * `start_position` - Initial click position in logical coordinates (unused, anchor is stored)
7687    /// * `current_position` - Current mouse position in logical coordinates
7688    ///
7689    /// ## Returns
7690    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering
7691    pub fn process_mouse_drag_for_selection(
7692        &mut self,
7693        _start_position: LogicalPosition,
7694        current_position: LogicalPosition,
7695    ) -> Option<Vec<DomNodeId>> {
7696        use azul_core::selection::{Selection, SelectionRange};
7697
7698        // Get the anchor cursor and editing node from MultiCursorState.
7699        // The anchor was set by process_mouse_click_for_selection.
7700        // IMPORTANT: For Range selections, the anchor is .start (fixed),
7701        // NOT .end (which moves with each drag event).
7702        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
7703        let anchor = match &mc.get_primary()?.selection {
7704            Selection::Cursor(c) => *c,
7705            Selection::Range(r) => r.start, // anchor stays fixed during drag
7706        };
7707        let dom_id = mc.node_id.dom;
7708        let node_id = mc.node_id.node.into_crate_internal()?;
7709        let dom_node_id = mc.node_id;
7710
7711        // Hit-test the current drag position to get the focus cursor
7712        let layout_result = self.layout_results.get(&dom_id)?;
7713        let tree = &layout_result.layout_tree;
7714        let layout_idx = tree.nodes.iter()
7715            .position(|n| n.dom_node_id == Some(node_id))?;
7716        let node_pos = layout_result.calculated_positions
7717            .get(layout_idx)
7718            .copied()
7719            .unwrap_or_default();
7720        let cached = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
7721
7722        let local_pos = LogicalPosition {
7723            x: current_position.x - node_pos.x,
7724            y: current_position.y - node_pos.y,
7725        };
7726        let focus = cached.layout.hittest_cursor(local_pos)?;
7727
7728        // Update primary selection: Cursor → Range(anchor, focus)
7729        let mc = self.text_edit_manager.multi_cursor.as_mut()?;
7730        if let Some(primary) = mc.get_primary_mut() {
7731            if anchor == focus {
7732                primary.selection = Selection::Cursor(anchor);
7733            } else {
7734                primary.selection = Selection::Range(SelectionRange {
7735                    start: anchor,
7736                    end: focus,
7737                });
7738            }
7739        }
7740
7741        self.text_edit_manager.mark_dirty();
7742        self.regenerate_display_list_for_dom(dom_id);
7743        Some(vec![dom_node_id])
7744    }
7745
7746    /// Delete the currently selected text or one character at the cursor
7747    ///
7748    /// Handles Backspace/Delete key. If a range selection exists, the selected
7749    /// text is deleted. If only a cursor exists (no range), one character is
7750    /// deleted before (Backspace) or after (Delete) the cursor.
7751    ///
7752    /// ## Arguments
7753    /// * `target` - The target node (focused contenteditable element)
7754    /// * `forward` - true for Delete key (forward), false for Backspace (backward)
7755    ///
7756    /// ## Returns
7757    /// * `Some(Vec<DomNodeId>)` - Affected nodes if deletion occurred
7758    /// * `None` - If no cursor/selection exists or deletion failed
7759    pub fn delete_selection(
7760        &mut self,
7761        target: DomNodeId,
7762        forward: bool,
7763    ) -> Option<Vec<DomNodeId>> {
7764        let dom_id = target.dom;
7765        let node_id = target.node.into_crate_internal()?;
7766
7767        // Multi-cursor path: use edit_text with DeleteBackward/DeleteForward
7768        let current_selections = if let Some(ref mc) = self.text_edit_manager.multi_cursor {
7769            mc.to_selections()
7770        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
7771            vec![Selection::Cursor(cursor)]
7772        } else {
7773            return None;
7774        };
7775
7776        let content = self.get_text_before_textinput(dom_id, node_id);
7777        let edit = if forward {
7778            crate::text3::edit::TextEdit::DeleteForward
7779        } else {
7780            crate::text3::edit::TextEdit::DeleteBackward
7781        };
7782        let (new_content, new_selections) = crate::text3::edit::edit_text(
7783            &content, &current_selections, &edit,
7784        );
7785
7786        // MWA-C-undo_redo: deletions (Backspace / Delete / Cut all route
7787        // here) were never recorded — only insertions were undoable. Record
7788        // a DeleteText operation with styled pre/post snapshots; the actual
7789        // undo/redo restore uses the snapshots (keyed by changeset id),
7790        // deleted_text/range are informational for the C-API inspect fns.
7791        // Ids count DOWN from usize::MAX so they cannot collide with the
7792        // insertion counter in apply_text_changeset (counts up from 0).
7793        {
7794            use crate::managers::changeset::{TextChangeset, TextOpDeleteText, TextOperation};
7795            use crate::managers::undo_redo::NodeStateSnapshot;
7796            static DELETE_CHANGESET_COUNTER: AtomicUsize = AtomicUsize::new(0);
7797
7798            let pre_text = self.extract_text_from_inline_content(&content);
7799            let old_cursor = current_selections.first().and_then(|sel| match sel {
7800                Selection::Cursor(c) => Some(*c),
7801                Selection::Range(_) => None,
7802            });
7803            let old_range = current_selections.first().and_then(|sel| match sel {
7804                Selection::Range(r) => Some(*r),
7805                Selection::Cursor(_) => None,
7806            });
7807            let record_range = old_range.unwrap_or_else(|| {
7808                let anchor = old_cursor.unwrap_or(TextCursor {
7809                    cluster_id: GraphemeClusterId {
7810                        source_run: 0,
7811                        start_byte_in_run: 0,
7812                    },
7813                    affinity: CursorAffinity::Leading,
7814                });
7815                SelectionRange {
7816                    start: anchor,
7817                    end: anchor,
7818                }
7819            });
7820            let changeset_id =
7821                usize::MAX - DELETE_CHANGESET_COUNTER.fetch_add(1, Ordering::SeqCst);
7822            let timestamp = {
7823                #[cfg(feature = "std")]
7824                {
7825                    Instant::now()
7826                }
7827                #[cfg(not(feature = "std"))]
7828                {
7829                    azul_core::task::Instant::Tick(azul_core::task::SystemTick {
7830                        tick_counter: 0,
7831                    })
7832                }
7833            };
7834            let pre_state = NodeStateSnapshot {
7835                node_id,
7836                text_content: pre_text.into(),
7837                cursor_position: old_cursor.into(),
7838                selection_range: old_range.into(),
7839                timestamp: timestamp.clone(),
7840            };
7841            let changeset = TextChangeset {
7842                id: changeset_id,
7843                target,
7844                operation: TextOperation::DeleteText(TextOpDeleteText {
7845                    range: record_range,
7846                    deleted_text: "".into(),
7847                    new_cursor: CursorPosition::Uninitialized,
7848                }),
7849                timestamp,
7850            };
7851            self.undo_redo_manager.store_content_snapshot(
7852                changeset_id,
7853                content,
7854                new_content.clone(),
7855            );
7856            self.undo_redo_manager.record_operation(changeset, pre_state);
7857        }
7858
7859        // Update multi-cursor state
7860        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
7861            mc.update_from_edit_result(&new_selections);
7862        }
7863        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
7864
7865        self.update_text_cache_after_edit(dom_id, node_id, new_content);
7866        self.regenerate_display_list_for_dom(dom_id);
7867
7868        Some(vec![target])
7869    }
7870
7871    /// Extract clipboard content from the current selection
7872    ///
7873    /// This method extracts both plain text and styled text from the selection ranges.
7874    /// It iterates through all selected text, extracts the actual characters, and
7875    /// preserves styling information from the `ShapedGlyph`'s `StyleProperties`.
7876    ///
7877    /// This is NOT reading from the system clipboard - use `clipboard_manager.get_paste_content()`
7878    /// for that. This extracts content FROM the selection TO be copied.
7879    ///
7880    /// ## Arguments
7881    /// * `dom_id` - The DOM to extract selection from
7882    ///
7883    /// ## Returns
7884    /// * `Some(ClipboardContent)` - If there is a selection with text
7885    /// * `None` - If no selection or no text layouts found
7886    pub fn get_selected_content_for_clipboard(
7887        &self,
7888        dom_id: &DomId,
7889    ) -> Option<crate::managers::selection::ClipboardContent> {
7890        use crate::managers::selection::ClipboardContent;
7891        use crate::text3::edit::cursor_byte_offset_in_run;
7892
7893        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
7894        let node_id = mc.node_id.node.into_crate_internal()?;
7895
7896        // Collect range selections (collapsed cursors contribute nothing to a copy).
7897        let ranges: Vec<_> = mc.selections.iter().filter_map(|s| match &s.selection {
7898            Selection::Range(r) => Some(*r),
7899            Selection::Cursor(_) => None,
7900        }).collect();
7901        if ranges.is_empty() {
7902            return None;
7903        }
7904
7905        // Most editables are a single text run (the whole string, newlines and
7906        // all), so source_run is 0 and the single-run branch handles everything.
7907        // The multi-run branch is a best-effort for rich (multi-span) content.
7908        // Byte offsets are affinity-aware (cursor_byte_offset_in_run), so a
7909        // select-all whose end cursor is Trailing on the last cluster copies the
7910        // full text — matching the affinity fix in delete_range.
7911        let content = self.get_text_before_textinput(*dom_id, node_id);
7912        let mut plain = String::new();
7913        for r in &ranges {
7914            let sr = r.start.cluster_id.source_run as usize;
7915            let er = r.end.cluster_id.source_run as usize;
7916            if sr == er {
7917                if let Some(InlineContent::Text(run)) = content.get(sr) {
7918                    let a = cursor_byte_offset_in_run(&run.text, &r.start);
7919                    let b = cursor_byte_offset_in_run(&run.text, &r.end);
7920                    let (lo, hi) = (a.min(b), a.max(b));
7921                    if hi <= run.text.len() && lo < hi {
7922                        plain.push_str(&run.text[lo..hi]);
7923                    }
7924                }
7925            } else {
7926                // Multi-run: walk runs in document order, taking the tail of the
7927                // first run, all middle runs, and the head of the last.
7928                let (first_idx, first_cur, last_idx, last_cur) = if sr <= er {
7929                    (sr, r.start, er, r.end)
7930                } else {
7931                    (er, r.end, sr, r.start)
7932                };
7933                for ri in first_idx..=last_idx {
7934                    if let Some(InlineContent::Text(run)) = content.get(ri) {
7935                        if ri == first_idx {
7936                            let off = cursor_byte_offset_in_run(&run.text, &first_cur).min(run.text.len());
7937                            plain.push_str(&run.text[off..]);
7938                        } else if ri == last_idx {
7939                            let off = cursor_byte_offset_in_run(&run.text, &last_cur).min(run.text.len());
7940                            plain.push_str(&run.text[..off]);
7941                        } else {
7942                            plain.push_str(&run.text);
7943                        }
7944                    }
7945                }
7946            }
7947        }
7948
7949        if plain.is_empty() {
7950            return None;
7951        }
7952        Some(ClipboardContent {
7953            plain_text: plain.into(),
7954            // TODO(superplan): styled_runs left empty — extracting per-run style
7955            // (font/size/color/bold/italic from the styled DOM) is only useful once
7956            // the platform clipboard backends gain an HTML/RTF format and ClipboardContent::to_html
7957            // is wired into the copy path (see layout/src/managers/selection.rs docs).
7958            // Plain-text copy is fully wired.
7959            styled_runs: Vec::new().into(),
7960        })
7961    }
7962
7963    /// Process image callback updates from callback changes
7964    ///
7965    /// This function re-invokes image callbacks for nodes that requested updates
7966    /// (typically from timer callbacks or resize events). It returns the updated
7967    /// textures along with their metadata for the rendering pipeline to process.
7968    ///
7969    /// # Arguments
7970    ///
7971    /// * `image_callbacks_changed` - Map of `DomId` -> Set of `NodeIds` that need re-rendering
7972    /// * `gl_context` - OpenGL context pointer for rendering
7973    ///
7974    /// # Returns
7975    ///
7976    /// Vector of (`DomId`, `NodeId`, Texture) tuples for textures that were updated
7977    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
7978    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7979    pub fn process_image_callback_updates(
7980        &mut self,
7981        image_callbacks_changed: &BTreeMap<DomId, FastBTreeSet<NodeId>>,
7982        gl_context: &OptionGlContextPtr,
7983    ) -> Vec<(DomId, NodeId, azul_core::gl::Texture)> {
7984        use crate::callbacks::{RenderImageCallback, RenderImageCallbackInfo};
7985        use std::panic;
7986
7987        let mut updated_textures = Vec::new();
7988
7989        for (dom_id, node_ids) in image_callbacks_changed {
7990            let Some(layout_result) = self.layout_results.get_mut(dom_id) else {
7991                continue;
7992            };
7993
7994            for node_id in node_ids {
7995                // Get the node data - store container ref to extend lifetime
7996                let node_data_container = layout_result.styled_dom.node_data.as_container();
7997                let Some(node_data) = node_data_container.get(*node_id) else {
7998                    continue;
7999                };
8000
8001                // Check if this is an Image node with a callback
8002                let has_callback = matches!(node_data.get_node_type(), NodeType::Image(img_ref)
8003                    if img_ref.get_image_callback().is_some());
8004
8005                if !has_callback {
8006                    continue;
8007                }
8008
8009                // Get layout indices for this DOM node (can have multiple due to text splitting,
8010                // etc.)
8011                let layout_indices = match layout_result.layout_tree.dom_to_layout.get(node_id) {
8012                    Some(indices) if !indices.is_empty() => indices,
8013                    _ => continue,
8014                };
8015
8016                // Use the first layout index (primary node)
8017                let layout_index = layout_indices[0];
8018
8019                // Get the position from calculated_positions
8020                let position = match layout_result.calculated_positions.get(layout_index) {
8021                    Some(pos) => *pos,
8022                    None => continue,
8023                };
8024
8025                // Get the layout node to determine size
8026                let Some(layout_node) = layout_result.layout_tree.get(layout_index) else {
8027                    continue;
8028                };
8029
8030                // Get the size from the layout node (used_size is the computed size from layout)
8031                let (width, height) = match layout_node.used_size {
8032                    Some(size) => (size.width, size.height),
8033                    None => continue, // Node hasn't been laid out yet
8034                };
8035
8036                let callback_domnode_id = DomNodeId {
8037                    dom: *dom_id,
8038                    node: NodeHierarchyItemId::from_crate_internal(Some(
8039                        *node_id,
8040                    )),
8041                };
8042
8043                let bounds = HidpiAdjustedBounds::from_bounds(
8044                    azul_css::props::basic::LayoutSize {
8045                        width: width as isize,
8046                        height: height as isize,
8047                    },
8048                    self.current_window_state.size.get_hidpi_factor(),
8049                );
8050
8051                // Create callback info
8052                let mut gl_callback_info = RenderImageCallbackInfo::new(
8053                    callback_domnode_id,
8054                    bounds,
8055                    gl_context,
8056                    &self.image_cache,
8057                    &self.font_manager.fc_cache,
8058                );
8059
8060                // Invoke the callback
8061                let new_image_ref = {
8062                    let mut node_data_mut = layout_result.styled_dom.node_data.as_container_mut();
8063                    match node_data_mut.get_mut(*node_id) {
8064                        Some(nd) => {
8065                            match &mut nd.node_type {
8066                                NodeType::Image(ref mut img_ref) => {
8067                                    // Try get_image_callback_mut first (requires exclusive access)
8068                                    let callback_result = img_ref.as_mut().get_image_callback_mut();
8069                                    
8070                                    if callback_result.is_none() {
8071                                        // The ImageRef has multiple copies (Arc refcount > 1),
8072                                        // so get_image_callback_mut returns None. Fall back to
8073                                        // read-only access + clone to invoke the callback.
8074                                        match img_ref.get_data() {
8075                                            azul_core::resources::DecodedImage::Callback(core_callback) => {
8076                                                if core_callback.callback.cb == 0 {
8077                                                    None
8078                                                } else {
8079                                                    let callback = RenderImageCallback::from_core(&core_callback.callback);
8080                                                    let refany_clone = core_callback.refany.clone();
8081                                                    let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
8082                                                        (callback.cb)(refany_clone, gl_callback_info)
8083                                                    }));
8084                                                    result.ok()
8085                                                }
8086                                            }
8087                                            _ => None,
8088                                        }
8089                                    } else {
8090                                        callback_result.map(|core_callback| {
8091                                            // Convert from CoreImageCallback (cb: usize) to
8092                                            // RenderImageCallback (cb: fn pointer)
8093                                            let callback =
8094                                                RenderImageCallback::from_core(&core_callback.callback);
8095                                            (callback.cb)(
8096                                                core_callback.refany.clone(),
8097                                                gl_callback_info,
8098                                            )
8099                                        })
8100                                    }
8101                                }
8102                                _ => None,
8103                            }
8104                        }
8105                        None => None,
8106                    }
8107                };
8108
8109                // Reset GL state after callback
8110                #[cfg(feature = "gl_context_loader")]
8111                if let Some(gl) = gl_context.as_ref() {
8112                    use gl_context_loader::gl;
8113                    gl.bind_framebuffer(gl::FRAMEBUFFER, 0);
8114                    gl.disable(gl::FRAMEBUFFER_SRGB);
8115                    gl.disable(gl::MULTISAMPLE);
8116                }
8117
8118                // Extract the texture from the returned ImageRef
8119                if let Some(image_ref) = new_image_ref {
8120                    if let Some(azul_core::resources::DecodedImage::Gl(texture)) = image_ref.into_inner() {
8121                        updated_textures.push((*dom_id, *node_id, texture));
8122                    }
8123                }
8124            }
8125        }
8126
8127        updated_textures
8128    }
8129
8130    /// Check if a scrolled node is a `VirtualView` that needs re-invocation. If so,
8131    /// queue it in `pending_virtual_view_updates` for processing before the next frame.
8132    ///
8133    /// This is the bridge between the scroll system and the `VirtualView` lifecycle:
8134    ///   `ScrollTo` → `scroll_manager.scroll_to()` → `check_and_queue_virtual_view_reinvoke()`
8135    ///
8136    /// Returns `true` if a `VirtualView` update was queued (caller should trigger a
8137    /// display list rebuild instead of a lightweight repaint).
8138    pub fn check_and_queue_virtual_view_reinvoke(
8139        &mut self,
8140        dom_id: DomId,
8141        node_id: NodeId,
8142    ) -> bool {
8143        // Get the VirtualView's current layout bounds (needed for check_reinvoke)
8144        let Some(bounds) = Self::get_virtual_view_bounds_from_layout(
8145            &self.layout_results,
8146            dom_id,
8147            node_id,
8148        ) else {
8149            return false; // Not a VirtualView or no layout info
8150        };
8151
8152        // Ask the VirtualViewManager whether this VirtualView needs re-invocation
8153        let reason = self.virtual_view_manager.check_reinvoke(
8154            dom_id, node_id, &self.scroll_manager, bounds,
8155        );
8156
8157        if let Some(reason) = reason {
8158            // Queue the VirtualView for re-invocation in the next render
8159            // pass, KEEPING the queue-time reason (MWA-C-virtual_view).
8160            self.pending_virtual_view_updates
8161                .entry(dom_id)
8162                .or_default()
8163                .insert(node_id, reason);
8164            true
8165        } else {
8166            false
8167        }
8168    }
8169
8170    /// Process `VirtualView` updates requested by callbacks
8171    ///
8172    /// This method handles manual `VirtualView` re-rendering triggered by `trigger_virtual_view_rerender()`.
8173    /// It invokes the `VirtualView` callback with `DomRecreated` reason and performs layout on the
8174    /// returned DOM, then submits a new display list to `WebRender` for that pipeline.
8175    ///
8176    /// # Arguments
8177    ///
8178    /// * `vviews_to_update` - Map of `DomId` -> Set of `NodeIds` that need re-rendering
8179    /// * `window_state` - Current window state
8180    /// * `renderer_resources` - Renderer resources
8181    /// * `system_callbacks` - External system callbacks
8182    ///
8183    /// # Returns
8184    ///
8185    /// Vector of (`DomId`, `NodeId`) tuples for `VirtualViews` that were successfully updated
8186    pub fn process_virtual_view_updates(
8187        &mut self,
8188        vviews_to_update: &BTreeMap<DomId, BTreeMap<NodeId, VirtualViewCallbackReason>>,
8189        window_state: &FullWindowState,
8190        renderer_resources: &RendererResources,
8191        system_callbacks: &ExternalSystemCallbacks,
8192    ) -> Vec<(DomId, NodeId)> {
8193        let mut updated_vviews = Vec::new();
8194
8195        for (dom_id, node_ids) in vviews_to_update {
8196            for (node_id, reason) in node_ids {
8197                // Extract virtualized view bounds from layout result
8198                let Some(bounds) = Self::get_virtual_view_bounds_from_layout(
8199                    &self.layout_results,
8200                    *dom_id,
8201                    *node_id,
8202                ) else {
8203                    continue;
8204                };
8205
8206                // MWA-C-virtual_view: stage the queue-time reason so the
8207                // invoke delivers it to the user callback — the old
8208                // force_reinvoke (clear was_invoked) collapsed everything to
8209                // InitialRender at delivery.
8210                self.virtual_view_manager
8211                    .set_reason_override(*dom_id, *node_id, *reason);
8212
8213                // Invoke the VirtualView callback
8214                if let Some(_child_dom_id) = self.invoke_virtual_view_callback(
8215                    *dom_id,
8216                    *node_id,
8217                    bounds,
8218                    window_state,
8219                    renderer_resources,
8220                    system_callbacks,
8221                    &mut None,
8222                ) {
8223                    updated_vviews.push((*dom_id, *node_id));
8224                }
8225            }
8226        }
8227
8228        updated_vviews
8229    }
8230
8231    /// Queue `VirtualView` updates to be processed in the next frame
8232    ///
8233    /// This is called after callbacks to store the `vviews_to_update` from callback changes
8234    pub fn queue_virtual_view_updates(
8235        &mut self,
8236        vviews_to_update: BTreeMap<DomId, FastBTreeSet<NodeId>>,
8237    ) {
8238        // MWA-C-virtual_view: programmatic re-renders
8239        // (trigger_virtual_view_rerender / trigger_all_virtual_view_rerender,
8240        // e.g. map-tile writebacks) deliver DomRecreated — the reason the
8241        // docs always claimed but which previously had ZERO producers. A
8242        // scroll-queued reason for the same node is not overwritten (it is
8243        // more specific).
8244        for (dom_id, node_ids) in vviews_to_update {
8245            let entry = self.pending_virtual_view_updates.entry(dom_id).or_default();
8246            for node_id in node_ids {
8247                entry
8248                    .entry(node_id)
8249                    .or_insert(VirtualViewCallbackReason::DomRecreated);
8250            }
8251        }
8252    }
8253
8254    /// Queue EVERY known `VirtualView` for re-invocation on the EXISTING DOM (no
8255    /// `RefreshDom` / DOM rebuild). Used when a shared dataset was mutated
8256    /// out-of-band — e.g. a background `MapWidget` tile-fetch writeback updated
8257    /// the cache that the `VirtualView`'s `refany` clone points at. Re-invoking in
8258    /// place keeps the content callback reading the same underlying data the
8259    /// worker threads write to; a `RefreshDom` would rebuild the DOM, allocate a
8260    /// fresh dataset, and orphan the workers' clone (so later tiles would never
8261    /// reach the rendered view).
8262    pub fn queue_all_virtual_view_reinvoke(&mut self) {
8263        let mut updates: BTreeMap<DomId, FastBTreeSet<NodeId>> = BTreeMap::new();
8264        for (dom_id, node_id) in self.virtual_view_manager.all_view_keys() {
8265            updates
8266                .entry(dom_id)
8267                .or_default()
8268                .insert(node_id);
8269        }
8270        self.queue_virtual_view_updates(updates);
8271    }
8272
8273    /// Process and clear pending `VirtualView` updates
8274    ///
8275    /// This is called during frame generation to re-render updated `VirtualViews`
8276    pub fn process_pending_virtual_view_updates(
8277        &mut self,
8278        window_state: &FullWindowState,
8279        renderer_resources: &RendererResources,
8280        system_callbacks: &ExternalSystemCallbacks,
8281    ) -> Vec<(DomId, NodeId)> {
8282        if self.pending_virtual_view_updates.is_empty() {
8283            return Vec::new();
8284        }
8285
8286        // Take ownership of pending updates
8287        let vviews_to_update = core::mem::take(&mut self.pending_virtual_view_updates);
8288
8289        // Process them
8290        let updated = self.process_virtual_view_updates(
8291            &vviews_to_update,
8292            window_state,
8293            renderer_resources,
8294            system_callbacks,
8295        );
8296
8297        // An in-place rebuild gives each child DOM FRESH NodeIds with no
8298        // reconcile mapping. Any hover/hit state recorded against the old
8299        // generation is now dangling — resolving it against the new styled DOM
8300        // reads out of bounds (hit_test.rs cursor panic while panning the map)
8301        // or targets the wrong node. Purge the rebuilt children's hits; the
8302        // next pointer move re-populates them from a fresh hit test.
8303        for (parent_dom, node_id) in &updated {
8304            if let Some(child_dom) = self
8305                .virtual_view_manager
8306                .get_nested_dom_id(*parent_dom, *node_id)
8307            {
8308                self.hover_manager.purge_dom(&child_dom);
8309            }
8310        }
8311
8312        updated
8313    }
8314
8315    /// Helper: Extract `VirtualView` bounds from layout results
8316    ///
8317    /// Returns None if the node is not a `VirtualView` or doesn't have layout info
8318    fn get_virtual_view_bounds_from_layout(
8319        layout_results: &BTreeMap<DomId, DomLayoutResult>,
8320        dom_id: DomId,
8321        node_id: NodeId,
8322    ) -> Option<LogicalRect> {
8323        let layout_result = layout_results.get(&dom_id)?;
8324
8325        // Check if this is a VirtualView node
8326        let node_data_container = layout_result.styled_dom.node_data.as_container();
8327        let node_data = node_data_container.get(node_id)?;
8328
8329        if !matches!(node_data.get_node_type(), NodeType::VirtualView) {
8330            return None;
8331        }
8332
8333        // Get layout indices
8334        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
8335        if layout_indices.is_empty() {
8336            return None;
8337        }
8338
8339        let layout_index = layout_indices[0];
8340
8341        // Get position
8342        let position = *layout_result.calculated_positions.get(layout_index)?;
8343
8344        // Get size
8345        let layout_node = layout_result.layout_tree.get(layout_index)?;
8346        let size = layout_node.used_size?;
8347
8348        Some(LogicalRect::new(
8349            position,
8350            LogicalSize::new(size.width, size.height),
8351        ))
8352    }
8353}
8354
8355#[cfg(feature = "a11y")]
8356#[derive(Debug, Clone)]
8357pub enum TextEditType {
8358    ReplaceSelection(String),
8359    SetValue(String),
8360    SetNumericValue(f64),
8361}
8362
8363// ============================================================================
8364// NodeId remapping after DOM reconciliation — the single driver
8365// ============================================================================
8366
8367impl LayoutWindow {
8368    /// Rewrite every `NodeId`-keyed piece of window state onto the rebuilt DOM
8369    /// and garbage-collect the state of unmounted nodes.
8370    ///
8371    /// This is THE place a DOM rebuild is folded into the managers. It is called
8372    /// once, from `regenerate_layout`, with the `NodeIdMap` built from
8373    /// `diff::reconcile_dom`'s `node_moves`.
8374    ///
8375    /// # Why this function destructures `Self` exhaustively
8376    ///
8377    /// A `NodeId` is an arena index. Deleting a node renumbers its following
8378    /// siblings, so a manager that is not remapped does not dangle — it points at
8379    /// a **live but wrong** node, and misbehaves silently. The failure has no
8380    /// panic and no error to grep for, so the only durable defence is to make it
8381    /// impossible to forget: the `let Self { .. }` below lists EVERY field with no
8382    /// `..` rest-pattern, so **adding a field to `LayoutWindow` fails to compile
8383    /// until it is classified here** as either node-keyed (remap it) or exempt
8384    /// (with a reason).
8385    ///
8386    /// New node-keyed managers should implement [`crate::managers::NodeIdRemap`]
8387    /// and be driven from here.
8388    #[allow(clippy::too_many_lines)]
8389    pub fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
8390        use crate::managers::NodeIdRemap;
8391
8392        let Self {
8393            // --- NODE-KEYED: managers implementing `NodeIdRemap` -------------
8394            scroll_manager,
8395            gesture_drag_manager,
8396            focus_manager,
8397            text_edit_manager,
8398            hover_manager,
8399            virtual_view_manager,
8400            gpu_state_manager,
8401            text_input_manager,
8402            undo_redo_manager,
8403            permission_manager,
8404
8405            // --- NODE-KEYED: plain caches owned directly by the window -------
8406            text_constraints_cache,
8407            dirty_text_nodes,
8408            pending_virtual_view_updates,
8409            gl_texture_cache,
8410            currently_dragging_thumb,
8411
8412            // --- EXEMPT: not keyed by NodeId ---------------------------------
8413            // Rebuilt wholesale by the very layout pass that triggered this remap:
8414            // Exempt: damage rects + frame counters only, keyed by nothing.
8415            frame_report: _,
8416            frame_report_reset_request: _,
8417            layout_cache: _,
8418            layout_results: _,
8419            // Content-addressed (hashes / font ids / image ids), never NodeIds:
8420            text_cache: _,
8421            font_manager: _,
8422            image_cache: _,
8423            cpu_image_callback_results: _,
8424            renderer_resources: _,
8425            // Derived per frame from the CURRENT StyledDom (a11y tree is rebuilt
8426            // from scratch in `A11yManager::build_tree_update`), so it cannot go stale:
8427            a11y_manager: _,
8428            // Capability/device-keyed, not node-keyed (their only DomNodeId is an
8429            // event target that defaults to the root):
8430            geolocation_manager: _,
8431            biometric_manager: _,
8432            keyring_manager: _,
8433            sensor_manager: _,
8434            gamepad_manager: _,
8435            // Payload-only state (file paths / clipboard contents), no NodeIds:
8436            file_drop_manager: _,
8437            clipboard_manager: _,
8438            // Plain window/render state, no NodeIds:
8439            // The E2E mount override is an XML source string + a dirty flag:
8440            skip_gpu_sync: _,
8441            e2e_mount: _,
8442            #[cfg(feature = "e2e-server")]
8443            e2e_scratch: _,
8444            #[cfg(feature = "pdf")]
8445            fragmentation_context: _,
8446            safe_area_insets: _,
8447            timers: _,
8448            threads: _,
8449            renderer_type: _,
8450            previous_window_state: _,
8451            current_window_state: _,
8452            document_id: _,
8453            id_namespace: _,
8454            epoch: _,
8455            system_style: _,
8456            monitors: _,
8457            font_stacks_hash: _,
8458            pre_preedit_content: _,
8459            input_interpreter: _,
8460            post_filter: _,
8461            routes: _,
8462            #[cfg(feature = "icu")]
8463            icu_localizer: _,
8464            // Lifecycle events carry NodeIds, but they are produced BY this very
8465            // reconciliation and are already expressed in NEW ids (Mount/Update/
8466            // Resize), or deliberately in OLD ids resolved before the swap
8467            // (BeforeUnmount, see `pending_unmount_invocations`). Remapping them
8468            // here would corrupt them.
8469            pending_lifecycle_events: _,
8470            pending_unmount_invocations: _,
8471        } = self;
8472
8473        scroll_manager.remap_node_ids(dom, map);
8474        gesture_drag_manager.remap_node_ids(dom, map);
8475        focus_manager.remap_node_ids(dom, map);
8476        text_edit_manager.remap_node_ids(dom, map);
8477        hover_manager.remap_node_ids(dom, map);
8478        virtual_view_manager.remap_node_ids(dom, map);
8479        gpu_state_manager.remap_node_ids(dom, map);
8480        text_input_manager.remap_node_ids(dom, map);
8481        undo_redo_manager.remap_node_ids(dom, map);
8482        permission_manager.remap_node_ids(dom, map);
8483
8484        // Window-owned caches (same contract: absent from `map` == unmounted).
8485        crate::managers::remap_dom_keys(&mut text_constraints_cache.constraints, dom, map);
8486        crate::managers::remap_dom_keys(dirty_text_nodes, dom, map);
8487
8488        if let Some(pending) = pending_virtual_view_updates.remove(&dom) {
8489            let remapped: BTreeMap<NodeId, _> = pending
8490                .into_iter()
8491                .filter_map(|(node_id, reason)| Some((map.resolve(node_id)?, reason)))
8492                .collect();
8493            if !remapped.is_empty() {
8494                pending_virtual_view_updates.insert(dom, remapped);
8495            }
8496        }
8497
8498        if let Some(textures) = gl_texture_cache.solved_textures.remove(&dom) {
8499            let remapped: BTreeMap<NodeId, _> = textures
8500                .into_iter()
8501                .filter_map(|(node_id, tex)| Some((map.resolve(node_id)?, tex)))
8502                .collect();
8503            gl_texture_cache.solved_textures.insert(dom, remapped);
8504        }
8505        let hashes = core::mem::take(&mut gl_texture_cache.hashes);
8506        gl_texture_cache.hashes = hashes
8507            .into_iter()
8508            .filter_map(|((d, node_id, image_hash), v)| {
8509                if d != dom {
8510                    return Some(((d, node_id, image_hash), v));
8511                }
8512                Some(((d, map.resolve(node_id)?, image_hash), v))
8513            })
8514            .collect();
8515
8516        // An in-flight scrollbar-thumb drag holds the NodeId of its scroll
8517        // container; if that node is gone the drag must end, not retarget.
8518        if let Some(drag) = currently_dragging_thumb.as_ref() {
8519            match remap_scrollbar_hit_id(drag.hit_id, dom, map) {
8520                Some(new_id) => {
8521                    if let Some(d) = currently_dragging_thumb.as_mut() {
8522                        d.hit_id = new_id;
8523                    }
8524                }
8525                None => *currently_dragging_thumb = None,
8526            }
8527        }
8528    }
8529}
8530
8531/// Remap the `NodeId` inside a `ScrollbarHitId`. `None` = the scroll container
8532/// was unmounted (drop the state); ids from other DOMs pass through.
8533fn remap_scrollbar_hit_id(
8534    id: ScrollbarHitId,
8535    dom: DomId,
8536    map: &crate::managers::NodeIdMap,
8537) -> Option<ScrollbarHitId> {
8538    Some(match id {
8539        ScrollbarHitId::VerticalTrack(d, n) if d == dom => {
8540            ScrollbarHitId::VerticalTrack(d, map.resolve(n)?)
8541        }
8542        ScrollbarHitId::VerticalThumb(d, n) if d == dom => {
8543            ScrollbarHitId::VerticalThumb(d, map.resolve(n)?)
8544        }
8545        ScrollbarHitId::HorizontalTrack(d, n) if d == dom => {
8546            ScrollbarHitId::HorizontalTrack(d, map.resolve(n)?)
8547        }
8548        ScrollbarHitId::HorizontalThumb(d, n) if d == dom => {
8549            ScrollbarHitId::HorizontalThumb(d, map.resolve(n)?)
8550        }
8551        other => other,
8552    })
8553}
8554
8555/// Adversarial unit tests generated for `layout/src/window.rs`.
8556///
8557/// Inline (rather than in `tests/`) so the private helpers — `duration_to_millis`,
8558/// `remap_scrollbar_hit_id`, `calculate_*_scroll_delta`,
8559/// `LayoutWindow::calculate_scrollbar_opacity`, `FrameReport::merge_into` — are
8560/// reachable. Every test here is deterministic: no wall-clock reads, no font
8561/// loading, no solver passes.
8562#[cfg(test)]
8563#[allow(clippy::float_cmp, clippy::unreadable_literal)]
8564mod autotest_generated {
8565    use super::*;
8566
8567    // ------------------------------------------------------------------
8568    // Shared helpers
8569    // ------------------------------------------------------------------
8570
8571    fn pos(x: f32, y: f32) -> LogicalPosition {
8572        LogicalPosition::new(x, y)
8573    }
8574
8575    fn size(w: f32, h: f32) -> LogicalSize {
8576        LogicalSize::new(w, h)
8577    }
8578
8579    fn rect(x: f32, y: f32, w: f32, h: f32) -> LogicalRect {
8580        LogicalRect::new(pos(x, y), size(w, h))
8581    }
8582
8583    fn tick(n: u64) -> Instant {
8584        Instant::Tick(azul_core::task::SystemTick { tick_counter: n })
8585    }
8586
8587    fn tick_dur(n: u64) -> Duration {
8588        Duration::Tick(SystemTickDiff { tick_diff: n })
8589    }
8590
8591    fn sys_dur_ms(ms: u64) -> Duration {
8592        Duration::System(SystemTimeDiff::from_millis(ms))
8593    }
8594
8595    fn fresh_window() -> LayoutWindow {
8596        LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new must succeed")
8597    }
8598
8599    /// A `DomLayoutResult` carrying a real `StyledDom` but an *empty* layout
8600    /// tree / display list — enough for every hierarchy + scan getter, and it
8601    /// needs no fonts and no solver pass.
8602    fn bare_layout_result(styled_dom: StyledDom) -> DomLayoutResult {
8603        DomLayoutResult {
8604            styled_dom,
8605            layout_tree: LayoutTree {
8606                nodes: Vec::new(),
8607                warm: Vec::new(),
8608                cold: Vec::new(),
8609                root: 0,
8610                dom_to_layout: BTreeMap::new(),
8611                children_arena: Vec::new(),
8612                children_offsets: Vec::new(),
8613                subtree_needs_intrinsic: Vec::new(),
8614            },
8615            calculated_positions: Vec::new(),
8616            viewport: LogicalRect::zero(),
8617            display_list: DisplayList::default(),
8618            scroll_ids: HashMap::new(),
8619            scroll_id_to_node_id: HashMap::new(),
8620        }
8621    }
8622
8623    /// `body` + three sibling `div`s => 4 nodes, pre-order, body first.
8624    fn fixture_dom() -> StyledDom {
8625        StyledDom::create_from_dom(
8626            Dom::create_body()
8627                .with_child(Dom::create_div())
8628                .with_child(Dom::create_div())
8629                .with_child(Dom::create_div()),
8630        )
8631    }
8632
8633    fn window_with_fixture() -> LayoutWindow {
8634        let mut w = fresh_window();
8635        w.layout_results
8636            .insert(DomId::ROOT_ID, bare_layout_result(fixture_dom()));
8637        w
8638    }
8639
8640    fn dnid(index: usize) -> DomNodeId {
8641        DomNodeId {
8642            dom: DomId::ROOT_ID,
8643            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(index))),
8644        }
8645    }
8646
8647    /// Every "this id cannot possibly resolve" shape we want the getters to
8648    /// survive: the encoded `None`, a plausible-but-stale index, and the
8649    /// largest id the 1-based encoding can even represent.
8650    fn hostile_node_ids() -> Vec<NodeHierarchyItemId> {
8651        vec![
8652            NodeHierarchyItemId::NONE,
8653            NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(999_999))),
8654            // NOT `from_crate_internal(Some(NodeId::new(usize::MAX)))` — that
8655            // overflows the 1-based encode (`inner + 1`) and panics in debug.
8656            NodeHierarchyItemId::from_raw(usize::MAX),
8657        ]
8658    }
8659
8660    // ==================================================================
8661    // new_document_id / new_id_namespace  (unique-id generators)
8662    // ==================================================================
8663
8664    #[test]
8665    fn new_document_id_is_strictly_monotonic_and_carries_a_fresh_namespace() {
8666        let a = new_document_id();
8667        let b = new_document_id();
8668        assert_ne!(a, b, "two DocumentIds must never be equal");
8669        assert!(b.id > a.id, "the counter is fetch_add, so it must increase");
8670        assert_ne!(
8671            a.namespace_id, b.namespace_id,
8672            "each DocumentId burns a fresh IdNamespace"
8673        );
8674    }
8675
8676    #[test]
8677    fn new_id_namespace_is_strictly_monotonic() {
8678        let a = new_id_namespace();
8679        let b = new_id_namespace();
8680        let c = new_id_namespace();
8681        assert!(a.0 < b.0 && b.0 < c.0);
8682    }
8683
8684    // ==================================================================
8685    // FrameDamage — predicates + getters
8686    // ==================================================================
8687
8688    #[test]
8689    fn frame_damage_default_is_none() {
8690        assert_eq!(FrameDamage::default(), FrameDamage::None);
8691        assert!(FrameDamage::default().is_none());
8692        assert!(!FrameDamage::default().is_full());
8693    }
8694
8695    #[test]
8696    fn frame_damage_is_none_and_is_full_are_mutually_exclusive() {
8697        let cases = [
8698            FrameDamage::None,
8699            FrameDamage::Full,
8700            FrameDamage::Rects(Vec::new()),
8701            FrameDamage::Rects(vec![rect(0.0, 0.0, 1.0, 1.0)]),
8702        ];
8703        for d in &cases {
8704            assert!(
8705                !(d.is_none() && d.is_full()),
8706                "no variant may be both none and full: {d:?}"
8707            );
8708        }
8709        assert!(FrameDamage::None.is_none());
8710        assert!(!FrameDamage::None.is_full());
8711        assert!(FrameDamage::Full.is_full());
8712        assert!(!FrameDamage::Full.is_none());
8713        // An EMPTY rect list is deliberately not `is_none()` — it is still an
8714        // incremental repaint, just one with nothing in it.
8715        assert!(!FrameDamage::Rects(Vec::new()).is_none());
8716        assert!(!FrameDamage::Rects(Vec::new()).is_full());
8717    }
8718
8719    #[test]
8720    fn frame_damage_rect_count_matches_documented_table() {
8721        assert_eq!(FrameDamage::None.rect_count(), 0);
8722        assert_eq!(FrameDamage::Full.rect_count(), 1);
8723        assert_eq!(FrameDamage::Rects(Vec::new()).rect_count(), 0);
8724        assert_eq!(
8725            FrameDamage::Rects(vec![rect(0.0, 0.0, 1.0, 1.0); 3]).rect_count(),
8726            3
8727        );
8728        // A huge list must not overflow / mis-count.
8729        assert_eq!(
8730            FrameDamage::Rects(vec![LogicalRect::zero(); 4096]).rect_count(),
8731            4096
8732        );
8733    }
8734
8735    #[test]
8736    fn frame_damage_rects_is_some_only_for_the_rects_variant() {
8737        assert!(FrameDamage::None.rects().is_none());
8738        assert!(FrameDamage::Full.rects().is_none());
8739        let empty: &[LogicalRect] = &[];
8740        assert_eq!(FrameDamage::Rects(Vec::new()).rects(), Some(empty));
8741        let r = rect(1.0, 2.0, 3.0, 4.0);
8742        assert_eq!(FrameDamage::Rects(vec![r]).rects(), Some(&[r][..]));
8743        // `rects()` and `rect_count()` must never disagree.
8744        for d in [
8745            FrameDamage::None,
8746            FrameDamage::Full,
8747            FrameDamage::Rects(Vec::new()),
8748            FrameDamage::Rects(vec![r, r]),
8749        ] {
8750            if let Some(slice) = d.rects() {
8751                assert_eq!(slice.len(), d.rect_count());
8752            }
8753        }
8754    }
8755
8756    // ==================================================================
8757    // FrameDamage::area — numeric edges
8758    // ==================================================================
8759
8760    #[test]
8761    fn frame_damage_area_none_is_always_exactly_zero() {
8762        for window_area in [0.0, 1.0, -1.0, f32::MAX, f32::MIN, f32::INFINITY] {
8763            let a = FrameDamage::None.area(window_area);
8764            assert_eq!(a, 0.0, "None must swallow window_area={window_area}");
8765        }
8766        // Even a NaN window area must not leak out of the `None` arm.
8767        let a = FrameDamage::None.area(f32::NAN);
8768        assert!(!a.is_nan() && a == 0.0);
8769    }
8770
8771    #[test]
8772    fn frame_damage_area_full_passes_window_area_through_verbatim() {
8773        assert_eq!(FrameDamage::Full.area(0.0), 0.0);
8774        assert_eq!(FrameDamage::Full.area(1920.0 * 1080.0), 2_073_600.0);
8775        // Documented as "the full window_area passed in" — including garbage.
8776        assert_eq!(FrameDamage::Full.area(-5.0), -5.0);
8777        assert_eq!(FrameDamage::Full.area(f32::INFINITY), f32::INFINITY);
8778        assert!(FrameDamage::Full.area(f32::NAN).is_nan());
8779    }
8780
8781    #[test]
8782    fn frame_damage_area_of_rects_ignores_window_area_and_sums_products() {
8783        assert_eq!(FrameDamage::Rects(Vec::new()).area(999.0), 0.0);
8784        let d = FrameDamage::Rects(vec![rect(0.0, 0.0, 10.0, 10.0), rect(50.0, 50.0, 2.0, 3.0)]);
8785        assert_eq!(d.area(1.0), 106.0);
8786        assert_eq!(d.area(f32::MAX), 106.0, "window_area is unused for Rects");
8787    }
8788
8789    #[test]
8790    fn frame_damage_area_of_degenerate_rects_is_defined_not_panicking() {
8791        // Zero-size rect contributes nothing.
8792        assert_eq!(FrameDamage::Rects(vec![rect(5.0, 5.0, 0.0, 0.0)]).area(1.0), 0.0);
8793        // Negative extents produce a negative "area" rather than being clamped
8794        // — pinned so a future clamp is a deliberate change, not a silent one.
8795        assert_eq!(
8796            FrameDamage::Rects(vec![rect(0.0, 0.0, -10.0, 10.0)]).area(1.0),
8797            -100.0
8798        );
8799        // Overflow saturates to +inf (f32 semantics), it does not panic.
8800        let huge = FrameDamage::Rects(vec![rect(0.0, 0.0, f32::MAX, f32::MAX)]);
8801        assert!(huge.area(1.0).is_infinite() && huge.area(1.0) > 0.0);
8802        // NaN propagates instead of poisoning the process.
8803        assert!(FrameDamage::Rects(vec![rect(0.0, 0.0, f32::NAN, 1.0)])
8804            .area(1.0)
8805            .is_nan());
8806    }
8807
8808    // ==================================================================
8809    // FrameDamage::to_present_rects_physical — the presenter contract
8810    // ==================================================================
8811
8812    #[test]
8813    fn present_rects_zero_sized_buffer_is_always_none_even_when_forced() {
8814        for d in [
8815            FrameDamage::None,
8816            FrameDamage::Full,
8817            FrameDamage::Rects(vec![rect(0.0, 0.0, 10.0, 10.0)]),
8818        ] {
8819            assert_eq!(d.to_present_rects_physical(1.0, 0, 100, false), None);
8820            assert_eq!(d.to_present_rects_physical(1.0, 100, 0, false), None);
8821            assert_eq!(d.to_present_rects_physical(1.0, 0, 0, true), None);
8822            // The zero-size guard runs BEFORE force_full.
8823            assert_eq!(d.to_present_rects_physical(1.0, 0, 480, true), None);
8824        }
8825    }
8826
8827    #[test]
8828    fn present_rects_force_full_overrides_every_variant() {
8829        for d in [
8830            FrameDamage::None,
8831            FrameDamage::Full,
8832            FrameDamage::Rects(Vec::new()),
8833            FrameDamage::Rects(vec![rect(1.0, 1.0, 2.0, 2.0)]),
8834        ] {
8835            assert_eq!(
8836                d.to_present_rects_physical(2.0, 640, 480, true),
8837                Some(vec![(0, 0, 640, 480)]),
8838                "force_full must present the whole buffer for {d:?}"
8839            );
8840        }
8841    }
8842
8843    #[test]
8844    fn present_rects_variant_defaults() {
8845        assert_eq!(
8846            FrameDamage::None.to_present_rects_physical(1.0, 800, 600, false),
8847            None,
8848            "None => present nothing"
8849        );
8850        assert_eq!(
8851            FrameDamage::Full.to_present_rects_physical(1.0, 800, 600, false),
8852            Some(vec![(0, 0, 800, 600)])
8853        );
8854        assert_eq!(
8855            FrameDamage::Rects(Vec::new()).to_present_rects_physical(1.0, 800, 600, false),
8856            None,
8857            "an empty rect list is nothing to present"
8858        );
8859    }
8860
8861    #[test]
8862    fn present_rects_round_outward_so_fractional_edges_are_covered() {
8863        // floor(origin) / ceil(far edge): a 1px rect starting at x=0.5 must
8864        // cover 2 physical columns, not 1 (truncation would leave a stale seam).
8865        let d = FrameDamage::Rects(vec![rect(0.5, 0.25, 1.0, 1.5)]);
8866        assert_eq!(
8867            d.to_present_rects_physical(1.0, 100, 100, false),
8868            Some(vec![(0, 0, 2, 2)])
8869        );
8870    }
8871
8872    #[test]
8873    fn present_rects_apply_the_dpi_factor() {
8874        let d = FrameDamage::Rects(vec![rect(1.0, 1.0, 3.0, 3.0)]);
8875        assert_eq!(
8876            d.to_present_rects_physical(2.0, 100, 100, false),
8877            Some(vec![(2, 2, 6, 6)])
8878        );
8879    }
8880
8881    #[test]
8882    fn present_rects_clamp_to_the_buffer_instead_of_wrapping() {
8883        // Far off to the negative side: clamps to the buffer origin.
8884        let d = FrameDamage::Rects(vec![rect(-50.0, -50.0, 100.0, 100.0)]);
8885        assert_eq!(
8886            d.to_present_rects_physical(1.0, 10, 10, false),
8887            Some(vec![(0, 0, 10, 10)])
8888        );
8889        // Entirely past the far edge: degenerate after clamping, so dropped.
8890        let d = FrameDamage::Rects(vec![rect(1000.0, 1000.0, 10.0, 10.0)]);
8891        assert_eq!(d.to_present_rects_physical(1.0, 100, 100, false), None);
8892        // f32::MAX coordinates saturate on the `as i64` cast (no wraparound).
8893        let d = FrameDamage::Rects(vec![rect(f32::MAX, f32::MAX, 10.0, 10.0)]);
8894        assert_eq!(d.to_present_rects_physical(1.0, 100, 100, false), None);
8895    }
8896
8897    #[test]
8898    fn present_rects_drop_degenerate_and_inverted_rects() {
8899        // Zero extent.
8900        assert_eq!(
8901            FrameDamage::Rects(vec![rect(5.0, 5.0, 0.0, 0.0)])
8902                .to_present_rects_physical(1.0, 100, 100, false),
8903            None
8904        );
8905        // Negative extent (x1 < x0).
8906        assert_eq!(
8907            FrameDamage::Rects(vec![rect(50.0, 50.0, -10.0, -10.0)])
8908                .to_present_rects_physical(1.0, 100, 100, false),
8909            None
8910        );
8911        // A good rect next to a degenerate one keeps only the good one.
8912        let d = FrameDamage::Rects(vec![rect(5.0, 5.0, 0.0, 0.0), rect(0.0, 0.0, 4.0, 4.0)]);
8913        assert_eq!(
8914            d.to_present_rects_physical(1.0, 100, 100, false),
8915            Some(vec![(0, 0, 4, 4)])
8916        );
8917    }
8918
8919    #[test]
8920    fn present_rects_collapse_past_sixteen_rects() {
8921        let one = |i: u32| rect(i as f32, 0.0, 1.0, 1.0);
8922        // Exactly the cap: kept individually.
8923        let sixteen = FrameDamage::Rects((0..16).map(one).collect());
8924        let got = sixteen
8925            .to_present_rects_physical(1.0, 100, 100, false)
8926            .expect("16 in-bounds rects must present");
8927        assert_eq!(got.len(), 16);
8928        assert_eq!(got[0], (0, 0, 1, 1));
8929        assert_eq!(got[15], (15, 0, 1, 1));
8930        // One over the cap: bounded cost, one full-buffer rect.
8931        let seventeen = FrameDamage::Rects((0..17).map(one).collect());
8932        assert_eq!(
8933            seventeen.to_present_rects_physical(1.0, 100, 100, false),
8934            Some(vec![(0, 0, 100, 100)])
8935        );
8936        // Way over the cap: still exactly one rect, no O(n) blowup.
8937        let many = FrameDamage::Rects((0..4096).map(|i| one(i % 100)).collect());
8938        assert_eq!(
8939            many.to_present_rects_physical(1.0, 100, 100, false),
8940            Some(vec![(0, 0, 100, 100)])
8941        );
8942    }
8943
8944    #[test]
8945    fn present_rects_survive_nan_and_infinite_dpi() {
8946        let d = FrameDamage::Rects(vec![rect(10.0, 10.0, 20.0, 20.0)]);
8947        // NaN scales every edge to NaN; `NaN as i64` == 0, so the rect collapses
8948        // and is dropped -> nothing to present (rather than a bogus rect).
8949        assert_eq!(d.to_present_rects_physical(f32::NAN, 100, 100, false), None);
8950        // Zero and negative scales likewise collapse.
8951        assert_eq!(d.to_present_rects_physical(0.0, 100, 100, false), None);
8952        assert_eq!(d.to_present_rects_physical(-1.0, 100, 100, false), None);
8953        assert_eq!(
8954            d.to_present_rects_physical(f32::NEG_INFINITY, 100, 100, false),
8955            None
8956        );
8957        // +inf saturates the far edge to the buffer bound rather than wrapping.
8958        let at_origin = FrameDamage::Rects(vec![rect(0.0, 0.0, 10.0, 10.0)]);
8959        assert_eq!(
8960            at_origin.to_present_rects_physical(f32::INFINITY, 100, 100, false),
8961            Some(vec![(0, 0, 100, 100)])
8962        );
8963        assert_eq!(
8964            at_origin.to_present_rects_physical(f32::MAX, 100, 100, false),
8965            Some(vec![(0, 0, 100, 100)])
8966        );
8967    }
8968
8969    #[test]
8970    fn present_rects_never_escape_the_buffer_for_any_input() {
8971        let buf_w = 137_u32;
8972        let buf_h = 71_u32;
8973        let damages = [
8974            FrameDamage::Full,
8975            FrameDamage::Rects(vec![rect(-1e9, -1e9, 2e9, 2e9)]),
8976            FrameDamage::Rects(vec![rect(0.0, 0.0, f32::INFINITY, f32::INFINITY)]),
8977            FrameDamage::Rects(vec![rect(f32::NAN, 0.0, 10.0, 10.0)]),
8978            FrameDamage::Rects(vec![rect(136.9, 70.9, 0.2, 0.2)]),
8979            FrameDamage::Rects(vec![rect(0.0, 0.0, 1.0, 1.0); 40]),
8980        ];
8981        let dpis = [0.0_f32, 0.5, 1.0, 2.0, 3.5, 1e9, f32::NAN, f32::INFINITY, -2.0];
8982        for d in &damages {
8983            for dpi in dpis {
8984                for force in [false, true] {
8985                    if let Some(rects) = d.to_present_rects_physical(dpi, buf_w, buf_h, force) {
8986                        assert!(!rects.is_empty(), "Some(..) must never be empty: {d:?}");
8987                        for (x, y, w, h) in rects {
8988                            assert!(w > 0 && h > 0, "present rects must be non-degenerate");
8989                            assert!(
8990                                x.checked_add(w).is_some_and(|far| far <= buf_w),
8991                                "rect escapes buffer width: {x}+{w} > {buf_w} ({d:?}, dpi={dpi})"
8992                            );
8993                            assert!(
8994                                y.checked_add(h).is_some_and(|far| far <= buf_h),
8995                                "rect escapes buffer height: {y}+{h} > {buf_h} ({d:?}, dpi={dpi})"
8996                            );
8997                        }
8998                    }
8999                }
9000            }
9001        }
9002    }
9003
9004    // ==================================================================
9005    // FrameReport
9006    // ==================================================================
9007
9008    /// A report in sync with generation 0, i.e. no reset pending, so the sync
9009    /// inside `record_frame_at_generation` is a no-op for the test.
9010    fn synced_report() -> FrameReport {
9011        FrameReport::default()
9012    }
9013
9014    /// `record_frame` against a report with no reset pending.
9015    fn record(r: &mut FrameReport, paint: FrameDamage, present: FrameDamage) {
9016        r.record_frame_at_generation(r.reset_generation, paint, present);
9017    }
9018
9019    #[test]
9020    fn frame_report_default_is_all_zero() {
9021        let r = FrameReport::default();
9022        assert_eq!(r.frame_index, 0);
9023        assert_eq!(r.frames_since_reset, 0);
9024        assert_eq!(r.relayout_iterations, 0);
9025        assert_eq!(r.dom_regenerations, 0);
9026        assert_eq!(r.reset_generation, 0);
9027        assert_eq!(r.terminal_result, 0);
9028        assert!(!r.hit_depth_cap);
9029        assert_eq!(r.paint_damage, FrameDamage::None);
9030        assert_eq!(r.present_damage, FrameDamage::None);
9031        assert_eq!(r.accumulated_paint_damage, FrameDamage::None);
9032        assert_eq!(r.accumulated_present_damage, FrameDamage::None);
9033    }
9034
9035    #[test]
9036    fn frame_report_merge_into_full_dominates_and_none_is_neutral() {
9037        let a = rect(0.0, 0.0, 1.0, 1.0);
9038        let b = rect(9.0, 9.0, 2.0, 2.0);
9039
9040        // None is the identity on the right.
9041        for start in [
9042            FrameDamage::None,
9043            FrameDamage::Full,
9044            FrameDamage::Rects(vec![a]),
9045        ] {
9046            let mut acc = start.clone();
9047            FrameReport::merge_into(&mut acc, &FrameDamage::None);
9048            assert_eq!(acc, start, "merging None must not change the accumulator");
9049        }
9050
9051        // Full absorbs everything on the right...
9052        for next in [
9053            FrameDamage::None,
9054            FrameDamage::Full,
9055            FrameDamage::Rects(vec![a]),
9056        ] {
9057            let mut acc = FrameDamage::Full;
9058            FrameReport::merge_into(&mut acc, &next);
9059            assert_eq!(acc, FrameDamage::Full, "Full is absorbing");
9060        }
9061        // ...and on the left.
9062        for start in [FrameDamage::None, FrameDamage::Rects(vec![a])] {
9063            let mut acc = start;
9064            FrameReport::merge_into(&mut acc, &FrameDamage::Full);
9065            assert_eq!(acc, FrameDamage::Full);
9066        }
9067
9068        // None + Rects adopts the rects (by clone, not by aliasing).
9069        let mut acc = FrameDamage::None;
9070        FrameReport::merge_into(&mut acc, &FrameDamage::Rects(vec![a]));
9071        assert_eq!(acc, FrameDamage::Rects(vec![a]));
9072
9073        // Rects + Rects concatenates (no dedup, no union).
9074        FrameReport::merge_into(&mut acc, &FrameDamage::Rects(vec![b, a]));
9075        assert_eq!(acc, FrameDamage::Rects(vec![a, b, a]));
9076    }
9077
9078    #[test]
9079    fn frame_report_merge_into_keeps_empty_rects_distinct_from_none() {
9080        // Merging two empty rect lists stays `Rects([])`, which reports
9081        // `rect_count() == 0` but is NOT `is_none()`.
9082        let mut acc = FrameDamage::Rects(Vec::new());
9083        FrameReport::merge_into(&mut acc, &FrameDamage::Rects(Vec::new()));
9084        assert_eq!(acc, FrameDamage::Rects(Vec::new()));
9085        assert_eq!(acc.rect_count(), 0);
9086        assert!(!acc.is_none());
9087        // But `None` + `Rects([])` also lands on `Rects([])`.
9088        let mut acc = FrameDamage::None;
9089        FrameReport::merge_into(&mut acc, &FrameDamage::Rects(Vec::new()));
9090        assert_eq!(acc, FrameDamage::Rects(Vec::new()));
9091    }
9092
9093    #[test]
9094    fn frame_report_record_frame_keeps_last_frame_and_accumulated_damage_apart() {
9095        let a = rect(0.0, 0.0, 4.0, 4.0);
9096        let mut r = synced_report();
9097
9098        record(&mut r, FrameDamage::Rects(vec![a]), FrameDamage::Full);
9099        assert_eq!(r.frame_index, 1);
9100        assert_eq!(r.frames_since_reset, 1);
9101        assert_eq!(r.paint_damage, FrameDamage::Rects(vec![a]));
9102        assert_eq!(r.present_damage, FrameDamage::Full);
9103        assert_eq!(r.accumulated_paint_damage, FrameDamage::Rects(vec![a]));
9104        assert_eq!(r.accumulated_present_damage, FrameDamage::Full);
9105
9106        // An idle frame clobbers the last-frame damage but must NOT erase the
9107        // accumulated damage — that is the whole point of the sticky counters.
9108        record(&mut r, FrameDamage::None, FrameDamage::None);
9109        assert_eq!(r.frame_index, 2);
9110        assert_eq!(r.frames_since_reset, 2);
9111        assert_eq!(r.paint_damage, FrameDamage::None);
9112        assert_eq!(r.present_damage, FrameDamage::None);
9113        assert_eq!(r.accumulated_paint_damage, FrameDamage::Rects(vec![a]));
9114        assert_eq!(r.accumulated_present_damage, FrameDamage::Full);
9115    }
9116
9117    #[test]
9118    fn frame_report_accumulated_rects_grow_without_dedup() {
9119        let a = rect(0.0, 0.0, 1.0, 1.0);
9120        let mut r = synced_report();
9121        for _ in 0..5 {
9122            record(&mut r, FrameDamage::Rects(vec![a]), FrameDamage::None);
9123        }
9124        // Five identical rects accumulate to five entries: the merge is a
9125        // concatenation, so a long-running window grows this list unboundedly
9126        // until someone calls the reset. Pinned deliberately.
9127        assert_eq!(r.accumulated_paint_damage.rect_count(), 5);
9128        assert_eq!(r.frames_since_reset, 5);
9129    }
9130
9131    #[test]
9132    fn frame_report_counters_saturate_and_wrap_as_documented() {
9133        let mut r = synced_report();
9134        r.frames_since_reset = u32::MAX;
9135        r.frame_index = u64::MAX;
9136
9137        record(&mut r, FrameDamage::None, FrameDamage::None);
9138
9139        // `saturating_add` on the frame counter...
9140        assert_eq!(r.frames_since_reset, u32::MAX);
9141        // ...but `wrapping_add` on the monotonic index (no debug-overflow panic).
9142        assert_eq!(r.frame_index, 0);
9143    }
9144
9145    #[test]
9146    fn frame_report_reset_counters_clears_only_the_sticky_fields() {
9147        let mut r = FrameReport {
9148            frame_index: 42,
9149            terminal_result: 7,
9150            paint_damage: FrameDamage::Full,
9151            present_damage: FrameDamage::Full,
9152            relayout_iterations: 9,
9153            dom_regenerations: 4,
9154            hit_depth_cap: true,
9155            frames_since_reset: 11,
9156            accumulated_paint_damage: FrameDamage::Full,
9157            accumulated_present_damage: FrameDamage::Rects(vec![rect(0.0, 0.0, 1.0, 1.0)]),
9158            ..Default::default()
9159        };
9160
9161        r.reset_counters();
9162
9163        assert_eq!(r.relayout_iterations, 0);
9164        assert_eq!(r.dom_regenerations, 0);
9165        assert!(!r.hit_depth_cap);
9166        assert_eq!(r.frames_since_reset, 0);
9167        assert_eq!(r.accumulated_paint_damage, FrameDamage::None);
9168        assert_eq!(r.accumulated_present_damage, FrameDamage::None);
9169        // Explicitly NOT reset:
9170        assert_eq!(r.frame_index, 42);
9171        assert_eq!(r.terminal_result, 7);
9172        assert_eq!(r.paint_damage, FrameDamage::Full);
9173        assert_eq!(r.present_damage, FrameDamage::Full);
9174
9175        // Idempotent.
9176        r.reset_counters();
9177        assert_eq!(r.frames_since_reset, 0);
9178    }
9179
9180    #[test]
9181    fn frame_report_sync_generation_resets_once_per_request() {
9182        let mut r = synced_report();
9183        r.relayout_iterations = 7;
9184        r.dom_regenerations = 3;
9185        r.hit_depth_cap = true;
9186        r.frames_since_reset = 5;
9187        r.accumulated_paint_damage = FrameDamage::Full;
9188        r.frame_index = 42;
9189        r.paint_damage = FrameDamage::Full;
9190
9191        // No request pending -> no-op.
9192        r.sync_generation_to(0);
9193        assert_eq!(r.relayout_iterations, 7);
9194        assert_eq!(r.accumulated_paint_damage, FrameDamage::Full);
9195
9196        r.sync_generation_to(1);
9197        assert_eq!(r.relayout_iterations, 0);
9198        assert_eq!(r.dom_regenerations, 0);
9199        assert!(!r.hit_depth_cap);
9200        assert_eq!(r.frames_since_reset, 0);
9201        assert_eq!(r.accumulated_paint_damage, FrameDamage::None);
9202        assert_eq!(r.accumulated_present_damage, FrameDamage::None);
9203        // The reset must not disturb the last-frame record or the index.
9204        assert_eq!(r.frame_index, 42);
9205        assert_eq!(r.paint_damage, FrameDamage::Full);
9206        assert_eq!(r.reset_generation, 1);
9207
9208        // A second sync without a new request must NOT re-zero.
9209        r.relayout_iterations = 3;
9210        r.sync_generation_to(1);
9211        assert_eq!(r.relayout_iterations, 3, "sync must fire once per request");
9212    }
9213
9214    /// The READ path an assertion uses. Before this existed, everything between
9215    /// `reset_frame_counters` and the next frame read the counters and the
9216    /// accumulated damage from BEFORE the reset — so an assertion placed right
9217    /// after the reset silently measured the previous checkpoint.
9218    #[test]
9219    fn frame_report_as_of_generation_applies_a_pending_reset_to_readers() {
9220        let mut r = synced_report();
9221        r.relayout_iterations = 4;
9222        r.dom_regenerations = 2;
9223        r.frames_since_reset = 9;
9224        r.accumulated_paint_damage = FrameDamage::Full;
9225        r.paint_damage = FrameDamage::Full;
9226
9227        // Same generation → the reader sees exactly what is stored.
9228        let same = r.as_of_generation(0);
9229        assert_eq!(same, r);
9230
9231        // Reset requested but not yet applied by any writer: the reader must
9232        // still see zeroed counters.
9233        let after = r.as_of_generation(1);
9234        assert_eq!(after.relayout_iterations, 0);
9235        assert_eq!(after.dom_regenerations, 0);
9236        assert_eq!(after.frames_since_reset, 0);
9237        assert_eq!(after.accumulated_paint_damage, FrameDamage::None);
9238        // ...without disturbing the stored report or the last-frame record.
9239        assert_eq!(after.paint_damage, FrameDamage::Full);
9240        assert_eq!(r.relayout_iterations, 4);
9241    }
9242
9243    /// One window's `reset_frame_counters` must not touch another window's
9244    /// counters — that is what lets scenarios that measure frame work run in
9245    /// parallel.
9246    #[test]
9247    fn frame_report_reset_request_is_per_window() {
9248        let mut a = fresh_window();
9249        let mut b = fresh_window();
9250        a.frame_report.relayout_iterations = 3;
9251        b.frame_report.relayout_iterations = 3;
9252
9253        a.request_frame_report_reset();
9254
9255        assert_eq!(a.frame_report_synced().relayout_iterations, 0);
9256        assert_eq!(
9257            b.frame_report_synced().relayout_iterations,
9258            3,
9259            "a reset on window A leaked into window B"
9260        );
9261
9262        a.sync_frame_report();
9263        b.sync_frame_report();
9264        assert_eq!(a.frame_report.relayout_iterations, 0);
9265        assert_eq!(b.frame_report.relayout_iterations, 3);
9266    }
9267
9268    // ==================================================================
9269    // duration_to_millis / default_duration_*
9270    // ==================================================================
9271
9272    #[test]
9273    fn duration_to_millis_system_boundaries() {
9274        assert_eq!(duration_to_millis(sys_dur_ms(0)), 0);
9275        assert_eq!(duration_to_millis(sys_dur_ms(1)), 1);
9276        assert_eq!(duration_to_millis(sys_dur_ms(999)), 999);
9277        assert_eq!(duration_to_millis(sys_dur_ms(1_000)), 1_000);
9278        assert_eq!(duration_to_millis(sys_dur_ms(1_500)), 1_500);
9279        // Sub-millisecond durations truncate toward zero.
9280        assert_eq!(
9281            duration_to_millis(Duration::System(SystemTimeDiff {
9282                secs: 0,
9283                nanos: 999_999
9284            })),
9285            0
9286        );
9287        assert_eq!(
9288            duration_to_millis(Duration::System(SystemTimeDiff {
9289                secs: 0,
9290                nanos: 1_000_000
9291            })),
9292            1
9293        );
9294    }
9295
9296    #[test]
9297    fn duration_to_millis_at_the_top_of_the_u64_range() {
9298        // `from_millis(u64::MAX)` normalises to secs/nanos and must round-trip
9299        // back to exactly u64::MAX millis (no truncation, no panic).
9300        assert_eq!(duration_to_millis(sys_dur_ms(u64::MAX)), u64::MAX);
9301        // The largest *normalised* SystemTimeDiff must not panic either — the
9302        // std `Duration::new` carry only overflows for nanos >= 1e9.
9303        let max_normalised = Duration::System(SystemTimeDiff {
9304            secs: u64::MAX,
9305            nanos: 999_999_999,
9306        });
9307        let ms = duration_to_millis(max_normalised);
9308        assert_eq!(ms, duration_to_millis(max_normalised), "deterministic");
9309        // as_millis() is u128 (u64::MAX*1000 + 999); the `as u64` narrowing
9310        // wraps back onto u64::MAX exactly. Pinned so a future widening/clamp
9311        // is a deliberate change.
9312        assert_eq!(ms, u64::MAX);
9313    }
9314
9315    #[test]
9316    fn duration_to_millis_tick_boundaries() {
9317        // A TICK IS A FRAME, not a millisecond. At the nominal 60 Hz that is
9318        // 1000/60 = 16.67 ms, floored to 16. This test asserted 1 ms per tick,
9319        // which is the bug the tick unit was introduced to remove: every timer
9320        // built in ticks asked the OS to wake ~16x more often than it could
9321        // possibly fire, and the gesture thresholds compared frame counts
9322        // against millisecond limits.
9323        assert_eq!(duration_to_millis(tick_dur(0)), 0);
9324        assert_eq!(duration_to_millis(tick_dur(1)), 16);
9325        assert_eq!(duration_to_millis(tick_dur(u64::MAX)), u64::MAX);
9326    }
9327
9328    #[test]
9329    fn default_durations_are_the_advertised_500ms_and_200ms() {
9330        assert_eq!(default_duration_500ms(), sys_dur_ms(500));
9331        assert_eq!(default_duration_200ms(), sys_dur_ms(200));
9332        assert_eq!(duration_to_millis(default_duration_500ms()), 500);
9333        assert_eq!(duration_to_millis(default_duration_200ms()), 200);
9334        assert_ne!(default_duration_500ms(), default_duration_200ms());
9335        assert!(default_duration_500ms().greater_than(&default_duration_200ms()));
9336    }
9337
9338    // ==================================================================
9339    // calculate_edge_distance
9340    // ==================================================================
9341
9342    #[test]
9343    fn edge_distance_for_a_rect_fully_inside_its_container() {
9344        let d = calculate_edge_distance(rect(10.0, 10.0, 20.0, 20.0), rect(0.0, 0.0, 100.0, 100.0));
9345        assert_eq!(d.left, 10.0);
9346        assert_eq!(d.right, 70.0);
9347        assert_eq!(d.top, 10.0);
9348        assert_eq!(d.bottom, 70.0);
9349    }
9350
9351    #[test]
9352    fn edge_distance_clamps_negative_overhang_to_zero() {
9353        // Rect hangs off the top-left.
9354        let d = calculate_edge_distance(rect(-50.0, -50.0, 10.0, 10.0), rect(0.0, 0.0, 100.0, 100.0));
9355        assert_eq!(d.left, 0.0);
9356        assert_eq!(d.top, 0.0);
9357        assert_eq!(d.right, 140.0);
9358        assert_eq!(d.bottom, 140.0);
9359        // Rect dwarfs the container.
9360        let d = calculate_edge_distance(rect(0.0, 0.0, 1000.0, 1000.0), rect(0.0, 0.0, 100.0, 100.0));
9361        assert_eq!(d.left, 0.0);
9362        assert_eq!(d.top, 0.0);
9363        assert_eq!(d.right, 0.0);
9364        assert_eq!(d.bottom, 0.0);
9365    }
9366
9367    #[test]
9368    fn edge_distance_never_returns_nan_or_a_negative_number() {
9369        let hostile = [
9370            (rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN), rect(0.0, 0.0, 100.0, 100.0)),
9371            (rect(0.0, 0.0, 10.0, 10.0), rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN)),
9372            (
9373                rect(f32::INFINITY, f32::INFINITY, 1.0, 1.0),
9374                rect(0.0, 0.0, 100.0, 100.0),
9375            ),
9376            (
9377                rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
9378                rect(0.0, 0.0, f32::INFINITY, f32::INFINITY),
9379            ),
9380            (LogicalRect::zero(), LogicalRect::zero()),
9381            (
9382                rect(f32::MIN, f32::MIN, f32::MAX, f32::MAX),
9383                rect(f32::MAX, f32::MAX, f32::MIN, f32::MIN),
9384            ),
9385        ];
9386        for (r, c) in hostile {
9387            let d = calculate_edge_distance(r, c);
9388            for (name, v) in [
9389                ("left", d.left),
9390                ("right", d.right),
9391                ("top", d.top),
9392                ("bottom", d.bottom),
9393            ] {
9394                assert!(!v.is_nan(), "{name} is NaN for rect={r:?} container={c:?}");
9395                assert!(v >= 0.0, "{name} is negative ({v}) for rect={r:?}");
9396            }
9397        }
9398        // Specifically: NaN in => 0.0 out (f32::max ignores NaN).
9399        let d = calculate_edge_distance(
9400            rect(f32::NAN, f32::NAN, 1.0, 1.0),
9401            rect(0.0, 0.0, 100.0, 100.0),
9402        );
9403        assert_eq!(d.left, 0.0);
9404        assert_eq!(d.top, 0.0);
9405    }
9406
9407    // ==================================================================
9408    // calculate_instant_scroll_delta
9409    // ==================================================================
9410
9411    #[test]
9412    fn instant_scroll_delta_is_zero_when_bounds_sit_comfortably_inside() {
9413        let d = calculate_instant_scroll_delta(rect(20.0, 20.0, 10.0, 10.0), rect(0.0, 0.0, 100.0, 100.0));
9414        assert_eq!(d, pos(0.0, 0.0));
9415    }
9416
9417    #[test]
9418    fn instant_scroll_delta_pushes_by_the_five_px_padding() {
9419        // Flush against the near edge -> scroll back by PADDING.
9420        let d = calculate_instant_scroll_delta(rect(0.0, 0.0, 10.0, 10.0), rect(0.0, 0.0, 100.0, 100.0));
9421        assert_eq!(d, pos(-5.0, -5.0));
9422        // Partially inside the near padding band.
9423        let d = calculate_instant_scroll_delta(rect(3.0, 3.0, 1.0, 1.0), rect(0.0, 0.0, 100.0, 100.0));
9424        assert_eq!(d, pos(-2.0, -2.0));
9425        // Past the far edge -> scroll forward past it, plus PADDING.
9426        let d = calculate_instant_scroll_delta(rect(95.0, 95.0, 10.0, 10.0), rect(0.0, 0.0, 100.0, 100.0));
9427        assert_eq!(d, pos(10.0, 10.0));
9428    }
9429
9430    #[test]
9431    fn instant_scroll_delta_near_edge_branch_wins_for_an_oversized_rect() {
9432        // The rect overflows BOTH edges; only the near-edge branch may fire
9433        // (they are `if / else if`), so the delta is the near-edge one.
9434        let d = calculate_instant_scroll_delta(rect(0.0, 0.0, 500.0, 500.0), rect(0.0, 0.0, 100.0, 100.0));
9435        assert_eq!(d, pos(-5.0, -5.0));
9436    }
9437
9438    #[test]
9439    fn instant_scroll_delta_with_nan_bounds_is_zero_not_nan() {
9440        let d = calculate_instant_scroll_delta(
9441            rect(f32::NAN, f32::NAN, f32::NAN, f32::NAN),
9442            rect(0.0, 0.0, 100.0, 100.0),
9443        );
9444        assert!(!d.x.is_nan() && !d.y.is_nan(), "NaN must not leak into the scroll delta");
9445        assert_eq!(d, pos(0.0, 0.0));
9446    }
9447
9448    #[test]
9449    fn instant_scroll_delta_saturates_instead_of_panicking_on_huge_bounds() {
9450        let d = calculate_instant_scroll_delta(
9451            rect(f32::MAX, f32::MAX, f32::MAX, f32::MAX),
9452            rect(0.0, 0.0, 100.0, 100.0),
9453        );
9454        assert!(d.x.is_infinite() && d.x > 0.0);
9455        assert!(d.y.is_infinite() && d.y > 0.0);
9456        // A zero-size viewport still yields a finite, deterministic nudge.
9457        let d = calculate_instant_scroll_delta(LogicalRect::zero(), LogicalRect::zero());
9458        assert_eq!(d, pos(-5.0, -5.0));
9459    }
9460
9461    // ==================================================================
9462    // calculate_accelerated_scroll_delta
9463    // ==================================================================
9464
9465    fn edges(left: f32, right: f32, top: f32, bottom: f32) -> EdgeDistance {
9466        EdgeDistance {
9467            left,
9468            right,
9469            top,
9470            bottom,
9471        }
9472    }
9473
9474    #[test]
9475    fn accelerated_scroll_delta_dead_zone_produces_no_movement() {
9476        assert_eq!(
9477            calculate_accelerated_scroll_delta(edges(0.0, 0.0, 0.0, 0.0)),
9478            pos(0.0, 0.0)
9479        );
9480        // Anything strictly inside the 20px dead zone.
9481        assert_eq!(
9482            calculate_accelerated_scroll_delta(edges(19.999, 1000.0, 19.999, 1000.0)),
9483            pos(0.0, 0.0)
9484        );
9485    }
9486
9487    #[test]
9488    fn accelerated_scroll_delta_zone_boundaries_are_exact() {
9489        // The comparisons are `<`, so each boundary value belongs to the NEXT
9490        // (faster) zone.
9491        let cases = [
9492            (19.999_f32, 0.0_f32),
9493            (20.0, -2.0),
9494            (49.999, -2.0),
9495            (50.0, -4.0),
9496            (99.999, -4.0),
9497            (100.0, -8.0),
9498            (199.999, -8.0),
9499            (200.0, -16.0),
9500            (1e9, -16.0),
9501        ];
9502        for (dist, expected_x) in cases {
9503            let d = calculate_accelerated_scroll_delta(edges(dist, f32::MAX, dist, f32::MAX));
9504            assert_eq!(d.x, expected_x, "left={dist}");
9505            assert_eq!(d.y, expected_x, "top={dist}");
9506        }
9507    }
9508
9509    #[test]
9510    fn accelerated_scroll_delta_picks_the_nearer_edge_and_signs_it() {
9511        // Nearer to the left/top -> negative (scroll back).
9512        let d = calculate_accelerated_scroll_delta(edges(30.0, 1000.0, 30.0, 1000.0));
9513        assert_eq!(d, pos(-2.0, -2.0));
9514        // Nearer to the right/bottom -> positive (scroll forward).
9515        let d = calculate_accelerated_scroll_delta(edges(1000.0, 60.0, 1000.0, 60.0));
9516        assert_eq!(d, pos(4.0, 4.0));
9517        // Ties go to the far edge (`<` is false), i.e. positive.
9518        let d = calculate_accelerated_scroll_delta(edges(60.0, 60.0, 60.0, 60.0));
9519        assert_eq!(d, pos(4.0, 4.0));
9520    }
9521
9522    #[test]
9523    fn accelerated_scroll_delta_treats_negative_distances_as_dead_zone() {
9524        let d = calculate_accelerated_scroll_delta(edges(-100.0, 1000.0, -100.0, 1000.0));
9525        assert_eq!(d.x, 0.0);
9526        assert_eq!(d.y, 0.0);
9527    }
9528
9529    #[test]
9530    fn accelerated_scroll_delta_with_nan_distances_falls_into_the_fastest_zone() {
9531        // Every `dist < ZONE` comparison is false for NaN, so the chain falls
9532        // through to VERY_FAST_SPEED. Pinned: NaN edge distances scroll at the
9533        // maximum rate instead of not scrolling.
9534        let d = calculate_accelerated_scroll_delta(edges(f32::NAN, f32::NAN, f32::NAN, f32::NAN));
9535        assert_eq!(d, pos(16.0, 16.0));
9536        assert!(!d.x.is_nan() && !d.y.is_nan());
9537    }
9538
9539    #[test]
9540    fn accelerated_scroll_delta_speed_is_always_bounded() {
9541        let vals = [
9542            0.0_f32,
9543            -1.0,
9544            19.9,
9545            20.0,
9546            50.0,
9547            100.0,
9548            200.0,
9549            f32::MAX,
9550            f32::INFINITY,
9551            f32::NEG_INFINITY,
9552            f32::NAN,
9553        ];
9554        for l in vals {
9555            for r in vals {
9556                let d = calculate_accelerated_scroll_delta(edges(l, r, l, r));
9557                assert!(d.x.abs() <= 16.0, "|x| out of range for ({l}, {r}): {}", d.x);
9558                assert!(d.y.abs() <= 16.0, "|y| out of range for ({l}, {r}): {}", d.y);
9559                assert!(!d.x.is_nan() && !d.y.is_nan());
9560            }
9561        }
9562    }
9563
9564    // ==================================================================
9565    // LayoutWindow::calculate_scrollbar_opacity
9566    // ==================================================================
9567
9568    fn opacity(last: Option<Instant>, now: Instant, delay: Duration, dur: Duration) -> f32 {
9569        LayoutWindow::calculate_scrollbar_opacity(last, now, delay, dur)
9570    }
9571
9572    #[test]
9573    fn scrollbar_opacity_without_activity_is_fully_transparent() {
9574        assert_eq!(
9575            opacity(None, tick(1_000), tick_dur(500), tick_dur(200)),
9576            0.0
9577        );
9578    }
9579
9580    #[test]
9581    fn scrollbar_opacity_stays_opaque_through_the_delay_window() {
9582        for elapsed in [0_u64, 1, 250, 499, 500] {
9583            let v = opacity(
9584                Some(tick(0)),
9585                tick(elapsed),
9586                tick_dur(500),
9587                tick_dur(200),
9588            );
9589            assert_eq!(v, 1.0, "must stay opaque at elapsed={elapsed}");
9590        }
9591    }
9592
9593    #[test]
9594    fn scrollbar_opacity_fades_linearly_then_pins_at_zero() {
9595        // Halfway through the 200-tick fade that starts after 500 ticks.
9596        let v = opacity(Some(tick(0)), tick(600), tick_dur(500), tick_dur(200));
9597        assert!((v - 0.5).abs() < 1e-4, "expected ~0.5, got {v}");
9598        // End of the fade.
9599        let v = opacity(Some(tick(0)), tick(700), tick_dur(500), tick_dur(200));
9600        assert!(v.abs() < 1e-4, "expected ~0.0, got {v}");
9601        // Long past the fade.
9602        assert_eq!(
9603            opacity(Some(tick(0)), tick(1_000_000), tick_dur(500), tick_dur(200)),
9604            0.0
9605        );
9606    }
9607
9608    #[test]
9609    fn scrollbar_opacity_handles_a_clock_that_went_backwards() {
9610        // `duration_since` saturates to zero, so a "negative" elapsed time is
9611        // treated as "just now" -> fully opaque, not NaN and not 0.
9612        let v = opacity(Some(tick(500)), tick(0), tick_dur(500), tick_dur(200));
9613        assert_eq!(v, 1.0);
9614    }
9615
9616    #[test]
9617    fn scrollbar_opacity_survives_zero_length_delay_and_fade() {
9618        // Both of these divide by zero somewhere inside; the result must still
9619        // be a defined value in [0, 1].
9620        for (delay, dur) in [
9621            (tick_dur(0), tick_dur(200)),
9622            (tick_dur(500), tick_dur(0)),
9623            (tick_dur(0), tick_dur(0)),
9624        ] {
9625            for now in [tick(0), tick(1), tick(500), tick(u64::MAX)] {
9626                let v = opacity(Some(tick(0)), now, delay, dur);
9627                assert!(!v.is_nan(), "NaN opacity for delay={delay:?} dur={dur:?}");
9628                assert!((0.0..=1.0).contains(&v), "opacity {v} out of range");
9629            }
9630        }
9631    }
9632
9633    /// Tick instants driving wall-clock fade constants must still fade.
9634    ///
9635    /// Every `div` across units used to yield `0.0`, so the function permanently
9636    /// reported "still inside the delay" and returned 1.0 — a scrollbar that
9637    /// never faded on any tick-driven clock. Now the ratio is computed on a
9638    /// canonical scale: 600 frames is 10 seconds, which is far past a 500ms delay
9639    /// plus a 200ms fade, so the scrollbar is fully faded.
9640    #[test]
9641    fn scrollbar_opacity_fades_when_a_tick_clock_drives_wall_clock_constants() {
9642        let v = opacity(Some(tick(0)), tick(600), sys_dur_ms(500), sys_dur_ms(200));
9643        assert_eq!(v, 0.0, "10s of frames is long past a 500ms + 200ms fade");
9644
9645        // Inside the delay it is still opaque: 30 frames is 500ms exactly, and
9646        // the delay check is `ratio < 1.0`.
9647        assert_eq!(opacity(Some(tick(0)), tick(29), sys_dur_ms(500), sys_dur_ms(200)), 1.0);
9648        // Halfway through the fade: 500ms delay + 100ms = 36 frames.
9649        let v = opacity(Some(tick(0)), tick(36), sys_dur_ms(500), sys_dur_ms(200));
9650        assert!((v - 0.5).abs() < 0.05, "expected ~0.5 halfway through the fade, got {v}");
9651    }
9652
9653    #[test]
9654    fn scrollbar_opacity_is_always_a_valid_alpha() {
9655        let times = [0_u64, 1, 499, 500, 501, 700, 10_000, u64::MAX];
9656        let durs = [
9657            tick_dur(0),
9658            tick_dur(1),
9659            tick_dur(200),
9660            tick_dur(500),
9661            tick_dur(u64::MAX),
9662        ];
9663        for now in times {
9664            for delay in durs {
9665                for dur in durs {
9666                    let v = opacity(Some(tick(0)), tick(now), delay, dur);
9667                    assert!(!v.is_nan(), "NaN at now={now} delay={delay:?} dur={dur:?}");
9668                    assert!(
9669                        (0.0..=1.0).contains(&v),
9670                        "opacity {v} out of range at now={now} delay={delay:?} dur={dur:?}"
9671                    );
9672                }
9673            }
9674        }
9675    }
9676
9677    // ==================================================================
9678    // remap_scrollbar_hit_id
9679    // ==================================================================
9680
9681    #[test]
9682    fn remap_scrollbar_hit_id_rewrites_every_variant_of_the_target_dom() {
9683        let dom = DomId { inner: 3 };
9684        let map = crate::managers::NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(9))]);
9685        let ctors: [fn(DomId, NodeId) -> ScrollbarHitId; 4] = [
9686            ScrollbarHitId::VerticalTrack,
9687            ScrollbarHitId::VerticalThumb,
9688            ScrollbarHitId::HorizontalTrack,
9689            ScrollbarHitId::HorizontalThumb,
9690        ];
9691        for ctor in ctors {
9692            assert_eq!(
9693                remap_scrollbar_hit_id(ctor(dom, NodeId::new(5)), dom, &map),
9694                Some(ctor(dom, NodeId::new(9)))
9695            );
9696        }
9697    }
9698
9699    #[test]
9700    fn remap_scrollbar_hit_id_drops_state_for_unmounted_nodes() {
9701        let dom = DomId { inner: 3 };
9702        let map = crate::managers::NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(9))]);
9703        // Node 7 survived nothing -> the drag must END, not retarget.
9704        assert_eq!(
9705            remap_scrollbar_hit_id(ScrollbarHitId::VerticalThumb(dom, NodeId::new(7)), dom, &map),
9706            None
9707        );
9708        // An empty map unmounts everything.
9709        let empty = crate::managers::NodeIdMap::from_pairs(Vec::<(NodeId, NodeId)>::new());
9710        assert_eq!(
9711            remap_scrollbar_hit_id(ScrollbarHitId::VerticalThumb(dom, NodeId::new(5)), dom, &empty),
9712            None
9713        );
9714        // An absurd node id is a lookup miss, not a panic.
9715        assert_eq!(
9716            remap_scrollbar_hit_id(
9717                ScrollbarHitId::HorizontalTrack(dom, NodeId::new(usize::MAX)),
9718                dom,
9719                &map
9720            ),
9721            None
9722        );
9723    }
9724
9725    #[test]
9726    fn remap_scrollbar_hit_id_passes_other_doms_through_untouched() {
9727        let dom = DomId { inner: 3 };
9728        let other = DomId { inner: 4 };
9729        let map = crate::managers::NodeIdMap::from_pairs([(NodeId::new(5), NodeId::new(9))]);
9730        // Same node id, different DOM: this reconciliation says nothing about it.
9731        let id = ScrollbarHitId::VerticalThumb(other, NodeId::new(5));
9732        assert_eq!(remap_scrollbar_hit_id(id, dom, &map), Some(id));
9733        let id = ScrollbarHitId::HorizontalThumb(other, NodeId::new(usize::MAX));
9734        assert_eq!(remap_scrollbar_hit_id(id, dom, &map), Some(id));
9735    }
9736
9737    // ==================================================================
9738    // LayoutResult::new
9739    // ==================================================================
9740
9741    #[test]
9742    fn layout_result_new_stores_its_arguments_verbatim() {
9743        let lr = LayoutResult::new(DisplayList::default(), Vec::new());
9744        assert!(lr.warnings.is_empty());
9745        assert!(lr.display_list.items.is_empty());
9746
9747        let lr = LayoutResult::new(
9748            DisplayList::default(),
9749            vec![String::new(), "a".repeat(10_000), "☃/🇺🇳/\u{202e}".to_string()],
9750        );
9751        assert_eq!(lr.warnings.len(), 3);
9752        assert_eq!(lr.warnings[1].len(), 10_000);
9753        // ☃ / 🇺 🇳 / U+202E == 6 scalar values (the flag is two regional indicators).
9754        assert_eq!(lr.warnings[2].chars().count(), 6);
9755    }
9756
9757    // ==================================================================
9758    // LayoutWindow constructors
9759    // ==================================================================
9760
9761    #[test]
9762    fn layout_window_new_starts_completely_empty() {
9763        let w = fresh_window();
9764        assert_eq!(w.get_timer_ids().len(), 0);
9765        assert_eq!(w.get_thread_ids().len(), 0);
9766        assert_eq!(w.get_dom_ids().len(), 0);
9767        assert!(w.layout_results.is_empty());
9768        assert!(w.timers.is_empty());
9769        assert!(w.threads.is_empty());
9770        assert_eq!(w.frame_report, FrameReport::default());
9771        assert!(w.layout_cache.tree.is_none());
9772        assert!(w.layout_cache.calculated_positions.is_empty());
9773        assert!(w.layout_cache.viewport.is_none());
9774        assert!(w.currently_dragging_thumb.is_none());
9775        assert!(w.scan_used_fonts().is_empty());
9776        assert!(w.scan_used_images(&ImageCache::default()).is_empty());
9777        assert!(!w.skip_gpu_sync);
9778    }
9779
9780    #[test]
9781    fn layout_window_constructors_hand_out_unique_document_and_namespace_ids() {
9782        let a = fresh_window();
9783        let b = fresh_window();
9784        assert_ne!(a.document_id, b.document_id);
9785        assert_ne!(a.id_namespace, b.id_namespace);
9786        assert_ne!(a.document_id.namespace_id, a.id_namespace);
9787    }
9788
9789    #[test]
9790    fn layout_window_new_with_shared_fonts_accepts_an_empty_shared_map() {
9791        let shared: Arc<std::sync::Mutex<HashMap<rust_fontconfig::FontId, FontRef>>> =
9792            Arc::new(std::sync::Mutex::new(HashMap::new()));
9793        let a = LayoutWindow::new_with_shared_fonts(FcFontCache::default(), Arc::clone(&shared))
9794            .expect("shared-font constructor must succeed on an empty map");
9795        let b = LayoutWindow::new_with_shared_fonts(FcFontCache::default(), shared)
9796            .expect("shared-font constructor must succeed twice");
9797        assert!(a.layout_results.is_empty());
9798        assert!(b.layout_results.is_empty());
9799        assert_ne!(a.id_namespace, b.id_namespace);
9800    }
9801
9802    #[cfg(feature = "pdf")]
9803    #[test]
9804    fn layout_window_new_paged_accepts_degenerate_page_sizes() {
9805        for page in [
9806            LogicalSize::zero(),
9807            size(-1.0, -1.0),
9808            size(f32::MAX, f32::MAX),
9809            size(f32::NAN, f32::NAN),
9810            size(f32::INFINITY, f32::INFINITY),
9811        ] {
9812            let w = LayoutWindow::new_paged(FcFontCache::default(), page)
9813                .expect("new_paged must not fail on a degenerate page size");
9814            assert!(w.layout_results.is_empty());
9815        }
9816    }
9817
9818    // ==================================================================
9819    // Node query getters
9820    // ==================================================================
9821
9822    #[test]
9823    fn node_getters_return_none_on_an_empty_window_for_every_hostile_id() {
9824        let w = fresh_window();
9825        for dom in [DomId::ROOT_ID, DomId { inner: 0 }, DomId { inner: usize::MAX }] {
9826            for node in hostile_node_ids() {
9827                let id = DomNodeId { dom, node };
9828                assert!(w.get_node_size(id).is_none(), "size {id:?}");
9829                assert!(w.get_node_position(id).is_none(), "position {id:?}");
9830                assert!(w.get_node_hit_test_bounds(id).is_none(), "bounds {id:?}");
9831                assert!(w.get_parent(id).is_none(), "parent {id:?}");
9832                assert!(w.get_first_child(id).is_none(), "first_child {id:?}");
9833                assert!(w.get_last_child(id).is_none(), "last_child {id:?}");
9834                assert!(w.get_next_sibling(id).is_none(), "next_sibling {id:?}");
9835                assert!(w.get_previous_sibling(id).is_none(), "prev_sibling {id:?}");
9836            }
9837        }
9838    }
9839
9840    #[test]
9841    fn node_getters_survive_hostile_ids_against_a_real_styled_dom() {
9842        let w = window_with_fixture();
9843        // `layout_results` IS populated now, so these ids reach the hierarchy
9844        // lookup rather than short-circuiting on a missing DOM.
9845        for node in hostile_node_ids() {
9846            let id = DomNodeId {
9847                dom: DomId::ROOT_ID,
9848                node,
9849            };
9850            assert!(w.get_parent(id).is_none(), "parent {id:?}");
9851            assert!(w.get_first_child(id).is_none(), "first_child {id:?}");
9852            assert!(w.get_last_child(id).is_none(), "last_child {id:?}");
9853            assert!(w.get_next_sibling(id).is_none(), "next_sibling {id:?}");
9854            assert!(w.get_previous_sibling(id).is_none(), "prev_sibling {id:?}");
9855            assert!(w.get_node_size(id).is_none(), "size {id:?}");
9856            assert!(w.get_node_position(id).is_none(), "position {id:?}");
9857            assert!(w.get_node_hit_test_bounds(id).is_none(), "bounds {id:?}");
9858        }
9859        // A valid node id but the wrong DOM must also miss.
9860        let wrong_dom = DomNodeId {
9861            dom: DomId { inner: 77 },
9862            node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
9863        };
9864        assert!(wrong_dom.node.into_crate_internal().is_some());
9865        assert!(w.get_parent(wrong_dom).is_none());
9866        assert!(w.get_first_child(wrong_dom).is_none());
9867    }
9868
9869    #[test]
9870    fn hierarchy_getters_agree_with_each_other_on_a_real_dom() {
9871        let w = window_with_fixture();
9872        let root = dnid(0);
9873        assert_eq!(
9874            w.get_layout_result(&DomId::ROOT_ID)
9875                .expect("fixture must be registered")
9876                .styled_dom
9877                .node_hierarchy
9878                .len(),
9879            4,
9880            "fixture is body + 3 divs"
9881        );
9882
9883        // The root has no parent, but does have children.
9884        assert!(w.get_parent(root).is_none());
9885        let first = w.get_first_child(root).expect("root must have a first child");
9886        let last = w.get_last_child(root).expect("root must have a last child");
9887        assert_ne!(first, last);
9888        assert_eq!(w.get_parent(first), Some(root));
9889        assert_eq!(w.get_parent(last), Some(root));
9890
9891        // Walking forward from `first` reaches `last` in exactly 2 hops.
9892        let mid = w.get_next_sibling(first).expect("second child");
9893        assert_eq!(w.get_next_sibling(mid), Some(last));
9894        assert_eq!(w.get_next_sibling(last), None, "last child has no successor");
9895
9896        // ...and the reverse walk is symmetric.
9897        assert_eq!(w.get_previous_sibling(last), Some(mid));
9898        assert_eq!(w.get_previous_sibling(mid), Some(first));
9899        assert_eq!(w.get_previous_sibling(first), None);
9900
9901        // Leaves have no children.
9902        for leaf in [first, mid, last] {
9903            assert!(w.get_first_child(leaf).is_none(), "{leaf:?} must be a leaf");
9904            assert!(w.get_last_child(leaf).is_none(), "{leaf:?} must be a leaf");
9905        }
9906    }
9907
9908    #[test]
9909    fn geometry_getters_return_none_without_a_layout_pass() {
9910        // The fixture has a real StyledDom but an EMPTY layout tree / display
9911        // list: geometry must be reported as absent, never as a zero rect.
9912        let w = window_with_fixture();
9913        for i in 0..4 {
9914            let id = dnid(i);
9915            assert!(w.get_node_size(id).is_none(), "size for node {i}");
9916            assert!(w.get_node_position(id).is_none(), "position for node {i}");
9917            assert!(w.get_node_hit_test_bounds(id).is_none(), "bounds for node {i}");
9918        }
9919    }
9920
9921    #[test]
9922    fn scan_used_resources_is_empty_for_an_empty_display_list() {
9923        let w = window_with_fixture();
9924        assert!(w.scan_used_fonts().is_empty());
9925        assert!(w.scan_used_images(&ImageCache::default()).is_empty());
9926        // Several DOMs, still nothing referenced.
9927        let mut w = w;
9928        w.layout_results
9929            .insert(DomId { inner: 1 }, bare_layout_result(fixture_dom()));
9930        w.layout_results
9931            .insert(DomId { inner: 2 }, bare_layout_result(fixture_dom()));
9932        assert_eq!(w.get_dom_ids().len(), 3);
9933        assert!(w.scan_used_fonts().is_empty());
9934        assert!(w.scan_used_images(&ImageCache::default()).is_empty());
9935    }
9936
9937    // ==================================================================
9938    // Text-structure predicates
9939    // ==================================================================
9940
9941    #[test]
9942    fn node_has_text_content_sees_direct_and_child_text() {
9943        let plain = fixture_dom();
9944        for i in 0..4 {
9945            assert!(
9946                !LayoutWindow::node_has_text_content(&plain, NodeId::new(i)),
9947                "node {i} of a text-free DOM must not report text"
9948            );
9949        }
9950
9951        // body > text("hello") => 2 nodes.
9952        let with_text =
9953            StyledDom::create_from_dom(Dom::create_body().with_child(Dom::create_text("hello")));
9954        assert_eq!(with_text.node_hierarchy.len(), 2);
9955        assert!(
9956            LayoutWindow::node_has_text_content(&with_text, NodeId::new(0)),
9957            "the parent of a text node has text content"
9958        );
9959        assert!(
9960            LayoutWindow::node_has_text_content(&with_text, NodeId::new(1)),
9961            "a text node is itself text content"
9962        );
9963
9964        // Empty and astral-plane text still count as text nodes.
9965        for s in ["", "🇺🇳👩‍👩‍👧‍👦", "\u{202e}\u{0}"] {
9966            let d = StyledDom::create_from_dom(Dom::create_body().with_child(Dom::create_text(s)));
9967            assert!(LayoutWindow::node_has_text_content(&d, NodeId::new(1)), "{s:?}");
9968            assert!(LayoutWindow::node_has_text_content(&d, NodeId::new(0)), "{s:?}");
9969        }
9970    }
9971
9972    #[test]
9973    fn is_text_selectable_honours_user_select_none() {
9974        let dom = StyledDom::create_from_dom(
9975            Dom::create_body()
9976                .with_child(Dom::create_div())
9977                .with_child(Dom::create_div().with_css("user-select: none;")),
9978        );
9979        assert_eq!(dom.node_hierarchy.len(), 3);
9980        assert!(
9981            LayoutWindow::is_text_selectable(&dom, NodeId::new(1)),
9982            "default is selectable"
9983        );
9984        assert!(
9985            !LayoutWindow::is_text_selectable(&dom, NodeId::new(2)),
9986            "user-select: none must opt out"
9987        );
9988    }
9989
9990    #[test]
9991    fn contenteditable_lookups_are_false_for_an_unknown_dom() {
9992        let w = window_with_fixture();
9993        let missing = DomId { inner: 999 };
9994        // The DOM lookup short-circuits BEFORE the (unguarded) node indexing,
9995        // so even an absurd node id is safe here.
9996        assert!(!w.is_node_contenteditable_internal(missing, NodeId::new(usize::MAX)));
9997        assert!(!w.is_node_contenteditable_inherited_internal(missing, NodeId::new(usize::MAX)));
9998        // A known DOM with plain divs: nothing is editable.
9999        for i in 0..4 {
10000            assert!(!w.is_node_contenteditable_internal(DomId::ROOT_ID, NodeId::new(i)));
10001        }
10002    }
10003
10004    #[test]
10005    fn contenteditable_is_detected_and_inherited() {
10006        let mut w = fresh_window();
10007        let dom = StyledDom::create_from_dom(
10008            Dom::create_body()
10009                .with_child(Dom::create_div().with_contenteditable(true).with_child(Dom::create_div())),
10010        );
10011        assert_eq!(dom.node_hierarchy.len(), 3);
10012        w.layout_results
10013            .insert(DomId::ROOT_ID, bare_layout_result(dom));
10014
10015        assert!(!w.is_node_contenteditable_internal(DomId::ROOT_ID, NodeId::new(0)));
10016        assert!(w.is_node_contenteditable_internal(DomId::ROOT_ID, NodeId::new(1)));
10017        // The nested child is NOT directly editable...
10018        assert!(!w.is_node_contenteditable_internal(DomId::ROOT_ID, NodeId::new(2)));
10019        // ...but it inherits editability from its ancestor.
10020        assert!(w.is_node_contenteditable_inherited_internal(DomId::ROOT_ID, NodeId::new(2)));
10021    }
10022
10023    // ==================================================================
10024    // Timers
10025    // ==================================================================
10026
10027    extern "C" fn time_tick_0() -> Instant {
10028        Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 })
10029    }
10030
10031    extern "C" fn time_tick_1000() -> Instant {
10032        Instant::Tick(azul_core::task::SystemTick { tick_counter: 1_000 })
10033    }
10034
10035    extern "C" fn time_tick_max() -> Instant {
10036        Instant::Tick(azul_core::task::SystemTick {
10037            tick_counter: u64::MAX,
10038        })
10039    }
10040
10041    extern "C" fn time_system_now() -> Instant {
10042        Instant::now()
10043    }
10044
10045    #[test]
10046    fn timer_add_get_remove_round_trips_and_overwrites_by_id() {
10047        let mut w = fresh_window();
10048        let id = TimerId { id: 1 };
10049        assert!(w.get_timer(&id).is_none());
10050        assert!(w.remove_timer(&id).is_none(), "removing an absent timer is None");
10051
10052        w.add_timer(id, Timer::default());
10053        assert!(w.get_timer(&id).is_some());
10054        assert!(w.get_timer_mut(&id).is_some());
10055        assert_eq!(w.get_timer_ids().len(), 1);
10056
10057        // Re-adding the same id replaces rather than duplicates.
10058        w.add_timer(id, Timer::default());
10059        assert_eq!(w.get_timer_ids().len(), 1);
10060
10061        assert!(w.remove_timer(&id).is_some());
10062        assert!(w.get_timer(&id).is_none());
10063        assert_eq!(w.get_timer_ids().len(), 0);
10064        // Double remove is a clean None, not a panic.
10065        assert!(w.remove_timer(&id).is_none());
10066    }
10067
10068    #[test]
10069    fn timer_ids_survive_extreme_id_values() {
10070        let mut w = fresh_window();
10071        let lo = TimerId { id: 0 };
10072        let hi = TimerId { id: usize::MAX };
10073        w.add_timer(lo, Timer::default());
10074        w.add_timer(hi, Timer::default());
10075        assert_eq!(w.get_timer_ids().len(), 2);
10076        assert!(w.get_timer(&lo).is_some());
10077        assert!(w.get_timer(&hi).is_some());
10078        assert!(w.remove_timer(&hi).is_some());
10079        assert_eq!(w.get_timer_ids().len(), 1);
10080    }
10081
10082    #[test]
10083    fn tick_timers_reports_every_registered_timer_regardless_of_the_clock() {
10084        let mut w = fresh_window();
10085        assert!(w.tick_timers(tick(0)).is_empty(), "no timers => nothing ready");
10086
10087        for i in 0..3 {
10088            w.add_timer(TimerId { id: i }, Timer::default());
10089        }
10090        // Pinned as CURRENT behaviour: `tick_timers` ignores `current_time` and
10091        // returns every registered id; readiness is decided by the caller.
10092        for now in [tick(0), tick(u64::MAX), Instant::now()] {
10093            let ready = w.tick_timers(now);
10094            assert_eq!(ready.len(), 3);
10095        }
10096    }
10097
10098    #[test]
10099    fn time_until_next_timer_ms_is_none_without_timers() {
10100        let w = fresh_window();
10101        let cb = azul_core::task::GetSystemTimeCallback { cb: time_tick_0 };
10102        assert_eq!(
10103            w.time_until_next_timer_ms(&cb),
10104            None,
10105            "no timers => the caller may block indefinitely"
10106        );
10107    }
10108
10109    #[test]
10110    fn time_until_next_timer_ms_reports_zero_for_an_overdue_timer() {
10111        let mut w = fresh_window();
10112        // `Timer::default()` is created at tick 0 with no delay/interval, so
10113        // its next run is tick 0 — already due at any clock value.
10114        w.add_timer(TimerId { id: 1 }, Timer::default());
10115        let clocks: [azul_core::task::GetSystemTimeCallbackType; 3] =
10116            [time_tick_0, time_tick_1000, time_tick_max];
10117        for cb in clocks {
10118            let cb = azul_core::task::GetSystemTimeCallback { cb };
10119            assert_eq!(w.time_until_next_timer_ms(&cb), Some(0));
10120        }
10121    }
10122
10123    #[test]
10124    fn time_until_next_timer_ms_takes_the_minimum_across_timers() {
10125        let mut w = fresh_window();
10126        w.add_timer(
10127            TimerId { id: 1 },
10128            Timer::default().with_interval(tick_dur(5_000)),
10129        );
10130        w.add_timer(
10131            TimerId { id: 2 },
10132            Timer::default().with_interval(tick_dur(2_000)),
10133        );
10134        w.add_timer(
10135            TimerId { id: 3 },
10136            Timer::default().with_interval(tick_dur(9_000)),
10137        );
10138        // The intervals are in TICKS and the function answers MILLISECONDS, so
10139        // the expected values convert at the nominal frame rate: 2000 ticks is
10140        // 2000 * 1000 / 60 = 33_333 ms. They read 2_000 and 1_000 while a tick
10141        // was assumed to be a millisecond.
10142        let cb = azul_core::task::GetSystemTimeCallback { cb: time_tick_0 };
10143        assert_eq!(w.time_until_next_timer_ms(&cb), Some(33_333));
10144
10145        // At tick 1000 the 2000-tick timer is 1000 ticks away.
10146        let cb = azul_core::task::GetSystemTimeCallback { cb: time_tick_1000 };
10147        assert_eq!(w.time_until_next_timer_ms(&cb), Some(16_666));
10148
10149        // Far past every deadline: overdue => 0.
10150        let cb = azul_core::task::GetSystemTimeCallback { cb: time_tick_max };
10151        assert_eq!(w.time_until_next_timer_ms(&cb), Some(0));
10152    }
10153
10154    #[test]
10155    fn time_until_next_timer_ms_does_not_overflow_on_a_max_interval() {
10156        let mut w = fresh_window();
10157        w.add_timer(
10158            TimerId { id: 1 },
10159            Timer::default().with_interval(tick_dur(u64::MAX)),
10160        );
10161        let cb = azul_core::task::GetSystemTimeCallback { cb: time_tick_0 };
10162        assert_eq!(w.time_until_next_timer_ms(&cb), Some(u64::MAX));
10163    }
10164
10165    #[test]
10166    fn time_until_next_timer_ms_saturates_across_mismatched_clock_kinds() {
10167        let mut w = fresh_window();
10168        // Tick-based timer, System-based clock. The mismatch here is between two
10169        // INSTANTS — `Timer::default()` is created at tick 0 and the clock hands
10170        // back a wall-clock instant — and those have no common origin, so the
10171        // span really is undefined and saturates. (A mismatch between the
10172        // interval's UNIT and the clock's unit is a different thing, and now
10173        // converts: see `add_optional_duration`.)
10174        w.add_timer(
10175            TimerId { id: 1 },
10176            Timer::default().with_interval(tick_dur(5_000)),
10177        );
10178        let cb = azul_core::task::GetSystemTimeCallback {
10179            cb: time_system_now,
10180        };
10181        assert_eq!(w.time_until_next_timer_ms(&cb), Some(0));
10182    }
10183
10184    #[test]
10185    fn create_tooltip_delay_timer_encodes_the_hover_time_as_a_one_shot_delay() {
10186        let w = fresh_window();
10187        for ms in [0_u32, 1, 500, u32::MAX] {
10188            let t = w.create_tooltip_delay_timer(ms);
10189            assert_eq!(
10190                t.delay,
10191                azul_core::task::OptionDuration::Some(sys_dur_ms(u64::from(ms))),
10192                "hover_time_ms={ms}"
10193            );
10194            assert_eq!(
10195                t.interval,
10196                azul_core::task::OptionDuration::None,
10197                "the tooltip timer is one-shot"
10198            );
10199            assert_eq!(t.timeout, azul_core::task::OptionDuration::None);
10200            assert_eq!(t.run_count, 0);
10201            assert!(matches!(t.last_run, azul_core::task::OptionInstant::None));
10202            // u32::MAX ms must survive the widening to u64 millis intact.
10203            match &t.delay {
10204                azul_core::task::OptionDuration::Some(d) => {
10205                    assert_eq!(duration_to_millis(*d), u64::from(ms));
10206                }
10207                azul_core::task::OptionDuration::None => panic!("delay must be set"),
10208            }
10209        }
10210    }
10211
10212    #[test]
10213    fn create_cursor_blink_timer_is_a_repeating_530ms_timer() {
10214        let w = fresh_window();
10215        let t = w.create_cursor_blink_timer(&FullWindowState::default());
10216        assert_eq!(
10217            t.delay,
10218            azul_core::task::OptionDuration::None,
10219            "the blink timer starts immediately"
10220        );
10221        assert_eq!(t.timeout, azul_core::task::OptionDuration::None);
10222        assert_eq!(t.run_count, 0);
10223        match &t.interval {
10224            azul_core::task::OptionDuration::Some(d) => assert_eq!(
10225                duration_to_millis(*d),
10226                crate::managers::text_edit::CURSOR_BLINK_INTERVAL_MS
10227            ),
10228            azul_core::task::OptionDuration::None => panic!("interval must be set"),
10229        }
10230    }
10231
10232    /// The blink timer copies the blink state's interval WHOLE — unit included.
10233    /// If it flattened to milliseconds, a `caret-animation-duration: 5t` would
10234    /// reach `Timer::invoke` as a wall-clock span and the exact-frame guarantee
10235    /// would be gone.
10236    #[test]
10237    fn create_cursor_blink_timer_carries_a_tick_interval_through_as_ticks() {
10238        let mut w = fresh_window();
10239        w.text_edit_manager
10240            .blink
10241            .set_blink_interval(Duration::from_ticks(5));
10242
10243        let t = w.create_cursor_blink_timer(&FullWindowState::default());
10244        assert_eq!(
10245            t.interval,
10246            azul_core::task::OptionDuration::Some(Duration::from_ticks(5)),
10247            "the tick unit must survive into the timer"
10248        );
10249        // ...and it converts to real milliseconds only where a real OS timer
10250        // needs one: 5 frames at 60Hz is 83ms, not 5ms.
10251        assert_eq!(t.tick_millis(), 83);
10252    }
10253
10254    /// `duration_to_millis` must convert ticks at the nominal frame rate. The
10255    /// old "assume tick = 1ms" made every tick-unit span 16x too short.
10256    #[test]
10257    fn duration_to_millis_converts_ticks_at_the_nominal_frame_rate() {
10258        assert_eq!(duration_to_millis(Duration::from_ticks(60)), 1_000);
10259        assert_eq!(duration_to_millis(Duration::from_ticks(5)), 83);
10260        assert_eq!(duration_to_millis(Duration::from_ticks(1)), 16);
10261        assert_eq!(duration_to_millis(Duration::from_ticks(0)), 0);
10262        // The wall-clock arm is unchanged.
10263        assert_eq!(duration_to_millis(Duration::from_millis(530)), 530);
10264        assert_eq!(duration_to_millis(Duration::from_millis(0)), 0);
10265    }
10266
10267    // ==================================================================
10268    // Threads
10269    // ==================================================================
10270
10271    #[test]
10272    fn thread_getters_are_none_on_an_empty_window() {
10273        let mut w = fresh_window();
10274        let id = ThreadId::unique();
10275        assert_eq!(w.get_thread_ids().len(), 0);
10276        assert!(w.get_thread(&id).is_none());
10277        assert!(w.get_thread_mut(&id).is_none());
10278        assert!(w.remove_thread(&id).is_none());
10279        // A second, distinct id is equally absent.
10280        assert!(w.get_thread(&ThreadId::unique()).is_none());
10281        assert_eq!(w.get_thread_ids().len(), 0);
10282    }
10283
10284    // ==================================================================
10285    // Scroll state
10286    // ==================================================================
10287
10288    #[test]
10289    fn get_scroll_position_is_none_before_anything_is_set() {
10290        let w = fresh_window();
10291        assert_eq!(w.get_scroll_position(DomId::ROOT_ID, NodeId::new(0)), None);
10292        assert_eq!(
10293            w.get_scroll_position(DomId { inner: usize::MAX }, NodeId::new(usize::MAX)),
10294            None
10295        );
10296    }
10297
10298    #[test]
10299    fn set_then_get_scroll_position_round_trips_inside_the_scrollable_range() {
10300        let mut w = fresh_window();
10301        let node = NodeId::new(4);
10302        let scroll = ScrollPosition {
10303            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10304            children_rect: rect(0.0, 0.0, 200.0, 200.0),
10305        };
10306        w.set_scroll_position(DomId::ROOT_ID, node, scroll);
10307        assert_eq!(w.get_scroll_position(DomId::ROOT_ID, node), Some(scroll));
10308
10309        // An offset inside [0, content - container] survives verbatim.
10310        let scrolled = ScrollPosition {
10311            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10312            children_rect: rect(50.0, 25.0, 200.0, 200.0),
10313        };
10314        w.set_scroll_position(DomId::ROOT_ID, node, scrolled);
10315        assert_eq!(w.get_scroll_position(DomId::ROOT_ID, node), Some(scrolled));
10316
10317        // Neighbouring keys stay untouched.
10318        assert_eq!(w.get_scroll_position(DomId::ROOT_ID, NodeId::new(5)), None);
10319        assert_eq!(w.get_scroll_position(DomId { inner: 1 }, node), None);
10320    }
10321
10322    #[test]
10323    fn set_scroll_position_clamps_out_of_range_and_nan_offsets() {
10324        let mut w = fresh_window();
10325        let node = NodeId::new(0);
10326        let container = rect(0.0, 0.0, 100.0, 100.0);
10327        // max scroll is (200 - 100) = 100 on each axis.
10328        for (requested, expected) in [
10329            (pos(99_999.0, 99_999.0), pos(100.0, 100.0)),
10330            (pos(-500.0, -500.0), pos(0.0, 0.0)),
10331            (pos(f32::INFINITY, f32::NEG_INFINITY), pos(100.0, 0.0)),
10332            (pos(f32::NAN, f32::NAN), pos(0.0, 0.0)),
10333        ] {
10334            w.set_scroll_position(
10335                DomId::ROOT_ID,
10336                node,
10337                ScrollPosition {
10338                    parent_rect: container,
10339                    children_rect: LogicalRect::new(requested, size(200.0, 200.0)),
10340                },
10341            );
10342            let got = w
10343                .get_scroll_position(DomId::ROOT_ID, node)
10344                .expect("state was just written");
10345            assert!(
10346                !got.children_rect.origin.x.is_nan() && !got.children_rect.origin.y.is_nan(),
10347                "clamped scroll offset must never be NaN (requested {requested:?})"
10348            );
10349            assert_eq!(
10350                got.children_rect.origin, expected,
10351                "requested {requested:?} must clamp to {expected:?}"
10352            );
10353        }
10354    }
10355
10356    #[test]
10357    fn set_scroll_position_on_a_non_scrollable_node_pins_to_the_origin() {
10358        let mut w = fresh_window();
10359        let node = NodeId::new(0);
10360        // Content smaller than the container => max scroll is 0 on both axes.
10361        w.set_scroll_position(
10362            DomId::ROOT_ID,
10363            node,
10364            ScrollPosition {
10365                parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10366                children_rect: rect(40.0, 40.0, 10.0, 10.0),
10367            },
10368        );
10369        let got = w.get_scroll_position(DomId::ROOT_ID, node).expect("written");
10370        assert_eq!(got.children_rect.origin, pos(0.0, 0.0));
10371    }
10372
10373    #[test]
10374    fn get_nested_scroll_states_always_contains_the_requested_dom_key() {
10375        let w = fresh_window();
10376        // Pinned: the DOM key is inserted unconditionally, so an empty result is
10377        // `{dom: {}}` rather than `{}` — callers must not treat "key present"
10378        // as "this DOM scrolls".
10379        let nested = w.get_nested_scroll_states(DomId::ROOT_ID);
10380        assert_eq!(nested.len(), 1);
10381        assert!(nested[&DomId::ROOT_ID].is_empty());
10382
10383        let mut w = w;
10384        let node = NodeId::new(2);
10385        w.set_scroll_position(
10386            DomId::ROOT_ID,
10387            node,
10388            ScrollPosition {
10389                parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10390                children_rect: rect(0.0, 0.0, 200.0, 200.0),
10391            },
10392        );
10393        let nested = w.get_nested_scroll_states(DomId::ROOT_ID);
10394        let inner = &nested[&DomId::ROOT_ID];
10395        assert_eq!(inner.len(), 1);
10396        assert!(inner.contains_key(&NodeHierarchyItemId::from_crate_internal(Some(node))));
10397        // A different DOM sees none of it.
10398        assert!(w.get_nested_scroll_states(DomId { inner: 9 })[&DomId { inner: 9 }].is_empty());
10399    }
10400
10401    // ==================================================================
10402    // Selection (documented no-ops)
10403    // ==================================================================
10404
10405    #[test]
10406    fn selection_accessors_are_the_documented_no_ops() {
10407        let mut w = fresh_window();
10408        let state = SelectionState {
10409            selections: Vec::<Selection>::new().into(),
10410            node_id: DomNodeId {
10411                dom: DomId::ROOT_ID,
10412                node: NodeHierarchyItemId::from_crate_internal(Some(NodeId::new(0))),
10413            },
10414        };
10415        assert!(w.get_selection(DomId::ROOT_ID).is_none());
10416        w.set_selection(DomId::ROOT_ID, state.clone());
10417        // `set_selection` is a no-op and `get_selection` is `const fn -> None`.
10418        assert!(w.get_selection(DomId::ROOT_ID).is_none());
10419        assert!(w.get_selection(DomId { inner: usize::MAX }).is_none());
10420        w.set_selection(DomId { inner: 42 }, state);
10421        assert!(w.get_selection(DomId { inner: 42 }).is_none());
10422    }
10423
10424    // ==================================================================
10425    // clear_caches
10426    // ==================================================================
10427
10428    #[test]
10429    fn clear_caches_drops_every_layout_result_and_is_idempotent() {
10430        let mut w = window_with_fixture();
10431        w.layout_results
10432            .insert(DomId { inner: 1 }, bare_layout_result(fixture_dom()));
10433        w.set_scroll_position(
10434            DomId::ROOT_ID,
10435            NodeId::new(0),
10436            ScrollPosition {
10437                parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10438                children_rect: rect(0.0, 0.0, 200.0, 200.0),
10439            },
10440        );
10441        assert_eq!(w.get_dom_ids().len(), 2);
10442        assert!(w.get_scroll_position(DomId::ROOT_ID, NodeId::new(0)).is_some());
10443
10444        w.clear_caches();
10445
10446        assert_eq!(w.get_dom_ids().len(), 0);
10447        assert!(w.layout_results.is_empty());
10448        assert!(w.layout_cache.tree.is_none());
10449        assert!(w.layout_cache.calculated_positions.is_empty());
10450        assert!(w.layout_cache.viewport.is_none());
10451        assert!(w.layout_cache.cached_display_list.is_none());
10452        assert_eq!(w.layout_cache.prev_dom_ptr, 0);
10453        assert!(
10454            w.get_scroll_position(DomId::ROOT_ID, NodeId::new(0)).is_none(),
10455            "clear_caches replaces the ScrollManager"
10456        );
10457
10458        // Idempotent, and safe on a never-used window.
10459        w.clear_caches();
10460        assert_eq!(w.get_dom_ids().len(), 0);
10461        fresh_window().clear_caches();
10462    }
10463
10464    // ==================================================================
10465    // GPU cache / layout result accessors
10466    // ==================================================================
10467
10468    #[test]
10469    fn gpu_cache_accessors_are_keyed_per_dom_and_created_on_demand() {
10470        let mut w = fresh_window();
10471        let a = DomId { inner: 0 };
10472        let b = DomId { inner: usize::MAX };
10473        assert!(w.get_gpu_cache(&a).is_none());
10474        assert!(w.get_gpu_cache_mut(&a).is_none());
10475
10476        assert!(w.get_or_create_gpu_cache(a).transform_keys.is_empty());
10477        assert!(w.get_gpu_cache(&a).is_some());
10478        assert!(w.get_gpu_cache(&b).is_none(), "creation must not leak across DOMs");
10479
10480        // Re-creating returns the same (still empty) cache, not a second one.
10481        assert!(w.get_or_create_gpu_cache(a).transform_keys.is_empty());
10482        assert!(w.get_or_create_gpu_cache(b).transform_keys.is_empty());
10483        assert!(w.get_gpu_cache(&b).is_some());
10484    }
10485
10486    #[test]
10487    fn layout_result_accessors_track_get_dom_ids() {
10488        let mut w = fresh_window();
10489        assert!(w.get_layout_result(&DomId::ROOT_ID).is_none());
10490        assert!(w.get_layout_result_mut(&DomId::ROOT_ID).is_none());
10491        assert_eq!(w.get_dom_ids().len(), 0);
10492
10493        w.layout_results
10494            .insert(DomId::ROOT_ID, bare_layout_result(fixture_dom()));
10495        assert!(w.get_layout_result(&DomId::ROOT_ID).is_some());
10496        assert!(w.get_layout_result_mut(&DomId::ROOT_ID).is_some());
10497        assert_eq!(w.get_dom_ids().len(), 1);
10498        assert!(w.get_layout_result(&DomId { inner: 1 }).is_none());
10499    }
10500
10501    // ==================================================================
10502    // Edge / error arms exercised by the headless e2e_json harness at the
10503    // integration level, pinned here as fast unit checks (no fonts, no
10504    // solver where avoidable). These cover the "nothing there" / degenerate
10505    // branches a happy-path fixture never reaches.
10506    // ==================================================================
10507
10508    /// Run the real layout pipeline on a `StyledDom` at a given viewport.
10509    fn laid_out(styled_dom: StyledDom, w: f32, h: f32) -> LayoutWindow {
10510        let mut win = fresh_window();
10511        let mut ws = FullWindowState::default();
10512        ws.size.dimensions = size(w, h);
10513        let rr = RendererResources::default();
10514        let sc = ExternalSystemCallbacks::rust_internal();
10515        let mut dbg = None;
10516        win.layout_and_generate_display_list(styled_dom, &ws, &rr, &sc, &mut dbg)
10517            .expect("layout must succeed on a well-formed DOM");
10518        win
10519    }
10520
10521    #[test]
10522    fn layout_and_generate_display_list_populates_results_for_a_plain_dom() {
10523        let win = laid_out(fixture_dom(), 200.0, 150.0);
10524        let lr = win
10525            .get_layout_result(&DomId::ROOT_ID)
10526            .expect("root layout result must exist after a successful layout");
10527        assert!(
10528            !lr.layout_tree.nodes.is_empty(),
10529            "the layout tree must carry nodes"
10530        );
10531        // The root (body) must have real computed bounds.
10532        let root_bounds = win.get_node_bounds(DomId::ROOT_ID, NodeId::new(0));
10533        assert!(root_bounds.is_some(), "root node must have bounds");
10534    }
10535
10536    #[test]
10537    fn get_node_bounds_is_none_for_missing_dom_and_missing_node() {
10538        let win = laid_out(fixture_dom(), 200.0, 150.0);
10539        // Missing DOM.
10540        assert!(win.get_node_bounds(DomId { inner: 9 }, NodeId::new(0)).is_none());
10541        // Out-of-range node in a real DOM.
10542        assert!(win
10543            .get_node_bounds(DomId::ROOT_ID, NodeId::new(9_999))
10544            .is_none());
10545    }
10546
10547    #[test]
10548    fn scroll_position_roundtrips_and_is_none_when_unregistered() {
10549        let mut win = fresh_window();
10550        let dom = DomId::ROOT_ID;
10551        let node = NodeId::new(1);
10552        assert!(
10553            win.get_scroll_position(dom, node).is_none(),
10554            "an unregistered node has no scroll position"
10555        );
10556        let sp = ScrollPosition {
10557            parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10558            children_rect: rect(0.0, 0.0, 100.0, 500.0),
10559        };
10560        win.set_scroll_position(dom, node, sp);
10561        assert!(
10562            win.get_scroll_position(dom, node).is_some(),
10563            "after set_scroll_position the node must have a position"
10564        );
10565    }
10566
10567    #[test]
10568    fn timer_scheduling_helpers_handle_the_empty_case() {
10569        let mut win = fresh_window();
10570        let sc = ExternalSystemCallbacks::rust_internal();
10571        assert!(
10572            win.time_until_next_timer_ms(&sc.get_system_time_fn).is_none(),
10573            "no timers => can block indefinitely"
10574        );
10575        assert!(
10576            win.tick_timers(tick(0)).is_empty(),
10577            "no timers => nothing is ready"
10578        );
10579    }
10580
10581    #[test]
10582    fn scroll_node_into_view_on_an_empty_window_yields_no_adjustments() {
10583        let mut win = fresh_window();
10584        let sc = ExternalSystemCallbacks::rust_internal();
10585        let now = (sc.get_system_time_fn.cb)();
10586        let adjustments = win.scroll_node_into_view(
10587            dnid(0),
10588            crate::managers::scroll_into_view::ScrollIntoViewOptions::start(),
10589            now,
10590        );
10591        assert!(
10592            adjustments.is_empty(),
10593            "a node with no scrollable ancestor cannot be scrolled into view"
10594        );
10595    }
10596
10597    #[test]
10598    fn find_scrollable_ancestor_is_none_without_a_layout_tree() {
10599        let win = fresh_window();
10600        assert!(
10601            win.find_scrollable_ancestor(dnid(0)).is_none(),
10602            "no layout_cache tree => no scrollable ancestor"
10603        );
10604    }
10605
10606    #[test]
10607    fn clear_caches_drops_layout_results_and_scroll_state() {
10608        let mut win = laid_out(fixture_dom(), 200.0, 150.0);
10609        win.set_scroll_position(
10610            DomId::ROOT_ID,
10611            NodeId::new(1),
10612            ScrollPosition {
10613                parent_rect: rect(0.0, 0.0, 100.0, 100.0),
10614                children_rect: rect(0.0, 0.0, 100.0, 500.0),
10615            },
10616        );
10617        assert!(!win.layout_results.is_empty());
10618        assert!(win.get_scroll_position(DomId::ROOT_ID, NodeId::new(1)).is_some());
10619
10620        win.clear_caches();
10621
10622        assert!(win.layout_results.is_empty(), "layout results must be cleared");
10623        assert!(
10624            win.get_scroll_position(DomId::ROOT_ID, NodeId::new(1)).is_none(),
10625            "the ScrollManager must be reset"
10626        );
10627        assert!(win.layout_cache.tree.is_none());
10628    }
10629
10630    #[test]
10631    fn finalize_pending_focus_changes_is_false_without_a_pending_request() {
10632        let mut win = fresh_window();
10633        assert!(
10634            !win.finalize_pending_focus_changes(),
10635            "nothing pending => no cursor initialization happened"
10636        );
10637    }
10638
10639    #[test]
10640    fn text_node_navigation_is_none_on_an_empty_window() {
10641        let win = fresh_window();
10642        assert!(win.find_next_text_node(&DomId::ROOT_ID, NodeId::new(0)).is_none());
10643        assert!(win.find_prev_text_node(&DomId::ROOT_ID, NodeId::new(0)).is_none());
10644    }
10645
10646    #[test]
10647    fn text_node_navigation_walks_a_real_dom_without_running_off_the_end() {
10648        let dom = StyledDom::create_from_dom(
10649            Dom::create_body().with_child(Dom::create_text("hello world")),
10650        );
10651        let win = laid_out(dom, 200.0, 150.0);
10652        let node_count = win
10653            .get_layout_result(&DomId::ROOT_ID)
10654            .unwrap()
10655            .styled_dom
10656            .node_hierarchy
10657            .len();
10658        // Searching forward from the very last node must terminate at None.
10659        let last = NodeId::new(node_count.saturating_sub(1));
10660        assert!(
10661            win.find_next_text_node(&DomId::ROOT_ID, last).is_none(),
10662            "no node exists after the last one"
10663        );
10664        // Searching backward from node 0 must terminate at None too.
10665        assert!(
10666            win.find_prev_text_node(&DomId::ROOT_ID, NodeId::new(0)).is_none(),
10667            "no node exists before the first one"
10668        );
10669    }
10670
10671    #[test]
10672    fn resize_window_relayouts_and_returns_a_display_list() {
10673        let mut win = laid_out(fixture_dom(), 200.0, 150.0);
10674        let rr = RendererResources::default();
10675        let sc = ExternalSystemCallbacks::rust_internal();
10676        let mut dbg = None;
10677        // resize_window consumes a fresh StyledDom (as the real caller re-derives
10678        // it each frame from the app's layout callback).
10679        let dl = win
10680            .resize_window(fixture_dom(), size(400.0, 300.0), &rr, &sc, &mut dbg)
10681            .expect("resize_window must succeed");
10682        // The returned display list belongs to the freshly laid-out DOM.
10683        let _ = dl;
10684        assert!(
10685            win.get_layout_result(&DomId::ROOT_ID).is_some(),
10686            "resize must leave a laid-out DOM behind"
10687        );
10688    }
10689}