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