Skip to main content

azul_layout/
window.rs

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