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    pub relayout_iterations: u32,
445    /// Number of `regenerate_layout()` (i.e. `layout_callback`) runs since the
446    /// last counter reset.
447    pub dom_regenerations: u32,
448    /// `true` if `MAX_EVENT_RECURSION_DEPTH` was ever hit since the last reset.
449    /// Today the engine only `log_warn`s on this; this flag is what lets a test
450    /// turn an invalidation loop into a red assertion instead of a silent cap.
451    pub hit_depth_cap: bool,
452    /// The last terminal `ProcessEventResult` (as its `u8` discriminant order).
453    pub terminal_result: u8,
454}
455
456/// Global "please reset the frame-report counters" generation.
457///
458/// The `reset_frame_counters` debug op bumps this; every writer of the
459/// `FrameReport` calls [`FrameReport::sync_generation`] before it writes, which
460/// zeroes the counters + accumulated damage exactly once. This indirection
461/// exists because an E2E assertion only ever holds `&LayoutWindow` (through
462/// `CallbackInfo`) and cannot clear the counters itself.
463pub static FRAME_REPORT_RESET_GENERATION: AtomicUsize = AtomicUsize::new(0);
464
465/// Ask every window to zero its frame-report counters before the next write.
466pub fn request_frame_report_reset() {
467    FRAME_REPORT_RESET_GENERATION.fetch_add(1, Ordering::SeqCst);
468}
469
470impl FrameReport {
471    /// Zero the work counters + accumulated damage if a reset was requested.
472    /// Called by every writer of the report before it writes.
473    pub fn sync_generation(&mut self) {
474        let current = FRAME_REPORT_RESET_GENERATION.load(Ordering::SeqCst) as u64;
475        if current != self.reset_generation {
476            self.reset_generation = current;
477            self.reset_counters();
478        }
479    }
480
481    /// Zero the sticky work counters + accumulated damage.
482    pub fn reset_counters(&mut self) {
483        self.relayout_iterations = 0;
484        self.dom_regenerations = 0;
485        self.hit_depth_cap = false;
486        self.frames_since_reset = 0;
487        self.accumulated_paint_damage = FrameDamage::None;
488        self.accumulated_present_damage = FrameDamage::None;
489    }
490
491    /// Record the damage of a freshly rendered frame: it becomes the last-frame
492    /// damage AND is merged into the accumulated damage since the last reset.
493    pub fn record_frame(&mut self, paint: FrameDamage, present: FrameDamage) {
494        self.sync_generation();
495        self.frame_index = self.frame_index.wrapping_add(1);
496        self.frames_since_reset = self.frames_since_reset.saturating_add(1);
497        Self::merge_into(&mut self.accumulated_paint_damage, &paint);
498        Self::merge_into(&mut self.accumulated_present_damage, &present);
499        self.paint_damage = paint;
500        self.present_damage = present;
501    }
502
503    fn merge_into(acc: &mut FrameDamage, next: &FrameDamage) {
504        match (&mut *acc, next) {
505            (_, FrameDamage::None) | (FrameDamage::Full, _) => {}
506            (_, FrameDamage::Full) => *acc = FrameDamage::Full,
507            (FrameDamage::None, FrameDamage::Rects(r)) => *acc = FrameDamage::Rects(r.clone()),
508            (FrameDamage::Rects(a), FrameDamage::Rects(b)) => a.extend(b.iter().copied()),
509        }
510    }
511}
512
513/// Result of a layout pass for a single DOM, before display list generation
514#[derive(Debug)]
515pub struct DomLayoutResult {
516    /// The styled DOM that was laid out
517    pub styled_dom: StyledDom,
518    /// The layout tree with computed sizes and positions
519    pub layout_tree: LayoutTree,
520    /// Absolute positions of all nodes
521    pub calculated_positions: solver3::PositionVec,
522    /// The viewport used for this layout
523    pub viewport: LogicalRect,
524    /// The generated display list for this DOM.
525    pub display_list: DisplayList,
526    /// Stable scroll IDs computed from `node_data_hash`
527    /// Maps layout node index -> external scroll ID
528    pub scroll_ids: HashMap<usize, u64>,
529    /// Mapping from scroll IDs to DOM `NodeIds` for hit testing
530    /// This allows us to map `WebRender` scroll IDs back to DOM nodes
531    pub scroll_id_to_node_id: HashMap<u64, NodeId>,
532}
533
534/// State for tracking scrollbar drag interaction
535#[derive(Copy, Debug, Clone)]
536pub struct ScrollbarDragState {
537    pub hit_id: ScrollbarHitId,
538    pub initial_mouse_pos: LogicalPosition,
539    pub initial_scroll_offset: LogicalPosition,
540}
541
542/// Information about the last text edit operation
543/// Allows callbacks to query what changed during text input
544// Re-export PendingTextEdit from text_input manager
545pub use crate::managers::text_input::PendingTextEdit;
546
547/// Cached text layout constraints for a node
548/// These are the layout parameters that were used to shape the text
549#[derive(Debug, Clone)]
550#[derive(Default)]
551pub struct TextConstraintsCache {
552    /// Map from (`dom_id`, `node_id`) to their layout constraints
553    pub constraints: BTreeMap<(DomId, NodeId), UnifiedConstraints>,
554}
555
556
557/// A text node that has been edited since the last full layout.
558/// This allows us to perform lightweight relayout without rebuilding the entire DOM.
559#[derive(Debug, Clone)]
560pub struct DirtyTextNode {
561    /// The new inline content (text + images) after editing
562    pub content: Vec<InlineContent>,
563    /// The new cursor position after editing
564    pub cursor: Option<TextCursor>,
565    /// Whether this edit requires ancestor relayout (e.g., text grew taller)
566    pub needs_ancestor_relayout: bool,
567}
568
569/// Result of applying a text changeset
570#[derive(Debug)]
571pub struct TextChangesetResult {
572    /// Nodes that need dirty marking
573    pub dirty_nodes: Vec<DomNodeId>,
574    /// Whether the text size changed enough to require full re-layout
575    /// (e.g., for scroll container recomputation)
576    pub needs_relayout: bool,
577}
578
579/// A window-level layout manager that encapsulates all layout state and caching.
580///
581/// This struct owns the layout and text caches, and provides methods `dir_to`:
582/// - Perform initial layout
583/// - Incrementally update layout on DOM changes
584/// - Generate display lists for rendering
585/// - Handle window resizes efficiently
586/// - Manage multiple DOMs (for `VirtualViews`)
587#[derive(Debug)]
588pub struct LayoutWindow {
589    /// M12.7 web/headless: skip the GPU transform/opacity sync in
590    /// `layout_dom_recursive`. That sync only feeds the display list (which
591    /// the web backend skips), has no GPU, and `GpuValueCache::synchronize`
592    /// currently mis-lifts to wasm (out-of-bounds). Gated via this heap field
593    /// (a normal struct read — reliable in the lift, unlike the
594    /// `SKIP_DISPLAY_LIST` `__bss` static, whose store/load is inconsistent
595    /// in the lifted wasm). Default false → desktop is unaffected.
596    pub skip_gpu_sync: bool,
597    /// Per-frame damage + frame-work observability record. Written by the CPU
598    /// backend after each `render_frame` and by the event loop; read by E2E
599    /// assertions through `CallbackInfo::get_layout_window()`.
600    pub frame_report: FrameReport,
601    /// Fragmentation context for this window (continuous for screen, paged for print)
602    #[cfg(feature = "pdf")]
603    pub fragmentation_context: crate::paged::FragmentationContext,
604    /// Layout cache for solver3 (incremental layout tree) - for the root DOM
605    pub layout_cache: Solver3LayoutCache,
606    /// Text layout cache for text3 (shaped glyphs, line breaks, etc.)
607    pub text_cache: TextLayoutCache,
608    /// Font manager for loading and caching fonts
609    pub font_manager: FontManager<FontRef>,
610    /// Cache to store decoded images
611    pub image_cache: ImageCache,
612    /// CPU-backend resolution of `RenderImageCallback` images: the produced
613    /// image for each callback-image node, keyed by the ORIGINAL callback
614    /// image's hash. Populated by [`LayoutWindow::invoke_cpu_image_callbacks`]
615    /// before each CPU `render_frame`; consumed by cpurender (which otherwise
616    /// draws a grey placeholder for `DecodedImage::Callback`). Empty on the GPU
617    /// path (`WebRender` invokes callbacks itself via `process_image_callback_updates`).
618    pub cpu_image_callback_results: BTreeMap<ImageRefHash, ImageRef>,
619    /// Cached layout results for all DOMs (root + virtualized views)
620    pub layout_results: BTreeMap<DomId, DomLayoutResult>,
621    /// Scroll state manager for all nodes across all DOMs
622    pub scroll_manager: ScrollManager,
623    /// Gesture and drag manager for multi-frame interactions (moved from `FullWindowState`)
624    pub gesture_drag_manager: crate::managers::gesture::GestureAndDragManager,
625    /// Focus manager for keyboard focus and tab navigation
626    pub focus_manager: crate::managers::focus_cursor::FocusManager,
627    /// Unified text editing manager (cursor + selection + dirty flag)
628    pub text_edit_manager: crate::managers::text_edit::TextEditManager,
629    /// File drop manager for cursor state and file drag-drop
630    pub file_drop_manager: crate::managers::file_drop::FileDropManager,
631    /// Clipboard manager for system clipboard integration
632    pub clipboard_manager: crate::managers::clipboard::ClipboardManager,
633    /// Hover manager for tracking hit test history over multiple frames
634    pub hover_manager: crate::managers::hover::HoverManager,
635    /// `VirtualView` manager for all nodes across all DOMs
636    pub virtual_view_manager: VirtualViewManager,
637    /// GPU state manager for all nodes across all DOMs
638    pub gpu_state_manager: GpuStateManager,
639    /// Accessibility manager for screen reader support
640    pub a11y_manager: crate::managers::a11y::A11yManager,
641    /// Permission manager — cross-platform capability state for camera /
642    /// microphone / geolocation / biometric / sensors / photo-library /
643    /// notifications / etc. The platform backend drains
644    /// `take_pending_permission_events` once per frame and routes each
645    /// `Subscribe` / `Release` through `dll::desktop::extra::permission::apply_diff_events`.
646    /// See `SUPER_PLAN_2.md` §1.5 + research/08 for the architecture.
647    pub permission_manager: crate::managers::permission::PermissionManager,
648    /// Geolocation manager — `LocationFix` storage + per-frame diff
649    /// against the `NodeType::GeolocationProbe`s in the styled DOM.
650    /// The platform backend (`dll::desktop::extra::geolocation`)
651    /// drains diff events and starts / stops native
652    /// `CLLocationManager` / `LocationManager` / `geoclue`
653    /// subscriptions.
654    pub geolocation_manager: crate::managers::geolocation::GeolocationManager,
655    /// Cross-platform biometric-auth state — latest result + sync
656    /// availability. The platform backend (`dll::desktop::extra::biometric`)
657    /// shows the OS prompt and parks results in the async channel that the
658    /// layout pass folds into this manager (request-driven; no probe node).
659    pub biometric_manager: crate::managers::biometric::BiometricManager,
660    /// Cross-platform keyring state — outcome of the last secret-store op.
661    /// The platform backend (`dll::desktop::extra::keyring`) reads/writes
662    /// the OS keyring (Keychain / `KeyStore` / libsecret / `CredentialLocker`)
663    /// and parks results in the async channel the layout pass folds in here.
664    pub keyring_manager: crate::managers::keyring::KeyringManager,
665    /// Cross-platform motion-sensor state — latest accel / gyro / mag
666    /// reading. The platform backend (`dll::desktop::extra::sensors`)
667    /// subscribes to `CoreMotion` / Android `SensorManager` and parks
668    /// readings in the async channel the layout pass folds in here.
669    pub sensor_manager: crate::managers::sensors::SensorManager,
670    /// Cross-platform gamepad / controller state. The dll's platform backend
671    /// (gilrs / `GCController` / `InputDevice`) parks per-pad states in the async
672    /// channel the layout pass folds in here.
673    pub gamepad_manager: crate::managers::gamepad::GamepadManager,
674    /// Safe-area insets (notch / system-UI margins) for this window, in logical
675    /// px. Set by the platform shell (macOS NSScreen.safeAreaInsets, iOS
676    /// UIView.safeAreaInsets, Android `WindowInsets`); zero where none.
677    pub safe_area_insets: azul_css::system::SafeAreaInsets,
678    /// Timers associated with this window
679    pub timers: BTreeMap<TimerId, Timer>,
680    /// Threads running in the background for this window
681    pub threads: BTreeMap<ThreadId, Thread>,
682    /// Currently loaded fonts and images present in this renderer (window)
683    pub renderer_resources: RendererResources,
684    /// Renderer type: Hardware-with-software-fallback, pure software or pure hardware renderer?
685    pub renderer_type: Option<RendererType>,
686    /// Windows state of the window of (current frame - 1): initialized to None on startup
687    pub previous_window_state: Option<FullWindowState>,
688    /// Window state of this current window (current frame): initialized to the state of
689    /// `WindowCreateOptions`
690    pub current_window_state: FullWindowState,
691    /// A "document" in `WebRender` usually corresponds to one tab (i.e. in Azuls case, the whole
692    /// window).
693    pub document_id: DocumentId,
694    /// ID namespace under which every font / image for this window is registered
695    pub id_namespace: IdNamespace,
696    /// The "epoch" is a frame counter, to remove outdated images, fonts and OpenGL textures when
697    /// they're not in use anymore.
698    pub epoch: Epoch,
699    /// Currently GL textures inside the active `CachedDisplayList`
700    pub gl_texture_cache: GlTextureCache,
701    /// State for tracking scrollbar drag interaction
702    currently_dragging_thumb: Option<ScrollbarDragState>,
703    /// Text input manager - centralizes all text editing logic
704    pub text_input_manager: crate::managers::text_input::TextInputManager,
705    /// Undo/Redo manager for text editing operations
706    pub undo_redo_manager: crate::managers::undo_redo::UndoRedoManager,
707    /// Cached text layout constraints for each node
708    /// This allows us to re-layout text with the same constraints after edits
709    pub text_constraints_cache: TextConstraintsCache,
710    /// Tracks which nodes have been edited since last full layout.
711    /// Key: (`DomId`, `NodeId` of IFC root)
712    /// Value: The edited inline content that should be used for relayout
713    pub dirty_text_nodes: BTreeMap<(DomId, NodeId), DirtyTextNode>,
714    /// Pending `VirtualView` updates from callbacks (processed in next frame)
715    /// Map of `DomId` -> Set of `NodeIds` that need re-rendering
716    /// MWA-C-virtual_view: pending re-invocations now carry the QUEUE-TIME
717    /// reason so the user callback receives EdgeScrolled/BoundsExpanded/
718    /// `DomRecreated` instead of everything collapsing to `InitialRender`.
719    pub pending_virtual_view_updates: BTreeMap<DomId, BTreeMap<NodeId, VirtualViewCallbackReason>>,
720    /// Lifecycle events produced by DOM reconciliation, waiting to be dispatched.
721    ///
722    /// `regenerate_layout` appends `diff::reconcile_dom`'s `DiffResult.events` here
723    /// (Mount / Update / Resize `SyntheticEvents` — note: NOT Unmount; see
724    /// `pending_unmount_invocations`). The shell's event loop drains and
725    /// dispatches them via `dispatch_events_propagated`, which routes
726    /// `EventFilter::Component(_)` filters through `matches_component_filter`.
727    /// Drain-and-clear is the caller's responsibility; nothing inside
728    /// `LayoutWindow` ages or discards these on its own.
729    pub pending_lifecycle_events: Vec<azul_core::events::SyntheticEvent>,
730    /// Resolved `BeforeUnmount` invocations queued for dispatch.
731    ///
732    /// Unmount events target OLD `NodeIds` that disappear once the new layout
733    /// is committed to `layout_results`, so the shell cannot resolve them
734    /// via DOM lookup at dispatch time. `regenerate_layout` resolves the
735    /// callback against the OLD node data while it still has access, then
736    /// pushes a `(CoreCallbackData, SyntheticEvent)` pair here. The shell's
737    /// dispatcher invokes each pair directly.
738    pub pending_unmount_invocations: Vec<(
739        azul_core::callbacks::CoreCallbackData,
740        azul_core::events::SyntheticEvent,
741    )>,
742    /// System style (colors, fonts, metrics) for resolving system color keywords
743    /// Set via `set_system_style()` from the shell after window creation
744    pub system_style: Option<Arc<azul_css::system::SystemStyle>>,
745    /// Shared monitor list — initialized once at app start, updated by the platform
746    /// layer on monitor topology changes. Arc<Mutex> allows zero-cost sharing
747    /// across all `CallbackInfoRefData` without cloning the Vec each time.
748    pub monitors: Arc<std::sync::Mutex<MonitorVec>>,
749    /// XOR of all `tier2b.font_family_hash` values from the last resolved DOM.
750    /// Used to skip font chain resolution on frames where the font requirements
751    /// haven't changed (e.g. scroll-only frames).
752    font_stacks_hash: u64,
753    /// Snapshot of inline content before IME preedit injection.
754    /// Saved on first setMarkedText so each subsequent call injects into
755    /// clean original text instead of accumulating old preedits.
756    pre_preedit_content: Option<Vec<InlineContent>>,
757    /// Configurable input interpreter: maps raw events → `SystemChange` actions.
758    /// Default: `default_input_interpreter` (standard desktop keybindings).
759    /// Replace to implement vim, game controls, accessibility remaps, etc.
760    pub input_interpreter: azul_core::events::InputInterpreterCallback,
761    /// Configurable post-callback filter.
762    /// Default: `default_post_filter` (scroll-into-view after cursor ops).
763    pub post_filter: azul_core::events::PostFilterCallback,
764    /// Registered routes from `AppConfig`.  Set once at window creation.
765    /// Used by `CallbackChange::SwitchRoute` to look up layout callbacks.
766    pub routes: azul_core::resources::RouteVec,
767    /// ICU4X localizer handle for internationalized formatting (numbers, dates, lists, plurals)
768    /// Initialized from system language at startup, can be overridden
769    #[cfg(feature = "icu")]
770    pub icu_localizer: IcuLocalizerHandle,
771}
772
773const fn default_duration_500ms() -> Duration {
774    Duration::System(SystemTimeDiff::from_millis(500))
775}
776
777const fn default_duration_200ms() -> Duration {
778    Duration::System(SystemTimeDiff::from_millis(200))
779}
780
781/// Helper function to convert Duration to milliseconds
782///
783/// Duration is an enum with System (`std::time::Duration`) and Tick variants.
784/// We need to handle both cases for proper time calculations.
785#[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
786fn duration_to_millis(duration: Duration) -> u64 {
787    match duration {
788        #[cfg(feature = "std")]
789        Duration::System(system_diff) => {
790            let std_duration: std::time::Duration = system_diff.into();
791            std_duration.as_millis() as u64
792        }
793        #[cfg(not(feature = "std"))]
794        Duration::System(system_diff) => {
795            // Manual calculation: secs * 1000 + nanos / 1_000_000
796            system_diff.secs * 1000 + (system_diff.nanos / 1_000_000) as u64
797        }
798        Duration::Tick(tick_diff) => {
799            // Assume tick = 1ms for simplicity (platform-specific)
800            tick_diff.tick_diff
801        }
802    }
803}
804
805impl LayoutWindow {
806    /// Create a new layout window with empty caches.
807    ///
808    /// For full initialization with `WindowInternal` compatibility, use `new_full()`.
809    /// The single place every `LayoutWindow` field is initialized; the public
810    /// constructors below are thin wrappers over this (deduplicated 2026-05-21,
811    /// so adding a field touches one site instead of three).
812    fn from_font_manager(font_manager: FontManager<FontRef>) -> Self {
813        Self {
814            // M12.7 web/headless GPU-sync skip (default false → desktop unaffected)
815            skip_gpu_sync: false,
816            frame_report: FrameReport::default(),
817            #[cfg(feature = "pdf")]
818            fragmentation_context: crate::paged::FragmentationContext::new_continuous(800.0),
819            layout_cache: Solver3LayoutCache {
820                tree: None,
821                calculated_positions: Vec::new(),
822                viewport: None,
823                scroll_ids: HashMap::new(),
824                scroll_id_to_node_id: HashMap::new(),
825                counters: HashMap::new(),
826                float_cache: HashMap::new(),
827                cache_map: solver3::cache::LayoutCacheMap::default(),
828                previous_positions: Vec::new(),
829                cached_display_list: None,
830                prev_dom_ptr: 0,
831                prev_viewport: LogicalRect::zero(),
832            },
833            text_cache: TextLayoutCache::new(),
834            font_manager,
835            image_cache: ImageCache::default(),
836            cpu_image_callback_results: BTreeMap::new(),
837            layout_results: BTreeMap::new(),
838            scroll_manager: ScrollManager::new(),
839            gesture_drag_manager: crate::managers::gesture::GestureAndDragManager::new(),
840            focus_manager: crate::managers::focus_cursor::FocusManager::new(),
841            text_edit_manager: crate::managers::text_edit::TextEditManager::new(),
842            file_drop_manager: crate::managers::file_drop::FileDropManager::new(),
843            clipboard_manager: crate::managers::clipboard::ClipboardManager::new(),
844            hover_manager: crate::managers::hover::HoverManager::new(),
845            virtual_view_manager: VirtualViewManager::new(),
846            gpu_state_manager: GpuStateManager::new(
847                default_duration_500ms(),
848                default_duration_200ms(),
849            ),
850            a11y_manager: crate::managers::a11y::A11yManager::new(),
851            permission_manager: crate::managers::permission::PermissionManager::new(),
852            geolocation_manager: crate::managers::geolocation::GeolocationManager::new(),
853            biometric_manager: crate::managers::biometric::BiometricManager::new(),
854            keyring_manager: crate::managers::keyring::KeyringManager::new(),
855            sensor_manager: crate::managers::sensors::SensorManager::new(),
856            gamepad_manager: crate::managers::gamepad::GamepadManager::new(),
857            safe_area_insets: azul_css::system::SafeAreaInsets::default(),
858            timers: BTreeMap::new(),
859            threads: BTreeMap::new(),
860            renderer_resources: RendererResources::default(),
861            renderer_type: None,
862            previous_window_state: None,
863            current_window_state: FullWindowState::default(),
864            document_id: new_document_id(),
865            id_namespace: new_id_namespace(),
866            epoch: Epoch::new(),
867            gl_texture_cache: GlTextureCache::default(),
868            currently_dragging_thumb: None,
869            text_input_manager: crate::managers::text_input::TextInputManager::new(),
870            undo_redo_manager: crate::managers::undo_redo::UndoRedoManager::new(),
871            text_constraints_cache: TextConstraintsCache {
872                constraints: BTreeMap::new(),
873            },
874            dirty_text_nodes: BTreeMap::new(),
875            pending_virtual_view_updates: BTreeMap::new(),
876            pending_lifecycle_events: Vec::new(),
877            pending_unmount_invocations: Vec::new(),
878            system_style: None,
879            monitors: Arc::new(std::sync::Mutex::new(MonitorVec::from_const_slice(&[]))),
880            font_stacks_hash: 0,
881            pre_preedit_content: None,
882            input_interpreter: azul_core::events::InputInterpreterCallback::default(),
883            post_filter: azul_core::events::PostFilterCallback::default(),
884            routes: azul_core::resources::RouteVec::from_const_slice(&[]),
885            #[cfg(feature = "icu")]
886            icu_localizer: IcuLocalizerHandle::default(),
887        }
888    }
889
890    /// Create a new layout window with empty caches.
891    ///
892    /// For full initialization with `WindowInternal` compatibility, use `new_full()`.
893    /// # Errors
894    ///
895    /// Returns a `LayoutError` if the layout window cannot be initialized.
896    pub fn new(fc_cache: FcFontCache) -> Result<Self, solver3::LayoutError> {
897        Ok(Self::from_font_manager(FontManager::new(fc_cache)?))
898    }
899
900    /// Create a new layout window that shares already-parsed fonts with
901    /// Create a `LayoutWindow` from a `FontContext` — shares all font data,
902    /// starts with fresh layout cache, text cache, and all other state.
903    /// # Errors
904    ///
905    /// Returns a `LayoutError` if the layout window cannot be initialized.
906    pub fn from_font_context(ctx: &crate::text3::cache::FontContext) -> Result<Self, solver3::LayoutError> {
907        let fm = ctx.to_font_manager();
908        let fc_cache = fm.fc_cache.clone();
909        let parsed_fonts = fm.parsed_fonts.clone();
910        let mut lw = Self::new_with_shared_fonts(fc_cache, parsed_fonts)?;
911        lw.font_manager = fm;
912        Ok(lw)
913    }
914
915    /// Create from shared `fc_cache` + `parsed_fonts` Arcs.
916    /// # Errors
917    ///
918    /// Returns a `LayoutError` if the layout window cannot be initialized.
919    pub fn new_with_shared_fonts(
920        fc_cache: FcFontCache,
921        parsed_fonts: Arc<std::sync::Mutex<HashMap<rust_fontconfig::FontId, FontRef>>>,
922    ) -> Result<Self, solver3::LayoutError> {
923        Ok(Self::from_font_manager(FontManager::from_arc_shared(
924            fc_cache,
925            parsed_fonts,
926        )?))
927    }
928
929    /// Create a new layout window for paged media (PDF generation).
930    ///
931    /// This constructor initializes the layout window with a paged fragmentation context,
932    /// which will cause content to flow across multiple pages instead of a single continuous
933    /// scrollable container.
934    ///
935    /// # Arguments
936    /// - `fc_cache`: Font configuration cache for font loading
937    /// - `page_size`: The logical size of each page
938    ///
939    /// # Returns
940    /// A new `LayoutWindow` configured for paged output, or an error if initialization fails.
941    #[cfg(feature = "pdf")]
942    pub fn new_paged(
943        fc_cache: FcFontCache,
944        page_size: LogicalSize,
945    ) -> Result<Self, crate::solver3::LayoutError> {
946        let mut lw = Self::from_font_manager(FontManager::new(fc_cache)?);
947        lw.fragmentation_context = crate::paged::FragmentationContext::new_paged(page_size);
948        Ok(lw)
949    }
950
951    /// Perform layout on a styled DOM and generate a display list.
952    ///
953    /// This is the main entry point for layout. It handles:
954    /// - Incremental layout updates using the cached layout tree
955    /// - Text shaping and line breaking
956    /// - `VirtualView` callback invocation and recursive layout
957    /// - Display list generation for rendering
958    /// - Accessibility tree synchronization
959    ///
960    /// # Arguments
961    /// - `styled_dom`: The styled DOM to layout
962    /// - `window_state`: Current window dimensions and state
963    /// - `renderer_resources`: Resources for image sizing etc.
964    /// - `debug_messages`: Optional vector to collect debug/warning messages
965    ///
966    /// # Returns
967    /// The display list ready for rendering, or an error if layout fails.
968    /// # Errors
969    ///
970    /// Returns a `LayoutError` if layout fails.
971    pub fn layout_and_generate_display_list(
972        &mut self,
973        root_dom: StyledDom,
974        window_state: &FullWindowState,
975        renderer_resources: &RendererResources,
976        system_callbacks: &ExternalSystemCallbacks,
977        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
978    ) -> Result<(), solver3::LayoutError> {
979        // Clear previous results for a full relayout
980        self.layout_results.clear();
981
982        // CRITICAL: Reset VirtualView invocation flags so check_reinvoke() returns
983        // InitialRender for every tracked VirtualView. Without this, the VirtualViewManager
984        // still has was_invoked=true from the previous frame, so it skips
985        // re-invocation — but the child DOM was just destroyed by clear().
986        self.virtual_view_manager.reset_all_invocation_flags();
987
988        if let Some(msgs) = debug_messages.as_mut() {
989            msgs.push(LayoutDebugMessage::info(format!(
990                "[layout_and_generate_display_list] Starting layout for DOM with {} nodes",
991                root_dom.node_data.len()
992            )));
993        }
994
995        // Start recursive layout from the root DOM. Passes ownership — the
996        // StyledDom ends up inside `layout_results` without a clone.
997        let result = self.layout_dom_recursive(
998            root_dom,
999            window_state,
1000            renderer_resources,
1001            system_callbacks,
1002            debug_messages,
1003        );
1004
1005        if let Err(ref e) = result {
1006            if let Some(msgs) = debug_messages.as_mut() {
1007                msgs.push(LayoutDebugMessage::error(format!(
1008                    "[layout_and_generate_display_list] Layout FAILED: {e:?}"
1009                )));
1010            }
1011        } else if let Some(msgs) = debug_messages.as_mut() {
1012            msgs.push(LayoutDebugMessage::info(format!(
1013                "[layout_and_generate_display_list] Layout SUCCESS, layout_results count: {}",
1014                self.layout_results.len()
1015            )));
1016        }
1017
1018        // After successful layout, update the accessibility tree
1019        #[cfg(feature = "a11y")]
1020        if result.is_ok() {
1021            self.update_a11y_tree();
1022        }
1023
1024        // After layout, automatically scroll cursor into view if there's a focused text input
1025        if result.is_ok() {
1026            self.scroll_focused_cursor_into_view();
1027        }
1028
1029        result
1030    }
1031
1032    /// Run the real layout solver for a single `StyledDom` + viewport
1033    /// (taffy block/flex/grid → `layout_cache.calculated_positions`).
1034    ///
1035    /// Made `pub` for the web backend (`AzStartup_solveLayoutReal`),
1036    /// which lifts this from ARM to wasm to position the headless
1037    /// `StyledDom`. On web the display-list step inside `layout_document`
1038    /// is hot-patched out at lift time (web emits TLV patches, not a
1039    /// display list); positions are written to the cache *before* that
1040    /// step, so the lifted path still produces correct geometry.
1041    /// # Errors
1042    ///
1043    /// Returns a `LayoutError` if recursive layout fails.
1044    /// Measure a DOM headlessly: style + lay it out against `available`
1045    /// constraints using this window's fonts, images and system style,
1046    /// WITHOUT touching the window's live layout state (fresh scratch
1047    /// caches; nothing is written to `layout_results` / `layout_cache`).
1048    /// Returns the union of all laid-out node bounds — the DOM's actual
1049    /// content extent, even when the root is viewport-clamped.
1050    ///
1051    /// Primary use: `VirtualView` item sizing — lay out one item's DOM at
1052    /// the target width with a very tall `available.height` (e.g.
1053    /// `1_000_000.0`) and read back the height to derive per-item extents
1054    /// and the virtual scroll size. Cost: a full cold style+layout pass per
1055    /// call — cache results per item template where possible.
1056    #[cfg(feature = "std")]
1057    pub fn measure_dom(&self, dom: Dom, available: LogicalSize) -> LogicalSize {
1058        let styled_dom = StyledDom::create_from_dom(dom);
1059        self.measure_styled_dom(&styled_dom, available)
1060    }
1061
1062    /// [`Self::measure_dom`] for an already-styled DOM.
1063    #[cfg(feature = "std")]
1064    pub fn measure_styled_dom(
1065        &self,
1066        styled_dom: &StyledDom,
1067        available: LogicalSize,
1068    ) -> LogicalSize {
1069        let mut scratch_cache = Solver3LayoutCache {
1070            tree: None,
1071            calculated_positions: Vec::new(),
1072            viewport: None,
1073            scroll_ids: HashMap::new(),
1074            scroll_id_to_node_id: HashMap::new(),
1075            counters: HashMap::new(),
1076            float_cache: HashMap::new(),
1077            cache_map: solver3::cache::LayoutCacheMap::default(),
1078            previous_positions: Vec::new(),
1079            cached_display_list: None,
1080            prev_dom_ptr: 0,
1081            prev_viewport: LogicalRect::zero(),
1082        };
1083        let mut scratch_text = TextLayoutCache::new();
1084        let viewport = LogicalRect::new(LogicalPosition::zero(), available);
1085        let external = ExternalSystemCallbacks::rust_internal();
1086
1087        let layout_result = solver3::layout_document(
1088            &mut scratch_cache,
1089            &mut scratch_text,
1090            styled_dom,
1091            viewport,
1092            &self.font_manager,
1093            &BTreeMap::new(),
1094            &BTreeMap::new(),
1095            &mut None,
1096            None, // gpu cache: render-time only, geometry doesn't need it
1097            &self.renderer_resources,
1098            self.id_namespace,
1099            styled_dom.dom_id,
1100            false,
1101            Vec::new(),
1102            None,
1103            &self.image_cache,
1104            self.system_style.clone(),
1105            external.get_system_time_fn,
1106        );
1107        if layout_result.is_err() {
1108            return LogicalSize::zero();
1109        }
1110
1111        // Union of every node's absolute bounds = true content extent
1112        // (root.used_size alone can be clamped to the viewport).
1113        let Some(tree) = scratch_cache.tree.as_ref() else {
1114            return LogicalSize::zero();
1115        };
1116        let mut max_x = 0.0f32;
1117        let mut max_y = 0.0f32;
1118        for (idx, node) in tree.nodes.iter().enumerate() {
1119            let Some(size) = node.used_size else { continue };
1120            let pos = solver3::pos_get(&scratch_cache.calculated_positions, idx)
1121                .unwrap_or(LogicalPosition::zero());
1122            max_x = max_x.max(pos.x + size.width);
1123            max_y = max_y.max(pos.y + size.height);
1124        }
1125        LogicalSize::new(max_x, max_y)
1126    }
1127
1128    /// # Errors
1129    ///
1130    /// Returns a [`solver3::LayoutError`] if the solver fails to lay out the
1131    /// root DOM or any child (`VirtualView` / iframe) DOM.
1132    pub fn layout_dom_recursive(
1133        &mut self,
1134        styled_dom: StyledDom,
1135        window_state: &FullWindowState,
1136        renderer_resources: &RendererResources,
1137        system_callbacks: &ExternalSystemCallbacks,
1138        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1139    ) -> Result<(), solver3::LayoutError> {
1140        // Child DOMs (VirtualView / iframe) must NOT lay out into the root's
1141        // live cache: the impl below writes tree + calculated_positions into
1142        // `self.layout_cache`, so a child pass CLOBBERS the root's geometry —
1143        // `get_node_layout_rect` and the next incremental relayout then read
1144        // the child's tree instead of the root's (live bug: azul-maps' header
1145        // laid out 640x0/None → toolbar invisible and unclickable while the
1146        // map child DOM rendered fine). Children lay out cold by design, so
1147        // give a child a fresh scratch cache and restore the root's cache
1148        // afterwards; the per-DOM snapshot lives in `layout_results`. Nested
1149        // children stack their swaps.
1150        let is_child_dom = styled_dom.dom_id.inner != 0;
1151        if is_child_dom {
1152            let saved_root_cache = core::mem::take(&mut self.layout_cache);
1153            let result = self.layout_dom_recursive_impl(
1154                styled_dom,
1155                window_state,
1156                renderer_resources,
1157                system_callbacks,
1158                debug_messages,
1159            );
1160            self.layout_cache = saved_root_cache;
1161            return result;
1162        }
1163        self.layout_dom_recursive_impl(
1164            styled_dom,
1165            window_state,
1166            renderer_resources,
1167            system_callbacks,
1168            debug_messages,
1169        )
1170    }
1171
1172    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
1173    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1174    fn layout_dom_recursive_impl(
1175        &mut self,
1176        styled_dom: StyledDom,
1177        window_state: &FullWindowState,
1178        renderer_resources: &RendererResources,
1179        system_callbacks: &ExternalSystemCallbacks,
1180        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1181    ) -> Result<(), solver3::LayoutError> {
1182        // Optional memory-breakdown print for the CSS property cache.
1183        // Gated on AZ_MEM_BREAKDOWN=1; off costs one env-var read on
1184        // the first call (`OnceLock`-cached) and nothing after.
1185        static MEM_BREAKDOWN_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1186        // Optional AZ_PROFILE=cpu dump: per-phase wall-clock timings from
1187        // `Probe::span` spans (layout, style, cascade, paint, text-shape,
1188        // callbacks, …). Drains the thread-local buffer once per pass so
1189        // the printout reflects ONE layout/relayout frame — which makes it
1190        // easy to see which phase spiked during a stuttering frame.
1191        static CPU_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1192        // Optional AZ_PROFILE=cascade dump: top-N CSS properties by
1193        // cascade-walk count per layout pass. Narrow diagnostic for
1194        // prop-cache triage — not a general CPU profile.
1195        static CASCADE_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1196
1197        let dom_id = if styled_dom.dom_id.inner == 0 {
1198            DomId::ROOT_ID
1199        } else {
1200            styled_dom.dom_id
1201        };
1202
1203        // Children enter with a fresh scratch cache (see the wrapper above);
1204        // reset_incremental() is kept as belt-and-braces for any direct
1205        // callers and is a no-op on a fresh cache.
1206        if dom_id != DomId::ROOT_ID {
1207            self.layout_cache.reset_incremental();
1208        }
1209
1210        let viewport = LogicalRect {
1211            origin: LogicalPosition::zero(),
1212            size: window_state.size.dimensions,
1213        };
1214
1215        // Get the platform from system_style, falling back to compile-time detection
1216        let platform = self.system_style.as_ref().map_or_else(azul_css::system::Platform::current, |s| s.platform.clone());
1217
1218        // Font Resolution And Loading
1219        // This must happen BEFORE layout_document() is called
1220        {
1221            use crate::{
1222                solver3::getters::collect_and_resolve_font_chains_with_registration,
1223                text3::default::PathLoader,
1224            };
1225
1226            // Per-node font dirty tracking (P4):
1227            // Check font_dirty_nodes populated by build_compact_cache(),
1228            // which compares each node's font_family_hash against the
1229            // previous frame. This replaces the collision-prone global XOR
1230            // approach: XOR(a,b,a,b) == 0 even though fonts changed.
1231            //
1232            // Additional guard: compute an FxHash signature of
1233            // `prev_font_hashes` and compare against the one we stashed
1234            // after the last successful chain resolution. If it matches,
1235            // the DOM's font stacks are identical to what's already in
1236            // `font_chain_cache` — no resolver call needed. This catches
1237            // the common "repeated layout on unchanged DOM" case that
1238            // `font_dirty_nodes.len() == 0` misses, because the dirty
1239            // list is only re-computed inside `build_compact_cache`,
1240            // which most layouts do NOT re-run.
1241            let compact_cache_ref = styled_dom.css_property_cache.ptr.compact_cache.as_ref();
1242            let font_dirty_count = compact_cache_ref
1243                .map_or(1, |cc| cc.font_dirty_nodes.len()); // if no compact cache, treat as dirty
1244
1245            let font_stacks_sig = compact_cache_ref.map(|cc| {
1246                // Fast polynomial rolling hash over the `prev_font_hashes`
1247                // slice. Mixes each u64 with a multiplier + bit-rotation,
1248                // which is collision-resistant enough for our one-at-a-time
1249                // "did this DOM's font stacks change" comparison and an
1250                // order of magnitude cheaper than SipHash for ~300 nodes.
1251                let mut h: u64 = 0xcbf2_9ce4_8422_2325;
1252                for &fh in &cc.prev_font_hashes {
1253                    h = h.rotate_left(13) ^ fh;
1254                    h = h.wrapping_mul(0x0100_0000_01b3);
1255                }
1256                h
1257            });
1258
1259            // Skip all font resolution steps only if the DOM's font stacks are
1260            // PROVABLY the same ones we last resolved: the signature over
1261            // `prev_font_hashes` must match the one stashed after the last
1262            // successful resolution, no node may be font-dirty, and the chain
1263            // cache must be populated.
1264            //
1265            // The `font_dirty_count == 0` clause used to be sufficient on its
1266            // own. That was wrong for a WHOLESALE DOM SWAP (`Update::RefreshDom`,
1267            // an e2e `mount`, a route change): the incoming StyledDom is a fresh
1268            // object whose compact cache has no dirty nodes relative to itself,
1269            // so the check said "nothing changed" and font resolution was skipped
1270            // ENTIRELY — a node with a brand-new `font-family` never got its font
1271            // loaded (parsed_fonts stayed flat even with eight distinct families
1272            // on screen) and silently rendered in the previous DOM's font. The
1273            // signature is what actually detects "these are different font
1274            // stacks", so it is now required, not merely an alternative.
1275            let font_requirements_unchanged = font_dirty_count == 0
1276                && font_stacks_sig.is_some()
1277                && font_stacks_sig == self.font_manager.last_resolved_font_stacks_sig
1278                && !self.font_manager.font_chain_cache.is_empty();
1279
1280            if font_requirements_unchanged {
1281                if let Some(msgs) = debug_messages.as_mut() {
1282                    msgs.push(LayoutDebugMessage::info(
1283                        "[FontLoading] Font requirements unchanged, skipping resolution (cached)".to_string(),
1284                    ));
1285                }
1286            } else {
1287                if let Some(msgs) = debug_messages.as_mut() {
1288                    msgs.push(LayoutDebugMessage::info(
1289                        "[FontLoading] Starting font resolution for DOM".to_string(),
1290                    ));
1291                }
1292
1293                // Merge font hash→families from compact cache into FontManager
1294                // so the reverse map accumulates across DOMs.
1295                if let Some(cc) = styled_dom.css_property_cache.ptr.compact_cache.as_ref() {
1296                    for (k, v) in &cc.font_hash_to_families {
1297                        self.font_manager.font_hash_to_families.insert(*k, v.clone());
1298                    }
1299                }
1300
1301                // Resolve chains (including the coverage-based prune
1302                // and the per-document scripts_hint), then delegate
1303                // the load-the-missing-ones dance to FontManager's
1304                // shared helper. Same logic that lives at
1305                // `FontContext::load_fonts_for_chains` and the CPU
1306                // rasterizer's preview pre-fill — one implementation,
1307                // three callers.
1308                crate::probe::sample_peak_rss("rss:before_font_chain");
1309                let mut chains = {
1310                    let _p = crate::probe::Probe::span("font_chain_resolve");
1311                    collect_and_resolve_font_chains_with_registration(
1312                        &styled_dom, &self.font_manager.fc_cache, &self.font_manager, &platform,
1313                    )
1314                };
1315                // [g80] localize where font_chain_cache drops to 0: chains right after collect_and_resolve.
1316                unsafe { crate::az_mark(0x60770_u32, chains.chains.len() as u32); }
1317                // WEB-LIFT last resort (the DEFINITIVE spot — the layout's own `chains` that
1318                // feed load_missing_for_chains below): the lifted font-query path can leave a
1319                // chain with NO fonts even when a fallback IS registered (generic→OS-name +
1320                // token/unicode query is lift-fragile). Append the first registered font to any
1321                // empty chain so load_missing loads it + text shapes instead of measuring 0.
1322                // Done here (azul-layout), NOT rust-fontconfig (which re-codegens the fragile
1323                // with_memory_fonts into a trapping shape).
1324                for chain in chains.chains.values_mut() {
1325                    let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
1326                        + chain.unicode_fallbacks.len();
1327                    if total == 0 {
1328                        if let Some((pattern, id)) = self.font_manager.fc_cache.list().first() {
1329                            chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
1330                                id: *id,
1331                                unicode_ranges: pattern.unicode_ranges.clone(),
1332                                fallbacks: Vec::new(),
1333                            });
1334                        }
1335                    }
1336                }
1337                // [g80] chains after the window.rs last-resort loop (values_mut path).
1338                unsafe { crate::az_mark(0x60774_u32, chains.chains.len() as u32); }
1339                // [az-web-lift 2026-06-05] REMOVED a WASM-ONLY diagnostic probe that computed
1340                // nchains/total_fonts/nreg here purely to write debug markers. Its
1341                // `chains.chains.values().map(|c| …).sum()` closure-iterator chain (and/or the
1342                // `fc_cache.list()` call) MIS-LIFTS on the web backend → memory-access-OOB → a
1343                // slice panic whose abort path spins in the OUTLINED_FUNCTION_2 dispatch (localized
1344                // via the 0x406C0=0xC0DE0007 marker: the explicit `for …values_mut()` loop ABOVE
1345                // lifts fine, only this closure-iterator form traps — same class as the css.rs
1346                // `map+collect → for-loop` lift fix). It was revert-able scaffolding; the chains
1347                // are sound (the for-loop iterated them), so load_missing_for_chains below proceeds.
1348                crate::probe::sample_peak_rss("rss:after_font_chain");
1349
1350                // Phase 3 (scout-on-demand): no snapshot-refresh
1351                // step is needed any more. rust-fontconfig 4.1
1352                // made `FcFontCache` a shared-state handle backed
1353                // by `Arc<RwLock<_>>`, so builder writes performed
1354                // during the `request_and_resolve_with_scripts`
1355                // call above are immediately visible to every
1356                // downstream `FontFallbackChain::resolve_char`
1357                // lookup without any explicit refresh.
1358                if let Some(msgs) = debug_messages.as_mut() {
1359                    msgs.push(LayoutDebugMessage::info(format!(
1360                        "[FontLoading] Resolved {} font chains",
1361                        chains.len()
1362                    )));
1363                }
1364
1365                let loader = PathLoader::new();
1366                crate::probe::sample_peak_rss("rss:before_font_load");
1367                let failed = {
1368                    let _p = crate::probe::Probe::span("font_load_missing");
1369                    self.font_manager.load_missing_for_chains(
1370                        &chains,
1371                        |bytes, index| loader.load_font_shared(bytes, index),
1372                    )
1373                };
1374                crate::probe::sample_peak_rss("rss:after_font_load");
1375                if let Some(msgs) = debug_messages.as_mut() {
1376                    for (font_id, error) in &failed {
1377                        msgs.push(LayoutDebugMessage::warning(format!(
1378                            "[FontLoading] Failed to load font {font_id:?}: {error}"
1379                        )));
1380                    }
1381                }
1382
1383                // Step 5b (FONT GC): everything the fonts of this document do NOT
1384                // reference is garbage — the node that pulled it in is gone. The
1385                // font tables used to be append-only (`AUDIT-TODO` in
1386                // `azul_core::resources`), so a window that cycled fonts never
1387                // gave one back. Evict here, where the definitive keep-set (the
1388                // chains just resolved + the family hashes in this DOM's property
1389                // cache) is in hand. Anything wrongly evicted is re-loaded by the
1390                // next `load_missing_for_chains`.
1391                //
1392                // Only for the single-DOM case: `font_chain_cache` is REPLACED per
1393                // DOM (see `set_font_chain_cache_with_sig` below), so with iframes
1394                // the keep-set describes just the DOM being laid out, and evicting
1395                // on it could drop a sibling DOM's font between its layout and its
1396                // raster.
1397                let single_dom = self
1398                    .layout_results
1399                    .keys()
1400                    .all(|d| *d == dom_id);
1401                if single_dom {
1402                    let keep_ids =
1403                        solver3::getters::collect_font_ids_from_chains(&chains);
1404                    let keep_hashes: std::collections::HashSet<u64> = styled_dom
1405                        .css_property_cache
1406                        .ptr
1407                        .compact_cache
1408                        .as_ref()
1409                        .map(|cc| cc.font_hash_to_families.keys().copied().collect())
1410                        .unwrap_or_default();
1411                    let evicted = self
1412                        .font_manager
1413                        .garbage_collect_fonts(&keep_ids, &keep_hashes);
1414                    if evicted > 0 {
1415                        if let Some(msgs) = debug_messages.as_mut() {
1416                            msgs.push(LayoutDebugMessage::info(format!(
1417                                "[FontLoading] GC evicted {evicted} unreferenced font(s)"
1418                            )));
1419                        }
1420                    }
1421                }
1422
1423                // Step 5: Update font chain cache (and stash the
1424                // `prev_font_hashes` signature so the next layout with
1425                // an identical DOM skips the resolver entirely).
1426                let fc_chains = chains.into_fontconfig_chains();
1427                // [g80] fc_chains after into_fontconfig_chains (the BTreeMap rebuild) — does it drop them?
1428                unsafe { crate::az_mark(0x60778_u32, fc_chains.len() as u32); }
1429                self.font_manager.set_font_chain_cache_with_sig(
1430                    fc_chains,
1431                    font_stacks_sig,
1432                );
1433                // [g80] font_chain_cache right after set (does set_font_chain_cache_with_sig persist it?).
1434                unsafe { crate::az_mark(0x6077C_u32, (self.font_manager.font_chain_cache.len() as u32)); }
1435            }
1436        }
1437        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
1438
1439        // Synchronize CSS transform / opacity keys with the current StyledDom
1440        // BEFORE building the display list. `display_list.rs` reads
1441        // `css_transform_keys` / `css_current_transform_values` (and the
1442        // opacity equivalents) to emit reference frames and opacity stacking
1443        // contexts — these maps are only populated by
1444        // `GpuValueCache::synchronize`. The returned events are merged into
1445        // `gpu_state_manager.pending_changes` so the renderer can later push
1446        // matching WebRender transactions alongside scrollbar transform
1447        // events.
1448        // The GPU transform/opacity sync only feeds the display list
1449        // (reference frames + opacity stacking contexts read by
1450        // display_list.rs). The web backend skips the display list
1451        // (SKIP_DISPLAY_LIST) and has no GPU, so skip this too — layout
1452        // geometry never depends on it (transforms are render-time). This
1453        // also avoids GpuValueCache::synchronize, which currently mis-lifts
1454        // to wasm (out-of-bounds access). Desktop is unaffected.
1455        if !self.skip_gpu_sync {
1456            let mut transform_opacity_events = self
1457                .gpu_state_manager
1458                .get_or_create_cache(dom_id)
1459                .synchronize(&styled_dom);
1460            // MWA-C-gpu_state: drop the PREVIOUS pass's events before
1461            // merging this one's. `pending_changes` has zero drain call
1462            // sites (both renderers re-read cache values via
1463            // synchronize_gpu_values / from_gpu_cache instead), and
1464            // merge() appends Vecs — so this accumulated every layout's
1465            // events forever, an unbounded leak in any long-running app.
1466            // The field stays as a same-pass event record until a consumer
1467            // exists (see FOLLOW-UPS).
1468            drop(self.gpu_state_manager.take_pending_changes());
1469            self.gpu_state_manager
1470                .pending_changes
1471                .merge(&mut transform_opacity_events);
1472        }
1473        // M12.7: in the headless web path the GPU cache is empty (sync skipped),
1474        // and `.clone()` of an empty hashbrown table drives RawTable::clone's
1475        // RawIterRange — which mis-lifts to wasm and loops forever. Use a fresh
1476        // empty cache instead (geometry doesn't use it). Desktop unchanged.
1477        let gpu_cache = if self.skip_gpu_sync {
1478            GpuValueCache::default()
1479        } else {
1480            self.gpu_state_manager.get_or_create_cache(dom_id).clone()
1481        };
1482
1483        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
1484        let cursor_locations = self.text_edit_manager.build_cursor_locations();
1485
1486        let mut display_list = {
1487            let _p = crate::probe::Probe::span("solver3_layout_document");
1488            solver3::layout_document(
1489                &mut self.layout_cache,
1490                &mut self.text_cache,
1491                &styled_dom,
1492                viewport,
1493                &self.font_manager,
1494                &scroll_offsets,
1495                &BTreeMap::new(),
1496                debug_messages,
1497                Some(&gpu_cache),
1498                &self.renderer_resources,
1499                self.id_namespace,
1500                dom_id,
1501                cursor_is_visible,
1502                cursor_locations,
1503                self.text_edit_manager.preedit_text.clone(),
1504                &self.image_cache,
1505                self.system_style.clone(),
1506                system_callbacks.get_system_time_fn,
1507            )?
1508        };
1509
1510        // Hint the allocator to return freed pages after the layout pass
1511        // drops its transient allocations (intrinsic sizing Vecs, etc.).
1512        crate::probe::hint_purge_allocator();
1513
1514        // M12.7: the headless web path needs the per-node geometry. Everything below —
1515        // scrollbar TransformKey registration, GPU-cache opacity/transform sync,
1516        // update_scrollbar_transforms — is webrender/display-list bookkeeping that web
1517        // doesn't use, and it contains an ARM loop whose lift to wasm never terminates
1518        // (an opt-folded `br self`; routing value resolves to a webrender code pointer).
1519        // So publish the geometry (tree + calculated_positions) to `layout_results` HERE
1520        // — the same DomLayoutResult the code below would store at the tail — so the
1521        // headless extractor (get_node_size / get_node_position, which read
1522        // layout_results via dom_to_layout) finds it; then skip the GPU bookkeeping.
1523        // Desktop (skip_gpu_sync == false) is unchanged.
1524        if self.skip_gpu_sync {
1525            if let Some(tree) = self.layout_cache.tree.clone() {
1526                self.layout_results.insert(
1527                    dom_id,
1528                    DomLayoutResult {
1529                        styled_dom,
1530                        layout_tree: tree,
1531                        calculated_positions: self.layout_cache.calculated_positions.clone(),
1532                        viewport,
1533                        display_list: DisplayList::default(),
1534                        scroll_ids: self.layout_cache.scroll_ids.clone(),
1535                        scroll_id_to_node_id: self.layout_cache.scroll_id_to_node_id.clone(),
1536                    },
1537                );
1538            }
1539            return Ok(());
1540        }
1541
1542        if *MEM_BREAKDOWN_ENABLED.get_or_init(azul_core::profile::memory_enabled) {
1543            let sr = styled_dom.memory_report();
1544            eprintln!("[MEM] StyledDom ({} nodes) total={} KiB", sr.node_count, sr.total_bytes() / 1024);
1545            eprintln!("[MEM]   node_hierarchy    {:>7} KiB", sr.node_hierarchy_bytes / 1024);
1546            eprintln!("[MEM]   node_data         {:>7} KiB", sr.node_data_bytes / 1024);
1547            eprintln!("[MEM]   styled_nodes      {:>7} KiB", sr.styled_nodes_bytes / 1024);
1548            eprintln!("[MEM]   cascade_info      {:>7} KiB", sr.cascade_info_bytes / 1024);
1549            eprintln!("[MEM]   tag_ids           {:>7} KiB", sr.tag_ids_bytes / 1024);
1550            eprintln!("[MEM]   non_leaf_nodes    {:>7} KiB", sr.non_leaf_nodes_bytes / 1024);
1551            let bd = &sr.css_property_cache;
1552            eprintln!("[MEM]   CssPropertyCache  {:>7} KiB", bd.total_bytes() / 1024);
1553            eprintln!("[MEM]     cascaded_props   {:>6} KiB", bd.cascaded_props_bytes / 1024);
1554            eprintln!("[MEM]     css_props        {:>6} KiB", bd.css_props_bytes / 1024);
1555            eprintln!("[MEM]   computed_values   {:>7} KiB", bd.computed_values_bytes / 1024);
1556            eprintln!("[MEM]   user_overridden   {:>7} KiB", bd.user_overridden_bytes / 1024);
1557            eprintln!("[MEM]   global_css_props  {:>7} KiB", bd.global_css_props_bytes / 1024);
1558            eprintln!("[MEM]   compact_cache     {:>7} KiB", bd.compact_cache_bytes / 1024);
1559            eprintln!("[MEM]   resolved_font_sz  {:>7} KiB", bd.resolved_font_sizes_bytes / 1024);
1560
1561            // solver3 LayoutCache breakdown
1562            let sc = self.layout_cache.memory_report();
1563            eprintln!("[MEM] Solver3 LayoutCache total={} KiB", sc.total_bytes() / 1024);
1564            if let Some(tr) = &sc.tree_report {
1565                eprintln!("[MEM]   LayoutTree        {:>7} KiB  ({} nodes)", sc.tree_bytes / 1024, tr.node_count);
1566                eprintln!("[MEM]     hot              {:>6} KiB", tr.hot_bytes / 1024);
1567                eprintln!("[MEM]     warm             {:>6} KiB", tr.warm_bytes / 1024);
1568                eprintln!("[MEM]     warm.inline      {:>6} KiB  (shaped text in CachedInlineLayout)", tr.warm_inline_layout_bytes / 1024);
1569                eprintln!("[MEM]     warm.taffy       {:>6} KiB", tr.warm_taffy_cache_bytes / 1024);
1570                eprintln!("[MEM]     cold             {:>6} KiB", tr.cold_bytes / 1024);
1571                eprintln!("[MEM]     children_arena   {:>6} KiB", tr.children_arena_bytes / 1024);
1572                eprintln!("[MEM]     dom_to_layout    {:>6} KiB", tr.dom_to_layout_bytes / 1024);
1573            }
1574            eprintln!("[MEM]   cache_map         {:>7} KiB  (Taffy-style 9+1 slots per node)", sc.cache_map_bytes / 1024);
1575            eprintln!("[MEM]   calculated_pos    {:>7} KiB", sc.calculated_positions_bytes / 1024);
1576            eprintln!("[MEM]   previous_pos      {:>7} KiB", sc.previous_positions_bytes / 1024);
1577            eprintln!("[MEM]   float_cache       {:>7} KiB", sc.float_cache_bytes / 1024);
1578            eprintln!("[MEM]   counters          {:>7} KiB", sc.counters_bytes / 1024);
1579            eprintln!("[MEM]   scroll_ids        {:>7} KiB", sc.scroll_ids_bytes / 1024);
1580            eprintln!("[MEM]   cached_display    {:>7} KiB", sc.cached_display_list_bytes / 1024);
1581
1582            // text shaping cache breakdown
1583            let tc = self.text_cache.memory_report();
1584            eprintln!("[MEM] TextShapingCache total={} KiB", tc.total_bytes() / 1024);
1585            eprintln!("[MEM]   logical_items     {:>7} KiB  ({} entries)", tc.logical_items_bytes / 1024, tc.logical_items_entries);
1586            eprintln!("[MEM]   visual_items      {:>7} KiB  ({} entries)", tc.visual_items_bytes / 1024, tc.visual_items_entries);
1587            eprintln!("[MEM]   shaped_items      {:>7} KiB  ({} entries)", tc.shaped_items_bytes / 1024, tc.shaped_items_entries);
1588            eprintln!("[MEM]     glyph_bytes     {:>7} KiB", tc.shaped_glyph_bytes / 1024);
1589            eprintln!("[MEM]     cluster_text    {:>7} KiB", tc.shaped_cluster_text_bytes / 1024);
1590            eprintln!("[MEM]   per_item_shaped   {:>7} KiB  ({} entries)", tc.per_item_shaped_bytes / 1024, tc.per_item_shaped_entries);
1591
1592            let grand_total = sr.total_bytes() + sc.total_bytes() + tc.total_bytes();
1593            eprintln!("[MEM] --- GRAND TOTAL (StyledDom + Solver3 + TextCache) = {} KiB = {:.2} MiB ---",
1594                grand_total / 1024, grand_total as f64 / 1_048_576.0);
1595
1596            #[cfg(feature = "probe")]
1597            {
1598                let (rss, _virt) = crate::probe::current_rss_bytes();
1599                let peak = crate::probe::peak_rss_bytes_pub();
1600                eprintln!("[MEM] after layout: current rss={:.1} MiB  peak rss={:.1} MiB  (unreturned={:.1} MiB)",
1601                    rss as f64 / 1048576.0, peak as f64 / 1048576.0,
1602                    (peak.saturating_sub(rss)) as f64 / 1048576.0);
1603                eprintln!("[MEM] accounted / rss = {:.1}% — the gap is allocator overhead + unreturned transient pages + fonts/images + misc",
1604                    grand_total as f64 * 100.0 / (rss as f64).max(1.0));
1605            }
1606        }
1607
1608        if *CPU_ENABLED.get_or_init(azul_core::profile::cpu_enabled) {
1609            let events = crate::probe::Probe::drain();
1610            crate::probe::print_drained_events("layout pass", &events);
1611        }
1612
1613        if *CASCADE_ENABLED.get_or_init(azul_core::profile::cascade_enabled) {
1614            let counts = azul_core::prop_cache::drain_css_prop_counts();
1615            let total: usize = counts.iter().map(|(_, n)| *n).sum();
1616            if total > 0 {
1617                eprintln!("[CASCADE] cascade-walks this pass: {total} total");
1618                for (label, n) in counts.iter().take(20) {
1619                    eprintln!("[CASCADE]   {n:>8}  {label}");
1620                }
1621            }
1622        }
1623
1624        let tree = self
1625            .layout_cache
1626            .tree
1627            .clone()
1628            .ok_or(solver3::LayoutError::InvalidTree)?;
1629
1630        // Get scroll IDs from cache (they were computed during layout_document)
1631        let scroll_ids = self.layout_cache.scroll_ids.clone();
1632        let scroll_id_to_node_id = self.layout_cache.scroll_id_to_node_id.clone();
1633
1634        // Register scrollbar thumb TransformKeys from the display list into the GPU cache.
1635        // paint_scrollbars() creates TransformKey::unique() for each thumb. We need to
1636        // register those keys in the GPU cache so that update_scrollbar_transforms() can
1637        // update the values during GPU-only scroll (without display list rebuild).
1638        // Also register opacity keys from the display list the same way.
1639        {
1640            use crate::solver3::display_list::{DisplayListItem, ScrollbarDrawInfo};
1641            let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id);
1642            for item in &display_list.items {
1643                if let DisplayListItem::ScrollBarStyled { info } = item {
1644                    if let Some(hit_id) = &info.hit_id {
1645                        // Register transform keys
1646                        if let Some(transform_key) = info.thumb_transform_key {
1647                            match hit_id {
1648                                ScrollbarHitId::VerticalThumb(_, nid) => {
1649                                    if !gpu_cache.transform_keys.contains_key(nid) {
1650                                        gpu_cache.transform_keys.insert(*nid, transform_key);
1651                                        gpu_cache.current_transform_values.insert(*nid, info.thumb_initial_transform);
1652                                    }
1653                                }
1654                                ScrollbarHitId::HorizontalThumb(_, nid) => {
1655                                    if !gpu_cache.h_transform_keys.contains_key(nid) {
1656                                        gpu_cache.h_transform_keys.insert(*nid, transform_key);
1657                                        gpu_cache.h_current_transform_values.insert(*nid, info.thumb_initial_transform);
1658                                    }
1659                                }
1660                                _ => {}
1661                            }
1662                        }
1663
1664                        // Register opacity keys (same pattern as transform keys).
1665                        // The display list always generates an OpacityKey for each
1666                        // scrollbar. We mirror these into the GPU cache so that
1667                        // synchronize_scrollbar_opacity can update the values and
1668                        // synchronize_gpu_values can push them to WebRender.
1669                        //
1670                        // Initial opacity depends on visibility mode:
1671                        //   Always       → 1.0 (legacy scrollbar, always visible)
1672                        //   WhenScrolling → 0.0 (overlay scrollbar, hidden until scroll)
1673                        //   Auto         → 0.0 (same as WhenScrolling)
1674                        let initial_opacity = if info.visibility == azul_css::props::style::scrollbar::ScrollbarVisibilityMode::Always {
1675                            1.0
1676                        } else {
1677                            0.0
1678                        };
1679                        if let Some(opacity_key) = info.opacity_key {
1680                            match hit_id {
1681                                ScrollbarHitId::VerticalThumb(_, nid) => {
1682                                    let key = (dom_id, *nid);
1683                                    if let std::collections::hash_map::Entry::Vacant(e) = gpu_cache.scrollbar_v_opacity_keys.entry(key) {
1684                                        e.insert(opacity_key);
1685                                        gpu_cache.scrollbar_v_opacity_values.insert(key, initial_opacity);
1686                                    }
1687                                }
1688                                ScrollbarHitId::HorizontalThumb(_, nid) => {
1689                                    let key = (dom_id, *nid);
1690                                    if let std::collections::hash_map::Entry::Vacant(e) = gpu_cache.scrollbar_h_opacity_keys.entry(key) {
1691                                        e.insert(opacity_key);
1692                                        gpu_cache.scrollbar_h_opacity_values.insert(key, initial_opacity);
1693                                    }
1694                                }
1695                                _ => {}
1696                            }
1697                        }
1698                    }
1699                }
1700            }
1701        }
1702
1703        // Synchronize scrollbar transforms AFTER layout
1704        self.gpu_state_manager
1705            .update_scrollbar_transforms(dom_id, &self.scroll_manager, &tree);
1706
1707        // Scan for VirtualViews *after* the initial layout pass
1708        // Pass styled_dom directly — layout_results isn't populated yet at this point
1709        let vviews = Self::scan_for_virtual_views(&styled_dom, &tree, &self.layout_cache.calculated_positions);
1710
1711        if std::env::var("AZ_MAP_DEBUG").is_ok() {
1712            eprintln!("[vview] scan found {} VirtualView node(s): {:?}", vviews.len(),
1713                vviews.iter().map(|(n, b)| (n.index(), b.origin.x, b.origin.y, b.size.width, b.size.height)).collect::<Vec<_>>());
1714        }
1715
1716        for (node_id, bounds) in vviews {
1717            if let Some(child_dom_id) = self.invoke_virtual_view_callback_with_dom(
1718                dom_id,
1719                node_id,
1720                bounds,
1721                Some(&styled_dom),
1722                window_state,
1723                renderer_resources,
1724                system_callbacks,
1725                debug_messages,
1726            ) {
1727                // Replace the VirtualViewPlaceholder with the real VirtualView item.
1728                // The placeholder was emitted by generate_display_list() at the
1729                // correct position (outside any scroll frame, inside the parent clip).
1730                let mut replaced = false;
1731                for item in &mut display_list.items {
1732                    if let solver3::display_list::DisplayListItem::VirtualViewPlaceholder {
1733                        node_id: ref placeholder_nid,
1734                        bounds: ref placeholder_bounds,
1735                        clip_rect: ref placeholder_clip,
1736                        ..
1737                    } = item
1738                    {
1739                        if *placeholder_nid == node_id {
1740                            if std::env::var("AZ_MAP_DEBUG").is_ok() {
1741                                eprintln!(
1742                                    "[vview] placeholder swap: node={} placeholder_bounds={:?} scan_bounds={:?}",
1743                                    node_id.index(), placeholder_bounds.inner(), bounds
1744                                );
1745                            }
1746                            *item = solver3::display_list::DisplayListItem::VirtualView {
1747                                child_dom_id,
1748                                bounds: *placeholder_bounds,
1749                                clip_rect: *placeholder_clip,
1750                            };
1751                            replaced = true;
1752                            break;
1753                        }
1754                    }
1755                }
1756
1757                if !replaced {
1758                    // Fallback: if no placeholder found (shouldn't happen), append at end
1759                    display_list
1760                        .items
1761                        .push(solver3::display_list::DisplayListItem::VirtualView {
1762                            child_dom_id,
1763                            bounds: bounds.into(),
1764                            clip_rect: bounds.into(),
1765                        });
1766                }
1767            }
1768        }
1769
1770        // Store the final layout result for this DOM. `styled_dom` was passed
1771        // in by value, so we move it into the map without cloning.
1772        self.layout_results.insert(
1773            dom_id,
1774            DomLayoutResult {
1775                styled_dom,
1776                layout_tree: tree,
1777                calculated_positions: self.layout_cache.calculated_positions.clone(),
1778                viewport,
1779                display_list,
1780                scroll_ids,
1781                scroll_id_to_node_id,
1782            },
1783        );
1784
1785        // Clear scroll dirty flag — the new display list has
1786        // up-to-date scroll offsets embedded in PushScrollFrame items.
1787        self.scroll_manager.clear_scroll_dirty();
1788
1789        Ok(())
1790    }
1791
1792    fn scan_for_virtual_views(
1793        styled_dom: &StyledDom,
1794        layout_tree: &LayoutTree,
1795        calculated_positions: &solver3::PositionVec,
1796    ) -> Vec<(NodeId, LogicalRect)> {
1797        let node_data_container = styled_dom.node_data.as_container();
1798        layout_tree
1799            .nodes
1800            .iter()
1801            .enumerate()
1802            .filter_map(|(idx, node)| {
1803                let node_dom_id = node.dom_node_id?;
1804                let node_data = node_data_container.get(node_dom_id)?;
1805                if matches!(node_data.get_node_type(), NodeType::VirtualView) {
1806                    let pos = calculated_positions.get(idx).copied().unwrap_or_default();
1807                    let size = node.used_size.unwrap_or_default();
1808                    Some((node_dom_id, LogicalRect::new(pos, size)))
1809                } else {
1810                    None
1811                }
1812            })
1813            .collect()
1814    }
1815
1816    /// Invoke every `RenderImageCallback` image once and cache the produced
1817    /// image, keyed by the ORIGINAL callback image's hash.
1818    ///
1819    /// The CPU renderer (`cpurender`) cannot invoke image callbacks itself — it
1820    /// draws a grey placeholder for `DecodedImage::Callback` (e.g. the `AzulPaint`
1821    /// canvas: an `<img>` whose data is a callback). The GPU path handles this
1822    /// in `process_image_callback_updates` (producing `WebRender` textures); this
1823    /// is the CPU equivalent, producing images that `render_frame` blits via
1824    /// [`crate::cpurender`]'s image path.
1825    ///
1826    /// Pass the backend's GL context. In CPU render mode it is effectively
1827    /// `None`/unusable, so a callback like `AzulPaint`'s `render_canvas` takes its
1828    /// CPU branch and returns a raw `RawImage`. The result is stored in
1829    /// [`Self::cpu_image_callback_results`] and threaded into `CpuRenderState`.
1830    ///
1831    /// No-op (clears the cache) when there are no callback images, so normal
1832    /// apps pay nothing.
1833    pub fn invoke_cpu_image_callbacks(&mut self, gl_context: &OptionGlContextPtr) {
1834        use azul_core::resources::DecodedImage;
1835
1836        // Phase 1: collect every callback-image node + its laid-out size.
1837        let hidpi_factor = self.current_window_state.size.get_hidpi_factor();
1838        let mut to_invoke: Vec<(DomId, NodeId, ImageRefHash, HidpiAdjustedBounds, ImageRef)> =
1839            Vec::new();
1840        for (dom_id, lr) in &self.layout_results {
1841            let node_data_container = lr.styled_dom.node_data.as_container();
1842            for (idx, node) in lr.layout_tree.nodes.iter().enumerate() {
1843                let Some(node_dom_id) = node.dom_node_id else {
1844                    continue;
1845                };
1846                let Some(node_data) = node_data_container.get(node_dom_id) else {
1847                    continue;
1848                };
1849                if let NodeType::Image(image_ref) = node_data.get_node_type() {
1850                    if !matches!(image_ref.get_data(), DecodedImage::Callback(_)) {
1851                        continue;
1852                    }
1853                    let _ = idx;
1854                    let size = node.used_size.unwrap_or_default();
1855                    let bounds = HidpiAdjustedBounds {
1856                        logical_size: size,
1857                        hidpi_factor,
1858                    };
1859                    to_invoke.push((
1860                        *dom_id,
1861                        node_dom_id,
1862                        image_ref.get_hash(),
1863                        bounds,
1864                        // NodeType::Image wraps the ImageRef in BoxOrStatic; deref
1865                        // to clone the inner ImageRef (cheap, refcounted).
1866                        (**image_ref).clone(),
1867                    ));
1868                }
1869            }
1870        }
1871
1872        if to_invoke.is_empty() {
1873            self.cpu_image_callback_results.clear();
1874            return;
1875        }
1876
1877        // Phase 2: invoke each callback, collecting the produced image by the
1878        // ORIGINAL callback image's hash (so cpurender can look it up from the
1879        // unchanged display-list `Image` item). Results go into a local map so
1880        // the immutable borrows of image_cache/fc_cache don't conflict with the
1881        // mutable store at the end.
1882        let mut results: BTreeMap<ImageRefHash, ImageRef> = BTreeMap::new();
1883        for (dom_id, node_id, hash, bounds, image_ref) in to_invoke {
1884            let domnode_id = DomNodeId {
1885                dom: dom_id,
1886                node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
1887            };
1888            let info = crate::callbacks::RenderImageCallbackInfo::new(
1889                domnode_id,
1890                bounds,
1891                gl_context,
1892                &self.image_cache,
1893                &self.font_manager.fc_cache,
1894            );
1895            let produced = match image_ref.get_data() {
1896                DecodedImage::Callback(core_callback) if core_callback.callback.cb != 0 => {
1897                    let cb = crate::callbacks::RenderImageCallback::from_core(&core_callback.callback);
1898                    let refany = core_callback.refany.clone();
1899                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (cb.cb)(refany, info)))
1900                        .ok()
1901                }
1902                _ => None,
1903            };
1904            if let Some(img) = produced {
1905                results.insert(hash, img);
1906            }
1907        }
1908        self.cpu_image_callback_results = results;
1909    }
1910
1911    /// Handle a window resize by updating the cached layout.
1912    ///
1913    /// This method leverages solver3's incremental layout system to efficiently
1914    /// relayout only the affected parts of the tree when the window size changes.
1915    ///
1916    /// Returns the new display list after the resize.
1917    /// # Errors
1918    ///
1919    /// Returns a `LayoutError` if relayout on resize fails.
1920    pub fn resize_window(
1921        &mut self,
1922        styled_dom: StyledDom,
1923        new_size: LogicalSize,
1924        renderer_resources: &RendererResources,
1925        system_callbacks: &ExternalSystemCallbacks,
1926        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
1927    ) -> Result<DisplayList, solver3::LayoutError> {
1928        // Create a temporary FullWindowState with the new size
1929        let mut window_state = FullWindowState::default();
1930        window_state.size.dimensions = new_size;
1931
1932        let dom_id = styled_dom.dom_id;
1933
1934        self.layout_and_generate_display_list(
1935            styled_dom,
1936            &window_state,
1937            renderer_resources,
1938            system_callbacks,
1939            debug_messages,
1940        )?;
1941
1942        // Retrieve the display list from the layout result
1943        // We need to take ownership of the display list, so we replace it with an empty one
1944        self.layout_results
1945            .get_mut(&dom_id)
1946            .map(|result| std::mem::take(&mut result.display_list))
1947            .ok_or(solver3::LayoutError::InvalidTree)
1948    }
1949
1950    /// Clear all caches (useful for testing or when switching documents).
1951    pub fn clear_caches(&mut self) {
1952        self.layout_cache = Solver3LayoutCache {
1953            tree: None,
1954            calculated_positions: Vec::new(),
1955            viewport: None,
1956            scroll_ids: HashMap::new(),
1957            scroll_id_to_node_id: HashMap::new(),
1958            counters: HashMap::new(),
1959            float_cache: HashMap::new(),
1960            cache_map: solver3::cache::LayoutCacheMap::default(),
1961            previous_positions: Vec::new(),
1962                cached_display_list: None,
1963                prev_dom_ptr: 0,
1964                prev_viewport: LogicalRect::zero(),
1965        };
1966        self.text_cache = TextLayoutCache::new();
1967        self.layout_results.clear();
1968        self.scroll_manager = ScrollManager::new();
1969    }
1970
1971    /// Set scroll position for a node
1972    pub fn set_scroll_position(&mut self, dom_id: DomId, node_id: NodeId, scroll: ScrollPosition) {
1973        // Convert ScrollPosition to the internal representation
1974        #[cfg(feature = "std")]
1975        let now = Instant::System(std::time::Instant::now().into());
1976        #[cfg(not(feature = "std"))]
1977        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
1978
1979        self.scroll_manager.update_node_bounds(
1980            dom_id,
1981            node_id,
1982            scroll.parent_rect,
1983            scroll.children_rect,
1984            now.clone(),
1985        );
1986        self.scroll_manager
1987            .set_scroll_position(dom_id, node_id, scroll.children_rect.origin, now);
1988    }
1989
1990    /// Get scroll position for a node
1991    pub fn get_scroll_position(&self, dom_id: DomId, node_id: NodeId) -> Option<ScrollPosition> {
1992        let states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
1993        states.get(&node_id).copied()
1994    }
1995
1996    /// Set selection state for a DOM (no-op: `selection_manager` removed, `multi_cursor` handles this)
1997    pub fn set_selection(&mut self, _dom_id: DomId, _selection: SelectionState) {
1998        // no-op: selection_manager removed
1999    }
2000
2001    /// Get selection state for a DOM (always None: `selection_manager` removed)
2002    pub const fn get_selection(&self, _dom_id: DomId) -> Option<&SelectionState> {
2003        None
2004    }
2005
2006    /// Invoke a `VirtualView` callback and perform layout on the returned DOM.
2007    ///
2008    /// This is the entry point that looks up the necessary `VirtualViewNode` data before
2009    /// delegating to the core implementation logic.
2010    /// Invoke a `VirtualView` callback for a node. Returns the child `DomId` if the
2011    /// callback was invoked and the child DOM was laid out.
2012    ///
2013    /// This calls the `VirtualView`'s own `RefAny` callback (NOT the main `layout()` callback),
2014    /// swaps the child `StyledDom`, and re-layouts only the `VirtualView` sub-tree.
2015    pub fn invoke_virtual_view_callback(
2016        &mut self,
2017        parent_dom_id: DomId,
2018        node_id: NodeId,
2019        bounds: LogicalRect,
2020        window_state: &FullWindowState,
2021        renderer_resources: &RendererResources,
2022        system_callbacks: &ExternalSystemCallbacks,
2023        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2024    ) -> Option<DomId> {
2025        self.invoke_virtual_view_callback_with_dom(
2026            parent_dom_id, node_id, bounds, None,
2027            window_state, renderer_resources, system_callbacks, debug_messages,
2028        )
2029    }
2030
2031    /// Invoke a `VirtualView` callback. If `styled_dom_override` is provided, use it
2032    /// instead of reading from `self.layout_results` (needed during initial
2033    /// layout when `layout_results` isn't populated yet).
2034    fn invoke_virtual_view_callback_with_dom(
2035        &mut self,
2036        parent_dom_id: DomId,
2037        node_id: NodeId,
2038        bounds: LogicalRect,
2039        styled_dom_override: Option<&StyledDom>,
2040        window_state: &FullWindowState,
2041        renderer_resources: &RendererResources,
2042        system_callbacks: &ExternalSystemCallbacks,
2043        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2044    ) -> Option<DomId> {
2045        if let Some(msgs) = debug_messages {
2046            msgs.push(LayoutDebugMessage::info(format!(
2047                "invoke_virtual_view_callback called for node {node_id:?}"
2048            )));
2049        }
2050
2051        // Use the override styled_dom if provided, otherwise read from layout_results
2052        let virtual_view_node = if let Some(styled_dom) = styled_dom_override {
2053            let node_data_container = styled_dom.node_data.as_container();
2054            let node_data = node_data_container.get(node_id)?;
2055            node_data.get_virtual_view_node_ref()?.clone()
2056        } else {
2057            let layout_result = self.layout_results.get(&parent_dom_id)?;
2058            if let Some(msgs) = debug_messages {
2059                msgs.push(LayoutDebugMessage::info(format!(
2060                    "Got layout result for parent DOM {parent_dom_id:?}"
2061                )));
2062            }
2063            let node_data_container = layout_result.styled_dom.node_data.as_container();
2064            let node_data = node_data_container.get(node_id)?;
2065            if let Some(vv) = node_data.get_virtual_view_node_ref() { vv.clone() } else {
2066                if let Some(msgs) = debug_messages {
2067                    msgs.push(LayoutDebugMessage::info(format!(
2068                        "Node is NOT VirtualView, type = {:?}",
2069                        node_data.get_node_type()
2070                    )));
2071                }
2072                return None;
2073            }
2074        };
2075
2076        if let Some(msgs) = debug_messages {
2077            msgs.push(LayoutDebugMessage::info("Node is VirtualView type".to_string()));
2078        }
2079
2080        // Call the actual implementation with all necessary data
2081        self.invoke_virtual_view_callback_impl(
2082            parent_dom_id,
2083            node_id,
2084            &virtual_view_node,
2085            bounds,
2086            window_state,
2087            renderer_resources,
2088            system_callbacks,
2089            debug_messages,
2090        )
2091    }
2092
2093    /// Core implementation for invoking a `VirtualView` callback and managing the recursive layout.
2094    ///
2095    /// This method implements the 5 conditional re-invocation rules by coordinating
2096    /// with the `VirtualViewManager` and `ScrollManager`.
2097    ///
2098    /// # Returns
2099    ///
2100    /// `Some(child_dom_id)` if the callback was invoked and the child DOM was laid out.
2101    /// The parent's display list generator will then use this ID to reference the child's
2102    /// display list. Returns `None` if the callback was not invoked.
2103    #[allow(clippy::too_many_lines)] // 5 re-invocation rules + recursive layout in one flow
2104    fn invoke_virtual_view_callback_impl(
2105        &mut self,
2106        parent_dom_id: DomId,
2107        node_id: NodeId,
2108        virtual_view_node: &azul_core::dom::VirtualViewNode,
2109        bounds: LogicalRect,
2110        window_state: &FullWindowState,
2111        renderer_resources: &RendererResources,
2112        system_callbacks: &ExternalSystemCallbacks,
2113        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
2114    ) -> Option<DomId> {
2115        // Get current time from system callbacks for state updates
2116        let now = (system_callbacks.get_system_time_fn.cb)();
2117
2118        // Update node bounds in the scroll manager. This is necessary for the VirtualViewManager
2119        // to correctly detect edge scroll conditions.
2120        self.scroll_manager.update_node_bounds(
2121            parent_dom_id,
2122            node_id,
2123            bounds,
2124            LogicalRect::new(LogicalPosition::zero(), bounds.size), // Initial content_rect
2125            now,
2126        );
2127
2128        // Check with the VirtualViewManager to see if re-invocation is necessary.
2129        // It handles all 5 conditional rules.
2130        let Some(reason) = self.virtual_view_manager.check_reinvoke(
2131            parent_dom_id,
2132            node_id,
2133            &self.scroll_manager,
2134            bounds,
2135        ) else {
2136            // No re-invocation needed, but we still need the child_dom_id for the display list.
2137            return self
2138                .virtual_view_manager
2139                .get_nested_dom_id(parent_dom_id, node_id);
2140        };
2141
2142        if let Some(msgs) = debug_messages {
2143            msgs.push(LayoutDebugMessage::info(format!(
2144                "VirtualView ({parent_dom_id:?}, {node_id:?}) - Reason: {reason:?}"
2145            )));
2146        }
2147
2148        let scroll_offset = self
2149            .scroll_manager
2150            .get_current_offset(parent_dom_id, node_id)
2151            .unwrap_or_default();
2152
2153        let hidpi_factor = window_state.size.get_hidpi_factor();
2154
2155        // Create VirtualViewCallbackInfo with the most up-to-date state
2156        let mut callback_info = azul_core::callbacks::VirtualViewCallbackInfo::new(
2157            reason,
2158            &self.font_manager.fc_cache,
2159            &self.image_cache,
2160            window_state.theme,
2161            HidpiAdjustedBounds {
2162                logical_size: bounds.size,
2163                hidpi_factor,
2164            },
2165            bounds.size,
2166            scroll_offset,
2167            bounds.size,
2168            LogicalPosition::zero(),
2169        );
2170        // Inject the headless-measure hook so the VirtualView callback can
2171        // size item DOMs (VirtualViewCallbackInfo::measure_dom → the
2172        // trampoline below → LayoutWindow::measure_dom on scratch caches).
2173        // Same raw-window-pointer liveness contract as CallbackInfo.
2174        #[cfg(feature = "std")]
2175        callback_info.set_measure_dom_fn(
2176            virtual_view_measure_dom_trampoline,
2177            core::ptr::from_mut::<Self>(self).cast(),
2178        );
2179
2180        // Clone the user data for the callback
2181        let callback_data = virtual_view_node.refany.clone();
2182
2183        // Invoke the user's VirtualView callback
2184        let callback_return = (virtual_view_node.callback.cb)(callback_data, callback_info);
2185
2186        // Mark the VirtualView as invoked to prevent duplicate InitialRender calls
2187        self.virtual_view_manager
2188            .mark_invoked(parent_dom_id, node_id, reason);
2189
2190        // Get the child Dom from the callback's return value, then convert to StyledDom
2191        let mut child_styled_dom = match callback_return.dom {
2192            azul_core::dom::OptionDom::Some(dom) => {
2193                // Convert Dom → StyledDom (single deferred cascade pass)
2194                StyledDom::create_from_dom(dom)
2195            },
2196            azul_core::dom::OptionDom::None => {
2197                // If the callback returns None, it's an optimization hint.
2198                if reason == VirtualViewCallbackReason::InitialRender {
2199                    // For the very first render, create an empty div as a fallback.
2200                    let mut empty_dom = Dom::create_div();
2201                    let empty_css = Css::empty();
2202                    StyledDom::create(&mut empty_dom, empty_css)
2203                } else {
2204                    // For subsequent calls, returning None means "keep the old DOM".
2205                    // We just need to update the scroll info and return the existing child ID.
2206                    self.virtual_view_manager.update_virtual_view_info(
2207                        parent_dom_id,
2208                        node_id,
2209                        callback_return.scroll_size,
2210                        callback_return.virtual_scroll_size,
2211                    );
2212                    // Propagate virtual scroll bounds to ScrollManager
2213                    self.scroll_manager.update_virtual_scroll_bounds(
2214                        parent_dom_id,
2215                        node_id,
2216                        callback_return.virtual_scroll_size,
2217                        Some(callback_return.scroll_offset),
2218                    );
2219                    return self
2220                        .virtual_view_manager
2221                        .get_nested_dom_id(parent_dom_id, node_id);
2222                }
2223            }
2224        };
2225
2226        // Get or create a unique DomId for the VirtualView's content
2227        let child_dom_id = self
2228            .virtual_view_manager
2229            .get_or_create_nested_dom_id(parent_dom_id, node_id);
2230        child_styled_dom.dom_id = child_dom_id;
2231
2232        // Update the VirtualViewManager with the new scroll sizes from the callback
2233        self.virtual_view_manager.update_virtual_view_info(
2234            parent_dom_id,
2235            node_id,
2236            callback_return.scroll_size,
2237            callback_return.virtual_scroll_size,
2238        );
2239        // Propagate virtual scroll bounds to ScrollManager
2240        self.scroll_manager.update_virtual_scroll_bounds(
2241            parent_dom_id,
2242            node_id,
2243            callback_return.virtual_scroll_size,
2244            Some(callback_return.scroll_offset),
2245        );
2246
2247        // **RECURSIVE LAYOUT STEP**
2248        // Perform a full layout pass on the child DOM. This will recursively handle
2249        // any VirtualViews within this VirtualView. Ownership of the child DOM
2250        // is transferred into `layout_results`.
2251        self.layout_dom_recursive(
2252            child_styled_dom,
2253            window_state,
2254            renderer_resources,
2255            system_callbacks,
2256            debug_messages,
2257        )
2258        .ok()?;
2259
2260        Some(child_dom_id)
2261    }
2262
2263    // Query methods for callbacks
2264
2265    /// Get the size of a laid-out node
2266    pub fn get_node_size(&self, node_id: DomNodeId) -> Option<LogicalSize> {
2267        let layout_result = self.layout_results.get(&node_id.dom)?;
2268        let nid = node_id.node.into_crate_internal()?;
2269        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
2270        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
2271        let layout_index = *layout_indices.first()?;
2272        let layout_node = layout_result.layout_tree.get(layout_index)?;
2273        layout_node.used_size
2274    }
2275
2276    /// Get the position of a laid-out node
2277    pub fn get_node_position(&self, node_id: DomNodeId) -> Option<LogicalPosition> {
2278        let layout_result = self.layout_results.get(&node_id.dom)?;
2279        let nid = node_id.node.into_crate_internal()?;
2280        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
2281        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&nid)?;
2282        let layout_index = *layout_indices.first()?;
2283        let position = layout_result.calculated_positions.get(layout_index)?;
2284        Some(*position)
2285    }
2286
2287    /// Get the hit test bounds of a node from the display list
2288    ///
2289    /// This is more reliable than `get_node_position` + `get_node_size` because
2290    /// the display list always contains the correct final rendered positions,
2291    /// including for nodes that may not have entries in `calculated_positions`.
2292    pub fn get_node_hit_test_bounds(&self, node_id: DomNodeId) -> Option<LogicalRect> {
2293        use crate::solver3::display_list::DisplayListItem;
2294
2295        let layout_result = self.layout_results.get(&node_id.dom)?;
2296        let nid = node_id.node.into_crate_internal()?;
2297
2298        // Look up tag_id from the authoritative tag_ids_to_node_ids mapping
2299        let nid_encoded = NodeHierarchyItemId::from_crate_internal(Some(nid));
2300        let tag_id = layout_result.styled_dom.tag_ids_to_node_ids.iter()
2301            .find(|m| m.node_id == nid_encoded)?
2302            .tag_id
2303            .inner;
2304
2305        // Search the display list for a HitTestArea with matching tag
2306        // Note: tag is now (u64, u16) tuple where tag.0 is the TagId.inner
2307        for item in &layout_result.display_list.items {
2308            if let DisplayListItem::HitTestArea { bounds, tag } = item {
2309                if tag.0 == tag_id && bounds.0.size.width > 0.0 && bounds.0.size.height > 0.0 {
2310                    return Some(bounds.0);
2311                }
2312            }
2313        }
2314        None
2315    }
2316
2317    /// Get the parent of a node
2318    pub fn get_parent(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2319        let layout_result = self.layout_results.get(&node_id.dom)?;
2320        let nid = node_id.node.into_crate_internal()?;
2321        let parent_id = layout_result
2322            .styled_dom
2323            .node_hierarchy
2324            .as_container()
2325            .get(nid)?
2326            .parent_id()?;
2327        Some(DomNodeId {
2328            dom: node_id.dom,
2329            node: NodeHierarchyItemId::from_crate_internal(Some(parent_id)),
2330        })
2331    }
2332
2333    /// Get the first child of a node
2334    pub fn get_first_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2335        let layout_result = self.layout_results.get(&node_id.dom)?;
2336        let nid = node_id.node.into_crate_internal()?;
2337        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
2338        let hierarchy_item = node_hierarchy.get(nid)?;
2339        let first_child_id = hierarchy_item.first_child_id(nid)?;
2340        Some(DomNodeId {
2341            dom: node_id.dom,
2342            node: NodeHierarchyItemId::from_crate_internal(Some(first_child_id)),
2343        })
2344    }
2345
2346    /// Get the next sibling of a node
2347    pub fn get_next_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2348        let layout_result = self.layout_results.get(&node_id.dom)?;
2349        let nid = node_id.node.into_crate_internal()?;
2350        let next_sibling_id = layout_result
2351            .styled_dom
2352            .node_hierarchy
2353            .as_container()
2354            .get(nid)?
2355            .next_sibling_id()?;
2356        Some(DomNodeId {
2357            dom: node_id.dom,
2358            node: NodeHierarchyItemId::from_crate_internal(Some(next_sibling_id)),
2359        })
2360    }
2361
2362    /// Get the previous sibling of a node
2363    pub fn get_previous_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2364        let layout_result = self.layout_results.get(&node_id.dom)?;
2365        let nid = node_id.node.into_crate_internal()?;
2366        let prev_sibling_id = layout_result
2367            .styled_dom
2368            .node_hierarchy
2369            .as_container()
2370            .get(nid)?
2371            .previous_sibling_id()?;
2372        Some(DomNodeId {
2373            dom: node_id.dom,
2374            node: NodeHierarchyItemId::from_crate_internal(Some(prev_sibling_id)),
2375        })
2376    }
2377
2378    /// Get the last child of a node
2379    pub fn get_last_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
2380        let layout_result = self.layout_results.get(&node_id.dom)?;
2381        let nid = node_id.node.into_crate_internal()?;
2382        let last_child_id = layout_result
2383            .styled_dom
2384            .node_hierarchy
2385            .as_container()
2386            .get(nid)?
2387            .last_child_id()?;
2388        Some(DomNodeId {
2389            dom: node_id.dom,
2390            node: NodeHierarchyItemId::from_crate_internal(Some(last_child_id)),
2391        })
2392    }
2393
2394    /// Scan all fonts referenced in the current display lists (for resource GC).
2395    ///
2396    /// Iterates every `Text` and `TextLayout` item in each DOM's display list
2397    /// and collects the deterministic `FontKey` derived from the font hash.
2398    /// Callers can diff the result against `renderer_resources.currently_registered_fonts`
2399    /// to find fonts that are no longer used.
2400    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
2401    pub fn scan_used_fonts(&self) -> BTreeSet<FontKey> {
2402        use crate::solver3::display_list::DisplayListItem;
2403
2404        let mut fonts = BTreeSet::new();
2405        for layout_result in self.layout_results.values() {
2406            for item in &layout_result.display_list.items {
2407                let hash = match item {
2408                    DisplayListItem::Text { font_hash, .. } => font_hash.font_hash,
2409                    DisplayListItem::TextLayout { font_hash, .. } => font_hash.font_hash,
2410                    _ => continue,
2411                };
2412                // Deterministic FontKey from hash (same algorithm as wr_translate2)
2413                let ns = (hash >> 32) as u32;
2414                let ns = if ns == 0 { 1 } else { ns };
2415                fonts.insert(FontKey {
2416                    namespace: IdNamespace(ns),
2417                    key: hash,
2418                });
2419            }
2420        }
2421        fonts
2422    }
2423
2424    /// Scan all images referenced in the current display lists (for resource GC).
2425    ///
2426    /// Iterates every `Image` and `PushImageMaskClip` item and collects
2427    /// their `ImageRefHash`.  Callers can diff the result against the
2428    /// currently loaded images to find unused ones.
2429    pub fn scan_used_images(&self, _css_image_cache: &ImageCache) -> BTreeSet<ImageRefHash> {
2430        use crate::solver3::display_list::DisplayListItem;
2431
2432        let mut images = BTreeSet::new();
2433        for layout_result in self.layout_results.values() {
2434            for item in &layout_result.display_list.items {
2435                match item {
2436                    DisplayListItem::Image { image, .. } => {
2437                        images.insert(image.get_hash());
2438                    }
2439                    DisplayListItem::PushImageMaskClip { mask_image, .. } => {
2440                        images.insert(mask_image.get_hash());
2441                    }
2442                    _ => {}
2443                }
2444            }
2445        }
2446        images
2447    }
2448
2449    /// Helper function to convert `ScrollManager` to nested format for `CallbackInfo`
2450    fn get_nested_scroll_states(
2451        &self,
2452        dom_id: DomId,
2453    ) -> BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> {
2454        let mut nested = BTreeMap::new();
2455        let scroll_states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
2456        let mut inner = BTreeMap::new();
2457        for (node_id, scroll_pos) in scroll_states {
2458            inner.insert(
2459                NodeHierarchyItemId::from_crate_internal(Some(node_id)),
2460                scroll_pos,
2461            );
2462        }
2463        nested.insert(dom_id, inner);
2464        nested
2465    }
2466
2467    // Scroll Into View
2468
2469    /// Scroll a DOM node into view
2470    ///
2471    /// This is the main API for scrolling elements into view. It handles:
2472    /// - Finding scroll ancestors
2473    /// - Calculating scroll deltas
2474    /// - Applying scroll animations
2475    ///
2476    /// # Arguments
2477    ///
2478    /// * `node_id` - The DOM node to scroll into view
2479    /// * `options` - Scroll alignment and animation options
2480    /// * `now` - Current timestamp for animations
2481    ///
2482    /// # Returns
2483    ///
2484    /// A vector of scroll adjustments that were applied
2485    pub fn scroll_node_into_view(
2486        &mut self,
2487        node_id: DomNodeId,
2488        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
2489        now: Instant,
2490    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
2491        crate::managers::scroll_into_view::scroll_node_into_view(
2492            node_id,
2493            &self.layout_results,
2494            &mut self.scroll_manager,
2495            options,
2496            now,
2497        )
2498    }
2499
2500    /// Scroll a text cursor into view
2501    ///
2502    /// Used when the cursor moves within a contenteditable element.
2503    /// The cursor rect should be in node-local coordinates.
2504    pub fn scroll_cursor_into_view(
2505        &mut self,
2506        cursor_rect: LogicalRect,
2507        node_id: DomNodeId,
2508        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
2509        now: Instant,
2510    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
2511        crate::managers::scroll_into_view::scroll_cursor_into_view(
2512            cursor_rect,
2513            node_id,
2514            &self.layout_results,
2515            &mut self.scroll_manager,
2516            options,
2517            now,
2518        )
2519    }
2520
2521    // Timer Management
2522
2523    /// Add a timer to this window
2524    pub fn add_timer(&mut self, timer_id: TimerId, timer: Timer) {
2525        self.timers.insert(timer_id, timer);
2526    }
2527
2528    /// Remove a timer from this window
2529    pub fn remove_timer(&mut self, timer_id: &TimerId) -> Option<Timer> {
2530        self.timers.remove(timer_id)
2531    }
2532
2533    /// Get a reference to a timer
2534    pub fn get_timer(&self, timer_id: &TimerId) -> Option<&Timer> {
2535        self.timers.get(timer_id)
2536    }
2537
2538    /// Get a mutable reference to a timer
2539    pub fn get_timer_mut(&mut self, timer_id: &TimerId) -> Option<&mut Timer> {
2540        self.timers.get_mut(timer_id)
2541    }
2542
2543    /// Get all timer IDs
2544    pub fn get_timer_ids(&self) -> TimerIdVec {
2545        self.timers.keys().copied().collect::<Vec<_>>().into()
2546    }
2547
2548    /// Tick all timers (called once per frame)
2549    /// Returns a list of timer IDs that are ready to run
2550    // Instant is a ref-counted FFI clock handle; called by every dll backend's event loop by value.
2551    #[allow(clippy::needless_pass_by_value)]
2552    pub fn tick_timers(&mut self, current_time: Instant) -> Vec<TimerId> {
2553        let mut ready_timers = Vec::new();
2554
2555        for (timer_id, timer) in &mut self.timers {
2556            // Check if timer is ready to run
2557            // This logic should match the timer's internal state
2558            // For now, we'll just collect all timer IDs
2559            // The actual readiness check will be done when invoking
2560            ready_timers.push(*timer_id);
2561        }
2562
2563        ready_timers
2564    }
2565
2566    /// Calculate milliseconds until the next timer needs to fire.
2567    ///
2568    /// Returns `None` if there are no timers, meaning the caller can block indefinitely.
2569    /// Returns `Some(0)` if a timer is already overdue.
2570    /// Otherwise returns the minimum time in milliseconds until any timer fires.
2571    ///
2572    /// This is used by Linux (X11/Wayland) to set an efficient poll/select timeout
2573    /// instead of always polling every 16ms.
2574    pub fn time_until_next_timer_ms(
2575        &self,
2576        get_system_time_fn: &azul_core::task::GetSystemTimeCallback,
2577    ) -> Option<u64> {
2578        if self.timers.is_empty() {
2579            return None; // No timers - can block indefinitely
2580        }
2581
2582        let now = (get_system_time_fn.cb)();
2583        let mut min_ms: Option<u64> = None;
2584
2585        for timer in self.timers.values() {
2586            let next_run = timer.instant_of_next_run();
2587
2588            // Calculate time difference in milliseconds
2589            let ms_until = if next_run < now {
2590                0 // Timer is overdue
2591            } else {
2592                duration_to_millis(next_run.duration_since(&now))
2593            };
2594
2595            min_ms = Some(min_ms.map_or(ms_until, |current_min| current_min.min(ms_until)));
2596        }
2597
2598        min_ms
2599    }
2600
2601    // Thread Management
2602
2603    /// Add a thread to this window
2604    pub fn add_thread(&mut self, thread_id: ThreadId, thread: Thread) {
2605        self.threads.insert(thread_id, thread);
2606    }
2607
2608    /// Remove a thread from this window
2609    pub fn remove_thread(&mut self, thread_id: &ThreadId) -> Option<Thread> {
2610        self.threads.remove(thread_id)
2611    }
2612
2613    /// Get a reference to a thread
2614    pub fn get_thread(&self, thread_id: &ThreadId) -> Option<&Thread> {
2615        self.threads.get(thread_id)
2616    }
2617
2618    /// Get a mutable reference to a thread
2619    pub fn get_thread_mut(&mut self, thread_id: &ThreadId) -> Option<&mut Thread> {
2620        self.threads.get_mut(thread_id)
2621    }
2622
2623    /// Get all thread IDs
2624    pub fn get_thread_ids(&self) -> ThreadIdVec {
2625        self.threads.keys().copied().collect::<Vec<_>>().into()
2626    }
2627
2628    // Cursor Blinking Timer
2629
2630    /// Create the cursor blink timer
2631    ///
2632    /// This timer toggles cursor visibility at ~530ms intervals.
2633    /// It checks if enough time has passed since the last user input before blinking,
2634    /// to avoid blinking while the user is actively typing.
2635    pub fn create_cursor_blink_timer(&self, _window_state: &FullWindowState) -> Timer {
2636        use azul_core::task::{Duration, SystemTimeDiff};
2637        use crate::timer::{Timer, TimerCallback};
2638        use azul_core::refany::RefAny;
2639
2640        let interval_ms = crate::managers::text_edit::CURSOR_BLINK_INTERVAL_MS;
2641
2642        // Create a RefAny with a unit type - the timer callback doesn't need any data
2643        // The actual cursor state is in LayoutWindow.text_edit_manager.multi_cursor / blink
2644        let refany = RefAny::new(());
2645
2646        Timer {
2647            refany,
2648            node_id: None.into(),
2649            created: Instant::now(),
2650            run_count: 0,
2651            last_run: azul_core::task::OptionInstant::None,
2652            delay: azul_core::task::OptionDuration::None,
2653            interval: azul_core::task::OptionDuration::Some(Duration::System(SystemTimeDiff::from_millis(interval_ms))),
2654            timeout: azul_core::task::OptionDuration::None,
2655            callback: TimerCallback::create(cursor_blink_timer_callback),
2656        }
2657    }
2658
2659    // Tooltip-Delay Timer
2660
2661    /// Create a one-shot tooltip-delay timer.
2662    ///
2663    /// Fires exactly once after `hover_time_ms` elapsed. On expiry the callback
2664    /// looks up the currently-hovered node's `title` / `alt` / `aria-label`
2665    /// attribute and emits a `ShowTooltip` `CallbackChange`, then terminates.
2666    pub fn create_tooltip_delay_timer(&self, hover_time_ms: u32) -> Timer {
2667        use azul_core::task::{Duration, SystemTimeDiff};
2668        use crate::timer::{Timer, TimerCallback};
2669        use azul_core::refany::RefAny;
2670
2671        Timer {
2672            refany: RefAny::new(()),
2673            node_id: None.into(),
2674            created: Instant::now(),
2675            run_count: 0,
2676            last_run: azul_core::task::OptionInstant::None,
2677            delay: azul_core::task::OptionDuration::Some(Duration::System(
2678                SystemTimeDiff::from_millis(u64::from(hover_time_ms)),
2679            )),
2680            interval: azul_core::task::OptionDuration::None,
2681            timeout: azul_core::task::OptionDuration::None,
2682            callback: TimerCallback::create(tooltip_delay_timer_callback),
2683        }
2684    }
2685
2686    /// Determine what tooltip-timer action the shell should take given a hover
2687    /// transition.
2688    ///
2689    /// The platform event loop calls this once per event-dispatch cycle (after
2690    /// hit-testing has updated `hover_manager`). It compares the current and
2691    /// previous deepest hovered nodes and returns:
2692    ///
2693    /// - `Start` if the user just hovered onto a node that has a tooltip
2694    ///   source (`title` / `alt` / `aria-label`) — the shell should (re)start
2695    ///   `TOOLTIP_DELAY_TIMER_ID` with the returned Timer.
2696    /// - `Stop` if the hover moved off a tooltip-bearing node (or left the
2697    ///   window) — the shell should stop `TOOLTIP_DELAY_TIMER_ID` and hide
2698    ///   any currently-visible tooltip.
2699    /// - `NoChange` if the hovered node hasn't changed between frames.
2700    pub fn handle_hover_change_for_tooltip(&self, hover_time_ms: u32) -> TooltipTimerAction {
2701        let current_hover = self.hover_manager.current_hover_node();
2702        let previous_hover = self.hover_manager.previous_hover_node();
2703
2704        if current_hover == previous_hover {
2705            return TooltipTimerAction::NoChange;
2706        }
2707
2708        let dom_id = DomId { inner: 0 };
2709        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2710            return TooltipTimerAction::Stop;
2711        };
2712        let node_data_cont = layout_result.styled_dom.node_data.as_container();
2713
2714        let node_has_tooltip = |node_id: NodeId| -> bool {
2715            node_data_cont
2716                .get(node_id)
2717                .is_some_and(|n| n.get_accessible_label().is_some())
2718        };
2719
2720        match current_hover {
2721            Some(node) if node_has_tooltip(node) => {
2722                TooltipTimerAction::Start(self.create_tooltip_delay_timer(hover_time_ms))
2723            }
2724            _ => TooltipTimerAction::Stop,
2725        }
2726    }
2727
2728    /// Check if a node is contenteditable (internal version using `NodeId`)
2729    fn is_node_contenteditable_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
2730        use crate::solver3::getters::is_node_contenteditable;
2731
2732        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2733            return false;
2734        };
2735
2736        is_node_contenteditable(&layout_result.styled_dom, node_id)
2737    }
2738
2739    /// Check if a node is contenteditable with W3C-conformant inheritance.
2740    ///
2741    /// This traverses up the DOM tree to check if the node or any ancestor
2742    /// has `contenteditable="true"` set, respecting `contenteditable="false"`
2743    /// to stop inheritance.
2744    fn is_node_contenteditable_inherited_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
2745        use crate::solver3::getters::is_node_contenteditable_inherited;
2746
2747        let Some(layout_result) = self.layout_results.get(&dom_id) else {
2748            return false;
2749        };
2750
2751        is_node_contenteditable_inherited(&layout_result.styled_dom, node_id)
2752    }
2753
2754    /// Handle focus change for cursor blink timer management (W3C "flag and defer" pattern)
2755    ///
2756    /// This method implements the W3C focus/selection model:
2757    /// 1. Focus change is handled immediately (timer start/stop)
2758    /// 2. Cursor initialization is DEFERRED until after layout (via flag)
2759    ///
2760    /// The cursor is NOT initialized here because text layout may not be available
2761    /// during focus event handling. Instead, we set a flag that is consumed by
2762    /// `finalize_pending_focus_changes()` after the layout pass.
2763    ///
2764    /// # Parameters
2765    ///
2766    /// * `new_focus` - The newly focused node (None if focus is being cleared)
2767    /// * `current_window_state` - Current window state for timer creation
2768    ///
2769    /// # Returns
2770    ///
2771    /// A `CursorBlinkTimerAction` indicating what timer action the platform
2772    /// layer should take.
2773    pub fn handle_focus_change_for_cursor_blink(
2774        &mut self,
2775        new_focus: Option<DomNodeId>,
2776        current_window_state: &FullWindowState,
2777    ) -> CursorBlinkTimerAction {
2778        // Check if the new focus is on a contenteditable element
2779        // Use the inherited check for W3C conformance
2780        let contenteditable_info = new_focus.and_then(|focus_node| {
2781            focus_node.node.into_crate_internal().and_then(|node_id| {
2782                // Check if this node or any ancestor is contenteditable
2783                if self.is_node_contenteditable_inherited_internal(focus_node.dom, node_id) {
2784                    // Find the text node where the cursor should be placed
2785                    let text_node_id = self.find_last_text_child(focus_node.dom, node_id)
2786                        .unwrap_or(node_id);
2787                    Some((focus_node.dom, node_id, text_node_id))
2788                } else {
2789                    None
2790                }
2791            })
2792        });
2793
2794        // Determine the action based on current state and new focus
2795        let timer_was_active = self.text_edit_manager.blink.is_blink_timer_active();
2796
2797        if let Some((dom_id, container_node_id, text_node_id)) = contenteditable_info {
2798
2799            // W3C "flag and defer" pattern:
2800            // Set flag for cursor initialization AFTER layout pass
2801            self.focus_manager.set_pending_contenteditable_focus(
2802                dom_id,
2803                container_node_id,
2804                text_node_id,
2805            );
2806
2807            // Make cursor visible and record current time (even before actual initialization)
2808            let now = Instant::now();
2809            self.text_edit_manager.blink.reset_blink_on_input(now);
2810            self.text_edit_manager.blink.set_blink_timer_active(true);
2811
2812            if timer_was_active {
2813                // Timer already active, just continue
2814                CursorBlinkTimerAction::NoChange
2815            } else {
2816                // Need to start the timer
2817                let timer = self.create_cursor_blink_timer(current_window_state);
2818                CursorBlinkTimerAction::Start(timer)
2819            }
2820        } else {
2821            // Focus is moving away from contenteditable or being cleared
2822
2823            // Clear the cursor AND the pending focus flag
2824            self.text_edit_manager.clear_editing();
2825            self.focus_manager.clear_pending_contenteditable_focus();
2826
2827            if timer_was_active {
2828                // Need to stop the timer
2829                self.text_edit_manager.blink.set_blink_timer_active(false);
2830                CursorBlinkTimerAction::Stop
2831            } else {
2832                CursorBlinkTimerAction::NoChange
2833            }
2834        }
2835    }
2836
2837    /// Finalize pending focus changes after layout pass (W3C "flag and defer" pattern)
2838    ///
2839    /// This method should be called AFTER the layout pass completes. It checks if
2840    /// there's a pending contenteditable focus and initializes the cursor now that
2841    /// text layout information is available.
2842    ///
2843    /// # W3C Conformance
2844    ///
2845    /// In the W3C model:
2846    /// 1. Focus event fires during event handling (layout may not be ready)
2847    /// 2. Selection/cursor placement happens after layout is computed
2848    /// 3. The cursor is drawn at the position specified by the Selection
2849    ///
2850    /// This function implements step 2+3 by:
2851    /// - Checking the `cursor_needs_initialization` flag
2852    /// - Getting the (now available) text layout
2853    /// - Initializing the cursor at the correct position
2854    ///
2855    /// # Returns
2856    ///
2857    /// `true` if cursor was initialized, `false` if no pending focus or initialization failed.
2858    pub fn finalize_pending_focus_changes(&mut self) -> bool {
2859        // Take the pending focus info (this clears the flag)
2860        let Some(pending) = self.focus_manager.take_pending_contenteditable_focus() else {
2861            return false;
2862        };
2863
2864        // Bug B+H fix: If process_mouse_click_for_selection already positioned
2865        // the cursor in this node during the same event cycle, don't override it
2866        // with initialize_cursor_at_end. The click handler sets cursor on the IFC
2867        // root node (may differ from text_node_id), so check both.
2868        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))
2869            || 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))
2870        {
2871            return true;
2872        }
2873
2874        // Now we can safely get the text layout (layout pass has completed)
2875        let text_layout = self.get_inline_layout_for_node(pending.dom_id, pending.text_node_id).cloned();
2876
2877        // Initialize cursor at end of text
2878        // Get the last cluster cursor from text layout
2879        let cursor = text_layout.as_ref()
2880            .and_then(|layout| {
2881                layout.items.iter().rev()
2882                    .find_map(|item| if let ShapedItem::Cluster(c) = &item.item {
2883                        Some(TextCursor {
2884                            cluster_id: c.source_cluster_id,
2885                            affinity: CursorAffinity::Trailing,
2886                        })
2887                    } else { None })
2888            })
2889            .unwrap_or(TextCursor {
2890                cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
2891                affinity: CursorAffinity::Trailing,
2892            });
2893        self.text_edit_manager.initialize_editing(cursor, pending.dom_id, pending.text_node_id, 0);
2894        true
2895    }
2896
2897    /// Helper: Get inline layout for a node
2898    ///
2899    /// For text nodes that participate in an IFC, the inline layout is stored
2900    /// on the IFC root node (the block container), not on the text node itself.
2901    /// This method handles both cases:
2902    /// 1. The node has its own `inline_layout_result` (IFC root)
2903    /// 2. The node has `ifc_membership` pointing to the IFC root
2904    ///
2905    /// This is a thin wrapper around `LayoutTree::get_inline_layout_for_node`.
2906    pub fn get_inline_layout_for_node(
2907        &self,
2908        dom_id: DomId,
2909        node_id: NodeId,
2910    ) -> Option<&Arc<UnifiedLayout>> {
2911        let layout_result = self.layout_results.get(&dom_id)?;
2912
2913        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
2914        let layout_index = *layout_indices.first()?;
2915
2916        // Use the centralized LayoutTree method that handles IFC membership
2917        layout_result.layout_tree.get_inline_layout_for_node(layout_index)
2918    }
2919
2920    /// Single dispatch: (direction, step) → `UnifiedLayout` cursor movement.
2921    fn resolve_step_static(
2922        layout: &UnifiedLayout,
2923        cursor: &TextCursor,
2924        direction: azul_core::events::SelectionDirection,
2925        step: azul_core::events::SelectionStep,
2926    ) -> TextCursor {
2927        use azul_core::events::{SelectionDirection as D, SelectionStep as S};
2928        match (direction, step) {
2929            (D::Backward, S::Character) => layout.move_cursor_left(*cursor, &mut None),
2930            (D::Forward, S::Character) => layout.move_cursor_right(*cursor, &mut None),
2931            (D::Backward, S::Word) => layout.move_cursor_to_prev_word(*cursor, &mut None),
2932            (D::Forward, S::Word) => layout.move_cursor_to_next_word(*cursor, &mut None),
2933            (D::Backward, S::VisualLine) => layout.move_cursor_up(*cursor, &mut None, &mut None),
2934            (D::Forward, S::VisualLine) => layout.move_cursor_down(*cursor, &mut None, &mut None),
2935            (D::Backward, S::Line) => layout.move_cursor_to_line_start(*cursor, &mut None),
2936            (D::Forward, S::Line) => layout.move_cursor_to_line_end(*cursor, &mut None),
2937            (D::Backward, S::Document) => layout.get_first_cluster_cursor().unwrap_or(*cursor),
2938            (D::Forward, S::Document) => layout.get_last_cluster_cursor().unwrap_or(*cursor),
2939        }
2940    }
2941
2942    /// Apply a unified selection operation (navigation, extend, or delete).
2943    ///
2944    /// Single entry point that replaces the separate `ArrowKeyNavigation` and
2945    /// `DeleteTextSelection` handlers, as well as `handle_cursor_movement` and
2946    /// `handle_multi_cursor_movement`.
2947    pub fn apply_selection_op(
2948        &mut self,
2949        target: DomNodeId,
2950        op: &azul_core::events::SelectionOp,
2951    ) -> bool {
2952        use azul_core::events::{SelectionMode, SelectionStep, SelectionDirection};
2953
2954        let dom_id = target.dom;
2955        let Some(node_id) = target.node.into_crate_internal() else {
2956            return false;
2957        };
2958
2959        let layout = match self.get_inline_layout_for_node(dom_id, node_id) {
2960            Some(l) => l.clone(),
2961            None => return false,
2962        };
2963
2964        match op.mode {
2965            SelectionMode::Move | SelectionMode::Extend => {
2966                let extend = matches!(op.mode, SelectionMode::Extend);
2967                if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
2968                    for _ in 0..op.repeat.max(1) {
2969                        mc.move_all_cursors(extend, |c| {
2970                            Self::resolve_step_static(&layout, c, op.direction, op.step)
2971                        });
2972                    }
2973                }
2974                self.regenerate_display_list_for_dom(dom_id);
2975                true
2976            }
2977            SelectionMode::Delete => {
2978                // Step 1: if step > Character, expand cursors to ranges first
2979                if !matches!(op.step, SelectionStep::Character) {
2980                    if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
2981                        for _ in 0..op.repeat.max(1) {
2982                            mc.move_all_cursors(true, |c| {
2983                                Self::resolve_step_static(&layout, c, op.direction, op.step)
2984                            });
2985                        }
2986                    }
2987                }
2988                // Step 2: delete the expanded ranges (or single char for Character step)
2989                let forward = matches!(op.direction, SelectionDirection::Forward);
2990                self.delete_selection(target, forward).is_some()
2991            }
2992        }
2993    }
2994
2995    /// Helper: Move cursor using a movement function and return the new cursor if it changed
2996    pub fn move_cursor_in_node<F>(
2997        &self,
2998        dom_id: DomId,
2999        node_id: NodeId,
3000        movement_fn: F,
3001    ) -> Option<TextCursor>
3002    where
3003        F: FnOnce(&UnifiedLayout, &TextCursor) -> TextCursor,
3004    {
3005        let current_cursor = self.text_edit_manager.get_primary_cursor()?;
3006        let layout = self.get_inline_layout_for_node(dom_id, node_id)?;
3007
3008        let new_cursor = movement_fn(layout, &current_cursor);
3009
3010        // Only return if cursor actually moved
3011        if new_cursor == current_cursor {
3012            None
3013        } else {
3014            Some(new_cursor)
3015        }
3016    }
3017
3018    /// Helper: Handle cursor movement with optional selection extension.
3019    ///
3020    /// Updates the primary cursor in `TextEditManager.multi_cursor` to the given
3021    /// position and triggers a display list regeneration.
3022    pub fn handle_cursor_movement(
3023        &mut self,
3024        dom_id: DomId,
3025        node_id: NodeId,
3026        new_cursor: TextCursor,
3027        extend_selection: bool,
3028    ) {
3029        // Update multi_cursor with the new cursor position
3030        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3031            mc.set_single_cursor(new_cursor);
3032        }
3033
3034        self.regenerate_display_list_for_dom(dom_id);
3035    }
3036
3037    /// Move all cursors in a `MultiCursorState` using a movement function.
3038    /// This is the multi-cursor version of `handle_cursor_movement`.
3039    pub fn handle_multi_cursor_movement(
3040        &mut self,
3041        dom_id: DomId,
3042        node_id: NodeId,
3043        extend_selection: bool,
3044        move_fn: impl Fn(&TextCursor) -> TextCursor,
3045    ) {
3046        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
3047            mc.move_all_cursors(extend_selection, &move_fn);
3048        } else {
3049            // Single cursor fallback via get_primary_cursor
3050            if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
3051                let new_cursor = move_fn(&cursor);
3052                self.handle_cursor_movement(dom_id, node_id, new_cursor, extend_selection);
3053                return;
3054            }
3055        }
3056
3057        self.regenerate_display_list_for_dom(dom_id);
3058    }
3059
3060    // Gpu Value Cache Management
3061
3062    /// Get the GPU value cache for a specific DOM
3063    pub fn get_gpu_cache(&self, dom_id: &DomId) -> Option<&GpuValueCache> {
3064        self.gpu_state_manager.caches.get(dom_id)
3065    }
3066
3067    /// Get a mutable reference to the GPU value cache for a specific DOM
3068    pub fn get_gpu_cache_mut(&mut self, dom_id: &DomId) -> Option<&mut GpuValueCache> {
3069        self.gpu_state_manager.caches.get_mut(dom_id)
3070    }
3071
3072    /// Get or create a GPU value cache for a specific DOM
3073    pub fn get_or_create_gpu_cache(&mut self, dom_id: DomId) -> &mut GpuValueCache {
3074        self.gpu_state_manager.get_or_create_cache(dom_id)
3075    }
3076
3077    // Layout Result Access
3078
3079    /// Get a layout result for a specific DOM
3080    pub fn get_layout_result(&self, dom_id: &DomId) -> Option<&DomLayoutResult> {
3081        self.layout_results.get(dom_id)
3082    }
3083
3084    /// Get a mutable layout result for a specific DOM
3085    pub fn get_layout_result_mut(&mut self, dom_id: &DomId) -> Option<&mut DomLayoutResult> {
3086        self.layout_results.get_mut(dom_id)
3087    }
3088
3089    /// Get all DOM IDs that have layout results
3090    pub fn get_dom_ids(&self) -> DomIdVec {
3091        self.layout_results
3092            .keys()
3093            .copied()
3094            .collect::<Vec<_>>()
3095            .into()
3096    }
3097
3098    // Hit-Test Computation
3099
3100    /// Compute the cursor type hit-test from a full hit-test
3101    ///
3102    /// This determines which mouse cursor to display based on the CSS cursor
3103    /// properties of the hovered nodes.
3104    pub fn compute_cursor_type_hit_test(
3105        &self,
3106        hit_test: &crate::hit_test::FullHitTest,
3107    ) -> crate::hit_test::CursorTypeHitTest {
3108        crate::hit_test::CursorTypeHitTest::new(hit_test, self)
3109    }
3110
3111    /// Helper function to calculate scrollbar opacity based on activity time
3112    // Instant is a ref-counted FFI clock handle threaded through the scrollbar-fade path by value.
3113    #[allow(clippy::needless_pass_by_value)]
3114    fn calculate_scrollbar_opacity(
3115        last_activity: Option<Instant>,
3116        now: Instant,
3117        fade_delay: Duration,
3118        fade_duration: Duration,
3119    ) -> f32 {
3120        let Some(last_activity) = last_activity else {
3121            return 0.0;
3122        };
3123
3124        let time_since_activity = now.duration_since(&last_activity);
3125
3126        // Phase 1: Scrollbar stays fully visible during fade_delay
3127        if time_since_activity.div(&fade_delay) < 1.0 {
3128            return 1.0;
3129        }
3130
3131        // Phase 2: Fade out over fade_duration
3132        let time_into_fade = time_since_activity.div(&fade_delay) - 1.0;
3133        let fade_progress = (time_into_fade * fade_delay.div(&fade_duration)).min(1.0);
3134
3135        // Phase 3: Fully faded
3136        (1.0 - fade_progress).max(0.0)
3137    }
3138
3139    /// Synchronize scrollbar opacity values with the GPU value cache.
3140    ///
3141    /// This method updates GPU opacity keys for all scrollbars based on scroll activity
3142    /// tracked by the `ScrollManager`. It enables smooth scrollbar fading without
3143    /// requiring display list regeneration. Static method that takes individual
3144    /// components instead of `&mut self` to avoid borrow conflicts.
3145    ///
3146    /// # Arguments
3147    ///
3148    /// * `dom_id` - The DOM to synchronize scrollbar opacity for
3149    /// * `layout_tree` - The layout tree containing scrollbar information
3150    /// * `now` - Current timestamp for calculating fade progress
3151    /// * `fade_delay` - Delay before scrollbar starts fading (e.g., 500ms)
3152    /// * `fade_duration` - Duration of the fade animation (e.g., 200ms)
3153    ///
3154    /// # Returns
3155    ///
3156    /// A vector of GPU scrollbar opacity change events
3157    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3158    /// MWA-C-gpu_state: per-frame scrollbar GPU-cache refresh for the CPU
3159    /// render path. The `WebRender` transaction builders run
3160    /// `update_scrollbar_transforms` + `synchronize_scrollbar_opacity` every
3161    /// frame, but the CPU branches only ticked the scroll manager — overlay
3162    /// scrollbar thumb transforms and fade opacity in the cache refreshed
3163    /// only on full relayout, and `scrollbar_fade_active` could keep
3164    /// requesting redraws that changed nothing. Call before
3165    /// `CpuBackend::render_frame`. Uses the manager's own
3166    /// `fade_delay`/`fade_duration` (the WR paths still pass literals — see
3167    /// FOLLOW-UPS note).
3168    #[cfg(feature = "std")]
3169    pub fn refresh_scrollbar_gpu_cache_for_cpu_frame(&mut self) {
3170        let system_callbacks = ExternalSystemCallbacks::rust_internal();
3171        {
3172            let Self {
3173                ref layout_results,
3174                ref scroll_manager,
3175                ref mut gpu_state_manager,
3176                ..
3177            } = *self;
3178            for (dom_id, layout_result) in layout_results {
3179                drop(gpu_state_manager.update_scrollbar_transforms(
3180                    *dom_id,
3181                    scroll_manager,
3182                    &layout_result.layout_tree,
3183                ));
3184            }
3185        }
3186        let fade_delay = self.gpu_state_manager.fade_delay;
3187        let fade_duration = self.gpu_state_manager.fade_duration;
3188        let Self {
3189            ref layout_results,
3190            ref scroll_manager,
3191            ref mut gpu_state_manager,
3192            ..
3193        } = *self;
3194        for (dom_id, layout_result) in layout_results {
3195            drop(Self::synchronize_scrollbar_opacity(
3196                gpu_state_manager,
3197                scroll_manager,
3198                *dom_id,
3199                &layout_result.layout_tree,
3200                &system_callbacks,
3201                fade_delay,
3202                fade_duration,
3203            ));
3204        }
3205    }
3206
3207    #[allow(clippy::too_many_lines)] // one cohesive fade state machine per scrollbar; no natural split
3208    pub fn synchronize_scrollbar_opacity(
3209        gpu_state_manager: &mut GpuStateManager,
3210        scroll_manager: &ScrollManager,
3211        dom_id: DomId,
3212        layout_tree: &LayoutTree,
3213        system_callbacks: &ExternalSystemCallbacks,
3214        fade_delay: Duration,
3215        fade_duration: Duration,
3216    ) -> Vec<GpuScrollbarOpacityEvent> {
3217        let mut events = Vec::new();
3218        let mut any_opacity_nonzero = false;
3219        let gpu_cache = gpu_state_manager.caches.entry(dom_id).or_default();
3220
3221        // Get current time from system callbacks
3222        let now = (system_callbacks.get_system_time_fn.cb)();
3223
3224        // Iterate over all nodes with scrollbar info
3225        for (node_idx, node) in layout_tree.nodes.iter().enumerate() {
3226            // Check if node needs scrollbars
3227            let warm = layout_tree.warm(node_idx);
3228            let Some(scrollbar_info) = warm.and_then(|w| w.scrollbar_info.as_ref()) else {
3229                continue;
3230            };
3231
3232            let Some(node_id) = node.dom_node_id else {
3233                continue; // Skip anonymous boxes
3234            };
3235
3236            // Calculate current opacity from ScrollManager
3237            let vertical_opacity = if scrollbar_info.needs_vertical {
3238                Self::calculate_scrollbar_opacity(
3239                    scroll_manager.get_last_activity_time(dom_id, node_id),
3240                    now.clone(),
3241                    fade_delay,
3242                    fade_duration,
3243                )
3244            } else {
3245                0.0
3246            };
3247
3248            let horizontal_opacity = if scrollbar_info.needs_horizontal {
3249                Self::calculate_scrollbar_opacity(
3250                    scroll_manager.get_last_activity_time(dom_id, node_id),
3251                    now.clone(),
3252                    fade_delay,
3253                    fade_duration,
3254                )
3255            } else {
3256                0.0
3257            };
3258
3259            // Track whether any scrollbar is actively fading (0 < opacity < 1).
3260            // We do NOT count fully-visible scrollbars (opacity == 1.0) because
3261            // those are driven by the scroll physics timer already. We only need
3262            // extra frames for the fade-out interpolation phase. Including
3263            // opacity == 1.0 here causes an infinite repaint loop.
3264            if (vertical_opacity > 0.0 && vertical_opacity < 1.0)
3265                || (horizontal_opacity > 0.0 && horizontal_opacity < 1.0)
3266            {
3267                any_opacity_nonzero = true;
3268            }
3269
3270            // Handle vertical scrollbar
3271            // IMPORTANT: Always pre-register the opacity key when the node needs a
3272            // vertical scrollbar, even if the current opacity is 0.  The display list
3273            // generator reads the key from the GPU cache to embed a PropertyBinding
3274            // in the ScrollBarStyled item.  If we only create the key when opacity > 0,
3275            // the first display list won't have the binding, and GPU-only scroll
3276            // updates (build_image_only_transaction) can never make the scrollbar
3277            // visible because WebRender doesn't know about the binding.
3278            let key = (dom_id, node_id);
3279            if scrollbar_info.needs_vertical {
3280                let existing = gpu_cache.scrollbar_v_opacity_values.get(&key);
3281
3282                match existing {
3283                    None => {
3284                        let opacity_key = OpacityKey::unique();
3285                        gpu_cache.scrollbar_v_opacity_keys.insert(key, opacity_key);
3286                        gpu_cache
3287                            .scrollbar_v_opacity_values
3288                            .insert(key, vertical_opacity);
3289                        events.push(GpuScrollbarOpacityEvent::VerticalAdded(
3290                            dom_id,
3291                            node_id,
3292                            opacity_key,
3293                            vertical_opacity,
3294                        ));
3295                    }
3296                    Some(&old_opacity) if (old_opacity - vertical_opacity).abs() > 0.001 => {
3297                        let opacity_key = gpu_cache.scrollbar_v_opacity_keys[&key];
3298                        gpu_cache
3299                            .scrollbar_v_opacity_values
3300                            .insert(key, vertical_opacity);
3301                        events.push(GpuScrollbarOpacityEvent::VerticalChanged(
3302                            dom_id,
3303                            node_id,
3304                            opacity_key,
3305                            old_opacity,
3306                            vertical_opacity,
3307                        ));
3308                    }
3309                    _ => {}
3310                }
3311            } else {
3312                // Remove if scrollbar no longer needed
3313                if let Some(opacity_key) = gpu_cache.scrollbar_v_opacity_keys.remove(&key) {
3314                    gpu_cache.scrollbar_v_opacity_values.remove(&key);
3315                    events.push(GpuScrollbarOpacityEvent::VerticalRemoved(
3316                        dom_id,
3317                        node_id,
3318                        opacity_key,
3319                    ));
3320                }
3321            }
3322
3323            // Handle horizontal scrollbar (same logic as vertical above)
3324            if scrollbar_info.needs_horizontal {
3325                let existing = gpu_cache.scrollbar_h_opacity_values.get(&key);
3326
3327                match existing {
3328                    None => {
3329                        let opacity_key = OpacityKey::unique();
3330                        gpu_cache.scrollbar_h_opacity_keys.insert(key, opacity_key);
3331                        gpu_cache
3332                            .scrollbar_h_opacity_values
3333                            .insert(key, horizontal_opacity);
3334                        events.push(GpuScrollbarOpacityEvent::HorizontalAdded(
3335                            dom_id,
3336                            node_id,
3337                            opacity_key,
3338                            horizontal_opacity,
3339                        ));
3340                    }
3341                    Some(&old_opacity) if (old_opacity - horizontal_opacity).abs() > 0.001 => {
3342                        let opacity_key = gpu_cache.scrollbar_h_opacity_keys[&key];
3343                        gpu_cache
3344                            .scrollbar_h_opacity_values
3345                            .insert(key, horizontal_opacity);
3346                        events.push(GpuScrollbarOpacityEvent::HorizontalChanged(
3347                            dom_id,
3348                            node_id,
3349                            opacity_key,
3350                            old_opacity,
3351                            horizontal_opacity,
3352                        ));
3353                    }
3354                    _ => {}
3355                }
3356            } else {
3357                // Remove if scrollbar no longer needed
3358                if let Some(opacity_key) = gpu_cache.scrollbar_h_opacity_keys.remove(&key) {
3359                    gpu_cache.scrollbar_h_opacity_values.remove(&key);
3360                    events.push(GpuScrollbarOpacityEvent::HorizontalRemoved(
3361                        dom_id,
3362                        node_id,
3363                        opacity_key,
3364                    ));
3365                }
3366            }
3367        }
3368
3369        // Signal to the platform render loop that more frames are needed
3370        // to complete the scrollbar fade animation. The caller should
3371        // schedule a redraw while this flag is true.
3372        gpu_state_manager.scrollbar_fade_active = any_opacity_nonzero;
3373
3374        events
3375    }
3376
3377    /// Compute stable scroll IDs for all scrollable nodes in a layout tree
3378    ///
3379    /// This should be called after layout but before display list generation.
3380    /// It creates stable IDs based on `node_data_hash` that persist across frames.
3381    ///
3382    /// Returns:
3383    /// - `scroll_ids`: Map from layout node index -> external scroll ID
3384    /// - `scroll_id_to_node_id`: Map from scroll ID -> DOM `NodeId` (for hit testing)
3385    #[must_use] pub fn compute_scroll_ids(
3386        layout_tree: &LayoutTree,
3387        styled_dom: &StyledDom,
3388    ) -> (HashMap<usize, u64>, HashMap<u64, NodeId>) {
3389        use azul_css::props::layout::LayoutOverflow;
3390
3391        use crate::solver3::getters::{get_overflow_x, get_overflow_y};
3392
3393        let mut scroll_ids = HashMap::new();
3394        let mut scroll_id_to_node_id = HashMap::new();
3395
3396        // Iterate through all layout nodes
3397        for (layout_idx, node) in layout_tree.nodes.iter().enumerate() {
3398            let Some(dom_node_id) = node.dom_node_id else {
3399                continue;
3400            };
3401
3402            // Get the node state
3403            let styled_node_state = styled_dom
3404                .styled_nodes
3405                .as_container()
3406                .get(dom_node_id)
3407                .map(|n| n.styled_node_state)
3408                .unwrap_or_default();
3409
3410            // Check if this node has scroll overflow
3411            let overflow_x = get_overflow_x(styled_dom, dom_node_id, &styled_node_state);
3412            let overflow_y = get_overflow_y(styled_dom, dom_node_id, &styled_node_state);
3413
3414            let is_scrollable = overflow_x.is_scroll() || overflow_y.is_scroll();
3415
3416            if !is_scrollable {
3417                continue;
3418            }
3419
3420            // Generate stable scroll ID from node_data_fingerprint
3421            // Use a combined hash of the fingerprint fields to create a stable ID
3422            let scroll_id = {
3423                use std::hash::{Hash, Hasher, DefaultHasher};
3424                let mut h = DefaultHasher::new();
3425                if let Some(cold) = layout_tree.cold(layout_idx) {
3426                    cold.node_data_fingerprint.hash(&mut h);
3427                }
3428                h.finish()
3429            };
3430
3431            scroll_ids.insert(layout_idx, scroll_id);
3432            scroll_id_to_node_id.insert(scroll_id, dom_node_id);
3433        }
3434
3435        (scroll_ids, scroll_id_to_node_id)
3436    }
3437
3438    /// Get the layout rectangle for a specific DOM node in logical coordinates
3439    ///
3440    /// This is useful in callbacks to get the position and size of the hit node
3441    /// for positioning menus, tooltips, or other overlays.
3442    ///
3443    /// Returns None if the node is not currently laid out (e.g., display:none)
3444    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
3445    pub fn get_node_layout_rect(
3446        &self,
3447        node_id: DomNodeId,
3448    ) -> Option<LogicalRect> {
3449        // Get the layout tree from cache
3450        let layout_tree = self.layout_cache.tree.as_ref()?;
3451        { let _ = (0xE5_000002u32 | ((layout_tree.nodes.len() as u32 & 0xff) << 8)); }
3452
3453        // Find the layout node index corresponding to this DOM node
3454        // Convert NodeHierarchyItemId to Option<NodeId> for comparison
3455        let target_node_id = node_id.node.into_crate_internal();
3456        let Some(layout_idx) = layout_tree.nodes.iter().position(|node| node.dom_node_id == target_node_id) else { { let _ = (0xE5_0000FFu32); } return None; };
3457        { let _ = (0xE5_000003u32 | ((self.layout_cache.calculated_positions.len() as u32 & 0xfff) << 8)); }
3458
3459        // Get the calculated layout position from cache (already in logical units)
3460        let Some(calc_pos) = self.layout_cache.calculated_positions.get(layout_idx) else { { let _ = (0xE5_0000FEu32); } return None; };
3461
3462        // Get the layout node for size information
3463        let layout_node = layout_tree.nodes.get(layout_idx)?;
3464
3465        // Get the used size (the actual laid-out size)
3466        let Some(used_size) = layout_node.used_size else { { let _ = (0xE5_0000FDu32); } return None; };
3467        { let _ = (0xE5_000004u32); }
3468
3469        // Convert size to logical coordinates
3470        let hidpi_factor = self
3471            .current_window_state
3472            .size
3473            .get_hidpi_factor()
3474            .inner
3475            .get();
3476
3477        Some(LogicalRect::new(
3478            LogicalPosition::new(calc_pos.x, calc_pos.y),
3479            LogicalSize::new(
3480                used_size.width / hidpi_factor,
3481                used_size.height / hidpi_factor,
3482            ),
3483        ))
3484    }
3485
3486    /// Get the cursor rect for the currently focused text input node in ABSOLUTE coordinates.
3487    ///
3488    /// This returns the cursor position in absolute window coordinates (not accounting for
3489    /// scroll offsets). This is used for scroll-into-view calculations where you need to
3490    /// compare the cursor position with the scrollable container's bounds.
3491    ///
3492    /// Returns None if:
3493    /// - No node is focused
3494    /// - Focused node has no text cursor
3495    /// - Focused node has no layout
3496    /// - Text cache cannot find cursor position
3497    ///
3498    /// For IME positioning (viewport-relative coordinates), use
3499    /// `get_focused_cursor_rect_viewport()`.
3500    /// Rebuild the accessibility tree from the current layout results, focus,
3501    /// and cursor state.  Called after full layout AND after display-list-only
3502    /// regeneration so that screen readers see up-to-date bounds, cursor, and
3503    /// focus information.
3504    #[cfg(feature = "a11y")]
3505    pub fn update_a11y_tree(&mut self) {
3506        let cursor_a11y_info = self.text_edit_manager.multi_cursor.as_ref().and_then(|mc| {
3507            let node_id = mc.node_id.node.into_crate_internal()?;
3508            let primary = mc.get_primary()?;
3509            let (anchor_offset, focus_offset) = match &primary.selection {
3510                Selection::Cursor(c) => {
3511                    let off = c.cluster_id.start_byte_in_run as usize;
3512                    (off, off)
3513                }
3514                Selection::Range(r) => (
3515                    r.start.cluster_id.start_byte_in_run as usize,
3516                    r.end.cluster_id.start_byte_in_run as usize,
3517                ),
3518            };
3519            Some(crate::managers::a11y::CursorA11yInfo {
3520                dom_id: mc.node_id.dom,
3521                node_id,
3522                anchor_offset,
3523                focus_offset,
3524            })
3525        });
3526
3527        // Build text overrides from dirty_text_nodes so the a11y tree
3528        // reads the current (edited) text, not the stale StyledDom text.
3529        let mut dirty_text_overrides: BTreeMap<(DomId, NodeId), String> = BTreeMap::new();
3530        for (&(dom_id, node_id), dirty_node) in &self.dirty_text_nodes {
3531            dirty_text_overrides.insert(
3532                (dom_id, node_id),
3533                self.extract_text_from_inline_content(&dirty_node.content),
3534            );
3535        }
3536
3537        let a11y_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3538            crate::managers::a11y::A11yManager::update_tree(
3539                self.a11y_manager.root_id,
3540                &self.layout_results,
3541                &self.scroll_manager,
3542                &self.current_window_state.title,
3543                self.current_window_state.size.dimensions,
3544                self.focus_manager.get_focused_node().copied(),
3545                self.current_window_state.size.get_hidpi_factor().inner.get(),
3546                &dirty_text_overrides,
3547                cursor_a11y_info,
3548            )
3549        }));
3550
3551        if let Ok(tree_update) = a11y_result {
3552            self.a11y_manager.last_tree_update = Some(tree_update);
3553            self.a11y_manager.tree_initialized = true;
3554        }
3555    }
3556
3557    /// Incremental a11y update: only push the focused contenteditable node's
3558    /// updated value + cursor/selection.  Falls back to full rebuild if the
3559    /// tree hasn't been initialized yet or there's no active editing.
3560    #[cfg(feature = "a11y")]
3561    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
3562    pub fn update_a11y_tree_incremental(&mut self) {
3563        if !self.a11y_manager.tree_initialized {
3564            // First time — need full tree
3565            return self.update_a11y_tree();
3566        }
3567
3568        // Only worth doing incremental if we have an active editing node
3569        let Some(mc) = self.text_edit_manager.multi_cursor.as_ref() else {
3570            return; // No cursor — nothing to update incrementally
3571        };
3572
3573        let dom_node_id = mc.node_id;
3574        let Some(node_id) = dom_node_id.node.into_crate_internal() else {
3575            return;
3576        };
3577        let dom_id = dom_node_id.dom;
3578
3579        // Get current text content (from dirty overrides or StyledDom)
3580        let text_content = if let Some(dirty) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
3581            self.extract_text_from_inline_content(&dirty.content)
3582        } else {
3583            // Fall back to StyledDom text
3584            let Some(lr) = self.layout_results.get(&dom_id) else {
3585                return self.update_a11y_tree();
3586            };
3587            let node_data = lr.styled_dom.node_data.as_ref();
3588            let hierarchy = lr.styled_dom.node_hierarchy.as_ref();
3589            let mut text = String::new();
3590            if let Some(item) = hierarchy.get(node_id.index()) {
3591                let mut child = item.first_child_id(node_id);
3592                while let Some(child_id) = child {
3593                    if let Some(cd) = node_data.get(child_id.index()) {
3594                        if let NodeType::Text(t) = &cd.node_type {
3595                            if !text.is_empty() { text.push(' '); }
3596                            text.push_str(t.as_str());
3597                        }
3598                    }
3599                    if child_id.index() >= hierarchy.len() { break; }
3600                    child = hierarchy[child_id.index()].next_sibling_id();
3601                }
3602            }
3603            text
3604        };
3605
3606        // Build the a11y node ID (same encoding as update_tree)
3607        let a11y_node_id = accesskit::NodeId(
3608            ((dom_id.inner as u64) << 32) | ((node_id.index() as u64) + 1),
3609        );
3610
3611        // Get the node data to determine role
3612        let role = self.layout_results.get(&dom_id)
3613            .and_then(|lr| lr.styled_dom.node_data.as_ref().get(node_id.index()))
3614            .map_or(accesskit::Role::GenericContainer, |nd| {
3615                if nd.is_contenteditable() || matches!(nd.node_type, NodeType::TextArea) {
3616                    accesskit::Role::MultilineTextInput
3617                } else if matches!(nd.node_type, NodeType::Input) {
3618                    accesskit::Role::TextInput
3619                } else {
3620                    accesskit::Role::GenericContainer
3621                }
3622            });
3623
3624        let mut node = accesskit::Node::new(role);
3625        node.set_value(text_content.as_str());
3626        node.add_action(accesskit::Action::SetTextSelection);
3627        node.add_action(accesskit::Action::ReplaceSelectedText);
3628        node.add_action(accesskit::Action::SetValue);
3629
3630        // Set cursor/selection
3631        let primary = mc.get_primary();
3632        if let Some(identified) = primary {
3633            let (anchor_off, focus_off) = match &identified.selection {
3634                Selection::Cursor(c) => {
3635                    let off = c.cluster_id.start_byte_in_run as usize;
3636                    (off, off)
3637                }
3638                Selection::Range(r) => (
3639                    r.start.cluster_id.start_byte_in_run as usize,
3640                    r.end.cluster_id.start_byte_in_run as usize,
3641                ),
3642            };
3643
3644            let char_lengths: Vec<u8> = text_content.chars()
3645                .map(|c| c.len_utf16() as u8)
3646                .collect();
3647            node.set_character_lengths(char_lengths.clone());
3648
3649            let byte_to_char = |byte_off: usize| -> usize {
3650                text_content.char_indices()
3651                    .take_while(|(b, _)| *b < byte_off)
3652                    .count()
3653                    .min(char_lengths.len())
3654            };
3655
3656            node.set_text_selection(accesskit::TextSelection {
3657                anchor: accesskit::TextPosition {
3658                    node: a11y_node_id,
3659                    character_index: byte_to_char(anchor_off),
3660                },
3661                focus: accesskit::TextPosition {
3662                    node: a11y_node_id,
3663                    character_index: byte_to_char(focus_off),
3664                },
3665            });
3666        }
3667
3668        // Focus: use the current focused node or root
3669        let focus = self.focus_manager.get_focused_node().copied()
3670            .and_then(|dn| {
3671                let idx = dn.node.into_crate_internal()?.index();
3672                Some(accesskit::NodeId(((dn.dom.inner as u64) << 32) | ((idx as u64) + 1)))
3673            })
3674            .unwrap_or(self.a11y_manager.root_id);
3675
3676        self.a11y_manager.last_tree_update = Some(accesskit::TreeUpdate {
3677            nodes: vec![(a11y_node_id, node)],
3678            tree: None, // Incremental — tree structure unchanged
3679            focus,
3680            tree_id: accesskit::TreeId::ROOT,
3681        });
3682    }
3683
3684    pub fn get_focused_cursor_rect(&self) -> Option<LogicalRect> {
3685        // Get the focused node
3686        let focused_node = self.focus_manager.focused_node?;
3687
3688        // Get the text cursor
3689        let cursor = self.text_edit_manager.get_primary_cursor()?;
3690
3691        // Get the layout tree from cache
3692        let layout_tree = self.layout_cache.tree.as_ref()?;
3693
3694        // Find the layout node index corresponding to the focused DOM node
3695        let target_node_id = focused_node.node.into_crate_internal();
3696        let layout_idx = layout_tree
3697            .nodes
3698            .iter()
3699            .position(|node| node.dom_node_id == target_node_id)?;
3700
3701        // Get the text layout result for this node (warm data)
3702        let warm_node = layout_tree.warm(layout_idx)?;
3703        let cached_layout = warm_node.inline_layout_result.as_ref()?;
3704        let inline_layout = &cached_layout.layout;
3705
3706        // Get the cursor rect in node-relative coordinates
3707        let mut cursor_rect = inline_layout.get_cursor_rect(&cursor)?;
3708
3709        // Get the calculated layout position from cache (already in logical units)
3710        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
3711
3712        // Add layout position to cursor rect (both already in logical units)
3713        cursor_rect.origin.x += calc_pos.x;
3714        cursor_rect.origin.y += calc_pos.y;
3715
3716        // Return ABSOLUTE position (no scroll correction)
3717        Some(cursor_rect)
3718    }
3719
3720    /// Compute the bounding rect of all selection ranges in the focused node.
3721    /// Returns the union of all selection rects in absolute coordinates.
3722    pub fn calculate_selection_bounding_rect(&self) -> Option<LogicalRect> {
3723        let focused_node = self.focus_manager.focused_node?;
3724        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
3725
3726        // Collect Range selections
3727        let ranges: Vec<_> = mc.selections.iter().filter_map(|s| {
3728            if let Selection::Range(ref r) = s.selection {
3729                Some(*r)
3730            } else {
3731                None
3732            }
3733        }).collect();
3734
3735        if ranges.is_empty() {
3736            return None;
3737        }
3738
3739        // Get the inline layout for the focused node
3740        let target_node_id = focused_node.node.into_crate_internal();
3741        let layout_tree = self.layout_cache.tree.as_ref()?;
3742        let layout_idx = layout_tree.nodes.iter()
3743            .position(|n| n.dom_node_id == target_node_id)?;
3744        let warm = layout_tree.warm(layout_idx)?;
3745        let inline_layout = &warm.inline_layout_result.as_ref()?.layout;
3746        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
3747
3748        let mut min_x = f32::MAX;
3749        let mut min_y = f32::MAX;
3750        let mut max_x = f32::MIN;
3751        let mut max_y = f32::MIN;
3752        let mut found_any = false;
3753
3754        for range in &ranges {
3755            for rect in inline_layout.get_selection_rects(range) {
3756                found_any = true;
3757                let abs_x = rect.origin.x + calc_pos.x;
3758                let abs_y = rect.origin.y + calc_pos.y;
3759                min_x = min_x.min(abs_x);
3760                min_y = min_y.min(abs_y);
3761                max_x = max_x.max(abs_x + rect.size.width);
3762                max_y = max_y.max(abs_y + rect.size.height);
3763            }
3764        }
3765
3766        if !found_any {
3767            return None;
3768        }
3769
3770        Some(LogicalRect::new(
3771            LogicalPosition { x: min_x, y: min_y },
3772            LogicalSize { width: max_x - min_x, height: max_y - min_y },
3773        ))
3774    }
3775
3776    /// Ctrl+D: select the next occurrence of the current selection/word.
3777    ///
3778    /// If the primary selection is a cursor (no range), first expand it to a word.
3779    /// Then search forward in the text for the next occurrence and add it as a
3780    /// new multi-cursor selection.
3781    ///
3782    /// Returns true if a new selection was added.
3783    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
3784    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
3785    /// # Panics
3786    ///
3787    /// Panics if there is no active multi-cursor.
3788    pub fn select_next_occurrence(&mut self) -> bool {
3789        use crate::text3::selection::select_word_at_cursor;
3790
3791        let Some(mc) = self.text_edit_manager.multi_cursor.as_mut() else {
3792            return false;
3793        };
3794        let node_id = mc.node_id;
3795        let Some(dom_node_id) = node_id.node.into_crate_internal() else {
3796            return false;
3797        };
3798
3799        // Get primary selection text (or word at cursor)
3800        let primary = match mc.selections.first() {
3801            Some(s) => *s,
3802            None => return false,
3803        };
3804
3805        let (search_range, need_word_expand) = match &primary.selection {
3806            Selection::Range(r) => (*r, false),
3807            Selection::Cursor(c) => {
3808                // Need to expand to word first
3809                (SelectionRange { start: *c, end: *c }, true)
3810            }
3811        };
3812
3813        // Get the inline layout
3814        let Some(inline_layout) = self.get_node_inline_layout(node_id.dom, dom_node_id) else {
3815            return false;
3816        };
3817
3818        // If no range yet, expand to word
3819        let word_range = if need_word_expand {
3820            match select_word_at_cursor(&search_range.start, &inline_layout) {
3821                Some(r) => r,
3822                None => return false,
3823            }
3824        } else {
3825            search_range
3826        };
3827
3828        // Extract the search text from inline content
3829        let content = self.get_text_before_textinput(node_id.dom, dom_node_id);
3830        let full_text = self.extract_text_from_inline_content(&content);
3831
3832        // Extract the selected word text using byte offsets
3833        let start_byte = word_range.start.cluster_id.start_byte_in_run as usize;
3834        let end_byte = word_range.end.cluster_id.start_byte_in_run as usize;
3835        let search_text = if word_range.start.cluster_id.source_run == word_range.end.cluster_id.source_run {
3836            if let Some(InlineContent::Text(run)) = content.get(word_range.start.cluster_id.source_run as usize) {
3837                if start_byte <= end_byte && end_byte <= run.text.len() {
3838                    run.text[start_byte..end_byte].to_string()
3839                } else {
3840                    return false;
3841                }
3842            } else {
3843                return false;
3844            }
3845        } else {
3846            return false; // Multi-run selection search not yet supported
3847        };
3848
3849        if search_text.is_empty() {
3850            return false;
3851        }
3852
3853        // Search forward from the end of the last selection
3854        let mc = self.text_edit_manager.multi_cursor.as_ref().unwrap();
3855        let last_end_byte = mc.selections.last()
3856            .map_or(0, |s| match &s.selection {
3857                Selection::Range(r) => r.end.cluster_id.start_byte_in_run as usize,
3858                Selection::Cursor(c) => c.cluster_id.start_byte_in_run as usize,
3859            });
3860
3861        let search_run = word_range.start.cluster_id.source_run;
3862
3863        // Find next occurrence in the same run's text
3864        if let Some(InlineContent::Text(run)) = content.get(search_run as usize) {
3865            let search_in = &run.text;
3866            // Search from after the last selection end
3867            if let Some(offset) = search_in[last_end_byte..].find(&search_text) {
3868                let match_start = last_end_byte + offset;
3869                let match_end = match_start + search_text.len();
3870
3871                let new_range = SelectionRange {
3872                    start: TextCursor {
3873                        cluster_id: GraphemeClusterId {
3874                            source_run: search_run,
3875                            start_byte_in_run: match_start as u32,
3876                        },
3877                        affinity: CursorAffinity::Leading,
3878                    },
3879                    end: TextCursor {
3880                        cluster_id: GraphemeClusterId {
3881                            source_run: search_run,
3882                            start_byte_in_run: match_end as u32,
3883                        },
3884                        affinity: CursorAffinity::Trailing,
3885                    },
3886                };
3887
3888                // If primary was a cursor, convert it to a word selection first
3889                let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
3890                if need_word_expand {
3891                    if let Some(first) = mc.selections.first_mut() {
3892                        first.selection = Selection::Range(word_range);
3893                    }
3894                }
3895                let _ = mc.add_selection(new_range);
3896                self.text_edit_manager.mark_dirty();
3897                return true;
3898            } else if last_end_byte > 0 {
3899                // Wrap around: search from the beginning
3900                if let Some(offset) = search_in[..start_byte].find(&search_text) {
3901                    let match_start = offset;
3902                    let match_end = match_start + search_text.len();
3903
3904                    let new_range = SelectionRange {
3905                        start: TextCursor {
3906                            cluster_id: GraphemeClusterId {
3907                                source_run: search_run,
3908                                start_byte_in_run: match_start as u32,
3909                            },
3910                            affinity: CursorAffinity::Leading,
3911                        },
3912                        end: TextCursor {
3913                            cluster_id: GraphemeClusterId {
3914                                source_run: search_run,
3915                                start_byte_in_run: match_end as u32,
3916                            },
3917                            affinity: CursorAffinity::Trailing,
3918                        },
3919                    };
3920
3921                    let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
3922                    if need_word_expand {
3923                        if let Some(first) = mc.selections.first_mut() {
3924                            first.selection = Selection::Range(word_range);
3925                        }
3926                    }
3927                    let _ = mc.add_selection(new_range);
3928                    self.text_edit_manager.mark_dirty();
3929                    return true;
3930                }
3931            }
3932        }
3933
3934        // If primary was cursor and we expanded to word but found no other occurrence,
3935        // still mark the word selection
3936        if need_word_expand {
3937            let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
3938            if let Some(first) = mc.selections.first_mut() {
3939                first.selection = Selection::Range(word_range);
3940            }
3941            self.text_edit_manager.mark_dirty();
3942            return true;
3943        }
3944
3945        false
3946    }
3947
3948    /// Get the cursor rect for the currently focused text input node in VIEWPORT coordinates.
3949    ///
3950    /// This returns the cursor position accounting for:
3951    /// 1. Scroll offsets from all scrollable ancestors
3952    /// 2. GPU transforms (CSS transforms, animations) from all transformed ancestors
3953    ///
3954    /// The returned position is viewport-relative (what the user actually sees on screen).
3955    /// This is used for IME window positioning, where the IME popup needs to appear at the
3956    /// visible cursor location, not the absolute layout position.
3957    ///
3958    /// Returns None if:
3959    /// - No node is focused
3960    /// - Focused node has no text cursor
3961    /// - Focused node has no layout
3962    /// - Text cache cannot find cursor position
3963    ///
3964    /// For scroll-into-view calculations (absolute coordinates), use `get_focused_cursor_rect()`.
3965    pub fn get_focused_cursor_rect_viewport(&self) -> Option<LogicalRect> {
3966        // Start with absolute position
3967        let mut cursor_rect = self.get_focused_cursor_rect()?;
3968
3969        // Get the focused node
3970        let focused_node = self.focus_manager.focused_node?;
3971
3972        // Get the layout tree from cache
3973        let layout_tree = self.layout_cache.tree.as_ref()?;
3974
3975        // Find the layout node index corresponding to the focused DOM node
3976        let target_node_id = focused_node.node.into_crate_internal();
3977        let layout_idx = layout_tree
3978            .nodes
3979            .iter()
3980            .position(|node| node.dom_node_id == target_node_id)?;
3981
3982        // Get the GPU cache for this DOM (if it exists)
3983        let gpu_cache = self.gpu_state_manager.caches.get(&focused_node.dom);
3984
3985        // CRITICAL STEP 1: Apply scroll offsets from all scrollable ancestors
3986        // CRITICAL STEP 2: Apply inverse GPU transforms from all transformed ancestors
3987        // Walk up the tree and apply both corrections
3988        let mut current_layout_idx = layout_idx;
3989
3990        while let Some(parent_idx) = layout_tree.nodes.get(current_layout_idx)?.parent {
3991            // Get the DOM node ID of the parent (if it's not anonymous)
3992            if let Some(parent_dom_node_id) = layout_tree.nodes.get(parent_idx)?.dom_node_id {
3993                // STEP 1: Check if this parent is scrollable and has scroll state
3994                if let Some(scroll_state) = self
3995                    .scroll_manager
3996                    .get_scroll_state(focused_node.dom, parent_dom_node_id)
3997                {
3998                    // Subtract scroll offset (scrolling down = positive offset, moves content up)
3999                    cursor_rect.origin.x -= scroll_state.current_offset.x;
4000                    cursor_rect.origin.y -= scroll_state.current_offset.y;
4001                }
4002
4003                // STEP 2: Check if this parent has a GPU transform applied
4004                if let Some(cache) = gpu_cache {
4005                    if let Some(transform) = cache.current_transform_values.get(&parent_dom_node_id)
4006                    {
4007                        // Apply the INVERSE transform to get back to viewport coordinates
4008                        // The transform moves the element, so we need to reverse it for the cursor
4009                        let inverse = transform.inverse();
4010                        if let Some(transformed_origin) =
4011                            inverse.transform_point2d(cursor_rect.origin)
4012                        {
4013                            cursor_rect.origin = transformed_origin;
4014                        }
4015                        // Note: We don't transform the size, only the position
4016                    }
4017                }
4018            }
4019
4020            // Move to parent for next iteration
4021            current_layout_idx = parent_idx;
4022        }
4023
4024        Some(cursor_rect)
4025    }
4026
4027    /// Find the nearest scrollable ancestor for a given node
4028    /// Returns (`DomId`, `NodeId`) of the scrollable container, or None if no scrollable ancestor
4029    /// exists
4030    pub fn find_scrollable_ancestor(
4031        &self,
4032        mut node_id: DomNodeId,
4033    ) -> Option<DomNodeId> {
4034        // Get the layout tree
4035        let layout_tree = self.layout_cache.tree.as_ref()?;
4036
4037        // Convert to internal NodeId
4038        let mut current_node_id = node_id.node.into_crate_internal();
4039
4040        // Walk up the tree looking for a scrollable node
4041        loop {
4042            // Find layout node index
4043            let layout_idx = layout_tree
4044                .nodes
4045                .iter()
4046                .position(|node| node.dom_node_id == current_node_id)?;
4047
4048            // Check if this node has scrollbar info (meaning it's scrollable)
4049            if layout_tree.warm(layout_idx).and_then(|w| w.scrollbar_info.as_ref()).is_some() {
4050                // Check if it actually has a scroll state registered
4051                let check_node_id = current_node_id?;
4052                if self
4053                    .scroll_manager
4054                    .get_scroll_state(node_id.dom, check_node_id)
4055                    .is_some()
4056                {
4057                    // Found a scrollable ancestor
4058                    return Some(DomNodeId {
4059                        dom: node_id.dom,
4060                        node: NodeHierarchyItemId::from_crate_internal(
4061                            Some(check_node_id),
4062                        ),
4063                    });
4064                }
4065            }
4066
4067            // Move to parent
4068            let parent_idx = layout_tree.get(layout_idx)?.parent?;
4069            let parent_node = layout_tree.get(parent_idx)?;
4070            current_node_id = parent_node.dom_node_id;
4071        }
4072    }
4073
4074    /// Scroll selection or cursor into view with distance-based acceleration.
4075    ///
4076    /// **Unified Scroll System**: This method handles both cursor (0-size selection)
4077    /// and full selection scrolling with a single implementation. For drag-to-scroll,
4078    /// scroll speed increases with distance from container edge.
4079    ///
4080    /// ## Algorithm
4081    /// 1. Get bounds to scroll (cursor rect, selection rect, or mouse position)
4082    /// 2. Find scrollable ancestor container
4083    /// 3. Calculate distance from bounds to container edges
4084    /// 4. Compute scroll delta (instant with padding, or accelerated with zones)
4085    /// 5. Apply scroll with appropriate animation
4086    ///
4087    /// ## Distance-Based Acceleration (`ScrollMode::Accelerated`)
4088    /// ```text
4089    /// Distance from edge:  Scroll speed per frame:
4090    /// 0-20px              Dead zone (no scroll)
4091    /// 20-50px             Slow (2px/frame)
4092    /// 50-100px            Medium (4px/frame)
4093    /// 100-200px           Fast (8px/frame)
4094    /// 200+px              Very fast (16px/frame)
4095    /// ```
4096    ///
4097    /// ## Returns
4098    /// `true` if scrolling was applied, `false` if already visible
4099    pub fn scroll_selection_into_view(
4100        &mut self,
4101        scroll_type: SelectionScrollType,
4102        scroll_mode: ScrollMode,
4103    ) -> bool {
4104        // Get bounds to scroll into view
4105        let bounds = match scroll_type {
4106            SelectionScrollType::Cursor => {
4107                // Cursor is 0-size selection at insertion point
4108                match self.get_focused_cursor_rect() {
4109                    Some(rect) => rect,
4110                    None => return false, // No cursor to scroll
4111                }
4112            }
4113            SelectionScrollType::Selection => {
4114                // Compute bounding rect of all selection ranges via the text layout.
4115                // Falls back to cursor rect if no ranges exist.
4116                match self.calculate_selection_bounding_rect()
4117                    .or_else(|| self.get_focused_cursor_rect())
4118                {
4119                    Some(rect) => rect,
4120                    None => return false,
4121                }
4122            }
4123            SelectionScrollType::DragSelection { mouse_position } => {
4124                // For drag: use mouse position to determine scroll direction/speed
4125                LogicalRect::new(mouse_position, LogicalSize::zero())
4126            }
4127        };
4128
4129        // Get the focused node (or bail if no focus)
4130        let Some(focused_node) = self.focus_manager.focused_node else {
4131            return false;
4132        };
4133
4134        // Find scrollable ancestor
4135        let Some(scroll_container) = self.find_scrollable_ancestor(focused_node) else {
4136            return false; // No scrollable ancestor
4137        };
4138
4139        // Get container bounds and current scroll state
4140        let Some(layout_tree) = self.layout_cache.tree.as_ref() else {
4141            return false;
4142        };
4143
4144        let Some(scrollable_node_internal) = scroll_container.node.into_crate_internal() else {
4145            return false;
4146        };
4147
4148        let Some(layout_idx) = layout_tree
4149            .nodes
4150            .iter()
4151            .position(|n| n.dom_node_id == Some(scrollable_node_internal))
4152        else {
4153            return false;
4154        };
4155
4156        let Some(scrollable_layout_node) = layout_tree.nodes.get(layout_idx) else {
4157            return false;
4158        };
4159
4160        let container_pos = self
4161            .layout_cache
4162            .calculated_positions
4163            .get(layout_idx)
4164            .copied()
4165            .unwrap_or_default();
4166
4167        let container_size = scrollable_layout_node.used_size.unwrap_or_default();
4168
4169        let container_rect = LogicalRect {
4170            origin: container_pos,
4171            size: container_size,
4172        };
4173
4174        // Get current scroll state
4175        let Some(scroll_state) = self
4176            .scroll_manager
4177            .get_scroll_state(scroll_container.dom, scrollable_node_internal)
4178        else {
4179            return false;
4180        };
4181
4182        // Calculate visible area (container rect adjusted by scroll offset)
4183        let visible_area = LogicalRect::new(
4184            LogicalPosition::new(
4185                container_rect.origin.x + scroll_state.current_offset.x,
4186                container_rect.origin.y + scroll_state.current_offset.y,
4187            ),
4188            container_rect.size,
4189        );
4190
4191        // Calculate scroll delta based on mode
4192        let scroll_delta = match scroll_mode {
4193            ScrollMode::Instant => {
4194                // For typing/clicking: instant scroll with fixed padding
4195                calculate_instant_scroll_delta(bounds, visible_area)
4196            }
4197            ScrollMode::Accelerated => {
4198                // For drag: accelerated scroll based on distance from edge
4199                let distance = calculate_edge_distance(bounds, visible_area);
4200                calculate_accelerated_scroll_delta(distance)
4201            }
4202        };
4203
4204        // Apply scroll if needed
4205        if scroll_delta.x != 0.0 || scroll_delta.y != 0.0 {
4206            let duration = match scroll_mode {
4207                ScrollMode::Instant => Duration::System(SystemTimeDiff { secs: 0, nanos: 0 }),
4208                ScrollMode::Accelerated => Duration::System(SystemTimeDiff {
4209                    secs: 0,
4210                    nanos: 16_666_667,
4211                }), // 60fps
4212            };
4213
4214            let external = ExternalSystemCallbacks::rust_internal();
4215            let now = (external.get_system_time_fn.cb)();
4216
4217            // Calculate new scroll target
4218            let new_target = LogicalPosition {
4219                x: scroll_state.current_offset.x + scroll_delta.x,
4220                y: scroll_state.current_offset.y + scroll_delta.y,
4221            };
4222
4223            self.scroll_manager.scroll_to(
4224                scroll_container.dom,
4225                scrollable_node_internal,
4226                new_target,
4227                duration,
4228                EasingFunction::Linear,
4229                now,
4230            );
4231
4232            true // Scrolled
4233        } else {
4234            false // Already visible
4235        }
4236    }
4237
4238    /// Scrolls the focused cursor into view after layout.
4239    ///
4240    /// Delegates to `scroll_selection_into_view` with cursor mode.
4241    /// Called internally from `layout_and_generate_display_list()`.
4242    fn scroll_focused_cursor_into_view(&mut self) {
4243        // Redirect to unified scroll system
4244        self.scroll_selection_into_view(SelectionScrollType::Cursor, ScrollMode::Instant);
4245    }
4246}
4247
4248/// Type of selection bounds to scroll into view
4249#[derive(Debug, Clone, Copy)]
4250pub enum SelectionScrollType {
4251    /// Scroll cursor (0-size selection) into view
4252    Cursor,
4253    /// Scroll current selection bounds into view
4254    Selection,
4255    /// Scroll for drag selection (use mouse position for direction/speed)
4256    DragSelection { mouse_position: LogicalPosition },
4257}
4258
4259/// Scroll animation mode
4260#[derive(Debug, Clone, Copy)]
4261pub enum ScrollMode {
4262    /// Instant scroll with fixed padding (for typing, arrow keys)
4263    Instant,
4264    /// Accelerated scroll based on distance from edge (for drag-to-scroll)
4265    Accelerated,
4266}
4267
4268/// Distance from rect edges to container edges (for acceleration calculation)
4269#[derive(Debug, Clone, Copy)]
4270struct EdgeDistance {
4271    left: f32,
4272    right: f32,
4273    top: f32,
4274    bottom: f32,
4275}
4276
4277/// Calculate distance from rect to container edges
4278fn calculate_edge_distance(rect: LogicalRect, container: LogicalRect) -> EdgeDistance {
4279    EdgeDistance {
4280        // Distance from rect's left edge to container's left edge
4281        left: (rect.origin.x - container.origin.x).max(0.0),
4282        // Distance from container's right edge to rect's right edge
4283        right: ((container.origin.x + container.size.width) - (rect.origin.x + rect.size.width))
4284            .max(0.0),
4285        // Distance from rect's top edge to container's top edge
4286        top: (rect.origin.y - container.origin.y).max(0.0),
4287        // Distance from container's bottom edge to rect's bottom edge
4288        bottom: ((container.origin.y + container.size.height) - (rect.origin.y + rect.size.height))
4289            .max(0.0),
4290    }
4291}
4292
4293/// Calculate scroll delta with fixed padding (instant scroll mode)
4294fn calculate_instant_scroll_delta(
4295    bounds: LogicalRect,
4296    visible_area: LogicalRect,
4297) -> LogicalPosition {
4298    const PADDING: f32 = 5.0;
4299    let mut delta = LogicalPosition::zero();
4300
4301    // Horizontal scrolling
4302    if bounds.origin.x < visible_area.origin.x + PADDING {
4303        delta.x = bounds.origin.x - visible_area.origin.x - PADDING;
4304    } else if bounds.origin.x + bounds.size.width
4305        > visible_area.origin.x + visible_area.size.width - PADDING
4306    {
4307        delta.x = (bounds.origin.x + bounds.size.width)
4308            - (visible_area.origin.x + visible_area.size.width)
4309            + PADDING;
4310    }
4311
4312    // Vertical scrolling
4313    if bounds.origin.y < visible_area.origin.y + PADDING {
4314        delta.y = bounds.origin.y - visible_area.origin.y - PADDING;
4315    } else if bounds.origin.y + bounds.size.height
4316        > visible_area.origin.y + visible_area.size.height - PADDING
4317    {
4318        delta.y = (bounds.origin.y + bounds.size.height)
4319            - (visible_area.origin.y + visible_area.size.height)
4320            + PADDING;
4321    }
4322
4323    delta
4324}
4325
4326/// Calculate scroll delta with distance-based acceleration (drag-to-scroll mode)
4327fn calculate_accelerated_scroll_delta(distance: EdgeDistance) -> LogicalPosition {
4328    // Acceleration zones (in pixels from edge)
4329    const DEAD_ZONE: f32 = 20.0;
4330    const SLOW_ZONE: f32 = 50.0;
4331    const MEDIUM_ZONE: f32 = 100.0;
4332    const FAST_ZONE: f32 = 200.0;
4333
4334    // Scroll speeds (pixels per frame at 60fps)
4335    const SLOW_SPEED: f32 = 2.0;
4336    const MEDIUM_SPEED: f32 = 4.0;
4337    const FAST_SPEED: f32 = 8.0;
4338    const VERY_FAST_SPEED: f32 = 16.0;
4339
4340    // Helper to calculate speed for one direction
4341    let speed_for_distance = |dist: f32| -> f32 {
4342        if dist < DEAD_ZONE {
4343            0.0
4344        } else if dist < SLOW_ZONE {
4345            SLOW_SPEED
4346        } else if dist < MEDIUM_ZONE {
4347            MEDIUM_SPEED
4348        } else if dist < FAST_ZONE {
4349            FAST_SPEED
4350        } else {
4351            VERY_FAST_SPEED
4352        }
4353    };
4354
4355    // Calculate horizontal scroll (left vs right)
4356    let scroll_x = if distance.left < distance.right {
4357        // Closer to left edge - scroll left
4358        -speed_for_distance(distance.left)
4359    } else {
4360        // Closer to right edge - scroll right
4361        speed_for_distance(distance.right)
4362    };
4363
4364    // Calculate vertical scroll (top vs bottom)
4365    let scroll_y = if distance.top < distance.bottom {
4366        // Closer to top edge - scroll up
4367        -speed_for_distance(distance.top)
4368    } else {
4369        // Closer to bottom edge - scroll down
4370        speed_for_distance(distance.bottom)
4371    };
4372
4373    LogicalPosition::new(scroll_x, scroll_y)
4374}
4375
4376/// Result of a layout operation
4377#[derive(Debug)]
4378pub struct LayoutResult {
4379    pub display_list: DisplayList,
4380    pub warnings: Vec<String>,
4381}
4382
4383impl LayoutResult {
4384    #[must_use] pub const fn new(display_list: DisplayList, warnings: Vec<String>) -> Self {
4385        Self {
4386            display_list,
4387            warnings,
4388        }
4389    }
4390}
4391
4392impl LayoutWindow {
4393    /// Runs a single timer, similar to `CallbacksOfHitTest.call()`
4394    ///
4395    /// NOTE: The timer has to be selected first by the calling code and verified
4396    /// that it is ready to run
4397    #[cfg(feature = "std")]
4398    /// Run a single timer callback and return raw changes + update.
4399    ///
4400    /// If the timer should terminate, a `RemoveTimer` change is appended.
4401    // Instant is a ref-counted FFI clock handle threaded through the event loop by value.
4402    #[allow(clippy::needless_pass_by_value)]
4403    /// # Panics
4404    ///
4405    /// Panics if `timer_id` does not correspond to a registered timer.
4406    pub fn run_single_timer(
4407        &mut self,
4408        timer_id: usize,
4409        frame_start: Instant,
4410        current_window_handle: &RawWindowHandle,
4411        gl_context: &OptionGlContextPtr,
4412        system_style: Arc<azul_css::system::SystemStyle>,
4413        system_callbacks: &ExternalSystemCallbacks,
4414        previous_window_state: &Option<FullWindowState>,
4415        current_window_state: &FullWindowState,
4416        renderer_resources: &RendererResources,
4417    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4418        use crate::callbacks::{CallbackInfo, CallbackChange};
4419
4420        let mut update = Update::DoNothing;
4421        let mut all_changes = Vec::new();
4422        let mut should_terminate = TerminateTimer::Continue;
4423
4424        let current_scroll_states_nested = self.get_nested_scroll_states(DomId::ROOT_ID);
4425
4426        let timer_exists = self.timers.contains_key(&TimerId { id: timer_id });
4427        let timer_node_id = self
4428            .timers
4429            .get(&TimerId { id: timer_id })
4430            .and_then(|t| t.node_id.into_option());
4431
4432        if timer_exists {
4433            let hit_dom_node = timer_node_id.map_or_else(|| DomNodeId {
4434                    dom: DomId::ROOT_ID,
4435                    node: NodeHierarchyItemId::from_crate_internal(None),
4436                }, |s| s);
4437            let cursor_relative_to_item = OptionLogicalPosition::None;
4438            let cursor_in_viewport = OptionLogicalPosition::None;
4439
4440            let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4441
4442            let timer_ctx = self
4443                .timers
4444                .get(&TimerId { id: timer_id })
4445                .map_or(OptionRefAny::None, |t| t.callback.ctx.clone());
4446
4447            let ref_data = crate::callbacks::CallbackInfoRefData {
4448                layout_window: self,
4449                renderer_resources,
4450                previous_window_state,
4451                current_window_state,
4452                gl_context,
4453                current_scroll_manager: &current_scroll_states_nested,
4454                current_window_handle,
4455                system_callbacks,
4456                system_style,
4457                monitors: self.monitors.clone(),
4458                #[cfg(feature = "icu")]
4459                icu_localizer: self.icu_localizer.clone(),
4460                ctx: timer_ctx,
4461            };
4462
4463            let callback_info = CallbackInfo::new(
4464                &ref_data,
4465                &callback_changes,
4466                hit_dom_node,
4467                cursor_relative_to_item,
4468                cursor_in_viewport,
4469            );
4470
4471            let timer = self.timers.get_mut(&TimerId { id: timer_id }).unwrap();
4472            let tcr = timer.invoke(&callback_info, &system_callbacks.get_system_time_fn);
4473
4474            update = tcr.should_update;
4475            should_terminate = tcr.should_terminate;
4476
4477            all_changes = callback_changes
4478                .lock()
4479                .map(|mut guard| core::mem::take(&mut *guard))
4480                .unwrap_or_default();
4481        }
4482
4483        if should_terminate == TerminateTimer::Terminate {
4484            all_changes.push(CallbackChange::RemoveTimer {
4485                timer_id: TimerId { id: timer_id },
4486            });
4487        }
4488
4489        (all_changes, update)
4490    }
4491
4492    #[cfg(feature = "std")]
4493    /// Run all thread writeback callbacks and return raw changes + update.
4494    // system_style is an Arc<SystemStyle> handed to this layout entry point by every dll backend;
4495    // taking the Arc by value (one refcount) matches that boundary and avoids a cross-backend &-ripple.
4496    #[allow(clippy::needless_pass_by_value)]
4497    pub fn run_all_threads(
4498        &mut self,
4499        data: &mut RefAny,
4500        current_window_handle: &RawWindowHandle,
4501        gl_context: &OptionGlContextPtr,
4502        system_style: Arc<azul_css::system::SystemStyle>,
4503        system_callbacks: &ExternalSystemCallbacks,
4504        previous_window_state: &Option<FullWindowState>,
4505        current_window_state: &FullWindowState,
4506        renderer_resources: &RendererResources,
4507    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4508        use std::collections::BTreeSet;
4509
4510        use crate::{
4511            callbacks::{CallbackInfo, CallbackChange},
4512            thread::{OptionThreadReceiveMsg, ThreadReceiveMsg, ThreadWriteBackMsg},
4513        };
4514
4515        let mut update = Update::DoNothing;
4516        let mut all_changes = Vec::new();
4517
4518        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
4519
4520        let thread_ids: Vec<ThreadId> = self.threads.keys().copied().collect();
4521
4522        for thread_id in thread_ids {
4523            let Some(thread) = self.threads.get_mut(&thread_id) else {
4524                continue;
4525            };
4526
4527            let hit_dom_node = DomNodeId {
4528                dom: DomId::ROOT_ID,
4529                node: NodeHierarchyItemId::from_crate_internal(None),
4530            };
4531            let cursor_relative_to_item = OptionLogicalPosition::None;
4532            let cursor_in_viewport = OptionLogicalPosition::None;
4533
4534            let (msg, writeback_data_ptr, is_finished) = {
4535                let thread_inner = &mut *if let Ok(s) = thread.ptr.lock() { s } else {
4536                    all_changes.push(CallbackChange::RemoveThread { thread_id });
4537                    continue;
4538                };
4539
4540                let _ = thread_inner.sender_send(ThreadSendMsg::Tick);
4541                let recv = thread_inner.receiver_try_recv();
4542                let msg = match recv {
4543                    OptionThreadReceiveMsg::None => continue,
4544                    OptionThreadReceiveMsg::Some(s) => s,
4545                };
4546
4547                let writeback_data_ptr: *mut RefAny = &raw mut thread_inner.writeback_data;
4548                let is_finished = thread_inner.is_finished();
4549
4550                (msg, writeback_data_ptr, is_finished)
4551            };
4552
4553            let ThreadWriteBackMsg {
4554                refany: mut data_inner,
4555                callback,
4556            } = match msg {
4557                ThreadReceiveMsg::Update(update_screen) => {
4558                    update.max_self(update_screen);
4559                    continue;
4560                }
4561                ThreadReceiveMsg::WriteBack(t) => t,
4562            };
4563
4564            let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4565
4566            let ref_data = crate::callbacks::CallbackInfoRefData {
4567                layout_window: self,
4568                renderer_resources,
4569                previous_window_state,
4570                current_window_state,
4571                gl_context,
4572                current_scroll_manager: &current_scroll_states,
4573                current_window_handle,
4574                system_callbacks,
4575                system_style: system_style.clone(),
4576                monitors: self.monitors.clone(),
4577                #[cfg(feature = "icu")]
4578                icu_localizer: self.icu_localizer.clone(),
4579                ctx: callback.ctx.clone(),
4580            };
4581
4582            let callback_info = CallbackInfo::new(
4583                &ref_data,
4584                &callback_changes,
4585                hit_dom_node,
4586                cursor_relative_to_item,
4587                cursor_in_viewport,
4588            );
4589
4590            let callback_update = (callback.cb)(
4591                unsafe { (*writeback_data_ptr).clone() },
4592                data_inner.clone(),
4593                callback_info,
4594            );
4595            update.max_self(callback_update);
4596
4597            let collected_changes = callback_changes
4598                .lock()
4599                .map(|mut guard| core::mem::take(&mut *guard))
4600                .unwrap_or_default();
4601
4602            all_changes.extend(collected_changes);
4603
4604            if is_finished {
4605                all_changes.push(CallbackChange::RemoveThread { thread_id });
4606            }
4607        }
4608
4609        (all_changes, update)
4610    }
4611
4612    /// Invokes a single callback and returns the raw changes + update signal.
4613    ///
4614    /// Caller is responsible for processing each `CallbackChange` via
4615    /// `PlatformWindowV2::apply_user_change()`.
4616    pub fn invoke_single_callback(
4617        &mut self,
4618        callback: &mut Callback,
4619        data: &mut RefAny,
4620        current_window_handle: &RawWindowHandle,
4621        gl_context: &OptionGlContextPtr,
4622        system_style: Arc<azul_css::system::SystemStyle>,
4623        system_callbacks: &ExternalSystemCallbacks,
4624        previous_window_state: &Option<FullWindowState>,
4625        current_window_state: &FullWindowState,
4626        renderer_resources: &RendererResources,
4627    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4628        // No specific event target (create / layout / timer / unmount callbacks):
4629        // `info.get_hit_node()` resolves to the root with a null node.
4630        let hit_dom_node = DomNodeId {
4631            dom: DomId::ROOT_ID,
4632            node: NodeHierarchyItemId::from_crate_internal(None),
4633        };
4634        self.invoke_single_callback_at(
4635            hit_dom_node,
4636            callback,
4637            data,
4638            current_window_handle,
4639            gl_context,
4640            system_style,
4641            system_callbacks,
4642            previous_window_state,
4643            current_window_state,
4644            renderer_resources,
4645        )
4646    }
4647
4648    /// Like [`invoke_single_callback`], but sets the callback's hit node (the
4649    /// event target) so `info.get_hit_node()` / `open_menu_for_hit_node()` /
4650    /// `get_hit_node_rect()` resolve to the node the event was dispatched to.
4651    /// Used by the W3C event-propagation dispatcher; without it those queries
4652    /// returned a null node (menus/dropdowns opened nowhere).
4653    pub fn invoke_single_callback_at(
4654        &mut self,
4655        hit_dom_node: DomNodeId,
4656        callback: &mut Callback,
4657        data: &mut RefAny,
4658        current_window_handle: &RawWindowHandle,
4659        gl_context: &OptionGlContextPtr,
4660        system_style: Arc<azul_css::system::SystemStyle>,
4661        system_callbacks: &ExternalSystemCallbacks,
4662        previous_window_state: &Option<FullWindowState>,
4663        current_window_state: &FullWindowState,
4664        renderer_resources: &RendererResources,
4665    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
4666        use crate::callbacks::{CallbackInfo, CallbackChange};
4667
4668        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
4669
4670        // Resolve the cursor position *local to the dispatched node* from the
4671        // current mouse hit test (the same `point_relative_to_item` the hit
4672        // tester computed, and that text selection consumes). Without this
4673        // `info.get_cursor_relative_to_node()` was always `None`, so any
4674        // callback needing a node-local cursor (map pan/drag, custom hit
4675        // logic) silently bailed. Falls back to `None` when the node isn't in
4676        // the current hit test (e.g. non-pointer events).
4677        let cursor_relative_to_item = match hit_dom_node.node.into_crate_internal() {
4678            Some(node_id) => self
4679                .hover_manager
4680                .get_current(&crate::managers::hover::InputPointId::Mouse)
4681                .and_then(|ht| ht.hovered_nodes.get(&hit_dom_node.dom))
4682                .and_then(|hit| hit.regular_hit_test_nodes.get(&node_id))
4683                .map_or(OptionLogicalPosition::None, |item| OptionLogicalPosition::Some(item.point_relative_to_item)),
4684            None => OptionLogicalPosition::None,
4685        };
4686        let cursor_in_viewport = current_window_state.mouse_state.cursor_position.get_position().map_or(OptionLogicalPosition::None, OptionLogicalPosition::Some);
4687
4688        // Create changes container for callback transaction system
4689        let callback_changes = Arc::new(std::sync::Mutex::new(Vec::new()));
4690
4691        // Create reference data container.
4692        //
4693        // `ctx` carries the callback's stored OptionRefAny (host-handle for
4694        // managed FFIs, PyCallableWrapper for Python, None for native Rust)
4695        // so `info.get_ctx()` reaches it. Without this the host-invoker
4696        // thunk in libazul sees `OptionRefAny::None` and bails out with
4697        // `Update::DoNothing` — and clicks would silently do nothing.
4698        let ref_data = crate::callbacks::CallbackInfoRefData {
4699            layout_window: self,
4700            renderer_resources,
4701            previous_window_state,
4702            current_window_state,
4703            gl_context,
4704            current_scroll_manager: &current_scroll_states,
4705            current_window_handle,
4706            system_callbacks,
4707            system_style,
4708            monitors: self.monitors.clone(),
4709            #[cfg(feature = "icu")]
4710            icu_localizer: self.icu_localizer.clone(),
4711            ctx: callback.ctx.clone(),
4712        };
4713
4714        let callback_info = CallbackInfo::new(
4715            &ref_data,
4716            &callback_changes,
4717            hit_dom_node,
4718            cursor_relative_to_item,
4719            cursor_in_viewport,
4720        );
4721
4722        let update = (callback.cb)(data.clone(), callback_info);
4723
4724        // Extract changes from the Arc<Mutex>
4725        let collected_changes = callback_changes
4726            .lock()
4727            .map(|mut guard| core::mem::take(&mut *guard))
4728            .unwrap_or_default();
4729
4730        (collected_changes, update)
4731    }
4732
4733    /// Set the system style for resolving system color keywords in CSS.
4734    ///
4735    /// This should be called during window initialization and whenever the system
4736    /// theme changes (dark/light mode switch, accent color change).
4737    ///
4738    /// The system style is used to resolve CSS system colors like `selection-background`,
4739    /// `selection-text`, `accent`, etc. If not set, hard-coded fallback values are used.
4740    pub fn set_system_style(&mut self, system_style: Arc<azul_css::system::SystemStyle>) {
4741        #[cfg(feature = "icu")]
4742        {
4743            self.icu_localizer = crate::icu::IcuLocalizerHandle::from_system_language(&system_style.language);
4744        }
4745        self.system_style = Some(system_style);
4746    }
4747}
4748
4749// --- ICU4X Internationalization API ---
4750
4751#[cfg(feature = "icu")]
4752impl LayoutWindow {
4753    /// Initialize the ICU localizer with the system's detected language.
4754    ///
4755    /// This should be called during window initialization, passing the language
4756    /// from `SystemStyle::language`.
4757    ///
4758    /// # Arguments
4759    /// * `locale` - The BCP 47 language tag (e.g., "en-US", "de-DE")
4760    pub fn set_icu_locale(&mut self, locale: &str) {
4761        self.icu_localizer.set_locale(locale);
4762    }
4763
4764    /// Initialize the ICU localizer from a SystemStyle.
4765    ///
4766    /// This is a convenience method that extracts the language from the system style.
4767    pub fn init_icu_from_system_style(&mut self, system_style: &azul_css::system::SystemStyle) {
4768        self.icu_localizer = IcuLocalizerHandle::from_system_language(&system_style.language);
4769    }
4770
4771    /// Get a clone of the ICU localizer handle.
4772    ///
4773    /// This can be used to perform locale-aware formatting outside of callbacks.
4774    pub fn get_icu_localizer(&self) -> IcuLocalizerHandle {
4775        self.icu_localizer.clone()
4776    }
4777
4778    /// Load additional ICU locale data from a binary blob.
4779    ///
4780    /// The blob should be generated using `icu4x-datagen` with the `--format blob` flag.
4781    /// This allows supporting locales that aren't compiled into the binary.
4782    pub fn load_icu_data_blob(&mut self, data: Vec<u8>) -> bool {
4783        self.icu_localizer.load_data_blob(&data)
4784    }
4785}
4786
4787#[cfg(test)]
4788mod tests {
4789    use super::*;
4790    use crate::{thread::Thread, timer::Timer};
4791
4792    #[test]
4793    fn test_timer_add_remove() {
4794        let fc_cache = FcFontCache::default();
4795        let mut window = LayoutWindow::new(fc_cache).unwrap();
4796
4797        let timer_id = TimerId { id: 1 };
4798        let timer = Timer::default();
4799
4800        // Add timer
4801        window.add_timer(timer_id, timer);
4802        assert!(window.get_timer(&timer_id).is_some());
4803        assert_eq!(window.get_timer_ids().len(), 1);
4804
4805        // Remove timer
4806        let removed = window.remove_timer(&timer_id);
4807        assert!(removed.is_some());
4808        assert!(window.get_timer(&timer_id).is_none());
4809        assert_eq!(window.get_timer_ids().len(), 0);
4810    }
4811
4812    #[test]
4813    fn test_timer_get_mut() {
4814        let fc_cache = FcFontCache::default();
4815        let mut window = LayoutWindow::new(fc_cache).unwrap();
4816
4817        let timer_id = TimerId { id: 1 };
4818        let timer = Timer::default();
4819
4820        window.add_timer(timer_id, timer);
4821
4822        // Get mutable reference
4823        let timer_mut = window.get_timer_mut(&timer_id);
4824        assert!(timer_mut.is_some());
4825    }
4826
4827    #[test]
4828    fn test_multiple_timers() {
4829        let fc_cache = FcFontCache::default();
4830        let mut window = LayoutWindow::new(fc_cache).unwrap();
4831
4832        let timer1 = TimerId { id: 1 };
4833        let timer2 = TimerId { id: 2 };
4834        let timer3 = TimerId { id: 3 };
4835
4836        window.add_timer(timer1, Timer::default());
4837        window.add_timer(timer2, Timer::default());
4838        window.add_timer(timer3, Timer::default());
4839
4840        assert_eq!(window.get_timer_ids().len(), 3);
4841
4842        window.remove_timer(&timer2);
4843        assert_eq!(window.get_timer_ids().len(), 2);
4844        assert!(window.get_timer(&timer1).is_some());
4845        assert!(window.get_timer(&timer2).is_none());
4846        assert!(window.get_timer(&timer3).is_some());
4847    }
4848
4849    // Thread management tests removed - Thread::default() not available
4850    // and threads require complex setup. Thread management is tested
4851    // through integration tests instead.
4852
4853    #[test]
4854    fn test_gpu_cache_management() {
4855        let fc_cache = FcFontCache::default();
4856        let mut window = LayoutWindow::new(fc_cache).unwrap();
4857
4858        let dom_id = DomId { inner: 0 };
4859
4860        // Initially empty
4861        assert!(window.get_gpu_cache(&dom_id).is_none());
4862
4863        // Get or create
4864        let cache = window.get_or_create_gpu_cache(dom_id);
4865        assert!(cache.transform_keys.is_empty());
4866
4867        // Now exists
4868        assert!(window.get_gpu_cache(&dom_id).is_some());
4869
4870        // Can get mutable reference
4871        let cache_mut = window.get_gpu_cache_mut(&dom_id);
4872        assert!(cache_mut.is_some());
4873    }
4874
4875    #[test]
4876    fn test_gpu_cache_multiple_doms() {
4877        let fc_cache = FcFontCache::default();
4878        let mut window = LayoutWindow::new(fc_cache).unwrap();
4879
4880        let dom1 = DomId { inner: 0 };
4881        let dom2 = DomId { inner: 1 };
4882
4883        window.get_or_create_gpu_cache(dom1);
4884        window.get_or_create_gpu_cache(dom2);
4885
4886        assert!(window.get_gpu_cache(&dom1).is_some());
4887        assert!(window.get_gpu_cache(&dom2).is_some());
4888    }
4889
4890    #[test]
4891    fn test_compute_cursor_type_empty_hit_test() {
4892        use crate::hit_test::FullHitTest;
4893
4894        let fc_cache = FcFontCache::default();
4895        let window = LayoutWindow::new(fc_cache).unwrap();
4896
4897        let empty_hit = FullHitTest::empty(None);
4898        let cursor_test = window.compute_cursor_type_hit_test(&empty_hit);
4899
4900        // Empty hit test should result in default cursor
4901        assert_eq!(
4902            cursor_test.cursor_icon,
4903            azul_core::window::MouseCursorType::Default
4904        );
4905        assert!(cursor_test.cursor_node.is_none());
4906    }
4907
4908    #[test]
4909    fn test_layout_result_access() {
4910        let fc_cache = FcFontCache::default();
4911        let window = LayoutWindow::new(fc_cache).unwrap();
4912
4913        let dom_id = DomId { inner: 0 };
4914
4915        // Initially no layout results
4916        assert!(window.get_layout_result(&dom_id).is_none());
4917        assert_eq!(window.get_dom_ids().len(), 0);
4918    }
4919
4920    // ScrollManager and VirtualView Integration Tests
4921
4922    #[test]
4923    fn test_scroll_manager_initialization() {
4924        let fc_cache = FcFontCache::default();
4925        let window = LayoutWindow::new(fc_cache).unwrap();
4926
4927        let dom_id = DomId::ROOT_ID;
4928        let node_id = NodeId::new(0);
4929
4930        // Initially no scroll states
4931        let scroll_offsets = window.scroll_manager.get_scroll_states_for_dom(dom_id);
4932        assert!(scroll_offsets.is_empty());
4933
4934        // No current offset
4935        let offset = window.scroll_manager.get_current_offset(dom_id, node_id);
4936        assert_eq!(offset, None);
4937    }
4938
4939    #[test]
4940    fn test_scroll_manager_tick_updates_activity() {
4941        let fc_cache = FcFontCache::default();
4942        let mut window = LayoutWindow::new(fc_cache).unwrap();
4943
4944        let dom_id = DomId::ROOT_ID;
4945        let node_id = NodeId::new(0);
4946
4947        // Create a scroll input
4948        #[cfg(feature = "std")]
4949        let now = Instant::System(std::time::Instant::now().into());
4950        #[cfg(not(feature = "std"))]
4951        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
4952
4953        let scroll_input = crate::managers::scroll_state::ScrollInput {
4954            dom_id,
4955            node_id,
4956            delta: LogicalPosition::new(10.0, 20.0),
4957            timestamp: now,
4958            source: crate::managers::scroll_state::ScrollInputSource::WheelDiscrete,
4959        };
4960
4961        let should_start_timer = window
4962            .scroll_manager
4963            .record_scroll_input(scroll_input);
4964
4965        // record_scroll_input should return true (timer was not running)
4966        assert!(should_start_timer);
4967    }
4968
4969    #[test]
4970    fn test_scroll_manager_programmatic_scroll() {
4971        let fc_cache = FcFontCache::default();
4972        let mut window = LayoutWindow::new(fc_cache).unwrap();
4973
4974        let dom_id = DomId::ROOT_ID;
4975        let node_id = NodeId::new(0);
4976
4977        #[cfg(feature = "std")]
4978        let now = Instant::System(std::time::Instant::now().into());
4979        #[cfg(not(feature = "std"))]
4980        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
4981
4982        // Programmatic scroll with animation
4983        window.scroll_manager.scroll_to(
4984            dom_id,
4985            node_id,
4986            LogicalPosition::new(100.0, 200.0),
4987            Duration::System(SystemTimeDiff::from_millis(300)),
4988            EasingFunction::EaseOut,
4989            now.clone(),
4990        );
4991
4992        let tick_result = window.scroll_manager.tick(now);
4993
4994        // Programmatic scroll should start animation
4995        assert!(tick_result.needs_repaint);
4996    }
4997
4998
4999
5000    #[test]
5001    fn test_gpu_cache_scrollbar_opacity_keys() {
5002        let fc_cache = FcFontCache::default();
5003        let mut window = LayoutWindow::new(fc_cache).unwrap();
5004
5005        let dom_id = DomId::ROOT_ID;
5006        let node_id = NodeId::new(0);
5007
5008        // Get or create GPU cache
5009        let gpu_cache = window.get_or_create_gpu_cache(dom_id);
5010
5011        // Initially no scrollbar opacity keys
5012        assert!(gpu_cache.scrollbar_v_opacity_keys.is_empty());
5013        assert!(gpu_cache.scrollbar_h_opacity_keys.is_empty());
5014
5015        // Add a vertical scrollbar opacity key
5016        let opacity_key = OpacityKey::unique();
5017        gpu_cache
5018            .scrollbar_v_opacity_keys
5019            .insert((dom_id, node_id), opacity_key);
5020        gpu_cache
5021            .scrollbar_v_opacity_values
5022            .insert((dom_id, node_id), 1.0);
5023
5024        // Verify it was added
5025        assert_eq!(gpu_cache.scrollbar_v_opacity_keys.len(), 1);
5026        assert_eq!(
5027            gpu_cache.scrollbar_v_opacity_values.get(&(dom_id, node_id)),
5028            Some(&1.0)
5029        );
5030    }
5031
5032
5033}
5034
5035// --- Cross-Paragraph Cursor Navigation API ---
5036impl LayoutWindow {
5037    /// Finds the next text node in the DOM tree after the given node.
5038    ///
5039    /// This function performs a depth-first traversal to find the next node
5040    /// that contains text content and is selectable (user-select != none).
5041    ///
5042    /// # Arguments
5043    /// * `dom_id` - The ID of the DOM containing the current node
5044    /// * `current_node` - The current node ID to start searching from
5045    ///
5046    /// # Returns
5047    /// * `Some((DomId, NodeId))` - The next text node if found
5048    /// * `None` - If no next text node exists
5049    pub fn find_next_text_node(
5050        &self,
5051        dom_id: &DomId,
5052        current_node: NodeId,
5053    ) -> Option<(DomId, NodeId)> {
5054        let layout_result = self.get_layout_result(dom_id)?;
5055        let styled_dom = &layout_result.styled_dom;
5056
5057        // Start from the next node in document order
5058        let start_idx = current_node.index() + 1;
5059        let node_hierarchy = &styled_dom.node_hierarchy;
5060
5061        for i in start_idx..node_hierarchy.len() {
5062            let node_id = NodeId::new(i);
5063
5064            // Check if node has text content
5065            if Self::node_has_text_content(styled_dom, node_id) {
5066                // Check if text is selectable
5067                if Self::is_text_selectable(styled_dom, node_id) {
5068                    return Some((*dom_id, node_id));
5069                }
5070            }
5071        }
5072
5073        None
5074    }
5075
5076    /// Finds the previous text node in the DOM tree before the given node.
5077    ///
5078    /// This function performs a reverse depth-first traversal to find the previous node
5079    /// that contains text content and is selectable.
5080    ///
5081    /// # Arguments
5082    /// * `dom_id` - The ID of the DOM containing the current node
5083    /// * `current_node` - The current node ID to start searching from
5084    ///
5085    /// # Returns
5086    /// * `Some((DomId, NodeId))` - The previous text node if found
5087    /// * `None` - If no previous text node exists
5088    pub fn find_prev_text_node(
5089        &self,
5090        dom_id: &DomId,
5091        current_node: NodeId,
5092    ) -> Option<(DomId, NodeId)> {
5093        let layout_result = self.get_layout_result(dom_id)?;
5094        let styled_dom = &layout_result.styled_dom;
5095
5096        // Start from the previous node in reverse document order
5097        let current_idx = current_node.index();
5098
5099        for i in (0..current_idx).rev() {
5100            let node_id = NodeId::new(i);
5101
5102            // Check if node has text content
5103            if Self::node_has_text_content(styled_dom, node_id) {
5104                // Check if text is selectable
5105                if Self::is_text_selectable(styled_dom, node_id) {
5106                    return Some((*dom_id, node_id));
5107                }
5108            }
5109        }
5110
5111        None
5112    }
5113
5114    /// Find the last text child node of a given node.
5115    ///
5116    /// For contenteditable elements, the text is usually in a child Text node,
5117    /// not the contenteditable div itself. This function finds the last Text node
5118    /// so the cursor defaults to the end position.
5119    fn find_last_text_child(&self, dom_id: DomId, parent_node_id: NodeId) -> Option<NodeId> {
5120        let layout_result = self.layout_results.get(&dom_id)?;
5121        let styled_dom = &layout_result.styled_dom;
5122        let node_data_container = styled_dom.node_data.as_container();
5123        let hierarchy_container = styled_dom.node_hierarchy.as_container();
5124
5125        // Check if parent itself is a text node
5126        let parent_type = node_data_container[parent_node_id].get_node_type();
5127        if matches!(parent_type, NodeType::Text(_)) {
5128            return Some(parent_node_id);
5129        }
5130
5131        // Find the last text child by iterating through all children
5132        let parent_item = &hierarchy_container[parent_node_id];
5133        let mut last_text_child: Option<NodeId> = None;
5134        let mut current_child = parent_item.first_child_id(parent_node_id);
5135        while let Some(child_id) = current_child {
5136            let child_type = node_data_container[child_id].get_node_type();
5137            if matches!(child_type, NodeType::Text(_)) {
5138                last_text_child = Some(child_id);
5139            }
5140            current_child = hierarchy_container[child_id].next_sibling_id();
5141        }
5142
5143        last_text_child
5144    }
5145
5146    /// Checks if a node has text content.
5147    fn node_has_text_content(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5148        // Check if node itself is a text node
5149        let node_data_container = styled_dom.node_data.as_container();
5150        let node_type = node_data_container[node_id].get_node_type();
5151        if matches!(node_type, NodeType::Text(_)) {
5152            return true;
5153        }
5154
5155        // Check if node has text children
5156        let hierarchy_container = styled_dom.node_hierarchy.as_container();
5157        let node_item = &hierarchy_container[node_id];
5158
5159        // Iterate through children
5160        let mut current_child = node_item.first_child_id(node_id);
5161        while let Some(child_id) = current_child {
5162            let child_type = node_data_container[child_id].get_node_type();
5163            if matches!(child_type, NodeType::Text(_)) {
5164                return true;
5165            }
5166
5167            // Move to next sibling
5168            current_child = hierarchy_container[child_id].next_sibling_id();
5169        }
5170
5171        false
5172    }
5173
5174    /// Checks if text in a node is selectable based on CSS user-select property.
5175    fn is_text_selectable(styled_dom: &StyledDom, node_id: NodeId) -> bool {
5176        let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
5177        solver3::getters::is_text_selectable(styled_dom, node_id, node_state)
5178    }
5179
5180    /// Process an accessibility action from an assistive technology.
5181    ///
5182    /// This method dispatches actions to the appropriate managers (scroll, focus, etc.)
5183    /// and returns information about which nodes were affected and how.
5184    ///
5185    /// # Arguments
5186    /// * `dom_id` - The DOM containing the target node
5187    /// * `node_id` - The target node for the action
5188    /// * `action` - The accessibility action to perform
5189    /// * `now` - Current timestamp for animations
5190    ///
5191    /// # Returns
5192    /// A `BTreeMap` of affected nodes with:
5193    /// - Key: `DomNodeId` that was affected
5194    /// - Value: (Vec<EventFilter> synthetic events to dispatch, bool indicating if node needs
5195    ///   re-layout)
5196    ///
5197    /// Empty map = action was not applicable or nothing changed
5198    #[cfg(feature = "a11y")]
5199    #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)] // bounded layout/render numeric cast
5200    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5201    #[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
5202    pub fn process_accessibility_action(
5203        &mut self,
5204        dom_id: DomId,
5205        node_id: NodeId,
5206        action: AccessibilityAction,
5207        now: std::time::Instant,
5208    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
5209        use crate::managers::text_input::TextInputSource;
5210
5211        let mut affected_nodes = BTreeMap::new();
5212
5213        match action {
5214            // Focus actions
5215            AccessibilityAction::Focus => {
5216                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5217                let dom_node_id = DomNodeId {
5218                    dom: dom_id,
5219                    node: hierarchy_id,
5220                };
5221                self.focus_manager.set_focused_node(Some(dom_node_id));
5222
5223                // Check if node is contenteditable - if so, initialize cursor at end of text
5224                if let Some(layout_result) = self.layout_results.get(&dom_id) {
5225                    if let Some(styled_node) = layout_result
5226                        .styled_dom
5227                        .node_data
5228                        .as_ref()
5229                        .get(node_id.index())
5230                    {
5231                        // Check BOTH: the contenteditable boolean field AND the attribute
5232                        // NodeData has a direct `contenteditable: bool` field that should be
5233                        // checked in addition to the attribute for robustness
5234                        let is_contenteditable = styled_node.is_contenteditable()
5235                            || styled_node.attributes().as_ref().iter().any(|attr| {
5236                                matches!(attr, AttributeType::ContentEditable(_))
5237                            });
5238
5239                        if is_contenteditable {
5240                            // Get inline layout for cursor positioning
5241                            // Clone the Arc to avoid borrow conflict
5242                            let inline_layout = self.get_inline_layout_for_node(dom_id, node_id).cloned();
5243                            if let Some(ref layout) = inline_layout {
5244                                let cursor = layout.items.iter().rev()
5245                                    .find_map(|item| if let ShapedItem::Cluster(c) = &item.item {
5246                                        Some(TextCursor {
5247                                            cluster_id: c.source_cluster_id,
5248                                            affinity: CursorAffinity::Trailing,
5249                                        })
5250                                    } else { None })
5251                                    .unwrap_or(TextCursor {
5252                                        cluster_id: GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
5253                                        affinity: CursorAffinity::Trailing,
5254                                    });
5255                                self.text_edit_manager.initialize_editing(cursor, dom_id, node_id, 0);
5256
5257                                // Scroll cursor into view if necessary
5258                                self.scroll_cursor_into_view_if_needed(dom_id, node_id, now);
5259                            }
5260                        } else {
5261                            // Not editable - clear cursor
5262                            self.text_edit_manager.clear_editing();
5263                        }
5264                    }
5265                }
5266
5267                // Optionally scroll into view
5268                self.scroll_to_node_if_needed(dom_id, node_id, now);
5269            }
5270            AccessibilityAction::Blur => {
5271                self.focus_manager.clear_focus();
5272                self.text_edit_manager.clear_editing();
5273            }
5274            AccessibilityAction::SetSequentialFocusNavigationStartingPoint => {
5275                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5276                let dom_node_id = DomNodeId {
5277                    dom: dom_id,
5278                    node: hierarchy_id,
5279                };
5280                self.focus_manager.set_focused_node(Some(dom_node_id));
5281                // Clear cursor for focus navigation
5282                self.text_edit_manager.clear_editing();
5283            }
5284
5285            // Scroll actions
5286            AccessibilityAction::ScrollIntoView => {
5287                self.scroll_to_node_if_needed(dom_id, node_id, now);
5288            }
5289            AccessibilityAction::ScrollLeft |
5290            AccessibilityAction::ScrollRight |
5291            AccessibilityAction::ScrollUp |
5292            AccessibilityAction::ScrollDown => {
5293                // Find the scrollable ancestor (or the node itself if scrollable)
5294                let dom_node_id = DomNodeId {
5295                    dom: dom_id,
5296                    node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
5297                };
5298                let (scroll_dom, scroll_nid) = self.find_scrollable_ancestor(dom_node_id)
5299                    .and_then(|a| Some((a.dom, a.node.into_crate_internal()?)))
5300                    .unwrap_or((dom_id, node_id));
5301
5302                // Use viewport-relative scroll amounts (75% of viewport dimension)
5303                let bounds = self.get_node_bounds(scroll_dom, scroll_nid);
5304                let vp_h = bounds.map_or(600.0, |b| b.size.height as f32);
5305                let vp_w = bounds.map_or(800.0, |b| b.size.width as f32);
5306
5307                let (dx, dy) = match action {
5308                    AccessibilityAction::ScrollLeft  => (-vp_w * 0.75, 0.0),
5309                    AccessibilityAction::ScrollRight => ( vp_w * 0.75, 0.0),
5310                    AccessibilityAction::ScrollUp    => (0.0, -vp_h * 0.75),
5311                    AccessibilityAction::ScrollDown  => (0.0,  vp_h * 0.75),
5312                    _ => unreachable!(),
5313                };
5314
5315                self.scroll_manager.scroll_by(
5316                    scroll_dom,
5317                    scroll_nid,
5318                    LogicalPosition { x: dx, y: dy },
5319                    std::time::Duration::from_millis(250).into(),
5320                    EasingFunction::EaseOut,
5321                    now.into(),
5322                );
5323            }
5324            AccessibilityAction::SetScrollOffset(pos) => {
5325                self.scroll_manager.scroll_to(
5326                    dom_id,
5327                    node_id,
5328                    pos,
5329                    std::time::Duration::from_millis(0).into(),
5330                    EasingFunction::Linear,
5331                    now.into(),
5332                );
5333            }
5334            AccessibilityAction::ScrollToPoint(pos) => {
5335                self.scroll_manager.scroll_to(
5336                    dom_id,
5337                    node_id,
5338                    pos,
5339                    std::time::Duration::from_millis(300).into(),
5340                    EasingFunction::EaseInOut,
5341                    now.into(),
5342                );
5343            }
5344
5345            // Actions that should trigger element callbacks if they exist
5346            // These generate synthetic EventFilters that go through the normal
5347            // callback system
5348            AccessibilityAction::Default => {
5349                // Default action → synthetic Click event
5350                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5351                let dom_node_id = DomNodeId {
5352                    dom: dom_id,
5353                    node: hierarchy_id,
5354                };
5355
5356                // Default action maps to a synthetic MouseUp (click) event
5357                let event_filter = EventFilter::Hover(HoverEventFilter::MouseUp);
5358
5359                affected_nodes.insert(dom_node_id, (vec![event_filter], false));
5360            }
5361
5362            AccessibilityAction::Increment | AccessibilityAction::Decrement => {
5363                // Increment/Decrement work by:
5364                // 1. Reading the current value (from "value" attribute or text content)
5365                // 2. Parsing it as a number
5366                // 3. Incrementing/decrementing by 1
5367                // 4. Converting back to string
5368                // 5. Recording as text input (fires TextInput event)
5369                //
5370                // This allows user callbacks to intercept via On::TextInput
5371
5372                let is_increment = matches!(action, AccessibilityAction::Increment);
5373
5374                // Get the current value
5375                let current_value = self.layout_results.get(&dom_id).and_then(|layout_result| {
5376                    layout_result
5377                        .styled_dom
5378                        .node_data
5379                        .as_ref()
5380                        .get(node_id.index())
5381                        .and_then(|styled_node| {
5382                            // Try "value" attribute first
5383                            styled_node
5384                                .attributes()
5385                                .as_ref()
5386                                .iter()
5387                                .find_map(|attr| {
5388                                    if let AttributeType::Value(v) = attr {
5389                                        Some(v.as_str().to_string())
5390                                    } else {
5391                                        None
5392                                    }
5393                                })
5394                                .or_else(|| {
5395                                    // Fallback to text content
5396                                    if let NodeType::Text(text) = styled_node.get_node_type() {
5397                                        Some(text.as_str().to_string())
5398                                    } else {
5399                                        None
5400                                    }
5401                                })
5402                        })
5403                });
5404
5405                // Parse as number, increment/decrement, convert back to string
5406                if let Some(value_str) = current_value {
5407                    let parsed: Result<f64, _> = value_str.trim().parse();
5408
5409                    let new_value_str = parsed.map_or_else(|_| if is_increment {
5410                            "1".to_string()
5411                        } else {
5412                            "-1".to_string()
5413                        }, |num| {
5414                        // Successfully parsed as number
5415                        let new_num = if is_increment { num + 1.0 } else { num - 1.0 };
5416                        // Format with same precision as input if possible
5417                        if num.fract() == 0.0 {
5418                            format!("{}", new_num as i64)
5419                        } else {
5420                            format!("{new_num}")
5421                        }
5422                    });
5423
5424                    // Record as text input (will fire On::TextInput callbacks)
5425                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5426                    let dom_node_id = DomNodeId {
5427                        dom: dom_id,
5428                        node: hierarchy_id,
5429                    };
5430
5431                    // Get old text for changeset
5432                    let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
5433                    let old_text = self.extract_text_from_inline_content(&old_inline_content);
5434
5435                    // Record the text input
5436                    self.text_input_manager.record_input(
5437                        dom_node_id,
5438                        new_value_str,
5439                        old_text,
5440                        TextInputSource::Accessibility,
5441                    );
5442
5443                    // Add TextInput event to affected nodes
5444                    affected_nodes.insert(
5445                        dom_node_id,
5446                        (vec![EventFilter::Focus(FocusEventFilter::TextInput)], false),
5447                    );
5448                }
5449            }
5450
5451            AccessibilityAction::Collapse | AccessibilityAction::Expand => {
5452                // Map to corresponding On:: events
5453                let event_type = match action {
5454                    AccessibilityAction::Collapse => On::Collapse,
5455                    AccessibilityAction::Expand => On::Expand,
5456                    _ => unreachable!(),
5457                };
5458
5459                // Check if node has a callback for this event type
5460                if let Some(layout_result) = self.layout_results.get(&dom_id) {
5461                    if let Some(styled_node) = layout_result
5462                        .styled_dom
5463                        .node_data
5464                        .as_ref()
5465                        .get(node_id.index())
5466                    {
5467                        // Check if any callback matches this event type
5468                        let has_callback = styled_node
5469                            .callbacks
5470                            .as_ref()
5471                            .iter()
5472                            .any(|cb| cb.event == event_type.into());
5473
5474                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5475                        let dom_node_id = DomNodeId {
5476                            dom: dom_id,
5477                            node: hierarchy_id,
5478                        };
5479
5480                        if has_callback {
5481                            // Generate EventFilter for this specific callback
5482                            affected_nodes.insert(dom_node_id, (vec![event_type.into()], false));
5483                        } else {
5484                            // No specific callback - fallback to regular Click
5485                            affected_nodes.insert(
5486                                dom_node_id,
5487                                (vec![EventFilter::Hover(HoverEventFilter::MouseUp)], false),
5488                            );
5489                        }
5490                    }
5491                }
5492            }
5493
5494            // Context menu - check if node has a menu and trigger right-click event
5495            AccessibilityAction::ShowContextMenu => {
5496                // Check if the node has a context menu attached
5497                let Some(layout_result) = self.layout_results.get(&dom_id) else {
5498                    return affected_nodes;
5499                };
5500
5501                // Get the node from the styled DOM
5502                let Some(styled_node) = layout_result
5503                    .styled_dom
5504                    .node_data
5505                    .as_ref()
5506                    .get(node_id.index())
5507                else {
5508                    return affected_nodes;
5509                };
5510
5511                // Check if node has context menu
5512                let has_context_menu = styled_node.get_context_menu().is_some();
5513
5514                if has_context_menu {
5515                    // Return a synthetic right-click so the caller's event dispatcher
5516                    // triggers the normal context-menu code path (platform-specific).
5517                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5518                    let dom_node_id = DomNodeId { dom: dom_id, node: hierarchy_id };
5519                    affected_nodes.insert(
5520                        dom_node_id,
5521                        (vec![EventFilter::Hover(
5522                            HoverEventFilter::RightMouseDown,
5523                        )], false),
5524                    );
5525                }
5526            }
5527
5528            // Text editing actions - use text3/edit.rs
5529            AccessibilityAction::ReplaceSelectedText(ref text) => {
5530                let nodes = self.edit_text_node(
5531                    dom_id,
5532                    node_id,
5533                    &TextEditType::ReplaceSelection(text.as_str().to_string()),
5534                );
5535                for node in nodes {
5536                    affected_nodes.insert(node, (Vec::new(), true)); // true = needs re-layout
5537                }
5538            }
5539            AccessibilityAction::SetValue(ref text) => {
5540                let nodes = self.edit_text_node(
5541                    dom_id,
5542                    node_id,
5543                    &TextEditType::SetValue(text.as_str().to_string()),
5544                );
5545                for node in nodes {
5546                    affected_nodes.insert(node, (Vec::new(), true));
5547                }
5548            }
5549            AccessibilityAction::SetNumericValue(value) => {
5550                let nodes = self.edit_text_node(
5551                    dom_id,
5552                    node_id,
5553                    &TextEditType::SetNumericValue(f64::from(value.get())),
5554                );
5555                for node in nodes {
5556                    affected_nodes.insert(node, (Vec::new(), true));
5557                }
5558            }
5559            AccessibilityAction::SetTextSelection(selection) => {
5560                // Get the text layout for this node from the layout tree
5561                let text_layout = self.get_node_inline_layout(dom_id, node_id);
5562
5563                if let Some(inline_layout) = text_layout {
5564                    // Convert byte offsets to TextCursor positions
5565                    let start_cursor = Self::byte_offset_to_cursor(
5566                        inline_layout.as_ref(),
5567                        selection.selection_start as u32,
5568                    );
5569                    let end_cursor = Self::byte_offset_to_cursor(
5570                        inline_layout.as_ref(),
5571                        selection.selection_end as u32,
5572                    );
5573
5574                    {
5575                        let (start, end) = (start_cursor, end_cursor);
5576                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5577                        let dom_node_id = DomNodeId {
5578                            dom: dom_id,
5579                            node: hierarchy_id,
5580                        };
5581
5582                        // A collapsed selection (start == end) and a ranged one
5583                        // both place the cursor at the selection start.
5584                        let _ = end;
5585                        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
5586                            mc.set_single_cursor(start);
5587                        }
5588                    }
5589                } else {
5590                    // No text layout available for node - silently ignore
5591                }
5592            }
5593
5594            // Tooltip actions
5595            AccessibilityAction::ShowTooltip | AccessibilityAction::HideTooltip => {
5596                // TODO: Integrate with tooltip manager when implemented
5597            }
5598
5599            AccessibilityAction::CustomAction(_id) => {
5600                // TODO: Allow custom action handlers
5601            }
5602        }
5603
5604        affected_nodes
5605    }
5606
5607    /// Process text input from keyboard using cursor/selection/focus managers.
5608    ///
5609    /// This is the new unified text input handling. The framework manages text editing
5610    /// internally using managers, then fires callbacks (`On::TextInput`, `On::Changed`)
5611    /// after the internal state is already updated.
5612    ///
5613    /// ## Workflow
5614    /// 1. Check if focus manager has a focused contenteditable node
5615    /// 2. Get cursor/selection from managers
5616    /// 3. Call `edit_text_node` to apply the edit and update cache
5617    /// 4. Collect affected nodes that need dirty marking
5618    /// 5. Return map for re-layout triggering
5619    ///
5620    /// ## Parameters
5621    /// * `text_input` - The text that was typed (can be multiple chars for IME)
5622    ///
5623    /// ## Returns
5624    /// `BTreeMap` of affected nodes with:
5625    /// - Key: `DomNodeId` that was affected
5626    /// - Value: (Vec<EventFilter> synthetic events, bool `needs_relayout`)
5627    /// - Empty map = no focused contenteditable node
5628    pub fn record_text_input(
5629        &mut self,
5630        text_input: &str,
5631    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
5632        use std::collections::BTreeMap;
5633
5634        use crate::managers::text_input::TextInputSource;
5635
5636        let mut affected_nodes = BTreeMap::new();
5637
5638        if text_input.is_empty() {
5639            return affected_nodes;
5640        }
5641
5642        // Get focused node
5643        let Some(focused_node) = self.focus_manager.get_focused_node().copied() else {
5644            return affected_nodes;
5645        };
5646
5647        let Some(node_id) = focused_node.node.into_crate_internal() else {
5648            return affected_nodes;
5649        };
5650
5651        // Get the OLD text before any changes
5652        let old_inline_content = self.get_text_before_textinput(focused_node.dom, node_id);
5653        let old_text = self.extract_text_from_inline_content(&old_inline_content);
5654
5655        // Record the changeset in TextInputManager (but DON'T apply changes yet)
5656        self.text_input_manager.record_input(
5657            focused_node,
5658            text_input.to_string(),
5659            old_text,
5660            TextInputSource::Keyboard, // Assuming keyboard for now
5661        );
5662
5663        // Return affected nodes with TextInput event so callbacks can be invoked
5664        let text_input_event = vec![EventFilter::Focus(FocusEventFilter::TextInput)];
5665
5666        affected_nodes.insert(focused_node, (text_input_event, false)); // false = no re-layout yet
5667
5668        affected_nodes
5669    }
5670
5671    /// Apply the recorded text changeset to the text cache
5672    ///
5673    /// This is called AFTER user callbacks, if preventDefault was not set.
5674    /// This is where we actually compute the new text and update the cache.
5675    ///
5676    /// Also updates the cursor position to reflect the edit.
5677    ///
5678    /// Returns the nodes that need to be marked dirty for re-layout,
5679    /// and whether a full re-layout is needed (text size changed).
5680    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
5681    pub fn apply_text_changeset(&mut self) -> TextChangesetResult {
5682        use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
5683        use crate::text3::edit::{edit_text, TextEdit};
5684        static CHANGESET_COUNTER: AtomicUsize = AtomicUsize::new(0);
5685
5686        // Get the changeset from TextInputManager
5687        let empty = TextChangesetResult { dirty_nodes: Vec::new(), needs_relayout: false };
5688
5689        let changeset = match self.text_input_manager.get_pending_changeset() {
5690            Some(cs) => {
5691                cs.clone()
5692            }
5693            None => {
5694                return empty;
5695            }
5696        };
5697
5698        let Some(node_id) = changeset.node.node.into_crate_internal() else {
5699            self.text_input_manager.clear_changeset();
5700            return empty;
5701        };
5702
5703        let dom_id = changeset.node.dom;
5704
5705        // Check if node is contenteditable
5706        let Some(layout_result) = self.layout_results.get(&dom_id) else {
5707            self.text_input_manager.clear_changeset();
5708            return empty;
5709        };
5710
5711        let Some(styled_node) = layout_result
5712            .styled_dom
5713            .node_data
5714            .as_ref()
5715            .get(node_id.index()) else {
5716            self.text_input_manager.clear_changeset();
5717            return empty;
5718        };
5719
5720        // Check BOTH: the contenteditable boolean field AND the attribute
5721        // NodeData has a direct `contenteditable: bool` field that should be
5722        // checked in addition to the attribute for robustness
5723        let is_contenteditable = styled_node.is_contenteditable()
5724            || styled_node.attributes().as_ref().iter().any(|attr| {
5725                matches!(attr, AttributeType::ContentEditable(_))
5726            });
5727
5728        if !is_contenteditable {
5729            self.text_input_manager.clear_changeset();
5730            return empty;
5731        }
5732
5733        // Get the current inline content from cache
5734        let content = self.get_text_before_textinput(dom_id, node_id);
5735
5736        // Get current cursor/selection — prefer non-empty MultiCursorState, fall back to legacy
5737        let mc_selections = self.text_edit_manager.multi_cursor.as_ref()
5738            .map(azul_core::selection::MultiCursorState::to_selections)
5739            .unwrap_or_default();
5740        let current_selection = if !mc_selections.is_empty() {
5741            mc_selections
5742        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
5743            vec![Selection::Cursor(cursor)]
5744        } else {
5745            vec![Selection::Cursor(TextCursor {
5746                cluster_id: GraphemeClusterId {
5747                    source_run: 0,
5748                    start_byte_in_run: 0,
5749                },
5750                affinity: CursorAffinity::Leading,
5751            })]
5752        };
5753
5754        // Capture pre-state for undo/redo BEFORE mutation
5755        let old_text = self.extract_text_from_inline_content(&content);
5756        let old_cursor = current_selection.first().and_then(|sel| {
5757            if let Selection::Cursor(c) = sel {
5758                Some(*c)
5759            } else {
5760                None
5761            }
5762        });
5763        let old_selection_range = current_selection.first().and_then(|sel| {
5764            if let Selection::Range(r) = sel {
5765                Some(*r)
5766            } else {
5767                None
5768            }
5769        });
5770
5771        let pre_state = crate::managers::undo_redo::NodeStateSnapshot {
5772            node_id: NodeId::new(node_id.index()),
5773            text_content: old_text.into(),
5774            cursor_position: old_cursor.into(),
5775            selection_range: old_selection_range.into(),
5776            #[cfg(feature = "std")]
5777            timestamp: Instant::System(std::time::Instant::now().into()),
5778            #[cfg(not(feature = "std"))]
5779            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
5780        };
5781
5782        // Apply the edit using text3::edit - this is a pure function
5783        let text_edit = TextEdit::Insert(changeset.inserted_text.as_str().to_string());
5784        let (new_content, new_selections) = edit_text(&content, &current_selection, &text_edit);
5785
5786        // Update cursors from edit result
5787        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
5788            mc.update_from_edit_result(&new_selections);
5789        }
5790        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
5791
5792        // MWA-C-undo_redo: styled pre/post snapshots so undo/redo restore
5793        // the REAL styled content instead of rebuilding with
5794        // StyleProperties::default() (which stripped all styling).
5795        let pre_content_snapshot = content;
5796        let post_content_snapshot = new_content.clone();
5797
5798        // Update the text cache with the new inline content
5799        self.update_text_cache_after_edit(dom_id, node_id, new_content);
5800
5801        // Record this operation to the undo/redo manager AFTER successful mutation
5802
5803        // Get the new cursor position after edit using the layout's cursor rect
5804        let new_cursor = self
5805            .get_focused_cursor_rect()
5806            .map_or(CursorPosition::Uninitialized, |r| CursorPosition::InWindow(r.origin));
5807
5808        let old_cursor_pos = old_cursor
5809            .as_ref()
5810            .map_or(CursorPosition::Uninitialized, |_| {
5811                // The old cursor position was before the edit — the layout may
5812                // have already updated so we use the same rect as new_cursor.
5813                // This is acceptable for undo: the exact pre-edit position is
5814                // approximated; what matters is restoring focus to the node.
5815                self.get_focused_cursor_rect()
5816                    .map_or(CursorPosition::Uninitialized, |r| CursorPosition::InWindow(r.origin))
5817            });
5818
5819        // Generate a unique changeset ID
5820        let changeset_id = CHANGESET_COUNTER.fetch_add(1, Ordering::SeqCst);
5821
5822        let undo_changeset = TextChangeset {
5823            id: changeset_id,
5824            target: changeset.node,
5825            operation: TextOperation::InsertText(TextOpInsertText {
5826                text: changeset.inserted_text,
5827                position: old_cursor_pos,
5828                new_cursor,
5829            }),
5830            #[cfg(feature = "std")]
5831            timestamp: Instant::System(std::time::Instant::now().into()),
5832            #[cfg(not(feature = "std"))]
5833            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
5834        };
5835        self.undo_redo_manager
5836            .store_content_snapshot(changeset_id, pre_content_snapshot, post_content_snapshot);
5837        self.undo_redo_manager
5838            .record_operation(undo_changeset, pre_state);
5839
5840        // Clear the changeset now that it's been applied
5841        self.text_input_manager.clear_changeset();
5842
5843        // MWA-C-text_edit: typing resets the blink phase so the caret is
5844        // solid while the user types (W3C/native behavior) — previously the
5845        // caret kept blinking mid-keystroke because reset ran only on
5846        // click/focus/user-API.
5847        let now = Instant::now();
5848        self.text_edit_manager.blink.reset_blink_on_input(now);
5849
5850        // Check if any dirty text node needs ancestor relayout (text size changed)
5851        let needs_relayout = self.dirty_text_nodes.values()
5852            .any(|d| d.needs_ancestor_relayout);
5853
5854        // Return nodes that need dirty marking
5855        let dirty_nodes = self.determine_dirty_text_nodes(dom_id, node_id);
5856        TextChangesetResult { dirty_nodes, needs_relayout }
5857    }
5858
5859    /// Determine which nodes need to be marked dirty after a text edit
5860    ///
5861    /// Returns the edited node + its parent (if it exists)
5862    fn determine_dirty_text_nodes(
5863        &self,
5864        dom_id: DomId,
5865        node_id: NodeId,
5866    ) -> Vec<DomNodeId> {
5867        let Some(layout_result) = self.layout_results.get(&dom_id) else {
5868            return Vec::new();
5869        };
5870
5871        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
5872        let node_dom_id = DomNodeId {
5873            dom: dom_id,
5874            node: hierarchy_id,
5875        };
5876
5877        // Get parent node ID
5878        let parent_id = layout_result
5879            .styled_dom
5880            .node_hierarchy
5881            .as_container()
5882            .get(node_id)
5883            .and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id)
5884            .map(|parent_node_id| {
5885                let parent_hierarchy_id =
5886                    NodeHierarchyItemId::from_crate_internal(Some(parent_node_id));
5887                DomNodeId {
5888                    dom: dom_id,
5889                    node: parent_hierarchy_id,
5890                }
5891            });
5892
5893        // Return node + parent (if exists)
5894        parent_id.map_or_else(|| vec![node_dom_id], |parent| vec![node_dom_id, parent])
5895    }
5896
5897    /// Legacy name for backward compatibility
5898    #[inline]
5899    pub fn process_text_input(
5900        &mut self,
5901        text_input: &str,
5902    ) -> BTreeMap<DomNodeId, (Vec<EventFilter>, bool)> {
5903        self.record_text_input(text_input)
5904    }
5905
5906    /// Get the last text changeset (what was changed in the last text input)
5907    pub const fn get_last_text_changeset(&self) -> Option<&PendingTextEdit> {
5908        self.text_input_manager.get_pending_changeset()
5909    }
5910
5911    /// Get the current inline content (text before text input is applied)
5912    ///
5913    /// This is a query function that retrieves the current text state from the node.
5914    /// Returns `InlineContent` vector if the node has text.
5915    ///
5916    /// # Implementation Note
5917    /// This function FIRST checks `dirty_text_nodes` for optimistic state (edits not yet
5918    /// committed to `StyledDom`), then falls back to the `StyledDom`. This is critical for
5919    /// correct text input handling - without this, each keystroke would read stale state.
5920    pub fn get_text_before_textinput(&self, dom_id: DomId, node_id: NodeId) -> Vec<InlineContent> {
5921        // CRITICAL FIX: Check dirty_text_nodes first!
5922        // If the node has been edited since last full layout, its most up-to-date
5923        // content is in dirty_text_nodes, NOT in the StyledDom.
5924        // Without this check, every keystroke reads the ORIGINAL text instead of
5925        // the accumulated edits, causing bugs like double-input and wrong node affected.
5926        if let Some(dirty_node) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
5927            return dirty_node.content.clone();
5928        }
5929
5930        // Fallback to committed state from StyledDom
5931        // Get the layout result for this DOM
5932        let Some(layout_result) = self.layout_results.get(&dom_id) else {
5933            return Vec::new();
5934        };
5935
5936        // Get the node data
5937        let Some(node_data) = layout_result
5938            .styled_dom
5939            .node_data
5940            .as_ref()
5941            .get(node_id.index())
5942        else {
5943            return Vec::new();
5944        };
5945
5946        // Extract text content from the node
5947        match node_data.get_node_type() {
5948            NodeType::Text(text) => {
5949                // Simple text node - create a single StyledRun
5950                let style = self.get_text_style_for_node(dom_id, node_id);
5951
5952                vec![InlineContent::Text(StyledRun {
5953                    text: text.as_str().to_string(),
5954                    style,
5955                    logical_start_byte: 0,
5956                    source_node_id: Some(node_id),
5957                })]
5958            }
5959            NodeType::Div | NodeType::Body | NodeType::VirtualView => {
5960                // Container nodes - recursively collect text from children
5961                self.collect_text_from_children(dom_id, node_id)
5962            }
5963            _ => {
5964                // Other node types (Image, etc.) don't contribute text
5965                Vec::new()
5966            }
5967        }
5968    }
5969
5970    /// Get the font style for a text node from CSS
5971    fn get_text_style_for_node(
5972        &self,
5973        dom_id: DomId,
5974        node_id: NodeId,
5975    ) -> Arc<StyleProperties> {
5976        use alloc::sync::Arc;
5977
5978        let Some(layout_result) = self.layout_results.get(&dom_id) else {
5979            return Arc::new(StyleProperties::default());
5980        };
5981
5982        // Use the proper CSS property resolution from solver3::getters
5983        let vp = layout_result.viewport.size;
5984        let props = solver3::getters::get_style_properties(
5985            &layout_result.styled_dom,
5986            node_id,
5987            self.system_style.as_ref(),
5988            azul_css::props::basic::PhysicalSize::new(vp.width, vp.height),
5989        );
5990
5991        Arc::new(props)
5992    }
5993
5994    /// Recursively collect text content from child nodes
5995    fn collect_text_from_children(
5996        &self,
5997        dom_id: DomId,
5998        parent_node_id: NodeId,
5999    ) -> Vec<InlineContent> {
6000        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6001            return Vec::new();
6002        };
6003
6004        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
6005        let Some(parent_item) = node_hierarchy.get(parent_node_id.index()) else {
6006            return Vec::new();
6007        };
6008
6009        let mut result = Vec::new();
6010
6011        // Traverse all children
6012        let mut current_child = parent_item.first_child_id(parent_node_id);
6013        while let Some(child_id) = current_child {
6014            // Get content from this child (recursive)
6015            let child_content = self.get_text_before_textinput(dom_id, child_id);
6016            result.extend(child_content);
6017
6018            // Move to next sibling
6019            let Some(child_item) = node_hierarchy.get(child_id.index()) else {
6020                break;
6021            };
6022            current_child = child_item.next_sibling_id();
6023        }
6024
6025        result
6026    }
6027
6028    /// Extract plain text string from inline content
6029    ///
6030    /// This is a helper for building the changeset's `resulting_text` field.
6031    // `&self` is only reached via the recursive Ruby arm; it is kept because this is a public
6032    // method called as `lw.extract_text_from_inline_content(..)` across dll and layout, and
6033    // converting to an associated fn would break that API at every call site.
6034    #[allow(clippy::only_used_in_recursion)]
6035    pub fn extract_text_from_inline_content(&self, content: &[InlineContent]) -> String {
6036        let mut result = String::new();
6037
6038        for item in content {
6039            match item {
6040                InlineContent::Text(text_run) => {
6041                    result.push_str(&text_run.text);
6042                }
6043                InlineContent::Space(_) => {
6044                    result.push(' ');
6045                }
6046                InlineContent::LineBreak(_) => {
6047                    result.push('\n');
6048                }
6049                InlineContent::Tab { .. } => {
6050                    result.push('\t');
6051                }
6052                InlineContent::Ruby { base, .. } => {
6053                    // For Ruby annotations, include the base text
6054                    result.push_str(&self.extract_text_from_inline_content(base));
6055                }
6056                InlineContent::Marker { run, .. } => {
6057                    // Markers contribute their text
6058                    result.push_str(&run.text);
6059                }
6060                // Images and shapes don't contribute to plain text
6061                InlineContent::Image(_) | InlineContent::Shape(_) => {}
6062            }
6063        }
6064
6065        result
6066    }
6067
6068    /// Update the text cache after a text edit
6069    ///
6070    /// This is the ONLY place where we mutate the text cache.
6071    /// All other functions are pure queries or transformations.
6072    ///
6073    /// This function:
6074    /// 1. Stores the new content in `dirty_text_nodes` for tracking
6075    /// 2. Re-runs the text3 layout pipeline (`create_logical_items` -> reorder -> shape -> fragment)
6076    /// 3. Updates the `inline_layout_result` on the IFC root node in the layout tree
6077    // called by the dll text-edit backends (event.rs/macos) with freshly-built content;
6078    // it is both cloned into the dirty-node cache and re-read for relayout, so it is taken
6079    // owned at this boundary rather than rippling a &[InlineContent] across the backends.
6080    #[allow(clippy::needless_pass_by_value)]
6081    #[allow(clippy::too_many_lines, clippy::cognitive_complexity)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6082    pub fn update_text_cache_after_edit(
6083        &mut self,
6084        dom_id: DomId,
6085        node_id: NodeId,
6086        new_inline_content: Vec<InlineContent>,
6087    ) {
6088        use crate::solver3::layout_tree::CachedInlineLayout;
6089
6090        // 1. Store the new content in dirty_text_nodes for tracking
6091        let cursor = self.text_edit_manager.get_primary_cursor();
6092        self.dirty_text_nodes.insert(
6093            (dom_id, node_id),
6094            DirtyTextNode {
6095                content: new_inline_content.clone(),
6096                cursor,
6097                needs_ancestor_relayout: false, // Will be set if size changes
6098            },
6099        );
6100
6101        // 2. Get the cached constraints from the existing inline layout result.
6102        // We need to find the IFC root node. The layout tree uses its own indices
6103        // (different from DOM node IDs), so we must go through dom_to_layout.
6104        // The IFC may be on this node OR a child — search all mapped layout nodes
6105        // and their children for one with inline_layout_result.
6106        let (mut constraints, ifc_layout_index) = {
6107            let Some(layout_result) = self.layout_results.get(&dom_id) else {
6108                return;
6109            };
6110
6111            // Find the layout node with inline_layout_result via dom_to_layout
6112            let mut found: Option<(usize, &CachedInlineLayout)> = None;
6113
6114            // First check layout nodes mapped to this DOM node
6115            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
6116                for &idx in layout_indices {
6117                    if let Some(w) = layout_result.layout_tree.warm(idx) {
6118                        if let Some(ref cached) = w.inline_layout_result {
6119                            found = Some((idx, cached));
6120                            break;
6121                        }
6122                    }
6123                }
6124            }
6125
6126            // If not found on this node, check child DOM nodes (text children of contenteditable)
6127            if found.is_none() {
6128                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
6129                if let Some(parent_item) = node_hierarchy.get(node_id.index()) {
6130                    let mut child = parent_item.first_child_id(node_id);
6131                    while let Some(child_id) = child {
6132                        if let Some(child_indices) = layout_result.layout_tree.dom_to_layout.get(&child_id) {
6133                            for &idx in child_indices {
6134                                if let Some(w) = layout_result.layout_tree.warm(idx) {
6135                                    if let Some(ref cached) = w.inline_layout_result {
6136                                        found = Some((idx, cached));
6137                                        break;
6138                                    }
6139                                }
6140                            }
6141                        }
6142                        if found.is_some() { break; }
6143                        child = node_hierarchy.get(child_id.index()).and_then(azul_core::styled_dom::NodeHierarchyItem::next_sibling_id);
6144                    }
6145                }
6146            }
6147
6148            let Some((ifc_idx, cached_layout)) = found else {
6149                return;
6150            };
6151
6152            match &cached_layout.constraints {
6153                Some(c) => (c.clone(), ifc_idx),
6154                None => {
6155                    return;
6156                }
6157            }
6158        };
6159
6160        // 2b. Refresh available_width from the containing block's used_size.
6161        //
6162        // The IFC root's `.parent` in the layout tree may point to a grandparent
6163        // (e.g. body) rather than the actual CSS containing block (the contenteditable
6164        // div) — layout tree parentage doesn't always match DOM parentage.
6165        //
6166        // Use `node_id` (the contenteditable DOM element) via dom_to_layout to find
6167        // the correct containing block. Its content-box width is what constrains text.
6168        if let Some(layout_result) = self.layout_results.get(&dom_id) {
6169            let mut found_width = false;
6170
6171            // Look up the contenteditable div's layout node directly via DOM mapping
6172            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
6173                for &idx in layout_indices {
6174                    if let Some(container_node) = layout_result.layout_tree.get(idx) {
6175                        if let Some(container_size) = container_node.used_size {
6176                            let bp = container_node.box_props.unpack();
6177                            let content_width = container_size.width
6178                                - bp.padding.left - bp.padding.right
6179                                - bp.border.left - bp.border.right;
6180                            if content_width > 0.0 {
6181                                constraints.available_width =
6182                                    crate::text3::cache::AvailableSpace::Definite(content_width);
6183                                found_width = true;
6184                            }
6185                            break;
6186                        }
6187                    }
6188                }
6189            }
6190
6191            // Fallback: walk up the IFC's ancestors in the layout tree
6192            if !found_width {
6193                if let Some(parent_idx) = layout_result.layout_tree.get(ifc_layout_index)
6194                    .and_then(|n| n.parent)
6195                {
6196                    if let Some(parent_node) = layout_result.layout_tree.get(parent_idx) {
6197                        if let Some(parent_size) = parent_node.used_size {
6198                            let bp = parent_node.box_props.unpack();
6199                            let content_width = parent_size.width
6200                                - bp.padding.left - bp.padding.right
6201                                - bp.border.left - bp.border.right;
6202                            if content_width > 0.0 {
6203                                constraints.available_width =
6204                                    crate::text3::cache::AvailableSpace::Definite(content_width);
6205                            }
6206                        }
6207                    }
6208                }
6209            }
6210        }
6211
6212        // 3. Re-run the text3 layout pipeline.
6213        //
6214        // Try the incremental path first: it runs stages 1-3 (logical items,
6215        // bidi, shape) on the new content and, if the cached layout is
6216        // still reusable (same item count, no overflow, line breaks cached),
6217        // skips stage 4 (line-breaking + positioning). For edits whose new
6218        // advances fall into GlyphSwap/LineShift territory, this turns a
6219        // full IFC relayout into a glyph + x-position patch.
6220        let cached_snapshot = self
6221            .layout_results
6222            .get(&dom_id)
6223            .and_then(|lr| lr.layout_tree.warm(ifc_layout_index))
6224            .and_then(|w| w.inline_layout_result.as_ref())
6225            .cloned();
6226
6227        let new_layout = cached_snapshot.map_or_else(|| self.relayout_text_node_internal(&new_inline_content, &constraints), |cached| self.try_incremental_text_relayout(
6228                &new_inline_content,
6229                &constraints,
6230                &cached,
6231                node_id,
6232            )
6233            .map(|(layout, _skipped_fragment)| layout));
6234
6235        let Some(new_layout) = new_layout else {
6236            return;
6237        };
6238
6239        // 4. Update the layout cache with the new layout
6240        // Use the ifc_layout_index we found earlier (correct layout tree index)
6241        if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
6242            let old_size = layout_result.layout_tree.get(ifc_layout_index).and_then(|n| n.used_size);
6243            let new_bounds = new_layout.bounds();
6244            let new_size = Some(LogicalSize {
6245                width: new_bounds.width,
6246                height: new_bounds.height,
6247            });
6248
6249            // Check if we need to propagate layout shift
6250            if let (Some(old), Some(new)) = (old_size, new_size) {
6251                if (old.height - new.height).abs() > 0.5 || (old.width - new.width).abs() > 0.5 {
6252                    // Mark that ancestor relayout is needed
6253                    if let Some(dirty_node) = self.dirty_text_nodes.get_mut(&(dom_id, node_id)) {
6254                        dirty_node.needs_ancestor_relayout = true;
6255                    }
6256                }
6257            }
6258
6259            // Update the inline layout result with the new layout but preserve constraints (warm data)
6260            if let Some(warm_node) = layout_result.layout_tree.warm_mut(ifc_layout_index) {
6261                warm_node.inline_layout_result = Some(CachedInlineLayout::new_with_constraints(
6262                    Arc::new(new_layout),
6263                    constraints.available_width,
6264                    false, // No floats in quick relayout
6265                    constraints,
6266                ));
6267            }
6268        }
6269
6270        // CRITICAL: Regenerate the display list after updating the inline layout.
6271        // Without this, the old display list (with old text glyphs) is sent to WebRender,
6272        // so the screen still shows the old text even though the layout tree is updated.
6273        self.regenerate_display_list_for_dom(dom_id);
6274    }
6275
6276    /// Re-apply a dirty text node's content to the layout cache after a full DOM rebuild.
6277    ///
6278    /// Called by `regenerate_layout()` after `layout_and_generate_display_list()`.
6279    /// The layout just ran on the stale DOM text, so we re-shape the edited text
6280    /// from `dirty_text_nodes` and update the inline layout result + display list.
6281    /// Inject preedit text into the text cache and regenerate the display list.
6282    ///
6283    /// Called from the platform IME handler (setMarkedText). Gets the current
6284    /// text content, splices the preedit string at the cursor position, then
6285    /// re-shapes and regenerates the display list so the preedit glyphs appear
6286    /// inline with an underline.
6287    /// # Panics
6288    ///
6289    /// Panics if there is no saved pre-preedit content to restore.
6290    pub fn apply_preedit_to_text_cache(&mut self, dom_id: DomId, node_id: NodeId) {
6291        let preedit = match &self.text_edit_manager.preedit_text {
6292            Some(p) if !p.is_empty() => p.clone(),
6293            _ => {
6294                // No preedit — restore original text and clear snapshot
6295                self.pre_preedit_content = None;
6296                self.reapply_dirty_text_node(dom_id, node_id);
6297                return;
6298            }
6299        };
6300
6301        let Some(cursor) = self.text_edit_manager.get_primary_cursor() else {
6302            return;
6303        };
6304
6305        // Save the original content on the FIRST preedit call so we always
6306        // inject into clean text (prevents accumulation of old preedits).
6307        if self.pre_preedit_content.is_none() {
6308            let original = self.get_text_before_textinput(dom_id, node_id);
6309            self.pre_preedit_content = Some(original);
6310        }
6311
6312        // Clone the saved original — never modify it in place
6313        let mut content = self.pre_preedit_content.clone().unwrap();
6314
6315        // Insert preedit at cursor position
6316        let run_idx = cursor.cluster_id.source_run as usize;
6317        let byte_pos = cursor.cluster_id.start_byte_in_run as usize;
6318        if let Some(InlineContent::Text(run)) = content.get_mut(run_idx) {
6319            let clamped_pos = byte_pos.min(run.text.len());
6320            run.text.insert_str(clamped_pos, &preedit);
6321        }
6322
6323        // Re-shape text with preedit injected — font fallback handles CJK
6324        self.update_text_cache_after_edit(dom_id, node_id, content);
6325        self.regenerate_display_list_for_dom(dom_id);
6326    }
6327
6328    pub fn reapply_dirty_text_node(&mut self, dom_id: DomId, node_id: NodeId) {
6329        let content = match self.dirty_text_nodes.get(&(dom_id, node_id)) {
6330            Some(dirty) => dirty.content.clone(),
6331            None => return,
6332        };
6333        // Re-run text shaping and update layout cache
6334        self.update_text_cache_after_edit(dom_id, node_id, content);
6335        // Regenerate display list with updated text
6336        self.regenerate_display_list_for_dom(dom_id);
6337    }
6338
6339    /// Regenerate the display list for a specific DOM from the current layout tree.
6340    ///
6341    /// This is the critical missing piece for text input: after `update_text_cache_after_edit`
6342    /// updates the `inline_layout_result` on layout tree nodes, the `DomLayoutResult.display_list`
6343    /// must be regenerated. Otherwise, `generate_frame()` sends the OLD display list to `WebRender`
6344    /// and the screen shows stale text.
6345    ///
6346    /// This method creates a temporary `LayoutContext` from the existing `LayoutWindow` state
6347    /// and calls `generate_display_list` on the already-computed layout tree and positions.
6348    pub fn regenerate_display_list_for_dom(&mut self, dom_id: DomId) {
6349        use crate::solver3::{
6350            display_list::generate_display_list,
6351            LayoutContext,
6352        };
6353
6354        // Get all the data we need from the layout result
6355        let Some(layout_result) = self.layout_results.get(&dom_id) else {
6356            return;
6357        };
6358
6359        let tree = &layout_result.layout_tree;
6360        let calculated_positions = &layout_result.calculated_positions;
6361        let scroll_ids = &layout_result.scroll_ids;
6362        let styled_dom = &layout_result.styled_dom;
6363        let viewport = layout_result.viewport;
6364
6365        // Get scroll offsets from scroll manager
6366        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
6367
6368        // Get GPU cache for this DOM
6369        let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id).clone();
6370
6371        // Get cursor state for display list generation
6372        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
6373        let cursor_locations = self.text_edit_manager.build_cursor_locations();
6374        let text_selections_map = self.text_edit_manager.build_text_selections_map();
6375
6376        // Build a temporary LayoutContext with all the state we need
6377        let mut counter_values = HashMap::new();
6378        let mut debug_messages: Option<Vec<LayoutDebugMessage>> = None;
6379        let cache_map = std::mem::take(&mut self.layout_cache.cache_map);
6380
6381        let mut ctx = LayoutContext {
6382            scrollbar_style_cache: core::cell::RefCell::new(HashMap::new()),
6383            styled_dom,
6384            font_manager: &self.font_manager,
6385            text_selections: &text_selections_map,
6386            debug_messages: &mut debug_messages,
6387            counters: &mut counter_values,
6388            viewport_size: viewport.size,
6389            fragmentation_context: None,
6390            cursor_is_visible,
6391            cursor_locations,
6392            preedit_text: self.text_edit_manager.preedit_text.clone(),
6393            cache_map,
6394            image_cache: &self.image_cache,
6395            system_style: self.system_style.clone(),
6396            get_system_time_fn: azul_core::task::GetSystemTimeCallback {
6397                cb: azul_core::task::get_system_time_libstd,
6398            },
6399            dirty_text_overrides: BTreeMap::new(),
6400        };
6401
6402        // Generate the new display list from the existing layout tree
6403        let new_display_list = generate_display_list(
6404            &mut ctx,
6405            tree,
6406            calculated_positions,
6407            &scroll_offsets,
6408            scroll_ids,
6409            Some(&gpu_cache),
6410            &self.renderer_resources,
6411            self.id_namespace,
6412            dom_id,
6413        );
6414
6415        // Restore the cache_map back to layout_cache
6416        self.layout_cache.cache_map = std::mem::take(&mut ctx.cache_map);
6417
6418        match new_display_list {
6419            Ok(display_list) => {
6420                if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
6421                    layout_result.display_list = display_list;
6422                }
6423                // Incremental a11y update: only push the edited node's
6424                // updated value + cursor, not the entire tree.
6425                #[cfg(feature = "a11y")]
6426                self.update_a11y_tree_incremental();
6427            }
6428            Err(_e) => {
6429            }
6430        }
6431    }
6432
6433    /// Internal helper to re-run the text3 layout pipeline on new content
6434    fn relayout_text_node_internal(
6435        &self,
6436        content: &[InlineContent],
6437        constraints: &UnifiedConstraints,
6438    ) -> Option<UnifiedLayout> {
6439        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
6440
6441        if logical_items.is_empty() {
6442            return Some(UnifiedLayout {
6443                items: Vec::new(),
6444                overflow: crate::text3::cache::OverflowInfo::default(),
6445            });
6446        }
6447
6448        self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)
6449    }
6450
6451    /// Stages 1-3 of the text3 pipeline (logical items, bidi reorder, shape).
6452    /// Returned separately so an incremental relayout path can skip stage 4
6453    /// (line breaking + positioning) when the cached layout is reusable.
6454    fn shape_text_for_relayout(
6455        &self,
6456        content: &[InlineContent],
6457        constraints: &UnifiedConstraints,
6458    ) -> Option<(
6459        Vec<crate::text3::cache::LogicalItem>,
6460        Vec<ShapedItem>,
6461    )> {
6462        use crate::text3::cache::{
6463            create_logical_items, reorder_logical_items, shape_visual_items, BidiDirection,
6464        };
6465
6466        let logical_items = create_logical_items(content, &[], &mut None);
6467        if logical_items.is_empty() {
6468            return Some((logical_items, Vec::new()));
6469        }
6470
6471        let base_direction = constraints.direction.unwrap_or(BidiDirection::Ltr);
6472        let visual_items = reorder_logical_items(
6473            &logical_items,
6474            base_direction,
6475            crate::text3::cache::UnicodeBidi::Normal,
6476            &mut None,
6477        )
6478        .ok()?;
6479
6480        let loaded_fonts = self.font_manager.get_loaded_fonts();
6481        let shaped_items = shape_visual_items(
6482            &visual_items,
6483            self.font_manager.get_font_chain_cache(),
6484            &self.font_manager.fc_cache,
6485            &loaded_fonts,
6486            &mut None,
6487        )
6488        .ok()?;
6489
6490        Some((logical_items, shaped_items))
6491    }
6492
6493    /// Stage 4 of the text3 pipeline: line breaking + positioning.
6494    fn fragment_layout_from_shaped(
6495        &self,
6496        logical_items: &[crate::text3::cache::LogicalItem],
6497        shaped_items: &[ShapedItem],
6498        constraints: &UnifiedConstraints,
6499    ) -> Option<UnifiedLayout> {
6500        use crate::text3::cache::{perform_fragment_layout, BreakCursor};
6501
6502        let loaded_fonts = self.font_manager.get_loaded_fonts();
6503        let mut cursor = BreakCursor::new(shaped_items);
6504        perform_fragment_layout(&mut cursor, logical_items, constraints, &mut None, &loaded_fonts).ok()
6505    }
6506
6507    /// Attempt an incremental IFC relayout for a text edit.
6508    ///
6509    /// Runs stages 1-3 (logical items, bidi, shape) on the new content, then
6510    /// checks whether the cached `UnifiedLayout` can be patched without
6511    /// re-running line-breaking (stage 4).
6512    ///
6513    /// Returns `Some((new_layout, skipped_fragment_layout))`:
6514    ///   - `skipped_fragment_layout == true` means we took the incremental
6515    ///     fast path and returned a patched cached layout.
6516    ///   - `skipped_fragment_layout == false` means we fell back to full
6517    ///     `fragment_layout` (stage 4) but reused shape output from stages 1-3.
6518    ///
6519    /// Returns `None` only if `logical_items` + reorder + shape itself fails.
6520    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
6521    fn try_incremental_text_relayout(
6522        &self,
6523        content: &[InlineContent],
6524        constraints: &UnifiedConstraints,
6525        cached: &solver3::layout_tree::CachedInlineLayout,
6526        edited_node_id: NodeId,
6527    ) -> Option<(UnifiedLayout, bool)> {
6528        use crate::text3::cache::{
6529            try_incremental_relayout as decide_incremental,
6530            IncrementalRelayoutResult, PositionedItem, ShapedItem,
6531        };
6532
6533        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
6534
6535        if logical_items.is_empty() {
6536            return Some((
6537                UnifiedLayout {
6538                    items: Vec::new(),
6539                    overflow: crate::text3::cache::OverflowInfo::default(),
6540                },
6541                true,
6542            ));
6543        }
6544
6545        // Incremental patching requires:
6546        //   - The cached layout came with line-break metadata.
6547        //   - No overflow in the cached layout (patching positions around
6548        //     overflow is not supported).
6549        //   - The new shape output has the same number of items as the
6550        //     cached positioned items, so we can zip 1:1.
6551        let incremental_ok = cached.line_breaks.is_some()
6552            && cached.layout.overflow.overflow_items.is_empty()
6553            && shaped_items.len() == cached.layout.items.len();
6554
6555        if incremental_ok {
6556            let line_breaks = cached.line_breaks.as_ref().unwrap();
6557
6558            let old_advances: Vec<f32> =
6559                cached.item_metrics.iter().map(|m| m.advance_width).collect();
6560            let new_advances: Vec<f32> =
6561                shaped_items.iter().map(|si| si.bounds().width).collect();
6562
6563            // An item is dirty if its advance width changed OR it originates
6564            // from the edited DOM node. The latter is needed so GlyphSwap
6565            // (same-width edits) still invalidates glyph data, not just
6566            // positions.
6567            let mut dirty_indices: Vec<usize> = Vec::new();
6568            for (i, (old_a, new_a)) in old_advances.iter().zip(new_advances.iter()).enumerate() {
6569                if (new_a - old_a).abs() > 0.01 {
6570                    dirty_indices.push(i);
6571                }
6572            }
6573            for (i, si) in shaped_items.iter().enumerate() {
6574                if let ShapedItem::Cluster(c) = si {
6575                    if c.source_node_id == Some(edited_node_id)
6576                        && !dirty_indices.contains(&i)
6577                    {
6578                        dirty_indices.push(i);
6579                    }
6580                }
6581            }
6582            dirty_indices.sort_unstable();
6583            dirty_indices.dedup();
6584
6585            let decision =
6586                decide_incremental(&dirty_indices, &old_advances, &new_advances, line_breaks);
6587
6588            match decision {
6589                IncrementalRelayoutResult::GlyphSwap => {
6590                    // Widths unchanged — keep cached positions and line
6591                    // assignments, swap in the new shaped items so their
6592                    // glyph data reflects the edit.
6593                    let items: Vec<PositionedItem> = cached
6594                        .layout
6595                        .items
6596                        .iter()
6597                        .zip(shaped_items)
6598                        .map(|(old_positioned, new_shaped)| PositionedItem {
6599                            item: new_shaped,
6600                            position: old_positioned.position,
6601                            line_index: old_positioned.line_index,
6602                        })
6603                        .collect();
6604                    return Some((
6605                        UnifiedLayout {
6606                            items,
6607                            overflow: cached.layout.overflow.clone(),
6608                        },
6609                        true,
6610                    ));
6611                }
6612                IncrementalRelayoutResult::LineShift {
6613                    affected_item,
6614                    delta,
6615                } => {
6616                    // Width changed but the line still fits — shift x
6617                    // positions of items after `affected_item` on the same
6618                    // line. Items on later lines keep their positions.
6619                    let affected_line = cached.layout.items[affected_item].line_index;
6620                    let items: Vec<PositionedItem> = cached
6621                        .layout
6622                        .items
6623                        .iter()
6624                        .zip(shaped_items)
6625                        .enumerate()
6626                        .map(|(i, (old_positioned, new_shaped))| {
6627                            let mut position = old_positioned.position;
6628                            if i > affected_item && old_positioned.line_index == affected_line {
6629                                position.x += delta;
6630                            }
6631                            PositionedItem {
6632                                item: new_shaped,
6633                                position,
6634                                line_index: old_positioned.line_index,
6635                            }
6636                        })
6637                        .collect();
6638                    return Some((
6639                        UnifiedLayout {
6640                            items,
6641                            overflow: cached.layout.overflow.clone(),
6642                        },
6643                        true,
6644                    ));
6645                }
6646                IncrementalRelayoutResult::PartialReflow { .. }
6647                | IncrementalRelayoutResult::FullRelayout => {
6648                    // Fall through to full fragment layout.
6649                }
6650            }
6651        }
6652
6653        // Fall-back: run stage 4 (line breaking + positioning) with the
6654        // already-computed logical + shaped items. Still cheaper than the
6655        // plain full path because stages 1-3 aren't repeated.
6656        let layout = self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)?;
6657        Some((layout, false))
6658    }
6659
6660    /// Helper to get node `used_size` for accessibility actions
6661    #[cfg(feature = "a11y")]
6662    fn get_node_used_size_a11y(
6663        &self,
6664        dom_id: DomId,
6665        node_id: NodeId,
6666    ) -> Option<LogicalSize> {
6667        let layout_result = self.layout_results.get(&dom_id)?;
6668        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
6669        let idx = *layout_indices.first()?;
6670        let node = layout_result.layout_tree.get(idx)?;
6671        node.used_size
6672    }
6673
6674    /// Get the layout bounds (position and size) of a specific node
6675    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
6676    pub fn get_node_bounds(
6677        &self,
6678        dom_id: DomId,
6679        node_id: NodeId,
6680    ) -> Option<azul_css::props::basic::LayoutRect> {
6681        use azul_css::props::basic::LayoutRect;
6682
6683        let layout_result = self.layout_results.get(&dom_id)?;
6684        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
6685        let idx = *layout_indices.first()?;
6686        let node = layout_result.layout_tree.get(idx)?;
6687
6688        // Get size from used_size
6689        let size = node.used_size?;
6690
6691        // Get position from calculated_positions — uses layout tree index, not DOM node index
6692        let position = layout_result.calculated_positions.get(idx)?;
6693
6694        Some(LayoutRect {
6695            origin: azul_css::props::basic::LayoutPoint {
6696                x: position.x as isize,
6697                y: position.y as isize,
6698            },
6699            size: azul_css::props::basic::LayoutSize {
6700                width: size.width as isize,
6701                height: size.height as isize,
6702            },
6703        })
6704    }
6705
6706    /// Scroll a node into view if it's not currently visible in the viewport
6707    #[cfg(feature = "a11y")]
6708    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
6709    fn scroll_to_node_if_needed(
6710        &mut self,
6711        dom_id: DomId,
6712        node_id: NodeId,
6713        now: std::time::Instant,
6714    ) {
6715        // 1. Get target node bounds
6716        let Some(target_bounds) = self.get_node_bounds(dom_id, node_id) else {
6717            return;
6718        };
6719
6720        // 2. Find nearest scrollable ancestor
6721        let dom_node_id = DomNodeId {
6722            dom: dom_id,
6723            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
6724        };
6725        let Some(scroll_ancestor) = self.find_scrollable_ancestor(dom_node_id) else {
6726            return;
6727        };
6728        let Some(scroll_node_id) = scroll_ancestor.node.into_crate_internal() else {
6729            return;
6730        };
6731        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
6732            return;
6733        };
6734
6735        let current_scroll = self
6736            .scroll_manager
6737            .get_current_offset(dom_id, scroll_node_id)
6738            .unwrap_or_default();
6739
6740        // 3. Check if target is already visible in the ancestor viewport
6741        let vp_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
6742        let vp_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
6743        let vp_w = ancestor_bounds.size.width as f32;
6744        let vp_h = ancestor_bounds.size.height as f32;
6745
6746        let target_x = target_bounds.origin.x as f32;
6747        let target_y = target_bounds.origin.y as f32;
6748        let target_w = target_bounds.size.width as f32;
6749        let target_h = target_bounds.size.height as f32;
6750
6751        let visible_x = target_x >= vp_x && (target_x + target_w) <= (vp_x + vp_w);
6752        let visible_y = target_y >= vp_y && (target_y + target_h) <= (vp_y + vp_h);
6753
6754        if visible_x && visible_y {
6755            return; // Already visible
6756        }
6757
6758        // 4. Calculate scroll offset to bring target into view
6759        let mut scroll_x = current_scroll.x;
6760        let mut scroll_y = current_scroll.y;
6761
6762        if target_x < vp_x {
6763            scroll_x = target_x - ancestor_bounds.origin.x as f32;
6764        } else if (target_x + target_w) > (vp_x + vp_w) {
6765            scroll_x = (target_x + target_w) - ancestor_bounds.origin.x as f32 - vp_w;
6766        }
6767
6768        if target_y < vp_y {
6769            scroll_y = target_y - ancestor_bounds.origin.y as f32;
6770        } else if (target_y + target_h) > (vp_y + vp_h) {
6771            scroll_y = (target_y + target_h) - ancestor_bounds.origin.y as f32 - vp_h;
6772        }
6773
6774        self.scroll_manager.scroll_to(
6775            dom_id,
6776            scroll_node_id,
6777            LogicalPosition { x: scroll_x, y: scroll_y },
6778            std::time::Duration::from_millis(300).into(),
6779            EasingFunction::EaseOut,
6780            now.into(),
6781        );
6782    }
6783
6784    /// Scroll the cursor into view if it's not currently visible
6785    ///
6786    /// This is automatically called when:
6787    /// - Focus lands on a contenteditable element
6788    /// - Cursor is moved programmatically
6789    /// - Text is inserted/deleted
6790    ///
6791    /// The function:
6792    /// 1. Gets the cursor rectangle from the text layout
6793    /// 2. Checks if the cursor is visible in the current viewport
6794    /// 3. If not, calculates the minimum scroll offset needed
6795    /// 4. Animates the scroll to bring the cursor into view
6796    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
6797    fn scroll_cursor_into_view_if_needed(
6798        &mut self,
6799        dom_id: DomId,
6800        node_id: NodeId,
6801        now: std::time::Instant,
6802    ) {
6803        // Get the cursor from multi_cursor
6804        let Some(cursor) = self.text_edit_manager.get_primary_cursor() else {
6805            return;
6806        };
6807
6808        // Get the inline layout for this node
6809        let Some(inline_layout) = self.get_node_inline_layout(dom_id, node_id) else {
6810            return;
6811        };
6812
6813        // Get the cursor rectangle from the text layout
6814        let Some(cursor_rect) = inline_layout.get_cursor_rect(&cursor) else {
6815            return;
6816        };
6817
6818        // Get the node bounds
6819        let Some(node_bounds) = self.get_node_bounds(dom_id, node_id) else {
6820            return;
6821        };
6822
6823        // Calculate the cursor's absolute position
6824        let cursor_abs_x = node_bounds.origin.x as f32 + cursor_rect.origin.x;
6825        let cursor_abs_y = node_bounds.origin.y as f32 + cursor_rect.origin.y;
6826
6827        // Walk up the DOM tree to find the nearest scrollable ancestor
6828        let dom_node_id = DomNodeId {
6829            dom: dom_id,
6830            node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
6831        };
6832        let Some(scroll_ancestor) = self.find_scrollable_ancestor(dom_node_id) else {
6833            return; // No scrollable container
6834        };
6835        let Some(scroll_node_id) = scroll_ancestor.node.into_crate_internal() else {
6836            return;
6837        };
6838
6839        // Get the scrollable ancestor's bounds and scroll offset
6840        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
6841            return;
6842        };
6843        let current_scroll = self
6844            .scroll_manager
6845            .get_current_offset(dom_id, scroll_node_id)
6846            .unwrap_or_default();
6847
6848        // Calculate visible viewport from the scrollable ancestor
6849        let viewport_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
6850        let viewport_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
6851        let viewport_width = ancestor_bounds.size.width as f32;
6852        let viewport_height = ancestor_bounds.size.height as f32;
6853
6854        // Check if cursor is visible
6855        let cursor_visible_x = cursor_abs_x >= viewport_x
6856            && cursor_abs_x <= viewport_x + viewport_width;
6857        let cursor_visible_y = cursor_abs_y >= viewport_y
6858            && cursor_abs_y <= viewport_y + viewport_height;
6859
6860        if cursor_visible_x && cursor_visible_y {
6861            // Cursor is already visible
6862            return;
6863        }
6864
6865        // Calculate scroll offset to make cursor visible
6866        let mut target_scroll_x = current_scroll.x;
6867        let mut target_scroll_y = current_scroll.y;
6868
6869        // Adjust horizontal scroll if needed
6870        if cursor_abs_x < viewport_x {
6871            target_scroll_x = cursor_abs_x - ancestor_bounds.origin.x as f32;
6872        } else if cursor_abs_x > viewport_x + viewport_width {
6873            target_scroll_x = cursor_abs_x - ancestor_bounds.origin.x as f32 - viewport_width
6874                + cursor_rect.size.width;
6875        }
6876
6877        // Adjust vertical scroll if needed
6878        if cursor_abs_y < viewport_y {
6879            target_scroll_y = cursor_abs_y - ancestor_bounds.origin.y as f32;
6880        } else if cursor_abs_y > viewport_y + viewport_height {
6881            target_scroll_y = cursor_abs_y - ancestor_bounds.origin.y as f32 - viewport_height
6882                + cursor_rect.size.height;
6883        }
6884
6885        // Animate scroll on the scrollable ancestor
6886        self.scroll_manager.scroll_to(
6887            dom_id,
6888            scroll_node_id,
6889            LogicalPosition {
6890                x: target_scroll_x,
6891                y: target_scroll_y,
6892            },
6893            std::time::Duration::from_millis(200).into(),
6894            EasingFunction::EaseOut,
6895            now.into(),
6896        );
6897    }
6898
6899    /// Convert a byte offset in the text to a `TextCursor` position
6900    ///
6901    /// This is used for accessibility `SetTextSelection` action, which provides
6902    /// byte offsets rather than grapheme cluster IDs.
6903    ///
6904    /// # Arguments
6905    ///
6906    /// * `text_layout` - The text layout containing the shaped runs
6907    /// * `byte_offset` - The byte offset in the UTF-8 text
6908    ///
6909    /// # Returns
6910    ///
6911    /// A `TextCursor` positioned at the given byte offset, or None if the offset
6912    /// is out of bounds.
6913    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
6914    fn byte_offset_to_cursor(
6915        text_layout: &UnifiedLayout,
6916        byte_offset: u32,
6917    ) -> TextCursor {
6918        // Handle offset 0 as special case (start of text)
6919        if byte_offset == 0 {
6920            // Find first cluster in items
6921            for item in &text_layout.items {
6922                if let ShapedItem::Cluster(cluster) = &item.item {
6923                    return TextCursor {
6924                        cluster_id: cluster.source_cluster_id,
6925                        affinity: CursorAffinity::Trailing,
6926                    };
6927                }
6928            }
6929            // No clusters found - return default
6930            return TextCursor {
6931                cluster_id: GraphemeClusterId {
6932                    source_run: 0,
6933                    start_byte_in_run: 0,
6934                },
6935                affinity: CursorAffinity::Trailing,
6936            };
6937        }
6938
6939        // Iterate through items to find which cluster contains this byte offset
6940        let mut current_byte_offset = 0u32;
6941
6942        for item in &text_layout.items {
6943            if let ShapedItem::Cluster(cluster) = &item.item {
6944                // Calculate byte length of this cluster from its text
6945                let cluster_byte_length = cluster.text.len() as u32;
6946                let cluster_end_byte = current_byte_offset + cluster_byte_length;
6947
6948                // Check if our target byte offset falls within this cluster
6949                if byte_offset >= current_byte_offset && byte_offset <= cluster_end_byte {
6950                    // Found the cluster
6951                    return TextCursor {
6952                        cluster_id: cluster.source_cluster_id,
6953                        affinity: CursorAffinity::Trailing,
6954                    };
6955                }
6956
6957                current_byte_offset = cluster_end_byte;
6958            }
6959        }
6960
6961        // Offset is beyond the end of all text - return cursor at end of last cluster
6962        for item in text_layout.items.iter().rev() {
6963            if let ShapedItem::Cluster(cluster) = &item.item {
6964                return TextCursor {
6965                    cluster_id: cluster.source_cluster_id,
6966                    affinity: CursorAffinity::Trailing,
6967                };
6968            }
6969        }
6970
6971        // No clusters at all - return default position
6972        TextCursor {
6973            cluster_id: GraphemeClusterId {
6974                source_run: 0,
6975                start_byte_in_run: 0,
6976            },
6977            affinity: CursorAffinity::Trailing,
6978        }
6979    }
6980
6981    /// Get the inline layout result for a specific node
6982    ///
6983    /// This looks up the node in the layout tree and returns its inline layout result
6984    /// if it exists.
6985    fn get_node_inline_layout(
6986        &self,
6987        dom_id: DomId,
6988        node_id: NodeId,
6989    ) -> Option<Arc<UnifiedLayout>> {
6990        // Get the layout tree from cache
6991        let layout_tree = self.layout_cache.tree.as_ref()?;
6992
6993        // Find the layout node index corresponding to the DOM node
6994        let layout_idx = layout_tree
6995            .nodes
6996            .iter()
6997            .position(|node| node.dom_node_id == Some(node_id))?;
6998
6999        // Return the inline layout result (warm data)
7000        layout_tree.warm(layout_idx)?
7001            .inline_layout_result
7002            .as_ref()
7003            .map(solver3::layout_tree::CachedInlineLayout::clone_layout)
7004    }
7005
7006    /// Edit the text content of a node (used for text input actions)
7007    ///
7008    /// This function applies text edits to nodes that contain text content.
7009    /// The DOM node itself is NOT modified - instead, the text cache is updated
7010    /// with the new shaped text that reflects the edit, cursor, and selection.
7011    ///
7012    /// It handles:
7013    /// - `ReplaceSelectedText`: Replaces the current selection with new text
7014    /// - `SetValue`: Sets the entire text value
7015    /// - `SetNumericValue`: Converts number to string and sets value
7016    ///
7017    /// # Returns
7018    ///
7019    /// Returns a Vec of `DomNodeIds` (node + parent) that need to be marked dirty
7020    /// for re-layout. The caller MUST use this return value to trigger layout.
7021    #[must_use = "Returned nodes must be marked dirty for re-layout"]
7022    #[cfg(feature = "a11y")]
7023    #[allow(clippy::match_same_arms)] // enum/value mapping/dispatch table: one arm per input variant (or cross-type bindings that can't merge)
7024    pub fn edit_text_node(
7025        &mut self,
7026        dom_id: DomId,
7027        node_id: NodeId,
7028        edit_type: &TextEditType,
7029    ) -> Vec<DomNodeId> {
7030        use crate::managers::text_input::TextInputSource;
7031
7032        // Convert TextEditType to string
7033        let text_input = match edit_type {
7034            TextEditType::ReplaceSelection(text) => text.clone(),
7035            TextEditType::SetValue(text) => text.clone(),
7036            TextEditType::SetNumericValue(value) => value.to_string(),
7037        };
7038
7039        // Get the OLD text before any changes
7040        let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
7041        let old_text = self.extract_text_from_inline_content(&old_inline_content);
7042
7043        // Create DomNodeId
7044        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
7045        let dom_node_id = DomNodeId {
7046            dom: dom_id,
7047            node: hierarchy_id,
7048        };
7049
7050        // Record the changeset in TextInputManager
7051        self.text_input_manager.record_input(
7052            dom_node_id,
7053            text_input,
7054            old_text,
7055            TextInputSource::Accessibility, // A11y source
7056        );
7057
7058        // Immediately apply the changeset (A11y doesn't go through callbacks)
7059        self.apply_text_changeset().dirty_nodes
7060    }
7061
7062    #[cfg(not(feature = "a11y"))]
7063    pub fn process_accessibility_action(
7064        &mut self,
7065        _dom_id: DomId,
7066        _node_id: NodeId,
7067        _action: azul_core::dom::AccessibilityAction,
7068        _now: std::time::Instant,
7069    ) -> BTreeMap<DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
7070        // No-op when accessibility is disabled
7071        BTreeMap::new()
7072    }
7073
7074    /// Process mouse click for text selection.
7075    ///
7076    /// This method handles:
7077    /// - Single click: Place cursor at click position
7078    /// - Double click: Select word at click position
7079    /// - Triple click: Select paragraph (line) at click position
7080    ///
7081    /// ## Workflow
7082    /// 1. Use `HoverManager`'s hit test to find hit nodes
7083    /// 2. Find the IFC layout via `inline_layout_result` (IFC root) or `ifc_membership` (text node)
7084    /// 3. Use `point_relative_to_item` for local cursor position
7085    /// 4. Hit-test the text layout to get logical cursor
7086    /// 5. Apply appropriate selection based on click count
7087    /// 6. Update `SelectionManager` with new selection
7088    ///
7089    /// ## IFC Architecture
7090    /// Text nodes don't store `inline_layout_result` directly. Instead:
7091    /// - IFC root nodes (e.g., `<p>`) have `inline_layout_result` with the complete text layout
7092    /// - Text nodes have `ifc_membership` pointing back to their IFC root
7093    /// - This allows efficient lookup without iterating all nodes
7094    ///
7095    /// ## Parameters
7096    /// * `position` - Click position in logical coordinates (for click count tracking)
7097    /// * `time_ms` - Current time in milliseconds (for multi-click detection)
7098    ///
7099    /// ## Returns
7100    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering, None if click didn't hit text
7101    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7102    pub fn process_mouse_click_for_selection(
7103        &mut self,
7104        position: LogicalPosition,
7105        time_ms: u64,
7106    ) -> Option<Vec<DomNodeId>> {
7107        use crate::managers::hover::InputPointId;
7108        use crate::text3::selection::{select_paragraph_at_cursor, select_word_at_cursor};
7109
7110        // found_selection stores: (dom_id, ifc_root_node_id, selection_range, local_pos)
7111        // IMPORTANT: We always store the IFC root NodeId, not the text node NodeId,
7112        // because selections are rendered via inline_layout_result which lives on the IFC root.
7113        let mut found_selection: Option<(DomId, NodeId, SelectionRange, LogicalPosition)> = None;
7114
7115        // Try to get hit test from HoverManager first (fast path, uses WebRender's point_relative_to_item)
7116        if let Some(hit_test) = self.hover_manager.get_current(&InputPointId::Mouse) {
7117            // Iterate through hit nodes from the HoverManager
7118            for (dom_id, hit) in &hit_test.hovered_nodes {
7119                let Some(layout_result) = self.layout_results.get(dom_id) else {
7120                    continue;
7121                };
7122                // Use layout tree from layout_result, not layout_cache
7123                let tree = &layout_result.layout_tree;
7124
7125                // Sort by DOM depth (deepest first) to prefer specific text nodes over containers.
7126                // We count the actual number of parents to determine DOM depth properly.
7127                // Secondary sort by NodeId for deterministic ordering within the same depth.
7128                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
7129                let get_dom_depth = |node_id: &NodeId| -> usize {
7130                    let mut depth = 0;
7131                    let mut current = *node_id;
7132                    while let Some(parent) = node_hierarchy.get(current).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id) {
7133                        depth += 1;
7134                        current = parent;
7135                    }
7136                    depth
7137                };
7138
7139                let mut sorted_hits: Vec<_> = hit.regular_hit_test_nodes.iter().collect();
7140                sorted_hits.sort_by(|(a_id, _), (b_id, _)| {
7141                    let depth_a = get_dom_depth(a_id);
7142                    let depth_b = get_dom_depth(b_id);
7143                    // Higher depth = deeper in DOM = should come first
7144                    // Then sort by NodeId for deterministic order within same depth
7145                    depth_b.cmp(&depth_a).then_with(|| a_id.index().cmp(&b_id.index()))
7146                });
7147
7148                for (node_id, hit_item) in sorted_hits {
7149                    // Check if text is selectable
7150                    if !Self::is_text_selectable(&layout_result.styled_dom, *node_id) {
7151                        continue;
7152                    }
7153
7154                    // Find the layout node for this DOM node
7155                    let layout_node_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(*node_id));
7156                    let Some(layout_node_idx) = layout_node_idx else {
7157                        continue;
7158                    };
7159                    let Some(warm_node) = tree.warm(layout_node_idx) else {
7160                        continue;
7161                    };
7162
7163                    // Get the IFC layout and IFC root NodeId
7164                    // Selection must be stored on the IFC root, not on text nodes
7165                    let (cached_layout, ifc_root_node_id) = if let Some(ref cached) = warm_node.inline_layout_result {
7166                        // This node IS an IFC root - use its own NodeId
7167                        (cached, *node_id)
7168                    } else if let Some(ref membership) = warm_node.ifc_membership {
7169                        // This node participates in an IFC - get layout and NodeId from IFC root
7170                        match tree.warm(membership.ifc_root_layout_index) {
7171                            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)) {
7172                                (Some(cached), Some(root_dom_id)) => (cached, root_dom_id),
7173                                _ => continue,
7174                            },
7175                            None => continue,
7176                        }
7177                    } else {
7178                        // No IFC involvement - not a text node
7179                        continue;
7180                    };
7181
7182                    let layout = &cached_layout.layout;
7183
7184                    // Use point_relative_to_item - this is the local position within the hit node
7185                    // provided by WebRender's hit test
7186                    let local_pos = hit_item.point_relative_to_item;
7187
7188                    // Hit-test the cursor in this text layout
7189                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
7190                        // Store selection with IFC root NodeId, not the hit text node
7191                        found_selection = Some((*dom_id, ifc_root_node_id, SelectionRange {
7192                            start: cursor,
7193                            end: cursor,
7194                        }, local_pos));
7195                        break;
7196                    }
7197                }
7198
7199                if found_selection.is_some() {
7200                    break;
7201                }
7202            }
7203        }
7204
7205        // Fallback: If HoverManager has no hit test (e.g., debug server),
7206        // search through IFC roots using global position
7207        if found_selection.is_none() {
7208            for (dom_id, layout_result) in &self.layout_results {
7209                // Use the layout tree from layout_result, not layout_cache
7210                // layout_cache.tree is for the root DOM only; layout_result.layout_tree
7211                // is the correct tree for each DOM (including virtualized views)
7212                let tree = &layout_result.layout_tree;
7213
7214                // Only iterate IFC roots (nodes with inline_layout_result)
7215                for (node_idx, layout_node) in tree.nodes.iter().enumerate() {
7216                    let Some(warm) = tree.warm(node_idx) else {
7217                        continue;
7218                    };
7219                    let Some(cached_layout) = warm.inline_layout_result.as_ref() else {
7220                        continue; // Skip non-IFC-root nodes
7221                    };
7222
7223                    let Some(node_id) = layout_node.dom_node_id else {
7224                        continue;
7225                    };
7226
7227                    // Check if text is selectable
7228                    if !Self::is_text_selectable(&layout_result.styled_dom, node_id) {
7229                        continue;
7230                    }
7231
7232                    // Get the node's absolute position
7233                    // Use layout_result.calculated_positions for the correct DOM
7234                    let node_pos = layout_result.calculated_positions
7235            .get(node_idx)
7236                        .copied()
7237                        .unwrap_or_default();
7238
7239                    // Check if position is within node bounds
7240                    let node_size = layout_node.used_size.unwrap_or_else(|| {
7241                        let bounds = cached_layout.layout.bounds();
7242                        LogicalSize::new(bounds.width, bounds.height)
7243                    });
7244
7245                    if position.x < node_pos.x || position.x > node_pos.x + node_size.width ||
7246                       position.y < node_pos.y || position.y > node_pos.y + node_size.height {
7247                        continue;
7248                    }
7249
7250                    // Convert global position to node-local coordinates
7251                    let local_pos = LogicalPosition {
7252                        x: position.x - node_pos.x,
7253                        y: position.y - node_pos.y,
7254                    };
7255
7256                    let layout = &cached_layout.layout;
7257
7258                    // Hit-test the cursor in this text layout
7259                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
7260                        found_selection = Some((*dom_id, node_id, SelectionRange {
7261                            start: cursor,
7262                            end: cursor,
7263                        }, local_pos));
7264                        break;
7265                    }
7266                }
7267
7268                if found_selection.is_some() {
7269                    break;
7270                }
7271            }
7272        }
7273
7274        let (dom_id, ifc_root_node_id, initial_range, _local_pos) = found_selection?;
7275
7276        // Create DomNodeId for click state tracking - use IFC root's NodeId
7277        // Selection state is keyed by IFC root because that's where inline_layout_result lives
7278        let node_hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(ifc_root_node_id));
7279        let dom_node_id = DomNodeId {
7280            dom: dom_id,
7281            node: node_hierarchy_id,
7282        };
7283
7284        // Derive click count from the gesture manager's session history
7285        // (timestamps + positions), no mutable click state needed.
7286        let click_count = self.gesture_drag_manager.detect_click_count();
7287
7288        // Get the text layout again for word/paragraph selection
7289        let final_range = if click_count > 1 {
7290            // Use layout_results for the correct DOM's tree
7291            let layout_result = self.layout_results.get(&dom_id)?;
7292            let tree = &layout_result.layout_tree;
7293
7294            // Find layout node - ifc_root_node_id is always the IFC root, so it has inline_layout_result
7295            let layout_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(ifc_root_node_id))?;
7296            let cached_layout = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
7297            let layout = &cached_layout.layout;
7298
7299            match click_count {
7300                2 => select_word_at_cursor(&initial_range.start, layout.as_ref())
7301                    .unwrap_or(initial_range),
7302                3 => select_paragraph_at_cursor(&initial_range.start, layout.as_ref())
7303                    .unwrap_or(initial_range),
7304                _ => initial_range,
7305            }
7306        } else {
7307            initial_range
7308        };
7309
7310        // CRITICAL FIX 1: Set focus on the clicked node
7311        // Without this, clicking on a contenteditable element shows a cursor but
7312        // text input doesn't work because record_text_input() checks focus_manager.get_focused_node()
7313        // and returns early if there's no focus.
7314        //
7315        // Check if the node OR ANY ANCESTOR is contenteditable before setting focus
7316        // The contenteditable attribute is typically on a parent div, not on the IFC root or text node
7317        let is_contenteditable = self.layout_results.get(&dom_id)
7318            .is_some_and(|lr| {
7319                let node_hierarchy = lr.styled_dom.node_hierarchy.as_container();
7320                let node_data = lr.styled_dom.node_data.as_ref();
7321
7322                // Walk up the DOM tree to check if any ancestor has contenteditable
7323                let mut current_node = Some(ifc_root_node_id);
7324                while let Some(node_id) = current_node {
7325                    if let Some(styled_node) = node_data.get(node_id.index()) {
7326                        // Check BOTH: the contenteditable boolean field AND the attribute
7327                        // NodeData has a direct `contenteditable: bool` field that should be
7328                        // checked in addition to the attribute for robustness
7329                        if styled_node.is_contenteditable() {
7330                            return true;
7331                        }
7332
7333                        // Also check the attribute (for backwards compatibility)
7334                        let has_contenteditable_attr = styled_node.attributes().as_ref().iter().any(|attr| {
7335                            matches!(attr, AttributeType::ContentEditable(_))
7336                        });
7337                        if has_contenteditable_attr {
7338                            return true;
7339                        }
7340                    }
7341                    // Move to parent
7342                    current_node = node_hierarchy.get(node_id).and_then(azul_core::styled_dom::NodeHierarchyItem::parent_id);
7343                }
7344                false
7345            });
7346
7347        // NOTE: Do NOT call focus_manager.set_focused_node() here!
7348        // The click-to-focus system in event.rs (process_window_events) handles
7349        // focus via SetFocus which also triggers apply_focus_restyle for :focus CSS.
7350        // Setting focus directly here bypasses that, causing the blue border to not
7351        // appear until the next full layout (e.g., resize).
7352
7353        // Initialize editing at the clicked position via unified API.
7354        let ce_key = self.layout_results.get(&dom_id).map_or(0, |lr| {
7355            azul_core::diff::calculate_contenteditable_key(
7356                lr.styled_dom.node_data.as_ref(),
7357                lr.styled_dom.node_hierarchy.as_ref(),
7358                ifc_root_node_id,
7359            )
7360        });
7361        self.text_edit_manager.initialize_editing(
7362            final_range.start, dom_id, ifc_root_node_id, ce_key,
7363        );
7364        // MWA-C-text_edit: double/triple-click computed the word/paragraph
7365        // range above but then threw it away — initialize_editing only
7366        // places a collapsed caret at range.start, so word/paragraph select
7367        // never actually selected anything. Apply the full range.
7368        if click_count > 1 && final_range.start != final_range.end {
7369            if let Some(mc) = self.text_edit_manager.multi_cursor.as_mut() {
7370                mc.set_single_range(final_range);
7371            }
7372        }
7373        let now = Instant::now();
7374        self.text_edit_manager.blink.reset_blink_on_input(now);
7375        self.text_edit_manager.blink.set_blink_timer_active(true);
7376        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
7377
7378        // Regenerate display list so cursor appears at the clicked position
7379        // (same pattern as handle_cursor_movement and apply_text_changeset)
7380        self.regenerate_display_list_for_dom(dom_id);
7381
7382        // Return the affected node for dirty tracking
7383        Some(vec![dom_node_id])
7384    }
7385
7386    /// Process mouse drag for text selection extension.
7387    ///
7388    /// This method handles drag-to-select by extending the selection from
7389    /// the anchor (mousedown position) to the current focus (drag position).
7390    ///
7391    /// Uses the anchor/focus model:
7392    /// - Anchor is fixed at the initial click position (set by `process_mouse_click_for_selection`)
7393    /// - Focus moves with the mouse during drag
7394    /// - Affected nodes between anchor and focus are computed in DOM order
7395    ///
7396    /// ## Parameters
7397    /// * `start_position` - Initial click position in logical coordinates (unused, anchor is stored)
7398    /// * `current_position` - Current mouse position in logical coordinates
7399    ///
7400    /// ## Returns
7401    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering
7402    pub fn process_mouse_drag_for_selection(
7403        &mut self,
7404        _start_position: LogicalPosition,
7405        current_position: LogicalPosition,
7406    ) -> Option<Vec<DomNodeId>> {
7407        use azul_core::selection::{Selection, SelectionRange};
7408
7409        // Get the anchor cursor and editing node from MultiCursorState.
7410        // The anchor was set by process_mouse_click_for_selection.
7411        // IMPORTANT: For Range selections, the anchor is .start (fixed),
7412        // NOT .end (which moves with each drag event).
7413        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
7414        let anchor = match &mc.get_primary()?.selection {
7415            Selection::Cursor(c) => *c,
7416            Selection::Range(r) => r.start, // anchor stays fixed during drag
7417        };
7418        let dom_id = mc.node_id.dom;
7419        let node_id = mc.node_id.node.into_crate_internal()?;
7420        let dom_node_id = mc.node_id;
7421
7422        // Hit-test the current drag position to get the focus cursor
7423        let layout_result = self.layout_results.get(&dom_id)?;
7424        let tree = &layout_result.layout_tree;
7425        let layout_idx = tree.nodes.iter()
7426            .position(|n| n.dom_node_id == Some(node_id))?;
7427        let node_pos = layout_result.calculated_positions
7428            .get(layout_idx)
7429            .copied()
7430            .unwrap_or_default();
7431        let cached = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
7432
7433        let local_pos = LogicalPosition {
7434            x: current_position.x - node_pos.x,
7435            y: current_position.y - node_pos.y,
7436        };
7437        let focus = cached.layout.hittest_cursor(local_pos)?;
7438
7439        // Update primary selection: Cursor → Range(anchor, focus)
7440        let mc = self.text_edit_manager.multi_cursor.as_mut()?;
7441        if let Some(primary) = mc.get_primary_mut() {
7442            if anchor == focus {
7443                primary.selection = Selection::Cursor(anchor);
7444            } else {
7445                primary.selection = Selection::Range(SelectionRange {
7446                    start: anchor,
7447                    end: focus,
7448                });
7449            }
7450        }
7451
7452        self.text_edit_manager.mark_dirty();
7453        self.regenerate_display_list_for_dom(dom_id);
7454        Some(vec![dom_node_id])
7455    }
7456
7457    /// Delete the currently selected text or one character at the cursor
7458    ///
7459    /// Handles Backspace/Delete key. If a range selection exists, the selected
7460    /// text is deleted. If only a cursor exists (no range), one character is
7461    /// deleted before (Backspace) or after (Delete) the cursor.
7462    ///
7463    /// ## Arguments
7464    /// * `target` - The target node (focused contenteditable element)
7465    /// * `forward` - true for Delete key (forward), false for Backspace (backward)
7466    ///
7467    /// ## Returns
7468    /// * `Some(Vec<DomNodeId>)` - Affected nodes if deletion occurred
7469    /// * `None` - If no cursor/selection exists or deletion failed
7470    pub fn delete_selection(
7471        &mut self,
7472        target: DomNodeId,
7473        forward: bool,
7474    ) -> Option<Vec<DomNodeId>> {
7475        let dom_id = target.dom;
7476        let node_id = target.node.into_crate_internal()?;
7477
7478        // Multi-cursor path: use edit_text with DeleteBackward/DeleteForward
7479        let current_selections = if let Some(ref mc) = self.text_edit_manager.multi_cursor {
7480            mc.to_selections()
7481        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
7482            vec![Selection::Cursor(cursor)]
7483        } else {
7484            return None;
7485        };
7486
7487        let content = self.get_text_before_textinput(dom_id, node_id);
7488        let edit = if forward {
7489            crate::text3::edit::TextEdit::DeleteForward
7490        } else {
7491            crate::text3::edit::TextEdit::DeleteBackward
7492        };
7493        let (new_content, new_selections) = crate::text3::edit::edit_text(
7494            &content, &current_selections, &edit,
7495        );
7496
7497        // MWA-C-undo_redo: deletions (Backspace / Delete / Cut all route
7498        // here) were never recorded — only insertions were undoable. Record
7499        // a DeleteText operation with styled pre/post snapshots; the actual
7500        // undo/redo restore uses the snapshots (keyed by changeset id),
7501        // deleted_text/range are informational for the C-API inspect fns.
7502        // Ids count DOWN from usize::MAX so they cannot collide with the
7503        // insertion counter in apply_text_changeset (counts up from 0).
7504        {
7505            use crate::managers::changeset::{TextChangeset, TextOpDeleteText, TextOperation};
7506            use crate::managers::undo_redo::NodeStateSnapshot;
7507            static DELETE_CHANGESET_COUNTER: AtomicUsize = AtomicUsize::new(0);
7508
7509            let pre_text = self.extract_text_from_inline_content(&content);
7510            let old_cursor = current_selections.first().and_then(|sel| match sel {
7511                Selection::Cursor(c) => Some(*c),
7512                Selection::Range(_) => None,
7513            });
7514            let old_range = current_selections.first().and_then(|sel| match sel {
7515                Selection::Range(r) => Some(*r),
7516                Selection::Cursor(_) => None,
7517            });
7518            let record_range = old_range.unwrap_or_else(|| {
7519                let anchor = old_cursor.unwrap_or(TextCursor {
7520                    cluster_id: GraphemeClusterId {
7521                        source_run: 0,
7522                        start_byte_in_run: 0,
7523                    },
7524                    affinity: CursorAffinity::Leading,
7525                });
7526                SelectionRange {
7527                    start: anchor,
7528                    end: anchor,
7529                }
7530            });
7531            let changeset_id =
7532                usize::MAX - DELETE_CHANGESET_COUNTER.fetch_add(1, Ordering::SeqCst);
7533            let timestamp = {
7534                #[cfg(feature = "std")]
7535                {
7536                    Instant::System(std::time::Instant::now().into())
7537                }
7538                #[cfg(not(feature = "std"))]
7539                {
7540                    azul_core::task::Instant::Tick(azul_core::task::SystemTick {
7541                        tick_counter: 0,
7542                    })
7543                }
7544            };
7545            let pre_state = NodeStateSnapshot {
7546                node_id,
7547                text_content: pre_text.into(),
7548                cursor_position: old_cursor.into(),
7549                selection_range: old_range.into(),
7550                timestamp: timestamp.clone(),
7551            };
7552            let changeset = TextChangeset {
7553                id: changeset_id,
7554                target,
7555                operation: TextOperation::DeleteText(TextOpDeleteText {
7556                    range: record_range,
7557                    deleted_text: "".into(),
7558                    new_cursor: CursorPosition::Uninitialized,
7559                }),
7560                timestamp,
7561            };
7562            self.undo_redo_manager.store_content_snapshot(
7563                changeset_id,
7564                content,
7565                new_content.clone(),
7566            );
7567            self.undo_redo_manager.record_operation(changeset, pre_state);
7568        }
7569
7570        // Update multi-cursor state
7571        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
7572            mc.update_from_edit_result(&new_selections);
7573        }
7574        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
7575
7576        self.update_text_cache_after_edit(dom_id, node_id, new_content);
7577        self.regenerate_display_list_for_dom(dom_id);
7578
7579        Some(vec![target])
7580    }
7581
7582    /// Extract clipboard content from the current selection
7583    ///
7584    /// This method extracts both plain text and styled text from the selection ranges.
7585    /// It iterates through all selected text, extracts the actual characters, and
7586    /// preserves styling information from the `ShapedGlyph`'s `StyleProperties`.
7587    ///
7588    /// This is NOT reading from the system clipboard - use `clipboard_manager.get_paste_content()`
7589    /// for that. This extracts content FROM the selection TO be copied.
7590    ///
7591    /// ## Arguments
7592    /// * `dom_id` - The DOM to extract selection from
7593    ///
7594    /// ## Returns
7595    /// * `Some(ClipboardContent)` - If there is a selection with text
7596    /// * `None` - If no selection or no text layouts found
7597    pub fn get_selected_content_for_clipboard(
7598        &self,
7599        dom_id: &DomId,
7600    ) -> Option<crate::managers::selection::ClipboardContent> {
7601        use crate::managers::selection::ClipboardContent;
7602        use crate::text3::edit::cursor_byte_offset_in_run;
7603
7604        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
7605        let node_id = mc.node_id.node.into_crate_internal()?;
7606
7607        // Collect range selections (collapsed cursors contribute nothing to a copy).
7608        let ranges: Vec<_> = mc.selections.iter().filter_map(|s| match &s.selection {
7609            Selection::Range(r) => Some(*r),
7610            Selection::Cursor(_) => None,
7611        }).collect();
7612        if ranges.is_empty() {
7613            return None;
7614        }
7615
7616        // Most editables are a single text run (the whole string, newlines and
7617        // all), so source_run is 0 and the single-run branch handles everything.
7618        // The multi-run branch is a best-effort for rich (multi-span) content.
7619        // Byte offsets are affinity-aware (cursor_byte_offset_in_run), so a
7620        // select-all whose end cursor is Trailing on the last cluster copies the
7621        // full text — matching the affinity fix in delete_range.
7622        let content = self.get_text_before_textinput(*dom_id, node_id);
7623        let mut plain = String::new();
7624        for r in &ranges {
7625            let sr = r.start.cluster_id.source_run as usize;
7626            let er = r.end.cluster_id.source_run as usize;
7627            if sr == er {
7628                if let Some(InlineContent::Text(run)) = content.get(sr) {
7629                    let a = cursor_byte_offset_in_run(&run.text, &r.start);
7630                    let b = cursor_byte_offset_in_run(&run.text, &r.end);
7631                    let (lo, hi) = (a.min(b), a.max(b));
7632                    if hi <= run.text.len() && lo < hi {
7633                        plain.push_str(&run.text[lo..hi]);
7634                    }
7635                }
7636            } else {
7637                // Multi-run: walk runs in document order, taking the tail of the
7638                // first run, all middle runs, and the head of the last.
7639                let (first_idx, first_cur, last_idx, last_cur) = if sr <= er {
7640                    (sr, r.start, er, r.end)
7641                } else {
7642                    (er, r.end, sr, r.start)
7643                };
7644                for ri in first_idx..=last_idx {
7645                    if let Some(InlineContent::Text(run)) = content.get(ri) {
7646                        if ri == first_idx {
7647                            let off = cursor_byte_offset_in_run(&run.text, &first_cur).min(run.text.len());
7648                            plain.push_str(&run.text[off..]);
7649                        } else if ri == last_idx {
7650                            let off = cursor_byte_offset_in_run(&run.text, &last_cur).min(run.text.len());
7651                            plain.push_str(&run.text[..off]);
7652                        } else {
7653                            plain.push_str(&run.text);
7654                        }
7655                    }
7656                }
7657            }
7658        }
7659
7660        if plain.is_empty() {
7661            return None;
7662        }
7663        Some(ClipboardContent {
7664            plain_text: plain.into(),
7665            // TODO(superplan): styled_runs left empty — extracting per-run style
7666            // (font/size/color/bold/italic from the styled DOM) is only useful once
7667            // the platform clipboard backends gain an HTML/RTF format and ClipboardContent::to_html
7668            // is wired into the copy path (see layout/src/managers/selection.rs docs).
7669            // Plain-text copy is fully wired.
7670            styled_runs: Vec::new().into(),
7671        })
7672    }
7673
7674    /// Process image callback updates from callback changes
7675    ///
7676    /// This function re-invokes image callbacks for nodes that requested updates
7677    /// (typically from timer callbacks or resize events). It returns the updated
7678    /// textures along with their metadata for the rendering pipeline to process.
7679    ///
7680    /// # Arguments
7681    ///
7682    /// * `image_callbacks_changed` - Map of `DomId` -> Set of `NodeIds` that need re-rendering
7683    /// * `gl_context` - OpenGL context pointer for rendering
7684    ///
7685    /// # Returns
7686    ///
7687    /// Vector of (`DomId`, `NodeId`, Texture) tuples for textures that were updated
7688    #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
7689    #[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
7690    pub fn process_image_callback_updates(
7691        &mut self,
7692        image_callbacks_changed: &BTreeMap<DomId, FastBTreeSet<NodeId>>,
7693        gl_context: &OptionGlContextPtr,
7694    ) -> Vec<(DomId, NodeId, azul_core::gl::Texture)> {
7695        use crate::callbacks::{RenderImageCallback, RenderImageCallbackInfo};
7696        use std::panic;
7697
7698        let mut updated_textures = Vec::new();
7699
7700        for (dom_id, node_ids) in image_callbacks_changed {
7701            let Some(layout_result) = self.layout_results.get_mut(dom_id) else {
7702                continue;
7703            };
7704
7705            for node_id in node_ids {
7706                // Get the node data - store container ref to extend lifetime
7707                let node_data_container = layout_result.styled_dom.node_data.as_container();
7708                let Some(node_data) = node_data_container.get(*node_id) else {
7709                    continue;
7710                };
7711
7712                // Check if this is an Image node with a callback
7713                let has_callback = matches!(node_data.get_node_type(), NodeType::Image(img_ref)
7714                    if img_ref.get_image_callback().is_some());
7715
7716                if !has_callback {
7717                    continue;
7718                }
7719
7720                // Get layout indices for this DOM node (can have multiple due to text splitting,
7721                // etc.)
7722                let layout_indices = match layout_result.layout_tree.dom_to_layout.get(node_id) {
7723                    Some(indices) if !indices.is_empty() => indices,
7724                    _ => continue,
7725                };
7726
7727                // Use the first layout index (primary node)
7728                let layout_index = layout_indices[0];
7729
7730                // Get the position from calculated_positions
7731                let position = match layout_result.calculated_positions.get(layout_index) {
7732                    Some(pos) => *pos,
7733                    None => continue,
7734                };
7735
7736                // Get the layout node to determine size
7737                let Some(layout_node) = layout_result.layout_tree.get(layout_index) else {
7738                    continue;
7739                };
7740
7741                // Get the size from the layout node (used_size is the computed size from layout)
7742                let (width, height) = match layout_node.used_size {
7743                    Some(size) => (size.width, size.height),
7744                    None => continue, // Node hasn't been laid out yet
7745                };
7746
7747                let callback_domnode_id = DomNodeId {
7748                    dom: *dom_id,
7749                    node: NodeHierarchyItemId::from_crate_internal(Some(
7750                        *node_id,
7751                    )),
7752                };
7753
7754                let bounds = HidpiAdjustedBounds::from_bounds(
7755                    azul_css::props::basic::LayoutSize {
7756                        width: width as isize,
7757                        height: height as isize,
7758                    },
7759                    self.current_window_state.size.get_hidpi_factor(),
7760                );
7761
7762                // Create callback info
7763                let mut gl_callback_info = RenderImageCallbackInfo::new(
7764                    callback_domnode_id,
7765                    bounds,
7766                    gl_context,
7767                    &self.image_cache,
7768                    &self.font_manager.fc_cache,
7769                );
7770
7771                // Invoke the callback
7772                let new_image_ref = {
7773                    let mut node_data_mut = layout_result.styled_dom.node_data.as_container_mut();
7774                    match node_data_mut.get_mut(*node_id) {
7775                        Some(nd) => {
7776                            match &mut nd.node_type {
7777                                NodeType::Image(ref mut img_ref) => {
7778                                    // Try get_image_callback_mut first (requires exclusive access)
7779                                    let callback_result = img_ref.as_mut().get_image_callback_mut();
7780                                    
7781                                    if callback_result.is_none() {
7782                                        // The ImageRef has multiple copies (Arc refcount > 1),
7783                                        // so get_image_callback_mut returns None. Fall back to
7784                                        // read-only access + clone to invoke the callback.
7785                                        match img_ref.get_data() {
7786                                            azul_core::resources::DecodedImage::Callback(core_callback) => {
7787                                                if core_callback.callback.cb == 0 {
7788                                                    None
7789                                                } else {
7790                                                    let callback = RenderImageCallback::from_core(&core_callback.callback);
7791                                                    let refany_clone = core_callback.refany.clone();
7792                                                    let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
7793                                                        (callback.cb)(refany_clone, gl_callback_info)
7794                                                    }));
7795                                                    result.ok()
7796                                                }
7797                                            }
7798                                            _ => None,
7799                                        }
7800                                    } else {
7801                                        callback_result.map(|core_callback| {
7802                                            // Convert from CoreImageCallback (cb: usize) to
7803                                            // RenderImageCallback (cb: fn pointer)
7804                                            let callback =
7805                                                RenderImageCallback::from_core(&core_callback.callback);
7806                                            (callback.cb)(
7807                                                core_callback.refany.clone(),
7808                                                gl_callback_info,
7809                                            )
7810                                        })
7811                                    }
7812                                }
7813                                _ => None,
7814                            }
7815                        }
7816                        None => None,
7817                    }
7818                };
7819
7820                // Reset GL state after callback
7821                #[cfg(feature = "gl_context_loader")]
7822                if let Some(gl) = gl_context.as_ref() {
7823                    use gl_context_loader::gl;
7824                    gl.bind_framebuffer(gl::FRAMEBUFFER, 0);
7825                    gl.disable(gl::FRAMEBUFFER_SRGB);
7826                    gl.disable(gl::MULTISAMPLE);
7827                }
7828
7829                // Extract the texture from the returned ImageRef
7830                if let Some(image_ref) = new_image_ref {
7831                    if let Some(azul_core::resources::DecodedImage::Gl(texture)) = image_ref.into_inner() {
7832                        updated_textures.push((*dom_id, *node_id, texture));
7833                    }
7834                }
7835            }
7836        }
7837
7838        updated_textures
7839    }
7840
7841    /// Check if a scrolled node is a `VirtualView` that needs re-invocation. If so,
7842    /// queue it in `pending_virtual_view_updates` for processing before the next frame.
7843    ///
7844    /// This is the bridge between the scroll system and the `VirtualView` lifecycle:
7845    ///   `ScrollTo` → `scroll_manager.scroll_to()` → `check_and_queue_virtual_view_reinvoke()`
7846    ///
7847    /// Returns `true` if a `VirtualView` update was queued (caller should trigger a
7848    /// display list rebuild instead of a lightweight repaint).
7849    pub fn check_and_queue_virtual_view_reinvoke(
7850        &mut self,
7851        dom_id: DomId,
7852        node_id: NodeId,
7853    ) -> bool {
7854        // Get the VirtualView's current layout bounds (needed for check_reinvoke)
7855        let Some(bounds) = Self::get_virtual_view_bounds_from_layout(
7856            &self.layout_results,
7857            dom_id,
7858            node_id,
7859        ) else {
7860            return false; // Not a VirtualView or no layout info
7861        };
7862
7863        // Ask the VirtualViewManager whether this VirtualView needs re-invocation
7864        let reason = self.virtual_view_manager.check_reinvoke(
7865            dom_id, node_id, &self.scroll_manager, bounds,
7866        );
7867
7868        if let Some(reason) = reason {
7869            // Queue the VirtualView for re-invocation in the next render
7870            // pass, KEEPING the queue-time reason (MWA-C-virtual_view).
7871            self.pending_virtual_view_updates
7872                .entry(dom_id)
7873                .or_default()
7874                .insert(node_id, reason);
7875            true
7876        } else {
7877            false
7878        }
7879    }
7880
7881    /// Process `VirtualView` updates requested by callbacks
7882    ///
7883    /// This method handles manual `VirtualView` re-rendering triggered by `trigger_virtual_view_rerender()`.
7884    /// It invokes the `VirtualView` callback with `DomRecreated` reason and performs layout on the
7885    /// returned DOM, then submits a new display list to `WebRender` for that pipeline.
7886    ///
7887    /// # Arguments
7888    ///
7889    /// * `vviews_to_update` - Map of `DomId` -> Set of `NodeIds` that need re-rendering
7890    /// * `window_state` - Current window state
7891    /// * `renderer_resources` - Renderer resources
7892    /// * `system_callbacks` - External system callbacks
7893    ///
7894    /// # Returns
7895    ///
7896    /// Vector of (`DomId`, `NodeId`) tuples for `VirtualViews` that were successfully updated
7897    pub fn process_virtual_view_updates(
7898        &mut self,
7899        vviews_to_update: &BTreeMap<DomId, BTreeMap<NodeId, VirtualViewCallbackReason>>,
7900        window_state: &FullWindowState,
7901        renderer_resources: &RendererResources,
7902        system_callbacks: &ExternalSystemCallbacks,
7903    ) -> Vec<(DomId, NodeId)> {
7904        let mut updated_vviews = Vec::new();
7905
7906        for (dom_id, node_ids) in vviews_to_update {
7907            for (node_id, reason) in node_ids {
7908                // Extract virtualized view bounds from layout result
7909                let Some(bounds) = Self::get_virtual_view_bounds_from_layout(
7910                    &self.layout_results,
7911                    *dom_id,
7912                    *node_id,
7913                ) else {
7914                    continue;
7915                };
7916
7917                // MWA-C-virtual_view: stage the queue-time reason so the
7918                // invoke delivers it to the user callback — the old
7919                // force_reinvoke (clear was_invoked) collapsed everything to
7920                // InitialRender at delivery.
7921                self.virtual_view_manager
7922                    .set_reason_override(*dom_id, *node_id, *reason);
7923
7924                // Invoke the VirtualView callback
7925                if let Some(_child_dom_id) = self.invoke_virtual_view_callback(
7926                    *dom_id,
7927                    *node_id,
7928                    bounds,
7929                    window_state,
7930                    renderer_resources,
7931                    system_callbacks,
7932                    &mut None,
7933                ) {
7934                    updated_vviews.push((*dom_id, *node_id));
7935                }
7936            }
7937        }
7938
7939        updated_vviews
7940    }
7941
7942    /// Queue `VirtualView` updates to be processed in the next frame
7943    ///
7944    /// This is called after callbacks to store the `vviews_to_update` from callback changes
7945    pub fn queue_virtual_view_updates(
7946        &mut self,
7947        vviews_to_update: BTreeMap<DomId, FastBTreeSet<NodeId>>,
7948    ) {
7949        // MWA-C-virtual_view: programmatic re-renders
7950        // (trigger_virtual_view_rerender / trigger_all_virtual_view_rerender,
7951        // e.g. map-tile writebacks) deliver DomRecreated — the reason the
7952        // docs always claimed but which previously had ZERO producers. A
7953        // scroll-queued reason for the same node is not overwritten (it is
7954        // more specific).
7955        for (dom_id, node_ids) in vviews_to_update {
7956            let entry = self.pending_virtual_view_updates.entry(dom_id).or_default();
7957            for node_id in node_ids {
7958                entry
7959                    .entry(node_id)
7960                    .or_insert(VirtualViewCallbackReason::DomRecreated);
7961            }
7962        }
7963    }
7964
7965    /// Queue EVERY known `VirtualView` for re-invocation on the EXISTING DOM (no
7966    /// `RefreshDom` / DOM rebuild). Used when a shared dataset was mutated
7967    /// out-of-band — e.g. a background `MapWidget` tile-fetch writeback updated
7968    /// the cache that the `VirtualView`'s `refany` clone points at. Re-invoking in
7969    /// place keeps the content callback reading the same underlying data the
7970    /// worker threads write to; a `RefreshDom` would rebuild the DOM, allocate a
7971    /// fresh dataset, and orphan the workers' clone (so later tiles would never
7972    /// reach the rendered view).
7973    pub fn queue_all_virtual_view_reinvoke(&mut self) {
7974        let mut updates: BTreeMap<DomId, FastBTreeSet<NodeId>> = BTreeMap::new();
7975        for (dom_id, node_id) in self.virtual_view_manager.all_view_keys() {
7976            updates
7977                .entry(dom_id)
7978                .or_default()
7979                .insert(node_id);
7980        }
7981        self.queue_virtual_view_updates(updates);
7982    }
7983
7984    /// Process and clear pending `VirtualView` updates
7985    ///
7986    /// This is called during frame generation to re-render updated `VirtualViews`
7987    pub fn process_pending_virtual_view_updates(
7988        &mut self,
7989        window_state: &FullWindowState,
7990        renderer_resources: &RendererResources,
7991        system_callbacks: &ExternalSystemCallbacks,
7992    ) -> Vec<(DomId, NodeId)> {
7993        if self.pending_virtual_view_updates.is_empty() {
7994            return Vec::new();
7995        }
7996
7997        // Take ownership of pending updates
7998        let vviews_to_update = core::mem::take(&mut self.pending_virtual_view_updates);
7999
8000        // Process them
8001        let updated = self.process_virtual_view_updates(
8002            &vviews_to_update,
8003            window_state,
8004            renderer_resources,
8005            system_callbacks,
8006        );
8007
8008        // An in-place rebuild gives each child DOM FRESH NodeIds with no
8009        // reconcile mapping. Any hover/hit state recorded against the old
8010        // generation is now dangling — resolving it against the new styled DOM
8011        // reads out of bounds (hit_test.rs cursor panic while panning the map)
8012        // or targets the wrong node. Purge the rebuilt children's hits; the
8013        // next pointer move re-populates them from a fresh hit test.
8014        for (parent_dom, node_id) in &updated {
8015            if let Some(child_dom) = self
8016                .virtual_view_manager
8017                .get_nested_dom_id(*parent_dom, *node_id)
8018            {
8019                self.hover_manager.purge_dom(&child_dom);
8020            }
8021        }
8022
8023        updated
8024    }
8025
8026    /// Helper: Extract `VirtualView` bounds from layout results
8027    ///
8028    /// Returns None if the node is not a `VirtualView` or doesn't have layout info
8029    fn get_virtual_view_bounds_from_layout(
8030        layout_results: &BTreeMap<DomId, DomLayoutResult>,
8031        dom_id: DomId,
8032        node_id: NodeId,
8033    ) -> Option<LogicalRect> {
8034        let layout_result = layout_results.get(&dom_id)?;
8035
8036        // Check if this is a VirtualView node
8037        let node_data_container = layout_result.styled_dom.node_data.as_container();
8038        let node_data = node_data_container.get(node_id)?;
8039
8040        if !matches!(node_data.get_node_type(), NodeType::VirtualView) {
8041            return None;
8042        }
8043
8044        // Get layout indices
8045        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
8046        if layout_indices.is_empty() {
8047            return None;
8048        }
8049
8050        let layout_index = layout_indices[0];
8051
8052        // Get position
8053        let position = *layout_result.calculated_positions.get(layout_index)?;
8054
8055        // Get size
8056        let layout_node = layout_result.layout_tree.get(layout_index)?;
8057        let size = layout_node.used_size?;
8058
8059        Some(LogicalRect::new(
8060            position,
8061            LogicalSize::new(size.width, size.height),
8062        ))
8063    }
8064}
8065
8066#[cfg(feature = "a11y")]
8067#[derive(Debug, Clone)]
8068pub enum TextEditType {
8069    ReplaceSelection(String),
8070    SetValue(String),
8071    SetNumericValue(f64),
8072}
8073
8074// ============================================================================
8075// NodeId remapping after DOM reconciliation — the single driver
8076// ============================================================================
8077
8078impl LayoutWindow {
8079    /// Rewrite every `NodeId`-keyed piece of window state onto the rebuilt DOM
8080    /// and garbage-collect the state of unmounted nodes.
8081    ///
8082    /// This is THE place a DOM rebuild is folded into the managers. It is called
8083    /// once, from `regenerate_layout`, with the `NodeIdMap` built from
8084    /// `diff::reconcile_dom`'s `node_moves`.
8085    ///
8086    /// # Why this function destructures `Self` exhaustively
8087    ///
8088    /// A `NodeId` is an arena index. Deleting a node renumbers its following
8089    /// siblings, so a manager that is not remapped does not dangle — it points at
8090    /// a **live but wrong** node, and misbehaves silently. The failure has no
8091    /// panic and no error to grep for, so the only durable defence is to make it
8092    /// impossible to forget: the `let Self { .. }` below lists EVERY field with no
8093    /// `..` rest-pattern, so **adding a field to `LayoutWindow` fails to compile
8094    /// until it is classified here** as either node-keyed (remap it) or exempt
8095    /// (with a reason).
8096    ///
8097    /// New node-keyed managers should implement [`crate::managers::NodeIdRemap`]
8098    /// and be driven from here.
8099    #[allow(clippy::too_many_lines)]
8100    pub fn remap_node_ids(&mut self, dom: DomId, map: &crate::managers::NodeIdMap) {
8101        use crate::managers::NodeIdRemap;
8102
8103        let Self {
8104            // --- NODE-KEYED: managers implementing `NodeIdRemap` -------------
8105            scroll_manager,
8106            gesture_drag_manager,
8107            focus_manager,
8108            text_edit_manager,
8109            hover_manager,
8110            virtual_view_manager,
8111            gpu_state_manager,
8112            text_input_manager,
8113            undo_redo_manager,
8114            permission_manager,
8115
8116            // --- NODE-KEYED: plain caches owned directly by the window -------
8117            text_constraints_cache,
8118            dirty_text_nodes,
8119            pending_virtual_view_updates,
8120            gl_texture_cache,
8121            currently_dragging_thumb,
8122
8123            // --- EXEMPT: not keyed by NodeId ---------------------------------
8124            // Rebuilt wholesale by the very layout pass that triggered this remap:
8125            // Exempt: damage rects + frame counters only, keyed by nothing.
8126            frame_report: _,
8127            layout_cache: _,
8128            layout_results: _,
8129            // Content-addressed (hashes / font ids / image ids), never NodeIds:
8130            text_cache: _,
8131            font_manager: _,
8132            image_cache: _,
8133            cpu_image_callback_results: _,
8134            renderer_resources: _,
8135            // Derived per frame from the CURRENT StyledDom (a11y tree is rebuilt
8136            // from scratch in `A11yManager::build_tree_update`), so it cannot go stale:
8137            a11y_manager: _,
8138            // Capability/device-keyed, not node-keyed (their only DomNodeId is an
8139            // event target that defaults to the root):
8140            geolocation_manager: _,
8141            biometric_manager: _,
8142            keyring_manager: _,
8143            sensor_manager: _,
8144            gamepad_manager: _,
8145            // Payload-only state (file paths / clipboard contents), no NodeIds:
8146            file_drop_manager: _,
8147            clipboard_manager: _,
8148            // Plain window/render state, no NodeIds:
8149            skip_gpu_sync: _,
8150            #[cfg(feature = "pdf")]
8151            fragmentation_context: _,
8152            safe_area_insets: _,
8153            timers: _,
8154            threads: _,
8155            renderer_type: _,
8156            previous_window_state: _,
8157            current_window_state: _,
8158            document_id: _,
8159            id_namespace: _,
8160            epoch: _,
8161            system_style: _,
8162            monitors: _,
8163            font_stacks_hash: _,
8164            pre_preedit_content: _,
8165            input_interpreter: _,
8166            post_filter: _,
8167            routes: _,
8168            #[cfg(feature = "icu")]
8169            icu_localizer: _,
8170            // Lifecycle events carry NodeIds, but they are produced BY this very
8171            // reconciliation and are already expressed in NEW ids (Mount/Update/
8172            // Resize), or deliberately in OLD ids resolved before the swap
8173            // (BeforeUnmount, see `pending_unmount_invocations`). Remapping them
8174            // here would corrupt them.
8175            pending_lifecycle_events: _,
8176            pending_unmount_invocations: _,
8177        } = self;
8178
8179        scroll_manager.remap_node_ids(dom, map);
8180        gesture_drag_manager.remap_node_ids(dom, map);
8181        focus_manager.remap_node_ids(dom, map);
8182        text_edit_manager.remap_node_ids(dom, map);
8183        hover_manager.remap_node_ids(dom, map);
8184        virtual_view_manager.remap_node_ids(dom, map);
8185        gpu_state_manager.remap_node_ids(dom, map);
8186        text_input_manager.remap_node_ids(dom, map);
8187        undo_redo_manager.remap_node_ids(dom, map);
8188        permission_manager.remap_node_ids(dom, map);
8189
8190        // Window-owned caches (same contract: absent from `map` == unmounted).
8191        crate::managers::remap_dom_keys(&mut text_constraints_cache.constraints, dom, map);
8192        crate::managers::remap_dom_keys(dirty_text_nodes, dom, map);
8193
8194        if let Some(pending) = pending_virtual_view_updates.remove(&dom) {
8195            let remapped: BTreeMap<NodeId, _> = pending
8196                .into_iter()
8197                .filter_map(|(node_id, reason)| Some((map.resolve(node_id)?, reason)))
8198                .collect();
8199            if !remapped.is_empty() {
8200                pending_virtual_view_updates.insert(dom, remapped);
8201            }
8202        }
8203
8204        if let Some(textures) = gl_texture_cache.solved_textures.remove(&dom) {
8205            let remapped: BTreeMap<NodeId, _> = textures
8206                .into_iter()
8207                .filter_map(|(node_id, tex)| Some((map.resolve(node_id)?, tex)))
8208                .collect();
8209            gl_texture_cache.solved_textures.insert(dom, remapped);
8210        }
8211        let hashes = core::mem::take(&mut gl_texture_cache.hashes);
8212        gl_texture_cache.hashes = hashes
8213            .into_iter()
8214            .filter_map(|((d, node_id, image_hash), v)| {
8215                if d != dom {
8216                    return Some(((d, node_id, image_hash), v));
8217                }
8218                Some(((d, map.resolve(node_id)?, image_hash), v))
8219            })
8220            .collect();
8221
8222        // An in-flight scrollbar-thumb drag holds the NodeId of its scroll
8223        // container; if that node is gone the drag must end, not retarget.
8224        if let Some(drag) = currently_dragging_thumb.as_ref() {
8225            match remap_scrollbar_hit_id(drag.hit_id, dom, map) {
8226                Some(new_id) => {
8227                    if let Some(d) = currently_dragging_thumb.as_mut() {
8228                        d.hit_id = new_id;
8229                    }
8230                }
8231                None => *currently_dragging_thumb = None,
8232            }
8233        }
8234    }
8235}
8236
8237/// Remap the `NodeId` inside a `ScrollbarHitId`. `None` = the scroll container
8238/// was unmounted (drop the state); ids from other DOMs pass through.
8239fn remap_scrollbar_hit_id(
8240    id: ScrollbarHitId,
8241    dom: DomId,
8242    map: &crate::managers::NodeIdMap,
8243) -> Option<ScrollbarHitId> {
8244    Some(match id {
8245        ScrollbarHitId::VerticalTrack(d, n) if d == dom => {
8246            ScrollbarHitId::VerticalTrack(d, map.resolve(n)?)
8247        }
8248        ScrollbarHitId::VerticalThumb(d, n) if d == dom => {
8249            ScrollbarHitId::VerticalThumb(d, map.resolve(n)?)
8250        }
8251        ScrollbarHitId::HorizontalTrack(d, n) if d == dom => {
8252            ScrollbarHitId::HorizontalTrack(d, map.resolve(n)?)
8253        }
8254        ScrollbarHitId::HorizontalThumb(d, n) if d == dom => {
8255            ScrollbarHitId::HorizontalThumb(d, map.resolve(n)?)
8256        }
8257        other => other,
8258    })
8259}