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

            
21
use std::{
22
    collections::{BTreeMap, BTreeSet, HashMap},
23
    sync::{
24
        atomic::{AtomicUsize, Ordering},
25
        Arc,
26
    },
27
};
28

            
29
use azul_core::{
30
    animation::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
};
60
use azul_css::{
61
    css::Css,
62
    props::{
63
        basic::FontRef,
64
        property::{CssProperty, CssPropertyVec},
65
    },
66
    AzString, LayoutDebugMessage, OptionString,
67
};
68
use rust_fontconfig::FcFontCache;
69

            
70
#[cfg(feature = "icu")]
71
use crate::icu::IcuLocalizerHandle;
72
use crate::{
73
    callbacks::{
74
        Callback, ExternalSystemCallbacks, MenuCallback,
75
    },
76
    managers::{
77
        gpu_state::GpuStateManager,
78
        virtual_view::VirtualViewManager,
79
        scroll_state::{ScrollManager, ScrollStates},
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, TextBoundary, 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
99
static DOCUMENT_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
100
static ID_NAMESPACE_COUNTER: AtomicUsize = AtomicUsize::new(0);
101

            
102
/// Helper function to create a unique DocumentId
103
3487
fn new_document_id() -> DocumentId {
104
3487
    let namespace_id = new_id_namespace();
105
3487
    let id = DOCUMENT_ID_COUNTER.fetch_add(1, Ordering::Relaxed) as u32;
106
3487
    DocumentId { namespace_id, id }
107
3487
}
108

            
109
/// Direction for cursor navigation
110
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
111
pub enum CursorNavigationDirection {
112
    /// Move cursor up one line
113
    Up,
114
    /// Move cursor down one line
115
    Down,
116
    /// Move cursor left one character
117
    Left,
118
    /// Move cursor right one character
119
    Right,
120
    /// Move cursor to start of current line
121
    LineStart,
122
    /// Move cursor to end of current line
123
    LineEnd,
124
    /// Move cursor to start of document
125
    DocumentStart,
126
    /// Move cursor to end of document
127
    DocumentEnd,
128
}
129

            
130
/// Result of a cursor movement operation
131
#[derive(Debug, Clone)]
132
pub enum CursorMovementResult {
133
    /// Cursor moved within the same text node
134
    MovedWithinNode(TextCursor),
135
    /// Cursor moved to a different text node
136
    MovedToNode {
137
        dom_id: DomId,
138
        node_id: NodeId,
139
        cursor: TextCursor,
140
    },
141
    /// Cursor is at a boundary and cannot move further
142
    AtBoundary {
143
        boundary: TextBoundary,
144
        cursor: TextCursor,
145
    },
146
}
147

            
148
/// Error when no cursor destination is available
149
#[derive(Debug, Clone)]
150
pub struct NoCursorDestination {
151
    pub reason: String,
152
}
153

            
154
/// Action to take for the cursor blink timer when focus changes
155
///
156
/// This enum is returned by `LayoutWindow::handle_focus_change_for_cursor_blink()`
157
/// to tell the platform layer what timer action to take.
158
#[derive(Debug, Clone)]
159
pub enum CursorBlinkTimerAction {
160
    /// Start the cursor blink timer with the given timer configuration
161
    Start(crate::timer::Timer),
162
    /// Stop the cursor blink timer
163
    Stop,
164
    /// No change needed (timer already in correct state)
165
    NoChange,
166
}
167

            
168
/// Action for the tooltip-delay timer, returned by
169
/// `LayoutWindow::handle_hover_change_for_tooltip()`. Platform layer translates
170
/// these to `start_timer` / `stop_timer` calls on `TOOLTIP_DELAY_TIMER_ID`.
171
#[derive(Debug, Clone)]
172
pub enum TooltipTimerAction {
173
    /// Start the tooltip-delay timer with the given configuration
174
    Start(crate::timer::Timer),
175
    /// Stop the tooltip-delay timer and hide the tooltip if shown
176
    Stop,
177
    /// No change needed (timer already in correct state)
178
    NoChange,
179
}
180

            
181
/// Helper function to create a unique IdNamespace
182
6974
fn new_id_namespace() -> IdNamespace {
183
6974
    let id = ID_NAMESPACE_COUNTER.fetch_add(1, Ordering::Relaxed) as u32;
184
6974
    IdNamespace(id)
185
6974
}
186

            
187
// ============================================================================
188
// Cursor Blink Timer Callback
189
// ============================================================================
190

            
191
/// Destructor for cursor blink timer RefAny (no-op since we use null pointer)
192
extern "C" fn cursor_blink_timer_destructor(_: RefAny) {
193
    // No cleanup needed - we use a null pointer RefAny
194
}
195

            
196
/// Callback for the cursor blink timer
197
///
198
/// This function is called every ~530ms to toggle cursor visibility.
199
/// It checks if enough time has passed since the last user input before blinking,
200
/// to avoid blinking while the user is actively typing.
201
///
202
/// The callback returns:
203
/// - `TerminateTimer::Continue` + `Update::RefreshDom` if cursor toggled
204
/// - `TerminateTimer::Terminate` if focus is no longer on a contenteditable element
205
pub extern "C" fn cursor_blink_timer_callback(
206
    _data: RefAny,
207
    mut info: crate::timer::TimerCallbackInfo,
208
) -> azul_core::callbacks::TimerCallbackReturn {
209
    use azul_core::callbacks::{TimerCallbackReturn, Update};
210
    use azul_core::task::TerminateTimer;
211

            
212
    // Get current time
213
    let now = info.get_current_time();
214

            
215
    // We need to access the LayoutWindow through the info
216
    // The timer callback needs to:
217
    // 1. Check if focus is still on a contenteditable element
218
    // 2. Check time since last input
219
    // 3. Toggle visibility or keep solid
220

            
221
    // For now, we'll queue changes via the CallbackInfo system
222
    // The actual state modification happens in apply_user_change
223

            
224
    // Check if we should blink or stay solid
225
    // This is done by checking CursorManager.should_blink(now) in the layout window
226

            
227
    // Since we can't access LayoutWindow directly here (it's not passed to timer callbacks),
228
    // we use a different approach: the timer callback always toggles, and the visibility
229
    // check is done in display_list.rs based on CursorManager state.
230

            
231
    // Simply toggle cursor visibility
232
    info.set_cursor_visibility_toggle();
233

            
234
    // Continue the timer and request a redraw.
235
    // DoNothing here because the SetCursorVisibility change (queued above)
236
    // already toggles blink state and returns ShouldUpdateDisplayListCurrentWindow,
237
    // which sets display_list_dirty. RefreshDom would trigger a full DOM rebuild
238
    // from the user callback; since the DOM is structurally unchanged (only cursor
239
    // visibility differs), is_layout_equivalent() returns LayoutUnchanged and the
240
    // display list change is lost.
241
    TimerCallbackReturn {
242
        should_update: Update::DoNothing,
243
        should_terminate: TerminateTimer::Continue,
244
    }
245
}
246

            
247
// ============================================================================
248
// Tooltip Delay Timer Callback
249
// ============================================================================
250

            
251
/// Callback for the tooltip-delay timer.
252
///
253
/// Fires once after `InputMetrics::hover_time_ms` has elapsed while a node with
254
/// a tooltip-bearing attribute was continuously hovered. The callback looks up
255
/// the `title` / `aria-label` / `alt` attribute on the currently-hovered node,
256
/// emits a `ShowTooltip` CallbackChange, and terminates — a single-shot timer.
257
/// Movement to a different node (or any hover loss) removes and re-adds the
258
/// timer from the platform layer, so the callback itself never needs to
259
/// reschedule.
260
pub extern "C" fn tooltip_delay_timer_callback(
261
    _data: RefAny,
262
    mut info: crate::timer::TimerCallbackInfo,
263
) -> azul_core::callbacks::TimerCallbackReturn {
264
    use azul_core::callbacks::{TimerCallbackReturn, Update};
265
    use azul_core::task::TerminateTimer;
266

            
267
    let layout_window = info.callback_info.get_layout_window();
268
    let hover_node_id = layout_window
269
        .hover_manager
270
        .current_hover_node()
271
        .map(|node_id| azul_core::dom::DomNodeId {
272
            dom: azul_core::dom::DomId { inner: 0 },
273
            node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_id)),
274
        });
275

            
276
    if let Some(dom_node_id) = hover_node_id {
277
        // Priority: aria-label > alt > title (mirrors DOM get_accessible_label).
278
        let tooltip_text = info
279
            .callback_info
280
            .get_node_attribute(dom_node_id, "aria-label")
281
            .or_else(|| info.callback_info.get_node_attribute(dom_node_id, "alt"))
282
            .or_else(|| info.callback_info.get_node_attribute(dom_node_id, "title"));
283

            
284
        if let Some(text) = tooltip_text {
285
            info.callback_info.show_tooltip(text);
286
        }
287
    }
288

            
289
    TimerCallbackReturn {
290
        should_update: Update::DoNothing,
291
        should_terminate: TerminateTimer::Terminate,
292
    }
293
}
294

            
295
/// Result of a layout pass for a single DOM, before display list generation
296
#[derive(Debug)]
297
pub struct DomLayoutResult {
298
    /// The styled DOM that was laid out
299
    pub styled_dom: StyledDom,
300
    /// The layout tree with computed sizes and positions
301
    pub layout_tree: LayoutTree,
302
    /// Absolute positions of all nodes
303
    pub calculated_positions: crate::solver3::PositionVec,
304
    /// The viewport used for this layout
305
    pub viewport: LogicalRect,
306
    /// The generated display list for this DOM.
307
    pub display_list: DisplayList,
308
    /// Stable scroll IDs computed from node_data_hash
309
    /// Maps layout node index -> external scroll ID
310
    pub scroll_ids: HashMap<usize, u64>,
311
    /// Mapping from scroll IDs to DOM NodeIds for hit testing
312
    /// This allows us to map WebRender scroll IDs back to DOM nodes
313
    pub scroll_id_to_node_id: HashMap<u64, NodeId>,
314
}
315

            
316
/// State for tracking scrollbar drag interaction
317
#[derive(Debug, Clone)]
318
pub struct ScrollbarDragState {
319
    pub hit_id: ScrollbarHitId,
320
    pub initial_mouse_pos: LogicalPosition,
321
    pub initial_scroll_offset: LogicalPosition,
322
}
323

            
324
/// Information about the last text edit operation
325
/// Allows callbacks to query what changed during text input
326
// Re-export PendingTextEdit from text_input manager
327
pub use crate::managers::text_input::PendingTextEdit;
328

            
329
/// Cached text layout constraints for a node
330
/// These are the layout parameters that were used to shape the text
331
#[derive(Debug, Clone)]
332
pub struct TextConstraintsCache {
333
    /// Map from (dom_id, node_id) to their layout constraints
334
    pub constraints: BTreeMap<(DomId, NodeId), UnifiedConstraints>,
335
}
336

            
337
impl Default for TextConstraintsCache {
338
    fn default() -> Self {
339
        Self {
340
            constraints: BTreeMap::new(),
341
        }
342
    }
343
}
344

            
345
/// A text node that has been edited since the last full layout.
346
/// This allows us to perform lightweight relayout without rebuilding the entire DOM.
347
#[derive(Debug, Clone)]
348
pub struct DirtyTextNode {
349
    /// The new inline content (text + images) after editing
350
    pub content: Vec<InlineContent>,
351
    /// The new cursor position after editing
352
    pub cursor: Option<TextCursor>,
353
    /// Whether this edit requires ancestor relayout (e.g., text grew taller)
354
    pub needs_ancestor_relayout: bool,
355
}
356

            
357
/// Result of applying a text changeset
358
pub struct TextChangesetResult {
359
    /// Nodes that need dirty marking
360
    pub dirty_nodes: Vec<azul_core::dom::DomNodeId>,
361
    /// Whether the text size changed enough to require full re-layout
362
    /// (e.g., for scroll container recomputation)
363
    pub needs_relayout: bool,
364
}
365

            
366
/// A window-level layout manager that encapsulates all layout state and caching.
367
///
368
/// This struct owns the layout and text caches, and provides methods dir_to:
369
/// - Perform initial layout
370
/// - Incrementally update layout on DOM changes
371
/// - Generate display lists for rendering
372
/// - Handle window resizes efficiently
373
/// - Manage multiple DOMs (for VirtualViews)
374
pub struct LayoutWindow {
375
    /// M12.7 web/headless: skip the GPU transform/opacity sync in
376
    /// `layout_dom_recursive`. That sync only feeds the display list (which
377
    /// the web backend skips), has no GPU, and `GpuValueCache::synchronize`
378
    /// currently mis-lifts to wasm (out-of-bounds). Gated via this heap field
379
    /// (a normal struct read — reliable in the lift, unlike the
380
    /// `SKIP_DISPLAY_LIST` `__bss` static, whose store/load is inconsistent
381
    /// in the lifted wasm). Default false → desktop is unaffected.
382
    pub skip_gpu_sync: bool,
383
    /// Fragmentation context for this window (continuous for screen, paged for print)
384
    #[cfg(feature = "pdf")]
385
    pub fragmentation_context: crate::paged::FragmentationContext,
386
    /// Layout cache for solver3 (incremental layout tree) - for the root DOM
387
    pub layout_cache: Solver3LayoutCache,
388
    /// Text layout cache for text3 (shaped glyphs, line breaks, etc.)
389
    pub text_cache: TextLayoutCache,
390
    /// Font manager for loading and caching fonts
391
    pub font_manager: FontManager<FontRef>,
392
    /// Cache to store decoded images
393
    pub image_cache: ImageCache,
394
    /// CPU-backend resolution of `RenderImageCallback` images: the produced
395
    /// image for each callback-image node, keyed by the ORIGINAL callback
396
    /// image's hash. Populated by [`LayoutWindow::invoke_cpu_image_callbacks`]
397
    /// before each CPU `render_frame`; consumed by cpurender (which otherwise
398
    /// draws a grey placeholder for `DecodedImage::Callback`). Empty on the GPU
399
    /// path (WebRender invokes callbacks itself via `process_image_callback_updates`).
400
    pub cpu_image_callback_results: BTreeMap<ImageRefHash, ImageRef>,
401
    /// Cached layout results for all DOMs (root + virtualized views)
402
    pub layout_results: BTreeMap<DomId, DomLayoutResult>,
403
    /// Scroll state manager for all nodes across all DOMs
404
    pub scroll_manager: ScrollManager,
405
    /// Gesture and drag manager for multi-frame interactions (moved from FullWindowState)
406
    pub gesture_drag_manager: crate::managers::gesture::GestureAndDragManager,
407
    /// Focus manager for keyboard focus and tab navigation
408
    pub focus_manager: crate::managers::focus_cursor::FocusManager,
409
    /// Unified text editing manager (cursor + selection + dirty flag)
410
    pub text_edit_manager: crate::managers::text_edit::TextEditManager,
411
    /// File drop manager for cursor state and file drag-drop
412
    pub file_drop_manager: crate::managers::file_drop::FileDropManager,
413
    /// Clipboard manager for system clipboard integration
414
    pub clipboard_manager: crate::managers::clipboard::ClipboardManager,
415
    /// Drag-drop manager for node and file dragging operations
416
    pub drag_drop_manager: crate::managers::drag_drop::DragDropManager,
417
    /// Hover manager for tracking hit test history over multiple frames
418
    pub hover_manager: crate::managers::hover::HoverManager,
419
    /// VirtualView manager for all nodes across all DOMs
420
    pub virtual_view_manager: VirtualViewManager,
421
    /// GPU state manager for all nodes across all DOMs
422
    pub gpu_state_manager: GpuStateManager,
423
    /// Accessibility manager for screen reader support
424
    pub a11y_manager: crate::managers::a11y::A11yManager,
425
    /// Permission manager — cross-platform capability state for camera /
426
    /// microphone / geolocation / biometric / sensors / photo-library /
427
    /// notifications / etc. The platform backend drains
428
    /// `take_pending_permission_events` once per frame and routes each
429
    /// `Subscribe` / `Release` through `dll::desktop::extra::permission::apply_diff_events`.
430
    /// See `SUPER_PLAN_2.md` §1.5 + research/08 for the architecture.
431
    pub permission_manager: crate::managers::permission::PermissionManager,
432
    /// Geolocation manager — `LocationFix` storage + per-frame diff
433
    /// against the `NodeType::GeolocationProbe`s in the styled DOM.
434
    /// The platform backend (`dll::desktop::extra::geolocation`)
435
    /// drains diff events and starts / stops native
436
    /// `CLLocationManager` / `LocationManager` / `geoclue`
437
    /// subscriptions.
438
    pub geolocation_manager: crate::managers::geolocation::GeolocationManager,
439
    /// Cross-platform biometric-auth state — latest result + sync
440
    /// availability. The platform backend (`dll::desktop::extra::biometric`)
441
    /// shows the OS prompt and parks results in the async channel that the
442
    /// layout pass folds into this manager (request-driven; no probe node).
443
    pub biometric_manager: crate::managers::biometric::BiometricManager,
444
    /// Cross-platform keyring state — outcome of the last secret-store op.
445
    /// The platform backend (`dll::desktop::extra::keyring`) reads/writes
446
    /// the OS keyring (Keychain / KeyStore / libsecret / CredentialLocker)
447
    /// and parks results in the async channel the layout pass folds in here.
448
    pub keyring_manager: crate::managers::keyring::KeyringManager,
449
    /// Cross-platform motion-sensor state — latest accel / gyro / mag
450
    /// reading. The platform backend (`dll::desktop::extra::sensors`)
451
    /// subscribes to CoreMotion / Android `SensorManager` and parks
452
    /// readings in the async channel the layout pass folds in here.
453
    pub sensor_manager: crate::managers::sensors::SensorManager,
454
    /// Cross-platform gamepad / controller state. The dll's platform backend
455
    /// (gilrs / GCController / InputDevice) parks per-pad states in the async
456
    /// channel the layout pass folds in here.
457
    pub gamepad_manager: crate::managers::gamepad::GamepadManager,
458
    /// Safe-area insets (notch / system-UI margins) for this window, in logical
459
    /// px. Set by the platform shell (macOS NSScreen.safeAreaInsets, iOS
460
    /// UIView.safeAreaInsets, Android WindowInsets); zero where none.
461
    pub safe_area_insets: azul_css::system::SafeAreaInsets,
462
    /// Timers associated with this window
463
    pub timers: BTreeMap<TimerId, Timer>,
464
    /// Threads running in the background for this window
465
    pub threads: BTreeMap<ThreadId, Thread>,
466
    /// Currently loaded fonts and images present in this renderer (window)
467
    pub renderer_resources: RendererResources,
468
    /// Renderer type: Hardware-with-software-fallback, pure software or pure hardware renderer?
469
    pub renderer_type: Option<RendererType>,
470
    /// Windows state of the window of (current frame - 1): initialized to None on startup
471
    pub previous_window_state: Option<FullWindowState>,
472
    /// Window state of this current window (current frame): initialized to the state of
473
    /// WindowCreateOptions
474
    pub current_window_state: FullWindowState,
475
    /// A "document" in WebRender usually corresponds to one tab (i.e. in Azuls case, the whole
476
    /// window).
477
    pub document_id: DocumentId,
478
    /// ID namespace under which every font / image for this window is registered
479
    pub id_namespace: IdNamespace,
480
    /// The "epoch" is a frame counter, to remove outdated images, fonts and OpenGL textures when
481
    /// they're not in use anymore.
482
    pub epoch: Epoch,
483
    /// Currently GL textures inside the active CachedDisplayList
484
    pub gl_texture_cache: GlTextureCache,
485
    /// State for tracking scrollbar drag interaction
486
    currently_dragging_thumb: Option<ScrollbarDragState>,
487
    /// Text input manager - centralizes all text editing logic
488
    pub text_input_manager: crate::managers::text_input::TextInputManager,
489
    /// Undo/Redo manager for text editing operations
490
    pub undo_redo_manager: crate::managers::undo_redo::UndoRedoManager,
491
    /// Cached text layout constraints for each node
492
    /// This allows us to re-layout text with the same constraints after edits
493
    pub text_constraints_cache: TextConstraintsCache,
494
    /// Tracks which nodes have been edited since last full layout.
495
    /// Key: (DomId, NodeId of IFC root)
496
    /// Value: The edited inline content that should be used for relayout
497
    pub dirty_text_nodes: BTreeMap<(DomId, NodeId), DirtyTextNode>,
498
    /// Pending VirtualView updates from callbacks (processed in next frame)
499
    /// Map of DomId -> Set of NodeIds that need re-rendering
500
    pub pending_virtual_view_updates: BTreeMap<DomId, FastBTreeSet<NodeId>>,
501
    /// Lifecycle events produced by DOM reconciliation, waiting to be dispatched.
502
    ///
503
    /// `regenerate_layout` appends `diff::reconcile_dom`'s `DiffResult.events` here
504
    /// (Mount / Update / Resize SyntheticEvents — note: NOT Unmount; see
505
    /// `pending_unmount_invocations`). The shell's event loop drains and
506
    /// dispatches them via `dispatch_events_propagated`, which routes
507
    /// `EventFilter::Component(_)` filters through `matches_component_filter`.
508
    /// Drain-and-clear is the caller's responsibility; nothing inside
509
    /// `LayoutWindow` ages or discards these on its own.
510
    pub pending_lifecycle_events: Vec<azul_core::events::SyntheticEvent>,
511
    /// Resolved BeforeUnmount invocations queued for dispatch.
512
    ///
513
    /// Unmount events target OLD NodeIds that disappear once the new layout
514
    /// is committed to `layout_results`, so the shell cannot resolve them
515
    /// via DOM lookup at dispatch time. `regenerate_layout` resolves the
516
    /// callback against the OLD node data while it still has access, then
517
    /// pushes a `(CoreCallbackData, SyntheticEvent)` pair here. The shell's
518
    /// dispatcher invokes each pair directly.
519
    pub pending_unmount_invocations: Vec<(
520
        azul_core::callbacks::CoreCallbackData,
521
        azul_core::events::SyntheticEvent,
522
    )>,
523
    /// System style (colors, fonts, metrics) for resolving system color keywords
524
    /// Set via `set_system_style()` from the shell after window creation
525
    pub system_style: Option<std::sync::Arc<azul_css::system::SystemStyle>>,
526
    /// Shared monitor list — initialized once at app start, updated by the platform
527
    /// layer on monitor topology changes. Arc<Mutex> allows zero-cost sharing
528
    /// across all CallbackInfoRefData without cloning the Vec each time.
529
    pub monitors: std::sync::Arc<std::sync::Mutex<MonitorVec>>,
530
    /// XOR of all tier2b.font_family_hash values from the last resolved DOM.
531
    /// Used to skip font chain resolution on frames where the font requirements
532
    /// haven't changed (e.g. scroll-only frames).
533
    font_stacks_hash: u64,
534
    /// Snapshot of inline content before IME preedit injection.
535
    /// Saved on first setMarkedText so each subsequent call injects into
536
    /// clean original text instead of accumulating old preedits.
537
    pre_preedit_content: Option<Vec<crate::text3::cache::InlineContent>>,
538
    /// Configurable input interpreter: maps raw events → SystemChange actions.
539
    /// Default: `default_input_interpreter` (standard desktop keybindings).
540
    /// Replace to implement vim, game controls, accessibility remaps, etc.
541
    pub input_interpreter: azul_core::events::InputInterpreterCallback,
542
    /// Configurable post-callback filter.
543
    /// Default: `default_post_filter` (scroll-into-view after cursor ops).
544
    pub post_filter: azul_core::events::PostFilterCallback,
545
    /// Registered routes from AppConfig.  Set once at window creation.
546
    /// Used by `CallbackChange::SwitchRoute` to look up layout callbacks.
547
    pub routes: azul_core::resources::RouteVec,
548
    /// ICU4X localizer handle for internationalized formatting (numbers, dates, lists, plurals)
549
    /// Initialized from system language at startup, can be overridden
550
    #[cfg(feature = "icu")]
551
    pub icu_localizer: IcuLocalizerHandle,
552
}
553

            
554
3487
fn default_duration_500ms() -> Duration {
555
3487
    Duration::System(SystemTimeDiff::from_millis(500))
556
3487
}
557

            
558
3487
fn default_duration_200ms() -> Duration {
559
3487
    Duration::System(SystemTimeDiff::from_millis(200))
560
3487
}
561

            
562
/// Helper function to convert Duration to milliseconds
563
///
564
/// Duration is an enum with System (std::time::Duration) and Tick variants.
565
/// We need to handle both cases for proper time calculations.
566
fn duration_to_millis(duration: Duration) -> u64 {
567
    match duration {
568
        #[cfg(feature = "std")]
569
        Duration::System(system_diff) => {
570
            let std_duration: std::time::Duration = system_diff.into();
571
            std_duration.as_millis() as u64
572
        }
573
        #[cfg(not(feature = "std"))]
574
        Duration::System(system_diff) => {
575
            // Manual calculation: secs * 1000 + nanos / 1_000_000
576
            system_diff.secs * 1000 + (system_diff.nanos / 1_000_000) as u64
577
        }
578
        Duration::Tick(tick_diff) => {
579
            // Assume tick = 1ms for simplicity (platform-specific)
580
            tick_diff.tick_diff
581
        }
582
    }
583
}
584

            
585
impl LayoutWindow {
586
    /// Create a new layout window with empty caches.
587
    ///
588
    /// For full initialization with WindowInternal compatibility, use `new_full()`.
589
    /// The single place every `LayoutWindow` field is initialized; the public
590
    /// constructors below are thin wrappers over this (deduplicated 2026-05-21,
591
    /// so adding a field touches one site instead of three).
592
3487
    fn from_font_manager(font_manager: FontManager<FontRef>) -> Self {
593
3487
        Self {
594
3487
            // M12.7 web/headless GPU-sync skip (default false → desktop unaffected)
595
3487
            skip_gpu_sync: false,
596
3487
            #[cfg(feature = "pdf")]
597
3487
            fragmentation_context: crate::paged::FragmentationContext::new_continuous(800.0),
598
3487
            layout_cache: Solver3LayoutCache {
599
3487
                tree: None,
600
3487
                calculated_positions: Vec::new(),
601
3487
                viewport: None,
602
3487
                scroll_ids: HashMap::new(),
603
3487
                scroll_id_to_node_id: HashMap::new(),
604
3487
                counters: HashMap::new(),
605
3487
                float_cache: HashMap::new(),
606
3487
                cache_map: Default::default(),
607
3487
                previous_positions: Vec::new(),
608
3487
                cached_display_list: None,
609
3487
                prev_dom_ptr: 0,
610
3487
                prev_viewport: LogicalRect::zero(),
611
3487
            },
612
3487
            text_cache: TextLayoutCache::new(),
613
3487
            font_manager,
614
3487
            image_cache: ImageCache::default(),
615
3487
            cpu_image_callback_results: BTreeMap::new(),
616
3487
            layout_results: BTreeMap::new(),
617
3487
            scroll_manager: ScrollManager::new(),
618
3487
            gesture_drag_manager: crate::managers::gesture::GestureAndDragManager::new(),
619
3487
            focus_manager: crate::managers::focus_cursor::FocusManager::new(),
620
3487
            text_edit_manager: crate::managers::text_edit::TextEditManager::new(),
621
3487
            file_drop_manager: crate::managers::file_drop::FileDropManager::new(),
622
3487
            clipboard_manager: crate::managers::clipboard::ClipboardManager::new(),
623
3487
            drag_drop_manager: crate::managers::drag_drop::DragDropManager::new(),
624
3487
            hover_manager: crate::managers::hover::HoverManager::new(),
625
3487
            virtual_view_manager: VirtualViewManager::new(),
626
3487
            gpu_state_manager: GpuStateManager::new(
627
3487
                default_duration_500ms(),
628
3487
                default_duration_200ms(),
629
3487
            ),
630
3487
            a11y_manager: crate::managers::a11y::A11yManager::new(),
631
3487
            permission_manager: crate::managers::permission::PermissionManager::new(),
632
3487
            geolocation_manager: crate::managers::geolocation::GeolocationManager::new(),
633
3487
            biometric_manager: crate::managers::biometric::BiometricManager::new(),
634
3487
            keyring_manager: crate::managers::keyring::KeyringManager::new(),
635
3487
            sensor_manager: crate::managers::sensors::SensorManager::new(),
636
3487
            gamepad_manager: crate::managers::gamepad::GamepadManager::new(),
637
3487
            safe_area_insets: azul_css::system::SafeAreaInsets::default(),
638
3487
            timers: BTreeMap::new(),
639
3487
            threads: BTreeMap::new(),
640
3487
            renderer_resources: RendererResources::default(),
641
3487
            renderer_type: None,
642
3487
            previous_window_state: None,
643
3487
            current_window_state: FullWindowState::default(),
644
3487
            document_id: new_document_id(),
645
3487
            id_namespace: new_id_namespace(),
646
3487
            epoch: Epoch::new(),
647
3487
            gl_texture_cache: GlTextureCache::default(),
648
3487
            currently_dragging_thumb: None,
649
3487
            text_input_manager: crate::managers::text_input::TextInputManager::new(),
650
3487
            undo_redo_manager: crate::managers::undo_redo::UndoRedoManager::new(),
651
3487
            text_constraints_cache: TextConstraintsCache {
652
3487
                constraints: BTreeMap::new(),
653
3487
            },
654
3487
            dirty_text_nodes: BTreeMap::new(),
655
3487
            pending_virtual_view_updates: BTreeMap::new(),
656
3487
            pending_lifecycle_events: Vec::new(),
657
3487
            pending_unmount_invocations: Vec::new(),
658
3487
            system_style: None,
659
3487
            monitors: std::sync::Arc::new(std::sync::Mutex::new(MonitorVec::from_const_slice(&[]))),
660
3487
            font_stacks_hash: 0,
661
3487
            pre_preedit_content: None,
662
3487
            input_interpreter: azul_core::events::InputInterpreterCallback::default(),
663
3487
            post_filter: azul_core::events::PostFilterCallback::default(),
664
3487
            routes: azul_core::resources::RouteVec::from_const_slice(&[]),
665
3487
            #[cfg(feature = "icu")]
666
3487
            icu_localizer: IcuLocalizerHandle::default(),
667
3487
        }
668
3487
    }
669

            
670
    /// Create a new layout window with empty caches.
671
    ///
672
    /// For full initialization with WindowInternal compatibility, use `new_full()`.
673
3487
    pub fn new(fc_cache: FcFontCache) -> Result<Self, crate::solver3::LayoutError> {
674
3487
        Ok(Self::from_font_manager(FontManager::new(fc_cache)?))
675
3487
    }
676

            
677
    /// Create a new layout window that shares already-parsed fonts with
678
    /// Create a LayoutWindow from a `FontContext` — shares all font data,
679
    /// starts with fresh layout cache, text cache, and all other state.
680
    pub fn from_font_context(ctx: &crate::text3::cache::FontContext) -> Result<Self, crate::solver3::LayoutError> {
681
        let fm = ctx.to_font_manager();
682
        let fc_cache = fm.fc_cache.clone();
683
        let parsed_fonts = fm.parsed_fonts.clone();
684
        let mut lw = Self::new_with_shared_fonts(fc_cache, parsed_fonts)?;
685
        lw.font_manager = fm;
686
        Ok(lw)
687
    }
688

            
689
    /// Create from shared fc_cache + parsed_fonts Arcs.
690
    pub fn new_with_shared_fonts(
691
        fc_cache: FcFontCache,
692
        parsed_fonts: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<rust_fontconfig::FontId, FontRef>>>,
693
    ) -> Result<Self, crate::solver3::LayoutError> {
694
        Ok(Self::from_font_manager(FontManager::from_arc_shared(
695
            fc_cache,
696
            parsed_fonts,
697
        )?))
698
    }
699

            
700
    /// Create a new layout window for paged media (PDF generation).
701
    ///
702
    /// This constructor initializes the layout window with a paged fragmentation context,
703
    /// which will cause content to flow across multiple pages instead of a single continuous
704
    /// scrollable container.
705
    ///
706
    /// # Arguments
707
    /// - `fc_cache`: Font configuration cache for font loading
708
    /// - `page_size`: The logical size of each page
709
    ///
710
    /// # Returns
711
    /// A new `LayoutWindow` configured for paged output, or an error if initialization fails.
712
    #[cfg(feature = "pdf")]
713
    pub fn new_paged(
714
        fc_cache: FcFontCache,
715
        page_size: LogicalSize,
716
    ) -> Result<Self, crate::solver3::LayoutError> {
717
        let mut lw = Self::from_font_manager(FontManager::new(fc_cache)?);
718
        lw.fragmentation_context = crate::paged::FragmentationContext::new_paged(page_size);
719
        Ok(lw)
720
    }
721

            
722
    /// Perform layout on a styled DOM and generate a display list.
723
    ///
724
    /// This is the main entry point for layout. It handles:
725
    /// - Incremental layout updates using the cached layout tree
726
    /// - Text shaping and line breaking
727
    /// - VirtualView callback invocation and recursive layout
728
    /// - Display list generation for rendering
729
    /// - Accessibility tree synchronization
730
    ///
731
    /// # Arguments
732
    /// - `styled_dom`: The styled DOM to layout
733
    /// - `window_state`: Current window dimensions and state
734
    /// - `renderer_resources`: Resources for image sizing etc.
735
    /// - `debug_messages`: Optional vector to collect debug/warning messages
736
    ///
737
    /// # Returns
738
    /// The display list ready for rendering, or an error if layout fails.
739
3432
    pub fn layout_and_generate_display_list(
740
3432
        &mut self,
741
3432
        root_dom: StyledDom,
742
3432
        window_state: &FullWindowState,
743
3432
        renderer_resources: &RendererResources,
744
3432
        system_callbacks: &ExternalSystemCallbacks,
745
3432
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
746
3432
    ) -> Result<(), solver3::LayoutError> {
747
        // Clear previous results for a full relayout
748
3432
        self.layout_results.clear();
749

            
750
        // CRITICAL: Reset VirtualView invocation flags so check_reinvoke() returns
751
        // InitialRender for every tracked VirtualView. Without this, the VirtualViewManager
752
        // still has was_invoked=true from the previous frame, so it skips
753
        // re-invocation — but the child DOM was just destroyed by clear().
754
3432
        self.virtual_view_manager.reset_all_invocation_flags();
755

            
756
3432
        if let Some(msgs) = debug_messages.as_mut() {
757
3432
            msgs.push(LayoutDebugMessage::info(format!(
758
3432
                "[layout_and_generate_display_list] Starting layout for DOM with {} nodes",
759
3432
                root_dom.node_data.len()
760
3432
            )));
761
3432
        }
762

            
763
        // Start recursive layout from the root DOM. Passes ownership — the
764
        // StyledDom ends up inside `layout_results` without a clone.
765
3432
        let result = self.layout_dom_recursive(
766
3432
            root_dom,
767
3432
            window_state,
768
3432
            renderer_resources,
769
3432
            system_callbacks,
770
3432
            debug_messages,
771
        );
772

            
773
3432
        if let Err(ref e) = result {
774
            if let Some(msgs) = debug_messages.as_mut() {
775
                msgs.push(LayoutDebugMessage::error(format!(
776
                    "[layout_and_generate_display_list] Layout FAILED: {:?}",
777
                    e
778
                )));
779
            }
780
        } else {
781
3432
            if let Some(msgs) = debug_messages.as_mut() {
782
3432
                msgs.push(LayoutDebugMessage::info(format!(
783
3432
                    "[layout_and_generate_display_list] Layout SUCCESS, layout_results count: {}",
784
3432
                    self.layout_results.len()
785
3432
                )));
786
3432
            }
787
        }
788

            
789
        // After successful layout, update the accessibility tree
790
        #[cfg(feature = "a11y")]
791
3432
        if result.is_ok() {
792
3432
            self.update_a11y_tree();
793
3432
        }
794

            
795
        // After layout, automatically scroll cursor into view if there's a focused text input
796
3432
        if result.is_ok() {
797
3432
            self.scroll_focused_cursor_into_view();
798
3432
        }
799

            
800
3432
        result
801
3432
    }
802

            
803
    /// Run the real layout solver for a single StyledDom + viewport
804
    /// (taffy block/flex/grid → `layout_cache.calculated_positions`).
805
    ///
806
    /// Made `pub` for the web backend (`AzStartup_solveLayoutReal`),
807
    /// which lifts this from ARM to wasm to position the headless
808
    /// StyledDom. On web the display-list step inside `layout_document`
809
    /// is hot-patched out at lift time (web emits TLV patches, not a
810
    /// display list); positions are written to the cache *before* that
811
    /// step, so the lifted path still produces correct geometry.
812
3872
    pub fn layout_dom_recursive(
813
3872
        &mut self,
814
3872
        styled_dom: StyledDom,
815
3872
        window_state: &FullWindowState,
816
3872
        renderer_resources: &RendererResources,
817
3872
        system_callbacks: &ExternalSystemCallbacks,
818
3872
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
819
3872
    ) -> Result<(), solver3::LayoutError> {
820
        // Child DOMs (VirtualView / iframe) must NOT lay out into the root's
821
        // live cache: the impl below writes tree + calculated_positions into
822
        // `self.layout_cache`, so a child pass CLOBBERS the root's geometry —
823
        // `get_node_layout_rect` and the next incremental relayout then read
824
        // the child's tree instead of the root's (live bug: azul-maps' header
825
        // laid out 640x0/None → toolbar invisible and unclickable while the
826
        // map child DOM rendered fine). Children lay out cold by design, so
827
        // give a child a fresh scratch cache and restore the root's cache
828
        // afterwards; the per-DOM snapshot lives in `layout_results`. Nested
829
        // children stack their swaps.
830
3872
        let is_child_dom = styled_dom.dom_id.inner != 0;
831
3872
        if is_child_dom {
832
308
            let saved_root_cache = core::mem::take(&mut self.layout_cache);
833
308
            let result = self.layout_dom_recursive_impl(
834
308
                styled_dom,
835
308
                window_state,
836
308
                renderer_resources,
837
308
                system_callbacks,
838
308
                debug_messages,
839
            );
840
308
            self.layout_cache = saved_root_cache;
841
308
            return result;
842
3564
        }
843
3564
        self.layout_dom_recursive_impl(
844
3564
            styled_dom,
845
3564
            window_state,
846
3564
            renderer_resources,
847
3564
            system_callbacks,
848
3564
            debug_messages,
849
        )
850
3872
    }
851

            
852
3872
    fn layout_dom_recursive_impl(
853
3872
        &mut self,
854
3872
        styled_dom: StyledDom,
855
3872
        window_state: &FullWindowState,
856
3872
        renderer_resources: &RendererResources,
857
3872
        system_callbacks: &ExternalSystemCallbacks,
858
3872
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
859
3872
    ) -> Result<(), solver3::LayoutError> {
860
3872
        let dom_id = if styled_dom.dom_id.inner == 0 {
861
3564
            DomId::ROOT_ID
862
        } else {
863
308
            styled_dom.dom_id
864
        };
865

            
866
        // Children enter with a fresh scratch cache (see the wrapper above);
867
        // reset_incremental() is kept as belt-and-braces for any direct
868
        // callers and is a no-op on a fresh cache.
869
3872
        if dom_id != DomId::ROOT_ID {
870
308
            self.layout_cache.reset_incremental();
871
3564
        }
872

            
873
3872
        let viewport = LogicalRect {
874
3872
            origin: LogicalPosition::zero(),
875
3872
            size: window_state.size.dimensions,
876
3872
        };
877

            
878
        // Get the platform from system_style, falling back to compile-time detection
879
3872
        let platform = self.system_style.as_ref()
880
3872
            .map(|s| s.platform.clone())
881
3872
            .unwrap_or_else(azul_css::system::Platform::current);
882

            
883
        // Font Resolution And Loading
884
        // This must happen BEFORE layout_document() is called
885
        {
886
            use crate::{
887
                solver3::getters::collect_and_resolve_font_chains_with_registration,
888
                text3::default::PathLoader,
889
            };
890

            
891
            // Per-node font dirty tracking (P4):
892
            // Check font_dirty_nodes populated by build_compact_cache(),
893
            // which compares each node's font_family_hash against the
894
            // previous frame. This replaces the collision-prone global XOR
895
            // approach: XOR(a,b,a,b) == 0 even though fonts changed.
896
            //
897
            // Additional guard: compute an FxHash signature of
898
            // `prev_font_hashes` and compare against the one we stashed
899
            // after the last successful chain resolution. If it matches,
900
            // the DOM's font stacks are identical to what's already in
901
            // `font_chain_cache` — no resolver call needed. This catches
902
            // the common "repeated layout on unchanged DOM" case that
903
            // `font_dirty_nodes.len() == 0` misses, because the dirty
904
            // list is only re-computed inside `build_compact_cache`,
905
            // which most layouts do NOT re-run.
906
3872
            let compact_cache_ref = styled_dom.css_property_cache.ptr.compact_cache.as_ref();
907
3872
            let font_dirty_count = compact_cache_ref
908
3872
                .map(|cc| cc.font_dirty_nodes.len())
909
3872
                .unwrap_or(1); // if no compact cache, treat as dirty
910

            
911
3872
            let font_stacks_sig = compact_cache_ref.map(|cc| {
912
                // Fast polynomial rolling hash over the `prev_font_hashes`
913
                // slice. Mixes each u64 with a multiplier + bit-rotation,
914
                // which is collision-resistant enough for our one-at-a-time
915
                // "did this DOM's font stacks change" comparison and an
916
                // order of magnitude cheaper than SipHash for ~300 nodes.
917
3872
                let mut h: u64 = 0xcbf29ce484222325;
918
42108
                for &fh in cc.prev_font_hashes.iter() {
919
42108
                    h = h.rotate_left(13) ^ fh;
920
42108
                    h = h.wrapping_mul(0x100000001b3);
921
42108
                }
922
3872
                h
923
3872
            });
924

            
925
            // Skip all font resolution steps if NO node's font_family_hash
926
            // changed AND the font_chain_cache has already been populated,
927
            // OR if the font-stacks signature matches the one we stashed
928
            // after the last successful resolution.
929
3872
            let font_requirements_unchanged = (font_dirty_count == 0
930
                && !self.font_manager.font_chain_cache.is_empty())
931
3872
                || (font_stacks_sig.is_some()
932
3872
                    && font_stacks_sig == self.font_manager.last_resolved_font_stacks_sig
933
88
                    && !self.font_manager.font_chain_cache.is_empty());
934

            
935
3872
            if font_requirements_unchanged {
936
                if let Some(msgs) = debug_messages.as_mut() {
937
                    msgs.push(LayoutDebugMessage::info(
938
                        "[FontLoading] Font requirements unchanged, skipping resolution (cached)".to_string(),
939
                    ));
940
                }
941
            } else {
942
3872
                if let Some(msgs) = debug_messages.as_mut() {
943
3828
                    msgs.push(LayoutDebugMessage::info(
944
3828
                        "[FontLoading] Starting font resolution for DOM".to_string(),
945
3828
                    ));
946
3828
                }
947

            
948
                // Merge font hash→families from compact cache into FontManager
949
                // so the reverse map accumulates across DOMs.
950
3872
                if let Some(cc) = styled_dom.css_property_cache.ptr.compact_cache.as_ref() {
951
3872
                    for (k, v) in cc.font_hash_to_families.iter() {
952
1144
                        self.font_manager.font_hash_to_families.insert(*k, v.clone());
953
1144
                    }
954
                }
955

            
956
                // Resolve chains (including the coverage-based prune
957
                // and the per-document scripts_hint), then delegate
958
                // the load-the-missing-ones dance to FontManager's
959
                // shared helper. Same logic that lives at
960
                // `FontContext::load_fonts_for_chains` and the CPU
961
                // rasterizer's preview pre-fill — one implementation,
962
                // three callers.
963
3872
                crate::probe::sample_peak_rss("rss:before_font_chain");
964
3872
                let mut chains = {
965
3872
                    let _p = crate::probe::Probe::span("font_chain_resolve");
966
3872
                    collect_and_resolve_font_chains_with_registration(
967
3872
                        &styled_dom, &self.font_manager.fc_cache, &self.font_manager, &platform,
968
                    )
969
                };
970
                // [g80] localize where font_chain_cache drops to 0: chains right after collect_and_resolve.
971
3872
                unsafe { crate::az_mark((0x60770) as u32, (chains.chains.len() as u32) as u32); }
972
                // WEB-LIFT last resort (the DEFINITIVE spot — the layout's own `chains` that
973
                // feed load_missing_for_chains below): the lifted font-query path can leave a
974
                // chain with NO fonts even when a fallback IS registered (generic→OS-name +
975
                // token/unicode query is lift-fragile). Append the first registered font to any
976
                // empty chain so load_missing loads it + text shapes instead of measuring 0.
977
                // Done here (azul-layout), NOT rust-fontconfig (which re-codegens the fragile
978
                // with_memory_fonts into a trapping shape).
979
3872
                for chain in chains.chains.values_mut() {
980
68728
                    let total = chain.css_fallbacks.iter().map(|g| g.fonts.len()).sum::<usize>()
981
3124
                        + chain.unicode_fallbacks.len();
982
3124
                    if total == 0 {
983
                        if let Some((pattern, id)) = self.font_manager.fc_cache.list().first() {
984
                            chain.unicode_fallbacks.push(rust_fontconfig::FontMatch {
985
                                id: *id,
986
                                unicode_ranges: pattern.unicode_ranges.clone(),
987
                                fallbacks: Vec::new(),
988
                            });
989
                        }
990
3124
                    }
991
                }
992
                // [g80] chains after the window.rs last-resort loop (values_mut path).
993
3872
                unsafe { crate::az_mark((0x60774) as u32, (chains.chains.len() as u32) as u32); }
994
                // [az-web-lift 2026-06-05] REMOVED a WASM-ONLY diagnostic probe that computed
995
                // nchains/total_fonts/nreg here purely to write debug markers. Its
996
                // `chains.chains.values().map(|c| …).sum()` closure-iterator chain (and/or the
997
                // `fc_cache.list()` call) MIS-LIFTS on the web backend → memory-access-OOB → a
998
                // slice panic whose abort path spins in the OUTLINED_FUNCTION_2 dispatch (localized
999
                // via the 0x406C0=0xC0DE0007 marker: the explicit `for …values_mut()` loop ABOVE
                // lifts fine, only this closure-iterator form traps — same class as the css.rs
                // `map+collect → for-loop` lift fix). It was revert-able scaffolding; the chains
                // are sound (the for-loop iterated them), so load_missing_for_chains below proceeds.
3872
                crate::probe::sample_peak_rss("rss:after_font_chain");
                // Phase 3 (scout-on-demand): no snapshot-refresh
                // step is needed any more. rust-fontconfig 4.1
                // made `FcFontCache` a shared-state handle backed
                // by `Arc<RwLock<_>>`, so builder writes performed
                // during the `request_and_resolve_with_scripts`
                // call above are immediately visible to every
                // downstream `FontFallbackChain::resolve_char`
                // lookup without any explicit refresh.
3872
                if let Some(msgs) = debug_messages.as_mut() {
3828
                    msgs.push(LayoutDebugMessage::info(format!(
3828
                        "[FontLoading] Resolved {} font chains",
3828
                        chains.len()
3828
                    )));
3828
                }
3872
                let loader = PathLoader::new();
3872
                crate::probe::sample_peak_rss("rss:before_font_load");
3872
                let failed = {
3872
                    let _p = crate::probe::Probe::span("font_load_missing");
3872
                    self.font_manager.load_missing_for_chains(
3872
                        &chains,
7128
                        |bytes, index| loader.load_font_shared(bytes, index),
                    )
                };
3872
                crate::probe::sample_peak_rss("rss:after_font_load");
3872
                if let Some(msgs) = debug_messages.as_mut() {
3828
                    for (font_id, error) in &failed {
                        msgs.push(LayoutDebugMessage::warning(format!(
                            "[FontLoading] Failed to load font {:?}: {}",
                            font_id, error
                        )));
                    }
44
                }
                // Step 5: Update font chain cache (and stash the
                // `prev_font_hashes` signature so the next layout with
                // an identical DOM skips the resolver entirely).
3872
                let fc_chains = chains.into_fontconfig_chains();
                // [g80] fc_chains after into_fontconfig_chains (the BTreeMap rebuild) — does it drop them?
3872
                unsafe { crate::az_mark((0x60778) as u32, (fc_chains.len() as u32) as u32); }
3872
                self.font_manager.set_font_chain_cache_with_sig(
3872
                    fc_chains,
3872
                    font_stacks_sig,
                );
                // [g80] font_chain_cache right after set (does set_font_chain_cache_with_sig persist it?).
3872
                unsafe { crate::az_mark((0x6077C) as u32, (self.font_manager.font_chain_cache.len() as u32) as u32); }
            }
        }
3872
        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
        // Synchronize CSS transform / opacity keys with the current StyledDom
        // BEFORE building the display list. `display_list.rs` reads
        // `css_transform_keys` / `css_current_transform_values` (and the
        // opacity equivalents) to emit reference frames and opacity stacking
        // contexts — these maps are only populated by
        // `GpuValueCache::synchronize`. The returned events are merged into
        // `gpu_state_manager.pending_changes` so the renderer can later push
        // matching WebRender transactions alongside scrollbar transform
        // events.
        // The GPU transform/opacity sync only feeds the display list
        // (reference frames + opacity stacking contexts read by
        // display_list.rs). The web backend skips the display list
        // (SKIP_DISPLAY_LIST) and has no GPU, so skip this too — layout
        // geometry never depends on it (transforms are render-time). This
        // also avoids GpuValueCache::synchronize, which currently mis-lifts
        // to wasm (out-of-bounds access). Desktop is unaffected.
3872
        if !self.skip_gpu_sync {
3828
            let mut transform_opacity_events = self
3828
                .gpu_state_manager
3828
                .get_or_create_cache(dom_id)
3828
                .synchronize(&styled_dom);
3828
            self.gpu_state_manager
3828
                .pending_changes
3828
                .merge(&mut transform_opacity_events);
3828
        }
        // M12.7: in the headless web path the GPU cache is empty (sync skipped),
        // and `.clone()` of an empty hashbrown table drives RawTable::clone's
        // RawIterRange — which mis-lifts to wasm and loops forever. Use a fresh
        // empty cache instead (geometry doesn't use it). Desktop unchanged.
3872
        let gpu_cache = if self.skip_gpu_sync {
44
            GpuValueCache::default()
        } else {
3828
            self.gpu_state_manager.get_or_create_cache(dom_id).clone()
        };
3872
        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
3872
        let cursor_locations = self.text_edit_manager.build_cursor_locations();
3872
        let mut display_list = {
3872
            let _p = crate::probe::Probe::span("solver3_layout_document");
3872
            solver3::layout_document(
3872
                &mut self.layout_cache,
3872
                &mut self.text_cache,
3872
                &styled_dom,
3872
                viewport,
3872
                &self.font_manager,
3872
                &scroll_offsets,
3872
                &std::collections::BTreeMap::new(),
3872
                debug_messages,
3872
                Some(&gpu_cache),
3872
                &self.renderer_resources,
3872
                self.id_namespace,
3872
                dom_id,
3872
                cursor_is_visible,
3872
                cursor_locations,
3872
                self.text_edit_manager.preedit_text.clone(),
3872
                &self.image_cache,
3872
                self.system_style.clone(),
3872
                system_callbacks.get_system_time_fn,
            )?
        };
        // Hint the allocator to return freed pages after the layout pass
        // drops its transient allocations (intrinsic sizing Vecs, etc.).
3872
        crate::probe::hint_purge_allocator();
        // M12.7: the headless web path needs the per-node geometry. Everything below —
        // scrollbar TransformKey registration, GPU-cache opacity/transform sync,
        // update_scrollbar_transforms — is webrender/display-list bookkeeping that web
        // doesn't use, and it contains an ARM loop whose lift to wasm never terminates
        // (an opt-folded `br self`; routing value resolves to a webrender code pointer).
        // So publish the geometry (tree + calculated_positions) to `layout_results` HERE
        // — the same DomLayoutResult the code below would store at the tail — so the
        // headless extractor (get_node_size / get_node_position, which read
        // layout_results via dom_to_layout) finds it; then skip the GPU bookkeeping.
        // Desktop (skip_gpu_sync == false) is unchanged.
3872
        if self.skip_gpu_sync {
44
            if let Some(tree) = self.layout_cache.tree.clone() {
44
                self.layout_results.insert(
44
                    dom_id,
44
                    DomLayoutResult {
44
                        styled_dom,
44
                        layout_tree: tree,
44
                        calculated_positions: self.layout_cache.calculated_positions.clone(),
44
                        viewport,
44
                        display_list: DisplayList::default(),
44
                        scroll_ids: self.layout_cache.scroll_ids.clone(),
44
                        scroll_id_to_node_id: self.layout_cache.scroll_id_to_node_id.clone(),
44
                    },
44
                );
44
            }
44
            return Ok(());
3828
        }
        // Optional memory-breakdown print for the CSS property cache.
        // Gated on AZ_MEM_BREAKDOWN=1; off costs one env-var read on
        // the first call (`OnceLock`-cached) and nothing after.
        static MEM_BREAKDOWN_ENABLED: std::sync::OnceLock<bool> =
            std::sync::OnceLock::new();
3828
        if *MEM_BREAKDOWN_ENABLED.get_or_init(azul_core::profile::memory_enabled) {
            let sr = styled_dom.memory_report();
            eprintln!("[MEM] StyledDom ({} nodes) total={} KiB", sr.node_count, sr.total_bytes() / 1024);
            eprintln!("[MEM]   node_hierarchy    {:>7} KiB", sr.node_hierarchy_bytes / 1024);
            eprintln!("[MEM]   node_data         {:>7} KiB", sr.node_data_bytes / 1024);
            eprintln!("[MEM]   styled_nodes      {:>7} KiB", sr.styled_nodes_bytes / 1024);
            eprintln!("[MEM]   cascade_info      {:>7} KiB", sr.cascade_info_bytes / 1024);
            eprintln!("[MEM]   tag_ids           {:>7} KiB", sr.tag_ids_bytes / 1024);
            eprintln!("[MEM]   non_leaf_nodes    {:>7} KiB", sr.non_leaf_nodes_bytes / 1024);
            let bd = &sr.css_property_cache;
            eprintln!("[MEM]   CssPropertyCache  {:>7} KiB", bd.total_bytes() / 1024);
            eprintln!("[MEM]     cascaded_props   {:>6} KiB", bd.cascaded_props_bytes / 1024);
            eprintln!("[MEM]     css_props        {:>6} KiB", bd.css_props_bytes / 1024);
            eprintln!("[MEM]   computed_values   {:>7} KiB", bd.computed_values_bytes / 1024);
            eprintln!("[MEM]   user_overridden   {:>7} KiB", bd.user_overridden_bytes / 1024);
            eprintln!("[MEM]   global_css_props  {:>7} KiB", bd.global_css_props_bytes / 1024);
            eprintln!("[MEM]   compact_cache     {:>7} KiB", bd.compact_cache_bytes / 1024);
            eprintln!("[MEM]   resolved_font_sz  {:>7} KiB", bd.resolved_font_sizes_bytes / 1024);
            // solver3 LayoutCache breakdown
            let sc = self.layout_cache.memory_report();
            eprintln!("[MEM] Solver3 LayoutCache total={} KiB", sc.total_bytes() / 1024);
            if let Some(tr) = &sc.tree_report {
                eprintln!("[MEM]   LayoutTree        {:>7} KiB  ({} nodes)", sc.tree_bytes / 1024, tr.node_count);
                eprintln!("[MEM]     hot              {:>6} KiB", tr.hot_bytes / 1024);
                eprintln!("[MEM]     warm             {:>6} KiB", tr.warm_bytes / 1024);
                eprintln!("[MEM]     warm.inline      {:>6} KiB  (shaped text in CachedInlineLayout)", tr.warm_inline_layout_bytes / 1024);
                eprintln!("[MEM]     warm.taffy       {:>6} KiB", tr.warm_taffy_cache_bytes / 1024);
                eprintln!("[MEM]     cold             {:>6} KiB", tr.cold_bytes / 1024);
                eprintln!("[MEM]     children_arena   {:>6} KiB", tr.children_arena_bytes / 1024);
                eprintln!("[MEM]     dom_to_layout    {:>6} KiB", tr.dom_to_layout_bytes / 1024);
            }
            eprintln!("[MEM]   cache_map         {:>7} KiB  (Taffy-style 9+1 slots per node)", sc.cache_map_bytes / 1024);
            eprintln!("[MEM]   calculated_pos    {:>7} KiB", sc.calculated_positions_bytes / 1024);
            eprintln!("[MEM]   previous_pos      {:>7} KiB", sc.previous_positions_bytes / 1024);
            eprintln!("[MEM]   float_cache       {:>7} KiB", sc.float_cache_bytes / 1024);
            eprintln!("[MEM]   counters          {:>7} KiB", sc.counters_bytes / 1024);
            eprintln!("[MEM]   scroll_ids        {:>7} KiB", sc.scroll_ids_bytes / 1024);
            eprintln!("[MEM]   cached_display    {:>7} KiB", sc.cached_display_list_bytes / 1024);
            // text shaping cache breakdown
            let tc = self.text_cache.memory_report();
            eprintln!("[MEM] TextShapingCache total={} KiB", tc.total_bytes() / 1024);
            eprintln!("[MEM]   logical_items     {:>7} KiB  ({} entries)", tc.logical_items_bytes / 1024, tc.logical_items_entries);
            eprintln!("[MEM]   visual_items      {:>7} KiB  ({} entries)", tc.visual_items_bytes / 1024, tc.visual_items_entries);
            eprintln!("[MEM]   shaped_items      {:>7} KiB  ({} entries)", tc.shaped_items_bytes / 1024, tc.shaped_items_entries);
            eprintln!("[MEM]     glyph_bytes     {:>7} KiB", tc.shaped_glyph_bytes / 1024);
            eprintln!("[MEM]     cluster_text    {:>7} KiB", tc.shaped_cluster_text_bytes / 1024);
            eprintln!("[MEM]   per_item_shaped   {:>7} KiB  ({} entries)", tc.per_item_shaped_bytes / 1024, tc.per_item_shaped_entries);
            let grand_total = sr.total_bytes() + sc.total_bytes() + tc.total_bytes();
            eprintln!("[MEM] --- GRAND TOTAL (StyledDom + Solver3 + TextCache) = {} KiB = {:.2} MiB ---",
                grand_total / 1024, grand_total as f64 / 1048576.0);
            #[cfg(feature = "probe")]
            {
                let (rss, _virt) = crate::probe::current_rss_bytes();
                let peak = crate::probe::peak_rss_bytes_pub();
                eprintln!("[MEM] after layout: current rss={:.1} MiB  peak rss={:.1} MiB  (unreturned={:.1} MiB)",
                    rss as f64 / 1048576.0, peak as f64 / 1048576.0,
                    (peak.saturating_sub(rss)) as f64 / 1048576.0);
                eprintln!("[MEM] accounted / rss = {:.1}% — the gap is allocator overhead + unreturned transient pages + fonts/images + misc",
                    grand_total as f64 * 100.0 / (rss as f64).max(1.0));
            }
3828
        }
        // Optional AZ_PROFILE=cpu dump: per-phase wall-clock timings from
        // `Probe::span` spans (layout, style, cascade, paint, text-shape,
        // callbacks, …). Drains the thread-local buffer once per pass so
        // the printout reflects ONE layout/relayout frame — which makes it
        // easy to see which phase spiked during a stuttering frame.
        static CPU_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3828
        if *CPU_ENABLED.get_or_init(azul_core::profile::cpu_enabled) {
            let events = crate::probe::Probe::drain();
            crate::probe::print_drained_events("layout pass", &events);
3828
        }
        // Optional AZ_PROFILE=cascade dump: top-N CSS properties by
        // cascade-walk count per layout pass. Narrow diagnostic for
        // prop-cache triage — not a general CPU profile.
        static CASCADE_ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
3828
        if *CASCADE_ENABLED.get_or_init(azul_core::profile::cascade_enabled) {
            let counts = azul_core::prop_cache::drain_css_prop_counts();
            let total: usize = counts.iter().map(|(_, n)| *n).sum();
            if total > 0 {
                eprintln!("[CASCADE] cascade-walks this pass: {} total", total);
                for (label, n) in counts.iter().take(20) {
                    eprintln!("[CASCADE]   {:>8}  {}", n, label);
                }
            }
3828
        }
3828
        let tree = self
3828
            .layout_cache
3828
            .tree
3828
            .clone()
3828
            .ok_or(solver3::LayoutError::InvalidTree)?;
        // Get scroll IDs from cache (they were computed during layout_document)
3828
        let scroll_ids = self.layout_cache.scroll_ids.clone();
3828
        let scroll_id_to_node_id = self.layout_cache.scroll_id_to_node_id.clone();
        // Register scrollbar thumb TransformKeys from the display list into the GPU cache.
        // paint_scrollbars() creates TransformKey::unique() for each thumb. We need to
        // register those keys in the GPU cache so that update_scrollbar_transforms() can
        // update the values during GPU-only scroll (without display list rebuild).
        // Also register opacity keys from the display list the same way.
        {
            use crate::solver3::display_list::{DisplayListItem, ScrollbarDrawInfo};
3828
            let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id);
127556
            for item in &display_list.items {
123728
                if let DisplayListItem::ScrollBarStyled { info } = item {
                    if let Some(hit_id) = &info.hit_id {
                        // Register transform keys
                        if let Some(transform_key) = info.thumb_transform_key {
                            match hit_id {
                                azul_core::hit_test::ScrollbarHitId::VerticalThumb(_, nid) => {
                                    if !gpu_cache.transform_keys.contains_key(nid) {
                                        gpu_cache.transform_keys.insert(*nid, transform_key);
                                        gpu_cache.current_transform_values.insert(*nid, info.thumb_initial_transform);
                                    }
                                }
                                azul_core::hit_test::ScrollbarHitId::HorizontalThumb(_, nid) => {
                                    if !gpu_cache.h_transform_keys.contains_key(nid) {
                                        gpu_cache.h_transform_keys.insert(*nid, transform_key);
                                        gpu_cache.h_current_transform_values.insert(*nid, info.thumb_initial_transform);
                                    }
                                }
                                _ => {}
                            }
                        }
                        // Register opacity keys (same pattern as transform keys).
                        // The display list always generates an OpacityKey for each
                        // scrollbar. We mirror these into the GPU cache so that
                        // synchronize_scrollbar_opacity can update the values and
                        // synchronize_gpu_values can push them to WebRender.
                        //
                        // Initial opacity depends on visibility mode:
                        //   Always       → 1.0 (legacy scrollbar, always visible)
                        //   WhenScrolling → 0.0 (overlay scrollbar, hidden until scroll)
                        //   Auto         → 0.0 (same as WhenScrolling)
                        let initial_opacity = if info.visibility == azul_css::props::style::scrollbar::ScrollbarVisibilityMode::Always {
                            1.0
                        } else {
                            0.0
                        };
                        if let Some(opacity_key) = info.opacity_key {
                            match hit_id {
                                azul_core::hit_test::ScrollbarHitId::VerticalThumb(_, nid) => {
                                    let key = (dom_id, *nid);
                                    if !gpu_cache.scrollbar_v_opacity_keys.contains_key(&key) {
                                        gpu_cache.scrollbar_v_opacity_keys.insert(key, opacity_key);
                                        gpu_cache.scrollbar_v_opacity_values.insert(key, initial_opacity);
                                    }
                                }
                                azul_core::hit_test::ScrollbarHitId::HorizontalThumb(_, nid) => {
                                    let key = (dom_id, *nid);
                                    if !gpu_cache.scrollbar_h_opacity_keys.contains_key(&key) {
                                        gpu_cache.scrollbar_h_opacity_keys.insert(key, opacity_key);
                                        gpu_cache.scrollbar_h_opacity_values.insert(key, initial_opacity);
                                    }
                                }
                                _ => {}
                            }
                        }
                    }
123728
                }
            }
        }
        // Synchronize scrollbar transforms AFTER layout
3828
        self.gpu_state_manager
3828
            .update_scrollbar_transforms(dom_id, &self.scroll_manager, &tree);
        // Scan for VirtualViews *after* the initial layout pass
        // Pass styled_dom directly — layout_results isn't populated yet at this point
3828
        let vviews = self.scan_for_virtual_views(&styled_dom, &tree, &self.layout_cache.calculated_positions);
3828
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
            eprintln!("[vview] scan found {} VirtualView node(s): {:?}", vviews.len(),
                vviews.iter().map(|(n, b)| (n.index(), b.origin.x, b.origin.y, b.size.width, b.size.height)).collect::<Vec<_>>());
3828
        }
4136
        for (node_id, bounds) in vviews {
308
            if let Some(child_dom_id) = self.invoke_virtual_view_callback_with_dom(
308
                dom_id,
308
                node_id,
308
                bounds,
308
                Some(&styled_dom),
308
                window_state,
308
                renderer_resources,
308
                system_callbacks,
308
                debug_messages,
308
            ) {
                // Replace the VirtualViewPlaceholder with the real VirtualView item.
                // The placeholder was emitted by generate_display_list() at the
                // correct position (outside any scroll frame, inside the parent clip).
308
                let mut replaced = false;
20108
                for item in display_list.items.iter_mut() {
                    if let crate::solver3::display_list::DisplayListItem::VirtualViewPlaceholder {
308
                        node_id: ref placeholder_nid,
308
                        bounds: ref placeholder_bounds,
308
                        clip_rect: ref placeholder_clip,
                        ..
20108
                    } = item
                    {
308
                        if *placeholder_nid == node_id {
308
                            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                                eprintln!(
                                    "[vview] placeholder swap: node={} placeholder_bounds={:?} scan_bounds={:?}",
                                    node_id.index(), placeholder_bounds.inner(), bounds
                                );
308
                            }
308
                            *item = crate::solver3::display_list::DisplayListItem::VirtualView {
308
                                child_dom_id,
308
                                bounds: *placeholder_bounds,
308
                                clip_rect: *placeholder_clip,
308
                            };
308
                            replaced = true;
308
                            break;
                        }
19800
                    }
                }
308
                if !replaced {
                    // Fallback: if no placeholder found (shouldn't happen), append at end
                    display_list
                        .items
                        .push(crate::solver3::display_list::DisplayListItem::VirtualView {
                            child_dom_id,
                            bounds: bounds.into(),
                            clip_rect: bounds.into(),
                        });
308
                }
            }
        }
        // Store the final layout result for this DOM. `styled_dom` was passed
        // in by value, so we move it into the map without cloning.
3828
        self.layout_results.insert(
3828
            dom_id,
3828
            DomLayoutResult {
3828
                styled_dom,
3828
                layout_tree: tree,
3828
                calculated_positions: self.layout_cache.calculated_positions.clone(),
3828
                viewport,
3828
                display_list,
3828
                scroll_ids,
3828
                scroll_id_to_node_id,
3828
            },
        );
        // Clear scroll dirty flag — the new display list has
        // up-to-date scroll offsets embedded in PushScrollFrame items.
3828
        self.scroll_manager.clear_scroll_dirty();
3828
        Ok(())
3872
    }
3828
    fn scan_for_virtual_views(
3828
        &self,
3828
        styled_dom: &StyledDom,
3828
        layout_tree: &LayoutTree,
3828
        calculated_positions: &crate::solver3::PositionVec,
3828
    ) -> Vec<(NodeId, LogicalRect)> {
3828
        let node_data_container = styled_dom.node_data.as_container();
3828
        layout_tree
3828
            .nodes
3828
            .iter()
3828
            .enumerate()
41932
            .filter_map(|(idx, node)| {
41932
                let node_dom_id = node.dom_node_id?;
41844
                let node_data = node_data_container.get(node_dom_id)?;
41844
                if matches!(node_data.get_node_type(), NodeType::VirtualView) {
308
                    let pos = calculated_positions.get(idx).copied().unwrap_or_default();
308
                    let size = node.used_size.unwrap_or_default();
308
                    Some((node_dom_id, LogicalRect::new(pos, size)))
                } else {
41536
                    None
                }
41932
            })
3828
            .collect()
3828
    }
    /// Invoke every `RenderImageCallback` image once and cache the produced
    /// image, keyed by the ORIGINAL callback image's hash.
    ///
    /// The CPU renderer (`cpurender`) cannot invoke image callbacks itself — it
    /// draws a grey placeholder for `DecodedImage::Callback` (e.g. the AzulPaint
    /// canvas: an `<img>` whose data is a callback). The GPU path handles this
    /// in `process_image_callback_updates` (producing WebRender textures); this
    /// is the CPU equivalent, producing images that `render_frame` blits via
    /// [`crate::cpurender`]'s image path.
    ///
    /// Pass the backend's GL context. In CPU render mode it is effectively
    /// `None`/unusable, so a callback like AzulPaint's `render_canvas` takes its
    /// CPU branch and returns a raw `RawImage`. The result is stored in
    /// [`Self::cpu_image_callback_results`] and threaded into `CpuRenderState`.
    ///
    /// No-op (clears the cache) when there are no callback images, so normal
    /// apps pay nothing.
    pub fn invoke_cpu_image_callbacks(&mut self, gl_context: &OptionGlContextPtr) {
        use azul_core::resources::DecodedImage;
        // Phase 1: collect every callback-image node + its laid-out size.
        let hidpi_factor = self.current_window_state.size.get_hidpi_factor();
        let mut to_invoke: Vec<(DomId, NodeId, ImageRefHash, HidpiAdjustedBounds, ImageRef)> =
            Vec::new();
        for (dom_id, lr) in &self.layout_results {
            let node_data_container = lr.styled_dom.node_data.as_container();
            for (idx, node) in lr.layout_tree.nodes.iter().enumerate() {
                let node_dom_id = match node.dom_node_id {
                    Some(n) => n,
                    None => continue,
                };
                let node_data = match node_data_container.get(node_dom_id) {
                    Some(nd) => nd,
                    None => continue,
                };
                if let NodeType::Image(image_ref) = node_data.get_node_type() {
                    if !matches!(image_ref.get_data(), DecodedImage::Callback(_)) {
                        continue;
                    }
                    let _ = idx;
                    let size = node.used_size.unwrap_or_default();
                    let bounds = HidpiAdjustedBounds {
                        logical_size: size,
                        hidpi_factor,
                    };
                    to_invoke.push((
                        *dom_id,
                        node_dom_id,
                        image_ref.get_hash(),
                        bounds,
                        // NodeType::Image wraps the ImageRef in BoxOrStatic; deref
                        // to clone the inner ImageRef (cheap, refcounted).
                        (**image_ref).clone(),
                    ));
                }
            }
        }
        if to_invoke.is_empty() {
            self.cpu_image_callback_results.clear();
            return;
        }
        // Phase 2: invoke each callback, collecting the produced image by the
        // ORIGINAL callback image's hash (so cpurender can look it up from the
        // unchanged display-list `Image` item). Results go into a local map so
        // the immutable borrows of image_cache/fc_cache don't conflict with the
        // mutable store at the end.
        let mut results: BTreeMap<ImageRefHash, ImageRef> = BTreeMap::new();
        for (dom_id, node_id, hash, bounds, image_ref) in to_invoke {
            let domnode_id = DomNodeId {
                dom: dom_id,
                node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
            };
            let info = crate::callbacks::RenderImageCallbackInfo::new(
                domnode_id,
                bounds,
                gl_context,
                &self.image_cache,
                &self.font_manager.fc_cache,
            );
            let produced = match image_ref.get_data() {
                DecodedImage::Callback(core_callback) if core_callback.callback.cb != 0 => {
                    let cb = crate::callbacks::RenderImageCallback::from_core(&core_callback.callback);
                    let refany = core_callback.refany.clone();
                    std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (cb.cb)(refany, info)))
                        .ok()
                }
                _ => None,
            };
            if let Some(img) = produced {
                results.insert(hash, img);
            }
        }
        self.cpu_image_callback_results = results;
    }
    /// Handle a window resize by updating the cached layout.
    ///
    /// This method leverages solver3's incremental layout system to efficiently
    /// relayout only the affected parts of the tree when the window size changes.
    ///
    /// Returns the new display list after the resize.
    pub fn resize_window(
        &mut self,
        styled_dom: StyledDom,
        new_size: LogicalSize,
        renderer_resources: &RendererResources,
        system_callbacks: &ExternalSystemCallbacks,
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
    ) -> Result<DisplayList, crate::solver3::LayoutError> {
        // Create a temporary FullWindowState with the new size
        let mut window_state = FullWindowState::default();
        window_state.size.dimensions = new_size;
        let dom_id = styled_dom.dom_id;
        self.layout_and_generate_display_list(
            styled_dom,
            &window_state,
            renderer_resources,
            system_callbacks,
            debug_messages,
        )?;
        // Retrieve the display list from the layout result
        // We need to take ownership of the display list, so we replace it with an empty one
        self.layout_results
            .get_mut(&dom_id)
            .map(|result| std::mem::replace(&mut result.display_list, DisplayList::default()))
            .ok_or(solver3::LayoutError::InvalidTree)
    }
    /// Clear all caches (useful for testing or when switching documents).
    pub fn clear_caches(&mut self) {
        self.layout_cache = Solver3LayoutCache {
            tree: None,
            calculated_positions: Vec::new(),
            viewport: None,
            scroll_ids: HashMap::new(),
            scroll_id_to_node_id: HashMap::new(),
            counters: HashMap::new(),
            float_cache: HashMap::new(),
            cache_map: Default::default(),
            previous_positions: Vec::new(),
                cached_display_list: None,
                prev_dom_ptr: 0,
                prev_viewport: LogicalRect::zero(),
        };
        self.text_cache = TextLayoutCache::new();
        self.layout_results.clear();
        self.scroll_manager = ScrollManager::new();
    }
    /// Set scroll position for a node
    pub fn set_scroll_position(&mut self, dom_id: DomId, node_id: NodeId, scroll: ScrollPosition) {
        // Convert ScrollPosition to the internal representation
        #[cfg(feature = "std")]
        let now = Instant::System(std::time::Instant::now().into());
        #[cfg(not(feature = "std"))]
        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
        self.scroll_manager.update_node_bounds(
            dom_id,
            node_id,
            scroll.parent_rect,
            scroll.children_rect,
            now.clone(),
        );
        self.scroll_manager
            .set_scroll_position(dom_id, node_id, scroll.children_rect.origin, now);
    }
    /// Get scroll position for a node
    pub fn get_scroll_position(&self, dom_id: DomId, node_id: NodeId) -> Option<ScrollPosition> {
        let states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
        states.get(&node_id).cloned()
    }
    /// Set selection state for a DOM (no-op: selection_manager removed, multi_cursor handles this)
    pub fn set_selection(&mut self, _dom_id: DomId, _selection: SelectionState) {
        // no-op: selection_manager removed
    }
    /// Get selection state for a DOM (always None: selection_manager removed)
    pub fn get_selection(&self, _dom_id: DomId) -> Option<&SelectionState> {
        None
    }
    /// Invoke a VirtualView callback and perform layout on the returned DOM.
    ///
    /// This is the entry point that looks up the necessary `VirtualViewNode` data before
    /// delegating to the core implementation logic.
    /// Invoke a VirtualView callback for a node. Returns the child DomId if the
    /// callback was invoked and the child DOM was laid out.
    ///
    /// This calls the VirtualView's own RefAny callback (NOT the main layout() callback),
    /// swaps the child StyledDom, and re-layouts only the VirtualView sub-tree.
    pub fn invoke_virtual_view_callback(
        &mut self,
        parent_dom_id: DomId,
        node_id: NodeId,
        bounds: LogicalRect,
        window_state: &FullWindowState,
        renderer_resources: &RendererResources,
        system_callbacks: &ExternalSystemCallbacks,
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
    ) -> Option<DomId> {
        self.invoke_virtual_view_callback_with_dom(
            parent_dom_id, node_id, bounds, None,
            window_state, renderer_resources, system_callbacks, debug_messages,
        )
    }
    /// Invoke a VirtualView callback. If `styled_dom_override` is provided, use it
    /// instead of reading from `self.layout_results` (needed during initial
    /// layout when layout_results isn't populated yet).
308
    fn invoke_virtual_view_callback_with_dom(
308
        &mut self,
308
        parent_dom_id: DomId,
308
        node_id: NodeId,
308
        bounds: LogicalRect,
308
        styled_dom_override: Option<&StyledDom>,
308
        window_state: &FullWindowState,
308
        renderer_resources: &RendererResources,
308
        system_callbacks: &ExternalSystemCallbacks,
308
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
308
    ) -> Option<DomId> {
308
        if let Some(msgs) = debug_messages {
308
            msgs.push(LayoutDebugMessage::info(format!(
308
                "invoke_virtual_view_callback called for node {:?}",
308
                node_id
308
            )));
308
        }
        // Use the override styled_dom if provided, otherwise read from layout_results
308
        let virtual_view_node = if let Some(styled_dom) = styled_dom_override {
308
            let node_data_container = styled_dom.node_data.as_container();
308
            let node_data = node_data_container.get(node_id)?;
308
            node_data.get_virtual_view_node_ref()?.clone()
        } else {
            let layout_result = self.layout_results.get(&parent_dom_id)?;
            if let Some(msgs) = debug_messages {
                msgs.push(LayoutDebugMessage::info(format!(
                    "Got layout result for parent DOM {:?}",
                    parent_dom_id
                )));
            }
            let node_data_container = layout_result.styled_dom.node_data.as_container();
            let node_data = node_data_container.get(node_id)?;
            match node_data.get_virtual_view_node_ref() {
                Some(vv) => vv.clone(),
                None => {
                    if let Some(msgs) = debug_messages {
                        msgs.push(LayoutDebugMessage::info(format!(
                            "Node is NOT VirtualView, type = {:?}",
                            node_data.get_node_type()
                        )));
                    }
                    return None;
                }
            }
        };
308
        if let Some(msgs) = debug_messages {
308
            msgs.push(LayoutDebugMessage::info("Node is VirtualView type".to_string()));
308
        }
        // Call the actual implementation with all necessary data
308
        self.invoke_virtual_view_callback_impl(
308
            parent_dom_id,
308
            node_id,
308
            &virtual_view_node,
308
            bounds,
308
            window_state,
308
            renderer_resources,
308
            system_callbacks,
308
            debug_messages,
        )
308
    }
    /// Core implementation for invoking a VirtualView callback and managing the recursive layout.
    ///
    /// This method implements the 5 conditional re-invocation rules by coordinating
    /// with the `VirtualViewManager` and `ScrollManager`.
    ///
    /// # Returns
    ///
    /// `Some(child_dom_id)` if the callback was invoked and the child DOM was laid out.
    /// The parent's display list generator will then use this ID to reference the child's
    /// display list. Returns `None` if the callback was not invoked.
308
    fn invoke_virtual_view_callback_impl(
308
        &mut self,
308
        parent_dom_id: DomId,
308
        node_id: NodeId,
308
        virtual_view_node: &azul_core::dom::VirtualViewNode,
308
        bounds: LogicalRect,
308
        window_state: &FullWindowState,
308
        renderer_resources: &RendererResources,
308
        system_callbacks: &ExternalSystemCallbacks,
308
        debug_messages: &mut Option<Vec<LayoutDebugMessage>>,
308
    ) -> Option<DomId> {
        // Get current time from system callbacks for state updates
308
        let now = (system_callbacks.get_system_time_fn.cb)();
        // Update node bounds in the scroll manager. This is necessary for the VirtualViewManager
        // to correctly detect edge scroll conditions.
308
        self.scroll_manager.update_node_bounds(
308
            parent_dom_id,
308
            node_id,
308
            bounds,
308
            LogicalRect::new(LogicalPosition::zero(), bounds.size), // Initial content_rect
308
            now.clone(),
        );
        // Check with the VirtualViewManager to see if re-invocation is necessary.
        // It handles all 5 conditional rules.
308
        let reason = match self.virtual_view_manager.check_reinvoke(
308
            parent_dom_id,
308
            node_id,
308
            &self.scroll_manager,
308
            bounds,
308
        ) {
308
            Some(r) => r,
            None => {
                // No re-invocation needed, but we still need the child_dom_id for the display list.
                return self
                    .virtual_view_manager
                    .get_nested_dom_id(parent_dom_id, node_id);
            }
        };
308
        if let Some(msgs) = debug_messages {
308
            msgs.push(LayoutDebugMessage::info(format!(
308
                "VirtualView ({:?}, {:?}) - Reason: {:?}",
308
                parent_dom_id, node_id, reason
308
            )));
308
        }
308
        let scroll_offset = self
308
            .scroll_manager
308
            .get_current_offset(parent_dom_id, node_id)
308
            .unwrap_or_default();
308
        let hidpi_factor = window_state.size.get_hidpi_factor();
        // Create VirtualViewCallbackInfo with the most up-to-date state
308
        let mut callback_info = azul_core::callbacks::VirtualViewCallbackInfo::new(
308
            reason,
308
            &self.font_manager.fc_cache,
308
            &self.image_cache,
308
            window_state.theme,
308
            azul_core::callbacks::HidpiAdjustedBounds {
308
                logical_size: bounds.size,
308
                hidpi_factor,
308
            },
308
            bounds.size,
308
            scroll_offset,
308
            bounds.size,
308
            LogicalPosition::zero(),
        );
        // Clone the user data for the callback
308
        let callback_data = virtual_view_node.refany.clone();
        // Invoke the user's VirtualView callback
308
        let callback_return = (virtual_view_node.callback.cb)(callback_data, callback_info);
        // Mark the VirtualView as invoked to prevent duplicate InitialRender calls
308
        self.virtual_view_manager
308
            .mark_invoked(parent_dom_id, node_id, reason);
        // Get the child Dom from the callback's return value, then convert to StyledDom
308
        let mut child_styled_dom = match callback_return.dom {
308
            azul_core::dom::OptionDom::Some(dom) => {
                // Convert Dom → StyledDom (single deferred cascade pass)
308
                azul_core::styled_dom::StyledDom::create_from_dom(dom)
            },
            azul_core::dom::OptionDom::None => {
                // If the callback returns None, it's an optimization hint.
                if reason == VirtualViewCallbackReason::InitialRender {
                    // For the very first render, create an empty div as a fallback.
                    let mut empty_dom = Dom::create_div();
                    let empty_css = Css::empty();
                    azul_core::styled_dom::StyledDom::create(&mut empty_dom, empty_css)
                } else {
                    // For subsequent calls, returning None means "keep the old DOM".
                    // We just need to update the scroll info and return the existing child ID.
                    self.virtual_view_manager.update_virtual_view_info(
                        parent_dom_id,
                        node_id,
                        callback_return.scroll_size,
                        callback_return.virtual_scroll_size,
                    );
                    // Propagate virtual scroll bounds to ScrollManager
                    self.scroll_manager.update_virtual_scroll_bounds(
                        parent_dom_id,
                        node_id,
                        callback_return.virtual_scroll_size,
                        Some(callback_return.scroll_offset),
                    );
                    return self
                        .virtual_view_manager
                        .get_nested_dom_id(parent_dom_id, node_id);
                }
            }
        };
        // Get or create a unique DomId for the VirtualView's content
308
        let child_dom_id = self
308
            .virtual_view_manager
308
            .get_or_create_nested_dom_id(parent_dom_id, node_id);
308
        child_styled_dom.dom_id = child_dom_id;
        // Update the VirtualViewManager with the new scroll sizes from the callback
308
        self.virtual_view_manager.update_virtual_view_info(
308
            parent_dom_id,
308
            node_id,
308
            callback_return.scroll_size,
308
            callback_return.virtual_scroll_size,
        );
        // Propagate virtual scroll bounds to ScrollManager
308
        self.scroll_manager.update_virtual_scroll_bounds(
308
            parent_dom_id,
308
            node_id,
308
            callback_return.virtual_scroll_size,
308
            Some(callback_return.scroll_offset),
        );
        // **RECURSIVE LAYOUT STEP**
        // Perform a full layout pass on the child DOM. This will recursively handle
        // any VirtualViews within this VirtualView. Ownership of the child DOM
        // is transferred into `layout_results`.
308
        self.layout_dom_recursive(
308
            child_styled_dom,
308
            window_state,
308
            renderer_resources,
308
            system_callbacks,
308
            debug_messages,
        )
308
        .ok()?;
308
        Some(child_dom_id)
308
    }
    // Query methods for callbacks
    /// Get the size of a laid-out node
220
    pub fn get_node_size(&self, node_id: DomNodeId) -> Option<LogicalSize> {
220
        let layout_result = match self.layout_results.get(&node_id.dom) {
220
            Some(r) => r,
            None => { return None; }
        };
220
        let nid = node_id.node.into_crate_internal()?;
        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
220
        let layout_indices = match layout_result.layout_tree.dom_to_layout.get(&nid) {
220
            Some(x) => x,
            None => { return None; }
        };
220
        let layout_index = *layout_indices.first()?;
220
        let layout_node = match layout_result.layout_tree.get(layout_index) {
220
            Some(n) => n,
            None => { return None; }
        };
220
        match layout_node.used_size {
220
            Some(s) => { Some(s) }
            None => { None }
        }
220
    }
    /// Get the position of a laid-out node
220
    pub fn get_node_position(&self, node_id: DomNodeId) -> Option<LogicalPosition> {
220
        let layout_result = match self.layout_results.get(&node_id.dom) {
220
            Some(r) => r,
            None => { return None; }
        };
220
        let nid = node_id.node.into_crate_internal()?;
        // Use dom_to_layout mapping since layout tree indices differ from DOM indices
220
        let layout_indices = match layout_result.layout_tree.dom_to_layout.get(&nid) {
220
            Some(x) => x,
            None => { return None; }
        };
220
        let layout_index = *layout_indices.first()?;
220
        let position = match layout_result.calculated_positions.get(layout_index) {
220
            Some(p) => p,
            None => { return None; }
        };
220
        Some(*position)
220
    }
    /// Get the hit test bounds of a node from the display list
    ///
    /// This is more reliable than get_node_position + get_node_size because
    /// the display list always contains the correct final rendered positions,
    /// including for nodes that may not have entries in calculated_positions.
    pub fn get_node_hit_test_bounds(&self, node_id: DomNodeId) -> Option<LogicalRect> {
        use crate::solver3::display_list::DisplayListItem;
        let layout_result = self.layout_results.get(&node_id.dom)?;
        let nid = node_id.node.into_crate_internal()?;
        // Look up tag_id from the authoritative tag_ids_to_node_ids mapping
        let nid_encoded = NodeHierarchyItemId::from_crate_internal(Some(nid));
        let tag_id = layout_result.styled_dom.tag_ids_to_node_ids.iter()
            .find(|m| m.node_id == nid_encoded)?
            .tag_id
            .inner;
        // Search the display list for a HitTestArea with matching tag
        // Note: tag is now (u64, u16) tuple where tag.0 is the TagId.inner
        for item in &layout_result.display_list.items {
            if let DisplayListItem::HitTestArea { bounds, tag } = item {
                if tag.0 == tag_id && bounds.0.size.width > 0.0 && bounds.0.size.height > 0.0 {
                    return Some(bounds.0);
                }
            }
        }
        None
    }
    /// Get the parent of a node
    pub fn get_parent(&self, node_id: DomNodeId) -> Option<DomNodeId> {
        let layout_result = self.layout_results.get(&node_id.dom)?;
        let nid = node_id.node.into_crate_internal()?;
        let parent_id = layout_result
            .styled_dom
            .node_hierarchy
            .as_container()
            .get(nid)?
            .parent_id()?;
        Some(DomNodeId {
            dom: node_id.dom,
            node: NodeHierarchyItemId::from_crate_internal(Some(parent_id)),
        })
    }
    /// Get the first child of a node
132
    pub fn get_first_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
132
        let layout_result = self.layout_results.get(&node_id.dom)?;
132
        let nid = node_id.node.into_crate_internal()?;
132
        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
132
        let hierarchy_item = node_hierarchy.get(nid)?;
132
        let first_child_id = hierarchy_item.first_child_id(nid)?;
132
        Some(DomNodeId {
132
            dom: node_id.dom,
132
            node: NodeHierarchyItemId::from_crate_internal(Some(first_child_id)),
132
        })
132
    }
    /// Get the next sibling of a node
44
    pub fn get_next_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
44
        let layout_result = self.layout_results.get(&node_id.dom)?;
44
        let nid = node_id.node.into_crate_internal()?;
44
        let next_sibling_id = layout_result
44
            .styled_dom
44
            .node_hierarchy
44
            .as_container()
44
            .get(nid)?
44
            .next_sibling_id()?;
44
        Some(DomNodeId {
44
            dom: node_id.dom,
44
            node: NodeHierarchyItemId::from_crate_internal(Some(next_sibling_id)),
44
        })
44
    }
    /// Get the previous sibling of a node
    pub fn get_previous_sibling(&self, node_id: DomNodeId) -> Option<DomNodeId> {
        let layout_result = self.layout_results.get(&node_id.dom)?;
        let nid = node_id.node.into_crate_internal()?;
        let prev_sibling_id = layout_result
            .styled_dom
            .node_hierarchy
            .as_container()
            .get(nid)?
            .previous_sibling_id()?;
        Some(DomNodeId {
            dom: node_id.dom,
            node: NodeHierarchyItemId::from_crate_internal(Some(prev_sibling_id)),
        })
    }
    /// Get the last child of a node
    pub fn get_last_child(&self, node_id: DomNodeId) -> Option<DomNodeId> {
        let layout_result = self.layout_results.get(&node_id.dom)?;
        let nid = node_id.node.into_crate_internal()?;
        let last_child_id = layout_result
            .styled_dom
            .node_hierarchy
            .as_container()
            .get(nid)?
            .last_child_id()?;
        Some(DomNodeId {
            dom: node_id.dom,
            node: NodeHierarchyItemId::from_crate_internal(Some(last_child_id)),
        })
    }
    /// Scan all fonts referenced in the current display lists (for resource GC).
    ///
    /// Iterates every `Text` and `TextLayout` item in each DOM's display list
    /// and collects the deterministic `FontKey` derived from the font hash.
    /// Callers can diff the result against `renderer_resources.currently_registered_fonts`
    /// to find fonts that are no longer used.
    pub fn scan_used_fonts(&self) -> BTreeSet<FontKey> {
        use crate::solver3::display_list::DisplayListItem;
        let mut fonts = BTreeSet::new();
        for (_dom_id, layout_result) in &self.layout_results {
            for item in &layout_result.display_list.items {
                let hash = match item {
                    DisplayListItem::Text { font_hash, .. } => font_hash.font_hash,
                    DisplayListItem::TextLayout { font_hash, .. } => font_hash.font_hash,
                    _ => continue,
                };
                // Deterministic FontKey from hash (same algorithm as wr_translate2)
                let ns = (hash >> 32) as u32;
                let ns = if ns == 0 { 1 } else { ns };
                fonts.insert(FontKey {
                    namespace: IdNamespace(ns),
                    key: hash,
                });
            }
        }
        fonts
    }
    /// Scan all images referenced in the current display lists (for resource GC).
    ///
    /// Iterates every `Image` and `PushImageMaskClip` item and collects
    /// their `ImageRefHash`.  Callers can diff the result against the
    /// currently loaded images to find unused ones.
    pub fn scan_used_images(&self, _css_image_cache: &ImageCache) -> BTreeSet<ImageRefHash> {
        use crate::solver3::display_list::DisplayListItem;
        let mut images = BTreeSet::new();
        for (_dom_id, layout_result) in &self.layout_results {
            for item in &layout_result.display_list.items {
                match item {
                    DisplayListItem::Image { image, .. } => {
                        images.insert(image.get_hash());
                    }
                    DisplayListItem::PushImageMaskClip { mask_image, .. } => {
                        images.insert(mask_image.get_hash());
                    }
                    _ => {}
                }
            }
        }
        images
    }
    /// Helper function to convert ScrollStates to nested format for CallbackInfo
    fn get_nested_scroll_states(
        &self,
        dom_id: DomId,
    ) -> BTreeMap<DomId, BTreeMap<NodeHierarchyItemId, ScrollPosition>> {
        let mut nested = BTreeMap::new();
        let scroll_states = self.scroll_manager.get_scroll_states_for_dom(dom_id);
        let mut inner = BTreeMap::new();
        for (node_id, scroll_pos) in scroll_states {
            inner.insert(
                NodeHierarchyItemId::from_crate_internal(Some(node_id)),
                scroll_pos,
            );
        }
        nested.insert(dom_id, inner);
        nested
    }
    // Scroll Into View
    /// Scroll a DOM node into view
    ///
    /// This is the main API for scrolling elements into view. It handles:
    /// - Finding scroll ancestors
    /// - Calculating scroll deltas
    /// - Applying scroll animations
    ///
    /// # Arguments
    ///
    /// * `node_id` - The DOM node to scroll into view
    /// * `options` - Scroll alignment and animation options
    /// * `now` - Current timestamp for animations
    ///
    /// # Returns
    ///
    /// A vector of scroll adjustments that were applied
    pub fn scroll_node_into_view(
        &mut self,
        node_id: DomNodeId,
        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
        now: azul_core::task::Instant,
    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
        crate::managers::scroll_into_view::scroll_node_into_view(
            node_id,
            &self.layout_results,
            &mut self.scroll_manager,
            options,
            now,
        )
    }
    /// Scroll a text cursor into view
    ///
    /// Used when the cursor moves within a contenteditable element.
    /// The cursor rect should be in node-local coordinates.
    pub fn scroll_cursor_into_view(
        &mut self,
        cursor_rect: LogicalRect,
        node_id: DomNodeId,
        options: crate::managers::scroll_into_view::ScrollIntoViewOptions,
        now: azul_core::task::Instant,
    ) -> Vec<crate::managers::scroll_into_view::ScrollAdjustment> {
        crate::managers::scroll_into_view::scroll_cursor_into_view(
            cursor_rect,
            node_id,
            &self.layout_results,
            &mut self.scroll_manager,
            options,
            now,
        )
    }
    // Timer Management
    /// Add a timer to this window
5
    pub fn add_timer(&mut self, timer_id: TimerId, timer: Timer) {
5
        self.timers.insert(timer_id, timer);
5
    }
    /// Remove a timer from this window
2
    pub fn remove_timer(&mut self, timer_id: &TimerId) -> Option<Timer> {
2
        self.timers.remove(timer_id)
2
    }
    /// Get a reference to a timer
5
    pub fn get_timer(&self, timer_id: &TimerId) -> Option<&Timer> {
5
        self.timers.get(timer_id)
5
    }
    /// Get a mutable reference to a timer
1
    pub fn get_timer_mut(&mut self, timer_id: &TimerId) -> Option<&mut Timer> {
1
        self.timers.get_mut(timer_id)
1
    }
    /// Get all timer IDs
4
    pub fn get_timer_ids(&self) -> TimerIdVec {
4
        self.timers.keys().copied().collect::<Vec<_>>().into()
4
    }
    /// Tick all timers (called once per frame)
    /// Returns a list of timer IDs that are ready to run
    pub fn tick_timers(&mut self, current_time: azul_core::task::Instant) -> Vec<TimerId> {
        let mut ready_timers = Vec::new();
        for (timer_id, timer) in &mut self.timers {
            // Check if timer is ready to run
            // This logic should match the timer's internal state
            // For now, we'll just collect all timer IDs
            // The actual readiness check will be done when invoking
            ready_timers.push(*timer_id);
        }
        ready_timers
    }
    /// Calculate milliseconds until the next timer needs to fire.
    ///
    /// Returns `None` if there are no timers, meaning the caller can block indefinitely.
    /// Returns `Some(0)` if a timer is already overdue.
    /// Otherwise returns the minimum time in milliseconds until any timer fires.
    ///
    /// This is used by Linux (X11/Wayland) to set an efficient poll/select timeout
    /// instead of always polling every 16ms.
    pub fn time_until_next_timer_ms(
        &self,
        get_system_time_fn: &azul_core::task::GetSystemTimeCallback,
    ) -> Option<u64> {
        if self.timers.is_empty() {
            return None; // No timers - can block indefinitely
        }
        let now = (get_system_time_fn.cb)();
        let mut min_ms: Option<u64> = None;
        for timer in self.timers.values() {
            let next_run = timer.instant_of_next_run();
            // Calculate time difference in milliseconds
            let ms_until = if next_run < now {
                0 // Timer is overdue
            } else {
                duration_to_millis(next_run.duration_since(&now))
            };
            min_ms = Some(match min_ms {
                Some(current_min) => current_min.min(ms_until),
                None => ms_until,
            });
        }
        min_ms
    }
    // Thread Management
    /// Add a thread to this window
    pub fn add_thread(&mut self, thread_id: ThreadId, thread: Thread) {
        self.threads.insert(thread_id, thread);
    }
    /// Remove a thread from this window
    pub fn remove_thread(&mut self, thread_id: &ThreadId) -> Option<Thread> {
        self.threads.remove(thread_id)
    }
    /// Get a reference to a thread
    pub fn get_thread(&self, thread_id: &ThreadId) -> Option<&Thread> {
        self.threads.get(thread_id)
    }
    /// Get a mutable reference to a thread
    pub fn get_thread_mut(&mut self, thread_id: &ThreadId) -> Option<&mut Thread> {
        self.threads.get_mut(thread_id)
    }
    /// Get all thread IDs
    pub fn get_thread_ids(&self) -> ThreadIdVec {
        self.threads.keys().copied().collect::<Vec<_>>().into()
    }
    // Cursor Blinking Timer
    /// Create the cursor blink timer
    ///
    /// This timer toggles cursor visibility at ~530ms intervals.
    /// It checks if enough time has passed since the last user input before blinking,
    /// to avoid blinking while the user is actively typing.
    pub fn create_cursor_blink_timer(&self, _window_state: &FullWindowState) -> crate::timer::Timer {
        use azul_core::task::{Duration, SystemTimeDiff};
        use crate::timer::{Timer, TimerCallback};
        use azul_core::refany::RefAny;
        let interval_ms = crate::managers::text_edit::CURSOR_BLINK_INTERVAL_MS;
        // Create a RefAny with a unit type - the timer callback doesn't need any data
        // The actual cursor state is in LayoutWindow.text_edit_manager.multi_cursor / blink
        let refany = RefAny::new(());
        Timer {
            refany,
            node_id: None.into(),
            created: azul_core::task::Instant::now(),
            run_count: 0,
            last_run: azul_core::task::OptionInstant::None,
            delay: azul_core::task::OptionDuration::None,
            interval: azul_core::task::OptionDuration::Some(Duration::System(SystemTimeDiff::from_millis(interval_ms))),
            timeout: azul_core::task::OptionDuration::None,
            callback: TimerCallback::create(cursor_blink_timer_callback),
        }
    }
    // Tooltip-Delay Timer
    /// Create a one-shot tooltip-delay timer.
    ///
    /// Fires exactly once after `hover_time_ms` elapsed. On expiry the callback
    /// looks up the currently-hovered node's `title` / `alt` / `aria-label`
    /// attribute and emits a `ShowTooltip` CallbackChange, then terminates.
    pub fn create_tooltip_delay_timer(&self, hover_time_ms: u32) -> crate::timer::Timer {
        use azul_core::task::{Duration, SystemTimeDiff};
        use crate::timer::{Timer, TimerCallback};
        use azul_core::refany::RefAny;
        Timer {
            refany: RefAny::new(()),
            node_id: None.into(),
            created: azul_core::task::Instant::now(),
            run_count: 0,
            last_run: azul_core::task::OptionInstant::None,
            delay: azul_core::task::OptionDuration::Some(Duration::System(
                SystemTimeDiff::from_millis(hover_time_ms as u64),
            )),
            interval: azul_core::task::OptionDuration::None,
            timeout: azul_core::task::OptionDuration::None,
            callback: TimerCallback::create(tooltip_delay_timer_callback),
        }
    }
    /// Determine what tooltip-timer action the shell should take given a hover
    /// transition.
    ///
    /// The platform event loop calls this once per event-dispatch cycle (after
    /// hit-testing has updated `hover_manager`). It compares the current and
    /// previous deepest hovered nodes and returns:
    ///
    /// - `Start` if the user just hovered onto a node that has a tooltip
    ///   source (`title` / `alt` / `aria-label`) — the shell should (re)start
    ///   `TOOLTIP_DELAY_TIMER_ID` with the returned Timer.
    /// - `Stop` if the hover moved off a tooltip-bearing node (or left the
    ///   window) — the shell should stop `TOOLTIP_DELAY_TIMER_ID` and hide
    ///   any currently-visible tooltip.
    /// - `NoChange` if the hovered node hasn't changed between frames.
    pub fn handle_hover_change_for_tooltip(&self, hover_time_ms: u32) -> TooltipTimerAction {
        let current_hover = self.hover_manager.current_hover_node();
        let previous_hover = self.hover_manager.previous_hover_node();
        if current_hover == previous_hover {
            return TooltipTimerAction::NoChange;
        }
        let dom_id = DomId { inner: 0 };
        let Some(layout_result) = self.layout_results.get(&dom_id) else {
            return TooltipTimerAction::Stop;
        };
        let node_data_cont = layout_result.styled_dom.node_data.as_container();
        let node_has_tooltip = |node_id: NodeId| -> bool {
            node_data_cont
                .get(node_id)
                .map(|n| n.get_accessible_label().is_some())
                .unwrap_or(false)
        };
        match current_hover {
            Some(node) if node_has_tooltip(node) => {
                TooltipTimerAction::Start(self.create_tooltip_delay_timer(hover_time_ms))
            }
            _ => TooltipTimerAction::Stop,
        }
    }
    /// Check if a node is contenteditable (internal version using NodeId)
    fn is_node_contenteditable_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
        use crate::solver3::getters::is_node_contenteditable;
        let Some(layout_result) = self.layout_results.get(&dom_id) else {
            return false;
        };
        is_node_contenteditable(&layout_result.styled_dom, node_id)
    }
    /// Check if a node is contenteditable with W3C-conformant inheritance.
    ///
    /// This traverses up the DOM tree to check if the node or any ancestor
    /// has `contenteditable="true"` set, respecting `contenteditable="false"`
    /// to stop inheritance.
    fn is_node_contenteditable_inherited_internal(&self, dom_id: DomId, node_id: NodeId) -> bool {
        use crate::solver3::getters::is_node_contenteditable_inherited;
        let Some(layout_result) = self.layout_results.get(&dom_id) else {
            return false;
        };
        is_node_contenteditable_inherited(&layout_result.styled_dom, node_id)
    }
    /// Handle focus change for cursor blink timer management (W3C "flag and defer" pattern)
    ///
    /// This method implements the W3C focus/selection model:
    /// 1. Focus change is handled immediately (timer start/stop)
    /// 2. Cursor initialization is DEFERRED until after layout (via flag)
    ///
    /// The cursor is NOT initialized here because text layout may not be available
    /// during focus event handling. Instead, we set a flag that is consumed by
    /// `finalize_pending_focus_changes()` after the layout pass.
    ///
    /// # Parameters
    ///
    /// * `new_focus` - The newly focused node (None if focus is being cleared)
    /// * `current_window_state` - Current window state for timer creation
    ///
    /// # Returns
    ///
    /// A `CursorBlinkTimerAction` indicating what timer action the platform
    /// layer should take.
    pub fn handle_focus_change_for_cursor_blink(
        &mut self,
        new_focus: Option<azul_core::dom::DomNodeId>,
        current_window_state: &FullWindowState,
    ) -> CursorBlinkTimerAction {
        // Check if the new focus is on a contenteditable element
        // Use the inherited check for W3C conformance
        let contenteditable_info = match new_focus {
            Some(focus_node) => {
                if let Some(node_id) = focus_node.node.into_crate_internal() {
                    // Check if this node or any ancestor is contenteditable
                    if self.is_node_contenteditable_inherited_internal(focus_node.dom, node_id) {
                        // Find the text node where the cursor should be placed
                        let text_node_id = self.find_last_text_child(focus_node.dom, node_id)
                            .unwrap_or(node_id);
                        Some((focus_node.dom, node_id, text_node_id))
                    } else {
                        None
                    }
                } else {
                    None
                }
            }
            None => None,
        };
        // Determine the action based on current state and new focus
        let timer_was_active = self.text_edit_manager.blink.is_blink_timer_active();
        if let Some((dom_id, container_node_id, text_node_id)) = contenteditable_info {
            // W3C "flag and defer" pattern:
            // Set flag for cursor initialization AFTER layout pass
            self.focus_manager.set_pending_contenteditable_focus(
                dom_id,
                container_node_id,
                text_node_id,
            );
            // Make cursor visible and record current time (even before actual initialization)
            let now = azul_core::task::Instant::now();
            self.text_edit_manager.blink.reset_blink_on_input(now);
            self.text_edit_manager.blink.set_blink_timer_active(true);
            if !timer_was_active {
                // Need to start the timer
                let timer = self.create_cursor_blink_timer(current_window_state);
                return CursorBlinkTimerAction::Start(timer);
            } else {
                // Timer already active, just continue
                return CursorBlinkTimerAction::NoChange;
            }
        } else {
            // Focus is moving away from contenteditable or being cleared
            // Clear the cursor AND the pending focus flag
            self.text_edit_manager.clear_editing();
            self.focus_manager.clear_pending_contenteditable_focus();
            if timer_was_active {
                // Need to stop the timer
                self.text_edit_manager.blink.set_blink_timer_active(false);
                return CursorBlinkTimerAction::Stop;
            } else {
                return CursorBlinkTimerAction::NoChange;
            }
        }
    }
    /// Finalize pending focus changes after layout pass (W3C "flag and defer" pattern)
    ///
    /// This method should be called AFTER the layout pass completes. It checks if
    /// there's a pending contenteditable focus and initializes the cursor now that
    /// text layout information is available.
    ///
    /// # W3C Conformance
    ///
    /// In the W3C model:
    /// 1. Focus event fires during event handling (layout may not be ready)
    /// 2. Selection/cursor placement happens after layout is computed
    /// 3. The cursor is drawn at the position specified by the Selection
    ///
    /// This function implements step 2+3 by:
    /// - Checking the `cursor_needs_initialization` flag
    /// - Getting the (now available) text layout
    /// - Initializing the cursor at the correct position
    ///
    /// # Returns
    ///
    /// `true` if cursor was initialized, `false` if no pending focus or initialization failed.
    pub fn finalize_pending_focus_changes(&mut self) -> bool {
        // Take the pending focus info (this clears the flag)
        let pending = match self.focus_manager.take_pending_contenteditable_focus() {
            Some(p) => p,
            None => return false,
        };
        // Bug B+H fix: If process_mouse_click_for_selection already positioned
        // the cursor in this node during the same event cycle, don't override it
        // with initialize_cursor_at_end. The click handler sets cursor on the IFC
        // root node (may differ from text_node_id), so check both.
        if self.text_edit_manager.multi_cursor.as_ref().map(|mc| mc.node_id.dom == pending.dom_id && mc.node_id.node.into_crate_internal() == Some(pending.text_node_id)).unwrap_or(false)
            || self.text_edit_manager.multi_cursor.as_ref().map(|mc| mc.node_id.dom == pending.dom_id && mc.node_id.node.into_crate_internal() == Some(pending.container_node_id)).unwrap_or(false)
        {
            return true;
        }
        // Now we can safely get the text layout (layout pass has completed)
        let text_layout = self.get_inline_layout_for_node(pending.dom_id, pending.text_node_id).cloned();
        // Initialize cursor at end of text
        // Get the last cluster cursor from text layout
        let cursor = text_layout.as_ref()
            .and_then(|layout| {
                layout.items.iter().rev()
                    .find_map(|item| if let crate::text3::cache::ShapedItem::Cluster(c) = &item.item {
                        Some(azul_core::selection::TextCursor {
                            cluster_id: c.source_cluster_id,
                            affinity: azul_core::selection::CursorAffinity::Trailing,
                        })
                    } else { None })
            })
            .unwrap_or(azul_core::selection::TextCursor {
                cluster_id: azul_core::selection::GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
                affinity: azul_core::selection::CursorAffinity::Trailing,
            });
        self.text_edit_manager.initialize_editing(cursor, pending.dom_id, pending.text_node_id, 0);
        true
    }
    /// Helper: Get inline layout for a node
    ///
    /// For text nodes that participate in an IFC, the inline layout is stored
    /// on the IFC root node (the block container), not on the text node itself.
    /// This method handles both cases:
    /// 1. The node has its own `inline_layout_result` (IFC root)
    /// 2. The node has `ifc_membership` pointing to the IFC root
    ///
    /// This is a thin wrapper around `LayoutTree::get_inline_layout_for_node`.
    pub fn get_inline_layout_for_node(
        &self,
        dom_id: DomId,
        node_id: NodeId,
    ) -> Option<&Arc<UnifiedLayout>> {
        let layout_result = self.layout_results.get(&dom_id)?;
        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
        let layout_index = *layout_indices.first()?;
        // Use the centralized LayoutTree method that handles IFC membership
        layout_result.layout_tree.get_inline_layout_for_node(layout_index)
    }
    /// Single dispatch: (direction, step) → UnifiedLayout cursor movement.
    fn resolve_step_static(
        layout: &crate::text3::cache::UnifiedLayout,
        cursor: &TextCursor,
        direction: azul_core::events::SelectionDirection,
        step: azul_core::events::SelectionStep,
    ) -> TextCursor {
        use azul_core::events::{SelectionDirection as D, SelectionStep as S};
        match (direction, step) {
            (D::Backward, S::Character) => layout.move_cursor_left(*cursor, &mut None),
            (D::Forward, S::Character) => layout.move_cursor_right(*cursor, &mut None),
            (D::Backward, S::Word) => layout.move_cursor_to_prev_word(*cursor, &mut None),
            (D::Forward, S::Word) => layout.move_cursor_to_next_word(*cursor, &mut None),
            (D::Backward, S::VisualLine) => layout.move_cursor_up(*cursor, &mut None, &mut None),
            (D::Forward, S::VisualLine) => layout.move_cursor_down(*cursor, &mut None, &mut None),
            (D::Backward, S::Line) => layout.move_cursor_to_line_start(*cursor, &mut None),
            (D::Forward, S::Line) => layout.move_cursor_to_line_end(*cursor, &mut None),
            (D::Backward, S::Document) => layout.get_first_cluster_cursor().unwrap_or(*cursor),
            (D::Forward, S::Document) => layout.get_last_cluster_cursor().unwrap_or(*cursor),
        }
    }
    /// Apply a unified selection operation (navigation, extend, or delete).
    ///
    /// Single entry point that replaces the separate ArrowKeyNavigation and
    /// DeleteTextSelection handlers, as well as handle_cursor_movement and
    /// handle_multi_cursor_movement.
    pub fn apply_selection_op(
        &mut self,
        target: azul_core::dom::DomNodeId,
        op: &azul_core::events::SelectionOp,
    ) -> bool {
        use azul_core::events::{SelectionMode, SelectionStep, SelectionDirection};
        let dom_id = target.dom;
        let node_id = match target.node.into_crate_internal() {
            Some(id) => id,
            None => return false,
        };
        let layout = match self.get_inline_layout_for_node(dom_id, node_id) {
            Some(l) => l.clone(),
            None => return false,
        };
        match op.mode {
            SelectionMode::Move | SelectionMode::Extend => {
                let extend = matches!(op.mode, SelectionMode::Extend);
                if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
                    for _ in 0..op.repeat.max(1) {
                        mc.move_all_cursors(extend, |c| {
                            Self::resolve_step_static(&layout, c, op.direction, op.step)
                        });
                    }
                }
                self.regenerate_display_list_for_dom(dom_id);
                true
            }
            SelectionMode::Delete => {
                // Step 1: if step > Character, expand cursors to ranges first
                if !matches!(op.step, SelectionStep::Character) {
                    if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
                        for _ in 0..op.repeat.max(1) {
                            mc.move_all_cursors(true, |c| {
                                Self::resolve_step_static(&layout, c, op.direction, op.step)
                            });
                        }
                    }
                }
                // Step 2: delete the expanded ranges (or single char for Character step)
                let forward = matches!(op.direction, SelectionDirection::Forward);
                self.delete_selection(target, forward).is_some()
            }
        }
    }
    /// Helper: Move cursor using a movement function and return the new cursor if it changed
    pub fn move_cursor_in_node<F>(
        &self,
        dom_id: DomId,
        node_id: NodeId,
        movement_fn: F,
    ) -> Option<TextCursor>
    where
        F: FnOnce(&UnifiedLayout, &TextCursor) -> TextCursor,
    {
        let current_cursor = self.text_edit_manager.get_primary_cursor()?;
        let layout = self.get_inline_layout_for_node(dom_id, node_id)?;
        let new_cursor = movement_fn(layout, &current_cursor);
        // Only return if cursor actually moved
        if new_cursor != current_cursor {
            Some(new_cursor)
        } else {
            None
        }
    }
    /// Helper: Handle cursor movement with optional selection extension.
    ///
    /// When multi-cursor is active, `new_cursor` is used as the movement applied
    /// to the primary cursor; all other cursors are moved by the same `move_fn`
    /// via `MultiCursorState::move_all_cursors`. For single-cursor mode, falls
    /// back to the legacy CursorManager + SelectionManager path.
    pub fn handle_cursor_movement(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        new_cursor: TextCursor,
        extend_selection: bool,
    ) {
        // Update multi_cursor with the new cursor position
        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
            mc.set_single_cursor(new_cursor);
        }
        self.regenerate_display_list_for_dom(dom_id);
    }
    /// Move all cursors in a MultiCursorState using a movement function.
    /// This is the multi-cursor version of handle_cursor_movement.
    pub fn handle_multi_cursor_movement(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        extend_selection: bool,
        move_fn: impl Fn(&TextCursor) -> TextCursor,
    ) {
        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
            mc.move_all_cursors(extend_selection, &move_fn);
        } else {
            // Single cursor fallback via get_primary_cursor
            if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
                let new_cursor = move_fn(&cursor);
                self.handle_cursor_movement(dom_id, node_id, new_cursor, extend_selection);
                return;
            }
        }
        self.regenerate_display_list_for_dom(dom_id);
    }
    // Gpu Value Cache Management
    /// Get the GPU value cache for a specific DOM
4
    pub fn get_gpu_cache(&self, dom_id: &DomId) -> Option<&GpuValueCache> {
4
        self.gpu_state_manager.caches.get(dom_id)
4
    }
    /// Get a mutable reference to the GPU value cache for a specific DOM
1
    pub fn get_gpu_cache_mut(&mut self, dom_id: &DomId) -> Option<&mut GpuValueCache> {
1
        self.gpu_state_manager.caches.get_mut(dom_id)
1
    }
    /// Get or create a GPU value cache for a specific DOM
4
    pub fn get_or_create_gpu_cache(&mut self, dom_id: DomId) -> &mut GpuValueCache {
4
        self.gpu_state_manager.get_or_create_cache(dom_id)
4
    }
    // Layout Result Access
    /// Get a layout result for a specific DOM
1
    pub fn get_layout_result(&self, dom_id: &DomId) -> Option<&DomLayoutResult> {
1
        self.layout_results.get(dom_id)
1
    }
    /// Get a mutable layout result for a specific DOM
    pub fn get_layout_result_mut(&mut self, dom_id: &DomId) -> Option<&mut DomLayoutResult> {
        self.layout_results.get_mut(dom_id)
    }
    /// Get all DOM IDs that have layout results
1
    pub fn get_dom_ids(&self) -> DomIdVec {
1
        self.layout_results
1
            .keys()
1
            .copied()
1
            .collect::<Vec<_>>()
1
            .into()
1
    }
    // Hit-Test Computation
    /// Compute the cursor type hit-test from a full hit-test
    ///
    /// This determines which mouse cursor to display based on the CSS cursor
    /// properties of the hovered nodes.
1
    pub fn compute_cursor_type_hit_test(
1
        &self,
1
        hit_test: &crate::hit_test::FullHitTest,
1
    ) -> crate::hit_test::CursorTypeHitTest {
1
        crate::hit_test::CursorTypeHitTest::new(hit_test, self)
1
    }
    /// Synchronize scrollbar opacity values with the GPU value cache.
    ///
    /// This method updates GPU opacity keys for all scrollbars based on scroll activity
    /// tracked by the ScrollManager. It enables smooth scrollbar fading without
    /// requiring display list regeneration.
    ///
    /// # Arguments
    ///
    /// * `dom_id` - The DOM to synchronize scrollbar opacity for
    /// * `layout_tree` - The layout tree containing scrollbar information
    /// * `now` - Current timestamp for calculating fade progress
    /// * `fade_delay` - Delay before scrollbar starts fading (e.g., 500ms)
    /// * `fade_duration` - Duration of the fade animation (e.g., 200ms)
    ///
    /// # Returns
    ///
    /// A vector of GPU scrollbar opacity change events
    /// Helper function to calculate scrollbar opacity based on activity time
    fn calculate_scrollbar_opacity(
        last_activity: Option<Instant>,
        now: Instant,
        fade_delay: Duration,
        fade_duration: Duration,
    ) -> f32 {
        let Some(last_activity) = last_activity else {
            return 0.0;
        };
        let time_since_activity = now.duration_since(&last_activity);
        // Phase 1: Scrollbar stays fully visible during fade_delay
        if time_since_activity.div(&fade_delay) < 1.0 {
            return 1.0;
        }
        // Phase 2: Fade out over fade_duration
        let time_into_fade = time_since_activity.div(&fade_delay) - 1.0;
        let fade_progress = (time_into_fade * fade_delay.div(&fade_duration)).min(1.0);
        // Phase 3: Fully faded
        (1.0 - fade_progress).max(0.0)
    }
    /// Synchronize scrollbar opacity values with the GPU value cache.
    ///
    /// Static method that takes individual components instead of &mut self to avoid borrow
    /// conflicts.
    pub fn synchronize_scrollbar_opacity(
        gpu_state_manager: &mut GpuStateManager,
        scroll_manager: &ScrollManager,
        dom_id: DomId,
        layout_tree: &LayoutTree,
        system_callbacks: &ExternalSystemCallbacks,
        fade_delay: azul_core::task::Duration,
        fade_duration: azul_core::task::Duration,
    ) -> Vec<azul_core::gpu::GpuScrollbarOpacityEvent> {
        let mut events = Vec::new();
        let mut any_opacity_nonzero = false;
        let gpu_cache = gpu_state_manager.caches.entry(dom_id).or_default();
        // Get current time from system callbacks
        let now = (system_callbacks.get_system_time_fn.cb)();
        // Iterate over all nodes with scrollbar info
        for (node_idx, node) in layout_tree.nodes.iter().enumerate() {
            // Check if node needs scrollbars
            let warm = layout_tree.warm(node_idx);
            let scrollbar_info = match warm.and_then(|w| w.scrollbar_info.as_ref()) {
                Some(info) => info,
                None => continue,
            };
            let node_id = match node.dom_node_id {
                Some(nid) => nid,
                None => continue, // Skip anonymous boxes
            };
            // Calculate current opacity from ScrollManager
            let vertical_opacity = if scrollbar_info.needs_vertical {
                Self::calculate_scrollbar_opacity(
                    scroll_manager.get_last_activity_time(dom_id, node_id),
                    now.clone(),
                    fade_delay,
                    fade_duration,
                )
            } else {
                0.0
            };
            let horizontal_opacity = if scrollbar_info.needs_horizontal {
                Self::calculate_scrollbar_opacity(
                    scroll_manager.get_last_activity_time(dom_id, node_id),
                    now.clone(),
                    fade_delay,
                    fade_duration,
                )
            } else {
                0.0
            };
            // Track whether any scrollbar is actively fading (0 < opacity < 1).
            // We do NOT count fully-visible scrollbars (opacity == 1.0) because
            // those are driven by the scroll physics timer already. We only need
            // extra frames for the fade-out interpolation phase. Including
            // opacity == 1.0 here causes an infinite repaint loop.
            if (vertical_opacity > 0.0 && vertical_opacity < 1.0)
                || (horizontal_opacity > 0.0 && horizontal_opacity < 1.0)
            {
                any_opacity_nonzero = true;
            }
            // Handle vertical scrollbar
            // IMPORTANT: Always pre-register the opacity key when the node needs a
            // vertical scrollbar, even if the current opacity is 0.  The display list
            // generator reads the key from the GPU cache to embed a PropertyBinding
            // in the ScrollBarStyled item.  If we only create the key when opacity > 0,
            // the first display list won't have the binding, and GPU-only scroll
            // updates (build_image_only_transaction) can never make the scrollbar
            // visible because WebRender doesn't know about the binding.
            if scrollbar_info.needs_vertical {
                let key = (dom_id, node_id);
                let existing = gpu_cache.scrollbar_v_opacity_values.get(&key);
                match existing {
                    None => {
                        let opacity_key = OpacityKey::unique();
                        gpu_cache.scrollbar_v_opacity_keys.insert(key, opacity_key);
                        gpu_cache
                            .scrollbar_v_opacity_values
                            .insert(key, vertical_opacity);
                        events.push(GpuScrollbarOpacityEvent::VerticalAdded(
                            dom_id,
                            node_id,
                            opacity_key,
                            vertical_opacity,
                        ));
                    }
                    Some(&old_opacity) if (old_opacity - vertical_opacity).abs() > 0.001 => {
                        let opacity_key = gpu_cache.scrollbar_v_opacity_keys[&key];
                        gpu_cache
                            .scrollbar_v_opacity_values
                            .insert(key, vertical_opacity);
                        events.push(GpuScrollbarOpacityEvent::VerticalChanged(
                            dom_id,
                            node_id,
                            opacity_key,
                            old_opacity,
                            vertical_opacity,
                        ));
                    }
                    _ => {}
                }
            } else {
                // Remove if scrollbar no longer needed
                let key = (dom_id, node_id);
                if let Some(opacity_key) = gpu_cache.scrollbar_v_opacity_keys.remove(&key) {
                    gpu_cache.scrollbar_v_opacity_values.remove(&key);
                    events.push(GpuScrollbarOpacityEvent::VerticalRemoved(
                        dom_id,
                        node_id,
                        opacity_key,
                    ));
                }
            }
            // Handle horizontal scrollbar (same logic as vertical above)
            if scrollbar_info.needs_horizontal {
                let key = (dom_id, node_id);
                let existing = gpu_cache.scrollbar_h_opacity_values.get(&key);
                match existing {
                    None => {
                        let opacity_key = OpacityKey::unique();
                        gpu_cache.scrollbar_h_opacity_keys.insert(key, opacity_key);
                        gpu_cache
                            .scrollbar_h_opacity_values
                            .insert(key, horizontal_opacity);
                        events.push(GpuScrollbarOpacityEvent::HorizontalAdded(
                            dom_id,
                            node_id,
                            opacity_key,
                            horizontal_opacity,
                        ));
                    }
                    Some(&old_opacity) if (old_opacity - horizontal_opacity).abs() > 0.001 => {
                        let opacity_key = gpu_cache.scrollbar_h_opacity_keys[&key];
                        gpu_cache
                            .scrollbar_h_opacity_values
                            .insert(key, horizontal_opacity);
                        events.push(GpuScrollbarOpacityEvent::HorizontalChanged(
                            dom_id,
                            node_id,
                            opacity_key,
                            old_opacity,
                            horizontal_opacity,
                        ));
                    }
                    _ => {}
                }
            } else {
                // Remove if scrollbar no longer needed
                let key = (dom_id, node_id);
                if let Some(opacity_key) = gpu_cache.scrollbar_h_opacity_keys.remove(&key) {
                    gpu_cache.scrollbar_h_opacity_values.remove(&key);
                    events.push(GpuScrollbarOpacityEvent::HorizontalRemoved(
                        dom_id,
                        node_id,
                        opacity_key,
                    ));
                }
            }
        }
        // Signal to the platform render loop that more frames are needed
        // to complete the scrollbar fade animation. The caller should
        // schedule a redraw while this flag is true.
        if any_opacity_nonzero {
            gpu_state_manager.scrollbar_fade_active = true;
        } else {
            gpu_state_manager.scrollbar_fade_active = false;
        }
        events
    }
    /// Compute stable scroll IDs for all scrollable nodes in a layout tree
    ///
    /// This should be called after layout but before display list generation.
    /// It creates stable IDs based on node_data_hash that persist across frames.
    ///
    /// Returns:
    /// - scroll_ids: Map from layout node index -> external scroll ID
    /// - scroll_id_to_node_id: Map from scroll ID -> DOM NodeId (for hit testing)
7260
    pub fn compute_scroll_ids(
7260
        layout_tree: &LayoutTree,
7260
        styled_dom: &azul_core::styled_dom::StyledDom,
7260
    ) -> (HashMap<usize, u64>, HashMap<u64, NodeId>) {
        use azul_css::props::layout::LayoutOverflow;
        use crate::solver3::getters::{get_overflow_x, get_overflow_y};
7260
        let mut scroll_ids = HashMap::new();
7260
        let mut scroll_id_to_node_id = HashMap::new();
        // Iterate through all layout nodes
57728
        for (layout_idx, node) in layout_tree.nodes.iter().enumerate() {
57728
            let Some(dom_node_id) = node.dom_node_id else {
264
                continue;
            };
            // Get the node state
57464
            let styled_node_state = styled_dom
57464
                .styled_nodes
57464
                .as_container()
57464
                .get(dom_node_id)
57464
                .map(|n| n.styled_node_state.clone())
57464
                .unwrap_or_default();
            // Check if this node has scroll overflow
57464
            let overflow_x = get_overflow_x(styled_dom, dom_node_id, &styled_node_state);
57464
            let overflow_y = get_overflow_y(styled_dom, dom_node_id, &styled_node_state);
57464
            let is_scrollable = overflow_x.is_scroll() || overflow_y.is_scroll();
57464
            if !is_scrollable {
57464
                continue;
            }
            // Generate stable scroll ID from node_data_fingerprint
            // Use a combined hash of the fingerprint fields to create a stable ID
            let scroll_id = {
                use std::hash::{Hash, Hasher, DefaultHasher};
                let mut h = DefaultHasher::new();
                if let Some(cold) = layout_tree.cold(layout_idx) {
                    cold.node_data_fingerprint.hash(&mut h);
                }
                h.finish()
            };
            scroll_ids.insert(layout_idx, scroll_id);
            scroll_id_to_node_id.insert(scroll_id, dom_node_id);
        }
7260
        (scroll_ids, scroll_id_to_node_id)
7260
    }
    /// Get the layout rectangle for a specific DOM node in logical coordinates
    ///
    /// This is useful in callbacks to get the position and size of the hit node
    /// for positioning menus, tooltips, or other overlays.
    ///
    /// Returns None if the node is not currently laid out (e.g., display:none)
13596
    pub fn get_node_layout_rect(
13596
        &self,
13596
        node_id: azul_core::dom::DomNodeId,
13596
    ) -> Option<azul_core::geom::LogicalRect> {
        // Get the layout tree from cache
13596
        let layout_tree = self.layout_cache.tree.as_ref()?;
13596
        { let _ = (0xE5_000002u32 | ((layout_tree.nodes.len() as u32 & 0xff) << 8)); }
        // Find the layout node index corresponding to this DOM node
        // Convert NodeHierarchyItemId to Option<NodeId> for comparison
13596
        let target_node_id = node_id.node.into_crate_internal();
102256
        let layout_idx = match layout_tree.nodes.iter().position(|node| node.dom_node_id == target_node_id) {
8536
            Some(i) => i,
5060
            None => { { let _ = (0xE5_0000FFu32); } return None; }
        };
8536
        { let _ = (0xE5_000003u32 | ((self.layout_cache.calculated_positions.len() as u32 & 0xfff) << 8)); }
        // Get the calculated layout position from cache (already in logical units)
8536
        let calc_pos = match self.layout_cache.calculated_positions.get(layout_idx) {
8536
            Some(p) => p,
            None => { { let _ = (0xE5_0000FEu32); } return None; }
        };
        // Get the layout node for size information
8536
        let layout_node = layout_tree.nodes.get(layout_idx)?;
        // Get the used size (the actual laid-out size)
8536
        let used_size = match layout_node.used_size {
6908
            Some(s) => s,
1628
            None => { { let _ = (0xE5_0000FDu32); } return None; }
        };
6908
        { let _ = (0xE5_000004u32); }
        // Convert size to logical coordinates
6908
        let hidpi_factor = self
6908
            .current_window_state
6908
            .size
6908
            .get_hidpi_factor()
6908
            .inner
6908
            .get();
6908
        Some(LogicalRect::new(
6908
            LogicalPosition::new(calc_pos.x as f32, calc_pos.y as f32),
6908
            LogicalSize::new(
6908
                used_size.width / hidpi_factor,
6908
                used_size.height / hidpi_factor,
6908
            ),
6908
        ))
13596
    }
    /// Get the cursor rect for the currently focused text input node in ABSOLUTE coordinates.
    ///
    /// This returns the cursor position in absolute window coordinates (not accounting for
    /// scroll offsets). This is used for scroll-into-view calculations where you need to
    /// compare the cursor position with the scrollable container's bounds.
    ///
    /// Returns None if:
    /// - No node is focused
    /// - Focused node has no text cursor
    /// - Focused node has no layout
    /// - Text cache cannot find cursor position
    ///
    /// For IME positioning (viewport-relative coordinates), use
    /// `get_focused_cursor_rect_viewport()`.
    /// Rebuild the accessibility tree from the current layout results, focus,
    /// and cursor state.  Called after full layout AND after display-list-only
    /// regeneration so that screen readers see up-to-date bounds, cursor, and
    /// focus information.
    #[cfg(feature = "a11y")]
3432
    pub fn update_a11y_tree(&mut self) {
3432
        let cursor_a11y_info = self.text_edit_manager.multi_cursor.as_ref().and_then(|mc| {
            let node_id = mc.node_id.node.into_crate_internal()?;
            let primary = mc.get_primary()?;
            let (anchor_offset, focus_offset) = match &primary.selection {
                azul_core::selection::Selection::Cursor(c) => {
                    let off = c.cluster_id.start_byte_in_run as usize;
                    (off, off)
                }
                azul_core::selection::Selection::Range(r) => (
                    r.start.cluster_id.start_byte_in_run as usize,
                    r.end.cluster_id.start_byte_in_run as usize,
                ),
            };
            Some(crate::managers::a11y::CursorA11yInfo {
                dom_id: mc.node_id.dom,
                node_id,
                anchor_offset,
                focus_offset,
            })
        });
        // Build text overrides from dirty_text_nodes so the a11y tree
        // reads the current (edited) text, not the stale StyledDom text.
3432
        let mut dirty_text_overrides: BTreeMap<(DomId, NodeId), String> = BTreeMap::new();
3432
        for (&(dom_id, node_id), dirty_node) in &self.dirty_text_nodes {
            dirty_text_overrides.insert(
                (dom_id, node_id),
                self.extract_text_from_inline_content(&dirty_node.content),
            );
        }
3432
        let a11y_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
3432
            crate::managers::a11y::A11yManager::update_tree(
3432
                self.a11y_manager.root_id,
3432
                &self.layout_results,
3432
                &self.current_window_state.title,
3432
                self.current_window_state.size.dimensions,
3432
                self.focus_manager.get_focused_node().copied(),
3432
                self.current_window_state.size.get_hidpi_factor().inner.get(),
3432
                &dirty_text_overrides,
3432
                cursor_a11y_info,
            )
3432
        }));
3432
        match a11y_result {
3432
            Ok(tree_update) => {
3432
                self.a11y_manager.last_tree_update = Some(tree_update);
3432
                self.a11y_manager.tree_initialized = true;
3432
            }
            Err(_) => {}
        }
3432
    }
    /// Incremental a11y update: only push the focused contenteditable node's
    /// updated value + cursor/selection.  Falls back to full rebuild if the
    /// tree hasn't been initialized yet or there's no active editing.
    #[cfg(feature = "a11y")]
572
    pub fn update_a11y_tree_incremental(&mut self) {
572
        if !self.a11y_manager.tree_initialized {
            // First time — need full tree
            return self.update_a11y_tree();
572
        }
        // Only worth doing incremental if we have an active editing node
572
        let mc = match self.text_edit_manager.multi_cursor.as_ref() {
572
            Some(mc) => mc,
            None => return, // No cursor — nothing to update incrementally
        };
572
        let dom_node_id = mc.node_id;
572
        let node_id = match dom_node_id.node.into_crate_internal() {
572
            Some(id) => id,
            None => return,
        };
572
        let dom_id = dom_node_id.dom;
        // Get current text content (from dirty overrides or StyledDom)
572
        let text_content = if let Some(dirty) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
            self.extract_text_from_inline_content(&dirty.content)
        } else {
            // Fall back to StyledDom text
572
            let lr = match self.layout_results.get(&dom_id) {
572
                Some(lr) => lr,
                None => return self.update_a11y_tree(),
            };
572
            let node_data = lr.styled_dom.node_data.as_ref();
572
            let hierarchy = lr.styled_dom.node_hierarchy.as_ref();
572
            let mut text = String::new();
572
            if let Some(item) = hierarchy.get(node_id.index()) {
572
                let mut child = item.first_child_id(node_id);
572
                while let Some(child_id) = child {
                    if let Some(cd) = node_data.get(child_id.index()) {
                        if let azul_core::dom::NodeType::Text(t) = &cd.node_type {
                            if !text.is_empty() { text.push(' '); }
                            text.push_str(t.as_str());
                        }
                    }
                    if child_id.index() >= hierarchy.len() { break; }
                    child = hierarchy[child_id.index()].next_sibling_id();
                }
            }
572
            text
        };
        // Build the a11y node ID (same encoding as update_tree)
572
        let a11y_node_id = accesskit::NodeId(
572
            ((dom_id.inner as u64) << 32) | (node_id.index() as u64) + 1,
572
        );
        // Get the node data to determine role
572
        let role = self.layout_results.get(&dom_id)
572
            .and_then(|lr| lr.styled_dom.node_data.as_ref().get(node_id.index()))
572
            .map(|nd| {
572
                if nd.is_contenteditable() || matches!(nd.node_type, azul_core::dom::NodeType::TextArea) {
                    accesskit::Role::MultilineTextInput
572
                } else if matches!(nd.node_type, azul_core::dom::NodeType::Input) {
                    accesskit::Role::TextInput
                } else {
572
                    accesskit::Role::GenericContainer
                }
572
            })
572
            .unwrap_or(accesskit::Role::GenericContainer);
572
        let mut node = accesskit::Node::new(role);
572
        node.set_value(text_content.as_str());
572
        node.add_action(accesskit::Action::SetTextSelection);
572
        node.add_action(accesskit::Action::ReplaceSelectedText);
572
        node.add_action(accesskit::Action::SetValue);
        // Set cursor/selection
572
        let primary = mc.get_primary();
572
        if let Some(identified) = primary {
572
            let (anchor_off, focus_off) = match &identified.selection {
572
                azul_core::selection::Selection::Cursor(c) => {
572
                    let off = c.cluster_id.start_byte_in_run as usize;
572
                    (off, off)
                }
                azul_core::selection::Selection::Range(r) => (
                    r.start.cluster_id.start_byte_in_run as usize,
                    r.end.cluster_id.start_byte_in_run as usize,
                ),
            };
572
            let char_lengths: Vec<u8> = text_content.chars()
572
                .map(|c| c.len_utf16() as u8)
572
                .collect();
572
            node.set_character_lengths(char_lengths.clone());
1144
            let byte_to_char = |byte_off: usize| -> usize {
1144
                text_content.char_indices()
1144
                    .take_while(|(b, _)| *b < byte_off)
1144
                    .count()
1144
                    .min(char_lengths.len())
1144
            };
572
            node.set_text_selection(accesskit::TextSelection {
572
                anchor: accesskit::TextPosition {
572
                    node: a11y_node_id,
572
                    character_index: byte_to_char(anchor_off),
572
                },
572
                focus: accesskit::TextPosition {
572
                    node: a11y_node_id,
572
                    character_index: byte_to_char(focus_off),
572
                },
572
            });
        }
        // Focus: use the current focused node or root
572
        let focus = self.focus_manager.get_focused_node().copied()
572
            .and_then(|dn| {
572
                let idx = dn.node.into_crate_internal()?.index();
572
                Some(accesskit::NodeId(((dn.dom.inner as u64) << 32) | (idx as u64) + 1))
572
            })
572
            .unwrap_or(self.a11y_manager.root_id);
572
        self.a11y_manager.last_tree_update = Some(accesskit::TreeUpdate {
572
            nodes: vec![(a11y_node_id, node)],
572
            tree: None, // Incremental — tree structure unchanged
572
            focus,
572
            tree_id: accesskit::TreeId::ROOT,
572
        });
572
    }
4576
    pub fn get_focused_cursor_rect(&self) -> Option<azul_core::geom::LogicalRect> {
        // Get the focused node
4576
        let focused_node = self.focus_manager.focused_node?;
        // Get the text cursor
1144
        let cursor = self.text_edit_manager.get_primary_cursor()?;
        // Get the layout tree from cache
1144
        let layout_tree = self.layout_cache.tree.as_ref()?;
        // Find the layout node index corresponding to the focused DOM node
1144
        let target_node_id = focused_node.node.into_crate_internal();
1144
        let layout_idx = layout_tree
1144
            .nodes
1144
            .iter()
2640
            .position(|node| node.dom_node_id == target_node_id)?;
        // Get the text layout result for this node (warm data)
1144
        let warm_node = layout_tree.warm(layout_idx)?;
1144
        let cached_layout = warm_node.inline_layout_result.as_ref()?;
1144
        let inline_layout = &cached_layout.layout;
        // Get the cursor rect in node-relative coordinates
1144
        let mut cursor_rect = inline_layout.get_cursor_rect(&cursor)?;
        // Get the calculated layout position from cache (already in logical units)
1144
        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
        // Add layout position to cursor rect (both already in logical units)
1144
        cursor_rect.origin.x += calc_pos.x as f32;
1144
        cursor_rect.origin.y += calc_pos.y as f32;
        // Return ABSOLUTE position (no scroll correction)
1144
        Some(cursor_rect)
4576
    }
    /// Compute the bounding rect of all selection ranges in the focused node.
    /// Returns the union of all selection rects in absolute coordinates.
    pub fn calculate_selection_bounding_rect(&self) -> Option<azul_core::geom::LogicalRect> {
        let focused_node = self.focus_manager.focused_node?;
        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
        // Collect Range selections
        let ranges: alloc::vec::Vec<_> = mc.selections.iter().filter_map(|s| {
            if let azul_core::selection::Selection::Range(ref r) = s.selection {
                Some(r.clone())
            } else {
                None
            }
        }).collect();
        if ranges.is_empty() {
            return None;
        }
        // Get the inline layout for the focused node
        let target_node_id = focused_node.node.into_crate_internal();
        let layout_tree = self.layout_cache.tree.as_ref()?;
        let layout_idx = layout_tree.nodes.iter()
            .position(|n| n.dom_node_id == target_node_id)?;
        let warm = layout_tree.warm(layout_idx)?;
        let inline_layout = &warm.inline_layout_result.as_ref()?.layout;
        let calc_pos = self.layout_cache.calculated_positions.get(layout_idx)?;
        let mut min_x = f32::MAX;
        let mut min_y = f32::MAX;
        let mut max_x = f32::MIN;
        let mut max_y = f32::MIN;
        let mut found_any = false;
        for range in &ranges {
            for rect in inline_layout.get_selection_rects(range) {
                found_any = true;
                let abs_x = rect.origin.x + calc_pos.x as f32;
                let abs_y = rect.origin.y + calc_pos.y as f32;
                min_x = min_x.min(abs_x);
                min_y = min_y.min(abs_y);
                max_x = max_x.max(abs_x + rect.size.width);
                max_y = max_y.max(abs_y + rect.size.height);
            }
        }
        if !found_any {
            return None;
        }
        Some(LogicalRect::new(
            LogicalPosition { x: min_x, y: min_y },
            LogicalSize { width: max_x - min_x, height: max_y - min_y },
        ))
    }
    /// Ctrl+D: select the next occurrence of the current selection/word.
    ///
    /// If the primary selection is a cursor (no range), first expand it to a word.
    /// Then search forward in the text for the next occurrence and add it as a
    /// new multi-cursor selection.
    ///
    /// Returns true if a new selection was added.
    pub fn select_next_occurrence(&mut self) -> bool {
        use crate::text3::selection::select_word_at_cursor;
        let mc = match self.text_edit_manager.multi_cursor.as_mut() {
            Some(mc) => mc,
            None => return false,
        };
        let node_id = mc.node_id;
        let dom_node_id = match node_id.node.into_crate_internal() {
            Some(id) => id,
            None => return false,
        };
        // Get primary selection text (or word at cursor)
        let primary = match mc.selections.first() {
            Some(s) => s.clone(),
            None => return false,
        };
        let (search_range, need_word_expand) = match &primary.selection {
            azul_core::selection::Selection::Range(r) => (*r, false),
            azul_core::selection::Selection::Cursor(c) => {
                // Need to expand to word first
                (azul_core::selection::SelectionRange { start: c.clone(), end: c.clone() }, true)
            }
        };
        // Get the inline layout
        let inline_layout = match self.get_node_inline_layout(node_id.dom, dom_node_id) {
            Some(l) => l,
            None => return false,
        };
        // If no range yet, expand to word
        let word_range = if need_word_expand {
            match select_word_at_cursor(&search_range.start, &inline_layout) {
                Some(r) => r,
                None => return false,
            }
        } else {
            search_range
        };
        // Extract the search text from inline content
        let content = self.get_text_before_textinput(node_id.dom, dom_node_id);
        let full_text = self.extract_text_from_inline_content(&content);
        // Extract the selected word text using byte offsets
        let start_byte = word_range.start.cluster_id.start_byte_in_run as usize;
        let end_byte = word_range.end.cluster_id.start_byte_in_run as usize;
        let search_text = if word_range.start.cluster_id.source_run == word_range.end.cluster_id.source_run {
            if let Some(InlineContent::Text(run)) = content.get(word_range.start.cluster_id.source_run as usize) {
                if start_byte <= end_byte && end_byte <= run.text.len() {
                    run.text[start_byte..end_byte].to_string()
                } else {
                    return false;
                }
            } else {
                return false;
            }
        } else {
            return false; // Multi-run selection search not yet supported
        };
        if search_text.is_empty() {
            return false;
        }
        // Search forward from the end of the last selection
        let mc = self.text_edit_manager.multi_cursor.as_ref().unwrap();
        let last_end_byte = mc.selections.last()
            .and_then(|s| match &s.selection {
                azul_core::selection::Selection::Range(r) => Some(r.end.cluster_id.start_byte_in_run as usize),
                azul_core::selection::Selection::Cursor(c) => Some(c.cluster_id.start_byte_in_run as usize),
            })
            .unwrap_or(0);
        let search_run = word_range.start.cluster_id.source_run;
        // Find next occurrence in the same run's text
        if let Some(InlineContent::Text(run)) = content.get(search_run as usize) {
            let search_in = &run.text;
            // Search from after the last selection end
            if let Some(offset) = search_in[last_end_byte..].find(&search_text) {
                let match_start = last_end_byte + offset;
                let match_end = match_start + search_text.len();
                let new_range = azul_core::selection::SelectionRange {
                    start: azul_core::selection::TextCursor {
                        cluster_id: azul_core::selection::GraphemeClusterId {
                            source_run: search_run,
                            start_byte_in_run: match_start as u32,
                        },
                        affinity: azul_core::selection::CursorAffinity::Leading,
                    },
                    end: azul_core::selection::TextCursor {
                        cluster_id: azul_core::selection::GraphemeClusterId {
                            source_run: search_run,
                            start_byte_in_run: match_end as u32,
                        },
                        affinity: azul_core::selection::CursorAffinity::Trailing,
                    },
                };
                // If primary was a cursor, convert it to a word selection first
                let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
                if need_word_expand {
                    if let Some(first) = mc.selections.first_mut() {
                        first.selection = azul_core::selection::Selection::Range(word_range);
                    }
                }
                let _ = mc.add_selection(new_range);
                self.text_edit_manager.mark_dirty();
                return true;
            } else if last_end_byte > 0 {
                // Wrap around: search from the beginning
                if let Some(offset) = search_in[..start_byte].find(&search_text) {
                    let match_start = offset;
                    let match_end = match_start + search_text.len();
                    let new_range = azul_core::selection::SelectionRange {
                        start: azul_core::selection::TextCursor {
                            cluster_id: azul_core::selection::GraphemeClusterId {
                                source_run: search_run,
                                start_byte_in_run: match_start as u32,
                            },
                            affinity: azul_core::selection::CursorAffinity::Leading,
                        },
                        end: azul_core::selection::TextCursor {
                            cluster_id: azul_core::selection::GraphemeClusterId {
                                source_run: search_run,
                                start_byte_in_run: match_end as u32,
                            },
                            affinity: azul_core::selection::CursorAffinity::Trailing,
                        },
                    };
                    let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
                    if need_word_expand {
                        if let Some(first) = mc.selections.first_mut() {
                            first.selection = azul_core::selection::Selection::Range(word_range);
                        }
                    }
                    let _ = mc.add_selection(new_range);
                    self.text_edit_manager.mark_dirty();
                    return true;
                }
            }
        }
        // If primary was cursor and we expanded to word but found no other occurrence,
        // still mark the word selection
        if need_word_expand {
            let mc = self.text_edit_manager.multi_cursor.as_mut().unwrap();
            if let Some(first) = mc.selections.first_mut() {
                first.selection = azul_core::selection::Selection::Range(word_range);
            }
            self.text_edit_manager.mark_dirty();
            return true;
        }
        false
    }
    /// Get the cursor rect for the currently focused text input node in VIEWPORT coordinates.
    ///
    /// This returns the cursor position accounting for:
    /// 1. Scroll offsets from all scrollable ancestors
    /// 2. GPU transforms (CSS transforms, animations) from all transformed ancestors
    ///
    /// The returned position is viewport-relative (what the user actually sees on screen).
    /// This is used for IME window positioning, where the IME popup needs to appear at the
    /// visible cursor location, not the absolute layout position.
    ///
    /// Returns None if:
    /// - No node is focused
    /// - Focused node has no text cursor
    /// - Focused node has no layout
    /// - Text cache cannot find cursor position
    ///
    /// For scroll-into-view calculations (absolute coordinates), use `get_focused_cursor_rect()`.
    pub fn get_focused_cursor_rect_viewport(&self) -> Option<azul_core::geom::LogicalRect> {
        // Start with absolute position
        let mut cursor_rect = self.get_focused_cursor_rect()?;
        // Get the focused node
        let focused_node = self.focus_manager.focused_node?;
        // Get the layout tree from cache
        let layout_tree = self.layout_cache.tree.as_ref()?;
        // Find the layout node index corresponding to the focused DOM node
        let target_node_id = focused_node.node.into_crate_internal();
        let layout_idx = layout_tree
            .nodes
            .iter()
            .position(|node| node.dom_node_id == target_node_id)?;
        // Get the GPU cache for this DOM (if it exists)
        let gpu_cache = self.gpu_state_manager.caches.get(&focused_node.dom);
        // CRITICAL STEP 1: Apply scroll offsets from all scrollable ancestors
        // CRITICAL STEP 2: Apply inverse GPU transforms from all transformed ancestors
        // Walk up the tree and apply both corrections
        let mut current_layout_idx = layout_idx;
        while let Some(parent_idx) = layout_tree.nodes.get(current_layout_idx)?.parent {
            // Get the DOM node ID of the parent (if it's not anonymous)
            if let Some(parent_dom_node_id) = layout_tree.nodes.get(parent_idx)?.dom_node_id {
                // STEP 1: Check if this parent is scrollable and has scroll state
                if let Some(scroll_state) = self
                    .scroll_manager
                    .get_scroll_state(focused_node.dom, parent_dom_node_id)
                {
                    // Subtract scroll offset (scrolling down = positive offset, moves content up)
                    cursor_rect.origin.x -= scroll_state.current_offset.x;
                    cursor_rect.origin.y -= scroll_state.current_offset.y;
                }
                // STEP 2: Check if this parent has a GPU transform applied
                if let Some(cache) = gpu_cache {
                    if let Some(transform) = cache.current_transform_values.get(&parent_dom_node_id)
                    {
                        // Apply the INVERSE transform to get back to viewport coordinates
                        // The transform moves the element, so we need to reverse it for the cursor
                        let inverse = transform.inverse();
                        if let Some(transformed_origin) =
                            inverse.transform_point2d(cursor_rect.origin)
                        {
                            cursor_rect.origin = transformed_origin;
                        }
                        // Note: We don't transform the size, only the position
                    }
                }
            }
            // Move to parent for next iteration
            current_layout_idx = parent_idx;
        }
        Some(cursor_rect)
    }
    /// Find the nearest scrollable ancestor for a given node
    /// Returns (DomId, NodeId) of the scrollable container, or None if no scrollable ancestor
    /// exists
    pub fn find_scrollable_ancestor(
        &self,
        mut node_id: azul_core::dom::DomNodeId,
    ) -> Option<azul_core::dom::DomNodeId> {
        // Get the layout tree
        let layout_tree = self.layout_cache.tree.as_ref()?;
        // Convert to internal NodeId
        let mut current_node_id = node_id.node.into_crate_internal();
        // Walk up the tree looking for a scrollable node
        loop {
            // Find layout node index
            let layout_idx = layout_tree
                .nodes
                .iter()
                .position(|node| node.dom_node_id == current_node_id)?;
            // Check if this node has scrollbar info (meaning it's scrollable)
            if layout_tree.warm(layout_idx).and_then(|w| w.scrollbar_info.as_ref()).is_some() {
                // Check if it actually has a scroll state registered
                let check_node_id = current_node_id?;
                if self
                    .scroll_manager
                    .get_scroll_state(node_id.dom, check_node_id)
                    .is_some()
                {
                    // Found a scrollable ancestor
                    return Some(azul_core::dom::DomNodeId {
                        dom: node_id.dom,
                        node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(
                            Some(check_node_id),
                        ),
                    });
                }
            }
            // Move to parent
            let parent_idx = layout_tree.get(layout_idx)?.parent?;
            let parent_node = layout_tree.get(parent_idx)?;
            current_node_id = parent_node.dom_node_id;
        }
    }
    /// Scroll selection or cursor into view with distance-based acceleration.
    ///
    /// **Unified Scroll System**: This method handles both cursor (0-size selection)
    /// and full selection scrolling with a single implementation. For drag-to-scroll,
    /// scroll speed increases with distance from container edge.
    ///
    /// ## Algorithm
    /// 1. Get bounds to scroll (cursor rect, selection rect, or mouse position)
    /// 2. Find scrollable ancestor container
    /// 3. Calculate distance from bounds to container edges
    /// 4. Compute scroll delta (instant with padding, or accelerated with zones)
    /// 5. Apply scroll with appropriate animation
    ///
    /// ## Distance-Based Acceleration (ScrollMode::Accelerated)
    /// ```text
    /// Distance from edge:  Scroll speed per frame:
    /// 0-20px              Dead zone (no scroll)
    /// 20-50px             Slow (2px/frame)
    /// 50-100px            Medium (4px/frame)
    /// 100-200px           Fast (8px/frame)
    /// 200+px              Very fast (16px/frame)
    /// ```
    ///
    /// ## Returns
    /// `true` if scrolling was applied, `false` if already visible
3432
    pub fn scroll_selection_into_view(
3432
        &mut self,
3432
        scroll_type: SelectionScrollType,
3432
        scroll_mode: ScrollMode,
3432
    ) -> bool {
        // Get bounds to scroll into view
3432
        let bounds = match scroll_type {
            SelectionScrollType::Cursor => {
                // Cursor is 0-size selection at insertion point
3432
                match self.get_focused_cursor_rect() {
                    Some(rect) => rect,
3432
                    None => return false, // No cursor to scroll
                }
            }
            SelectionScrollType::Selection => {
                // Compute bounding rect of all selection ranges via the text layout.
                // Falls back to cursor rect if no ranges exist.
                match self.calculate_selection_bounding_rect()
                    .or_else(|| self.get_focused_cursor_rect())
                {
                    Some(rect) => rect,
                    None => return false,
                }
            }
            SelectionScrollType::DragSelection { mouse_position } => {
                // For drag: use mouse position to determine scroll direction/speed
                LogicalRect::new(mouse_position, LogicalSize::zero())
            }
        };
        // Get the focused node (or bail if no focus)
        let focused_node = match self.focus_manager.focused_node {
            Some(node) => node,
            None => return false,
        };
        // Find scrollable ancestor
        let scroll_container = match self.find_scrollable_ancestor(focused_node) {
            Some(node) => node,
            None => return false, // No scrollable ancestor
        };
        // Get container bounds and current scroll state
        let layout_tree = match self.layout_cache.tree.as_ref() {
            Some(tree) => tree,
            None => return false,
        };
        let scrollable_node_internal = match scroll_container.node.into_crate_internal() {
            Some(id) => id,
            None => return false,
        };
        let layout_idx = match layout_tree
            .nodes
            .iter()
            .position(|n| n.dom_node_id == Some(scrollable_node_internal))
        {
            Some(idx) => idx,
            None => return false,
        };
        let scrollable_layout_node = match layout_tree.nodes.get(layout_idx) {
            Some(node) => node,
            None => return false,
        };
        let container_pos = self
            .layout_cache
            .calculated_positions
            .get(layout_idx)
            .copied()
            .unwrap_or_default();
        let container_size = scrollable_layout_node.used_size.unwrap_or_default();
        let container_rect = LogicalRect {
            origin: container_pos,
            size: container_size,
        };
        // Get current scroll state
        let scroll_state = match self
            .scroll_manager
            .get_scroll_state(scroll_container.dom, scrollable_node_internal)
        {
            Some(state) => state,
            None => return false,
        };
        // Calculate visible area (container rect adjusted by scroll offset)
        let visible_area = LogicalRect::new(
            LogicalPosition::new(
                container_rect.origin.x + scroll_state.current_offset.x,
                container_rect.origin.y + scroll_state.current_offset.y,
            ),
            container_rect.size,
        );
        // Calculate scroll delta based on mode
        let scroll_delta = match scroll_mode {
            ScrollMode::Instant => {
                // For typing/clicking: instant scroll with fixed padding
                calculate_instant_scroll_delta(bounds, visible_area)
            }
            ScrollMode::Accelerated => {
                // For drag: accelerated scroll based on distance from edge
                let distance = calculate_edge_distance(bounds, visible_area);
                calculate_accelerated_scroll_delta(distance)
            }
        };
        // Apply scroll if needed
        if scroll_delta.x != 0.0 || scroll_delta.y != 0.0 {
            let duration = match scroll_mode {
                ScrollMode::Instant => Duration::System(SystemTimeDiff { secs: 0, nanos: 0 }),
                ScrollMode::Accelerated => Duration::System(SystemTimeDiff {
                    secs: 0,
                    nanos: 16_666_667,
                }), // 60fps
            };
            let external = ExternalSystemCallbacks::rust_internal();
            let now = (external.get_system_time_fn.cb)();
            // Calculate new scroll target
            let new_target = LogicalPosition {
                x: scroll_state.current_offset.x + scroll_delta.x,
                y: scroll_state.current_offset.y + scroll_delta.y,
            };
            self.scroll_manager.scroll_to(
                scroll_container.dom,
                scrollable_node_internal,
                new_target,
                duration,
                EasingFunction::Linear,
                now.into(),
            );
            true // Scrolled
        } else {
            false // Already visible
        }
3432
    }
    /// Scrolls the focused cursor into view after layout.
    ///
    /// Delegates to `scroll_selection_into_view` with cursor mode.
    /// Called internally from `layout_and_generate_display_list()`.
3432
    fn scroll_focused_cursor_into_view(&mut self) {
        // Redirect to unified scroll system
3432
        self.scroll_selection_into_view(SelectionScrollType::Cursor, ScrollMode::Instant);
3432
    }
}
/// Type of selection bounds to scroll into view
#[derive(Debug, Clone, Copy)]
pub enum SelectionScrollType {
    /// Scroll cursor (0-size selection) into view
    Cursor,
    /// Scroll current selection bounds into view
    Selection,
    /// Scroll for drag selection (use mouse position for direction/speed)
    DragSelection { mouse_position: LogicalPosition },
}
/// Scroll animation mode
#[derive(Debug, Clone, Copy)]
pub enum ScrollMode {
    /// Instant scroll with fixed padding (for typing, arrow keys)
    Instant,
    /// Accelerated scroll based on distance from edge (for drag-to-scroll)
    Accelerated,
}
/// Distance from rect edges to container edges (for acceleration calculation)
#[derive(Debug, Clone, Copy)]
struct EdgeDistance {
    left: f32,
    right: f32,
    top: f32,
    bottom: f32,
}
/// Calculate distance from rect to container edges
fn calculate_edge_distance(rect: LogicalRect, container: LogicalRect) -> EdgeDistance {
    EdgeDistance {
        // Distance from rect's left edge to container's left edge
        left: (rect.origin.x - container.origin.x).max(0.0),
        // Distance from container's right edge to rect's right edge
        right: ((container.origin.x + container.size.width) - (rect.origin.x + rect.size.width))
            .max(0.0),
        // Distance from rect's top edge to container's top edge
        top: (rect.origin.y - container.origin.y).max(0.0),
        // Distance from container's bottom edge to rect's bottom edge
        bottom: ((container.origin.y + container.size.height) - (rect.origin.y + rect.size.height))
            .max(0.0),
    }
}
/// Calculate scroll delta with fixed padding (instant scroll mode)
fn calculate_instant_scroll_delta(
    bounds: LogicalRect,
    visible_area: LogicalRect,
) -> LogicalPosition {
    const PADDING: f32 = 5.0;
    let mut delta = LogicalPosition::zero();
    // Horizontal scrolling
    if bounds.origin.x < visible_area.origin.x + PADDING {
        delta.x = bounds.origin.x - visible_area.origin.x - PADDING;
    } else if bounds.origin.x + bounds.size.width
        > visible_area.origin.x + visible_area.size.width - PADDING
    {
        delta.x = (bounds.origin.x + bounds.size.width)
            - (visible_area.origin.x + visible_area.size.width)
            + PADDING;
    }
    // Vertical scrolling
    if bounds.origin.y < visible_area.origin.y + PADDING {
        delta.y = bounds.origin.y - visible_area.origin.y - PADDING;
    } else if bounds.origin.y + bounds.size.height
        > visible_area.origin.y + visible_area.size.height - PADDING
    {
        delta.y = (bounds.origin.y + bounds.size.height)
            - (visible_area.origin.y + visible_area.size.height)
            + PADDING;
    }
    delta
}
/// Calculate scroll delta with distance-based acceleration (drag-to-scroll mode)
fn calculate_accelerated_scroll_delta(distance: EdgeDistance) -> LogicalPosition {
    // Acceleration zones (in pixels from edge)
    const DEAD_ZONE: f32 = 20.0;
    const SLOW_ZONE: f32 = 50.0;
    const MEDIUM_ZONE: f32 = 100.0;
    const FAST_ZONE: f32 = 200.0;
    // Scroll speeds (pixels per frame at 60fps)
    const SLOW_SPEED: f32 = 2.0;
    const MEDIUM_SPEED: f32 = 4.0;
    const FAST_SPEED: f32 = 8.0;
    const VERY_FAST_SPEED: f32 = 16.0;
    // Helper to calculate speed for one direction
    let speed_for_distance = |dist: f32| -> f32 {
        if dist < DEAD_ZONE {
            0.0
        } else if dist < SLOW_ZONE {
            SLOW_SPEED
        } else if dist < MEDIUM_ZONE {
            MEDIUM_SPEED
        } else if dist < FAST_ZONE {
            FAST_SPEED
        } else {
            VERY_FAST_SPEED
        }
    };
    // Calculate horizontal scroll (left vs right)
    let scroll_x = if distance.left < distance.right {
        // Closer to left edge - scroll left
        -speed_for_distance(distance.left)
    } else {
        // Closer to right edge - scroll right
        speed_for_distance(distance.right)
    };
    // Calculate vertical scroll (top vs bottom)
    let scroll_y = if distance.top < distance.bottom {
        // Closer to top edge - scroll up
        -speed_for_distance(distance.top)
    } else {
        // Closer to bottom edge - scroll down
        speed_for_distance(distance.bottom)
    };
    LogicalPosition::new(scroll_x, scroll_y)
}
/// Result of a layout operation
pub struct LayoutResult {
    pub display_list: DisplayList,
    pub warnings: Vec<String>,
}
impl LayoutResult {
    pub fn new(display_list: DisplayList, warnings: Vec<String>) -> Self {
        Self {
            display_list,
            warnings,
        }
    }
}
impl LayoutWindow {
    /// Runs a single timer, similar to CallbacksOfHitTest.call()
    ///
    /// NOTE: The timer has to be selected first by the calling code and verified
    /// that it is ready to run
    #[cfg(feature = "std")]
    /// Run a single timer callback and return raw changes + update.
    ///
    /// If the timer should terminate, a `RemoveTimer` change is appended.
    pub fn run_single_timer(
        &mut self,
        timer_id: usize,
        frame_start: Instant,
        current_window_handle: &RawWindowHandle,
        gl_context: &OptionGlContextPtr,
        system_style: std::sync::Arc<azul_css::system::SystemStyle>,
        system_callbacks: &ExternalSystemCallbacks,
        previous_window_state: &Option<FullWindowState>,
        current_window_state: &FullWindowState,
        renderer_resources: &RendererResources,
    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
        use crate::callbacks::{CallbackInfo, CallbackChange};
        let mut update = Update::DoNothing;
        let mut all_changes = Vec::new();
        let mut should_terminate = TerminateTimer::Continue;
        let current_scroll_states_nested = self.get_nested_scroll_states(DomId::ROOT_ID);
        let timer_exists = self.timers.contains_key(&TimerId { id: timer_id });
        let timer_node_id = self
            .timers
            .get(&TimerId { id: timer_id })
            .and_then(|t| t.node_id.into_option());
        if timer_exists {
            let hit_dom_node = match timer_node_id {
                Some(s) => s,
                None => DomNodeId {
                    dom: DomId::ROOT_ID,
                    node: NodeHierarchyItemId::from_crate_internal(None),
                },
            };
            let cursor_relative_to_item = OptionLogicalPosition::None;
            let cursor_in_viewport = OptionLogicalPosition::None;
            let callback_changes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
            let timer_ctx = self
                .timers
                .get(&TimerId { id: timer_id })
                .map(|t| t.callback.ctx.clone())
                .unwrap_or(OptionRefAny::None);
            let ref_data = crate::callbacks::CallbackInfoRefData {
                layout_window: self,
                renderer_resources,
                previous_window_state,
                current_window_state,
                gl_context,
                current_scroll_manager: &current_scroll_states_nested,
                current_window_handle,
                system_callbacks,
                system_style,
                monitors: self.monitors.clone(),
                #[cfg(feature = "icu")]
                icu_localizer: self.icu_localizer.clone(),
                ctx: timer_ctx,
            };
            let callback_info = CallbackInfo::new(
                &ref_data,
                &callback_changes,
                hit_dom_node,
                cursor_relative_to_item,
                cursor_in_viewport,
            );
            let timer = self.timers.get_mut(&TimerId { id: timer_id }).unwrap();
            let tcr = timer.invoke(&callback_info, &system_callbacks.get_system_time_fn);
            update = tcr.should_update;
            should_terminate = tcr.should_terminate;
            all_changes = callback_changes
                .lock()
                .map(|mut guard| core::mem::take(&mut *guard))
                .unwrap_or_default();
        }
        if should_terminate == TerminateTimer::Terminate {
            all_changes.push(CallbackChange::RemoveTimer {
                timer_id: TimerId { id: timer_id },
            });
        }
        (all_changes, update)
    }
    #[cfg(feature = "std")]
    /// Run all thread writeback callbacks and return raw changes + update.
    pub fn run_all_threads(
        &mut self,
        data: &mut RefAny,
        current_window_handle: &RawWindowHandle,
        gl_context: &OptionGlContextPtr,
        system_style: std::sync::Arc<azul_css::system::SystemStyle>,
        system_callbacks: &ExternalSystemCallbacks,
        previous_window_state: &Option<FullWindowState>,
        current_window_state: &FullWindowState,
        renderer_resources: &RendererResources,
    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
        use std::collections::BTreeSet;
        use crate::{
            callbacks::{CallbackInfo, CallbackChange},
            thread::{OptionThreadReceiveMsg, ThreadReceiveMsg, ThreadWriteBackMsg},
        };
        let mut update = Update::DoNothing;
        let mut all_changes = Vec::new();
        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
        let thread_ids: Vec<ThreadId> = self.threads.keys().copied().collect();
        for thread_id in thread_ids {
            let thread = match self.threads.get_mut(&thread_id) {
                Some(t) => t,
                None => continue,
            };
            let hit_dom_node = DomNodeId {
                dom: DomId::ROOT_ID,
                node: NodeHierarchyItemId::from_crate_internal(None),
            };
            let cursor_relative_to_item = OptionLogicalPosition::None;
            let cursor_in_viewport = OptionLogicalPosition::None;
            let (msg, writeback_data_ptr, is_finished) = {
                let thread_inner = &mut *match thread.ptr.lock().ok() {
                    Some(s) => s,
                    None => {
                        all_changes.push(CallbackChange::RemoveThread { thread_id });
                        continue;
                    }
                };
                let _ = thread_inner.sender_send(ThreadSendMsg::Tick);
                let recv = thread_inner.receiver_try_recv();
                let msg = match recv {
                    OptionThreadReceiveMsg::None => continue,
                    OptionThreadReceiveMsg::Some(s) => s,
                };
                let writeback_data_ptr: *mut RefAny = &mut thread_inner.writeback_data as *mut _;
                let is_finished = thread_inner.is_finished();
                (msg, writeback_data_ptr, is_finished)
            };
            let ThreadWriteBackMsg {
                refany: mut data_inner,
                callback,
            } = match msg {
                ThreadReceiveMsg::Update(update_screen) => {
                    update.max_self(update_screen);
                    continue;
                }
                ThreadReceiveMsg::WriteBack(t) => t,
            };
            let callback_changes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
            let ref_data = crate::callbacks::CallbackInfoRefData {
                layout_window: self,
                renderer_resources,
                previous_window_state,
                current_window_state,
                gl_context,
                current_scroll_manager: &current_scroll_states,
                current_window_handle,
                system_callbacks,
                system_style: system_style.clone(),
                monitors: self.monitors.clone(),
                #[cfg(feature = "icu")]
                icu_localizer: self.icu_localizer.clone(),
                ctx: callback.ctx.clone(),
            };
            let callback_info = CallbackInfo::new(
                &ref_data,
                &callback_changes,
                hit_dom_node,
                cursor_relative_to_item,
                cursor_in_viewport,
            );
            let callback_update = (callback.cb)(
                unsafe { (*writeback_data_ptr).clone() },
                data_inner.clone(),
                callback_info,
            );
            update.max_self(callback_update);
            let collected_changes = callback_changes
                .lock()
                .map(|mut guard| core::mem::take(&mut *guard))
                .unwrap_or_default();
            all_changes.extend(collected_changes);
            if is_finished {
                all_changes.push(CallbackChange::RemoveThread { thread_id });
            }
        }
        (all_changes, update)
    }
    /// Invokes a single callback and returns the raw changes + update signal.
    ///
    /// Caller is responsible for processing each `CallbackChange` via
    /// `PlatformWindowV2::apply_user_change()`.
    pub fn invoke_single_callback(
        &mut self,
        callback: &mut Callback,
        data: &mut RefAny,
        current_window_handle: &RawWindowHandle,
        gl_context: &OptionGlContextPtr,
        system_style: std::sync::Arc<azul_css::system::SystemStyle>,
        system_callbacks: &ExternalSystemCallbacks,
        previous_window_state: &Option<FullWindowState>,
        current_window_state: &FullWindowState,
        renderer_resources: &RendererResources,
    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
        // No specific event target (create / layout / timer / unmount callbacks):
        // `info.get_hit_node()` resolves to the root with a null node.
        let hit_dom_node = DomNodeId {
            dom: DomId::ROOT_ID,
            node: NodeHierarchyItemId::from_crate_internal(None),
        };
        self.invoke_single_callback_at(
            hit_dom_node,
            callback,
            data,
            current_window_handle,
            gl_context,
            system_style,
            system_callbacks,
            previous_window_state,
            current_window_state,
            renderer_resources,
        )
    }
    /// Like [`invoke_single_callback`], but sets the callback's hit node (the
    /// event target) so `info.get_hit_node()` / `open_menu_for_hit_node()` /
    /// `get_hit_node_rect()` resolve to the node the event was dispatched to.
    /// Used by the W3C event-propagation dispatcher; without it those queries
    /// returned a null node (menus/dropdowns opened nowhere).
    pub fn invoke_single_callback_at(
        &mut self,
        hit_dom_node: DomNodeId,
        callback: &mut Callback,
        data: &mut RefAny,
        current_window_handle: &RawWindowHandle,
        gl_context: &OptionGlContextPtr,
        system_style: std::sync::Arc<azul_css::system::SystemStyle>,
        system_callbacks: &ExternalSystemCallbacks,
        previous_window_state: &Option<FullWindowState>,
        current_window_state: &FullWindowState,
        renderer_resources: &RendererResources,
    ) -> (Vec<crate::callbacks::CallbackChange>, Update) {
        use crate::callbacks::{CallbackInfo, CallbackChange};
        let current_scroll_states = self.get_nested_scroll_states(DomId::ROOT_ID);
        // Resolve the cursor position *local to the dispatched node* from the
        // current mouse hit test (the same `point_relative_to_item` the hit
        // tester computed, and that text selection consumes). Without this
        // `info.get_cursor_relative_to_node()` was always `None`, so any
        // callback needing a node-local cursor (map pan/drag, custom hit
        // logic) silently bailed. Falls back to `None` when the node isn't in
        // the current hit test (e.g. non-pointer events).
        let cursor_relative_to_item = match hit_dom_node.node.into_crate_internal() {
            Some(node_id) => self
                .hover_manager
                .get_current(&crate::managers::hover::InputPointId::Mouse)
                .and_then(|ht| ht.hovered_nodes.get(&hit_dom_node.dom))
                .and_then(|hit| hit.regular_hit_test_nodes.get(&node_id))
                .map(|item| OptionLogicalPosition::Some(item.point_relative_to_item))
                .unwrap_or(OptionLogicalPosition::None),
            None => OptionLogicalPosition::None,
        };
        let cursor_in_viewport = match current_window_state.mouse_state.cursor_position.get_position() {
            Some(pos) => OptionLogicalPosition::Some(pos),
            None => OptionLogicalPosition::None,
        };
        // Create changes container for callback transaction system
        let callback_changes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
        // Create reference data container.
        //
        // `ctx` carries the callback's stored OptionRefAny (host-handle for
        // managed FFIs, PyCallableWrapper for Python, None for native Rust)
        // so `info.get_ctx()` reaches it. Without this the host-invoker
        // thunk in libazul sees `OptionRefAny::None` and bails out with
        // `Update::DoNothing` — and clicks would silently do nothing.
        let ref_data = crate::callbacks::CallbackInfoRefData {
            layout_window: self,
            renderer_resources,
            previous_window_state,
            current_window_state,
            gl_context,
            current_scroll_manager: &current_scroll_states,
            current_window_handle,
            system_callbacks,
            system_style,
            monitors: self.monitors.clone(),
            #[cfg(feature = "icu")]
            icu_localizer: self.icu_localizer.clone(),
            ctx: callback.ctx.clone(),
        };
        let callback_info = CallbackInfo::new(
            &ref_data,
            &callback_changes,
            hit_dom_node,
            cursor_relative_to_item,
            cursor_in_viewport,
        );
        let update = (callback.cb)(data.clone(), callback_info);
        // Extract changes from the Arc<Mutex>
        let collected_changes = callback_changes
            .lock()
            .map(|mut guard| core::mem::take(&mut *guard))
            .unwrap_or_default();
        (collected_changes, update)
    }
    /// Set the system style for resolving system color keywords in CSS.
    ///
    /// This should be called during window initialization and whenever the system
    /// theme changes (dark/light mode switch, accent color change).
    ///
    /// The system style is used to resolve CSS system colors like `selection-background`,
    /// `selection-text`, `accent`, etc. If not set, hard-coded fallback values are used.
    pub fn set_system_style(&mut self, system_style: std::sync::Arc<azul_css::system::SystemStyle>) {
        #[cfg(feature = "icu")]
        {
            self.icu_localizer = crate::icu::IcuLocalizerHandle::from_system_language(&system_style.language);
        }
        self.system_style = Some(system_style);
    }
}
// --- ICU4X Internationalization API ---
#[cfg(feature = "icu")]
impl LayoutWindow {
    /// Initialize the ICU localizer with the system's detected language.
    ///
    /// This should be called during window initialization, passing the language
    /// from `SystemStyle::language`.
    ///
    /// # Arguments
    /// * `locale` - The BCP 47 language tag (e.g., "en-US", "de-DE")
    pub fn set_icu_locale(&mut self, locale: &str) {
        self.icu_localizer.set_locale(locale);
    }
    /// Initialize the ICU localizer from a SystemStyle.
    ///
    /// This is a convenience method that extracts the language from the system style.
    pub fn init_icu_from_system_style(&mut self, system_style: &azul_css::system::SystemStyle) {
        self.icu_localizer = IcuLocalizerHandle::from_system_language(&system_style.language);
    }
    /// Get a clone of the ICU localizer handle.
    ///
    /// This can be used to perform locale-aware formatting outside of callbacks.
    pub fn get_icu_localizer(&self) -> IcuLocalizerHandle {
        self.icu_localizer.clone()
    }
    /// Load additional ICU locale data from a binary blob.
    ///
    /// The blob should be generated using `icu4x-datagen` with the `--format blob` flag.
    /// This allows supporting locales that aren't compiled into the binary.
    pub fn load_icu_data_blob(&mut self, data: Vec<u8>) -> bool {
        self.icu_localizer.load_data_blob(&data)
    }
}
#[cfg(test)]
mod tests {
    use super::*;
    use crate::{thread::Thread, timer::Timer};
    #[test]
1
    fn test_timer_add_remove() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let timer_id = TimerId { id: 1 };
1
        let timer = Timer::default();
        // Add timer
1
        window.add_timer(timer_id, timer);
1
        assert!(window.get_timer(&timer_id).is_some());
1
        assert_eq!(window.get_timer_ids().len(), 1);
        // Remove timer
1
        let removed = window.remove_timer(&timer_id);
1
        assert!(removed.is_some());
1
        assert!(window.get_timer(&timer_id).is_none());
1
        assert_eq!(window.get_timer_ids().len(), 0);
1
    }
    #[test]
1
    fn test_timer_get_mut() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let timer_id = TimerId { id: 1 };
1
        let timer = Timer::default();
1
        window.add_timer(timer_id, timer);
        // Get mutable reference
1
        let timer_mut = window.get_timer_mut(&timer_id);
1
        assert!(timer_mut.is_some());
1
    }
    #[test]
1
    fn test_multiple_timers() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let timer1 = TimerId { id: 1 };
1
        let timer2 = TimerId { id: 2 };
1
        let timer3 = TimerId { id: 3 };
1
        window.add_timer(timer1, Timer::default());
1
        window.add_timer(timer2, Timer::default());
1
        window.add_timer(timer3, Timer::default());
1
        assert_eq!(window.get_timer_ids().len(), 3);
1
        window.remove_timer(&timer2);
1
        assert_eq!(window.get_timer_ids().len(), 2);
1
        assert!(window.get_timer(&timer1).is_some());
1
        assert!(window.get_timer(&timer2).is_none());
1
        assert!(window.get_timer(&timer3).is_some());
1
    }
    // Thread management tests removed - Thread::default() not available
    // and threads require complex setup. Thread management is tested
    // through integration tests instead.
    #[test]
1
    fn test_gpu_cache_management() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId { inner: 0 };
        // Initially empty
1
        assert!(window.get_gpu_cache(&dom_id).is_none());
        // Get or create
1
        let cache = window.get_or_create_gpu_cache(dom_id);
1
        assert!(cache.transform_keys.is_empty());
        // Now exists
1
        assert!(window.get_gpu_cache(&dom_id).is_some());
        // Can get mutable reference
1
        let cache_mut = window.get_gpu_cache_mut(&dom_id);
1
        assert!(cache_mut.is_some());
1
    }
    #[test]
1
    fn test_gpu_cache_multiple_doms() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom1 = DomId { inner: 0 };
1
        let dom2 = DomId { inner: 1 };
1
        window.get_or_create_gpu_cache(dom1);
1
        window.get_or_create_gpu_cache(dom2);
1
        assert!(window.get_gpu_cache(&dom1).is_some());
1
        assert!(window.get_gpu_cache(&dom2).is_some());
1
    }
    #[test]
1
    fn test_compute_cursor_type_empty_hit_test() {
        use crate::hit_test::FullHitTest;
1
        let fc_cache = FcFontCache::default();
1
        let window = LayoutWindow::new(fc_cache).unwrap();
1
        let empty_hit = FullHitTest::empty(None);
1
        let cursor_test = window.compute_cursor_type_hit_test(&empty_hit);
        // Empty hit test should result in default cursor
1
        assert_eq!(
            cursor_test.cursor_icon,
            azul_core::window::MouseCursorType::Default
        );
1
        assert!(cursor_test.cursor_node.is_none());
1
    }
    #[test]
1
    fn test_layout_result_access() {
1
        let fc_cache = FcFontCache::default();
1
        let window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId { inner: 0 };
        // Initially no layout results
1
        assert!(window.get_layout_result(&dom_id).is_none());
1
        assert_eq!(window.get_dom_ids().len(), 0);
1
    }
    // ScrollManager and VirtualView Integration Tests
    #[test]
1
    fn test_scroll_manager_initialization() {
1
        let fc_cache = FcFontCache::default();
1
        let window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId::ROOT_ID;
1
        let node_id = NodeId::new(0);
        // Initially no scroll states
1
        let scroll_offsets = window.scroll_manager.get_scroll_states_for_dom(dom_id);
1
        assert!(scroll_offsets.is_empty());
        // No current offset
1
        let offset = window.scroll_manager.get_current_offset(dom_id, node_id);
1
        assert_eq!(offset, None);
1
    }
    #[test]
1
    fn test_scroll_manager_tick_updates_activity() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId::ROOT_ID;
1
        let node_id = NodeId::new(0);
        // Create a scroll input
        #[cfg(feature = "std")]
1
        let now = Instant::System(std::time::Instant::now().into());
        #[cfg(not(feature = "std"))]
        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
1
        let scroll_input = crate::managers::scroll_state::ScrollInput {
1
            dom_id,
1
            node_id,
1
            delta: LogicalPosition::new(10.0, 20.0),
1
            timestamp: now.clone(),
1
            source: crate::managers::scroll_state::ScrollInputSource::WheelDiscrete,
1
        };
1
        let should_start_timer = window
1
            .scroll_manager
1
            .record_scroll_input(scroll_input);
        // record_scroll_input should return true (timer was not running)
1
        assert!(should_start_timer);
1
    }
    #[test]
1
    fn test_scroll_manager_programmatic_scroll() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId::ROOT_ID;
1
        let node_id = NodeId::new(0);
        #[cfg(feature = "std")]
1
        let now = Instant::System(std::time::Instant::now().into());
        #[cfg(not(feature = "std"))]
        let now = Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 });
        // Programmatic scroll with animation
1
        window.scroll_manager.scroll_to(
1
            dom_id,
1
            node_id,
1
            LogicalPosition::new(100.0, 200.0),
1
            Duration::System(SystemTimeDiff::from_millis(300)),
1
            EasingFunction::EaseOut,
1
            now.clone(),
        );
1
        let tick_result = window.scroll_manager.tick(now);
        // Programmatic scroll should start animation
1
        assert!(tick_result.needs_repaint);
1
    }
    #[test]
1
    fn test_gpu_cache_scrollbar_opacity_keys() {
1
        let fc_cache = FcFontCache::default();
1
        let mut window = LayoutWindow::new(fc_cache).unwrap();
1
        let dom_id = DomId::ROOT_ID;
1
        let node_id = NodeId::new(0);
        // Get or create GPU cache
1
        let gpu_cache = window.get_or_create_gpu_cache(dom_id);
        // Initially no scrollbar opacity keys
1
        assert!(gpu_cache.scrollbar_v_opacity_keys.is_empty());
1
        assert!(gpu_cache.scrollbar_h_opacity_keys.is_empty());
        // Add a vertical scrollbar opacity key
1
        let opacity_key = azul_core::resources::OpacityKey::unique();
1
        gpu_cache
1
            .scrollbar_v_opacity_keys
1
            .insert((dom_id, node_id), opacity_key);
1
        gpu_cache
1
            .scrollbar_v_opacity_values
1
            .insert((dom_id, node_id), 1.0);
        // Verify it was added
1
        assert_eq!(gpu_cache.scrollbar_v_opacity_keys.len(), 1);
1
        assert_eq!(
1
            gpu_cache.scrollbar_v_opacity_values.get(&(dom_id, node_id)),
            Some(&1.0)
        );
1
    }
}
// --- Cross-Paragraph Cursor Navigation API ---
impl LayoutWindow {
    /// Finds the next text node in the DOM tree after the given node.
    ///
    /// This function performs a depth-first traversal to find the next node
    /// that contains text content and is selectable (user-select != none).
    ///
    /// # Arguments
    /// * `dom_id` - The ID of the DOM containing the current node
    /// * `current_node` - The current node ID to start searching from
    ///
    /// # Returns
    /// * `Some((DomId, NodeId))` - The next text node if found
    /// * `None` - If no next text node exists
    pub fn find_next_text_node(
        &self,
        dom_id: &DomId,
        current_node: NodeId,
    ) -> Option<(DomId, NodeId)> {
        let layout_result = self.get_layout_result(dom_id)?;
        let styled_dom = &layout_result.styled_dom;
        // Start from the next node in document order
        let start_idx = current_node.index() + 1;
        let node_hierarchy = &styled_dom.node_hierarchy;
        for i in start_idx..node_hierarchy.len() {
            let node_id = NodeId::new(i);
            // Check if node has text content
            if self.node_has_text_content(styled_dom, node_id) {
                // Check if text is selectable
                if self.is_text_selectable(styled_dom, node_id) {
                    return Some((*dom_id, node_id));
                }
            }
        }
        None
    }
    /// Finds the previous text node in the DOM tree before the given node.
    ///
    /// This function performs a reverse depth-first traversal to find the previous node
    /// that contains text content and is selectable.
    ///
    /// # Arguments
    /// * `dom_id` - The ID of the DOM containing the current node
    /// * `current_node` - The current node ID to start searching from
    ///
    /// # Returns
    /// * `Some((DomId, NodeId))` - The previous text node if found
    /// * `None` - If no previous text node exists
    pub fn find_prev_text_node(
        &self,
        dom_id: &DomId,
        current_node: NodeId,
    ) -> Option<(DomId, NodeId)> {
        let layout_result = self.get_layout_result(dom_id)?;
        let styled_dom = &layout_result.styled_dom;
        // Start from the previous node in reverse document order
        let current_idx = current_node.index();
        for i in (0..current_idx).rev() {
            let node_id = NodeId::new(i);
            // Check if node has text content
            if self.node_has_text_content(styled_dom, node_id) {
                // Check if text is selectable
                if self.is_text_selectable(styled_dom, node_id) {
                    return Some((*dom_id, node_id));
                }
            }
        }
        None
    }
    /// Find the last text child node of a given node.
    ///
    /// For contenteditable elements, the text is usually in a child Text node,
    /// not the contenteditable div itself. This function finds the last Text node
    /// so the cursor defaults to the end position.
    fn find_last_text_child(&self, dom_id: DomId, parent_node_id: NodeId) -> Option<NodeId> {
        let layout_result = self.layout_results.get(&dom_id)?;
        let styled_dom = &layout_result.styled_dom;
        let node_data_container = styled_dom.node_data.as_container();
        let hierarchy_container = styled_dom.node_hierarchy.as_container();
        // Check if parent itself is a text node
        let parent_type = node_data_container[parent_node_id].get_node_type();
        if matches!(parent_type, NodeType::Text(_)) {
            return Some(parent_node_id);
        }
        // Find the last text child by iterating through all children
        let parent_item = &hierarchy_container[parent_node_id];
        let mut last_text_child: Option<NodeId> = None;
        let mut current_child = parent_item.first_child_id(parent_node_id);
        while let Some(child_id) = current_child {
            let child_type = node_data_container[child_id].get_node_type();
            if matches!(child_type, NodeType::Text(_)) {
                last_text_child = Some(child_id);
            }
            current_child = hierarchy_container[child_id].next_sibling_id();
        }
        last_text_child
    }
    /// Checks if a node has text content.
    fn node_has_text_content(&self, styled_dom: &StyledDom, node_id: NodeId) -> bool {
        // Check if node itself is a text node
        let node_data_container = styled_dom.node_data.as_container();
        let node_type = node_data_container[node_id].get_node_type();
        if matches!(node_type, NodeType::Text(_)) {
            return true;
        }
        // Check if node has text children
        let hierarchy_container = styled_dom.node_hierarchy.as_container();
        let node_item = &hierarchy_container[node_id];
        // Iterate through children
        let mut current_child = node_item.first_child_id(node_id);
        while let Some(child_id) = current_child {
            let child_type = node_data_container[child_id].get_node_type();
            if matches!(child_type, NodeType::Text(_)) {
                return true;
            }
            // Move to next sibling
            current_child = hierarchy_container[child_id].next_sibling_id();
        }
        false
    }
    /// Checks if text in a node is selectable based on CSS user-select property.
    fn is_text_selectable(&self, styled_dom: &StyledDom, node_id: NodeId) -> bool {
        let node_state = &styled_dom.styled_nodes.as_container()[node_id].styled_node_state;
        crate::solver3::getters::is_text_selectable(styled_dom, node_id, node_state)
    }
    /// Process an accessibility action from an assistive technology.
    ///
    /// This method dispatches actions to the appropriate managers (scroll, focus, etc.)
    /// and returns information about which nodes were affected and how.
    ///
    /// # Arguments
    /// * `dom_id` - The DOM containing the target node
    /// * `node_id` - The target node for the action
    /// * `action` - The accessibility action to perform
    /// * `now` - Current timestamp for animations
    ///
    /// # Returns
    /// A BTreeMap of affected nodes with:
    /// - Key: DomNodeId that was affected
    /// - Value: (Vec<EventFilter> synthetic events to dispatch, bool indicating if node needs
    ///   re-layout)
    ///
    /// Empty map = action was not applicable or nothing changed
    #[cfg(feature = "a11y")]
    pub fn process_accessibility_action(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        action: azul_core::dom::AccessibilityAction,
        now: std::time::Instant,
    ) -> BTreeMap<DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
        use crate::managers::text_input::TextInputSource;
        let mut affected_nodes = BTreeMap::new();
        match action {
            // Focus actions
            AccessibilityAction::Focus => {
                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                let dom_node_id = DomNodeId {
                    dom: dom_id,
                    node: hierarchy_id,
                };
                self.focus_manager.set_focused_node(Some(dom_node_id));
                // Check if node is contenteditable - if so, initialize cursor at end of text
                if let Some(layout_result) = self.layout_results.get(&dom_id) {
                    if let Some(styled_node) = layout_result
                        .styled_dom
                        .node_data
                        .as_ref()
                        .get(node_id.index())
                    {
                        // Check BOTH: the contenteditable boolean field AND the attribute
                        // NodeData has a direct `contenteditable: bool` field that should be
                        // checked in addition to the attribute for robustness
                        let is_contenteditable = styled_node.is_contenteditable()
                            || styled_node.attributes().as_ref().iter().any(|attr| {
                                matches!(attr, azul_core::dom::AttributeType::ContentEditable(_))
                            });
                        if is_contenteditable {
                            // Get inline layout for cursor positioning
                            // Clone the Arc to avoid borrow conflict
                            let inline_layout = self.get_inline_layout_for_node(dom_id, node_id).cloned();
                            if let Some(ref layout) = inline_layout {
                                let cursor = layout.items.iter().rev()
                                    .find_map(|item| if let crate::text3::cache::ShapedItem::Cluster(c) = &item.item {
                                        Some(azul_core::selection::TextCursor {
                                            cluster_id: c.source_cluster_id,
                                            affinity: azul_core::selection::CursorAffinity::Trailing,
                                        })
                                    } else { None })
                                    .unwrap_or(azul_core::selection::TextCursor {
                                        cluster_id: azul_core::selection::GraphemeClusterId { source_run: 0, start_byte_in_run: 0 },
                                        affinity: azul_core::selection::CursorAffinity::Trailing,
                                    });
                                self.text_edit_manager.initialize_editing(cursor, dom_id, node_id, 0);
                                // Scroll cursor into view if necessary
                                self.scroll_cursor_into_view_if_needed(dom_id, node_id, now);
                            }
                        } else {
                            // Not editable - clear cursor
                            self.text_edit_manager.clear_editing();
                        }
                    }
                }
                // Optionally scroll into view
                self.scroll_to_node_if_needed(dom_id, node_id, now);
            }
            AccessibilityAction::Blur => {
                self.focus_manager.clear_focus();
                self.text_edit_manager.clear_editing();
            }
            AccessibilityAction::SetSequentialFocusNavigationStartingPoint => {
                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                let dom_node_id = DomNodeId {
                    dom: dom_id,
                    node: hierarchy_id,
                };
                self.focus_manager.set_focused_node(Some(dom_node_id));
                // Clear cursor for focus navigation
                self.text_edit_manager.clear_editing();
            }
            // Scroll actions
            AccessibilityAction::ScrollIntoView => {
                self.scroll_to_node_if_needed(dom_id, node_id, now);
            }
            AccessibilityAction::ScrollLeft |
            AccessibilityAction::ScrollRight |
            AccessibilityAction::ScrollUp |
            AccessibilityAction::ScrollDown => {
                // Find the scrollable ancestor (or the node itself if scrollable)
                let dom_node_id = DomNodeId {
                    dom: dom_id,
                    node: NodeHierarchyItemId::from_crate_internal(Some(node_id)),
                };
                let (scroll_dom, scroll_nid) = self.find_scrollable_ancestor(dom_node_id)
                    .and_then(|a| Some((a.dom, a.node.into_crate_internal()?)))
                    .unwrap_or((dom_id, node_id));
                // Use viewport-relative scroll amounts (75% of viewport dimension)
                let bounds = self.get_node_bounds(scroll_dom, scroll_nid);
                let vp_h = bounds.map(|b| b.size.height as f32).unwrap_or(600.0);
                let vp_w = bounds.map(|b| b.size.width as f32).unwrap_or(800.0);
                let (dx, dy) = match action {
                    AccessibilityAction::ScrollLeft  => (-vp_w * 0.75, 0.0),
                    AccessibilityAction::ScrollRight => ( vp_w * 0.75, 0.0),
                    AccessibilityAction::ScrollUp    => (0.0, -vp_h * 0.75),
                    AccessibilityAction::ScrollDown  => (0.0,  vp_h * 0.75),
                    _ => unreachable!(),
                };
                self.scroll_manager.scroll_by(
                    scroll_dom,
                    scroll_nid,
                    LogicalPosition { x: dx, y: dy },
                    std::time::Duration::from_millis(250).into(),
                    azul_core::events::EasingFunction::EaseOut,
                    now.into(),
                );
            }
            AccessibilityAction::SetScrollOffset(pos) => {
                self.scroll_manager.scroll_to(
                    dom_id,
                    node_id,
                    pos,
                    std::time::Duration::from_millis(0).into(),
                    azul_core::events::EasingFunction::Linear,
                    now.into(),
                );
            }
            AccessibilityAction::ScrollToPoint(pos) => {
                self.scroll_manager.scroll_to(
                    dom_id,
                    node_id,
                    pos,
                    std::time::Duration::from_millis(300).into(),
                    azul_core::events::EasingFunction::EaseInOut,
                    now.into(),
                );
            }
            // Actions that should trigger element callbacks if they exist
            // These generate synthetic EventFilters that go through the normal
            // callback system
            AccessibilityAction::Default => {
                // Default action → synthetic Click event
                let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                let dom_node_id = DomNodeId {
                    dom: dom_id,
                    node: hierarchy_id,
                };
                // Default action maps to a synthetic MouseUp (click) event
                let event_filter = EventFilter::Hover(HoverEventFilter::MouseUp);
                affected_nodes.insert(dom_node_id, (vec![event_filter], false));
            }
            AccessibilityAction::Increment | AccessibilityAction::Decrement => {
                // Increment/Decrement work by:
                // 1. Reading the current value (from "value" attribute or text content)
                // 2. Parsing it as a number
                // 3. Incrementing/decrementing by 1
                // 4. Converting back to string
                // 5. Recording as text input (fires TextInput event)
                //
                // This allows user callbacks to intercept via On::TextInput
                let is_increment = matches!(action, AccessibilityAction::Increment);
                // Get the current value
                let current_value = if let Some(layout_result) = self.layout_results.get(&dom_id) {
                    if let Some(styled_node) = layout_result
                        .styled_dom
                        .node_data
                        .as_ref()
                        .get(node_id.index())
                    {
                        // Try "value" attribute first
                        styled_node
                            .attributes()
                            .as_ref()
                            .iter()
                            .find_map(|attr| {
                                if let AttributeType::Value(v) = attr {
                                    Some(v.as_str().to_string())
                                } else {
                                    None
                                }
                            })
                            .or_else(|| {
                                // Fallback to text content
                                if let NodeType::Text(text) = styled_node.get_node_type() {
                                    Some(text.as_str().to_string())
                                } else {
                                    None
                                }
                            })
                    } else {
                        None
                    }
                } else {
                    None
                };
                // Parse as number, increment/decrement, convert back to string
                if let Some(value_str) = current_value {
                    let parsed: Result<f64, _> = value_str.trim().parse();
                    let new_value_str = if let Ok(num) = parsed {
                        // Successfully parsed as number
                        let new_num = if is_increment { num + 1.0 } else { num - 1.0 };
                        // Format with same precision as input if possible
                        if num.fract() == 0.0 {
                            format!("{}", new_num as i64)
                        } else {
                            format!("{}", new_num)
                        }
                    } else {
                        // Not a number - treat as 0 and increment/decrement
                        if is_increment {
                            "1".to_string()
                        } else {
                            "-1".to_string()
                        }
                    };
                    // Record as text input (will fire On::TextInput callbacks)
                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                    let dom_node_id = DomNodeId {
                        dom: dom_id,
                        node: hierarchy_id,
                    };
                    // Get old text for changeset
                    let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
                    let old_text = self.extract_text_from_inline_content(&old_inline_content);
                    // Record the text input
                    self.text_input_manager.record_input(
                        dom_node_id,
                        new_value_str,
                        old_text,
                        TextInputSource::Accessibility,
                    );
                    // Add TextInput event to affected nodes
                    affected_nodes.insert(
                        dom_node_id,
                        (vec![EventFilter::Focus(FocusEventFilter::TextInput)], false),
                    );
                }
            }
            AccessibilityAction::Collapse | AccessibilityAction::Expand => {
                // Map to corresponding On:: events
                let event_type = match action {
                    AccessibilityAction::Collapse => On::Collapse,
                    AccessibilityAction::Expand => On::Expand,
                    _ => unreachable!(),
                };
                // Check if node has a callback for this event type
                if let Some(layout_result) = self.layout_results.get(&dom_id) {
                    if let Some(styled_node) = layout_result
                        .styled_dom
                        .node_data
                        .as_ref()
                        .get(node_id.index())
                    {
                        // Check if any callback matches this event type
                        let has_callback = styled_node
                            .callbacks
                            .as_ref()
                            .iter()
                            .any(|cb| cb.event == event_type.into());
                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                        let dom_node_id = DomNodeId {
                            dom: dom_id,
                            node: hierarchy_id,
                        };
                        if has_callback {
                            // Generate EventFilter for this specific callback
                            affected_nodes.insert(dom_node_id, (vec![event_type.into()], false));
                        } else {
                            // No specific callback - fallback to regular Click
                            affected_nodes.insert(
                                dom_node_id,
                                (vec![EventFilter::Hover(HoverEventFilter::MouseUp)], false),
                            );
                        }
                    }
                }
            }
            // Context menu - check if node has a menu and trigger right-click event
            AccessibilityAction::ShowContextMenu => {
                // Check if the node has a context menu attached
                let layout_result = match self.layout_results.get(&dom_id) {
                    Some(lr) => lr,
                    None => {
                        return affected_nodes;
                    }
                };
                // Get the node from the styled DOM
                let styled_node = match layout_result
                    .styled_dom
                    .node_data
                    .as_ref()
                    .get(node_id.index())
                {
                    Some(node) => node,
                    None => {
                        return affected_nodes;
                    }
                };
                // Check if node has context menu
                let has_context_menu = styled_node.get_context_menu().is_some();
                if has_context_menu {
                    // Return a synthetic right-click so the caller's event dispatcher
                    // triggers the normal context-menu code path (platform-specific).
                    let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                    let dom_node_id = DomNodeId { dom: dom_id, node: hierarchy_id };
                    affected_nodes.insert(
                        dom_node_id,
                        (vec![azul_core::events::EventFilter::Hover(
                            azul_core::events::HoverEventFilter::RightMouseDown,
                        )], false),
                    );
                }
            }
            // Text editing actions - use text3/edit.rs
            AccessibilityAction::ReplaceSelectedText(ref text) => {
                let nodes = self.edit_text_node(
                    dom_id,
                    node_id,
                    TextEditType::ReplaceSelection(text.as_str().to_string()),
                );
                for node in nodes {
                    affected_nodes.insert(node, (Vec::new(), true)); // true = needs re-layout
                }
            }
            AccessibilityAction::SetValue(ref text) => {
                let nodes = self.edit_text_node(
                    dom_id,
                    node_id,
                    TextEditType::SetValue(text.as_str().to_string()),
                );
                for node in nodes {
                    affected_nodes.insert(node, (Vec::new(), true));
                }
            }
            AccessibilityAction::SetNumericValue(value) => {
                let nodes = self.edit_text_node(
                    dom_id,
                    node_id,
                    TextEditType::SetNumericValue(value.get() as f64),
                );
                for node in nodes {
                    affected_nodes.insert(node, (Vec::new(), true));
                }
            }
            AccessibilityAction::SetTextSelection(selection) => {
                // Get the text layout for this node from the layout tree
                let text_layout = self.get_node_inline_layout(dom_id, node_id);
                if let Some(inline_layout) = text_layout {
                    // Convert byte offsets to TextCursor positions
                    let start_cursor = self.byte_offset_to_cursor(
                        inline_layout.as_ref(),
                        selection.selection_start as u32,
                    );
                    let end_cursor = self.byte_offset_to_cursor(
                        inline_layout.as_ref(),
                        selection.selection_end as u32,
                    );
                    if let (Some(start), Some(end)) = (start_cursor, end_cursor) {
                        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
                        let dom_node_id = DomNodeId {
                            dom: dom_id,
                            node: hierarchy_id,
                        };
                        if start == end {
                            // Same position - just set cursor
                            if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
                                mc.set_single_cursor(start);
                            }
                        } else {
                            // Different positions - set cursor to start of selection
                            if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
                                mc.set_single_cursor(start);
                            }
                        }
                    } else {
                        // Could not convert byte offsets to cursors - silently ignore
                    }
                } else {
                    // No text layout available for node - silently ignore
                }
            }
            // Tooltip actions
            AccessibilityAction::ShowTooltip | AccessibilityAction::HideTooltip => {
                // TODO: Integrate with tooltip manager when implemented
            }
            AccessibilityAction::CustomAction(_id) => {
                // TODO: Allow custom action handlers
            }
        }
        affected_nodes
    }
    /// Process text input from keyboard using cursor/selection/focus managers.
    ///
    /// This is the new unified text input handling. The framework manages text editing
    /// internally using managers, then fires callbacks (On::TextInput, On::Changed)
    /// after the internal state is already updated.
    ///
    /// ## Workflow
    /// 1. Check if focus manager has a focused contenteditable node
    /// 2. Get cursor/selection from managers
    /// 3. Call edit_text_node to apply the edit and update cache
    /// 4. Collect affected nodes that need dirty marking
    /// 5. Return map for re-layout triggering
    ///
    /// ## Parameters
    /// * `text_input` - The text that was typed (can be multiple chars for IME)
    ///
    /// ## Returns
    /// BTreeMap of affected nodes with:
    /// - Key: DomNodeId that was affected
    /// - Value: (Vec<EventFilter> synthetic events, bool needs_relayout)
    /// - Empty map = no focused contenteditable node
572
    pub fn record_text_input(
572
        &mut self,
572
        text_input: &str,
572
    ) -> BTreeMap<azul_core::dom::DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
        use std::collections::BTreeMap;
        use crate::managers::text_input::TextInputSource;
572
        let mut affected_nodes = BTreeMap::new();
572
        if text_input.is_empty() {
            return affected_nodes;
572
        }
        // Get focused node
572
        let focused_node = match self.focus_manager.get_focused_node().copied() {
572
            Some(node) => node,
            None => {
                return affected_nodes;
            }
        };
572
        let node_id = match focused_node.node.into_crate_internal() {
572
            Some(id) => id,
            None => {
                return affected_nodes;
            }
        };
        // Get the OLD text before any changes
572
        let old_inline_content = self.get_text_before_textinput(focused_node.dom, node_id);
572
        let old_text = self.extract_text_from_inline_content(&old_inline_content);
        // Record the changeset in TextInputManager (but DON'T apply changes yet)
572
        self.text_input_manager.record_input(
572
            focused_node,
572
            text_input.to_string(),
572
            old_text,
572
            TextInputSource::Keyboard, // Assuming keyboard for now
        );
        // Return affected nodes with TextInput event so callbacks can be invoked
572
        let text_input_event = vec![EventFilter::Focus(FocusEventFilter::TextInput)];
572
        affected_nodes.insert(focused_node, (text_input_event, false)); // false = no re-layout yet
572
        affected_nodes
572
    }
    /// Apply the recorded text changeset to the text cache
    ///
    /// This is called AFTER user callbacks, if preventDefault was not set.
    /// This is where we actually compute the new text and update the cache.
    ///
    /// Also updates the cursor position to reflect the edit.
    ///
    /// Returns the nodes that need to be marked dirty for re-layout,
    /// and whether a full re-layout is needed (text size changed).
572
    pub fn apply_text_changeset(&mut self) -> TextChangesetResult {
        // Get the changeset from TextInputManager
572
        let empty = TextChangesetResult { dirty_nodes: Vec::new(), needs_relayout: false };
572
        let changeset = match self.text_input_manager.get_pending_changeset() {
572
            Some(cs) => {
572
                cs.clone()
            }
            None => {
                return empty;
            }
        };
572
        let node_id = match changeset.node.node.into_crate_internal() {
572
            Some(id) => id,
            None => {
                self.text_input_manager.clear_changeset();
                return empty;
            }
        };
572
        let dom_id = changeset.node.dom;
        // Check if node is contenteditable
572
        let layout_result = match self.layout_results.get(&dom_id) {
572
            Some(lr) => lr,
            None => {
                self.text_input_manager.clear_changeset();
                return empty;
            }
        };
572
        let styled_node = match layout_result
572
            .styled_dom
572
            .node_data
572
            .as_ref()
572
            .get(node_id.index())
        {
572
            Some(node) => node,
            None => {
                self.text_input_manager.clear_changeset();
                return empty;
            }
        };
        // Check BOTH: the contenteditable boolean field AND the attribute
        // NodeData has a direct `contenteditable: bool` field that should be
        // checked in addition to the attribute for robustness
572
        let is_contenteditable = styled_node.is_contenteditable()
            || styled_node.attributes().as_ref().iter().any(|attr| {
                matches!(attr, azul_core::dom::AttributeType::ContentEditable(_))
            });
572
        if !is_contenteditable {
            self.text_input_manager.clear_changeset();
            return empty;
572
        }
        // Get the current inline content from cache
572
        let content = self.get_text_before_textinput(dom_id, node_id);
        // Get current cursor/selection — prefer non-empty MultiCursorState, fall back to legacy
572
        let mc_selections = self.text_edit_manager.multi_cursor.as_ref()
572
            .map(|mc| mc.to_selections())
572
            .unwrap_or_default();
572
        let current_selection = if !mc_selections.is_empty() {
572
            mc_selections
        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
            vec![Selection::Cursor(cursor)]
        } else {
            vec![Selection::Cursor(TextCursor {
                cluster_id: GraphemeClusterId {
                    source_run: 0,
                    start_byte_in_run: 0,
                },
                affinity: CursorAffinity::Leading,
            })]
        };
        // Capture pre-state for undo/redo BEFORE mutation
572
        let old_text = self.extract_text_from_inline_content(&content);
572
        let old_cursor = current_selection.first().and_then(|sel| {
572
            if let Selection::Cursor(c) = sel {
572
                Some(c.clone())
            } else {
                None
            }
572
        });
572
        let old_selection_range = current_selection.first().and_then(|sel| {
572
            if let Selection::Range(r) = sel {
                Some(*r)
            } else {
572
                None
            }
572
        });
572
        let pre_state = crate::managers::undo_redo::NodeStateSnapshot {
572
            node_id: azul_core::id::NodeId::new(node_id.index()),
572
            text_content: old_text.into(),
572
            cursor_position: old_cursor.into(),
572
            selection_range: old_selection_range.into(),
572
            #[cfg(feature = "std")]
572
            timestamp: azul_core::task::Instant::System(std::time::Instant::now().into()),
572
            #[cfg(not(feature = "std"))]
572
            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
572
        };
        // Apply the edit using text3::edit - this is a pure function
        use crate::text3::edit::{edit_text, TextEdit};
572
        let text_edit = TextEdit::Insert(changeset.inserted_text.as_str().to_string());
572
        let (new_content, new_selections) = edit_text(&content, &current_selection, &text_edit);
        // Update cursors from edit result
572
        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
572
            mc.update_from_edit_result(&new_selections);
572
        }
        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
        // Update the text cache with the new inline content
572
        self.update_text_cache_after_edit(dom_id, node_id, new_content);
        // Record this operation to the undo/redo manager AFTER successful mutation
        use crate::managers::changeset::{TextChangeset, TextOpInsertText, TextOperation};
        // Get the new cursor position after edit using the layout's cursor rect
572
        let new_cursor = self
572
            .get_focused_cursor_rect()
572
            .map(|r| CursorPosition::InWindow(r.origin))
572
            .unwrap_or(CursorPosition::Uninitialized);
572
        let old_cursor_pos = old_cursor
572
            .as_ref()
572
            .map(|_| {
                // The old cursor position was before the edit — the layout may
                // have already updated so we use the same rect as new_cursor.
                // This is acceptable for undo: the exact pre-edit position is
                // approximated; what matters is restoring focus to the node.
572
                self.get_focused_cursor_rect()
572
                    .map(|r| CursorPosition::InWindow(r.origin))
572
                    .unwrap_or(CursorPosition::Uninitialized)
572
            })
572
            .unwrap_or(CursorPosition::Uninitialized);
        // Generate a unique changeset ID
        static CHANGESET_COUNTER: std::sync::atomic::AtomicUsize =
            std::sync::atomic::AtomicUsize::new(0);
572
        let changeset_id = CHANGESET_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
572
        let undo_changeset = TextChangeset {
572
            id: changeset_id,
572
            target: changeset.node,
572
            operation: TextOperation::InsertText(TextOpInsertText {
572
                text: changeset.inserted_text.clone(),
572
                position: old_cursor_pos,
572
                new_cursor,
572
            }),
572
            #[cfg(feature = "std")]
572
            timestamp: azul_core::task::Instant::System(std::time::Instant::now().into()),
572
            #[cfg(not(feature = "std"))]
572
            timestamp: azul_core::task::Instant::Tick(azul_core::task::SystemTick { tick_counter: 0 }),
572
        };
572
        self.undo_redo_manager
572
            .record_operation(undo_changeset, pre_state);
        // Clear the changeset now that it's been applied
572
        self.text_input_manager.clear_changeset();
        // Check if any dirty text node needs ancestor relayout (text size changed)
572
        let needs_relayout = self.dirty_text_nodes.values()
572
            .any(|d| d.needs_ancestor_relayout);
        // Return nodes that need dirty marking
572
        let dirty_nodes = self.determine_dirty_text_nodes(dom_id, node_id);
572
        TextChangesetResult { dirty_nodes, needs_relayout }
572
    }
    /// Determine which nodes need to be marked dirty after a text edit
    ///
    /// Returns the edited node + its parent (if it exists)
572
    fn determine_dirty_text_nodes(
572
        &self,
572
        dom_id: DomId,
572
        node_id: NodeId,
572
    ) -> Vec<azul_core::dom::DomNodeId> {
572
        let layout_result = match self.layout_results.get(&dom_id) {
572
            Some(lr) => lr,
            None => return Vec::new(),
        };
572
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
572
        let node_dom_id = azul_core::dom::DomNodeId {
572
            dom: dom_id,
572
            node: hierarchy_id,
572
        };
        // Get parent node ID
572
        let parent_id = layout_result
572
            .styled_dom
572
            .node_hierarchy
572
            .as_container()
572
            .get(node_id)
572
            .and_then(|item| item.parent_id())
572
            .map(|parent_node_id| {
572
                let parent_hierarchy_id =
572
                    NodeHierarchyItemId::from_crate_internal(Some(parent_node_id));
572
                azul_core::dom::DomNodeId {
572
                    dom: dom_id,
572
                    node: parent_hierarchy_id,
572
                }
572
            });
        // Return node + parent (if exists)
572
        if let Some(parent) = parent_id {
572
            vec![node_dom_id, parent]
        } else {
            vec![node_dom_id]
        }
572
    }
    /// Legacy name for backward compatibility
    #[inline]
    pub fn process_text_input(
        &mut self,
        text_input: &str,
    ) -> BTreeMap<azul_core::dom::DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
        self.record_text_input(text_input)
    }
    /// Get the last text changeset (what was changed in the last text input)
13
    pub fn get_last_text_changeset(&self) -> Option<&PendingTextEdit> {
13
        self.text_input_manager.get_pending_changeset()
13
    }
    /// Get the current inline content (text before text input is applied)
    ///
    /// This is a query function that retrieves the current text state from the node.
    /// Returns InlineContent vector if the node has text.
    ///
    /// # Implementation Note
    /// This function FIRST checks `dirty_text_nodes` for optimistic state (edits not yet
    /// committed to StyledDom), then falls back to the StyledDom. This is critical for
    /// correct text input handling - without this, each keystroke would read stale state.
1760
    pub fn get_text_before_textinput(&self, dom_id: DomId, node_id: NodeId) -> Vec<InlineContent> {
        // CRITICAL FIX: Check dirty_text_nodes first!
        // If the node has been edited since last full layout, its most up-to-date
        // content is in dirty_text_nodes, NOT in the StyledDom.
        // Without this check, every keystroke reads the ORIGINAL text instead of
        // the accumulated edits, causing bugs like double-input and wrong node affected.
1760
        if let Some(dirty_node) = self.dirty_text_nodes.get(&(dom_id, node_id)) {
528
            return dirty_node.content.clone();
1232
        }
        // Fallback to committed state from StyledDom
        // Get the layout result for this DOM
1232
        let layout_result = match self.layout_results.get(&dom_id) {
1232
            Some(lr) => lr,
            None => return Vec::new(),
        };
        // Get the node data
1232
        let node_data = match layout_result
1232
            .styled_dom
1232
            .node_data
1232
            .as_ref()
1232
            .get(node_id.index())
        {
1232
            Some(nd) => nd,
            None => return Vec::new(),
        };
        // Extract text content from the node
1232
        match node_data.get_node_type() {
616
            NodeType::Text(text) => {
                // Simple text node - create a single StyledRun
616
                let style = self.get_text_style_for_node(dom_id, node_id);
616
                vec![InlineContent::Text(StyledRun {
616
                    text: text.as_str().to_string(),
616
                    style,
616
                    logical_start_byte: 0,
616
                    source_node_id: Some(node_id),
616
                })]
            }
            NodeType::Div | NodeType::Body | NodeType::VirtualView => {
                // Container nodes - recursively collect text from children
616
                self.collect_text_from_children(dom_id, node_id)
            }
            _ => {
                // Other node types (Image, etc.) don't contribute text
                Vec::new()
            }
        }
1760
    }
    /// Get the font style for a text node from CSS
616
    fn get_text_style_for_node(
616
        &self,
616
        dom_id: DomId,
616
        node_id: NodeId,
616
    ) -> alloc::sync::Arc<StyleProperties> {
        use alloc::sync::Arc;
616
        let layout_result = match self.layout_results.get(&dom_id) {
616
            Some(lr) => lr,
            None => return Arc::new(Default::default()),
        };
        // Use the proper CSS property resolution from solver3::getters
616
        let vp = layout_result.viewport.size;
616
        let props = crate::solver3::getters::get_style_properties(
616
            &layout_result.styled_dom,
616
            node_id,
616
            self.system_style.as_ref(),
616
            azul_css::props::basic::PhysicalSize::new(vp.width, vp.height),
        );
616
        Arc::new(props)
616
    }
    /// Recursively collect text content from child nodes
616
    fn collect_text_from_children(
616
        &self,
616
        dom_id: DomId,
616
        parent_node_id: NodeId,
616
    ) -> Vec<InlineContent> {
616
        let layout_result = match self.layout_results.get(&dom_id) {
616
            Some(lr) => lr,
            None => return Vec::new(),
        };
616
        let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
616
        let parent_item = match node_hierarchy.get(parent_node_id.index()) {
616
            Some(item) => item,
            None => return Vec::new(),
        };
616
        let mut result = Vec::new();
        // Traverse all children
616
        let mut current_child = parent_item.first_child_id(parent_node_id);
1232
        while let Some(child_id) = current_child {
            // Get content from this child (recursive)
616
            let child_content = self.get_text_before_textinput(dom_id, child_id);
616
            result.extend(child_content);
            // Move to next sibling
616
            let child_item = match node_hierarchy.get(child_id.index()) {
616
                Some(item) => item,
                None => break,
            };
616
            current_child = child_item.next_sibling_id();
        }
616
        result
616
    }
    /// Extract plain text string from inline content
    ///
    /// This is a helper for building the changeset's resulting_text field.
1144
    pub fn extract_text_from_inline_content(&self, content: &[InlineContent]) -> String {
1144
        let mut result = String::new();
2288
        for item in content {
1144
            match item {
1144
                InlineContent::Text(text_run) => {
1144
                    result.push_str(&text_run.text);
1144
                }
                InlineContent::Space(_) => {
                    result.push(' ');
                }
                InlineContent::LineBreak(_) => {
                    result.push('\n');
                }
                InlineContent::Tab { .. } => {
                    result.push('\t');
                }
                InlineContent::Ruby { base, .. } => {
                    // For Ruby annotations, include the base text
                    result.push_str(&self.extract_text_from_inline_content(base));
                }
                InlineContent::Marker { run, .. } => {
                    // Markers contribute their text
                    result.push_str(&run.text);
                }
                // Images and shapes don't contribute to plain text
                InlineContent::Image(_) | InlineContent::Shape(_) => {}
            }
        }
1144
        result
1144
    }
    /// Update the text cache after a text edit
    ///
    /// This is the ONLY place where we mutate the text cache.
    /// All other functions are pure queries or transformations.
    ///
    /// This function:
    /// 1. Stores the new content in `dirty_text_nodes` for tracking
    /// 2. Re-runs the text3 layout pipeline (create_logical_items -> reorder -> shape -> fragment)
    /// 3. Updates the inline_layout_result on the IFC root node in the layout tree
572
    pub fn update_text_cache_after_edit(
572
        &mut self,
572
        dom_id: DomId,
572
        node_id: NodeId,
572
        new_inline_content: Vec<InlineContent>,
572
    ) {
        use crate::solver3::layout_tree::CachedInlineLayout;
        // 1. Store the new content in dirty_text_nodes for tracking
572
        let cursor = self.text_edit_manager.get_primary_cursor();
572
        self.dirty_text_nodes.insert(
572
            (dom_id, node_id),
572
            DirtyTextNode {
572
                content: new_inline_content.clone(),
572
                cursor,
572
                needs_ancestor_relayout: false, // Will be set if size changes
572
            },
        );
        // 2. Get the cached constraints from the existing inline layout result.
        // We need to find the IFC root node. The layout tree uses its own indices
        // (different from DOM node IDs), so we must go through dom_to_layout.
        // The IFC may be on this node OR a child — search all mapped layout nodes
        // and their children for one with inline_layout_result.
572
        let (mut constraints, ifc_layout_index) = {
572
            let layout_result = match self.layout_results.get(&dom_id) {
572
                Some(r) => r,
                None => {
                    return;
                }
            };
            // Find the layout node with inline_layout_result via dom_to_layout
572
            let mut found: Option<(usize, &CachedInlineLayout)> = None;
            // First check layout nodes mapped to this DOM node
572
            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
572
                for &idx in layout_indices {
572
                    if let Some(w) = layout_result.layout_tree.warm(idx) {
572
                        if let Some(ref cached) = w.inline_layout_result {
572
                            found = Some((idx, cached));
572
                            break;
                        }
                    }
                }
            }
            // If not found on this node, check child DOM nodes (text children of contenteditable)
572
            if found.is_none() {
                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_ref();
                if let Some(parent_item) = node_hierarchy.get(node_id.index()) {
                    let mut child = parent_item.first_child_id(node_id);
                    while let Some(child_id) = child {
                        if let Some(child_indices) = layout_result.layout_tree.dom_to_layout.get(&child_id) {
                            for &idx in child_indices {
                                if let Some(w) = layout_result.layout_tree.warm(idx) {
                                    if let Some(ref cached) = w.inline_layout_result {
                                        found = Some((idx, cached));
                                        break;
                                    }
                                }
                            }
                        }
                        if found.is_some() { break; }
                        child = node_hierarchy.get(child_id.index()).and_then(|h| h.next_sibling_id());
                    }
                }
572
            }
572
            let (ifc_idx, cached_layout) = match found {
572
                Some(f) => {
572
                    f
                },
                None => {
                    return;
                }
            };
572
            match &cached_layout.constraints {
572
                Some(c) => (c.clone(), ifc_idx),
                None => {
                    return;
                }
            }
        };
        // 2b. Refresh available_width from the containing block's used_size.
        //
        // The IFC root's `.parent` in the layout tree may point to a grandparent
        // (e.g. body) rather than the actual CSS containing block (the contenteditable
        // div) — layout tree parentage doesn't always match DOM parentage.
        //
        // Use `node_id` (the contenteditable DOM element) via dom_to_layout to find
        // the correct containing block. Its content-box width is what constrains text.
572
        if let Some(layout_result) = self.layout_results.get(&dom_id) {
572
            let mut found_width = false;
            // Look up the contenteditable div's layout node directly via DOM mapping
572
            if let Some(layout_indices) = layout_result.layout_tree.dom_to_layout.get(&node_id) {
572
                for &idx in layout_indices {
572
                    if let Some(container_node) = layout_result.layout_tree.get(idx) {
572
                        if let Some(container_size) = container_node.used_size {
572
                            let bp = container_node.box_props.unpack();
572
                            let content_width = container_size.width
572
                                - bp.padding.left - bp.padding.right
572
                                - bp.border.left - bp.border.right;
572
                            if content_width > 0.0 {
572
                                constraints.available_width =
572
                                    crate::text3::cache::AvailableSpace::Definite(content_width);
572
                                found_width = true;
572
                            }
572
                            break;
                        }
                    }
                }
            }
            // Fallback: walk up the IFC's ancestors in the layout tree
572
            if !found_width {
                if let Some(parent_idx) = layout_result.layout_tree.get(ifc_layout_index)
                    .and_then(|n| n.parent)
                {
                    if let Some(parent_node) = layout_result.layout_tree.get(parent_idx) {
                        if let Some(parent_size) = parent_node.used_size {
                            let bp = parent_node.box_props.unpack();
                            let content_width = parent_size.width
                                - bp.padding.left - bp.padding.right
                                - bp.border.left - bp.border.right;
                            if content_width > 0.0 {
                                constraints.available_width =
                                    crate::text3::cache::AvailableSpace::Definite(content_width);
                            }
                        }
                    }
                }
572
            }
        }
        // 3. Re-run the text3 layout pipeline.
        //
        // Try the incremental path first: it runs stages 1-3 (logical items,
        // bidi, shape) on the new content and, if the cached layout is
        // still reusable (same item count, no overflow, line breaks cached),
        // skips stage 4 (line-breaking + positioning). For edits whose new
        // advances fall into GlyphSwap/LineShift territory, this turns a
        // full IFC relayout into a glyph + x-position patch.
572
        let cached_snapshot = self
572
            .layout_results
572
            .get(&dom_id)
572
            .and_then(|lr| lr.layout_tree.warm(ifc_layout_index))
572
            .and_then(|w| w.inline_layout_result.as_ref())
572
            .cloned();
572
        let new_layout = if let Some(cached) = cached_snapshot {
572
            self.try_incremental_text_relayout(
572
                &new_inline_content,
572
                &constraints,
572
                &cached,
572
                node_id,
            )
572
            .map(|(layout, _skipped_fragment)| layout)
        } else {
            self.relayout_text_node_internal(&new_inline_content, &constraints)
        };
572
        let Some(new_layout) = new_layout else {
            return;
        };
        // 4. Update the layout cache with the new layout
        // Use the ifc_layout_index we found earlier (correct layout tree index)
572
        if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
572
            let old_size = layout_result.layout_tree.get(ifc_layout_index).and_then(|n| n.used_size);
572
            let new_bounds = new_layout.bounds();
572
            let new_size = Some(LogicalSize {
572
                width: new_bounds.width,
572
                height: new_bounds.height,
572
            });
            // Check if we need to propagate layout shift
572
            if let (Some(old), Some(new)) = (old_size, new_size) {
572
                if (old.height - new.height).abs() > 0.5 || (old.width - new.width).abs() > 0.5 {
                    // Mark that ancestor relayout is needed
572
                    if let Some(dirty_node) = self.dirty_text_nodes.get_mut(&(dom_id, node_id)) {
572
                        dirty_node.needs_ancestor_relayout = true;
572
                    }
                }
            }
            // Update the inline layout result with the new layout but preserve constraints (warm data)
572
            if let Some(warm_node) = layout_result.layout_tree.warm_mut(ifc_layout_index) {
572
                warm_node.inline_layout_result = Some(CachedInlineLayout::new_with_constraints(
572
                    Arc::new(new_layout),
572
                    constraints.available_width,
572
                    false, // No floats in quick relayout
572
                    constraints,
572
                ));
572
            }
        }
        // CRITICAL: Regenerate the display list after updating the inline layout.
        // Without this, the old display list (with old text glyphs) is sent to WebRender,
        // so the screen still shows the old text even though the layout tree is updated.
572
        self.regenerate_display_list_for_dom(dom_id);
572
    }
    /// Re-apply a dirty text node's content to the layout cache after a full DOM rebuild.
    ///
    /// Called by regenerate_layout() after layout_and_generate_display_list().
    /// The layout just ran on the stale DOM text, so we re-shape the edited text
    /// from dirty_text_nodes and update the inline layout result + display list.
    /// Inject preedit text into the text cache and regenerate the display list.
    ///
    /// Called from the platform IME handler (setMarkedText). Gets the current
    /// text content, splices the preedit string at the cursor position, then
    /// re-shapes and regenerates the display list so the preedit glyphs appear
    /// inline with an underline.
    pub fn apply_preedit_to_text_cache(&mut self, dom_id: DomId, node_id: NodeId) {
        let preedit = match &self.text_edit_manager.preedit_text {
            Some(p) if !p.is_empty() => p.clone(),
            _ => {
                // No preedit — restore original text and clear snapshot
                self.pre_preedit_content = None;
                self.reapply_dirty_text_node(dom_id, node_id);
                return;
            }
        };
        let cursor = match self.text_edit_manager.get_primary_cursor() {
            Some(c) => c,
            None => return,
        };
        // Save the original content on the FIRST preedit call so we always
        // inject into clean text (prevents accumulation of old preedits).
        if self.pre_preedit_content.is_none() {
            let original = self.get_text_before_textinput(dom_id, node_id);
            self.pre_preedit_content = Some(original);
        }
        // Clone the saved original — never modify it in place
        let mut content = self.pre_preedit_content.clone().unwrap();
        // Insert preedit at cursor position
        let run_idx = cursor.cluster_id.source_run as usize;
        let byte_pos = cursor.cluster_id.start_byte_in_run as usize;
        if let Some(crate::text3::cache::InlineContent::Text(run)) = content.get_mut(run_idx) {
            let clamped_pos = byte_pos.min(run.text.len());
            run.text.insert_str(clamped_pos, &preedit);
        }
        // Re-shape text with preedit injected — font fallback handles CJK
        self.update_text_cache_after_edit(dom_id, node_id, content);
        self.regenerate_display_list_for_dom(dom_id);
    }
    pub fn reapply_dirty_text_node(&mut self, dom_id: DomId, node_id: NodeId) {
        let content = match self.dirty_text_nodes.get(&(dom_id, node_id)) {
            Some(dirty) => dirty.content.clone(),
            None => return,
        };
        // Re-run text shaping and update layout cache
        self.update_text_cache_after_edit(dom_id, node_id, content);
        // Regenerate display list with updated text
        self.regenerate_display_list_for_dom(dom_id);
    }
    /// Regenerate the display list for a specific DOM from the current layout tree.
    ///
    /// This is the critical missing piece for text input: after `update_text_cache_after_edit`
    /// updates the `inline_layout_result` on layout tree nodes, the `DomLayoutResult.display_list`
    /// must be regenerated. Otherwise, `generate_frame()` sends the OLD display list to WebRender
    /// and the screen shows stale text.
    ///
    /// This method creates a temporary `LayoutContext` from the existing `LayoutWindow` state
    /// and calls `generate_display_list` on the already-computed layout tree and positions.
572
    pub fn regenerate_display_list_for_dom(&mut self, dom_id: DomId) {
        use crate::solver3::{
            display_list::generate_display_list,
            LayoutContext,
        };
        // Get all the data we need from the layout result
572
        let layout_result = match self.layout_results.get(&dom_id) {
572
            Some(lr) => lr,
            None => { return; }
        };
572
        let tree = &layout_result.layout_tree;
572
        let calculated_positions = &layout_result.calculated_positions;
572
        let scroll_ids = &layout_result.scroll_ids;
572
        let styled_dom = &layout_result.styled_dom;
572
        let viewport = layout_result.viewport;
        // Get scroll offsets from scroll manager
572
        let scroll_offsets = self.scroll_manager.get_scroll_states_for_dom(dom_id);
        // Get GPU cache for this DOM
572
        let gpu_cache = self.gpu_state_manager.get_or_create_cache(dom_id).clone();
        // Get cursor state for display list generation
572
        let cursor_is_visible = self.text_edit_manager.should_draw_cursor();
572
        let cursor_locations = self.text_edit_manager.build_cursor_locations();
572
        let text_selections_map = self.text_edit_manager.build_text_selections_map();
        // Build a temporary LayoutContext with all the state we need
572
        let mut counter_values = HashMap::new();
572
        let mut debug_messages: Option<Vec<LayoutDebugMessage>> = None;
572
        let cache_map = std::mem::take(&mut self.layout_cache.cache_map);
572
        let mut ctx = LayoutContext {
572
            scrollbar_style_cache: core::cell::RefCell::new(std::collections::HashMap::new()),
572
            styled_dom,
572
            font_manager: &self.font_manager,
572
            text_selections: &text_selections_map,
572
            debug_messages: &mut debug_messages,
572
            counters: &mut counter_values,
572
            viewport_size: viewport.size,
572
            fragmentation_context: None,
572
            cursor_is_visible,
572
            cursor_locations,
572
            preedit_text: self.text_edit_manager.preedit_text.clone(),
572
            cache_map,
572
            image_cache: &self.image_cache,
572
            system_style: self.system_style.clone(),
572
            get_system_time_fn: azul_core::task::GetSystemTimeCallback {
572
                cb: azul_core::task::get_system_time_libstd,
572
            },
572
            dirty_text_overrides: BTreeMap::new(),
572
        };
        // Generate the new display list from the existing layout tree
572
        let new_display_list = generate_display_list(
572
            &mut ctx,
572
            tree,
572
            calculated_positions,
572
            &scroll_offsets,
572
            scroll_ids,
572
            Some(&gpu_cache),
572
            &self.renderer_resources,
572
            self.id_namespace,
572
            dom_id,
        );
        // Restore the cache_map back to layout_cache
572
        self.layout_cache.cache_map = std::mem::take(&mut ctx.cache_map);
572
        match new_display_list {
572
            Ok(display_list) => {
572
                if let Some(layout_result) = self.layout_results.get_mut(&dom_id) {
572
                    layout_result.display_list = display_list;
572
                }
                // Incremental a11y update: only push the edited node's
                // updated value + cursor, not the entire tree.
                #[cfg(feature = "a11y")]
572
                self.update_a11y_tree_incremental();
            }
            Err(_e) => {
            }
        }
572
    }
    /// Internal helper to re-run the text3 layout pipeline on new content
    fn relayout_text_node_internal(
        &self,
        content: &[InlineContent],
        constraints: &UnifiedConstraints,
    ) -> Option<UnifiedLayout> {
        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
        if logical_items.is_empty() {
            return Some(UnifiedLayout {
                items: Vec::new(),
                overflow: crate::text3::cache::OverflowInfo::default(),
            });
        }
        self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)
    }
    /// Stages 1-3 of the text3 pipeline (logical items, bidi reorder, shape).
    /// Returned separately so an incremental relayout path can skip stage 4
    /// (line breaking + positioning) when the cached layout is reusable.
572
    fn shape_text_for_relayout(
572
        &self,
572
        content: &[InlineContent],
572
        constraints: &UnifiedConstraints,
572
    ) -> Option<(
572
        Vec<crate::text3::cache::LogicalItem>,
572
        Vec<crate::text3::cache::ShapedItem>,
572
    )> {
        use crate::text3::cache::{
            create_logical_items, reorder_logical_items, shape_visual_items, BidiDirection,
        };
572
        let logical_items = create_logical_items(content, &[], &mut None);
572
        if logical_items.is_empty() {
            return Some((logical_items, Vec::new()));
572
        }
572
        let base_direction = constraints.direction.unwrap_or(BidiDirection::Ltr);
572
        let visual_items = reorder_logical_items(
572
            &logical_items,
572
            base_direction,
572
            crate::text3::cache::UnicodeBidi::Normal,
572
            &mut None,
        )
572
        .ok()?;
572
        let loaded_fonts = self.font_manager.get_loaded_fonts();
572
        let shaped_items = shape_visual_items(
572
            &visual_items,
572
            self.font_manager.get_font_chain_cache(),
572
            &self.font_manager.fc_cache,
572
            &loaded_fonts,
572
            &mut None,
        )
572
        .ok()?;
572
        Some((logical_items, shaped_items))
572
    }
    /// Stage 4 of the text3 pipeline: line breaking + positioning.
572
    fn fragment_layout_from_shaped(
572
        &self,
572
        logical_items: &[crate::text3::cache::LogicalItem],
572
        shaped_items: &[crate::text3::cache::ShapedItem],
572
        constraints: &UnifiedConstraints,
572
    ) -> Option<UnifiedLayout> {
        use crate::text3::cache::{perform_fragment_layout, BreakCursor};
572
        let loaded_fonts = self.font_manager.get_loaded_fonts();
572
        let mut cursor = BreakCursor::new(shaped_items);
572
        perform_fragment_layout(&mut cursor, logical_items, constraints, &mut None, &loaded_fonts).ok()
572
    }
    /// Attempt an incremental IFC relayout for a text edit.
    ///
    /// Runs stages 1-3 (logical items, bidi, shape) on the new content, then
    /// checks whether the cached UnifiedLayout can be patched without
    /// re-running line-breaking (stage 4).
    ///
    /// Returns `Some((new_layout, skipped_fragment_layout))`:
    ///   - `skipped_fragment_layout == true` means we took the incremental
    ///     fast path and returned a patched cached layout.
    ///   - `skipped_fragment_layout == false` means we fell back to full
    ///     fragment_layout (stage 4) but reused shape output from stages 1-3.
    ///
    /// Returns `None` only if logical_items + reorder + shape itself fails.
572
    fn try_incremental_text_relayout(
572
        &self,
572
        content: &[InlineContent],
572
        constraints: &UnifiedConstraints,
572
        cached: &crate::solver3::layout_tree::CachedInlineLayout,
572
        edited_node_id: NodeId,
572
    ) -> Option<(UnifiedLayout, bool)> {
        use crate::text3::cache::{
            try_incremental_relayout as decide_incremental,
            IncrementalRelayoutResult, PositionedItem, ShapedItem,
        };
572
        let (logical_items, shaped_items) = self.shape_text_for_relayout(content, constraints)?;
572
        if logical_items.is_empty() {
            return Some((
                UnifiedLayout {
                    items: Vec::new(),
                    overflow: crate::text3::cache::OverflowInfo::default(),
                },
                true,
            ));
572
        }
        // Incremental patching requires:
        //   - The cached layout came with line-break metadata.
        //   - No overflow in the cached layout (patching positions around
        //     overflow is not supported).
        //   - The new shape output has the same number of items as the
        //     cached positioned items, so we can zip 1:1.
572
        let incremental_ok = cached.line_breaks.is_some()
572
            && cached.layout.overflow.overflow_items.is_empty()
572
            && shaped_items.len() == cached.layout.items.len();
572
        if incremental_ok {
            let line_breaks = cached.line_breaks.as_ref().unwrap();
            let old_advances: Vec<f32> =
                cached.item_metrics.iter().map(|m| m.advance_width).collect();
            let new_advances: Vec<f32> =
                shaped_items.iter().map(|si| si.bounds().width).collect();
            // An item is dirty if its advance width changed OR it originates
            // from the edited DOM node. The latter is needed so GlyphSwap
            // (same-width edits) still invalidates glyph data, not just
            // positions.
            let mut dirty_indices: Vec<usize> = Vec::new();
            for (i, (old_a, new_a)) in old_advances.iter().zip(new_advances.iter()).enumerate() {
                if (new_a - old_a).abs() > 0.01 {
                    dirty_indices.push(i);
                }
            }
            for (i, si) in shaped_items.iter().enumerate() {
                if let ShapedItem::Cluster(c) = si {
                    if c.source_node_id == Some(edited_node_id)
                        && !dirty_indices.contains(&i)
                    {
                        dirty_indices.push(i);
                    }
                }
            }
            dirty_indices.sort_unstable();
            dirty_indices.dedup();
            let decision =
                decide_incremental(&dirty_indices, &old_advances, &new_advances, line_breaks);
            match decision {
                IncrementalRelayoutResult::GlyphSwap => {
                    // Widths unchanged — keep cached positions and line
                    // assignments, swap in the new shaped items so their
                    // glyph data reflects the edit.
                    let items: Vec<PositionedItem> = cached
                        .layout
                        .items
                        .iter()
                        .zip(shaped_items.into_iter())
                        .map(|(old_positioned, new_shaped)| PositionedItem {
                            item: new_shaped,
                            position: old_positioned.position,
                            line_index: old_positioned.line_index,
                        })
                        .collect();
                    return Some((
                        UnifiedLayout {
                            items,
                            overflow: cached.layout.overflow.clone(),
                        },
                        true,
                    ));
                }
                IncrementalRelayoutResult::LineShift {
                    affected_item,
                    delta,
                } => {
                    // Width changed but the line still fits — shift x
                    // positions of items after `affected_item` on the same
                    // line. Items on later lines keep their positions.
                    let affected_line = cached.layout.items[affected_item].line_index;
                    let items: Vec<PositionedItem> = cached
                        .layout
                        .items
                        .iter()
                        .zip(shaped_items.into_iter())
                        .enumerate()
                        .map(|(i, (old_positioned, new_shaped))| {
                            let mut position = old_positioned.position;
                            if i > affected_item && old_positioned.line_index == affected_line {
                                position.x += delta;
                            }
                            PositionedItem {
                                item: new_shaped,
                                position,
                                line_index: old_positioned.line_index,
                            }
                        })
                        .collect();
                    return Some((
                        UnifiedLayout {
                            items,
                            overflow: cached.layout.overflow.clone(),
                        },
                        true,
                    ));
                }
                IncrementalRelayoutResult::PartialReflow { .. }
                | IncrementalRelayoutResult::FullRelayout => {
                    // Fall through to full fragment layout.
                }
            }
572
        }
        // Fall-back: run stage 4 (line breaking + positioning) with the
        // already-computed logical + shaped items. Still cheaper than the
        // plain full path because stages 1-3 aren't repeated.
572
        let layout = self.fragment_layout_from_shaped(&logical_items, &shaped_items, constraints)?;
572
        Some((layout, false))
572
    }
    /// Helper to get node used_size for accessibility actions
    #[cfg(feature = "a11y")]
    fn get_node_used_size_a11y(
        &self,
        dom_id: DomId,
        node_id: NodeId,
    ) -> Option<azul_core::geom::LogicalSize> {
        let layout_result = self.layout_results.get(&dom_id)?;
        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
        let idx = *layout_indices.first()?;
        let node = layout_result.layout_tree.get(idx)?;
        node.used_size
    }
    /// Get the layout bounds (position and size) of a specific node
    pub fn get_node_bounds(
        &self,
        dom_id: DomId,
        node_id: NodeId,
    ) -> Option<azul_css::props::basic::LayoutRect> {
        use azul_css::props::basic::LayoutRect;
        let layout_result = self.layout_results.get(&dom_id)?;
        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
        let idx = *layout_indices.first()?;
        let node = layout_result.layout_tree.get(idx)?;
        // Get size from used_size
        let size = node.used_size?;
        // Get position from calculated_positions — uses layout tree index, not DOM node index
        let position = layout_result.calculated_positions.get(idx)?;
        Some(LayoutRect {
            origin: azul_css::props::basic::LayoutPoint {
                x: position.x as f32 as isize,
                y: position.y as f32 as isize,
            },
            size: azul_css::props::basic::LayoutSize {
                width: size.width as isize,
                height: size.height as isize,
            },
        })
    }
    /// Scroll a node into view if it's not currently visible in the viewport
    #[cfg(feature = "a11y")]
    fn scroll_to_node_if_needed(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        now: std::time::Instant,
    ) {
        // 1. Get target node bounds
        let Some(target_bounds) = self.get_node_bounds(dom_id, node_id) else {
            return;
        };
        // 2. Find nearest scrollable ancestor
        let dom_node_id = azul_core::dom::DomNodeId {
            dom: dom_id,
            node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_id)),
        };
        let Some(scroll_ancestor) = self.find_scrollable_ancestor(dom_node_id) else {
            return;
        };
        let Some(scroll_node_id) = scroll_ancestor.node.into_crate_internal() else {
            return;
        };
        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
            return;
        };
        let current_scroll = self
            .scroll_manager
            .get_current_offset(dom_id, scroll_node_id)
            .unwrap_or_default();
        // 3. Check if target is already visible in the ancestor viewport
        let vp_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
        let vp_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
        let vp_w = ancestor_bounds.size.width as f32;
        let vp_h = ancestor_bounds.size.height as f32;
        let target_x = target_bounds.origin.x as f32;
        let target_y = target_bounds.origin.y as f32;
        let target_w = target_bounds.size.width as f32;
        let target_h = target_bounds.size.height as f32;
        let visible_x = target_x >= vp_x && (target_x + target_w) <= (vp_x + vp_w);
        let visible_y = target_y >= vp_y && (target_y + target_h) <= (vp_y + vp_h);
        if visible_x && visible_y {
            return; // Already visible
        }
        // 4. Calculate scroll offset to bring target into view
        let mut scroll_x = current_scroll.x;
        let mut scroll_y = current_scroll.y;
        if target_x < vp_x {
            scroll_x = target_x - ancestor_bounds.origin.x as f32;
        } else if (target_x + target_w) > (vp_x + vp_w) {
            scroll_x = (target_x + target_w) - ancestor_bounds.origin.x as f32 - vp_w;
        }
        if target_y < vp_y {
            scroll_y = target_y - ancestor_bounds.origin.y as f32;
        } else if (target_y + target_h) > (vp_y + vp_h) {
            scroll_y = (target_y + target_h) - ancestor_bounds.origin.y as f32 - vp_h;
        }
        self.scroll_manager.scroll_to(
            dom_id,
            scroll_node_id,
            LogicalPosition { x: scroll_x, y: scroll_y },
            std::time::Duration::from_millis(300).into(),
            azul_core::events::EasingFunction::EaseOut,
            now.into(),
        );
    }
    /// Scroll the cursor into view if it's not currently visible
    ///
    /// This is automatically called when:
    /// - Focus lands on a contenteditable element
    /// - Cursor is moved programmatically
    /// - Text is inserted/deleted
    ///
    /// The function:
    /// 1. Gets the cursor rectangle from the text layout
    /// 2. Checks if the cursor is visible in the current viewport
    /// 3. If not, calculates the minimum scroll offset needed
    /// 4. Animates the scroll to bring the cursor into view
    fn scroll_cursor_into_view_if_needed(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        now: std::time::Instant,
    ) {
        // Get the cursor from multi_cursor
        let Some(cursor) = self.text_edit_manager.get_primary_cursor() else {
            return;
        };
        // Get the inline layout for this node
        let Some(inline_layout) = self.get_node_inline_layout(dom_id, node_id) else {
            return;
        };
        // Get the cursor rectangle from the text layout
        let Some(cursor_rect) = inline_layout.get_cursor_rect(&cursor) else {
            return;
        };
        // Get the node bounds
        let Some(node_bounds) = self.get_node_bounds(dom_id, node_id) else {
            return;
        };
        // Calculate the cursor's absolute position
        let cursor_abs_x = node_bounds.origin.x as f32 + cursor_rect.origin.x;
        let cursor_abs_y = node_bounds.origin.y as f32 + cursor_rect.origin.y;
        // Walk up the DOM tree to find the nearest scrollable ancestor
        let dom_node_id = azul_core::dom::DomNodeId {
            dom: dom_id,
            node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(node_id)),
        };
        let scroll_ancestor = match self.find_scrollable_ancestor(dom_node_id) {
            Some(a) => a,
            None => return, // No scrollable container
        };
        let scroll_node_id = match scroll_ancestor.node.into_crate_internal() {
            Some(id) => id,
            None => return,
        };
        // Get the scrollable ancestor's bounds and scroll offset
        let Some(ancestor_bounds) = self.get_node_bounds(dom_id, scroll_node_id) else {
            return;
        };
        let current_scroll = self
            .scroll_manager
            .get_current_offset(dom_id, scroll_node_id)
            .unwrap_or_default();
        // Calculate visible viewport from the scrollable ancestor
        let viewport_x = ancestor_bounds.origin.x as f32 + current_scroll.x;
        let viewport_y = ancestor_bounds.origin.y as f32 + current_scroll.y;
        let viewport_width = ancestor_bounds.size.width as f32;
        let viewport_height = ancestor_bounds.size.height as f32;
        // Check if cursor is visible
        let cursor_visible_x = (cursor_abs_x as f32) >= viewport_x
            && (cursor_abs_x as f32) <= viewport_x + viewport_width;
        let cursor_visible_y = (cursor_abs_y as f32) >= viewport_y
            && (cursor_abs_y as f32) <= viewport_y + viewport_height;
        if cursor_visible_x && cursor_visible_y {
            // Cursor is already visible
            return;
        }
        // Calculate scroll offset to make cursor visible
        let mut target_scroll_x = current_scroll.x;
        let mut target_scroll_y = current_scroll.y;
        // Adjust horizontal scroll if needed
        if (cursor_abs_x as f32) < viewport_x {
            target_scroll_x = cursor_abs_x as f32 - ancestor_bounds.origin.x as f32;
        } else if (cursor_abs_x as f32) > viewport_x + viewport_width {
            target_scroll_x = cursor_abs_x as f32 - ancestor_bounds.origin.x as f32 - viewport_width
                + cursor_rect.size.width;
        }
        // Adjust vertical scroll if needed
        if (cursor_abs_y as f32) < viewport_y {
            target_scroll_y = cursor_abs_y as f32 - ancestor_bounds.origin.y as f32;
        } else if (cursor_abs_y as f32) > viewport_y + viewport_height {
            target_scroll_y = cursor_abs_y as f32 - ancestor_bounds.origin.y as f32 - viewport_height
                + cursor_rect.size.height;
        }
        // Animate scroll on the scrollable ancestor
        self.scroll_manager.scroll_to(
            dom_id,
            scroll_node_id,
            LogicalPosition {
                x: target_scroll_x,
                y: target_scroll_y,
            },
            std::time::Duration::from_millis(200).into(),
            azul_core::events::EasingFunction::EaseOut,
            now.into(),
        );
    }
    /// Convert a byte offset in the text to a TextCursor position
    ///
    /// This is used for accessibility SetTextSelection action, which provides
    /// byte offsets rather than grapheme cluster IDs.
    ///
    /// # Arguments
    ///
    /// * `text_layout` - The text layout containing the shaped runs
    /// * `byte_offset` - The byte offset in the UTF-8 text
    ///
    /// # Returns
    ///
    /// A TextCursor positioned at the given byte offset, or None if the offset
    /// is out of bounds.
    fn byte_offset_to_cursor(
        &self,
        text_layout: &UnifiedLayout,
        byte_offset: u32,
    ) -> Option<TextCursor> {
        // Handle offset 0 as special case (start of text)
        if byte_offset == 0 {
            // Find first cluster in items
            for item in &text_layout.items {
                if let ShapedItem::Cluster(cluster) = &item.item {
                    return Some(TextCursor {
                        cluster_id: cluster.source_cluster_id,
                        affinity: CursorAffinity::Trailing,
                    });
                }
            }
            // No clusters found - return default
            return Some(TextCursor {
                cluster_id: GraphemeClusterId {
                    source_run: 0,
                    start_byte_in_run: 0,
                },
                affinity: CursorAffinity::Trailing,
            });
        }
        // Iterate through items to find which cluster contains this byte offset
        let mut current_byte_offset = 0u32;
        for item in &text_layout.items {
            if let ShapedItem::Cluster(cluster) = &item.item {
                // Calculate byte length of this cluster from its text
                let cluster_byte_length = cluster.text.len() as u32;
                let cluster_end_byte = current_byte_offset + cluster_byte_length;
                // Check if our target byte offset falls within this cluster
                if byte_offset >= current_byte_offset && byte_offset <= cluster_end_byte {
                    // Found the cluster
                    return Some(TextCursor {
                        cluster_id: cluster.source_cluster_id,
                        affinity: CursorAffinity::Trailing,
                    });
                }
                current_byte_offset = cluster_end_byte;
            }
        }
        // Offset is beyond the end of all text - return cursor at end of last cluster
        for item in text_layout.items.iter().rev() {
            if let ShapedItem::Cluster(cluster) = &item.item {
                return Some(TextCursor {
                    cluster_id: cluster.source_cluster_id,
                    affinity: CursorAffinity::Trailing,
                });
            }
        }
        // No clusters at all - return default position
        Some(TextCursor {
            cluster_id: GraphemeClusterId {
                source_run: 0,
                start_byte_in_run: 0,
            },
            affinity: CursorAffinity::Trailing,
        })
    }
    /// Get the inline layout result for a specific node
    ///
    /// This looks up the node in the layout tree and returns its inline layout result
    /// if it exists.
    fn get_node_inline_layout(
        &self,
        dom_id: DomId,
        node_id: NodeId,
    ) -> Option<alloc::sync::Arc<UnifiedLayout>> {
        // Get the layout tree from cache
        let layout_tree = self.layout_cache.tree.as_ref()?;
        // Find the layout node index corresponding to the DOM node
        let layout_idx = layout_tree
            .nodes
            .iter()
            .position(|node| node.dom_node_id == Some(node_id))?;
        // Return the inline layout result (warm data)
        layout_tree.warm(layout_idx)?
            .inline_layout_result
            .as_ref()
            .map(|c| c.clone_layout())
    }
    /// Edit the text content of a node (used for text input actions)
    ///
    /// This function applies text edits to nodes that contain text content.
    /// The DOM node itself is NOT modified - instead, the text cache is updated
    /// with the new shaped text that reflects the edit, cursor, and selection.
    ///
    /// It handles:
    /// - ReplaceSelectedText: Replaces the current selection with new text
    /// - SetValue: Sets the entire text value
    /// - SetNumericValue: Converts number to string and sets value
    ///
    /// # Returns
    ///
    /// Returns a Vec of DomNodeIds (node + parent) that need to be marked dirty
    /// for re-layout. The caller MUST use this return value to trigger layout.
    #[must_use = "Returned nodes must be marked dirty for re-layout"]
    #[cfg(feature = "a11y")]
    pub fn edit_text_node(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
        edit_type: TextEditType,
    ) -> Vec<azul_core::dom::DomNodeId> {
        use crate::managers::text_input::TextInputSource;
        // Convert TextEditType to string
        let text_input = match &edit_type {
            TextEditType::ReplaceSelection(text) => text.clone(),
            TextEditType::SetValue(text) => text.clone(),
            TextEditType::SetNumericValue(value) => value.to_string(),
        };
        // Get the OLD text before any changes
        let old_inline_content = self.get_text_before_textinput(dom_id, node_id);
        let old_text = self.extract_text_from_inline_content(&old_inline_content);
        // Create DomNodeId
        let hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(node_id));
        let dom_node_id = azul_core::dom::DomNodeId {
            dom: dom_id,
            node: hierarchy_id,
        };
        // Record the changeset in TextInputManager
        self.text_input_manager.record_input(
            dom_node_id,
            text_input,
            old_text,
            TextInputSource::Accessibility, // A11y source
        );
        // Immediately apply the changeset (A11y doesn't go through callbacks)
        self.apply_text_changeset().dirty_nodes
    }
    #[cfg(not(feature = "a11y"))]
    pub fn process_accessibility_action(
        &mut self,
        _dom_id: DomId,
        _node_id: NodeId,
        _action: azul_core::dom::AccessibilityAction,
        _now: std::time::Instant,
    ) -> BTreeMap<DomNodeId, (Vec<azul_core::events::EventFilter>, bool)> {
        // No-op when accessibility is disabled
        BTreeMap::new()
    }
    /// Process mouse click for text selection.
    ///
    /// This method handles:
    /// - Single click: Place cursor at click position
    /// - Double click: Select word at click position
    /// - Triple click: Select paragraph (line) at click position
    ///
    /// ## Workflow
    /// 1. Use HoverManager's hit test to find hit nodes
    /// 2. Find the IFC layout via `inline_layout_result` (IFC root) or `ifc_membership` (text node)
    /// 3. Use point_relative_to_item for local cursor position
    /// 4. Hit-test the text layout to get logical cursor
    /// 5. Apply appropriate selection based on click count
    /// 6. Update SelectionManager with new selection
    ///
    /// ## IFC Architecture
    /// Text nodes don't store `inline_layout_result` directly. Instead:
    /// - IFC root nodes (e.g., `<p>`) have `inline_layout_result` with the complete text layout
    /// - Text nodes have `ifc_membership` pointing back to their IFC root
    /// - This allows efficient lookup without iterating all nodes
    ///
    /// ## Parameters
    /// * `position` - Click position in logical coordinates (for click count tracking)
    /// * `time_ms` - Current time in milliseconds (for multi-click detection)
    ///
    /// ## Returns
    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering, None if click didn't hit text
    pub fn process_mouse_click_for_selection(
        &mut self,
        position: azul_core::geom::LogicalPosition,
        time_ms: u64,
    ) -> Option<Vec<azul_core::dom::DomNodeId>> {
        use crate::managers::hover::InputPointId;
        use crate::text3::selection::{select_paragraph_at_cursor, select_word_at_cursor};
        // found_selection stores: (dom_id, ifc_root_node_id, selection_range, local_pos)
        // IMPORTANT: We always store the IFC root NodeId, not the text node NodeId,
        // because selections are rendered via inline_layout_result which lives on the IFC root.
        let mut found_selection: Option<(DomId, NodeId, SelectionRange, azul_core::geom::LogicalPosition)> = None;
        // Try to get hit test from HoverManager first (fast path, uses WebRender's point_relative_to_item)
        if let Some(hit_test) = self.hover_manager.get_current(&InputPointId::Mouse) {
            // Iterate through hit nodes from the HoverManager
            for (dom_id, hit) in &hit_test.hovered_nodes {
                let layout_result = match self.layout_results.get(dom_id) {
                    Some(lr) => lr,
                    None => continue,
                };
                // Use layout tree from layout_result, not layout_cache
                let tree = &layout_result.layout_tree;
                // Sort by DOM depth (deepest first) to prefer specific text nodes over containers.
                // We count the actual number of parents to determine DOM depth properly.
                // Secondary sort by NodeId for deterministic ordering within the same depth.
                let node_hierarchy = layout_result.styled_dom.node_hierarchy.as_container();
                let get_dom_depth = |node_id: &NodeId| -> usize {
                    let mut depth = 0;
                    let mut current = *node_id;
                    while let Some(parent) = node_hierarchy.get(current).and_then(|h| h.parent_id()) {
                        depth += 1;
                        current = parent;
                    }
                    depth
                };
                let mut sorted_hits: Vec<_> = hit.regular_hit_test_nodes.iter().collect();
                sorted_hits.sort_by(|(a_id, _), (b_id, _)| {
                    let depth_a = get_dom_depth(a_id);
                    let depth_b = get_dom_depth(b_id);
                    // Higher depth = deeper in DOM = should come first
                    // Then sort by NodeId for deterministic order within same depth
                    depth_b.cmp(&depth_a).then_with(|| a_id.index().cmp(&b_id.index()))
                });
                for (node_id, hit_item) in sorted_hits {
                    // Check if text is selectable
                    if !self.is_text_selectable(&layout_result.styled_dom, *node_id) {
                        continue;
                    }
                    // Find the layout node for this DOM node
                    let layout_node_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(*node_id));
                    let layout_node_idx = match layout_node_idx {
                        Some(idx) => idx,
                        None => continue,
                    };
                    let warm_node = match tree.warm(layout_node_idx) {
                        Some(w) => w,
                        None => continue,
                    };
                    // Get the IFC layout and IFC root NodeId
                    // Selection must be stored on the IFC root, not on text nodes
                    let (cached_layout, ifc_root_node_id) = if let Some(ref cached) = warm_node.inline_layout_result {
                        // This node IS an IFC root - use its own NodeId
                        (cached, *node_id)
                    } else if let Some(ref membership) = warm_node.ifc_membership {
                        // This node participates in an IFC - get layout and NodeId from IFC root
                        match tree.warm(membership.ifc_root_layout_index) {
                            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)) {
                                (Some(cached), Some(root_dom_id)) => (cached, root_dom_id),
                                _ => continue,
                            },
                            None => continue,
                        }
                    } else {
                        // No IFC involvement - not a text node
                        continue;
                    };
                    let layout = &cached_layout.layout;
                    // Use point_relative_to_item - this is the local position within the hit node
                    // provided by WebRender's hit test
                    let local_pos = hit_item.point_relative_to_item;
                    // Hit-test the cursor in this text layout
                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
                        // Store selection with IFC root NodeId, not the hit text node
                        found_selection = Some((*dom_id, ifc_root_node_id, SelectionRange {
                            start: cursor.clone(),
                            end: cursor,
                        }, local_pos));
                        break;
                    }
                }
                if found_selection.is_some() {
                    break;
                }
            }
        }
        // Fallback: If HoverManager has no hit test (e.g., debug server),
        // search through IFC roots using global position
        if found_selection.is_none() {
            for (dom_id, layout_result) in &self.layout_results {
                // Use the layout tree from layout_result, not layout_cache
                // layout_cache.tree is for the root DOM only; layout_result.layout_tree
                // is the correct tree for each DOM (including virtualized views)
                let tree = &layout_result.layout_tree;
                // Only iterate IFC roots (nodes with inline_layout_result)
                for (node_idx, layout_node) in tree.nodes.iter().enumerate() {
                    let warm = match tree.warm(node_idx) {
                        Some(w) => w,
                        None => continue,
                    };
                    let cached_layout = match warm.inline_layout_result.as_ref() {
                        Some(c) => c,
                        None => continue, // Skip non-IFC-root nodes
                    };
                    let node_id = match layout_node.dom_node_id {
                        Some(n) => n,
                        None => continue,
                    };
                    // Check if text is selectable
                    if !self.is_text_selectable(&layout_result.styled_dom, node_id) {
                        continue;
                    }
                    // Get the node's absolute position
                    // Use layout_result.calculated_positions for the correct DOM
                    let node_pos = layout_result.calculated_positions
            .get(node_idx)
                        .copied()
                        .unwrap_or_default();
                    // Check if position is within node bounds
                    let node_size = layout_node.used_size.unwrap_or_else(|| {
                        let bounds = cached_layout.layout.bounds();
                        azul_core::geom::LogicalSize::new(bounds.width, bounds.height)
                    });
                    if position.x < node_pos.x || position.x > node_pos.x + node_size.width ||
                       position.y < node_pos.y || position.y > node_pos.y + node_size.height {
                        continue;
                    }
                    // Convert global position to node-local coordinates
                    let local_pos = azul_core::geom::LogicalPosition {
                        x: position.x - node_pos.x,
                        y: position.y - node_pos.y,
                    };
                    let layout = &cached_layout.layout;
                    // Hit-test the cursor in this text layout
                    if let Some(cursor) = layout.hittest_cursor(local_pos) {
                        found_selection = Some((*dom_id, node_id, SelectionRange {
                            start: cursor.clone(),
                            end: cursor,
                        }, local_pos));
                        break;
                    }
                }
                if found_selection.is_some() {
                    break;
                }
            }
        }
        let (dom_id, ifc_root_node_id, initial_range, _local_pos) = found_selection?;
        // Create DomNodeId for click state tracking - use IFC root's NodeId
        // Selection state is keyed by IFC root because that's where inline_layout_result lives
        let node_hierarchy_id = NodeHierarchyItemId::from_crate_internal(Some(ifc_root_node_id));
        let dom_node_id = azul_core::dom::DomNodeId {
            dom: dom_id,
            node: node_hierarchy_id,
        };
        // Derive click count from the gesture manager's session history
        // (timestamps + positions), no mutable click state needed.
        let click_count = self.gesture_drag_manager.detect_click_count();
        // Get the text layout again for word/paragraph selection
        let final_range = if click_count > 1 {
            // Use layout_results for the correct DOM's tree
            let layout_result = self.layout_results.get(&dom_id)?;
            let tree = &layout_result.layout_tree;
            // Find layout node - ifc_root_node_id is always the IFC root, so it has inline_layout_result
            let layout_idx = tree.nodes.iter().position(|n| n.dom_node_id == Some(ifc_root_node_id))?;
            let cached_layout = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
            let layout = &cached_layout.layout;
            match click_count {
                2 => select_word_at_cursor(&initial_range.start, layout.as_ref())
                    .unwrap_or(initial_range),
                3 => select_paragraph_at_cursor(&initial_range.start, layout.as_ref())
                    .unwrap_or(initial_range),
                _ => initial_range,
            }
        } else {
            initial_range
        };
        // CRITICAL FIX 1: Set focus on the clicked node
        // Without this, clicking on a contenteditable element shows a cursor but
        // text input doesn't work because record_text_input() checks focus_manager.get_focused_node()
        // and returns early if there's no focus.
        //
        // Check if the node OR ANY ANCESTOR is contenteditable before setting focus
        // The contenteditable attribute is typically on a parent div, not on the IFC root or text node
        let is_contenteditable = self.layout_results.get(&dom_id)
            .map(|lr| {
                let node_hierarchy = lr.styled_dom.node_hierarchy.as_container();
                let node_data = lr.styled_dom.node_data.as_ref();
                // Walk up the DOM tree to check if any ancestor has contenteditable
                let mut current_node = Some(ifc_root_node_id);
                while let Some(node_id) = current_node {
                    if let Some(styled_node) = node_data.get(node_id.index()) {
                        // Check BOTH: the contenteditable boolean field AND the attribute
                        // NodeData has a direct `contenteditable: bool` field that should be
                        // checked in addition to the attribute for robustness
                        if styled_node.is_contenteditable() {
                            return true;
                        }
                        // Also check the attribute (for backwards compatibility)
                        let has_contenteditable_attr = styled_node.attributes().as_ref().iter().any(|attr| {
                            matches!(attr, azul_core::dom::AttributeType::ContentEditable(_))
                        });
                        if has_contenteditable_attr {
                            return true;
                        }
                    }
                    // Move to parent
                    current_node = node_hierarchy.get(node_id).and_then(|h| h.parent_id());
                }
                false
            })
            .unwrap_or(false);
        // NOTE: Do NOT call focus_manager.set_focused_node() here!
        // The click-to-focus system in event.rs (process_window_events) handles
        // focus via SetFocus which also triggers apply_focus_restyle for :focus CSS.
        // Setting focus directly here bypasses that, causing the blue border to not
        // appear until the next full layout (e.g., resize).
        // Initialize editing at the clicked position via unified API.
        let ce_key = self.layout_results.get(&dom_id).map(|lr| {
            azul_core::diff::calculate_contenteditable_key(
                lr.styled_dom.node_data.as_ref(),
                lr.styled_dom.node_hierarchy.as_ref(),
                ifc_root_node_id,
            )
        }).unwrap_or(0);
        self.text_edit_manager.initialize_editing(
            final_range.start, dom_id, ifc_root_node_id, ce_key,
        );
        let now = azul_core::task::Instant::now();
        self.text_edit_manager.blink.reset_blink_on_input(now.clone());
        self.text_edit_manager.blink.set_blink_timer_active(true);
        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
        // Regenerate display list so cursor appears at the clicked position
        // (same pattern as handle_cursor_movement and apply_text_changeset)
        self.regenerate_display_list_for_dom(dom_id);
        // Return the affected node for dirty tracking
        Some(vec![dom_node_id])
    }
    /// Process mouse drag for text selection extension.
    ///
    /// This method handles drag-to-select by extending the selection from
    /// the anchor (mousedown position) to the current focus (drag position).
    ///
    /// Uses the anchor/focus model:
    /// - Anchor is fixed at the initial click position (set by process_mouse_click_for_selection)
    /// - Focus moves with the mouse during drag
    /// - Affected nodes between anchor and focus are computed in DOM order
    ///
    /// ## Parameters
    /// * `start_position` - Initial click position in logical coordinates (unused, anchor is stored)
    /// * `current_position` - Current mouse position in logical coordinates
    ///
    /// ## Returns
    /// * `Option<Vec<DomNodeId>>` - Affected nodes that need re-rendering
    pub fn process_mouse_drag_for_selection(
        &mut self,
        _start_position: azul_core::geom::LogicalPosition,
        current_position: azul_core::geom::LogicalPosition,
    ) -> Option<Vec<azul_core::dom::DomNodeId>> {
        use azul_core::selection::{Selection, SelectionRange};
        // Get the anchor cursor and editing node from MultiCursorState.
        // The anchor was set by process_mouse_click_for_selection.
        // IMPORTANT: For Range selections, the anchor is .start (fixed),
        // NOT .end (which moves with each drag event).
        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
        let anchor = match &mc.get_primary()?.selection {
            Selection::Cursor(c) => *c,
            Selection::Range(r) => r.start, // anchor stays fixed during drag
        };
        let dom_id = mc.node_id.dom;
        let node_id = mc.node_id.node.into_crate_internal()?;
        let dom_node_id = mc.node_id;
        // Hit-test the current drag position to get the focus cursor
        let layout_result = self.layout_results.get(&dom_id)?;
        let tree = &layout_result.layout_tree;
        let layout_idx = tree.nodes.iter()
            .position(|n| n.dom_node_id == Some(node_id))?;
        let node_pos = layout_result.calculated_positions
            .get(layout_idx)
            .copied()
            .unwrap_or_default();
        let cached = tree.warm(layout_idx)?.inline_layout_result.as_ref()?;
        let local_pos = azul_core::geom::LogicalPosition {
            x: current_position.x - node_pos.x,
            y: current_position.y - node_pos.y,
        };
        let focus = cached.layout.hittest_cursor(local_pos)?;
        // Update primary selection: Cursor → Range(anchor, focus)
        let mc = self.text_edit_manager.multi_cursor.as_mut()?;
        if let Some(primary) = mc.get_primary_mut() {
            if anchor == focus {
                primary.selection = Selection::Cursor(anchor);
            } else {
                primary.selection = Selection::Range(SelectionRange {
                    start: anchor,
                    end: focus,
                });
            }
        }
        self.text_edit_manager.mark_dirty();
        self.regenerate_display_list_for_dom(dom_id);
        Some(vec![dom_node_id])
    }
    /// Delete the currently selected text or one character at the cursor
    ///
    /// Handles Backspace/Delete key. If a range selection exists, the selected
    /// text is deleted. If only a cursor exists (no range), one character is
    /// deleted before (Backspace) or after (Delete) the cursor.
    ///
    /// ## Arguments
    /// * `target` - The target node (focused contenteditable element)
    /// * `forward` - true for Delete key (forward), false for Backspace (backward)
    ///
    /// ## Returns
    /// * `Some(Vec<DomNodeId>)` - Affected nodes if deletion occurred
    /// * `None` - If no cursor/selection exists or deletion failed
    pub fn delete_selection(
        &mut self,
        target: azul_core::dom::DomNodeId,
        forward: bool,
    ) -> Option<Vec<azul_core::dom::DomNodeId>> {
        let dom_id = target.dom;
        let node_id = target.node.into_crate_internal()?;
        // Multi-cursor path: use edit_text with DeleteBackward/DeleteForward
        let current_selections = if let Some(ref mc) = self.text_edit_manager.multi_cursor {
            mc.to_selections()
        } else if let Some(cursor) = self.text_edit_manager.get_primary_cursor() {
            vec![Selection::Cursor(cursor)]
        } else {
            return None;
        };
        let content = self.get_text_before_textinput(dom_id, node_id);
        let edit = if forward {
            crate::text3::edit::TextEdit::DeleteForward
        } else {
            crate::text3::edit::TextEdit::DeleteBackward
        };
        let (new_content, new_selections) = crate::text3::edit::edit_text(
            &content, &current_selections, &edit,
        );
        // Update multi-cursor state
        if let Some(ref mut mc) = self.text_edit_manager.multi_cursor {
            mc.update_from_edit_result(&new_selections);
        }
        // No legacy cursor manager sync needed -- multi_cursor is the source of truth
        self.update_text_cache_after_edit(dom_id, node_id, new_content);
        self.regenerate_display_list_for_dom(dom_id);
        Some(vec![target])
    }
    /// Extract clipboard content from the current selection
    ///
    /// This method extracts both plain text and styled text from the selection ranges.
    /// It iterates through all selected text, extracts the actual characters, and
    /// preserves styling information from the ShapedGlyph's StyleProperties.
    ///
    /// This is NOT reading from the system clipboard - use `clipboard_manager.get_paste_content()`
    /// for that. This extracts content FROM the selection TO be copied.
    ///
    /// ## Arguments
    /// * `dom_id` - The DOM to extract selection from
    ///
    /// ## Returns
    /// * `Some(ClipboardContent)` - If there is a selection with text
    /// * `None` - If no selection or no text layouts found
    pub fn get_selected_content_for_clipboard(
        &self,
        dom_id: &DomId,
    ) -> Option<crate::managers::selection::ClipboardContent> {
        use crate::managers::selection::ClipboardContent;
        use crate::text3::edit::cursor_byte_offset_in_run;
        let mc = self.text_edit_manager.multi_cursor.as_ref()?;
        let node_id = mc.node_id.node.into_crate_internal()?;
        // Collect range selections (collapsed cursors contribute nothing to a copy).
        let ranges: Vec<_> = mc.selections.iter().filter_map(|s| match &s.selection {
            azul_core::selection::Selection::Range(r) => Some(*r),
            _ => None,
        }).collect();
        if ranges.is_empty() {
            return None;
        }
        // Most editables are a single text run (the whole string, newlines and
        // all), so source_run is 0 and the single-run branch handles everything.
        // The multi-run branch is a best-effort for rich (multi-span) content.
        // Byte offsets are affinity-aware (cursor_byte_offset_in_run), so a
        // select-all whose end cursor is Trailing on the last cluster copies the
        // full text — matching the affinity fix in delete_range.
        let content = self.get_text_before_textinput(*dom_id, node_id);
        let mut plain = String::new();
        for r in &ranges {
            let sr = r.start.cluster_id.source_run as usize;
            let er = r.end.cluster_id.source_run as usize;
            if sr == er {
                if let Some(InlineContent::Text(run)) = content.get(sr) {
                    let a = cursor_byte_offset_in_run(&run.text, &r.start);
                    let b = cursor_byte_offset_in_run(&run.text, &r.end);
                    let (lo, hi) = (a.min(b), a.max(b));
                    if hi <= run.text.len() && lo < hi {
                        plain.push_str(&run.text[lo..hi]);
                    }
                }
            } else {
                // Multi-run: walk runs in document order, taking the tail of the
                // first run, all middle runs, and the head of the last.
                let (first_idx, first_cur, last_idx, last_cur) = if sr <= er {
                    (sr, r.start, er, r.end)
                } else {
                    (er, r.end, sr, r.start)
                };
                for ri in first_idx..=last_idx {
                    if let Some(InlineContent::Text(run)) = content.get(ri) {
                        if ri == first_idx {
                            let off = cursor_byte_offset_in_run(&run.text, &first_cur).min(run.text.len());
                            plain.push_str(&run.text[off..]);
                        } else if ri == last_idx {
                            let off = cursor_byte_offset_in_run(&run.text, &last_cur).min(run.text.len());
                            plain.push_str(&run.text[..off]);
                        } else {
                            plain.push_str(&run.text);
                        }
                    }
                }
            }
        }
        if plain.is_empty() {
            return None;
        }
        Some(ClipboardContent {
            plain_text: plain.into(),
            styled_runs: Vec::new().into(),
        })
    }
    /// Process image callback updates from callback changes
    ///
    /// This function re-invokes image callbacks for nodes that requested updates
    /// (typically from timer callbacks or resize events). It returns the updated
    /// textures along with their metadata for the rendering pipeline to process.
    ///
    /// # Arguments
    ///
    /// * `image_callbacks_changed` - Map of DomId -> Set of NodeIds that need re-rendering
    /// * `gl_context` - OpenGL context pointer for rendering
    ///
    /// # Returns
    ///
    /// Vector of (DomId, NodeId, Texture) tuples for textures that were updated
    pub fn process_image_callback_updates(
        &mut self,
        image_callbacks_changed: &BTreeMap<DomId, FastBTreeSet<NodeId>>,
        gl_context: &OptionGlContextPtr,
    ) -> Vec<(DomId, NodeId, azul_core::gl::Texture)> {
        use crate::callbacks::{RenderImageCallback, RenderImageCallbackInfo};
        let mut updated_textures = Vec::new();
        for (dom_id, node_ids) in image_callbacks_changed {
            let layout_result = match self.layout_results.get_mut(dom_id) {
                Some(lr) => lr,
                None => continue,
            };
            for node_id in node_ids {
                // Get the node data - store container ref to extend lifetime
                let node_data_container = layout_result.styled_dom.node_data.as_container();
                let node_data = match node_data_container.get(*node_id) {
                    Some(nd) => nd,
                    None => continue,
                };
                // Check if this is an Image node with a callback
                let has_callback = matches!(node_data.get_node_type(), NodeType::Image(img_ref)
                    if img_ref.get_image_callback().is_some());
                if !has_callback {
                    continue;
                }
                // Get layout indices for this DOM node (can have multiple due to text splitting,
                // etc.)
                let layout_indices = match layout_result.layout_tree.dom_to_layout.get(node_id) {
                    Some(indices) if !indices.is_empty() => indices,
                    _ => continue,
                };
                // Use the first layout index (primary node)
                let layout_index = layout_indices[0];
                // Get the position from calculated_positions
                let position = match layout_result.calculated_positions.get(layout_index) {
                    Some(pos) => *pos,
                    None => continue,
                };
                // Get the layout node to determine size
                let layout_node = match layout_result.layout_tree.get(layout_index) {
                    Some(ln) => ln,
                    None => continue,
                };
                // Get the size from the layout node (used_size is the computed size from layout)
                let (width, height) = match layout_node.used_size {
                    Some(size) => (size.width, size.height),
                    None => continue, // Node hasn't been laid out yet
                };
                let callback_domnode_id = DomNodeId {
                    dom: *dom_id,
                    node: azul_core::styled_dom::NodeHierarchyItemId::from_crate_internal(Some(
                        *node_id,
                    )),
                };
                let bounds = HidpiAdjustedBounds::from_bounds(
                    azul_css::props::basic::LayoutSize {
                        width: width as isize,
                        height: height as isize,
                    },
                    self.current_window_state.size.get_hidpi_factor(),
                );
                // Create callback info
                let mut gl_callback_info = RenderImageCallbackInfo::new(
                    callback_domnode_id,
                    bounds,
                    gl_context,
                    &self.image_cache,
                    &self.font_manager.fc_cache,
                );
                // Invoke the callback
                let new_image_ref = {
                    let mut node_data_mut = layout_result.styled_dom.node_data.as_container_mut();
                    match node_data_mut.get_mut(*node_id) {
                        Some(nd) => {
                            match &mut nd.node_type {
                                NodeType::Image(ref mut img_ref) => {
                                    // Try get_image_callback_mut first (requires exclusive access)
                                    let callback_result = img_ref.as_mut().get_image_callback_mut();
                                    if callback_result.is_none() {
                                        // The ImageRef has multiple copies (Arc refcount > 1),
                                        // so get_image_callback_mut returns None. Fall back to
                                        // read-only access + clone to invoke the callback.
                                        match img_ref.get_data() {
                                            azul_core::resources::DecodedImage::Callback(core_callback) => {
                                                if core_callback.callback.cb == 0 {
                                                    None
                                                } else {
                                                    let callback = RenderImageCallback::from_core(&core_callback.callback);
                                                    let refany_clone = core_callback.refany.clone();
                                                    use std::panic;
                                                    let result = panic::catch_unwind(panic::AssertUnwindSafe(|| {
                                                        (callback.cb)(refany_clone, gl_callback_info)
                                                    }));
                                                    result.ok()
                                                }
                                            }
                                            _ => None,
                                        }
                                    } else {
                                        callback_result.map(|core_callback| {
                                            // Convert from CoreImageCallback (cb: usize) to
                                            // RenderImageCallback (cb: fn pointer)
                                            let callback =
                                                RenderImageCallback::from_core(&core_callback.callback);
                                            (callback.cb)(
                                                core_callback.refany.clone(),
                                                gl_callback_info,
                                            )
                                        })
                                    }
                                }
                                _ => None,
                            }
                        }
                        None => None,
                    }
                };
                // Reset GL state after callback
                #[cfg(feature = "gl_context_loader")]
                if let Some(gl) = gl_context.as_ref() {
                    use gl_context_loader::gl;
                    gl.bind_framebuffer(gl::FRAMEBUFFER, 0);
                    gl.disable(gl::FRAMEBUFFER_SRGB);
                    gl.disable(gl::MULTISAMPLE);
                }
                // Extract the texture from the returned ImageRef
                if let Some(image_ref) = new_image_ref {
                    if let Some(decoded_image) = image_ref.into_inner() {
                        if let azul_core::resources::DecodedImage::Gl(texture) = decoded_image {
                            updated_textures.push((*dom_id, *node_id, texture));
                        }
                    }
                }
            }
        }
        updated_textures
    }
    /// Check if a scrolled node is a VirtualView that needs re-invocation. If so,
    /// queue it in `pending_virtual_view_updates` for processing before the next frame.
    ///
    /// This is the bridge between the scroll system and the VirtualView lifecycle:
    ///   ScrollTo → scroll_manager.scroll_to() → check_and_queue_virtual_view_reinvoke()
    ///
    /// Returns `true` if a VirtualView update was queued (caller should trigger a
    /// display list rebuild instead of a lightweight repaint).
    pub fn check_and_queue_virtual_view_reinvoke(
        &mut self,
        dom_id: DomId,
        node_id: NodeId,
    ) -> bool {
        // Get the VirtualView's current layout bounds (needed for check_reinvoke)
        let bounds = match Self::get_virtual_view_bounds_from_layout(
            &self.layout_results,
            dom_id,
            node_id,
        ) {
            Some(b) => b,
            None => return false, // Not a VirtualView or no layout info
        };
        // Ask the VirtualViewManager whether this VirtualView needs re-invocation
        let reason = self.virtual_view_manager.check_reinvoke(
            dom_id, node_id, &self.scroll_manager, bounds,
        );
        if reason.is_some() {
            // Queue the VirtualView for re-invocation in the next render pass
            self.pending_virtual_view_updates
                .entry(dom_id)
                .or_insert_with(FastBTreeSet::new)
                .insert(node_id);
            true
        } else {
            false
        }
    }
    /// Process VirtualView updates requested by callbacks
    ///
    /// This method handles manual VirtualView re-rendering triggered by `trigger_virtual_view_rerender()`.
    /// It invokes the VirtualView callback with `DomRecreated` reason and performs layout on the
    /// returned DOM, then submits a new display list to WebRender for that pipeline.
    ///
    /// # Arguments
    ///
    /// * `vviews_to_update` - Map of DomId -> Set of NodeIds that need re-rendering
    /// * `window_state` - Current window state
    /// * `renderer_resources` - Renderer resources
    /// * `system_callbacks` - External system callbacks
    ///
    /// # Returns
    ///
    /// Vector of (DomId, NodeId) tuples for VirtualViews that were successfully updated
    pub fn process_virtual_view_updates(
        &mut self,
        vviews_to_update: &BTreeMap<DomId, FastBTreeSet<NodeId>>,
        window_state: &FullWindowState,
        renderer_resources: &RendererResources,
        system_callbacks: &ExternalSystemCallbacks,
    ) -> Vec<(DomId, NodeId)> {
        let mut updated_vviews = Vec::new();
        for (dom_id, node_ids) in vviews_to_update {
            for node_id in node_ids {
                // Extract virtualized view bounds from layout result
                let bounds = match Self::get_virtual_view_bounds_from_layout(
                    &self.layout_results,
                    *dom_id,
                    *node_id,
                ) {
                    Some(b) => b,
                    None => continue,
                };
                // Force re-invocation by clearing the "was_invoked" flag
                self.virtual_view_manager.force_reinvoke(*dom_id, *node_id);
                // Invoke the VirtualView callback
                if let Some(_child_dom_id) = self.invoke_virtual_view_callback(
                    *dom_id,
                    *node_id,
                    bounds,
                    window_state,
                    renderer_resources,
                    system_callbacks,
                    &mut None,
                ) {
                    updated_vviews.push((*dom_id, *node_id));
                }
            }
        }
        updated_vviews
    }
    /// Queue VirtualView updates to be processed in the next frame
    ///
    /// This is called after callbacks to store the vviews_to_update from callback changes
    pub fn queue_virtual_view_updates(
        &mut self,
        vviews_to_update: BTreeMap<DomId, FastBTreeSet<NodeId>>,
    ) {
        for (dom_id, node_ids) in vviews_to_update {
            self.pending_virtual_view_updates
                .entry(dom_id)
                .or_insert_with(FastBTreeSet::new)
                .extend(node_ids);
        }
    }
    /// Queue EVERY known VirtualView for re-invocation on the EXISTING DOM (no
    /// RefreshDom / DOM rebuild). Used when a shared dataset was mutated
    /// out-of-band — e.g. a background `MapWidget` tile-fetch writeback updated
    /// the cache that the VirtualView's `refany` clone points at. Re-invoking in
    /// place keeps the content callback reading the same underlying data the
    /// worker threads write to; a RefreshDom would rebuild the DOM, allocate a
    /// fresh dataset, and orphan the workers' clone (so later tiles would never
    /// reach the rendered view).
    pub fn queue_all_virtual_view_reinvoke(&mut self) {
        let mut updates: BTreeMap<DomId, FastBTreeSet<NodeId>> = BTreeMap::new();
        for (dom_id, node_id) in self.virtual_view_manager.all_view_keys() {
            updates
                .entry(dom_id)
                .or_insert_with(FastBTreeSet::new)
                .insert(node_id);
        }
        self.queue_virtual_view_updates(updates);
    }
    /// Process and clear pending VirtualView updates
    ///
    /// This is called during frame generation to re-render updated VirtualViews
    pub fn process_pending_virtual_view_updates(
        &mut self,
        window_state: &FullWindowState,
        renderer_resources: &RendererResources,
        system_callbacks: &ExternalSystemCallbacks,
    ) -> Vec<(DomId, NodeId)> {
        if self.pending_virtual_view_updates.is_empty() {
            return Vec::new();
        }
        // Take ownership of pending updates
        let vviews_to_update = core::mem::take(&mut self.pending_virtual_view_updates);
        // Process them
        let updated = self.process_virtual_view_updates(
            &vviews_to_update,
            window_state,
            renderer_resources,
            system_callbacks,
        );
        // An in-place rebuild gives each child DOM FRESH NodeIds with no
        // reconcile mapping. Any hover/hit state recorded against the old
        // generation is now dangling — resolving it against the new styled DOM
        // reads out of bounds (hit_test.rs cursor panic while panning the map)
        // or targets the wrong node. Purge the rebuilt children's hits; the
        // next pointer move re-populates them from a fresh hit test.
        for (parent_dom, node_id) in &updated {
            if let Some(child_dom) = self
                .virtual_view_manager
                .get_nested_dom_id(*parent_dom, *node_id)
            {
                self.hover_manager.purge_dom(&child_dom);
            }
        }
        updated
    }
    /// Helper: Extract VirtualView bounds from layout results
    ///
    /// Returns None if the node is not a VirtualView or doesn't have layout info
    fn get_virtual_view_bounds_from_layout(
        layout_results: &BTreeMap<DomId, DomLayoutResult>,
        dom_id: DomId,
        node_id: NodeId,
    ) -> Option<LogicalRect> {
        let layout_result = layout_results.get(&dom_id)?;
        // Check if this is a VirtualView node
        let node_data_container = layout_result.styled_dom.node_data.as_container();
        let node_data = node_data_container.get(node_id)?;
        if !matches!(node_data.get_node_type(), NodeType::VirtualView) {
            return None;
        }
        // Get layout indices
        let layout_indices = layout_result.layout_tree.dom_to_layout.get(&node_id)?;
        if layout_indices.is_empty() {
            return None;
        }
        let layout_index = layout_indices[0];
        // Get position
        let position = *layout_result.calculated_positions.get(layout_index)?;
        // Get size
        let layout_node = layout_result.layout_tree.get(layout_index)?;
        let size = layout_node.used_size?;
        Some(LogicalRect::new(
            position,
            LogicalSize::new(size.width as f32, size.height as f32),
        ))
    }
}
#[cfg(feature = "a11y")]
#[derive(Debug, Clone)]
pub enum TextEditType {
    ReplaceSelection(String),
    SetValue(String),
    SetNumericValue(f64),
}