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