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