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