Skip to main content

blitz_dom/
document.rs

1use crate::NodeTree;
2use crate::events::{DragMode, ScrollAnimationState, handle_dom_event};
3use crate::font_metrics::BlitzFontMetricsProvider;
4use crate::layout::construct::ConstructionTask;
5use crate::layout::damage::ALL_DAMAGE;
6use crate::mutator::ViewportMut;
7use crate::net::{
8    Resource, ResourceHandler, ResourceLoadResponse, StylesheetHandler, StylesheetLoader,
9};
10use crate::node::{
11    ImageData, NodeFlags, RasterImageData, SpecialElementData, Status, TextBrush, TextGranularity,
12};
13use crate::selection::TextSelection;
14use crate::stylo_to_cursor_icon::stylo_to_cursor_icon;
15use crate::traversal::TreeTraverser;
16use crate::url::DocumentUrl;
17use crate::util::ImageType;
18use crate::{
19    DEFAULT_CSS, DocumentConfig, DocumentMutator, DummyHtmlParserProvider, ElementData,
20    EventDriver, HtmlParserProvider, Node, NodeData, NoopEventHandler, StyleThreading,
21    TextNodeData,
22};
23use blitz_traits::devtools::DevtoolSettings;
24use blitz_traits::events::{BlitzScrollEvent, DomEvent, DomEventData, HitResult, UiEvent};
25use blitz_traits::handoff::{EventQueue, EventTarget};
26use blitz_traits::navigation::{DummyNavigationProvider, NavigationProvider};
27use blitz_traits::net::{AbortSignal, DummyNetProvider, NetProvider, Request, RequestDestination};
28use blitz_traits::node_id::NodeId;
29use blitz_traits::shell::{ColorScheme, DummyShellProvider, ShellProvider, Viewport};
30use cursor_icon::CursorIcon;
31use linebender_resource_handle::Blob;
32use markup5ever::{local_name, ns};
33use parley::{FontContext, PlainEditorDriver};
34use rustc_hash::FxHashSet;
35use selectors::{Element, matching::QuirksMode};
36use smallvec::SmallVec;
37use std::any::Any;
38use std::cell::RefCell;
39use std::collections::{BTreeMap, Bound, HashMap, HashSet};
40use std::ops::{Deref, DerefMut};
41use std::rc::Rc;
42use std::str::FromStr;
43use std::sync::atomic::{AtomicUsize, Ordering};
44use std::sync::{Arc, Mutex, MutexGuard, OnceLock, RwLockReadGuard, RwLockWriteGuard};
45use std::task::{Context as TaskContext, Waker};
46use style::Atom;
47use style::animation::{AnimationState, DocumentAnimationSet};
48use style::attr::{AttrIdentifier, AttrValue};
49use style::data::{ElementData as StyloElementData, ElementStyles};
50use style::invalidation::element::restyle_hints::RestyleHint;
51use style::media_queries::MediaType;
52use style::properties::ComputedValues;
53use style::properties::style_structs::Font;
54use style::queries::values::PrefersColorScheme;
55use style::selector_parser::ServoElementSnapshot;
56use style::servo::media_features::PointerCapabilities;
57use style::servo_arc::Arc as ServoArc;
58use style::values::GenericAtomIdent;
59use style::values::computed::ui::CursorKind;
60use style::values::computed::{Overflow, UserSelect};
61use style::values::specified::box_::{DisplayInside, DisplayOutside};
62use style::{
63    device::Device,
64    dom::{TDocument, TNode},
65    media_queries::MediaList,
66    selector_parser::SnapshotMap,
67    shared_lock::{SharedRwLock, StylesheetGuards},
68    stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Stylesheet},
69    stylist::Stylist,
70};
71use thin_vec::ThinVec;
72use url::Url;
73use web_time::Instant;
74
75#[cfg(feature = "parallel-construct")]
76use thread_local::ThreadLocal;
77
78pub enum DocGuard<'a> {
79    Ref(&'a BaseDocument),
80    RefCell(std::cell::Ref<'a, BaseDocument>),
81    RwLock(RwLockReadGuard<'a, BaseDocument>),
82    Mutex(MutexGuard<'a, BaseDocument>),
83}
84
85impl Deref for DocGuard<'_> {
86    type Target = BaseDocument;
87    #[inline(always)]
88    fn deref(&self) -> &Self::Target {
89        match self {
90            Self::Ref(base_document) => base_document,
91            Self::RefCell(refcell_guard) => refcell_guard,
92            Self::RwLock(rw_lock_read_guard) => rw_lock_read_guard,
93            Self::Mutex(mutex_guard) => mutex_guard,
94        }
95    }
96}
97
98pub enum DocGuardMut<'a> {
99    Ref(&'a mut BaseDocument),
100    RefCell(std::cell::RefMut<'a, BaseDocument>),
101    RwLock(RwLockWriteGuard<'a, BaseDocument>),
102    Mutex(MutexGuard<'a, BaseDocument>),
103}
104
105impl Deref for DocGuardMut<'_> {
106    type Target = BaseDocument;
107    #[inline(always)]
108    fn deref(&self) -> &Self::Target {
109        match self {
110            Self::Ref(base_document) => base_document,
111            Self::RefCell(refcell_guard) => refcell_guard,
112            Self::RwLock(rw_lock_read_guard) => rw_lock_read_guard,
113            Self::Mutex(mutex_guard) => mutex_guard,
114        }
115    }
116}
117
118impl DerefMut for DocGuardMut<'_> {
119    #[inline(always)]
120    fn deref_mut(&mut self) -> &mut Self::Target {
121        match self {
122            Self::Ref(base_document) => base_document,
123            Self::RefCell(refcell_guard) => &mut *refcell_guard,
124            Self::RwLock(rw_lock_read_guard) => &mut *rw_lock_read_guard,
125            Self::Mutex(mutex_guard) => &mut *mutex_guard,
126        }
127    }
128}
129
130/// Abstraction over wrappers around [`BaseDocument`] to allow for them all to
131/// be driven by [`blitz-shell`](https://docs.rs/blitz-shell)
132pub trait Document: Any + 'static {
133    fn inner(&self) -> DocGuard<'_>;
134    fn inner_mut(&mut self) -> DocGuardMut<'_>;
135
136    /// Update the [`Document`] in response to a [`UiEvent`] (click, keypress, etc)
137    fn handle_ui_event(&mut self, event: UiEvent) {
138        if self.inner().page_frozen || self.inner().page_retiring || self.inner().page_input_blocked
139        {
140            return;
141        }
142        let mut doc = self.inner_mut();
143        let mut driver = EventDriver::new(&mut *doc, NoopEventHandler);
144        driver.handle_ui_event(event);
145    }
146
147    /// Poll any pending async operations, and flush changes to the underlying [`BaseDocument`]
148    fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
149        // Default implementation does nothing
150        let _ = task_context;
151        false
152    }
153
154    /// Drain immediate work for a host's settle loop.
155    ///
156    /// Pass `run_timers = true` only on the first pass to allow one batch of
157    /// due timer and animation callbacks. Subsequent passes must leave those
158    /// callbacks scheduled while draining scripts and microtasks.
159    ///
160    /// Documents that execute timers must override this method. Wrappers that
161    /// poll child documents must propagate the policy to their children.
162    fn poll_for_settle(&mut self, task_context: Option<TaskContext>, run_timers: bool) -> bool {
163        let _ = run_timers;
164        self.poll(task_context)
165    }
166
167    /// Revoke child work before ownership is dropped. Completion is separate.
168    fn prepare_teardown(&mut self) {
169        self.inner_mut().prepare_teardown();
170    }
171
172    fn dispose(mut self: Box<Self>) -> Arc<crate::release::Teardown> {
173        self.prepare_teardown();
174        let receipt = Arc::clone(&self.inner().teardown);
175        drop(self);
176        receipt
177    }
178
179    fn set_page_visible(&mut self, visible: bool) {
180        self.inner_mut().page_visible = visible;
181    }
182    fn freeze(&mut self) {
183        self.inner_mut().page_frozen = true;
184    }
185    fn finish_restore(&mut self) {
186        self.inner_mut().page_input_blocked = false;
187    }
188    fn resume(&mut self) {
189        self.inner_mut().page_frozen = false;
190    }
191
192    /// Get the [`Document`]'s id
193    fn id(&self) -> usize {
194        self.inner().id
195    }
196}
197
198/// What the pre-click activation steps changed, so a cancelled click can put
199/// it back. Produced by [`BaseDocument::run_pre_click_activation`].
200#[derive(Debug, Clone, PartialEq)]
201pub struct PreClickActivation {
202    /// Every node whose checkedness moved, paired with the value it held.
203    previous: Vec<(NodeId, bool)>,
204}
205
206/// Escape text for inclusion in an HTML clipboard payload.
207///
208/// The five characters that would otherwise be read as markup. A selection is
209/// document text, so a paragraph containing `a < b` must arrive as `a &lt; b`
210/// rather than opening a tag in whatever receives the paste.
211fn escape_html(text: &str) -> String {
212    let mut out = String::with_capacity(text.len());
213    for ch in text.chars() {
214        match ch {
215            '&' => out.push_str("&amp;"),
216            '<' => out.push_str("&lt;"),
217            '>' => out.push_str("&gt;"),
218            '"' => out.push_str("&quot;"),
219            _ => out.push(ch),
220        }
221    }
222    out
223}
224
225pub struct PlainDocument(pub BaseDocument);
226impl Document for PlainDocument {
227    fn inner(&self) -> DocGuard<'_> {
228        DocGuard::Ref(&self.0)
229    }
230    fn inner_mut(&mut self) -> DocGuardMut<'_> {
231        DocGuardMut::Ref(&mut self.0)
232    }
233}
234
235impl Document for BaseDocument {
236    fn inner(&self) -> DocGuard<'_> {
237        DocGuard::Ref(self)
238    }
239    fn inner_mut(&mut self) -> DocGuardMut<'_> {
240        DocGuardMut::Ref(self)
241    }
242}
243
244impl Document for Rc<RefCell<BaseDocument>> {
245    fn inner(&self) -> DocGuard<'_> {
246        DocGuard::RefCell(self.borrow())
247    }
248
249    fn inner_mut(&mut self) -> DocGuardMut<'_> {
250        DocGuardMut::RefCell(self.borrow_mut())
251    }
252}
253
254pub enum DocumentEvent {
255    ResourceLoad(ResourceLoadResponse),
256    /// A navigation originating from within an iframe's sub-document
257    /// (e.g. a link click), to be applied to the iframe identified by `node_id`.
258    NavigateIframe {
259        node_id: NodeId,
260        url: Url,
261    },
262}
263
264/// How urgently a document needs another animation frame.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
266pub enum AnimationPacing {
267    Idle,
268    Caret,
269    SlowCss,
270    Interactive,
271}
272
273pub struct BaseDocument {
274    /// ID of the document
275    id: usize,
276
277    // Config
278    /// Base url for resolving linked resources (stylesheets, images, fonts, etc)
279    pub(crate) url: DocumentUrl,
280    // Devtool settings. Currently used to render debug overlays
281    pub(crate) devtool_settings: DevtoolSettings,
282    // Viewport details such as the dimensions, HiDPI scale, and zoom factor,
283    pub(crate) viewport: Viewport,
284    // Scroll within our viewport
285    pub(crate) viewport_scroll: crate::Point<f64>,
286    /// Changes recorded for a script's `MutationObserver`, or `None` while no
287    /// observer is registered. See [`crate::DomMutation`].
288    pub(crate) mutation_log: Option<Vec<crate::DomMutation>>,
289    /// Content handler changes, independent of MutationObserver recording.
290    inline_handler_changes: Vec<(NodeId, crate::QualName, Option<crate::node::AttrAtom>)>,
291    /// Weak registrations make pages without live ranges pay only a branch.
292    pub(crate) live_ranges: Vec<crate::range::WeakRange>,
293    /// Script Selection shares the same live range object as getRangeAt().
294    pub dom_selection: Option<crate::LiveRange>,
295    /// CSS media type used to evaluate `@media` rules.
296    pub(crate) media_type: MediaType,
297    /// Strategy for Stylo's style traversal during `resolve`.
298    pub(crate) style_threading: StyleThreading,
299    /// Whether incremental layout is enabled for this document.
300    pub(crate) incremental_layout: bool,
301    /// How deeply this document is nested within other documents
302    /// (0 for a root document). Used to limit `<iframe>` nesting depth.
303    pub(crate) subdocument_depth: usize,
304
305    // Events
306    pub(crate) tx: EventTarget<DocumentEvent>,
307    pub(crate) rx: EventQueue<DocumentEvent>,
308
309    /// A slotmap-backed tree of nodes
310    ///
311    /// We pin the tree to a guarantee to the nodes it creates that the tree is stable in memory.
312    /// There is no way to create the tree - publicly or privately - that would invalidate that invariant.
313    pub(crate) nodes: Box<NodeTree>,
314
315    /// Batch cache invalidation across recursive subtree teardown.
316    node_drop_depth: usize,
317    /// Active mutation scopes defer redraw until the outermost scope ends.
318    pub(crate) mutator_depth: usize,
319    pub(crate) mutator_redraw_pending: bool,
320    /// `<details>` elements whose `open` attribute appeared or disappeared since the script
321    /// runtime last took them, each with whether it was open before its first change. The
322    /// HTML toggle task coalesces per element, so an element is listed once.
323    details_toggles: Vec<(NodeId, bool)>,
324    /// Bumped whenever the stylist device changes (viewport size, scale or colour scheme),
325    /// so script can skip re-evaluating media queries while it stays the same.
326    viewport_generation: u64,
327    /// Bumped each time a Stylo traversal restyles the tree. A resolve that
328    /// finds nothing to restyle leaves it alone, so an inspector can tell a
329    /// style change from an idle frame.
330    pub(crate) style_revision: u64,
331    /// Bumped each time layout is computed. An incremental resolve that keeps
332    /// every box leaves it alone.
333    layout_revision: u64,
334    /// The inert document that owns every `<template>`'s contents, made on first use. It
335    /// has no browsing context, so nothing in it is upgraded as a custom element.
336    template_contents_owner: Option<NodeId>,
337    dropped_node_ids: FxHashSet<NodeId>,
338    dropped_node_parents: FxHashSet<NodeId>,
339    /// Survivors that can still point at an id freed by this batch.
340    ///
341    /// Recorded while the node is still in the slab. The purge runs after a
342    /// subtree drop has already removed those nodes, so the links have to be
343    /// captured first.
344    dropped_reference_owners: FxHashSet<NodeId>,
345
346    /// The id of the root node (a Document node)
347    pub(crate) root_node_id: NodeId,
348
349    /// For each `position: fixed` node reparented onto the root element, the
350    /// layout parent it was taken from.
351    ///
352    /// Hoisting gives a fixed node the viewport as its containing block, which
353    /// is what CSS asks for. It must not also decide which stacking context the
354    /// node paints in: that follows the box tree, and the two are independent.
355    /// Without this record the node joins the root's stacking context, so a
356    /// negative z-index fixed layer inside an `isolation: isolate` ancestor
357    /// paints beneath every background between them and disappears.
358    pub(crate) hoisted_fixed_parents: HashMap<NodeId, NodeId>,
359
360    /// Authored child slots restored temporarily for static-position layout.
361    pub(crate) hoisted_fixed_indices: HashMap<NodeId, usize>,
362
363    /// Escaped absolute boxes whose explicit axes follow a containing block.
364    pub(crate) abspos_placements: Vec<crate::layout::abspos::AbsposPlacement>,
365
366    /// Maintained during the existing fixed/sticky walk on style or tree changes.
367    pub(crate) abspos_candidates: Vec<crate::layout::abspos::AbsposCandidate>,
368    pub(crate) abspos_candidate_ids: HashSet<NodeId>,
369    pub(crate) abspos_inputs: HashMap<NodeId, crate::layout::abspos::AbsposInputs>,
370    /// Candidates whose hypothetical layouts or styles were written this pass.
371    pub(crate) abspos_written: HashSet<NodeId>,
372    pub(crate) abspos_candidates_dirty: bool,
373    pub(crate) abspos_layout_dirty: bool,
374    /// Whether the normal root layout missed its PerformLayout cache.
375    pub(crate) abspos_normal_layout: bool,
376
377    /// Every `position: fixed` node whose containing block is the viewport,
378    /// which is every one of them except those under a transformed ancestor.
379    ///
380    /// Collected by the walk that hoists them, and used by
381    /// `resolve_fixed_positions` to hold them still while the page scrolls.
382    pub(crate) fixed_nodes: Vec<NodeId>,
383
384    /// The viewport scroll currently baked into those nodes' locations.
385    ///
386    /// One value for the whole document rather than one per node: the pin is
387    /// the same displacement for every fixed box, because they all share the
388    /// viewport as their containing block. Reset by `resolve_layout`, which
389    /// rewrites the locations it was added to.
390    pub(crate) fixed_scroll_offset: crate::Point<f64>,
391
392    /// Every `position: sticky` node in the document, in tree order.
393    ///
394    /// Collected by the same walk that hoists fixed nodes, because both need
395    /// one pre-order pass over the box tree and a second one would cost the
396    /// same on every frame of every document, sticky or not. Tree order matters:
397    /// a sticky box inside another sticky box is adjusted on top of its
398    /// ancestor's adjustment, so the ancestor has to be settled first.
399    pub(crate) sticky_nodes: Vec<NodeId>,
400
401    /// For each sticky node, the offset currently baked into its
402    /// `final_layout().location`.
403    ///
404    /// The adjustment is written into the box itself so that paint, hit testing
405    /// and `absolute_position` cannot disagree about where the box is. That
406    /// makes the pass non-idempotent unless it can recover the flow position it
407    /// started from, which is what this records. Cleared by `resolve_layout`,
408    /// which rewrites every location from taffy and so discards the offsets
409    /// along with them.
410    pub(crate) sticky_offsets: HashMap<NodeId, taffy::Point<f32>>,
411
412    /// Stacking contexts holding a hoisted child that an ancestor clips.
413    ///
414    /// Collected while flushing styles so that `resolve_hoisted_clips` visits
415    /// those contexts alone, rather than scanning every node in the document
416    /// after every layout to find the handful that hoist anything at all.
417    pub(crate) hoisted_clip_hosts: HashSet<NodeId>,
418
419    /// Contexts with hoisted paint children, maintained by the style flush.
420    pub(crate) hoisted_position_hosts: HashSet<NodeId>,
421
422    // Stylo
423    /// The Stylo engine
424    pub(crate) stylist: Stylist,
425    pub(crate) animations: DocumentAnimationSet,
426    /// Monotonic animation clock used by the most recent resolve.
427    ///
428    /// Embedders may inspect or capture between window frames. A diagnostic
429    /// caller historically passed `0.0` and rewound every CSS transition in
430    /// the document; retaining the high-water mark makes that impossible at
431    /// the document boundary.
432    pub(crate) last_resolve_animation_time: f64,
433    /// Stylo shared lock
434    pub(crate) guard: SharedRwLock,
435    /// Stylo invalidation map. We insert into this map prior to mutating nodes.
436    pub(crate) snapshots: SnapshotMap,
437
438    // Parley contexts
439    /// A Parley font context
440    pub(crate) font_ctx: Arc<Mutex<parley::FontContext>>,
441    #[cfg(feature = "parallel-construct")]
442    /// Thread-and-document-local copies to the font context
443    pub(crate) thread_font_contexts: ThreadLocal<RefCell<Box<FontContext>>>,
444    /// A Parley layout context
445    pub(crate) layout_ctx: parley::LayoutContext<TextBrush>,
446
447    /// The real (non-anonymous) node which is currently hovered (if any).
448    /// This is never a layout-generated (anonymous) node, so it remains valid
449    /// across box-tree reconstruction.
450    pub(crate) hover_node_id: Option<NodeId>,
451    /// The precise (may be anonymous) layout node under the pointer (if any).
452    /// This can be invalidated by box-tree reconstruction, and is re-resolved against
453    /// fresh layout at the end of every `resolve` pass.
454    pub(crate) hover_hit_node_id: Option<NodeId>,
455    /// Whether the node which is currently hovered is a text node/span
456    pub(crate) hover_node_is_text: bool,
457    /// The last known pointer position in client coordinates (viewport-relative, unscrolled).
458    pub(crate) last_client_pointer_position: Option<taffy::Point<f32>>,
459    /// Exact DOM target selected by semantic automation.
460    ///
461    /// Window pointers leave this empty and are re-hit-tested after layout.
462    /// An inspector already resolved identity, so re-hit-testing its synthetic
463    /// centre coordinate would silently replace that target with an overlap.
464    pub(crate) semantic_hover_node_id: Option<NodeId>,
465    /// The node which is currently focussed (if any)
466    pub(crate) focus_node_id: Option<NodeId>,
467    /// The node which is currently active (if any)
468    pub(crate) active_node_id: Option<NodeId>,
469    /// The node which recieved a mousedown event (if any)
470    pub(crate) mousedown_node_id: Option<NodeId>,
471    /// The last time a mousedown was made (for double-click detection)
472    pub(crate) last_mousedown_time: Option<Instant>,
473    /// The position where mousedown occurred (for selection drags and double-click detection)
474    pub(crate) mousedown_position: taffy::Point<f32>,
475    /// How many clicks have been made in quick succession
476    pub(crate) click_count: u16,
477    /// Whether we're currently in a text selection drag (moved 2px+ from mousedown)
478    pub(crate) drag_mode: DragMode,
479    /// The scrollbar thumb currently under the pointer, if any
480    pub(crate) hovered_scrollbar: Option<crate::node::ScrollbarRef>,
481    /// When each scroll container's overlay scrollbars were last shown
482    /// (scrolled, or the pointer left the thumb); drives their fade-out
483    pub(crate) scrollbar_activity: HashMap<NodeId, Instant>,
484    /// Whether and what kind of scroll animation is currently in progress
485    pub(crate) scroll_animation: ScrollAnimationState,
486
487    /// Text selection state (for non-input text)
488    pub(crate) text_selection: TextSelection,
489
490    // TODO: collapse animating state into a bitflags
491    /// Whether there are active CSS animations/transitions (so we should re-render every frame)
492    pub(crate) has_active_animations: bool,
493    /// Whether there is a `<canvas>` element in the DOM (so we should re-render every frame)
494    pub(crate) has_canvas: bool,
495    /// The most urgent animation cadence required by any subdocument.
496    pub(crate) subdoc_animation_pacing: AnimationPacing,
497
498    /// Map of id attribute values to node IDs for fast lookups.
499    /// May contain multiple nodes for the same id: `get_element_by_id`
500    /// returns the first in tree order.
501    pub(crate) nodes_to_id: HashMap<String, SmallVec<[NodeId; 1]>>,
502    /// Map of `<style>` and `<link>` node IDs to their associated stylesheet
503    pub(crate) nodes_to_stylesheet: BTreeMap<NodeId, DocumentStyleSheet>,
504    /// `<style>`/`<link>` nodes whose sheet is in the document Stylist. Shadow
505    /// tree sheets live elsewhere, and Stylo panics on removing a sheet it
506    /// does not hold.
507    pub(crate) document_sheet_nodes: HashSet<NodeId>,
508    /// The shadow root each shadow-tree `<style>`/`<link>` sheet was installed
509    /// into. Unloading happens after the node is detached, when its root can
510    /// no longer be found by walking up, so the owner is remembered here.
511    #[cfg(feature = "shadow-dom")]
512    pub(crate) shadow_sheet_roots: HashMap<NodeId, NodeId>,
513    /// Stylesheets added by the useragent
514    /// where the key is the hashed CSS
515    pub(crate) ua_stylesheets: HashMap<String, DocumentStyleSheet>,
516    /// Map from form control node ID's to their associated forms node ID's
517    pub(crate) controls_to_form: HashMap<NodeId, NodeId>,
518    /// Nodes that contain sub documents
519    pub(crate) sub_document_nodes: HashSet<NodeId>,
520    pub stash_policy: crate::stash_policy::StashPolicy,
521    pub teardown: Arc<crate::release::Teardown>,
522    _dom_lease: crate::release::ReleaseLease,
523    pub page_visible: bool,
524    pub page_frozen: bool,
525    pub page_input_blocked: bool,
526    pub page_retiring: bool,
527    pub was_discarded: bool,
528    pub paint_revision: std::cell::Cell<u64>,
529    pub stash_paint: bool,
530    pub restore_scroll: Option<crate::Point<f64>>,
531    #[cfg(feature = "custom-widget")]
532    retired_resources: Vec<crate::release::ResourceRetirement>,
533    /// Load state (abort controller and in-flight request id) for each
534    /// `<iframe>` element whose sub-document is loaded automatically
535    pub(crate) iframe_loads: HashMap<NodeId, crate::iframe::IframeLoad>,
536    /// Nodes whose layout construction is waiting for a later pass.
537    pub(crate) deferred_construction_nodes: Vec<ConstructionTask>,
538    /// Which parts of the document differ from the previously painted frame.
539    ///
540    /// Off unless a consumer asks for it, so a document that never questions
541    /// its own frames does not pay to answer. See
542    /// [`set_paint_damage_tracking`](Self::set_paint_damage_tracking).
543    pub(crate) paint_damage: crate::paint_damage::PaintDamageTracker,
544
545    /// Nodes that contain custom widgets
546    #[cfg(feature = "custom-widget")]
547    pub(crate) custom_widget_nodes: HashSet<NodeId>,
548    /// Rendering resources allocated by custom widgets that should be deallocated during the next render
549    #[cfg(feature = "custom-widget")]
550    pub(crate) pending_resource_deallocations: Vec<anyrender::ResourceId>,
551
552    /// Registry of custom element definitions keyed by tag name
553    #[cfg(feature = "shadow-dom")]
554    pub(crate) custom_element_registry: crate::node::CustomElementRegistry,
555    /// Nodes that are shadow hosts (have an attached shadow root)
556    #[cfg(feature = "shadow-dom")]
557    pub(crate) shadow_host_nodes: HashSet<NodeId>,
558    #[cfg(feature = "shadow-dom")]
559    pub(crate) dirty_shadow_hosts: HashSet<NodeId>,
560    #[cfg(feature = "shadow-dom")]
561    pub(crate) pending_slot_changes: Vec<NodeId>,
562    #[cfg(feature = "shadow-dom")]
563    pub(crate) signaled_slots: HashSet<NodeId>,
564    #[cfg(feature = "shadow-dom")]
565    pub(crate) adopted_stylesheets: HashMap<NodeId, Vec<DocumentStyleSheet>>,
566    /// Nodes that have an attached custom element controller
567    #[cfg(feature = "shadow-dom")]
568    pub(crate) custom_element_nodes: HashSet<NodeId>,
569    /// Template element -> declarative shadow root, while that template is open
570    /// on the parser stack. Stored here because each streaming sink call builds
571    /// a new parser.
572    #[cfg(feature = "shadow-dom")]
573    pub(crate) declarative_template_shadows: HashMap<NodeId, NodeId>,
574
575    /// Native candidate index and mutation reactions for JavaScript custom elements.
576    pub(crate) script_custom_elements: crate::node::script_custom_element::Registry,
577
578    /// Cache of loaded images, keyed by URL. Allows reusing images across multiple
579    /// elements without re-fetching from the network.
580    pub(crate) image_cache: HashMap<String, ImageData>,
581
582    /// Tracks in-flight image requests. When an image is being fetched, additional
583    /// requests for the same URL are queued here instead of starting new fetches.
584    /// Value is a list of (node_id, image_type) pairs waiting for the image.
585    pub(crate) pending_images: HashMap<String, Vec<(NodeId, ImageType)>>,
586
587    // Tracks in-flight "critical" resources (e.g. stylesheets linked from the `<head>`),
588    // keyed by request id
589    pub(crate) pending_critical_resources: HashSet<usize>,
590
591    pub(crate) web_fonts: Vec<Arc<crate::net::web_fonts::WebFont>>,
592    pub(crate) font_epoch: usize,
593    document_referrer: String,
594    document_content_type: String,
595    document_window_focused: bool,
596
597    // Service providers
598    /// Network provider. Can be used to fetch assets.
599    pub net_provider: Arc<dyn NetProvider>,
600    /// Navigation provider. Can be used to navigate to a new page (bubbles up the event
601    /// on e.g. clicking a Link)
602    pub navigation_provider: Arc<dyn NavigationProvider>,
603    /// Shell provider. Can be used to request a redraw or set the cursor icon
604    pub shell_provider: Arc<dyn ShellProvider>,
605    /// HTML parser provider. Used to parse HTML for setInnerHTML
606    pub html_parser_provider: Arc<dyn HtmlParserProvider>,
607    /// Carried on every sub-resource `Request` this document issues; aborting
608    /// it cancels all in-flight fetches tied to this document. Set via
609    /// [`DocumentConfig::abort_signal`].
610    pub(crate) abort_signal: Option<AbortSignal>,
611}
612
613pub(crate) fn make_device(
614    viewport: &Viewport,
615    media_type: MediaType,
616    font_ctx: Arc<Mutex<FontContext>>,
617) -> Device {
618    let width = viewport.window_size.0 as f32 / viewport.scale();
619    let height = viewport.window_size.1 as f32 / viewport.scale();
620    let viewport_size = euclid::Size2D::new(width, height);
621    let device_size = euclid::Size2D::new(width, height) * viewport.scale();
622    let device_pixel_ratio = euclid::Scale::new(viewport.scale());
623
624    Device::new(
625        media_type,
626        selectors::matching::QuirksMode::NoQuirks,
627        viewport_size,
628        device_size,
629        device_pixel_ratio,
630        Box::new(BlitzFontMetricsProvider { font_ctx }),
631        ComputedValues::initial_values_with_font_override(Font::initial_values()),
632        match viewport.color_scheme {
633            ColorScheme::Light => PrefersColorScheme::Light,
634            ColorScheme::Dark => PrefersColorScheme::Dark,
635        },
636        PointerCapabilities::default(),
637        PointerCapabilities::default(),
638    )
639}
640
641/// Whether layout reuses its caches, and how that can be overridden at runtime.
642///
643/// Incremental layout is on unless a caller or the environment turns it off.
644///
645/// The environment override exists so a single build can be measured both ways:
646/// with it off every `resolve` clears the Taffy cache and re-shapes every inline
647/// root from scratch, so comparing the two in separate binaries would also
648/// compare two different compilations. `BLITZ_INCREMENTAL=0` forces the old
649/// behaviour, `=1` forces the new one.
650///
651/// This used to fall back to `cfg!(feature = "incremental")`. That feature is
652/// gone, replaced by `DocumentConfig::incremental`, and for a while afterwards
653/// this function was never called at all: the config read
654/// `unwrap_or(true)` directly, so `BLITZ_INCREMENTAL` was accepted and ignored.
655fn incremental_layout_default() -> bool {
656    !matches!(
657        std::env::var("BLITZ_INCREMENTAL").ok().as_deref(),
658        Some("0" | "false" | "off")
659    )
660}
661
662impl BaseDocument {
663    /// Create a new (empty) [`BaseDocument`] with the specified configuration
664    pub fn new(config: DocumentConfig) -> Self {
665        static ID_GENERATOR: AtomicUsize = AtomicUsize::new(1);
666
667        let id = ID_GENERATOR.fetch_add(1, Ordering::SeqCst);
668        let teardown = config
669            .teardown
670            .unwrap_or_else(crate::release::Teardown::new);
671
672        let font_ctx = config
673            .font_ctx
674            .map(|mut font_ctx| {
675                font_ctx.source_cache.make_shared();
676                // font_ctx.collection.make_shared();
677                font_ctx
678            })
679            .unwrap_or_else(|| {
680                use parley::fontique::{Collection, CollectionOptions, SourceCache};
681                let mut font_ctx = FontContext {
682                    source_cache: SourceCache::new_shared(),
683                    collection: Collection::new(CollectionOptions {
684                        shared: false,
685                        system_fonts: cfg!(all(
686                            feature = "system-fonts",
687                            not(target_arch = "wasm32")
688                        )),
689                    }),
690                };
691                font_ctx
692                    .collection
693                    .register_fonts(Blob::new(Arc::new(crate::BULLET_FONT) as _), None);
694                font_ctx
695            });
696        let font_ctx = Arc::new(Mutex::new(font_ctx));
697        if let Some(fallback) = config.family_fallback {
698            crate::font_fallback::set(fallback);
699        }
700
701        // Make sure we turn on stylo features *before* creating the Stylist
702        style_config::set_pref!("layout.grid.enabled", true);
703        style_config::set_pref!("layout.unimplemented", true);
704        style_config::set_pref!("layout.columns.enabled", true);
705        style_config::set_pref!("layout.css.basic-shape-shape.enabled", true);
706        style_config::set_pref!("layout.threads", -1);
707
708        let viewport = config.viewport.unwrap_or_default();
709        let media_type = config.media_type.unwrap_or_else(MediaType::screen);
710        let device = make_device(&viewport, media_type.clone(), font_ctx.clone());
711        let stylist = Stylist::new(device, QuirksMode::NoQuirks);
712        let snapshots = SnapshotMap::new();
713        let nodes = Box::new(NodeTree::new());
714        let guard = SharedRwLock::new();
715        let nodes_to_id = HashMap::new();
716
717        let base_url = config
718            .base_url
719            .and_then(|url| DocumentUrl::from_str(&url).ok())
720            .unwrap_or_default();
721
722        let net_provider = config
723            .net_provider
724            .unwrap_or_else(|| Arc::new(DummyNetProvider));
725        let navigation_provider = config
726            .navigation_provider
727            .unwrap_or_else(|| Arc::new(DummyNavigationProvider));
728        let shell_provider = config
729            .shell_provider
730            .unwrap_or_else(|| Arc::new(DummyShellProvider));
731        let html_parser_provider = config
732            .html_parser_provider
733            .unwrap_or_else(|| Arc::new(DummyHtmlParserProvider));
734
735        let wake_shell = Arc::clone(&shell_provider);
736        let rx = EventQueue::new(move || wake_shell.request_redraw());
737        let tx = rx.target();
738
739        let mut doc = Self {
740            node_drop_depth: 0,
741            mutator_depth: 0,
742            mutator_redraw_pending: false,
743            details_toggles: Vec::new(),
744            viewport_generation: 0,
745            style_revision: 0,
746            layout_revision: 0,
747            template_contents_owner: None,
748            dropped_node_ids: FxHashSet::default(),
749            dropped_node_parents: FxHashSet::default(),
750            dropped_reference_owners: FxHashSet::default(),
751            hoisted_fixed_parents: HashMap::new(),
752            hoisted_fixed_indices: HashMap::new(),
753            abspos_placements: Vec::new(),
754            abspos_candidates: Vec::new(),
755            abspos_candidate_ids: HashSet::new(),
756            abspos_inputs: HashMap::new(),
757            abspos_written: HashSet::new(),
758            abspos_candidates_dirty: true,
759            abspos_layout_dirty: true,
760            abspos_normal_layout: false,
761            fixed_nodes: Vec::new(),
762            fixed_scroll_offset: crate::Point::ZERO,
763            sticky_nodes: Vec::new(),
764            sticky_offsets: HashMap::new(),
765            hoisted_clip_hosts: HashSet::new(),
766            hoisted_position_hosts: HashSet::new(),
767            id,
768            tx,
769            rx,
770
771            guard,
772            nodes,
773            root_node_id: NodeId::default(),
774            stylist,
775            animations: DocumentAnimationSet::default(),
776            last_resolve_animation_time: 0.0,
777            snapshots,
778            nodes_to_id,
779            viewport,
780            media_type,
781            style_threading: config.style_threading,
782            incremental_layout: config
783                .incremental
784                .unwrap_or_else(incremental_layout_default),
785            subdocument_depth: config.subdocument_depth,
786            devtool_settings: DevtoolSettings::default(),
787            viewport_scroll: crate::Point::ZERO,
788            mutation_log: None,
789            inline_handler_changes: Vec::new(),
790            live_ranges: Vec::new(),
791            dom_selection: None,
792            url: base_url,
793            ua_stylesheets: HashMap::new(),
794            nodes_to_stylesheet: BTreeMap::new(),
795            document_sheet_nodes: HashSet::new(),
796            #[cfg(feature = "shadow-dom")]
797            shadow_sheet_roots: HashMap::new(),
798            font_ctx,
799            #[cfg(feature = "parallel-construct")]
800            thread_font_contexts: ThreadLocal::new(),
801            layout_ctx: parley::LayoutContext::new(),
802
803            hover_node_id: None,
804            hover_hit_node_id: None,
805            hover_node_is_text: false,
806            last_client_pointer_position: None,
807            semantic_hover_node_id: None,
808            focus_node_id: None,
809            active_node_id: None,
810            mousedown_node_id: None,
811            has_active_animations: false,
812            subdoc_animation_pacing: AnimationPacing::Idle,
813            has_canvas: false,
814            sub_document_nodes: HashSet::new(),
815            stash_policy: Default::default(),
816            teardown: Arc::clone(&teardown),
817            _dom_lease: teardown.lease(),
818            page_visible: false,
819            page_frozen: false,
820            page_input_blocked: false,
821            page_retiring: false,
822            was_discarded: false,
823            paint_revision: std::cell::Cell::new(0),
824            stash_paint: false,
825            restore_scroll: None,
826            #[cfg(feature = "custom-widget")]
827            retired_resources: Vec::new(),
828            iframe_loads: HashMap::new(),
829
830            #[cfg(feature = "custom-widget")]
831            custom_widget_nodes: HashSet::new(),
832            #[cfg(feature = "custom-widget")]
833            pending_resource_deallocations: Vec::new(),
834
835            #[cfg(feature = "shadow-dom")]
836            custom_element_registry: crate::node::CustomElementRegistry::new(),
837            #[cfg(feature = "shadow-dom")]
838            shadow_host_nodes: HashSet::new(),
839            #[cfg(feature = "shadow-dom")]
840            dirty_shadow_hosts: HashSet::new(),
841            #[cfg(feature = "shadow-dom")]
842            pending_slot_changes: Vec::new(),
843            #[cfg(feature = "shadow-dom")]
844            signaled_slots: HashSet::new(),
845            #[cfg(feature = "shadow-dom")]
846            adopted_stylesheets: HashMap::new(),
847            #[cfg(feature = "shadow-dom")]
848            custom_element_nodes: HashSet::new(),
849            #[cfg(feature = "shadow-dom")]
850            declarative_template_shadows: HashMap::new(),
851
852            deferred_construction_nodes: Vec::new(),
853            paint_damage: Default::default(),
854            script_custom_elements: Default::default(),
855            image_cache: HashMap::new(),
856            pending_images: HashMap::new(),
857            pending_critical_resources: HashSet::new(),
858            web_fonts: Vec::new(),
859            font_epoch: 0,
860            document_referrer: String::new(),
861            document_content_type: String::from("text/html"),
862            document_window_focused: false,
863            controls_to_form: HashMap::new(),
864            net_provider,
865            navigation_provider,
866            shell_provider,
867            html_parser_provider,
868            abort_signal: config.abort_signal,
869            last_mousedown_time: None,
870            mousedown_position: taffy::Point::ZERO,
871            click_count: 0,
872            drag_mode: DragMode::None,
873            hovered_scrollbar: None,
874            scrollbar_activity: HashMap::new(),
875            scroll_animation: ScrollAnimationState::None,
876            text_selection: TextSelection::default(),
877        };
878
879        // Initialise document with root Document node
880        doc.root_node_id = doc.create_node(NodeData::Document(Box::default()));
881        doc.root_node_mut().flags.insert(NodeFlags::IS_IN_DOCUMENT);
882
883        match config.ua_stylesheets {
884            Some(stylesheets) => {
885                for ss in &stylesheets {
886                    doc.add_user_agent_stylesheet(ss);
887                }
888            }
889            None => doc.add_user_agent_stylesheet(DEFAULT_CSS),
890        }
891
892        // Stylo data on the root node container is needed to render the node
893        let stylo_element_data = StyloElementData {
894            styles: ElementStyles {
895                primary: Some(
896                    ComputedValues::initial_values_with_font_override(Font::initial_values())
897                        .to_arc(),
898                ),
899                ..Default::default()
900            },
901            ..Default::default()
902        };
903        let stylo_data = doc.root_node_mut().stylo_element_data_mut();
904        *stylo_data.ensure_init_mut() = stylo_element_data;
905
906        doc
907    }
908
909    /// Set the Document's networking provider
910    pub fn set_net_provider(&mut self, net_provider: Arc<dyn NetProvider>) {
911        self.net_provider = net_provider;
912    }
913
914    /// Set the Document's navigation provider
915    pub fn set_navigation_provider(&mut self, navigation_provider: Arc<dyn NavigationProvider>) {
916        self.navigation_provider = navigation_provider;
917    }
918
919    /// Set the Document's shell provider
920    pub fn set_shell_provider(&mut self, shell_provider: Arc<dyn ShellProvider>) {
921        self.shell_provider = shell_provider;
922    }
923
924    /// Set the Document's html parser provider
925    pub fn set_html_parser_provider(&mut self, html_parser_provider: Arc<dyn HtmlParserProvider>) {
926        self.html_parser_provider = html_parser_provider;
927    }
928
929    /// Set base url for resolving linked resources (stylesheets, images, fonts, etc)
930    pub fn set_base_url(&mut self, url: &str) {
931        self.url = DocumentUrl::from(Url::parse(url).unwrap());
932    }
933
934    pub fn guard(&self) -> &SharedRwLock {
935        &self.guard
936    }
937
938    pub fn tree(&self) -> &NodeTree {
939        &self.nodes
940    }
941
942    pub fn id(&self) -> usize {
943        self.id
944    }
945
946    /// Wrapper around [`crate::net::stamped_request`]. Use the free function
947    /// when `&self` would conflict with a held `&mut` borrow on a field.
948    pub(crate) fn build_request(&self, url: url::Url) -> Request {
949        crate::net::with_referrer(
950            crate::net::stamped_request(url, self.abort_signal.as_ref()),
951            self.url(),
952        )
953    }
954
955    pub fn favicon_url(&self) -> Option<String> {
956        self.tree().iter().find_map(|(_, node)| {
957            let data = &node.data;
958            if !data.is_element_with_tag_name(&local_name!("link")) {
959                return None;
960            }
961            let rel = data.attr(local_name!("rel"))?;
962            if !rel
963                .split_ascii_whitespace()
964                .any(|v| v.eq_ignore_ascii_case("icon"))
965            {
966                return None;
967            }
968            data.attr(local_name!("href")).map(|s| s.to_string())
969        })
970    }
971
972    pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
973        self.nodes.get(node_id)
974    }
975
976    pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
977        self.nodes.get_mut(node_id)
978    }
979
980    pub fn get_focussed_node_id(&self) -> Option<NodeId> {
981        self.focus_node_id
982            .or(self.try_root_element().map(|el| el.id))
983    }
984
985    pub fn mutate<'doc>(&'doc mut self) -> DocumentMutator<'doc> {
986        DocumentMutator::new(self)
987    }
988
989    pub fn handle_dom_event<F: FnMut(DomEvent)>(
990        &mut self,
991        event: &mut DomEvent,
992        dispatch_event: F,
993    ) {
994        handle_dom_event(self, event, dispatch_event)
995    }
996
997    pub fn as_any_mut(&mut self) -> &mut dyn Any {
998        self
999    }
1000
1001    /// Find the label's bound input elements:
1002    /// the element id referenced by the "for" attribute of a given label element
1003    /// or the first input element which is nested in the label
1004    /// Note that although there should only be one bound element,
1005    /// we return all possibilities instead of just the first
1006    /// in order to allow the caller to decide which one is correct
1007    pub fn label_bound_input_element(&self, label_node_id: NodeId) -> Option<&Node> {
1008        let label_element = self.nodes[label_node_id].element_data()?;
1009        if let Some(target_element_dom_id) = label_element.attr(local_name!("for")) {
1010            TreeTraverser::new(self)
1011                .filter_map(|id| {
1012                    let node = self.get_node(id)?;
1013                    let element_data = node.element_data()?;
1014                    if element_data.name.local != local_name!("input") {
1015                        return None;
1016                    }
1017                    let id = element_data.id.as_ref()?;
1018                    if *id == *target_element_dom_id {
1019                        Some(node)
1020                    } else {
1021                        None
1022                    }
1023                })
1024                .next()
1025        } else {
1026            TreeTraverser::new_with_root(self, label_node_id)
1027                .filter_map(|child_id| {
1028                    let node = self.get_node(child_id)?;
1029                    let element_data = node.element_data()?;
1030                    if element_data.name.local == local_name!("input") {
1031                        Some(node)
1032                    } else {
1033                        None
1034                    }
1035                })
1036                .next()
1037        }
1038    }
1039
1040    /// The checkedness a click changed, kept so the click's *canceled
1041    /// activation steps* can put it back when a listener calls
1042    /// `preventDefault()`.
1043    ///
1044    /// A radio carries its whole set, because selecting one clears the others.
1045    pub fn run_pre_click_activation(&mut self, target: NodeId) -> Option<PreClickActivation> {
1046        let node_id = crate::events::pointer::checkable_activation_target(self, target)?;
1047        let el = self.get_node(node_id)?.data.downcast_element()?;
1048        let is_radio = el.attr(local_name!("type")) == Some("radio");
1049
1050        if !is_radio {
1051            let previous = el.checkbox_input_checked()?;
1052            let el = self.get_node_mut(node_id)?.data.downcast_element_mut()?;
1053            Self::toggle_checkbox(el);
1054            return Some(PreClickActivation {
1055                previous: vec![(node_id, previous)],
1056            });
1057        }
1058
1059        let radio_set = el.attr(local_name!("name")).map(str::to_string);
1060        let Some(radio_set) = radio_set else {
1061            let previous = el.checkbox_input_checked()?;
1062            let el = self.get_node_mut(node_id)?.data.downcast_element_mut()?;
1063            *el.checkbox_input_checked_mut()? = true;
1064            return Some(PreClickActivation {
1065                previous: vec![(node_id, previous)],
1066            });
1067        };
1068
1069        // Recorded *while* selecting rather than before it. Selecting one radio
1070        // clears every other in the set, so cancelling has to restore all of
1071        // them, and reading them first meant walking the whole arena twice for
1072        // one press. The membership test is `toggle_radio`'s, deliberately: two
1073        // predicates that disagreed would restore a different set than the one
1074        // that changed.
1075        //
1076        // That predicate is name plus "has checkbox state", which is neither
1077        // scoped to `type=radio` nor to a form owner. Wrong per HTML, and
1078        // longstanding; matching it here keeps this change to the ordering it
1079        // is about.
1080        let mut previous: Vec<(NodeId, bool)> = Vec::new();
1081        for (id, node) in self.nodes.iter_mut() {
1082            let Some(el) = node.data.downcast_element_mut() else {
1083                continue;
1084            };
1085            if el.attr(local_name!("name")) != Some(&*radio_set) {
1086                continue;
1087            }
1088            let Some(is_checked) = el.checkbox_input_checked_mut() else {
1089                continue;
1090            };
1091            previous.push((id, *is_checked));
1092            *is_checked = id == node_id;
1093        }
1094        Some(PreClickActivation { previous })
1095    }
1096
1097    /// Undo [`Self::run_pre_click_activation`]. The click's *canceled
1098    /// activation steps*.
1099    pub fn undo_pre_click_activation(&mut self, activation: PreClickActivation) {
1100        for (node_id, was_checked) in activation.previous {
1101            let Some(node) = self.get_node_mut(node_id) else {
1102                continue;
1103            };
1104            let Some(el) = node.data.downcast_element_mut() else {
1105                continue;
1106            };
1107            if let Some(is_checked) = el.checkbox_input_checked_mut() {
1108                *is_checked = was_checked;
1109            }
1110        }
1111    }
1112
1113    pub fn toggle_checkbox(el: &mut ElementData) -> bool {
1114        let Some(is_checked) = el.checkbox_input_checked_mut() else {
1115            return false;
1116        };
1117        *is_checked = !*is_checked;
1118
1119        *is_checked
1120    }
1121
1122    pub fn toggle_radio(&mut self, radio_set_name: String, target_radio_id: NodeId) {
1123        for (i, node) in self.nodes.iter_mut() {
1124            if let Some(node_data) = node.data.downcast_element_mut() {
1125                if node_data.attr(local_name!("name")) == Some(&radio_set_name) {
1126                    let was_clicked = i == target_radio_id;
1127                    let Some(is_checked) = node_data.checkbox_input_checked_mut() else {
1128                        continue;
1129                    };
1130                    *is_checked = was_clicked;
1131                }
1132            }
1133        }
1134    }
1135
1136    /// Toggle the `open` attribute of a `<details>` element, expanding or
1137    /// collapsing it. This is the default action triggered when the element's
1138    /// first `<summary>` child is activated.
1139    pub fn toggle_details_open(&mut self, details_id: NodeId) {
1140        use crate::qual_name;
1141
1142        let node = &self.nodes[details_id];
1143        if !node.data.is_element_with_tag_name(&local_name!("details")) {
1144            return;
1145        }
1146        let is_open = node.data.has_attr(local_name!("open"));
1147
1148        // Note: HTML attributes are in the empty (null) namespace, so the
1149        // QualName must not use the html namespace here, else it won't match
1150        // an `open` attribute created by the HTML parser.
1151        let mut mutator = self.mutate();
1152        if is_open {
1153            mutator.clear_attribute(details_id, qual_name!("open"));
1154        } else {
1155            mutator.set_attribute(details_id, qual_name!("open"), "");
1156        }
1157        drop(mutator);
1158
1159        self.shell_provider.request_redraw();
1160    }
1161
1162    pub fn set_style_property(&mut self, node_id: NodeId, name: &str, value: &str) {
1163        let node = &mut self.nodes[node_id];
1164        let did_change = node.element_data_mut().unwrap().set_style_property(
1165            name,
1166            value,
1167            &self.guard,
1168            self.url.url_extra_data(),
1169        );
1170        if did_change {
1171            node.mark_style_attr_updated();
1172        }
1173    }
1174
1175    pub fn remove_style_property(&mut self, node_id: NodeId, name: &str) {
1176        let node = &mut self.nodes[node_id];
1177        let did_change = node.element_data_mut().unwrap().remove_style_property(
1178            name,
1179            &self.guard,
1180            self.url.url_extra_data(),
1181        );
1182        if did_change {
1183            node.mark_style_attr_updated();
1184        }
1185    }
1186
1187    pub fn sub_document_node_ids(&self) -> Vec<NodeId> {
1188        self.sub_document_nodes.iter().copied().collect()
1189    }
1190
1191    /// Install the effective navigation referrer and response MIME type.
1192    pub fn set_navigation_metadata(&mut self, request: &Request, content_type: Option<&str>) {
1193        self.document_referrer = request
1194            .headers
1195            .get(blitz_traits::net::http::header::REFERER)
1196            .and_then(|value| value.to_str().ok())
1197            .and_then(|value| Url::parse(value).ok())
1198            .map(|mut url| {
1199                let _ = url.set_username("");
1200                let _ = url.set_password(None);
1201                url.set_fragment(None);
1202                url.to_string()
1203            })
1204            .unwrap_or_default();
1205        self.document_content_type = content_type
1206            .and_then(|value| value.split(';').next())
1207            .map(str::trim)
1208            .filter(|value| !value.is_empty())
1209            .unwrap_or("text/html")
1210            .to_ascii_lowercase();
1211    }
1212
1213    pub fn referrer(&self) -> &str {
1214        &self.document_referrer
1215    }
1216
1217    pub fn content_type(&self) -> &str {
1218        &self.document_content_type
1219    }
1220
1221    pub fn window_focused(&self) -> bool {
1222        self.document_window_focused
1223    }
1224
1225    /// Hosts must deliver focus changes on the document's owning thread.
1226    pub fn set_window_focused(&mut self, focused: bool) {
1227        self.document_window_focused = focused;
1228        for id in self.sub_document_node_ids() {
1229            if let Some(child) = self
1230                .get_node_mut(id)
1231                .and_then(|node| node.element_data_mut())
1232                .and_then(|element| element.sub_doc_data_mut())
1233            {
1234                child.inner_mut().set_window_focused(focused);
1235            }
1236        }
1237    }
1238
1239    /// 0 is standards, 1 is limited quirks, and 2 is quirks.
1240    pub fn set_document_quirks_mode(&mut self, mode: u8) {
1241        if let NodeData::Document(data) = &mut self.root_node_mut().data {
1242            data.quirks_mode = mode;
1243        }
1244    }
1245
1246    pub fn compat_mode(&self) -> &'static str {
1247        if matches!(&self.root_node().data, NodeData::Document(data) if data.quirks_mode == 2) {
1248            "BackCompat"
1249        } else {
1250            "CSS1Compat"
1251        }
1252    }
1253
1254    pub fn begin_document_stream(&mut self) {
1255        self.reset_web_fonts();
1256        self.pending_critical_resources.clear();
1257        self.pending_images.clear();
1258        self.set_document_quirks_mode(0);
1259    }
1260
1261    pub fn set_sub_document(&mut self, node_id: NodeId, mut sub_document: Box<dyn Document>) {
1262        sub_document
1263            .inner_mut()
1264            .set_window_focused(self.document_window_focused);
1265        self.nodes[node_id]
1266            .element_data_mut()
1267            .unwrap()
1268            .set_sub_document(sub_document);
1269        self.sub_document_nodes.insert(node_id);
1270    }
1271
1272    pub fn prepare_teardown(&mut self) {
1273        if self.page_retiring {
1274            return;
1275        }
1276        self.page_retiring = true;
1277        if let Some(signal) = &self.abort_signal {
1278            blitz_traits::net::AbortController {
1279                signal: signal.clone(),
1280            }
1281            .abort();
1282        }
1283        self.page_visible = false;
1284        self.page_frozen = true;
1285        let nodes: Vec<_> = self.sub_document_nodes.iter().copied().collect();
1286        let mut children = Vec::new();
1287        for id in nodes {
1288            if let Some(receipt) = self.retire_sub_document(id) {
1289                children.push(receipt);
1290            }
1291        }
1292        #[cfg(feature = "custom-widget")]
1293        self.destroy_surfaces();
1294        for (_, load) in std::mem::take(&mut self.iframe_loads) {
1295            load.abort_controller.abort();
1296        }
1297        self.teardown.retire(children);
1298    }
1299
1300    /// Detach the complete owner, rather than leaving a frozen runtime mounted.
1301    pub fn take_sub_document(&mut self, node_id: NodeId) -> Option<Box<dyn Document>> {
1302        let element = self.nodes.get_mut(node_id)?.element_data_mut()?;
1303        let crate::node::SpecialElementData::SubDocument(document) = element.special_data.take()
1304        else {
1305            return None;
1306        };
1307        self.sub_document_nodes.remove(&node_id);
1308        if let Some(load) = self.iframe_loads.remove(&node_id) {
1309            load.abort_controller.abort();
1310        }
1311        Some(document)
1312    }
1313
1314    pub fn retire_sub_document(
1315        &mut self,
1316        node_id: NodeId,
1317    ) -> Option<Arc<crate::release::Teardown>> {
1318        let mut document = self.take_sub_document(node_id)?;
1319        document.prepare_teardown();
1320        #[cfg(feature = "custom-widget")]
1321        {
1322            let receipt = Arc::clone(&document.inner().teardown);
1323            let (resources, mut leases) = document.inner_mut().retire_render_resources();
1324            if !resources.is_empty() {
1325                leases.push(receipt.lease());
1326                self.retired_resources
1327                    .push(crate::release::ResourceRetirement { resources, leases });
1328            }
1329        }
1330        Some(document.dispose())
1331    }
1332
1333    #[cfg(feature = "custom-widget")]
1334    fn retire_render_resources(
1335        &mut self,
1336    ) -> (
1337        Vec<anyrender::ResourceId>,
1338        Vec<crate::release::ReleaseLease>,
1339    ) {
1340        let mut resources = self.take_pending_resource_deallocations();
1341        let mut leases = Vec::new();
1342        for retirement in self.retired_resources.drain(..) {
1343            resources.extend(retirement.resources);
1344            leases.extend(retirement.leases);
1345        }
1346        for id in self.custom_widget_nodes.iter().copied() {
1347            if let Some(data) = self
1348                .nodes
1349                .get_mut(id)
1350                .and_then(|node| node.element_data_mut())
1351                .and_then(|el| el.custom_widget_data_mut())
1352            {
1353                resources.append(&mut data.active_resource_ids);
1354            }
1355        }
1356        for id in self.sub_document_nodes.iter().copied() {
1357            if let Some(document) = self.nodes.get_mut(id).and_then(|node| node.subdoc_mut()) {
1358                let (child_resources, child_leases) =
1359                    document.inner_mut().retire_render_resources();
1360                resources.extend(child_resources);
1361                leases.extend(child_leases);
1362            }
1363        }
1364        (resources, leases)
1365    }
1366
1367    #[cfg(feature = "custom-widget")]
1368    pub fn take_retired_resources(&mut self) -> Vec<crate::release::ResourceRetirement> {
1369        std::mem::take(&mut self.retired_resources)
1370    }
1371
1372    pub fn remove_sub_document(&mut self, node_id: NodeId) {
1373        let _ = self.retire_sub_document(node_id);
1374    }
1375
1376    /// Poll all sub-documents (see [`Document::poll`]), allowing them to make progress
1377    /// on any pending async operations (e.g. JavaScript timers). Hosts which poll a
1378    /// wrapper around a [`BaseDocument`] should call this from their `poll` implementation.
1379    ///
1380    /// Returns `true` if any sub-document reported changes.
1381    pub fn poll_subdocuments(&mut self, waker: Option<&Waker>) -> bool {
1382        self.poll_subdocuments_inner(waker, None)
1383    }
1384
1385    /// Propagate a settle pass's timer policy to every child document.
1386    pub fn poll_subdocuments_for_settle(
1387        &mut self,
1388        waker: Option<&Waker>,
1389        run_timers: bool,
1390    ) -> bool {
1391        self.poll_subdocuments_inner(waker, Some(run_timers))
1392    }
1393
1394    fn poll_subdocuments_inner(
1395        &mut self,
1396        waker: Option<&Waker>,
1397        settle_timers: Option<bool>,
1398    ) -> bool {
1399        let mut has_changes = false;
1400        let node_ids: Vec<NodeId> = self.sub_document_nodes.iter().copied().collect();
1401        for node_id in node_ids {
1402            let Some(sub_doc) = self
1403                .nodes
1404                .get_mut(node_id)
1405                .and_then(|node| node.subdoc_mut())
1406            else {
1407                continue;
1408            };
1409            let task_context = waker.map(TaskContext::from_waker);
1410            has_changes |= match settle_timers {
1411                Some(run_timers) => sub_doc.poll_for_settle(task_context, run_timers),
1412                None => sub_doc.poll(task_context),
1413            };
1414        }
1415        has_changes
1416    }
1417
1418    #[cfg(feature = "custom-widget")]
1419    pub fn custom_widget_node_ids(&self) -> Vec<NodeId> {
1420        self.custom_widget_nodes.iter().copied().collect()
1421    }
1422
1423    #[cfg(feature = "custom-widget")]
1424    pub fn take_pending_resource_deallocations(&mut self) -> Vec<anyrender::ResourceId> {
1425        std::mem::take(&mut self.pending_resource_deallocations)
1426    }
1427
1428    #[cfg(feature = "custom-widget")]
1429    pub fn set_custom_widget(&mut self, node_id: NodeId, widget: Box<dyn crate::Widget>) {
1430        self.nodes[node_id]
1431            .element_data_mut()
1432            .unwrap()
1433            .set_custom_widget(widget);
1434        self.custom_widget_nodes.insert(node_id);
1435    }
1436
1437    #[cfg(feature = "custom-widget")]
1438    pub fn remove_custom_widget(&mut self, node_id: NodeId) {
1439        let resources_to_deallocate = self.nodes[node_id]
1440            .element_data_mut()
1441            .unwrap()
1442            .remove_custom_widget();
1443        self.pending_resource_deallocations
1444            .extend_from_slice(&resources_to_deallocate);
1445        self.custom_widget_nodes.remove(&node_id);
1446    }
1447
1448    /// Mutable access to the custom element registry. Use
1449    /// [`CustomElementRegistry::define`](crate::node::CustomElementRegistry::define)
1450    /// to register custom elements by tag name.
1451    #[cfg(feature = "shadow-dom")]
1452    pub fn custom_elements_mut(&mut self) -> &mut crate::node::CustomElementRegistry {
1453        &mut self.custom_element_registry
1454    }
1455
1456    /// Register a custom element definition against a tag name (analogous to
1457    /// `customElements.define`).
1458    #[cfg(feature = "shadow-dom")]
1459    pub fn define_custom_element(
1460        &mut self,
1461        name: markup5ever::LocalName,
1462        definition: crate::node::CustomElementDefinition,
1463    ) {
1464        self.custom_element_registry.define(name, definition);
1465    }
1466
1467    /// The node ids of all shadow hosts in the document.
1468    #[cfg(feature = "shadow-dom")]
1469    pub fn shadow_host_node_ids(&self) -> Vec<NodeId> {
1470        self.shadow_host_nodes.iter().copied().collect()
1471    }
1472
1473    /// If `host_id` is a shadow host, returns the node id of its shadow root.
1474    #[cfg(feature = "shadow-dom")]
1475    pub fn shadow_root_id(&self, host_id: NodeId) -> Option<NodeId> {
1476        self.get_node(host_id)
1477            .and_then(|node| node.shadow_root_id())
1478    }
1479
1480    /// Attach a shadow root to the given host element, returning the node id of
1481    /// the newly-created shadow root. If the host already has a shadow root, its
1482    /// existing shadow root id is returned unchanged.
1483    #[cfg(feature = "shadow-dom")]
1484    pub fn attach_shadow(&mut self, host_id: NodeId, mode: crate::node::ShadowRootMode) -> NodeId {
1485        if let Some(existing) = self.nodes[host_id].shadow_root_id() {
1486            return existing;
1487        }
1488
1489        let shadow_root_id = self.create_node(NodeData::ShadowRoot(
1490            crate::node::ShadowRootData::new(host_id, mode),
1491        ));
1492
1493        // The shadow root's parent is the host. It is *not* added to the host's
1494        // `children` list (which holds light-DOM children); it is referenced via
1495        // the host's `ElementData::shadow_root` field instead.
1496        self.nodes[shadow_root_id].parent = Some(host_id);
1497        self.nodes[shadow_root_id].owner_document = self.nodes[host_id].owner_document;
1498        if self.nodes[host_id].flags.is_in_document() {
1499            self.nodes[shadow_root_id]
1500                .flags
1501                .insert(NodeFlags::IS_IN_DOCUMENT);
1502        }
1503
1504        self.nodes[host_id]
1505            .element_data_mut()
1506            .expect("Shadow host must be an element")
1507            .shadow_root = Some(shadow_root_id);
1508        self.shadow_host_nodes.insert(host_id);
1509        self.dirty_shadow_hosts.insert(host_id);
1510        self.nodes[host_id].set_restyle_hint(
1511            style::invalidation::element::restyle_hints::RestyleHint::restyle_subtree(),
1512        );
1513
1514        // Host needs its box tree rebuilt to account for the shadow tree.
1515        self.nodes[host_id].insert_damage(ALL_DAMAGE);
1516        self.nodes[host_id].mark_ancestors_dirty();
1517
1518        shadow_root_id
1519    }
1520
1521    /// HTML elements that `Element.attachShadow` accepts.
1522    ///
1523    /// Hyphenated names are included, matching the existing script binding.
1524    /// `template` is not a host.
1525    #[cfg(feature = "shadow-dom")]
1526    pub fn element_can_be_shadow_host(element: &ElementData) -> bool {
1527        let tag = element.name.local.as_ref();
1528        element.name.ns == ns!(html)
1529            && (tag.contains('-')
1530                || matches!(
1531                    tag,
1532                    "article"
1533                        | "aside"
1534                        | "blockquote"
1535                        | "body"
1536                        | "div"
1537                        | "footer"
1538                        | "h1"
1539                        | "h2"
1540                        | "h3"
1541                        | "h4"
1542                        | "h5"
1543                        | "h6"
1544                        | "header"
1545                        | "main"
1546                        | "nav"
1547                        | "p"
1548                        | "section"
1549                        | "span"
1550                ))
1551    }
1552
1553    /// Attach a declarative shadow root, or return false and leave the template
1554    /// as a normal template.
1555    ///
1556    /// Failure is the host being missing, not a shadow host, or already having
1557    /// a root. Success records the template so parsed children land in the root.
1558    #[cfg(feature = "shadow-dom")]
1559    pub fn try_attach_declarative_shadow(
1560        &mut self,
1561        host_id: NodeId,
1562        template_id: NodeId,
1563        mode: crate::node::ShadowRootMode,
1564        clonable: bool,
1565        serializable: bool,
1566        delegates_focus: bool,
1567    ) -> bool {
1568        let can_attach = self.nodes.get(host_id).is_some_and(|node| {
1569            node.element_data().is_some_and(|element| {
1570                Self::element_can_be_shadow_host(element) && element.shadow_root.is_none()
1571            })
1572        });
1573        if !can_attach {
1574            return false;
1575        }
1576        let root_id = self.attach_shadow(host_id, mode);
1577        if let Some(root) = self.nodes[root_id].shadow_root_data_mut() {
1578            root.declarative = true;
1579            root.clonable = clonable;
1580            root.serializable = serializable;
1581            root.delegates_focus = delegates_focus;
1582        }
1583        self.declarative_template_shadows
1584            .insert(template_id, root_id);
1585        true
1586    }
1587
1588    /// Detach (and drop) the shadow root of the given host element, if any.
1589    #[cfg(feature = "shadow-dom")]
1590    pub fn detach_shadow(&mut self, host_id: NodeId) {
1591        let shadow_root_id = self.nodes[host_id]
1592            .element_data_mut()
1593            .and_then(|el| el.shadow_root.take());
1594        if let Some(shadow_root_id) = shadow_root_id {
1595            self.drop_node_ignoring_parent(shadow_root_id);
1596            self.shadow_host_nodes.remove(&host_id);
1597            self.dirty_shadow_hosts.remove(&host_id);
1598            self.nodes[host_id].flattened_children = None;
1599            self.nodes[host_id].set_restyle_hint(
1600                style::invalidation::element::restyle_hints::RestyleHint::restyle_subtree(),
1601            );
1602            self.nodes[host_id].insert_damage(ALL_DAMAGE);
1603            self.nodes[host_id].mark_ancestors_dirty();
1604        }
1605    }
1606
1607    /// Attach a custom element controller to the given node.
1608    #[cfg(feature = "shadow-dom")]
1609    pub fn set_custom_element(
1610        &mut self,
1611        node_id: NodeId,
1612        controller: Box<dyn crate::node::CustomElement>,
1613    ) {
1614        use crate::node::{CustomElementData, SpecialElementData};
1615        self.nodes[node_id]
1616            .element_data_mut()
1617            .expect("Custom element host must be an element")
1618            .special_data = SpecialElementData::CustomElement(CustomElementData::new(controller));
1619        self.custom_element_nodes.insert(node_id);
1620    }
1621
1622    /// Detach the custom element controller from the given node (without running
1623    /// the `disconnected` callback). Returns the controller if present.
1624    #[cfg(feature = "shadow-dom")]
1625    pub fn take_custom_element(
1626        &mut self,
1627        node_id: NodeId,
1628    ) -> Option<Box<dyn crate::node::CustomElement>> {
1629        use crate::node::SpecialElementData;
1630        self.custom_element_nodes.remove(&node_id);
1631        let element = self.nodes[node_id].element_data_mut()?;
1632        if matches!(element.special_data, SpecialElementData::CustomElement(_)) {
1633            if let SpecialElementData::CustomElement(mut data) = element.special_data.take() {
1634                return data.controller.take();
1635            }
1636        }
1637        None
1638    }
1639
1640    pub fn root_node(&self) -> &Node {
1641        &self.nodes[self.root_node_id]
1642    }
1643
1644    pub fn root_node_mut(&mut self) -> &mut Node {
1645        &mut self.nodes[self.root_node_id]
1646    }
1647
1648    /// Ask this document to work out which regions differ between frames.
1649    ///
1650    /// Off by default. A consumer that turns it on is charged one pass over the
1651    /// node list per [`resolve`](Self::resolve) - a pass `resolve` already makes
1652    /// to clear damage - plus a hash lookup and a rectangle comparison per node.
1653    /// Nothing else in the document reads the result, so leaving it off costs a
1654    /// single branch.
1655    ///
1656    /// The consumer this exists for is a `backdrop-filter` cache. Blurring what
1657    /// is behind an element costs a render pass and a filter every frame, and
1658    /// the only way that stops being permanent is to skip the elements whose
1659    /// input has not changed. Turning this on is what makes that question
1660    /// answerable.
1661    ///
1662    /// The first frame after enabling reports everything as changed, because
1663    /// there is no previous frame to compare against.
1664    pub fn set_paint_damage_tracking(&mut self, enabled: bool) {
1665        self.paint_damage.set_enabled(enabled);
1666    }
1667
1668    /// Whether [`set_paint_damage_tracking`](Self::set_paint_damage_tracking) is on.
1669    pub fn paint_damage_tracking(&self) -> bool {
1670        self.paint_damage.is_enabled()
1671    }
1672
1673    /// What changed since the previously resolved frame.
1674    ///
1675    /// Empty when tracking is off, which is indistinguishable from "nothing
1676    /// changed" and deliberately so: a consumer that has not asked for the
1677    /// question to be answered must not read the empty answer as a licence to
1678    /// reuse a cache. Check
1679    /// [`paint_damage_tracking`](Self::paint_damage_tracking) first.
1680    pub fn paint_damage(&self) -> &crate::paint_damage::PaintDamage {
1681        self.paint_damage.damage()
1682    }
1683
1684    pub fn try_root_element(&self) -> Option<&Node> {
1685        TDocument::as_node(&self.root_node()).first_element_child()
1686    }
1687
1688    pub fn root_element(&self) -> &Node {
1689        TDocument::as_node(&self.root_node())
1690            .first_element_child()
1691            .unwrap()
1692            .as_element()
1693            .unwrap()
1694    }
1695
1696    pub fn create_node(&mut self, node_data: NodeData) -> NodeId {
1697        let tree_ptr = self.nodes.as_mut() as *mut NodeTree;
1698        let guard = self.guard.clone();
1699        let is_document = matches!(node_data, NodeData::Document(_));
1700        let id = self
1701            .nodes
1702            .insert_with_key(|id| Node::new(tree_ptr, id, guard, node_data));
1703        if !is_document {
1704            self.nodes[id].owner_document = Some(self.root_node_id);
1705        }
1706        self.register_script_custom_element(id);
1707        if let Some(attributes) = self.nodes[id]
1708            .element_data()
1709            .and_then(|element| element.inline_event_attributes.as_deref())
1710        {
1711            self.inline_handler_changes.extend(
1712                attributes
1713                    .iter()
1714                    .map(|attr| (id, attr.name.clone(), Some(attr.value.clone()))),
1715            );
1716        }
1717        id
1718    }
1719
1720    pub fn add_to_document_id_map(&mut self, document_id: NodeId, id: &str, node_id: NodeId) {
1721        if document_id == self.root_node_id || id.is_empty() {
1722            return;
1723        }
1724        let Some(node) = self.get_node_mut(document_id) else {
1725            return;
1726        };
1727        let NodeData::Document(data) = &mut node.data else {
1728            return;
1729        };
1730        let candidates = data.ids.entry(id.to_owned()).or_default();
1731        if !candidates.contains(&node_id) {
1732            candidates.push(node_id);
1733        }
1734    }
1735
1736    pub fn remove_from_document_id_map(&mut self, document_id: NodeId, id: &str, node_id: NodeId) {
1737        if document_id == self.root_node_id {
1738            return;
1739        }
1740        let Some(node) = self.get_node_mut(document_id) else {
1741            return;
1742        };
1743        let NodeData::Document(data) = &mut node.data else {
1744            return;
1745        };
1746        if let Some(candidates) = data.ids.get_mut(id) {
1747            candidates.retain(|candidate| *candidate != node_id);
1748            if candidates.is_empty() {
1749                data.ids.remove(id);
1750            }
1751        }
1752    }
1753
1754    /// Indexed lookup scoped to one document. Parent child-list membership
1755    /// excludes removed nodes, template contents, and shadow trees.
1756    pub fn get_element_by_id_in(&self, document_id: NodeId, id: &str) -> Option<NodeId> {
1757        if document_id == self.root_node_id {
1758            return self.get_element_by_id(id);
1759        }
1760        if id.is_empty() {
1761            return None;
1762        }
1763        let NodeData::Document(data) = &self.get_node(document_id)?.data else {
1764            return None;
1765        };
1766        data.ids
1767            .get(id)?
1768            .iter()
1769            .filter_map(|candidate| {
1770                let mut current = *candidate;
1771                let mut path = Vec::new();
1772                while current != document_id {
1773                    let node = self.get_node(current)?;
1774                    let parent_id = node.parent?;
1775                    let parent = self.get_node(parent_id)?;
1776                    path.push(parent.index_of_child(current)?);
1777                    current = parent_id;
1778                }
1779                path.reverse();
1780                Some((path, *candidate))
1781            })
1782            .min_by(|left, right| left.0.cmp(&right.0))
1783            .map(|(_, node_id)| node_id)
1784    }
1785
1786    /// Nodes that still hold a link to `node_id` after it leaves the document.
1787    ///
1788    /// Direct parents are recorded separately and force reconstruction. This
1789    /// collects everyone else the old document-wide pass would have mutated:
1790    /// surviving layout children (their `layout_parent` still points here),
1791    /// anonymous blocks that list the id without being its DOM parent, shadow
1792    /// slots and hosts, the authored parent of a hoisted fixed box, and
1793    /// ancestors whose inline layout, list marker, table grid, or stacking
1794    /// context stores a descendant id. Ancestors inside `removed` are not
1795    /// walked; detachment does not clean lists on nodes that are themselves
1796    /// going away.
1797    fn collect_live_reference_owners(
1798        nodes: &NodeTree,
1799        hoisted_fixed_parents: &HashMap<NodeId, NodeId>,
1800        removed: &FxHashSet<NodeId>,
1801        node_id: NodeId,
1802        candidates: &mut FxHashSet<NodeId>,
1803    ) {
1804        fn push_survivor(
1805            removed: &FxHashSet<NodeId>,
1806            candidates: &mut FxHashSet<NodeId>,
1807            id: NodeId,
1808        ) {
1809            if !removed.contains(&id) {
1810                candidates.insert(id);
1811            }
1812        }
1813
1814        let (removed_is_text, dom_parent, layout_parent, seeds) = {
1815            let Some(node) = nodes.get(node_id) else {
1816                return;
1817            };
1818            let mut seeds: SmallVec<[NodeId; 8]> = SmallVec::new();
1819            {
1820                let borrowed = node.layout_children.borrow();
1821                if let Some(children) = borrowed.as_ref() {
1822                    seeds.extend(children.iter().copied());
1823                }
1824            }
1825            seeds.extend(node.children.iter().copied());
1826            seeds.extend(node.anonymous_blocks.iter().copied());
1827            seeds.extend(node.before());
1828            seeds.extend(node.after());
1829            #[cfg(feature = "shadow-dom")]
1830            {
1831                seeds.extend(node.assigned_slot);
1832                seeds.extend(
1833                    node.element_data()
1834                        .and_then(|element| element.assigned_slot),
1835                );
1836                if let Some(assigned) = node.assigned_nodes.as_ref() {
1837                    seeds.extend(assigned.iter().copied());
1838                }
1839                if let Some(root) = node.shadow_root_data() {
1840                    seeds.push(root.host);
1841                }
1842            }
1843            (
1844                node.is_text_node(),
1845                node.parent,
1846                node.layout_parent.get(),
1847                seeds,
1848            )
1849        };
1850
1851        for parent_id in [dom_parent, layout_parent].into_iter().flatten() {
1852            let Some(parent) = nodes.get(parent_id) else {
1853                continue;
1854            };
1855            let anons: SmallVec<[NodeId; 4]> = parent.anonymous_blocks.iter().copied().collect();
1856            for anon in anons {
1857                let Some(anon_node) = nodes.get(anon) else {
1858                    continue;
1859                };
1860                let listed = anon_node
1861                    .children
1862                    .iter()
1863                    .any(|child| removed.contains(child))
1864                    || anon_node
1865                        .layout_children
1866                        .borrow()
1867                        .as_ref()
1868                        .is_some_and(|children| {
1869                            children.iter().any(|child| removed.contains(child))
1870                        });
1871                if listed {
1872                    push_survivor(removed, candidates, anon);
1873                }
1874            }
1875        }
1876        for id in seeds {
1877            push_survivor(removed, candidates, id);
1878        }
1879
1880        let mut stack: SmallVec<[NodeId; 16]> = SmallVec::new();
1881        if let Some(id) = dom_parent {
1882            if !removed.contains(&id) {
1883                stack.push(id);
1884            }
1885        }
1886        if let Some(id) = layout_parent {
1887            if !removed.contains(&id) {
1888                stack.push(id);
1889            }
1890        }
1891        if let Some(origin) = hoisted_fixed_parents.get(&node_id).copied() {
1892            // Hoist drops the layout edge before paint children are rebuilt, so
1893            // the authored parent can still name the fixed box.
1894            push_survivor(removed, candidates, origin);
1895            if !removed.contains(&origin) {
1896                stack.push(origin);
1897            }
1898        }
1899
1900        let mut seen: SmallVec<[NodeId; 16]> = SmallVec::new();
1901        seen.push(node_id);
1902        while let Some(id) = stack.pop() {
1903            if seen.contains(&id) {
1904                continue;
1905            }
1906            seen.push(id);
1907            let Some(ancestor) = nodes.get(id) else {
1908                continue;
1909            };
1910            let stacking = ancestor.stacking_context.as_ref().is_some_and(|context| {
1911                context.children.iter().any(|child| {
1912                    removed.contains(&child.node_id)
1913                        || child
1914                            .clip_ancestors
1915                            .iter()
1916                            .any(|clip| removed.contains(clip))
1917                })
1918            });
1919            let inline_or_list = ancestor.element_data().is_some_and(|element| {
1920                element.inline_layout_data.is_some() || element.list_item_data.is_some()
1921            });
1922            // Table grids store cells, rows, and row groups, never text nodes.
1923            let table_root = !removed_is_text && ancestor.flags.is_table_root();
1924            #[cfg(feature = "shadow-dom")]
1925            let shadow_hit = {
1926                let mut hit = ancestor
1927                    .flattened_children
1928                    .as_ref()
1929                    .is_some_and(|children| children.iter().any(|child| removed.contains(child)))
1930                    || ancestor.assigned_nodes.as_ref().is_some_and(|assigned| {
1931                        assigned.iter().any(|child| removed.contains(child))
1932                    });
1933                if let Some(root) = ancestor.shadow_root_data() {
1934                    let host = root.host;
1935                    hit = hit
1936                        || root.slots.iter().any(|slot| removed.contains(slot))
1937                        || root
1938                            .slottables
1939                            .iter()
1940                            .any(|slottable| removed.contains(slottable))
1941                        || root
1942                            .stylesheet_nodes
1943                            .iter()
1944                            .any(|sheet| removed.contains(sheet));
1945                    stack.push(host);
1946                }
1947                hit
1948            };
1949            #[cfg(not(feature = "shadow-dom"))]
1950            let shadow_hit = false;
1951            if stacking || inline_or_list || table_root || shadow_hit {
1952                push_survivor(removed, candidates, id);
1953            }
1954            if let Some(parent) = ancestor.parent {
1955                stack.push(parent);
1956            }
1957            if let Some(parent) = ancestor.layout_parent.get() {
1958                stack.push(parent);
1959            }
1960        }
1961    }
1962
1963    /// Stacking-context hosts are few, and a fixed box can paint in one that
1964    /// is not a layout ancestor of its current parent. Visit a host only when
1965    /// its hoisted list actually names a removed id.
1966    fn collect_stacking_context_owners(
1967        nodes: &NodeTree,
1968        position_hosts: &HashSet<NodeId>,
1969        clip_hosts: &HashSet<NodeId>,
1970        removed: &FxHashSet<NodeId>,
1971        candidates: &mut FxHashSet<NodeId>,
1972    ) {
1973        for host in position_hosts.iter().chain(clip_hosts.iter()).copied() {
1974            if removed.contains(&host) {
1975                continue;
1976            }
1977            let Some(node) = nodes.get(host) else {
1978                continue;
1979            };
1980            let Some(context) = node.stacking_context.as_ref() else {
1981                continue;
1982            };
1983            let references = context.children.iter().any(|child| {
1984                removed.contains(&child.node_id)
1985                    || child
1986                        .clip_ancestors
1987                        .iter()
1988                        .any(|clip| removed.contains(clip))
1989            });
1990            if references {
1991                candidates.insert(host);
1992            }
1993        }
1994    }
1995
1996    /// Remove detached or freed ids from every persistent rendering cache.
1997    ///
1998    /// DOM ancestry alone cannot find the owners: fixed boxes can belong to
1999    /// the root's layout list and another ancestor's stacking context, while
2000    /// inline boxes and table cells have additional flattened representations.
2001    /// Invalidate those owners before this mutation returns. Hidden subtrees
2002    /// retain caches, so leaving cleanup until resolve is insufficient.
2003    ///
2004    /// Only those owners are visited. A pass over every node made each
2005    /// `textContent` write rescan the whole document.
2006    pub(crate) fn invalidate_node_references(
2007        &mut self,
2008        removed: &FxHashSet<NodeId>,
2009        mut parents: FxHashSet<NodeId>,
2010        extra_owners: FxHashSet<NodeId>,
2011    ) {
2012        if removed.is_empty() {
2013            return;
2014        }
2015
2016        fn layout_references_removed(
2017            layout: &parley::Layout<TextBrush>,
2018            removed: &FxHashSet<NodeId>,
2019        ) -> bool {
2020            layout
2021                .inline_boxes()
2022                .iter()
2023                .any(|ibox| removed.contains(&NodeId::from_u64(ibox.id)))
2024                || layout.styles().iter().any(|style| {
2025                    removed.contains(&style.brush.id)
2026                        || style
2027                            .brush
2028                            .text_node
2029                            .is_some_and(|id| removed.contains(&id))
2030                })
2031        }
2032
2033        // An anonymous origin can disappear while its hoisted fixed box
2034        // survives. Reconstruct the root edge too, so construction can give
2035        // that box a current authored origin.
2036        for (&id, &origin) in &self.hoisted_fixed_parents {
2037            if removed.contains(&id) || removed.contains(&origin) {
2038                if let Some(node) = self.nodes.get(id) {
2039                    parents.extend(node.layout_parent.get());
2040                }
2041            }
2042        }
2043
2044        let mut candidates: FxHashSet<NodeId> = FxHashSet::with_capacity_and_hasher(
2045            parents.len() + extra_owners.len() + removed.len(),
2046            Default::default(),
2047        );
2048        for &id in &parents {
2049            if !removed.contains(&id) {
2050                candidates.insert(id);
2051            }
2052        }
2053        for id in extra_owners {
2054            if !removed.contains(&id) {
2055                candidates.insert(id);
2056            }
2057        }
2058        for &id in removed {
2059            let detach = self
2060                .nodes
2061                .get(id)
2062                .and_then(|node| node.layout_parent.get())
2063                .is_some_and(|parent| !removed.contains(&parent));
2064            if detach {
2065                candidates.insert(id);
2066            }
2067            if self.nodes.contains_key(id) {
2068                Self::collect_live_reference_owners(
2069                    &self.nodes,
2070                    &self.hoisted_fixed_parents,
2071                    removed,
2072                    id,
2073                    &mut candidates,
2074                );
2075            }
2076        }
2077        Self::collect_stacking_context_owners(
2078            &self.nodes,
2079            &self.hoisted_position_hosts,
2080            &self.hoisted_clip_hosts,
2081            removed,
2082            &mut candidates,
2083        );
2084
2085        self.fixed_nodes.retain(|id| !removed.contains(id));
2086        self.sticky_nodes.retain(|id| !removed.contains(id));
2087        self.sticky_offsets.retain(|id, _| !removed.contains(id));
2088        self.hoisted_fixed_parents
2089            .retain(|id, origin| !removed.contains(id) && !removed.contains(origin));
2090        let fixed_parents = &self.hoisted_fixed_parents;
2091        self.hoisted_fixed_indices
2092            .retain(|id, _| fixed_parents.contains_key(id));
2093        self.hoisted_clip_hosts.retain(|id| !removed.contains(id));
2094        self.hoisted_position_hosts
2095            .retain(|id| !removed.contains(id));
2096
2097        // These snapshots contain both box ids and containing-block/parent
2098        // ids. Discard the complete snapshots rather than retaining inputs
2099        // computed against a box tree that has just changed.
2100        self.abspos_placements.clear();
2101        self.abspos_candidates.clear();
2102        self.abspos_candidate_ids.clear();
2103        self.abspos_inputs.clear();
2104        self.abspos_written.clear();
2105        self.abspos_candidates_dirty = true;
2106        self.abspos_layout_dirty = true;
2107
2108        let mut dirty = FxHashSet::default();
2109        for id in candidates {
2110            let Some(node) = self.nodes.get_mut(id) else {
2111                continue;
2112            };
2113            // Detached nodes still owned by script keep their internal box
2114            // tree, but must lose any edge back into the live layout tree.
2115            if removed.contains(&id) {
2116                if node
2117                    .layout_parent
2118                    .get()
2119                    .is_some_and(|parent| !removed.contains(&parent))
2120                {
2121                    node.layout_parent.set(None);
2122                }
2123                continue;
2124            }
2125
2126            let mut reconstruct = parents.contains(&id);
2127            reconstruct |= node
2128                .layout_children
2129                .get_mut()
2130                .as_ref()
2131                .is_some_and(|children| children.iter().any(|id| removed.contains(id)));
2132            let mut repaint = node
2133                .paint_children
2134                .get_mut()
2135                .as_ref()
2136                .is_some_and(|children| children.iter().any(|id| removed.contains(id)));
2137
2138            let old_anonymous_count = node.anonymous_blocks.len();
2139            node.anonymous_blocks.retain(|id| !removed.contains(id));
2140            reconstruct |= old_anonymous_count != node.anonymous_blocks.len();
2141            if node.is_anonymous() {
2142                let old_child_count = node.children.len();
2143                node.children.retain(|id| !removed.contains(id));
2144                reconstruct |= old_child_count != node.children.len();
2145            }
2146            if node
2147                .layout_parent
2148                .get()
2149                .is_some_and(|parent| removed.contains(&parent))
2150            {
2151                node.layout_parent.set(None);
2152                reconstruct = true;
2153            }
2154
2155            if let Some(context) = node.stacking_context.as_mut() {
2156                let old_count = context.children.len();
2157                context.children.retain(|child| {
2158                    !removed.contains(&child.node_id)
2159                        && !child.clip_ancestors.iter().any(|id| removed.contains(id))
2160                });
2161                if old_count != context.children.len() {
2162                    // Filtering changes the split between negative and
2163                    // non-negative children as well as the content bounds.
2164                    context.sort();
2165                    context.content_area = taffy::Rect::ZERO;
2166                    repaint = true;
2167                }
2168            }
2169
2170            #[cfg(feature = "shadow-dom")]
2171            {
2172                for children in [
2173                    node.flattened_children.as_mut(),
2174                    node.assigned_nodes.as_mut(),
2175                ]
2176                .into_iter()
2177                .flatten()
2178                {
2179                    let old_count = children.len();
2180                    children.retain(|id| !removed.contains(id));
2181                    reconstruct |= old_count != children.len();
2182                }
2183                if node.assigned_slot.is_some_and(|id| removed.contains(&id)) {
2184                    node.assigned_slot = None;
2185                    reconstruct = true;
2186                }
2187                if let Some(root) = node.shadow_root_data_mut() {
2188                    let old_count = root.slots.len() + root.slottables.len();
2189                    root.slots.retain(|id| !removed.contains(id));
2190                    root.slottables.retain(|id| !removed.contains(id));
2191                    root.stylesheet_nodes.retain(|id| !removed.contains(id));
2192                    reconstruct |= old_count != root.slots.len() + root.slottables.len();
2193                }
2194            }
2195
2196            let mut table_removed = false;
2197            if let Some(element) = node.data.downcast_element_mut() {
2198                for pseudo in [&mut element.before, &mut element.after] {
2199                    if pseudo.is_some_and(|id| removed.contains(&id)) {
2200                        *pseudo = None;
2201                        reconstruct = true;
2202                    }
2203                }
2204                if element
2205                    .inline_layout_data
2206                    .as_ref()
2207                    .is_some_and(|inline| layout_references_removed(&inline.layout, removed))
2208                {
2209                    element.inline_layout_data = None;
2210                    reconstruct = true;
2211                }
2212                if element
2213                    .list_item_data
2214                    .as_ref()
2215                    .is_some_and(|item| match &item.position {
2216                        crate::node::ListItemLayoutPosition::Outside(layout) => {
2217                            layout_references_removed(layout, removed)
2218                        }
2219                        crate::node::ListItemLayoutPosition::Inside => false,
2220                    })
2221                {
2222                    element.list_item_data = None;
2223                    reconstruct = true;
2224                }
2225                if let SpecialElementData::TableRoot(context) = &element.special_data {
2226                    table_removed = context.references_removed_nodes(removed);
2227                }
2228                if table_removed {
2229                    element.special_data = SpecialElementData::None;
2230                    reconstruct = true;
2231                }
2232                #[cfg(feature = "shadow-dom")]
2233                if element
2234                    .assigned_slot
2235                    .is_some_and(|id| removed.contains(&id))
2236                {
2237                    element.assigned_slot = None;
2238                    reconstruct = true;
2239                }
2240            }
2241            if table_removed {
2242                node.flags.remove(NodeFlags::IS_TABLE_ROOT);
2243            }
2244
2245            if reconstruct || repaint {
2246                node.layout_children.get_mut().take();
2247                node.paint_children.get_mut().take();
2248                if let Some(element) = node.data.downcast_element_mut() {
2249                    element.inline_layout_data = None;
2250                }
2251                dirty.insert(id);
2252            }
2253        }
2254
2255        // Clear ancestor layout caches immediately, including ancestors that
2256        // the next incremental traversal would otherwise skip. Follow both
2257        // trees and tolerate parents already freed by a root-first drop.
2258        let mut stack: Vec<NodeId> = dirty.iter().copied().collect();
2259        let mut visited = FxHashSet::default();
2260        while let Some(id) = stack.pop() {
2261            if !visited.insert(id) || removed.contains(&id) {
2262                continue;
2263            }
2264            let Some(node) = self.nodes.get_mut(id) else {
2265                continue;
2266            };
2267            stack.extend(node.parent);
2268            stack.extend(node.layout_parent.get());
2269            #[cfg(feature = "shadow-dom")]
2270            {
2271                stack.extend(node.assigned_slot);
2272                if let Some(root) = node.shadow_root_data() {
2273                    stack.push(root.host);
2274                }
2275            }
2276            node.set_dirty_descendants();
2277            if node.stylo_element_data_opt().is_some() {
2278                node.insert_damage(if dirty.contains(&id) {
2279                    ALL_DAMAGE
2280                } else {
2281                    crate::layout::damage::ONLY_RELAYOUT
2282                        | style::selector_parser::RestyleDamage::REPAINT
2283                });
2284                node.cache_mut().clear();
2285            }
2286            if let Some(inline) = node
2287                .data
2288                .downcast_element_mut()
2289                .and_then(|element| element.inline_layout_data.as_mut())
2290            {
2291                inline.content_widths = None;
2292            }
2293        }
2294
2295        #[cfg(feature = "shadow-dom")]
2296        {
2297            let nodes = &self.nodes;
2298            self.shadow_host_nodes.retain(|id| nodes.contains_key(*id));
2299            self.dirty_shadow_hosts.retain(|id| nodes.contains_key(*id));
2300            self.pending_slot_changes
2301                .retain(|id| nodes.contains_key(*id));
2302            self.signaled_slots.retain(|id| nodes.contains_key(*id));
2303            self.dirty_shadow_hosts
2304                .extend(self.shadow_host_nodes.iter().copied());
2305        }
2306
2307        self.controls_to_form
2308            .retain(|control, form| !removed.contains(control) && !removed.contains(form));
2309        // Detached owners still need their caches cleared, but cannot affect
2310        // the frame. Live owners share the surrounding mutator's redraw.
2311        if dirty.iter().any(|id| {
2312            self.nodes
2313                .get(*id)
2314                .is_some_and(|node| node.flags.is_in_document())
2315        }) {
2316            if self.mutator_depth == 0 {
2317                self.shell_provider.request_redraw();
2318            } else {
2319                self.mutator_redraw_pending = true;
2320            }
2321        }
2322
2323        // PaintDamage deliberately keeps old rectangles until resolve so
2324        // pixels formerly occupied by removed boxes are included in damage.
2325    }
2326
2327    fn purge_dropped_node_references(&mut self) {
2328        if self.node_drop_depth != 0 || self.dropped_node_ids.is_empty() {
2329            return;
2330        }
2331        let removed = std::mem::take(&mut self.dropped_node_ids);
2332        let parents = std::mem::take(&mut self.dropped_node_parents);
2333        let owners = std::mem::take(&mut self.dropped_reference_owners);
2334        self.invalidate_node_references(&removed, parents, owners);
2335    }
2336
2337    /// Remove a node from the node tree, clearing interaction state and
2338    /// invalidating rendering references before the surrounding drop returns.
2339    pub(crate) fn remove_node_from_tree(&mut self, node_id: NodeId) -> Option<Node> {
2340        self.clear_interaction_state_for_removed_node(node_id);
2341        let indexed = self.get_node(node_id).and_then(|node| {
2342            Some((
2343                node.owner_document?,
2344                node.element_data()?.id.as_ref()?.to_string(),
2345            ))
2346        });
2347        if let Some((document_id, id)) = indexed {
2348            self.remove_from_document_id_map(document_id, &id, node_id);
2349        }
2350        self.forget_script_custom_element(node_id);
2351        if self.nodes.contains_key(node_id) {
2352            self.dropped_node_ids.insert(node_id);
2353            let mut owners = FxHashSet::default();
2354            Self::collect_live_reference_owners(
2355                &self.nodes,
2356                &self.hoisted_fixed_parents,
2357                &self.dropped_node_ids,
2358                node_id,
2359                &mut owners,
2360            );
2361            self.dropped_reference_owners.extend(owners);
2362        }
2363        let node = self.nodes.remove(node_id);
2364        if let Some(node) = &node {
2365            self.dropped_node_parents.extend(
2366                [node.parent, node.layout_parent.get()]
2367                    .into_iter()
2368                    .flatten(),
2369            );
2370            self.purge_dropped_node_references();
2371        }
2372        node
2373    }
2374
2375    /// The nearest element ancestor of `node_id` that is still in the
2376    /// document. Used to retarget hover/active state when the node they
2377    /// reference is removed. Tolerates already-removed ancestors (subtree
2378    /// teardown proceeds root-first) by giving up and returning `None`.
2379    fn nearest_surviving_element_ancestor(&self, node_id: NodeId) -> Option<NodeId> {
2380        let mut current = self.get_node(node_id)?.parent;
2381        while let Some(id) = current {
2382            let node = self.get_node(id)?;
2383            if node.is_element() && node.flags.is_in_document() {
2384                return Some(id);
2385            }
2386            current = node.parent;
2387        }
2388        None
2389    }
2390
2391    /// Clear any interaction state (hover/active/focus/mousedown/selection/
2392    /// drag/scrollbar) that references `node_id`, which is being removed from
2393    /// the document, running the usual teardown steps. `node_id` must still be
2394    /// present in the slab.
2395    ///
2396    /// This matches browser semantics (WebKit `hoveredElementDidDetach` /
2397    /// `elementInActiveChainDidDetach`, Blink `HoveredElementDetached` /
2398    /// `ActiveChainNodeDetached`):
2399    /// - Hover and active retarget to the nearest surviving element ancestor
2400    ///   as a *transient bridge*: the HOVER/ACTIVE element-state bits along
2401    ///   the surviving chain stay lit (no one-frame gap in `:hover`/`:active`
2402    ///   styling), and the subsequent hover diff can unset exactly the right
2403    ///   bits. Hover is then re-resolved against the pointer position by
2404    ///   [`Self::refresh_hover`] at the end of the next resolve pass (the
2405    ///   analogue of WebKit's "fake mouse move"), which corrects the bridge
2406    ///   value — including cases where the removed node overflowed its
2407    ///   ancestor's box, so the ancestor was never truly under the pointer.
2408    /// - Focus resets to the body (encoded as `None`), running blur
2409    ///   side-effects (clearing focus element state and disabling IME for
2410    ///   text inputs).
2411    pub(crate) fn clear_interaction_state_for_removed_node(&mut self, node_id: NodeId) {
2412        if !self.nodes.contains_key(node_id) {
2413            return;
2414        }
2415
2416        if self.hover_node_id == Some(node_id) {
2417            self.hover_node_id = self.nearest_surviving_element_ancestor(node_id);
2418            self.hover_node_is_text = false;
2419        }
2420        if self.hover_hit_node_id == Some(node_id) {
2421            self.hover_hit_node_id = None;
2422        }
2423        if self.semantic_hover_node_id == Some(node_id) {
2424            self.semantic_hover_node_id = None;
2425        }
2426        if self.active_node_id == Some(node_id) {
2427            self.active_node_id = self.nearest_surviving_element_ancestor(node_id);
2428        }
2429        if self.focus_node_id == Some(node_id) {
2430            let shell_provider = self.shell_provider.clone();
2431            self.nodes[node_id].blur(shell_provider);
2432            self.focus_node_id = None;
2433        }
2434        if self.mousedown_node_id == Some(node_id) {
2435            self.mousedown_node_id = None;
2436        }
2437        if self.text_selection.anchor.node_or_parent == Some(node_id)
2438            || self.text_selection.focus.node_or_parent == Some(node_id)
2439        {
2440            self.text_selection.clear();
2441        }
2442        if self
2443            .hovered_scrollbar
2444            .is_some_and(|scrollbar| scrollbar.node_id == node_id)
2445        {
2446            self.hovered_scrollbar = None;
2447        }
2448        let drag_references_node = match &self.drag_mode {
2449            DragMode::Panning(state) => state.target == node_id,
2450            DragMode::ScrollbarDrag(state) => state.scrollbar.node_id == node_id,
2451            DragMode::Selecting | DragMode::None => false,
2452        };
2453        if drag_references_node {
2454            self.drag_mode = DragMode::None;
2455        }
2456        if matches!(
2457            &self.scroll_animation,
2458            ScrollAnimationState::Fling(state) if state.target == node_id
2459        ) {
2460            self.scroll_animation = ScrollAnimationState::None;
2461        }
2462        self.scrollbar_activity.remove(&node_id);
2463
2464        // The form-owner map is keyed by control id and was never pruned, so a
2465        // page that re-renders its fields grew an entry per render, every one
2466        // of them a freed slot. Nothing dereferences those any more, but an
2467        // unbounded map keyed on dead ids is a leak either way.
2468        self.controls_to_form.remove(&node_id);
2469    }
2470
2471    pub(crate) fn drop_node_ignoring_parent(&mut self, node_id: NodeId) -> Option<Node> {
2472        self.drop_node_ignoring_parent_with(node_id, &mut |_| {})
2473    }
2474
2475    /// Like [`Self::drop_node_ignoring_parent`], but calls `on_drop` with the id of
2476    /// every dropped node (the node itself and all of its descendants).
2477    pub(crate) fn drop_node_ignoring_parent_with(
2478        &mut self,
2479        node_id: NodeId,
2480        on_drop: &mut dyn FnMut(NodeId),
2481    ) -> Option<Node> {
2482        self.node_drop_depth += 1;
2483        let mut node = self.remove_node_from_tree(node_id);
2484        if let Some(node) = &mut node {
2485            on_drop(node_id);
2486            if let Some(before) = node.before() {
2487                self.drop_node_ignoring_parent_with(before, on_drop);
2488            }
2489            if let Some(after) = node.after() {
2490                self.drop_node_ignoring_parent_with(after, on_drop);
2491            }
2492
2493            for &child in &node.children {
2494                self.drop_node_ignoring_parent_with(child, on_drop);
2495            }
2496            if let Some(contents) = node
2497                .element_data()
2498                .and_then(|element| element.template_contents)
2499            {
2500                self.drop_node_ignoring_parent_with(contents, on_drop);
2501            }
2502
2503            // Anonymous blocks live only in the slab, so deallocate the ones this
2504            // node owns rather than leaking them.
2505            for &anon_id in &node.anonymous_blocks {
2506                self.deallocate_anonymous_block(anon_id);
2507            }
2508
2509            // Drop any attached shadow root (its children are dropped recursively
2510            // via the recursive call below).
2511            #[cfg(feature = "shadow-dom")]
2512            if let Some(shadow_root_id) = node.shadow_root_id() {
2513                self.shadow_host_nodes.remove(&node_id);
2514                self.dirty_shadow_hosts.remove(&node_id);
2515                self.custom_element_nodes.remove(&node_id);
2516                self.drop_node_ignoring_parent_with(shadow_root_id, on_drop);
2517            }
2518        }
2519        self.node_drop_depth -= 1;
2520        self.purge_dropped_node_references();
2521        node
2522    }
2523
2524    /// Deallocate an anonymous block created in a previous construction
2525    /// round, along with any anonymous blocks nested within it.
2526    pub(crate) fn deallocate_anonymous_block(&mut self, anon_id: NodeId) {
2527        // The block may already have been removed from the slab (e.g. a
2528        // whitespace-only anonymous block dropped during construction).
2529        if !self.nodes.contains_key(anon_id) {
2530            return;
2531        }
2532
2533        // Free any anonymous blocks that this block owns before removing it.
2534        self.node_drop_depth += 1;
2535        let nested = std::mem::take(&mut self.nodes[anon_id].anonymous_blocks);
2536        for nested_id in nested {
2537            self.deallocate_anonymous_block(nested_id);
2538        }
2539
2540        self.remove_node_from_tree(anon_id);
2541        self.node_drop_depth -= 1;
2542        self.purge_dropped_node_references();
2543    }
2544
2545    pub fn create_text_node(&mut self, text: &str) -> NodeId {
2546        let content = text.to_string();
2547        let data = NodeData::Text(TextNodeData::new(content));
2548        self.create_node(data)
2549    }
2550
2551    pub fn deep_clone_node(&mut self, node_id: NodeId) -> NodeId {
2552        // Load existing node
2553        let node = &self.nodes[node_id];
2554        let mut data = node.data.clone();
2555
2556        match &mut data {
2557            NodeData::Element(elem) | NodeData::AnonymousBlock(elem) => {
2558                if let Some(arc) = elem.style_attribute.as_mut() {
2559                    let read_guard = self.guard().read();
2560                    let block = arc.read_with(&read_guard);
2561                    *arc = ServoArc::new(self.guard().wrap(block.clone()));
2562                }
2563            }
2564            _ => {}
2565        }
2566
2567        let children = node.children.clone();
2568        #[cfg(feature = "shadow-dom")]
2569        let shadow_source = node.shadow_root_id();
2570        let template_contents = node
2571            .element_data()
2572            .and_then(|element| element.template_contents);
2573        let owner_document = node.owner_document;
2574        let markup = node.markup.clone();
2575        let inert_script = node
2576            .flags
2577            .contains(crate::node::NodeFlags::IS_PARSER_INERT_SCRIPT);
2578
2579        // Create new node
2580        let new_node_id = self.create_node(data);
2581        self.inherit_stash_classification(node_id, new_node_id);
2582        self.nodes[new_node_id].owner_document = owner_document;
2583        self.nodes[new_node_id].markup = markup;
2584        self.nodes[new_node_id]
2585            .flags
2586            .set(crate::node::NodeFlags::IS_PARSER_INERT_SCRIPT, inert_script);
2587        if let Some(document_id) = owner_document {
2588            let id = self.nodes[new_node_id]
2589                .element_data()
2590                .and_then(|element| element.id.as_ref())
2591                .map(ToString::to_string);
2592            if let Some(id) = id {
2593                self.add_to_document_id_map(document_id, &id, new_node_id);
2594            }
2595        }
2596        if let Some(contents) = template_contents {
2597            let cloned_contents = self.deep_clone_node(contents);
2598            self.nodes[new_node_id]
2599                .element_data_mut()
2600                .expect("template clone must be an element")
2601                .template_contents = Some(cloned_contents);
2602        }
2603
2604        // Recursively clone children
2605        let new_children: ThinVec<NodeId> = children
2606            .into_iter()
2607            .map(|child_id| self.deep_clone_node(child_id))
2608            .collect();
2609        for &child_id in &new_children {
2610            self.nodes[child_id].parent = Some(new_node_id);
2611            self.attach_script_custom_element_subtree(child_id, new_node_id);
2612        }
2613        self.nodes[new_node_id].children = new_children;
2614
2615        // Only a shadow root that opted into `clonable` is copied. Imperative
2616        // roots keep the previous behavior of not cloning.
2617        #[cfg(feature = "shadow-dom")]
2618        if let Some(source_root) = shadow_source {
2619            let clonable = self
2620                .nodes
2621                .get(source_root)
2622                .and_then(|node| node.shadow_root_data())
2623                .is_some_and(|root| root.clonable);
2624            if clonable {
2625                let mode = self.nodes[source_root].shadow_root_data().unwrap().mode;
2626                let serializable = self.nodes[source_root]
2627                    .shadow_root_data()
2628                    .unwrap()
2629                    .serializable;
2630                let delegates_focus = self.nodes[source_root]
2631                    .shadow_root_data()
2632                    .unwrap()
2633                    .delegates_focus;
2634                let declarative = self.nodes[source_root]
2635                    .shadow_root_data()
2636                    .unwrap()
2637                    .declarative;
2638                let shadow_children = self.nodes[source_root].children.clone();
2639                let new_root = self.attach_shadow(new_node_id, mode);
2640                if let Some(root) = self.nodes[new_root].shadow_root_data_mut() {
2641                    root.clonable = true;
2642                    root.serializable = serializable;
2643                    root.delegates_focus = delegates_focus;
2644                    root.declarative = declarative;
2645                }
2646                let cloned_shadow_children: ThinVec<NodeId> = shadow_children
2647                    .into_iter()
2648                    .map(|child_id| self.deep_clone_node(child_id))
2649                    .collect();
2650                for &child_id in &cloned_shadow_children {
2651                    self.nodes[child_id].parent = Some(new_root);
2652                    self.attach_script_custom_element_subtree(child_id, new_root);
2653                }
2654                self.nodes[new_root].children = cloned_shadow_children;
2655            }
2656        }
2657
2658        new_node_id
2659    }
2660
2661    pub(crate) fn remove_and_drop_pe(&mut self, node_id: NodeId) -> Option<Node> {
2662        fn remove_pe_ignoring_parent(doc: &mut BaseDocument, node_id: NodeId) -> Option<Node> {
2663            let mut node = doc.remove_node_from_tree(node_id);
2664            if let Some(node) = &mut node {
2665                for &child in &node.children {
2666                    remove_pe_ignoring_parent(doc, child);
2667                }
2668                for &anon_id in &node.anonymous_blocks {
2669                    doc.deallocate_anonymous_block(anon_id);
2670                }
2671            }
2672            node
2673        }
2674
2675        self.node_drop_depth += 1;
2676        let node = remove_pe_ignoring_parent(self, node_id);
2677
2678        // Update child_idx values
2679        if let Some(parent_id) = node.as_ref().and_then(|node| node.parent) {
2680            let parent = &mut self.nodes[parent_id];
2681            parent.children.retain(|id| *id != node_id);
2682        }
2683
2684        self.node_drop_depth -= 1;
2685        self.purge_dropped_node_references();
2686        node
2687    }
2688
2689    pub(crate) fn resolve_url(&self, raw: &str) -> url::Url {
2690        self.url.resolve_relative(raw).unwrap_or_else(|| {
2691            panic!(
2692                "to be able to resolve {raw} with the base_url: {:?}",
2693                *self.url
2694            )
2695        })
2696    }
2697
2698    /// Navigate to `raw`, resolved against this document's base URL.
2699    ///
2700    /// The same route a link click takes, exposed so that script can reach it:
2701    /// `location.assign`, `location.replace` and `location.reload` had nowhere
2702    /// to go, because `resolve_url` and the navigation provider are both
2703    /// internal to this crate. Returns `false` when `raw` will not resolve,
2704    /// so the caller can report that rather than navigate somewhere wrong.
2705    pub fn navigate_to_url(&self, raw: &str) -> bool {
2706        let Some(url) = self.url.resolve_relative(raw) else {
2707            return false;
2708        };
2709        self.navigation_provider
2710            .navigate_to(blitz_traits::navigation::NavigationOptions::new(
2711                url,
2712                None,
2713                self.id(),
2714            ));
2715        true
2716    }
2717
2718    /// This document's URL, as a page's `location.href` reads it.
2719    pub fn current_url(&self) -> String {
2720        self.url.to_string()
2721    }
2722
2723    pub fn print_tree(&self) {
2724        crate::util::walk_tree(0, self.root_node());
2725    }
2726
2727    pub fn print_subtree(&self, node_id: NodeId) {
2728        crate::util::walk_tree(0, &self.nodes[node_id]);
2729    }
2730
2731    pub fn reload_resource_by_href(&mut self, href_to_reload: &str) {
2732        for &node_id in self.nodes_to_stylesheet.keys() {
2733            let node = &self.nodes[node_id];
2734            let Some(element) = node.element_data() else {
2735                continue;
2736            };
2737
2738            if element.name.local == local_name!("link") {
2739                if let Some(href) = element.attr(local_name!("href")) {
2740                    // println!("Node {node_id} {href} {href_to_reload} {} {}", resolved_href.as_str(), resolved_href.as_str() == url_to_reload);
2741                    if href == href_to_reload {
2742                        let resolved_href = self.resolve_url(href);
2743                        self.net_provider.fetch(
2744                            self.id(),
2745                            self.build_request(resolved_href.clone())
2746                                .destination(RequestDestination::Style),
2747                            ResourceHandler::boxed(
2748                                self.tx.clone(),
2749                                self.id,
2750                                Some(node_id),
2751                                self.shell_provider.clone(),
2752                                StylesheetHandler {
2753                                    source_url: resolved_href,
2754                                    guard: self.guard.clone(),
2755                                    net_provider: self.net_provider.clone(),
2756                                    abort_signal: self.abort_signal.clone(),
2757                                },
2758                            ),
2759                        );
2760                    }
2761                }
2762            }
2763        }
2764    }
2765
2766    pub fn process_style_element(&mut self, target_id: NodeId) {
2767        let css = self.nodes[target_id].text_content();
2768        let css = html_escape::decode_html_entities(&css);
2769        let sheet = self.make_stylesheet(&css, Origin::Author);
2770        self.add_stylesheet_for_node(sheet, target_id);
2771    }
2772
2773    pub fn remove_user_agent_stylesheet(&mut self, contents: &str) {
2774        if let Some(sheet) = self.ua_stylesheets.remove(contents) {
2775            self.stylist.remove_stylesheet(sheet, &self.guard.read());
2776        }
2777    }
2778
2779    /// The document's base URL
2780    pub fn url(&self) -> &url::Url {
2781        &self.url
2782    }
2783
2784    /// Iterate over the author stylesheets (from `<style>` and `<link>` nodes)
2785    /// currently associated with this document
2786    pub fn author_stylesheets(&self) -> impl Iterator<Item = &DocumentStyleSheet> {
2787        self.nodes_to_stylesheet.values()
2788    }
2789
2790    /// Iterate over the user-agent stylesheets currently associated with this document
2791    pub fn useragent_stylesheets(&self) -> impl Iterator<Item = &DocumentStyleSheet> {
2792        self.ua_stylesheets.values()
2793    }
2794
2795    pub fn add_user_agent_stylesheet(&mut self, css: &str) {
2796        let sheet = self.make_stylesheet(css, Origin::UserAgent);
2797        self.ua_stylesheets.insert(css.to_string(), sheet.clone());
2798        self.stylist.append_stylesheet(sheet, &self.guard.read());
2799    }
2800
2801    pub fn make_stylesheet(&self, css: impl AsRef<str>, origin: Origin) -> DocumentStyleSheet {
2802        let data = Stylesheet::from_str(
2803            css.as_ref(),
2804            self.url.url_extra_data(),
2805            origin,
2806            ServoArc::new(self.guard.wrap(MediaList::empty())),
2807            self.guard.clone(),
2808            Some(&StylesheetLoader {
2809                tx: self.tx.clone(),
2810                doc_id: self.id,
2811                net_provider: self.net_provider.clone(),
2812                shell_provider: self.shell_provider.clone(),
2813                abort_signal: self.abort_signal.clone(),
2814            }),
2815            None,
2816            QuirksMode::NoQuirks,
2817            AllowImportRules::Yes,
2818        );
2819
2820        DocumentStyleSheet(ServoArc::new(data))
2821    }
2822
2823    pub fn upsert_stylesheet_for_node(&mut self, node_id: NodeId) {
2824        let raw_styles = self.nodes[node_id].text_content();
2825        let sheet = self.make_stylesheet(raw_styles, Origin::Author);
2826        self.add_stylesheet_for_node(sheet, node_id);
2827    }
2828
2829    pub fn add_stylesheet_for_node(&mut self, stylesheet: DocumentStyleSheet, node_id: NodeId) {
2830        self.platform_initial_sheet_media(&stylesheet, node_id);
2831        #[cfg(feature = "shadow-dom")]
2832        if let Some(root_id) = self.containing_shadow_root(node_id) {
2833            // A sheet that moved from the document into a shadow tree leaves
2834            // the document Stylist first.
2835            if self.document_sheet_nodes.remove(&node_id)
2836                && let Some(old) = self.nodes_to_stylesheet.get(&node_id).cloned()
2837            {
2838                self.stylist.remove_stylesheet(old, &self.guard.read());
2839            }
2840            self.install_shadow_stylesheet(root_id, node_id, stylesheet);
2841            return;
2842        }
2843
2844        let old = self.nodes_to_stylesheet.insert(node_id, stylesheet.clone());
2845
2846        if let Some(old) = old
2847            && self.document_sheet_nodes.contains(&node_id)
2848        {
2849            self.stylist.remove_stylesheet(old, &self.guard.read())
2850        }
2851        self.document_sheet_nodes.insert(node_id);
2852
2853        // Fetch @font-face fonts
2854        crate::net::fetch_font_face(
2855            self.tx.clone(),
2856            self.id,
2857            Some(node_id),
2858            &stylesheet.0,
2859            &self.net_provider,
2860            &self.shell_provider,
2861            &self.guard.read(),
2862            self.abort_signal.as_ref(),
2863            &mut self.web_fonts,
2864            self.font_epoch,
2865        );
2866
2867        // Store data on element
2868        let element = &mut self.nodes[node_id].element_data_mut().unwrap();
2869        element.special_data = SpecialElementData::Stylesheet(stylesheet.clone());
2870
2871        // TODO: Nodes could potentially get reused so ordering by node_id might be wrong.
2872        // Only sheets the document Stylist holds can be an insertion point: shadow-root sheets are
2873        // in `nodes_to_stylesheet` too, and stylo panics on a `before` sheet it does not have.
2874        let insertion_point = self
2875            .nodes_to_stylesheet
2876            .range((Bound::Excluded(node_id), Bound::Unbounded))
2877            .find(|(id, _)| self.document_sheet_nodes.contains(id))
2878            .map(|(_, sheet)| sheet);
2879
2880        if let Some(insertion_point) = insertion_point {
2881            self.stylist.insert_stylesheet_before(
2882                stylesheet,
2883                insertion_point.clone(),
2884                &self.guard.read(),
2885            )
2886        } else {
2887            self.stylist
2888                .append_stylesheet(stylesheet, &self.guard.read())
2889        }
2890    }
2891
2892    pub fn handle_messages(&mut self) {
2893        for msg in self.rx.take_pending() {
2894            let msg =
2895                Arc::try_unwrap(msg).unwrap_or_else(|_| panic!("document event has one consumer"));
2896            self.handle_message(msg);
2897        }
2898    }
2899
2900    pub fn handle_message(&mut self, msg: DocumentEvent) {
2901        match msg {
2902            DocumentEvent::ResourceLoad(resource) => self.load_resource(resource),
2903            DocumentEvent::NavigateIframe { node_id, url } => self.navigate_iframe(node_id, url),
2904        }
2905    }
2906
2907    /// Whether the Document has pending requests for "critical" resources (that should block rendering)
2908    pub fn has_pending_critical_resources(&self) -> bool {
2909        !self.pending_critical_resources.is_empty()
2910    }
2911
2912    /// How many distinct image URLs are still being fetched.
2913    ///
2914    /// Images are deliberately not "critical" resources, so they never block
2915    /// rendering. An embedder that needs a settled page (a screenshot, a test,
2916    /// a print) has no other way to tell an image that is still in flight from
2917    /// one that will never arrive.
2918    pub fn pending_image_count(&self) -> usize {
2919        self.pending_images.len()
2920    }
2921
2922    pub fn load_resource(&mut self, res: ResourceLoadResponse) {
2923        self.pending_critical_resources.remove(&res.request_id);
2924
2925        let resource = match res.result {
2926            Ok(resource) => resource,
2927            Err(err) => {
2928                if let Some(url) = res.resolved_url.as_ref() {
2929                    let waiting_nodes = self.pending_images.remove(url).unwrap_or_default();
2930                    #[cfg(feature = "tracing")]
2931                    tracing::warn!(
2932                        url = url.as_str(),
2933                        waiting_nodes = waiting_nodes.len(),
2934                        error = err.as_str(),
2935                        "Resource load failed"
2936                    );
2937                    #[cfg(not(feature = "tracing"))]
2938                    let _ = (waiting_nodes, err);
2939                } else {
2940                    #[cfg(feature = "tracing")]
2941                    tracing::warn!(error = err.as_str(), "Resource load failed (no url)");
2942                    #[cfg(not(feature = "tracing"))]
2943                    let _ = err;
2944                }
2945                return;
2946            }
2947        };
2948
2949        let (resource, font_ticket) = match resource {
2950            Resource::TrackedFont(bytes, overrides, ticket) => {
2951                if ticket.css && ticket.epoch != self.font_epoch {
2952                    ticket.finish(false);
2953                    return;
2954                }
2955                (Resource::Font(bytes, overrides), Some(ticket))
2956            }
2957            resource => (resource, None),
2958        };
2959        if matches!(&resource, Resource::Css(_) | Resource::Font(_, _))
2960            && res.node_id.is_some_and(|id| {
2961                self.get_node(id)
2962                    .is_none_or(|node| !node.flags.is_in_document())
2963            })
2964        {
2965            if let Some(ticket) = font_ticket {
2966                ticket.finish(false);
2967            }
2968            return;
2969        }
2970
2971        match resource {
2972            Resource::Css(css) => {
2973                let node_id = res.node_id.unwrap();
2974                self.add_stylesheet_for_node(css, node_id);
2975            }
2976            Resource::ImportSheet(import_rule, sheet) => {
2977                // The write that used to happen on the network worker. Here it
2978                // is on the thread that owns styling, so it cannot collide
2979                // with a concurrent read of the same lock.
2980                //
2981                // Scoped, because the `@font-face` scan below needs a read of
2982                // the same lock and this is an `AtomicRefCell`: holding the
2983                // write across it would deadlock against itself rather than
2984                // wait.
2985                {
2986                    let mut guard = self.guard.write();
2987                    import_rule.write_with(&mut guard).stylesheet =
2988                        style::stylesheets::import_rule::ImportSheet::Sheet(sheet.clone());
2989                }
2990
2991                // The same scan `add_stylesheet_for_node` does for a top-level
2992                // sheet. An imported sheet may declare fonts too, and until now
2993                // nothing fetched them from a thread allowed to read the lock.
2994                crate::net::fetch_font_face(
2995                    self.tx.clone(),
2996                    self.id,
2997                    res.node_id,
2998                    &sheet,
2999                    &self.net_provider,
3000                    &self.shell_provider,
3001                    &self.guard.read(),
3002                    self.abort_signal.as_ref(),
3003                    &mut self.web_fonts,
3004                    self.font_epoch,
3005                );
3006            }
3007            Resource::Image(_kind, width, height, image_data) => {
3008                // Create the ImageData and cache it
3009                let image = ImageData::Raster(RasterImageData::new(width, height, image_data));
3010
3011                let Some(url) = res.resolved_url.as_ref() else {
3012                    return;
3013                };
3014
3015                self.apply_loaded_image(url, image);
3016            }
3017            #[cfg(feature = "svg")]
3018            Resource::Svg(_kind, svg) => {
3019                // Create the ImageData and cache it
3020                let image = ImageData::Svg(svg);
3021
3022                let Some(url) = res.resolved_url.as_ref() else {
3023                    return;
3024                };
3025
3026                self.apply_loaded_image(url, image);
3027            }
3028            Resource::DocumentSrc(html) => {
3029                let Some(node_id) = res.node_id else {
3030                    return;
3031                };
3032                self.apply_iframe_html(node_id, res.request_id, res.resolved_url, &html);
3033            }
3034            Resource::Font(bytes, overrides) => {
3035                let font = Blob::new(Arc::new(bytes));
3036
3037                // Build a `FontInfoOverride` from the `@font-face` descriptors
3038                // captured during stylesheet parsing. Without this, parley
3039                // reads the family name from the TTF's own metadata, which
3040                // means CSS `font-family: 'Avenir Book'` won't match a font
3041                // file that internally identifies as `Avenir 45 Book`.
3042                let weight_override = overrides.weight.map(parley::fontique::FontWeight::new);
3043                let info_override = parley::fontique::FontInfoOverride {
3044                    family_name: overrides.family_name.as_deref(),
3045                    weight: weight_override,
3046                    style: overrides.style,
3047                    ..Default::default()
3048                };
3049
3050                // TODO: Investigate eliminating double-box
3051                let mut global_font_ctx = self.font_ctx.lock().unwrap();
3052                let registered = global_font_ctx
3053                    .collection
3054                    .register_fonts(font.clone(), Some(info_override));
3055                let registered = !registered.is_empty();
3056
3057                #[cfg(feature = "parallel-construct")]
3058                {
3059                    rayon::broadcast(|_ctx| {
3060                        let mut font_ctx = self
3061                            .thread_font_contexts
3062                            .get_or(|| RefCell::new(Box::new(global_font_ctx.clone())))
3063                            .borrow_mut();
3064                        font_ctx
3065                            .collection
3066                            .register_fonts(font.clone(), Some(info_override));
3067                    });
3068                }
3069                drop(global_font_ctx);
3070
3071                // TODO: see if we can only invalidate if resolved fonts may have changed
3072                self.invalidate_inline_contexts();
3073                if let Some(ticket) = font_ticket {
3074                    ticket.finish(registered);
3075                }
3076            }
3077            Resource::TrackedFont(..) => unreachable!("tracked font was normalized"),
3078            Resource::None => {
3079                // Do nothing
3080            }
3081        }
3082    }
3083
3084    /// Cache a loaded image and apply it to all nodes waiting on it
3085    /// (`<img>` elements, `background-image` layers and `mask-image` layers).
3086    fn apply_loaded_image(&mut self, url: &str, image: ImageData) {
3087        // Get all nodes waiting for this image
3088        let waiting_nodes = self.pending_images.remove(url).unwrap_or_default();
3089
3090        #[cfg(feature = "tracing")]
3091        tracing::info!(
3092            "Image {url} loaded, applying to {} nodes",
3093            waiting_nodes.len()
3094        );
3095
3096        // Cache the image
3097        self.image_cache.insert(url.to_string(), image.clone());
3098
3099        // Apply to all waiting nodes
3100        for (node_id, image_type) in waiting_nodes {
3101            let Some(node) = self.get_node_mut(node_id) else {
3102                continue;
3103            };
3104
3105            match image_type {
3106                ImageType::Image => {
3107                    node.element_data_mut().unwrap().special_data =
3108                        SpecialElementData::Image(Box::new(image.clone()));
3109
3110                    // Clear layout cache
3111                    node.cache_mut().clear();
3112                    node.insert_damage(ALL_DAMAGE);
3113                }
3114                ImageType::Background(idx) | ImageType::Mask(idx) => {
3115                    let layer_image = node.element_data_mut().and_then(|el| {
3116                        let images = match image_type {
3117                            ImageType::Background(_) => &mut el.background_images,
3118                            ImageType::Mask(_) => &mut el.mask_images,
3119                            ImageType::Image => unreachable!(),
3120                        };
3121                        images.get_mut(idx)
3122                    });
3123                    if let Some(Some(layer_image)) = layer_image {
3124                        layer_image.status = Status::Ok;
3125                        layer_image.image = image.clone();
3126                    }
3127                }
3128            }
3129        }
3130    }
3131
3132    pub fn snapshot_node(&mut self, node_id: NodeId) {
3133        let node = &mut self.nodes[node_id];
3134
3135        // Do not snapshot nodes that have never been styled. A snapshot records an element's
3136        // pre-mutation state so a restyle can diff selector matches then-vs-now. An element
3137        // that has never been styled has no "then" to diff against. Snapshotting it anyway
3138        // makes Stylo's invalidation unwrap its (absent) primary style and panic.
3139        let has_been_styled = node.primary_styles().is_some();
3140        if !has_been_styled {
3141            return;
3142        }
3143
3144        let opaque_node_id = TNode::opaque(&&*node);
3145        node.set_has_snapshot(true);
3146        node.snapshot_handled()
3147            .store(false, std::sync::atomic::Ordering::SeqCst);
3148
3149        // TODO: handle invalidations other than hover
3150        if let Some(_existing_snapshot) = self.snapshots.get_mut(&opaque_node_id) {
3151            // Do nothing
3152            // TODO: update snapshot
3153        } else {
3154            let attrs: Option<Vec<_>> = node.attrs().map(|attrs| {
3155                attrs
3156                    .iter()
3157                    .map(|attr| {
3158                        let ident = AttrIdentifier {
3159                            local_name: GenericAtomIdent(attr.name.local.clone()),
3160                            name: GenericAtomIdent(attr.name.local.clone()),
3161                            namespace: GenericAtomIdent(attr.name.ns.clone()),
3162                            prefix: None,
3163                        };
3164
3165                        let value = if attr.name.local == local_name!("id") {
3166                            AttrValue::Atom(Atom::from(&*attr.value))
3167                        } else if attr.name.local == local_name!("class") {
3168                            let classes = attr
3169                                .value
3170                                .split_ascii_whitespace()
3171                                .map(Atom::from)
3172                                .collect();
3173                            // Stylo's `AttrValue` owns a `String`, so the atom
3174                            // is materialised here. This is the one place
3175                            // interning is paid back out, and it is bounded:
3176                            // once per snapshotted attribute, not per element
3177                            // per frame.
3178                            AttrValue::TokenList(OnceLock::from(attr.value.to_string()), classes)
3179                        } else {
3180                            AttrValue::String(attr.value.to_string())
3181                        };
3182
3183                        (ident, value)
3184                    })
3185                    .collect()
3186            });
3187
3188            let changed_attrs = attrs
3189                .as_ref()
3190                .map(|attrs| attrs.iter().map(|attr| attr.0.name.clone()).collect())
3191                .unwrap_or_default();
3192
3193            self.snapshots.insert(
3194                opaque_node_id,
3195                ServoElementSnapshot {
3196                    state: Some(*node.element_state()),
3197                    attrs,
3198                    changed_attrs,
3199                    class_changed: true,
3200                    id_changed: true,
3201                    other_attributes_changed: true,
3202                },
3203            );
3204        }
3205    }
3206
3207    /// Preserve the original attributes and accumulate names changed before traversal.
3208    pub(crate) fn snapshot_attribute(&mut self, node_id: NodeId, name: &markup5ever::LocalName) {
3209        self.snapshot_node(node_id);
3210        let opaque_node_id = TNode::opaque(&&self.nodes[node_id]);
3211        if let Some(snapshot) = self.snapshots.get_mut(&opaque_node_id) {
3212            let changed_attr = GenericAtomIdent(name.clone());
3213            if !snapshot.changed_attrs.contains(&changed_attr) {
3214                snapshot.changed_attrs.extend(std::iter::once(changed_attr));
3215            }
3216        }
3217    }
3218
3219    /// Snapshot a node and act on it, if it is still there.
3220    ///
3221    /// Tolerant of a node that has gone, because the ids reaching this are
3222    /// remembered across events — focus, hover, the last press — and the node
3223    /// they name can be removed between one event and the next. Indexing
3224    /// directly turned that ordinary case into a panic inside an event handler.
3225    pub fn snapshot_node_and(&mut self, node_id: NodeId, cb: impl FnOnce(&mut Node)) {
3226        if !self.nodes.contains_key(node_id) {
3227            return;
3228        }
3229        self.snapshot_node(node_id);
3230        cb(&mut self.nodes[node_id]);
3231    }
3232
3233    // Takes (x, y) co-ordinates (relative to the )
3234    pub fn hit(&self, x: f32, y: f32) -> Option<HitResult> {
3235        self.hit_with_scrollbar(x, y).0
3236    }
3237
3238    /// Walk up the tree to the nearest DOM node whose id is stable across
3239    /// box-tree reconstruction, so canonicalized interaction state never goes
3240    /// stale.
3241    ///
3242    /// Layout-generated nodes (anonymous blocks and `::before`/`::after`
3243    /// pseudo-elements, both stored as anonymous blocks) get new ids on every
3244    /// reconstruction, so we skip any anonymous node *and* a non-anonymous node
3245    /// whose parent is anonymous (the pseudo's text content). The first
3246    /// non-anonymous node with a non-anonymous parent is a real DOM node; the
3247    /// root element's `Document` parent guarantees termination.
3248    ///
3249    /// Returns `None` if `node_id` (or an ancestor) no longer exists.
3250    pub fn nearest_non_anonymous_ancestor(&self, node_id: NodeId) -> Option<NodeId> {
3251        // Recurse up the tree keeping a window of the current node and its
3252        // parent, advancing one step per iteration so each node is looked up
3253        // exactly once.
3254        let mut node = self.get_node(node_id)?;
3255        loop {
3256            let parent = match node.parent {
3257                Some(parent_id) => self.get_node(parent_id)?,
3258                None => return Some(node.id),
3259            };
3260            if !node.is_anonymous() && !parent.is_anonymous() {
3261                return Some(node.id);
3262            }
3263            node = parent;
3264        }
3265    }
3266
3267    pub fn focus_next_node(&mut self) -> Option<NodeId> {
3268        let focussed_node_id = self.get_focussed_node_id()?;
3269        let id = self.next_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?;
3270        self.set_focus_to(id);
3271        Some(id)
3272    }
3273
3274    /// Move focus to the previous focussable node in the document
3275    pub fn focus_prev_node(&mut self) -> Option<NodeId> {
3276        let focussed_node_id = self.get_focussed_node_id()?;
3277        let id = self.prev_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?;
3278        self.set_focus_to(id);
3279        Some(id)
3280    }
3281
3282    /// Clear the focussed node
3283    pub fn clear_focus(&mut self) {
3284        if let Some(id) = self.focus_node_id {
3285            let shell_provider = self.shell_provider.clone();
3286            self.snapshot_node_and(id, |node| node.blur(shell_provider));
3287            self.focus_node_id = None;
3288        }
3289    }
3290
3291    pub fn set_mousedown_node_id(&mut self, node_id: Option<NodeId>) {
3292        self.mousedown_node_id = node_id.and_then(|id| self.nearest_non_anonymous_ancestor(id));
3293    }
3294    pub fn set_focus_to(&mut self, focus_node_id: NodeId) -> bool {
3295        let Some(focus_node_id) = self.nearest_non_anonymous_ancestor(focus_node_id) else {
3296            return false;
3297        };
3298        if Some(focus_node_id) == self.focus_node_id {
3299            return false;
3300        }
3301
3302        #[cfg(feature = "tracing")]
3303        tracing::info!("Focussed node {focus_node_id}");
3304
3305        let shell_provider = self.shell_provider.clone();
3306
3307        // Remove focus from the old node
3308        if let Some(id) = self.focus_node_id {
3309            self.snapshot_node_and(id, |node| node.blur(shell_provider.clone()));
3310        }
3311
3312        // Focus the new node
3313        self.snapshot_node_and(focus_node_id, |node| node.focus(shell_provider));
3314
3315        self.focus_node_id = Some(focus_node_id);
3316
3317        true
3318    }
3319
3320    pub fn active_node(&mut self) -> bool {
3321        let Some(hover_node_id) = self.get_hover_node_id() else {
3322            return false;
3323        };
3324
3325        if let Some(active_node_id) = self.active_node_id {
3326            if active_node_id == hover_node_id {
3327                return true;
3328            }
3329            self.unactive_node();
3330        }
3331
3332        // hover_node_id is canonicalized when stored, so this always holds.
3333        debug_assert!(
3334            self.get_node(hover_node_id)
3335                .is_some_and(|node| !node.is_anonymous()),
3336            "interaction state must reference DOM nodes, not layout-generated nodes"
3337        );
3338        let active_node_id = Some(hover_node_id);
3339
3340        let node_path = self.maybe_node_layout_ancestors(active_node_id);
3341        for &id in node_path.iter() {
3342            self.snapshot_node_and(id, |node| node.active());
3343        }
3344
3345        self.active_node_id = active_node_id;
3346
3347        true
3348    }
3349
3350    pub fn unactive_node(&mut self) -> bool {
3351        let Some(active_node_id) = self.active_node_id.take() else {
3352            return false;
3353        };
3354
3355        let node_path = self.maybe_node_layout_ancestors(Some(active_node_id));
3356        for &id in node_path.iter() {
3357            self.snapshot_node_and(id, |node| node.unactive());
3358        }
3359
3360        true
3361    }
3362
3363    /// The scrollbar thumb currently under the pointer, if any.
3364    pub fn hovered_scrollbar(&self) -> Option<crate::node::ScrollbarRef> {
3365        self.hovered_scrollbar
3366    }
3367
3368    /// The scrollbar thumb currently being dragged, if any.
3369    pub fn scrollbar_drag_target(&self) -> Option<crate::node::ScrollbarRef> {
3370        match &self.drag_mode {
3371            DragMode::ScrollbarDrag(state) => Some(state.scrollbar),
3372            _ => None,
3373        }
3374    }
3375
3376    /// The current opacity of `node_id`'s overlay scrollbars. They show at
3377    /// full opacity on scroll and fade out after a delay (Chromium's overlay
3378    /// timings); the pointer resting on a thumb, or dragging it, holds them
3379    /// visible.
3380    pub fn scrollbar_opacity(&self, node_id: NodeId) -> f32 {
3381        let interacting = |scrollbar: &crate::node::ScrollbarRef| scrollbar.node_id == node_id;
3382        if self.hovered_scrollbar.as_ref().is_some_and(interacting)
3383            || self
3384                .scrollbar_drag_target()
3385                .as_ref()
3386                .is_some_and(interacting)
3387        {
3388            return 1.0;
3389        }
3390        self.scrollbar_activity.get(&node_id).map_or(1.0, |last| {
3391            crate::node::scrollbar::opacity_at(last.elapsed())
3392        })
3393    }
3394
3395    /// Show `node_id`'s overlay scrollbars at full opacity and restart their
3396    /// fade-out delay.
3397    pub(crate) fn show_scrollbars(&mut self, node_id: NodeId) {
3398        if cfg!(feature = "scrollbars") {
3399            self.scrollbar_activity.insert(node_id, Instant::now());
3400        }
3401    }
3402
3403    /// Whether any overlay scrollbars are awaiting or animating their
3404    /// fade-out (so frames must keep rendering until they finish).
3405    fn scrollbars_animating(&self) -> bool {
3406        use crate::node::scrollbar::{FADE_DELAY, FADE_DURATION};
3407        self.scrollbar_activity
3408            .values()
3409            .any(|last| last.elapsed() < FADE_DELAY + FADE_DURATION)
3410    }
3411
3412    /// [`hit`](Self::hit), also resolving the innermost overlay scrollbar
3413    /// thumb under the point (shares the traversal, so it costs nothing
3414    /// extra).
3415    pub(crate) fn hit_with_scrollbar(
3416        &self,
3417        x: f32,
3418        y: f32,
3419    ) -> (Option<HitResult>, Option<crate::node::ScrollbarRef>) {
3420        if TDocument::as_node(&self.root_node())
3421            .first_element_child()
3422            .is_none()
3423        {
3424            #[cfg(feature = "tracing")]
3425            tracing::warn!("No DOM - not resolving hit test");
3426            return (None, None);
3427        }
3428        let mut scrollbar = None;
3429        let hit = self
3430            .root_element()
3431            .hit_inner(x, y, self.viewport().scale_f64(), &mut scrollbar);
3432        (hit, scrollbar)
3433    }
3434
3435    pub fn set_hover_to(&mut self, x: f32, y: f32) -> bool {
3436        self.semantic_hover_node_id = None;
3437        // Record the pointer position in client (unscrolled) coordinates so
3438        // that `refresh_hover` can re-resolve hover state after layout or
3439        // scroll changes.
3440        self.last_client_pointer_position = Some(taffy::Point {
3441            x: x - self.viewport_scroll.x as f32,
3442            y: y - self.viewport_scroll.y as f32,
3443        });
3444
3445        let (hit, hovered_scrollbar) = self.hit_with_scrollbar(x, y);
3446        // A faded-out thumb is not interactive: pointer moves never fade
3447        // overlay scrollbars back in (only scrolling shows them).
3448        let hovered_scrollbar =
3449            hovered_scrollbar.filter(|scrollbar| self.scrollbar_opacity(scrollbar.node_id) > 0.0);
3450        // Scrollbar-thumb hover is part of hover state: track it here so a
3451        // pointer crossing a thumb restyles it even when the hit node (the
3452        // content under the overlay thumb) is unchanged.
3453        let scrollbar_changed = hovered_scrollbar != self.hovered_scrollbar;
3454        if scrollbar_changed {
3455            // Entering a thumb restores full opacity mid-fade; leaving one
3456            // restarts the fade-out delay.
3457            for scrollbar in [self.hovered_scrollbar, hovered_scrollbar]
3458                .into_iter()
3459                .flatten()
3460            {
3461                self.show_scrollbars(scrollbar.node_id);
3462            }
3463        }
3464        self.hovered_scrollbar = hovered_scrollbar;
3465
3466        // Store both the precise layout node that was hit (transient: used for
3467        // cursor/style queries) and its canonical DOM target (persistent: must
3468        // not reference layout-generated nodes, whose ids die on box-tree
3469        // reconstruction).
3470        let hit_node_id = hit.map(|hit| hit.node_id);
3471        let hover_node_id = hit_node_id.and_then(|id| self.nearest_non_anonymous_ancestor(id));
3472        let new_is_text = hit.map(|hit| hit.is_text).unwrap_or(false);
3473
3474        self.apply_hover_target(hit_node_id, hover_node_id, new_is_text, scrollbar_changed)
3475    }
3476
3477    /// Move the authored hover state to an already-resolved DOM node.
3478    ///
3479    /// Semantic automation has selected a node by identity already. Repeating
3480    /// hit testing at its centre can choose an overlapping child or overlay,
3481    /// especially inside nested scrollers, and makes `Hover { node_id }`
3482    /// target something other than the requested node. Pointer coordinates are
3483    /// still recorded for event payloads and later layout refreshes.
3484    pub fn set_hover_to_node(&mut self, node_id: NodeId, x: f32, y: f32) -> bool {
3485        self.semantic_hover_node_id = Some(node_id);
3486        self.last_client_pointer_position = Some(taffy::Point {
3487            x: x - self.viewport_scroll.x as f32,
3488            y: y - self.viewport_scroll.y as f32,
3489        });
3490
3491        let hovered_scrollbar = self.hovered_scrollbar.take();
3492        let scrollbar_changed = hovered_scrollbar.is_some();
3493        if let Some(scrollbar) = hovered_scrollbar {
3494            self.show_scrollbars(scrollbar.node_id);
3495        }
3496        let hover_node_id = self.nearest_non_anonymous_ancestor(node_id);
3497        self.apply_hover_target(Some(node_id), hover_node_id, false, scrollbar_changed)
3498    }
3499
3500    fn apply_hover_target(
3501        &mut self,
3502        hit_node_id: Option<NodeId>,
3503        hover_node_id: Option<NodeId>,
3504        new_is_text: bool,
3505        scrollbar_changed: bool,
3506    ) -> bool {
3507        let hit_changed =
3508            hit_node_id != self.hover_hit_node_id || new_is_text != self.hover_node_is_text;
3509        self.hover_hit_node_id = hit_node_id;
3510        self.hover_node_is_text = new_is_text;
3511
3512        // Return early if the new node is the same as the already-hovered node
3513        if hover_node_id == self.hover_node_id {
3514            if hit_changed {
3515                // The canonical target is unchanged (so no restyle is needed)
3516                // but the precise hit node changed, which can change the cursor
3517                // (e.g. moving between text and non-text within one element).
3518                self.shell_provider.set_cursor(self.get_cursor());
3519            }
3520            return scrollbar_changed;
3521        }
3522
3523        let old_node_path = self.maybe_node_layout_ancestors(self.hover_node_id);
3524        let new_node_path = self.maybe_node_layout_ancestors(hover_node_id);
3525        let same_count = old_node_path
3526            .iter()
3527            .zip(&new_node_path)
3528            .take_while(|(o, n)| o == n)
3529            .count();
3530        for &id in old_node_path.iter().skip(same_count) {
3531            self.snapshot_node_and(id, |node| node.unhover());
3532        }
3533        for &id in new_node_path.iter().skip(same_count) {
3534            self.snapshot_node_and(id, |node| node.hover());
3535        }
3536
3537        self.hover_node_id = hover_node_id;
3538
3539        // Update the cursor
3540        self.shell_provider.set_cursor(self.get_cursor());
3541
3542        // Request redraw
3543        self.shell_provider.request_redraw();
3544
3545        true
3546    }
3547
3548    pub fn clear_hover(&mut self) -> bool {
3549        // The pointer is no longer over the document, so stop re-resolving
3550        // hover state against it.
3551        self.last_client_pointer_position = None;
3552        self.semantic_hover_node_id = None;
3553        self.hover_hit_node_id = None;
3554
3555        let Some(hover_node_id) = self.hover_node_id else {
3556            return false;
3557        };
3558
3559        let old_node_path = self.maybe_node_layout_ancestors(Some(hover_node_id));
3560        for &id in old_node_path.iter() {
3561            self.snapshot_node_and(id, |node| node.unhover());
3562        }
3563
3564        self.hover_node_id = None;
3565        self.hover_node_is_text = false;
3566
3567        // Update the cursor
3568        self.shell_provider.set_cursor(self.get_cursor());
3569
3570        // Request redraw
3571        self.shell_provider.request_redraw();
3572
3573        true
3574    }
3575
3576    /// Re-resolve hover state against the current layout using the last known
3577    /// pointer position.
3578    ///
3579    /// TODO: synthesizing pointerenter/pointerleave DOM events for
3580    /// hover changes caused by layout shifts.
3581    pub fn refresh_hover(&mut self) -> bool {
3582        if let Some(node_id) = self.semantic_hover_node_id {
3583            if self.get_node(node_id).is_some() {
3584                let hover_node_id = self.nearest_non_anonymous_ancestor(node_id);
3585                return self.apply_hover_target(Some(node_id), hover_node_id, false, false);
3586            }
3587            self.semantic_hover_node_id = None;
3588        }
3589        let Some(pos) = self.last_client_pointer_position else {
3590            return false;
3591        };
3592        let x = pos.x + self.viewport_scroll.x as f32;
3593        let y = pos.y + self.viewport_scroll.y as f32;
3594        self.set_hover_to(x, y)
3595    }
3596
3597    pub fn get_hover_node_id(&self) -> Option<NodeId> {
3598        self.hover_node_id
3599    }
3600
3601    pub fn get_mousedown_node_id(&self) -> Option<NodeId> {
3602        self.mousedown_node_id
3603    }
3604
3605    pub fn set_viewport(&mut self, viewport: Viewport) {
3606        let scale_has_changed = viewport.scale_f64() != self.viewport.scale_f64();
3607        self.viewport = viewport;
3608        self.set_stylist_device(make_device(
3609            &self.viewport,
3610            self.media_type.clone(),
3611            self.font_ctx.clone(),
3612        ));
3613        self.scroll_viewport_by(0.0, 0.0); // Clamp scroll offset
3614
3615        if scale_has_changed {
3616            self.invalidate_inline_contexts();
3617            self.shell_provider.request_redraw();
3618        }
3619    }
3620
3621    /// Returns the current CSS media type used to evaluate `@media` rules.
3622    pub fn media_type(&self) -> &MediaType {
3623        &self.media_type
3624    }
3625
3626    /// Sets the CSS media type used to evaluate `@media` rules (e.g. `screen` or `print`)
3627    /// and rebuilds the stylist device so updated rules apply on the next restyle.
3628    pub fn set_media_type(&mut self, media_type: MediaType) {
3629        if self.media_type == media_type {
3630            return;
3631        }
3632        self.media_type = media_type;
3633        self.set_stylist_device(make_device(
3634            &self.viewport,
3635            self.media_type.clone(),
3636            self.font_ctx.clone(),
3637        ));
3638    }
3639
3640    pub fn viewport(&self) -> &Viewport {
3641        &self.viewport
3642    }
3643
3644    pub fn viewport_mut(&mut self) -> ViewportMut<'_> {
3645        ViewportMut::new(self)
3646    }
3647
3648    pub fn zoom_by(&mut self, increment: f32) {
3649        let mut viewport = self.viewport.clone();
3650        viewport.zoom_by(increment);
3651        self.set_viewport(viewport);
3652    }
3653
3654    pub fn zoom_to(&mut self, zoom: f32) {
3655        let mut viewport = self.viewport.clone();
3656        viewport.set_zoom(zoom);
3657        self.set_viewport(viewport);
3658    }
3659
3660    pub fn get_viewport(&self) -> Viewport {
3661        self.viewport.clone()
3662    }
3663
3664    /// Returns whether incremental layout is currently enabled for this document.
3665    pub fn incremental_layout(&self) -> bool {
3666        self.incremental_layout
3667    }
3668
3669    /// Enables or disables incremental layout for this document.
3670    pub fn set_incremental_layout(&mut self, enabled: bool) {
3671        self.incremental_layout = enabled;
3672    }
3673
3674    pub fn devtools(&self) -> &DevtoolSettings {
3675        &self.devtool_settings
3676    }
3677
3678    pub fn devtools_mut(&mut self) -> &mut DevtoolSettings {
3679        &mut self.devtool_settings
3680    }
3681
3682    pub fn subdoc(&self, node_id: NodeId) -> Option<&dyn Document> {
3683        self.get_node(node_id)
3684            .and_then(|node| node.element_data())
3685            .and_then(|el| el.sub_doc_data())
3686    }
3687
3688    pub fn subdoc_mut(&mut self, node_id: NodeId) -> Option<&mut dyn Document> {
3689        self.get_node_mut(node_id)
3690            .and_then(|node| node.element_data_mut())
3691            .and_then(|el| el.sub_doc_data_mut())
3692    }
3693
3694    pub fn is_animating(&self) -> bool {
3695        #[cfg(feature = "custom-widget")]
3696        let custom_widget_is_animating = self.custom_widget_nodes.iter().any(|&node_id| {
3697            self.nodes[node_id]
3698                .element_data()
3699                .and_then(|el| el.custom_widget_data())
3700                .is_some_and(|data| data.widget.requires_redraw())
3701        });
3702        #[cfg(not(feature = "custom-widget"))]
3703        let custom_widget_is_animating = false;
3704
3705        let animating = self.has_canvas
3706            | self.has_active_animations
3707            | (self.subdoc_animation_pacing != AnimationPacing::Idle)
3708            | custom_widget_is_animating
3709            | (self.scroll_animation != ScrollAnimationState::None)
3710            | self.scrollbars_animating();
3711
3712        if animating && crate::debug::animation_reasons_enabled() {
3713            crate::debug::report_animation_reasons(
3714                self.id(),
3715                self.has_canvas,
3716                self.has_active_animations,
3717                self.subdoc_animation_pacing != AnimationPacing::Idle,
3718                custom_widget_is_animating,
3719                self.scroll_animation != ScrollAnimationState::None,
3720                self.scrollbars_animating(),
3721                self.animating_node_names().as_deref(),
3722            );
3723        }
3724
3725        animating
3726    }
3727
3728    /// Return the cadence class for the next animation-only frame.
3729    ///
3730    /// CSS animations are commonly decorative and can use a lower cadence.
3731    /// Canvas, scrolling and custom widgets remain at the interactive cadence.
3732    pub fn animation_pacing(&self) -> AnimationPacing {
3733        let focused_text_input = self.focus_node_id.is_some_and(|node_id| {
3734            self.nodes
3735                .get(node_id)
3736                .and_then(|node| node.element_data())
3737                .is_some_and(|element| element.text_input_data().is_some())
3738        });
3739        #[cfg(feature = "custom-widget")]
3740        let custom_widget_is_animating = self.custom_widget_nodes.iter().any(|&node_id| {
3741            self.nodes[node_id]
3742                .element_data()
3743                .and_then(|el| el.custom_widget_data())
3744                .is_some_and(|data| data.widget.requires_redraw())
3745        });
3746        #[cfg(not(feature = "custom-widget"))]
3747        let custom_widget_is_animating = false;
3748
3749        if self.has_canvas
3750            || custom_widget_is_animating
3751            || self.scroll_animation != ScrollAnimationState::None
3752            || self.scrollbars_animating()
3753        {
3754            AnimationPacing::Interactive
3755        } else if self.has_active_animations {
3756            const SLOW_ANIMATION_SECONDS: f64 = 2.0;
3757            let sets = self.animations.sets.read();
3758            let has_fast_animation_or_transition = sets.values().any(|set| {
3759                set.transitions.iter().any(|transition| {
3760                    matches!(
3761                        transition.state,
3762                        AnimationState::Pending | AnimationState::Running
3763                    )
3764                }) || set.animations.iter().any(|animation| {
3765                    matches!(
3766                        animation.state,
3767                        AnimationState::Pending | AnimationState::Running
3768                    ) && animation.duration < SLOW_ANIMATION_SECONDS
3769                })
3770            });
3771            if has_fast_animation_or_transition {
3772                AnimationPacing::Interactive
3773            } else {
3774                AnimationPacing::SlowCss
3775            }
3776        } else if focused_text_input {
3777            AnimationPacing::Caret
3778        } else if self.subdoc_animation_pacing != AnimationPacing::Idle {
3779            self.subdoc_animation_pacing
3780        } else {
3781            AnimationPacing::Idle
3782        }
3783    }
3784
3785    /// Which elements Stylo currently holds animations or transitions for.
3786    ///
3787    /// Only built when the diagnostic is switched on: a frame loop that will
3788    /// not settle is otherwise very hard to attribute, because
3789    /// `has_active_animations` is one bool for the whole document and says
3790    /// nothing about which element is keeping it true.
3791    fn animating_node_names(&self) -> Option<String> {
3792        if !self.has_active_animations {
3793            return None;
3794        }
3795        let sets = self.animations.sets.read();
3796        let mut described: Vec<String> = sets
3797            .iter()
3798            .filter(|(_, state)| state.needs_animation_ticks())
3799            .filter_map(|(key, state)| {
3800                let node_id = NodeId::from_u64(key.node.id() as u64);
3801                let node = self.nodes.get(node_id)?;
3802                let element = node.element_data()?;
3803                let name = element
3804                    .attr(local_name!("id"))
3805                    .map(|id| format!("#{id}"))
3806                    .or_else(|| {
3807                        element
3808                            .attr(local_name!("class"))
3809                            .and_then(|c| c.split_ascii_whitespace().next())
3810                            .map(|c| format!(".{c}"))
3811                    })
3812                    .unwrap_or_else(|| element.name.local.to_string());
3813                Some(format!(
3814                    "{name}(anim={},trans={},in_doc={})",
3815                    state.animations.len(),
3816                    state.transitions.len(),
3817                    node.flags.is_in_document(),
3818                ))
3819            })
3820            .collect();
3821        described.sort();
3822        described.truncate(12);
3823        Some(described.join(" "))
3824    }
3825
3826    /// Update the device and reset the stylist to process the new size
3827    pub fn set_stylist_device(&mut self, device: Device) {
3828        self.viewport_generation = self.viewport_generation.wrapping_add(1);
3829        // Viewport units become pixels at cascade time, from this device.
3830        // Compare before `device` is moved into the stylist.
3831        let previous_viewport = self.stylist.device().au_viewport_size();
3832        let next_viewport = device.au_viewport_size();
3833
3834        // Seed the new device with the root element's current style and font-relative
3835        // unit state (used to resolve rem/rlh/rex/rch/rcap/ric units). Stylo only
3836        // updates this state when the root element's style *changes* during a restyle,
3837        // so a freshly-built device would otherwise resolve these units against the
3838        // default font-size (16px) until the root's font-size next changes.
3839        let root_styles = self
3840            .try_root_element()
3841            .and_then(|root| root.primary_styles());
3842        if let Some(root_style) = root_styles.as_deref() {
3843            device.set_root_style(root_style);
3844
3845            let font = root_style.get_font();
3846            let font_size = font.clone_font_size().computed_size();
3847            device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px()));
3848
3849            let line_height = device
3850                .calc_line_height(font, root_style.writing_mode, None)
3851                .0;
3852            device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px()));
3853        }
3854        drop(root_styles);
3855
3856        let origins = {
3857            let guard = &self.guard;
3858            let guards = StylesheetGuards {
3859                author: &guard.read(),
3860                ua_or_user: &guard.read(),
3861            };
3862            self.stylist.set_device(device, &guards)
3863        };
3864        self.stylist.force_stylesheet_origins_dirty(origins);
3865        #[cfg(feature = "shadow-dom")]
3866        for host_id in self.shadow_host_node_ids() {
3867            if let Some(root_id) = self.shadow_root_id(host_id) {
3868                self.invalidate_shadow_styles(root_id);
3869            }
3870        }
3871
3872        // Recascade through normal style traversal, which skips hidden subtrees.
3873        // Allocated element data does not guarantee a computed primary style.
3874        if let Some(root_id) = self.try_root_element().map(|root| root.id) {
3875            self.nodes[root_id].set_restyle_hint(RestyleHint::recascade_subtree());
3876            self.shell_provider.request_redraw();
3877        }
3878        if previous_viewport != next_viewport {
3879            self.abspos_layout_dirty = true;
3880        }
3881    }
3882
3883    pub fn stylist_device(&mut self) -> &Device {
3884        self.stylist.device()
3885    }
3886
3887    /// The cursor to show, where `None` means `cursor: none` — hide it.
3888    ///
3889    /// `None` is an answer, not the absence of one. The shell hides the pointer
3890    /// when it sees `None`, so every path that means "nothing to say here" must
3891    /// return `Default` instead. Returning `None` from those made the pointer
3892    /// vanish as it crossed into page content, which is the shape this used to
3893    /// have: three `?`s that each meant "no opinion" and all read as "hide".
3894    pub fn get_cursor(&self) -> Option<CursorIcon> {
3895        // Prefer the precise hit node: `cursor` and `user-select` may be set on
3896        // a pseudo-element or resolved on an anonymous box, and text hits carry
3897        // is_text via the hit node. Fall back to the canonical hover node if
3898        // the hit node has been removed (it is transient across resolves).
3899        let node_id = self
3900            .hover_hit_node_id
3901            .filter(|&id| self.nodes.contains_key(id))
3902            .or(self.get_hover_node_id());
3903        let Some(node_id) = node_id else {
3904            return Some(CursorIcon::Default);
3905        };
3906        let node = &self.nodes[node_id];
3907
3908        if let Some(subdoc) = node.subdoc().map(|doc| doc.inner()) {
3909            // Only delegate when the sub-document has hover state of its own.
3910            // Without this check an embedded document that has not been hovered
3911            // yet answers `None` — meaning "I have no hover node" — and the
3912            // pointer disappears the moment it enters the page area, which is
3913            // every page in a browser built on sub-documents.
3914            if subdoc.hover_hit_node_id.is_some() || subdoc.get_hover_node_id().is_some() {
3915                return subdoc.get_cursor();
3916            }
3917            return Some(CursorIcon::Default);
3918        }
3919
3920        let Some(style) = node.primary_styles() else {
3921            return Some(CursorIcon::Default);
3922        };
3923        let user_select = style.clone_user_select();
3924        let keyword = style.clone_cursor().keyword;
3925
3926        // Return cursor from style if it is non-auto
3927        if keyword != CursorKind::Auto {
3928            return stylo_to_cursor_icon(keyword);
3929        }
3930
3931        // Return text cursor for text inputs
3932        if node
3933            .element_data()
3934            .is_some_and(|e| e.text_input_data().is_some())
3935        {
3936            return Some(CursorIcon::Text);
3937        }
3938
3939        // Use "pointer" cursor if any ancestor is a link
3940        let mut maybe_node = Some(node);
3941        while let Some(node) = maybe_node {
3942            if node.is_link() {
3943                return Some(CursorIcon::Pointer);
3944            }
3945
3946            maybe_node = node.layout_parent.get().map(|node_id| node.with(node_id));
3947        }
3948
3949        // Return text cursor for text nodes
3950        if self.hover_node_is_text {
3951            return Some(match user_select {
3952                UserSelect::Text | UserSelect::All | UserSelect::Auto => CursorIcon::Text,
3953                UserSelect::None => CursorIcon::Default,
3954            });
3955        }
3956
3957        // Else fallback to default cursor
3958        Some(CursorIcon::Default)
3959    }
3960
3961    pub fn scroll_node_by<F: FnMut(DomEvent)>(
3962        &mut self,
3963        node_id: NodeId,
3964        x: f64,
3965        y: f64,
3966        dispatch_event: F,
3967    ) {
3968        self.scroll_node_by_has_changed(node_id, x, y, dispatch_event);
3969    }
3970
3971    /// Scroll a node by given x and y
3972    /// Will bubble scrolling up to parent node once it can no longer scroll further
3973    /// If we're already at the root node, bubbles scrolling up to the viewport
3974    ///
3975    /// A `position: sticky` box is held against the edge of the scrollport it
3976    /// lives in, so the boxes have to be re-adjusted here rather than only in
3977    /// `resolve`: a wheel event does not necessarily produce a style and layout
3978    /// pass, and a header that only unstuck on the next restyle is a header
3979    /// that visibly lags the scroll.
3980    pub fn scroll_node_by_has_changed<F: FnMut(DomEvent)>(
3981        &mut self,
3982        node_id: NodeId,
3983        x: f64,
3984        y: f64,
3985        dispatch_event: F,
3986    ) -> bool {
3987        let has_changed = self.scroll_node_by_inner(node_id, x, y, dispatch_event);
3988        if has_changed {
3989            self.resolve_positioned_scroll();
3990        }
3991        has_changed
3992    }
3993
3994    fn scroll_node_by_inner<F: FnMut(DomEvent)>(
3995        &mut self,
3996        node_id: NodeId,
3997        x: f64,
3998        y: f64,
3999        mut dispatch_event: F,
4000    ) -> bool {
4001        // Per the CSS overflow propagation rules, the root element's overflow (and usually
4002        // the <body>'s) is applied to the viewport, and the element itself must not have
4003        // a scrolling mechanism of its own. So scrolls that reach the root element are
4004        // forwarded to the viewport rather than scrolling the root element itself.
4005        if self.try_root_element().is_some_and(|el| el.id == node_id) {
4006            let has_changed = self.scroll_viewport_by_has_changed(x, y);
4007            if has_changed {
4008                let layout = *self.root_element().final_layout();
4009                let scale = self.viewport.scale() as f64;
4010                let event = BlitzScrollEvent {
4011                    scroll_top: self.viewport_scroll.y,
4012                    scroll_left: self.viewport_scroll.x,
4013                    scroll_width: layout.size.width.max(layout.content_size.width) as i32,
4014                    scroll_height: layout.size.height.max(layout.content_size.height) as i32,
4015                    client_width: (self.viewport.window_size.0 as f64 / scale) as i32,
4016                    client_height: (self.viewport.window_size.1 as f64 / scale) as i32,
4017                };
4018                dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
4019            }
4020            return has_changed;
4021        }
4022
4023        let Some(node) = self.nodes.get_mut(node_id) else {
4024            return false;
4025        };
4026
4027        // Text inputs scroll their own internal text content rather than using the generic
4028        // overflow mechanism: single-line inputs scroll horizontally, multi-line inputs scroll
4029        // vertically. Any delta the input cannot consume is bubbled up to an ancestor scroller.
4030        if node
4031            .element_data()
4032            .is_some_and(|el| el.text_input_data().is_some())
4033        {
4034            let parent = node.parent;
4035            let content_box_width = node.final_layout().content_box_width();
4036            let content_box_height = node.final_layout().content_box_height();
4037            let input = node
4038                .element_data_mut()
4039                .and_then(|el| el.text_input_data_mut())
4040                .unwrap();
4041
4042            let (bubble_x, bubble_y) = if input.is_multiline {
4043                (
4044                    x,
4045                    input.scroll_by(y as f32, content_box_width, content_box_height) as f64,
4046                )
4047            } else {
4048                (
4049                    input.scroll_by(x as f32, content_box_width, content_box_height) as f64,
4050                    y,
4051                )
4052            };
4053
4054            let has_changed = bubble_x != x || bubble_y != y;
4055
4056            if bubble_x != 0.0 || bubble_y != 0.0 {
4057                let bubbled = if let Some(parent) = parent {
4058                    self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event)
4059                } else {
4060                    self.scroll_viewport_by_has_changed(bubble_x, bubble_y)
4061                };
4062                return bubbled | has_changed;
4063            }
4064
4065            return has_changed;
4066        }
4067
4068        let (can_x_scroll, can_y_scroll) = node
4069            .primary_styles()
4070            .map(|styles| {
4071                (
4072                    matches!(styles.clone_overflow_x(), Overflow::Scroll | Overflow::Auto),
4073                    matches!(styles.clone_overflow_y(), Overflow::Scroll | Overflow::Auto),
4074                )
4075            })
4076            .unwrap_or((false, false));
4077
4078        let initial = *node.scroll_offset();
4079        let new_x = node.scroll_offset().x - x;
4080        let new_y = node.scroll_offset().y - y;
4081
4082        let mut bubble_x = 0.0;
4083        let mut bubble_y = 0.0;
4084
4085        let scroll_width = node.final_layout().scroll_width() as f64;
4086        let scroll_height = node.final_layout().scroll_height() as f64;
4087
4088        // Handle sub document case
4089        if let Some(mut sub_doc) = node.subdoc_mut().map(|doc| doc.inner_mut()) {
4090            let has_changed = if let Some(hover_node_id) = sub_doc.get_hover_node_id() {
4091                sub_doc.scroll_node_by_has_changed(hover_node_id, x, y, dispatch_event)
4092            } else {
4093                sub_doc.scroll_viewport_by_has_changed(x, y)
4094            };
4095
4096            // TODO: propagate remaining scroll to parent
4097            return has_changed;
4098        }
4099
4100        // If we're past our scroll bounds, transfer remainder of scrolling to parent/viewport
4101        if !can_x_scroll {
4102            bubble_x = x
4103        } else if new_x < 0.0 {
4104            bubble_x = -new_x;
4105            node.scroll_offset_mut().x = 0.0;
4106        } else if new_x > scroll_width {
4107            bubble_x = scroll_width - new_x;
4108            node.scroll_offset_mut().x = scroll_width;
4109        } else {
4110            node.scroll_offset_mut().x = new_x;
4111        }
4112
4113        if !can_y_scroll {
4114            bubble_y = y
4115        } else if new_y < 0.0 {
4116            bubble_y = -new_y;
4117            node.scroll_offset_mut().y = 0.0;
4118        } else if new_y > scroll_height {
4119            bubble_y = scroll_height - new_y;
4120            node.scroll_offset_mut().y = scroll_height;
4121        } else {
4122            node.scroll_offset_mut().y = new_y;
4123        }
4124
4125        let has_changed = *node.scroll_offset() != initial;
4126
4127        if has_changed {
4128            let layout = *node.final_layout();
4129            let event = BlitzScrollEvent {
4130                scroll_top: node.scroll_offset().y,
4131                scroll_left: node.scroll_offset().x,
4132                scroll_width: layout.scroll_width() as i32,
4133                scroll_height: layout.scroll_height() as i32,
4134                client_width: layout.size.width as i32,
4135                client_height: layout.size.height as i32,
4136            };
4137
4138            dispatch_event(DomEvent::new(node_id, DomEventData::Scroll(event)));
4139        }
4140
4141        let parent = node.parent;
4142        if has_changed {
4143            self.show_scrollbars(node_id);
4144        }
4145
4146        if bubble_x != 0.0 || bubble_y != 0.0 {
4147            if let Some(parent) = parent {
4148                return self.scroll_node_by_inner(parent, bubble_x, bubble_y, dispatch_event)
4149                    | has_changed;
4150            } else {
4151                return self.scroll_viewport_by_has_changed(bubble_x, bubble_y) | has_changed;
4152            }
4153        }
4154
4155        has_changed
4156    }
4157
4158    pub fn scroll_viewport_by(&mut self, x: f64, y: f64) {
4159        self.scroll_viewport_by_has_changed(x, y);
4160    }
4161
4162    /// Scroll the viewport by the given values
4163    pub fn scroll_viewport_by_has_changed(&mut self, x: f64, y: f64) -> bool {
4164        // The viewport scrolls the root element's scrollable overflow, which includes both
4165        // the root element itself and any content which overflows it (e.g. when the root
4166        // element has a fixed height but its content is taller). A document without a root
4167        // element has no scrollable content, so its content size is zero.
4168        let (content_width, content_height) = match self.try_root_element() {
4169            Some(root) => {
4170                let root_layout = root.final_layout();
4171                (
4172                    root_layout.size.width.max(root_layout.content_size.width) as f64,
4173                    root_layout.size.height.max(root_layout.content_size.height) as f64,
4174                )
4175            }
4176            None => (0.0, 0.0),
4177        };
4178        let new_scroll = (self.viewport_scroll.x - x, self.viewport_scroll.y - y);
4179        let window_width = self.viewport.window_size.0 as f64 / self.viewport.scale() as f64;
4180        let window_height = self.viewport.window_size.1 as f64 / self.viewport.scale() as f64;
4181
4182        let initial = self.viewport_scroll;
4183        self.viewport_scroll.x =
4184            f64::max(0.0, f64::min(new_scroll.0, content_width - window_width));
4185        self.viewport_scroll.y =
4186            f64::max(0.0, f64::min(new_scroll.1, content_height - window_height));
4187
4188        let has_changed = self.viewport_scroll != initial;
4189        if has_changed {
4190            // The viewport is the scrollport a page-level sticky box is held
4191            // against, and the containing block a fixed box is pinned to, so
4192            // both move with this and not with the next relayout. See
4193            // `resolve_sticky_positions` and `resolve_fixed_positions`.
4194            self.resolve_positioned_scroll();
4195        }
4196        has_changed
4197    }
4198
4199    pub fn scroll_by(
4200        &mut self,
4201        anchor_node_id: Option<NodeId>,
4202        scroll_x: f64,
4203        scroll_y: f64,
4204        dispatch_event: &mut dyn FnMut(DomEvent),
4205    ) -> bool {
4206        if let Some(anchor_node_id) = anchor_node_id {
4207            self.scroll_node_by_has_changed(anchor_node_id, scroll_x, scroll_y, dispatch_event)
4208        } else {
4209            self.scroll_viewport_by_has_changed(scroll_x, scroll_y)
4210        }
4211    }
4212
4213    pub fn viewport_scroll(&self) -> crate::Point<f64> {
4214        self.viewport_scroll
4215    }
4216
4217    pub fn set_viewport_scroll(&mut self, scroll: crate::Point<f64>) {
4218        self.viewport_scroll = scroll;
4219    }
4220
4221    /// Find the node targeted by a URL fragment (the `#...` part of a URL).
4222    ///
4223    /// Per the HTML spec, this is the element whose `id` matches the fragment, falling
4224    /// back to the first `<a>` element whose `name` attribute matches.
4225    pub fn get_fragment_target(&self, fragment: &str) -> Option<NodeId> {
4226        if let Some(node_id) = self.get_element_by_id(fragment) {
4227            return Some(node_id);
4228        }
4229
4230        // Fall back to a named anchor: `<a name="...">`
4231        self.nodes.iter().find_map(|(id, node)| {
4232            let el = node.element_data()?;
4233            (el.name.local == local_name!("a") && el.attr(local_name!("name")) == Some(fragment))
4234                .then_some(id)
4235        })
4236    }
4237
4238    /// Scroll the viewport so that the given node is aligned with the top of the viewport.
4239    /// Scroll the nearest scroll container at or above `node_id`.
4240    ///
4241    /// "Scroll this panel" is the operation callers actually want, and
4242    /// `scroll_node_by` only moves the node itself, so naming any inner element
4243    /// silently did nothing. Wheel events are no help either: they are
4244    /// delivered to whatever the document last saw hovered, which an injected
4245    /// pointer move does not set, so an automated caller had no way to scroll
4246    /// anything at all.
4247    /// The nearest scroll container at or above `node_id`, if there is one.
4248    pub fn nearest_scroll_container(&self, node_id: NodeId) -> Option<NodeId> {
4249        let mut current = Some(node_id);
4250        for _ in 0..64 {
4251            let id = current?;
4252            let node = self.nodes.get(id)?;
4253            if node.style().overflow.x.is_scroll_container()
4254                || node.style().overflow.y.is_scroll_container()
4255            {
4256                return Some(id);
4257            }
4258            current = node.parent;
4259        }
4260        None
4261    }
4262
4263    pub fn scroll_nearest_container_by(&mut self, node_id: NodeId, x: f64, y: f64) -> bool {
4264        self.scroll_nearest_container_by_with_events(node_id, x, y, |_| {})
4265    }
4266
4267    pub fn scroll_nearest_container_by_with_events<F: FnMut(DomEvent)>(
4268        &mut self,
4269        node_id: NodeId,
4270        x: f64,
4271        y: f64,
4272        mut dispatch_event: F,
4273    ) -> bool {
4274        let mut current = Some(node_id);
4275        for _ in 0..64 {
4276            let Some(id) = current else { break };
4277            let Some(node) = self.nodes.get(id) else {
4278                break;
4279            };
4280            let scrolls = node.style().overflow.x.is_scroll_container()
4281                || node.style().overflow.y.is_scroll_container();
4282            if scrolls {
4283                self.scroll_node_by(id, x, y, &mut dispatch_event);
4284                return true;
4285            }
4286            current = node.parent;
4287        }
4288        self.scroll_viewport_by(x, y);
4289        false
4290    }
4291
4292    pub fn scroll_to_node(&mut self, node_id: NodeId) {
4293        self.scroll_to_node_with_events(node_id, |_| {});
4294    }
4295
4296    pub fn scroll_to_node_with_events<F: FnMut(DomEvent)>(
4297        &mut self,
4298        node_id: NodeId,
4299        dispatch_event: F,
4300    ) {
4301        self.scroll_to_node_aligned_with_events(node_id, false, dispatch_event);
4302    }
4303
4304    /// Reveal a node near the centre of each scrollport and the viewport.
4305    ///
4306    /// Semantic input uses this placement so fixed or sticky chrome cannot
4307    /// cover the point an automation client will drive. Ordinary DOM
4308    /// `scrollIntoView` and fragment navigation retain their start alignment.
4309    pub fn scroll_to_node_centered_with_events<F: FnMut(DomEvent)>(
4310        &mut self,
4311        node_id: NodeId,
4312        dispatch_event: F,
4313    ) {
4314        self.scroll_to_node_aligned_with_events(node_id, true, dispatch_event);
4315    }
4316
4317    fn scroll_to_node_aligned_with_events<F: FnMut(DomEvent)>(
4318        &mut self,
4319        node_id: NodeId,
4320        centered: bool,
4321        mut dispatch_event: F,
4322    ) {
4323        // Semantic snapshots expose visible text as addressable nodes, but a
4324        // text node does not own a Taffy layout box. `absolute_position` and
4325        // the scrolling calculations below require one, so resolve such a
4326        // target to its nearest layout ancestor first. Without this, asking an
4327        // automation client to reveal a label panics the entire host at
4328        // `Node::final_layout` instead of scrolling the label's box into view.
4329        let mut scroll_target = Some(node_id);
4330        let node_id = loop {
4331            let Some(candidate) = scroll_target else {
4332                return;
4333            };
4334            let Some(node) = self.nodes.get(candidate) else {
4335                return;
4336            };
4337            if matches!(
4338                node.data,
4339                NodeData::Element(_) | NodeData::AnonymousBlock(_) | NodeData::Document(_)
4340            ) {
4341                break candidate;
4342            }
4343            scroll_target = node.layout_parent.get().or(node.parent);
4344        };
4345
4346        // Every scroll container between the node and the root, innermost
4347        // first. Scrolling only the viewport is not `scrollIntoView`: it does
4348        // nothing at all for a node inside a nested scroller, which is what an
4349        // application's own scrolling panes are.
4350        //
4351        // This was not academic. A transcript pane held its "Show 12 earlier
4352        // messages" button at y=-9463 and neither wheel events, Page Up nor
4353        // this call moved it by a single pixel, so a layout bug that only
4354        // appears further up the thread could not be reached from outside the
4355        // app at all. Every measurement of it had to come from a human
4356        // scrolling by hand and saying "now".
4357        let mut chain = Vec::new();
4358        let mut current = self.nodes.get(node_id).and_then(|node| node.parent);
4359        while let Some(id) = current {
4360            let Some(node) = self.nodes.get(id) else {
4361                break;
4362            };
4363            let scrolls = node.style().overflow.x.is_scroll_container()
4364                || node.style().overflow.y.is_scroll_container();
4365            if scrolls {
4366                chain.push(id);
4367            }
4368            current = node.parent;
4369        }
4370
4371        // Innermost first: scrolling an outer container moves the inner one, so
4372        // the inner offsets have to be settled before the outer ones are
4373        // measured, and each step re-reads the node's position.
4374        for container in chain {
4375            let Some(node) = self.nodes.get(node_id) else {
4376                return;
4377            };
4378            let target = node.absolute_position(0.0, 0.0);
4379            let Some(scroller) = self.nodes.get(container) else {
4380                continue;
4381            };
4382            let box_ = scroller.absolute_position(0.0, 0.0);
4383            let layout = scroller.final_layout();
4384            let target_layout = node.final_layout();
4385            // `scroll_node_by` subtracts its delta from the offset. Semantic
4386            // automation centres the target to avoid chrome; DOM behavior
4387            // keeps its historical start alignment.
4388            let (dx, dy) = if centered {
4389                (
4390                    f64::from(
4391                        box_.x + layout.size.width / 2.0
4392                            - (target.x + target_layout.size.width / 2.0),
4393                    ),
4394                    f64::from(
4395                        box_.y + layout.size.height / 2.0
4396                            - (target.y + target_layout.size.height / 2.0),
4397                    ),
4398                )
4399            } else {
4400                (f64::from(box_.x - target.x), f64::from(box_.y - target.y))
4401            };
4402            self.scroll_node_by(container, dx, dy, &mut dispatch_event);
4403        }
4404
4405        // `absolute_position` gives the node's position in document space (it does not
4406        // account for the viewport scroll), so it is the scroll offset we want to land on.
4407        let Some(node) = self.nodes.get(node_id) else {
4408            return;
4409        };
4410        let target = node.absolute_position(0.0, 0.0);
4411        let current = self.viewport_scroll;
4412        let (desired_x, desired_y) = if centered {
4413            let target_layout = node.final_layout();
4414            let scale = self.viewport.scale();
4415            let viewport_width = self.viewport.window_size.0 as f32 / scale;
4416            let viewport_height = self.viewport.window_size.1 as f32 / scale;
4417            (
4418                f64::from(target.x + target_layout.size.width / 2.0 - viewport_width / 2.0)
4419                    .max(0.0),
4420                f64::from(target.y + target_layout.size.height / 2.0 - viewport_height / 2.0)
4421                    .max(0.0),
4422            )
4423        } else {
4424            (f64::from(target.x), f64::from(target.y))
4425        };
4426
4427        // `scroll_viewport_by` subtracts the delta from the current scroll
4428        // offset, so pass current minus the centred destination.
4429        let dx = current.x - desired_x;
4430        let dy = current.y - desired_y;
4431        if let Some(root) = self.try_root_element().map(|element| element.id) {
4432            self.scroll_node_by(root, dx, dy, dispatch_event);
4433        } else {
4434            self.scroll_viewport_by(dx, dy);
4435        }
4436    }
4437
4438    /// Scroll to the element targeted by the given URL fragment (the `#...` part of a URL).
4439    ///
4440    /// An empty fragment (or a `top` fragment that matches no element) scrolls to the top
4441    /// of the document, matching browser behaviour. Returns `true` if a scroll target was
4442    /// found.
4443    pub fn scroll_to_fragment(&mut self, fragment: &str) -> bool {
4444        // Fragments are percent-encoded in URLs (e.g. `%20`); decode before matching.
4445        let decoded = percent_encoding::percent_decode_str(fragment)
4446            .decode_utf8_lossy()
4447            .into_owned();
4448
4449        if !decoded.is_empty() {
4450            if let Some(node_id) = self.get_fragment_target(&decoded) {
4451                self.scroll_to_node(node_id);
4452                return true;
4453            }
4454        }
4455
4456        // An empty fragment, or the special "top" fragment when no matching element exists,
4457        // scrolls to the top of the document.
4458        if decoded.is_empty() || decoded.eq_ignore_ascii_case("top") {
4459            let current = self.viewport_scroll;
4460            self.scroll_viewport_by(current.x, current.y);
4461            return true;
4462        }
4463
4464        false
4465    }
4466
4467    /// SVG graphics bounds in the element's user coordinate system.
4468    /// Callers must flush layout before reading, as for client rectangles.
4469    pub fn get_svg_bbox(&self, node_id: NodeId) -> Option<BoundingRect> {
4470        #[cfg(feature = "svg")]
4471        {
4472            self.svg_user_bbox(node_id)
4473        }
4474        #[cfg(not(feature = "svg"))]
4475        {
4476            let _ = node_id;
4477            None
4478        }
4479    }
4480
4481    /// Geometry, ancestor clipping and computed visibility, in CSS viewport units.
4482    pub fn surface_on_screen(&self, id: NodeId) -> bool {
4483        use style::computed_values::visibility::T as Visibility;
4484        let Some(rect) = self.get_client_bounding_rect(id) else {
4485            return false;
4486        };
4487        let viewport = self.viewport();
4488        let scale = viewport.scale_f64();
4489        let mut x0 = rect.x.max(0.0);
4490        let mut y0 = rect.y.max(0.0);
4491        let mut x1 = (rect.x + rect.width).min(viewport.window_size.0 as f64 / scale);
4492        let mut y1 = (rect.y + rect.height).min(viewport.window_size.1 as f64 / scale);
4493        let mut cursor = Some(id);
4494        while let Some(node_id) = cursor {
4495            let Some(node) = self.get_node(node_id) else {
4496                return false;
4497            };
4498            if matches!(node.style().display, taffy::Display::None) {
4499                return false;
4500            }
4501            if let Some(styles) = node.primary_styles() {
4502                if styles.clone_visibility() != Visibility::Visible
4503                    || styles.get_effects().opacity == 0.0
4504                {
4505                    return false;
4506                }
4507                if node_id != id
4508                    && let Some(clip) = self.get_client_bounding_rect(node_id)
4509                {
4510                    if styles.get_box().overflow_x != Overflow::Visible {
4511                        x0 = x0.max(clip.x);
4512                        x1 = x1.min(clip.x + clip.width);
4513                    }
4514                    if styles.get_box().overflow_y != Overflow::Visible {
4515                        y0 = y0.max(clip.y);
4516                        y1 = y1.min(clip.y + clip.height);
4517                    }
4518                }
4519            }
4520            cursor = node.parent;
4521        }
4522        x1 > x0 && y1 > y0
4523    }
4524
4525    /// Computes the size and position of the `Node` relative to the viewport
4526    pub fn get_client_bounding_rect(&self, node_id: NodeId) -> Option<BoundingRect> {
4527        #[cfg(feature = "svg")]
4528        if let Some(root) = self.svg_geometry_root(node_id) {
4529            return self.svg_client_rect(root, node_id);
4530        }
4531
4532        // Non-atomic inline elements have no layout box of their own: return
4533        // the union of their per-line-box fragment rects.
4534        if let Some(rects) = self.inline_fragment_rects(node_id) {
4535            let rects: Vec<_> = rects
4536                .into_iter()
4537                .map(|rect| self.transformed_client_rect(node_id, rect))
4538                .collect();
4539            let x0 = rects.iter().map(|r| r.x).fold(f64::INFINITY, f64::min);
4540            let y0 = rects.iter().map(|r| r.y).fold(f64::INFINITY, f64::min);
4541            let x1 = rects
4542                .iter()
4543                .map(|r| r.x + r.width)
4544                .fold(f64::NEG_INFINITY, f64::max);
4545            let y1 = rects
4546                .iter()
4547                .map(|r| r.y + r.height)
4548                .fold(f64::NEG_INFINITY, f64::max);
4549            return match rects.is_empty() {
4550                true => None,
4551                false => Some(BoundingRect {
4552                    x: x0,
4553                    y: y0,
4554                    width: x1 - x0,
4555                    height: y1 - y0,
4556                }),
4557            };
4558        }
4559
4560        let node = self.get_node(node_id)?;
4561        if !matches!(
4562            node.data,
4563            NodeData::Element(_) | NodeData::AnonymousBlock(_) | NodeData::Document(_)
4564        ) {
4565            return None;
4566        }
4567        let pos = node.absolute_position(0.0, 0.0);
4568
4569        Some(self.transformed_client_rect(
4570            node_id,
4571            BoundingRect {
4572                x: pos.x as f64 - self.viewport_scroll.x,
4573                y: pos.y as f64 - self.viewport_scroll.y,
4574                width: node.unrounded_layout().size.width as f64,
4575                height: node.unrounded_layout().size.height as f64,
4576            },
4577        ))
4578    }
4579
4580    /// Map the layout rectangle through the same two-dimensional transforms as paint.
4581    /// Layout coordinates deliberately exclude transforms; CSSOM client rectangles do not.
4582    pub(crate) fn transformed_client_rect(
4583        &self,
4584        node_id: NodeId,
4585        rect: BoundingRect,
4586    ) -> BoundingRect {
4587        let mut matrix = kurbo::Affine::IDENTITY;
4588        let mut current = self.get_node(node_id);
4589        let scale = self.viewport.scale_f64();
4590        while let Some(node) = current {
4591            if matches!(
4592                node.data,
4593                NodeData::Element(_) | NodeData::AnonymousBlock(_) | NodeData::Document(_)
4594            ) && let Some(transform) = *node.transform()
4595            {
4596                let [a, b, c, d, e, f] = transform.as_coeffs();
4597                let css_transform = kurbo::Affine::new([a, b, c, d, e / scale, f / scale]);
4598                let origin = node.absolute_position(0.0, 0.0);
4599                let translate = kurbo::Affine::translate((
4600                    origin.x as f64 - self.viewport_scroll.x,
4601                    origin.y as f64 - self.viewport_scroll.y,
4602                ));
4603                matrix = translate * css_transform * translate.inverse() * matrix;
4604            }
4605            current = node
4606                .layout_parent
4607                .get()
4608                .or(node.parent)
4609                .and_then(|id| self.get_node(id));
4610        }
4611        let transformed = matrix.transform_rect_bbox(kurbo::Rect::new(
4612            rect.x,
4613            rect.y,
4614            rect.x + rect.width,
4615            rect.y + rect.height,
4616        ));
4617        BoundingRect {
4618            x: transformed.x0,
4619            y: transformed.y0,
4620            width: transformed.width(),
4621            height: transformed.height(),
4622        }
4623    }
4624
4625    /// Computes the sizes and positions of the `Node`'s box fragments relative to the
4626    /// viewport (CSSOM `getClientRects()` semantics). Nodes with their own layout box
4627    /// return a single rect. Non-atomic inline elements (which are laid out as style
4628    /// spans within an inline root's text layout) return one rect per line box.
4629    pub fn node_client_rects(&self, node_id: NodeId) -> Vec<BoundingRect> {
4630        #[cfg(feature = "svg")]
4631        if self.svg_geometry_root(node_id).is_some() {
4632            return self.get_client_bounding_rect(node_id).into_iter().collect();
4633        }
4634        match self.inline_fragment_rects(node_id) {
4635            Some(rects) => rects
4636                .into_iter()
4637                .map(|rect| self.transformed_client_rect(node_id, rect))
4638                .collect(),
4639            None => self.get_client_bounding_rect(node_id).into_iter().collect(),
4640        }
4641    }
4642
4643    /// Computes per-line-box fragment rects for a non-atomic inline element by walking
4644    /// the containing inline root's text layout. Returns `None` for nodes that have
4645    /// their own layout box (which should use `get_client_bounding_rect` instead).
4646    /// Report inline elements whose fragment rects lie outside the inline root
4647    /// that owns them. `BLITZ_TRACE_INLINE=1`, once per resolve.
4648    ///
4649    /// A non-atomic inline element has no layout box of its own: its geometry
4650    /// is read back out of the containing inline root's text layout on demand.
4651    /// So "the chip is 900px to the right of its block" is a statement about
4652    /// that text layout, and the only way to see it is from in here, with both
4653    /// the fragment and the root in hand. Every earlier attempt to chase this
4654    /// from outside was reading a number the engine computes on the fly and
4655    /// could not say where it came from.
4656    pub(crate) fn trace_escaped_inline_fragments(&self) {
4657        static TRACE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
4658        if !*TRACE.get_or_init(|| std::env::var_os("BLITZ_TRACE_INLINE").is_some()) {
4659            return;
4660        }
4661        let mut reported = 0;
4662        for (id, node) in self.nodes.iter() {
4663            if !node.is_element() {
4664                continue;
4665            }
4666            let Some(rects) = self.inline_fragment_rects(id) else {
4667                continue;
4668            };
4669            let Some(root) = node.inline_root_ancestor() else {
4670                continue;
4671            };
4672            let root_layout = root.final_layout();
4673            let root_pos = root.absolute_position(0.0, 0.0);
4674            let root_right =
4675                root_pos.x as f64 + root_layout.size.width as f64 - self.viewport_scroll.x;
4676            for rect in &rects {
4677                if rect.x + rect.width > root_right + 1.0 {
4678                    reported += 1;
4679                    if reported <= 12 {
4680                        eprintln!(
4681                            "escaped-fragment node={id:?} rect=[{:.1},{:.1} {:.1}x{:.1}] \
4682root={:?} root_right={root_right:.1} root_w={:.1} lines={} layout_scale={:.2} vp_scale={:.2} layout_w={:.1}",
4683                            rect.x,
4684                            rect.y,
4685                            rect.width,
4686                            rect.height,
4687                            root.id,
4688                            root_layout.size.width,
4689                            root.element_data()
4690                                .and_then(|e| e.inline_layout_data.as_ref())
4691                                .map(|i| i.layout.len())
4692                                .unwrap_or(0),
4693                            root.element_data()
4694                                .and_then(|e| e.inline_layout_data.as_ref())
4695                                .map(|i| i.layout.scale())
4696                                .unwrap_or(0.0),
4697                            self.viewport.scale(),
4698                            root.element_data()
4699                                .and_then(|e| e.inline_layout_data.as_ref())
4700                                .map(|i| i.layout.width())
4701                                .unwrap_or(0.0),
4702                        );
4703                    }
4704                    break;
4705                }
4706            }
4707        }
4708        if reported > 0 {
4709            eprintln!("escaped-fragment total={reported}");
4710        }
4711
4712        // The opposite failure, and the one that reads as "first load is
4713        // broken": lines broken far narrower than the box they sit in, so a
4714        // paragraph comes out as a column of one or two words inside a
4715        // full-width bubble. Nothing escapes, so the check above never sees it.
4716        let mut narrow = 0;
4717        for (id, node) in self.nodes.iter() {
4718            let Some(inline) = node
4719                .data
4720                .downcast_element()
4721                .and_then(|element| element.inline_layout_data.as_ref())
4722            else {
4723                continue;
4724            };
4725            let box_width = node.final_layout().size.width as f64 * self.viewport.scale() as f64;
4726            let broken_at = inline.layout.width() as f64;
4727            // Only interesting when the text had more to give: a short string
4728            // legitimately measures narrower than its box.
4729            let full = inline.layout.calculate_content_widths().max as f64;
4730            if box_width > 40.0 && broken_at < box_width * 0.6 && full > box_width * 0.9 {
4731                narrow += 1;
4732                if narrow <= 12 {
4733                    eprintln!(
4734                        "narrow-break node={id:?} broken_at={broken_at:.1} box={box_width:.1} \
4735                         max_content={full:.1} lines={} text={:?}",
4736                        inline.layout.len(),
4737                        inline.text.chars().take(40).collect::<String>(),
4738                    );
4739                }
4740            }
4741        }
4742        if narrow > 0 {
4743            eprintln!("narrow-break total={narrow}");
4744        }
4745    }
4746
4747    pub fn inline_fragment_rects(&self, node_id: NodeId) -> Option<Vec<BoundingRect>> {
4748        use parley::PositionedLayoutItem;
4749
4750        let node = self.get_node(node_id)?;
4751
4752        // Text and non-atomic inline elements live in the inline root's glyph
4753        // runs rather than owning layout boxes.
4754        let is_text = node.is_text_node();
4755        if !is_text {
4756            if !node.is_element() || node.flags.is_inline_root() {
4757                return None;
4758            }
4759            let display = node.primary_styles()?.clone_display();
4760            if !(display.outside() == DisplayOutside::Inline
4761                && display.inside() == DisplayInside::Flow)
4762            {
4763                return None;
4764            }
4765        }
4766
4767        let inline_root = node.inline_root_ancestor()?;
4768        let inline_layout = inline_root.element_data()?.inline_layout_data.as_ref()?;
4769        let layout = &inline_layout.layout;
4770        let scale = layout.scale() as f64;
4771
4772        // Walk up the DOM parent chain from `id` to check whether it is (or is
4773        // inside) the target node, stopping at the inline root.
4774        let is_in_target = |mut id: NodeId| -> bool {
4775            loop {
4776                if id == node_id {
4777                    return true;
4778                }
4779                if id == inline_root.id {
4780                    return false;
4781                }
4782                match self.get_node(id).and_then(|n| n.parent) {
4783                    Some(parent) => id = parent,
4784                    None => return false,
4785                }
4786            }
4787        };
4788
4789        // Fragment rects are relative to the inline root's content box.
4790        let root_layout = inline_root.final_layout();
4791        let root_pos = inline_root.absolute_position(0.0, 0.0);
4792        let origin_x = root_pos.x as f64
4793            + (root_layout.padding.left + root_layout.border.left) as f64
4794            - self.viewport_scroll.x;
4795        let origin_y = root_pos.y as f64
4796            + (root_layout.padding.top + root_layout.border.top) as f64
4797            - self.viewport_scroll.y;
4798
4799        let mut rects: Vec<BoundingRect> = Vec::new();
4800        for line in layout.lines() {
4801            let line_metrics = line.metrics();
4802            // Union all of the target's fragments on this line into a single rect
4803            let mut line_rect: Option<(f64, f64, f64, f64)> = None;
4804            let mut add = |x0: f64, y0: f64, x1: f64, y1: f64| {
4805                line_rect = Some(match line_rect {
4806                    Some((lx0, ly0, lx1, ly1)) => {
4807                        (lx0.min(x0), ly0.min(y0), lx1.max(x1), ly1.max(y1))
4808                    }
4809                    None => (x0, y0, x1, y1),
4810                });
4811            };
4812
4813            for item in line.items() {
4814                match item {
4815                    PositionedLayoutItem::GlyphRun(glyph_run) => {
4816                        let brush = glyph_run.style().brush;
4817                        let matches = if is_text {
4818                            brush.text_node == Some(node_id)
4819                        } else {
4820                            is_in_target(brush.id)
4821                        };
4822                        if !matches {
4823                            continue;
4824                        }
4825                        let x0 = glyph_run.offset() as f64;
4826                        let x1 = x0 + glyph_run.advance() as f64;
4827                        // Use the line box's block extent rather than the
4828                        // run's font ascent/descent: fonts with small
4829                        // typographic metrics would otherwise produce rects
4830                        // that clip the rendered glyphs. This matches the
4831                        // geometry used for text selection highlights.
4832                        let y0 = line_metrics.block_min_coord as f64;
4833                        let y1 = line_metrics.block_max_coord as f64;
4834                        add(x0, y0, x1, y1);
4835                    }
4836                    PositionedLayoutItem::InlineBox(inline_box) => {
4837                        if is_text || !is_in_target(NodeId::from_u64(inline_box.id)) {
4838                            continue;
4839                        }
4840                        let x0 = inline_box.x as f64;
4841                        let y0 = inline_box.y as f64;
4842                        add(
4843                            x0,
4844                            y0,
4845                            x0 + inline_box.width as f64,
4846                            y0 + inline_box.height as f64,
4847                        );
4848                    }
4849                }
4850            }
4851
4852            if let Some((x0, y0, x1, y1)) = line_rect {
4853                rects.push(BoundingRect {
4854                    x: origin_x + x0 / scale,
4855                    y: origin_y + y0 / scale,
4856                    width: (x1 - x0) / scale,
4857                    height: (y1 - y0) / scale,
4858                });
4859            }
4860        }
4861
4862        Some(rects)
4863    }
4864
4865    pub fn find_title_node(&self) -> Option<&Node> {
4866        TreeTraverser::new(self)
4867            .find(|node_id| {
4868                let node = &self.nodes[*node_id];
4869                let Some(element) = node.element_data() else {
4870                    return false;
4871                };
4872                if element.name.ns != ns!(html) || element.name.local != local_name!("title") {
4873                    return false;
4874                }
4875                node.parent
4876                    .and_then(|parent_id| self.nodes.get(parent_id))
4877                    .and_then(Node::element_data)
4878                    .is_some_and(|parent| {
4879                        parent.name.ns == ns!(html) && parent.name.local == local_name!("head")
4880                    })
4881            })
4882            .map(|node_id| &self.nodes[node_id])
4883    }
4884
4885    pub fn with_text_input(
4886        &mut self,
4887        node_id: NodeId,
4888        cb: impl FnOnce(PlainEditorDriver<TextBrush>),
4889    ) {
4890        let Some(node) = self.nodes.get_mut(node_id) else {
4891            return;
4892        };
4893
4894        if let Some(text_input) = node
4895            .element_data_mut()
4896            .and_then(|el| el.text_input_data_mut())
4897        {
4898            let mut font_ctx = self.font_ctx.lock().unwrap();
4899            let layout_ctx = &mut self.layout_ctx;
4900            let driver = text_input.editor.driver(&mut font_ctx, layout_ctx);
4901            cb(driver)
4902        }
4903    }
4904
4905    /// Recompute the scroll offset of the text input at `node_id` (if any) so that its caret
4906    /// remains visible within the input's content box.
4907    pub(crate) fn clamp_text_input_scroll(&mut self, node_id: NodeId) {
4908        let Some(node) = self.nodes.get_mut(node_id) else {
4909            return;
4910        };
4911
4912        let content_box_width = node.final_layout().content_box_width();
4913        let content_box_height = node.final_layout().content_box_height();
4914
4915        if let Some(text_input) = node
4916            .element_data_mut()
4917            .and_then(|el| el.text_input_data_mut())
4918        {
4919            text_input.clamp_scroll_offset(content_box_width, content_box_height);
4920        }
4921    }
4922
4923    pub(crate) fn compute_has_canvas(&self) -> bool {
4924        TreeTraverser::new(self).any(|node_id| {
4925            let node = &self.nodes[node_id];
4926            let Some(element) = node.element_data() else {
4927                return false;
4928            };
4929            if element.name.local == local_name!("canvas") && element.has_attr(local_name!("src")) {
4930                return true;
4931            }
4932
4933            false
4934        })
4935    }
4936
4937    // Text selection methods
4938
4939    /// Find the text position (inline_root_id, byte_offset) at a given point.
4940    /// Uses hit() for proper coordinate transformation, then finds the inline root
4941    /// and byte offset.
4942    pub fn find_text_position(&self, x: f32, y: f32) -> Option<(NodeId, usize)> {
4943        let hit = self.hit(x, y)?;
4944        let hit_node = self.get_node(hit.node_id)?;
4945        let inline_root = hit_node.inline_root_ancestor()?;
4946        let byte_offset = inline_root.text_offset_at_point(hit.x, hit.y)?;
4947        Some((inline_root.id, byte_offset))
4948    }
4949
4950    /// Find the word or line at a point, as `(inline_root_id, start, end)`.
4951    ///
4952    /// The multi-click counterpart of
4953    /// [`find_text_position`](Self::find_text_position): that one answers where
4954    /// a caret goes, this one answers what a double or triple click selects.
4955    pub fn find_text_range(
4956        &self,
4957        x: f32,
4958        y: f32,
4959        granularity: TextGranularity,
4960    ) -> Option<(NodeId, usize, usize)> {
4961        let hit = self.hit(x, y)?;
4962        let hit_node = self.get_node(hit.node_id)?;
4963        let inline_root = hit_node.inline_root_ancestor()?;
4964        let range = inline_root.text_range_at_point(hit.x, hit.y, granularity)?;
4965        Some((inline_root.id, range.start, range.end))
4966    }
4967
4968    /// Set the text selection range (creates a new selection from anchor to focus)
4969    pub fn set_text_selection(
4970        &mut self,
4971        anchor_node: NodeId,
4972        anchor_offset: usize,
4973        focus_node: NodeId,
4974        focus_offset: usize,
4975    ) {
4976        self.dom_selection = None;
4977        self.text_selection =
4978            TextSelection::new(anchor_node, anchor_offset, focus_node, focus_offset);
4979
4980        // For anonymous blocks, switch to storing parent+sibling_index (stable reference)
4981        if let (Some(parent), Some(idx)) = self.anonymous_block_location(anchor_node) {
4982            self.text_selection
4983                .anchor
4984                .set_anonymous(parent, idx, anchor_offset);
4985        }
4986        if let (Some(parent), Some(idx)) = self.anonymous_block_location(focus_node) {
4987            self.text_selection
4988                .focus
4989                .set_anonymous(parent, idx, focus_offset);
4990        }
4991    }
4992
4993    /// Get the parent ID and sibling index for a node if it's an anonymous block.
4994    /// Returns (None, None) for non-anonymous blocks.
4995    fn anonymous_block_location(&self, node_id: NodeId) -> (Option<NodeId>, Option<usize>) {
4996        let Some(node) = self.get_node(node_id) else {
4997            return (None, None);
4998        };
4999
5000        if !node.is_anonymous() {
5001            return (None, None);
5002        }
5003
5004        let Some(parent_id) = node.parent else {
5005            return (None, None);
5006        };
5007
5008        let Some(parent) = self.get_node(parent_id) else {
5009            return (Some(parent_id), None);
5010        };
5011
5012        let layout_children = parent.layout_children.borrow();
5013        let Some(children) = layout_children.as_ref() else {
5014            return (Some(parent_id), None);
5015        };
5016
5017        // Find the index of this anonymous block among siblings
5018        let mut anon_index = 0;
5019        for &child_id in children.iter() {
5020            if child_id == node_id {
5021                return (Some(parent_id), Some(anon_index));
5022            }
5023            if self.get_node(child_id).is_some_and(|n| n.is_anonymous()) {
5024                anon_index += 1;
5025            }
5026        }
5027
5028        (Some(parent_id), None)
5029    }
5030
5031    /// Clear the text selection
5032    pub fn clear_text_selection(&mut self) {
5033        self.dom_selection = None;
5034        self.text_selection.clear();
5035    }
5036
5037    /// Update the selection focus point (used during mouse drag to extend selection).
5038    pub fn update_selection_focus(&mut self, focus_node: NodeId, focus_offset: usize) {
5039        self.dom_selection = None;
5040        // For anonymous blocks, store parent+sibling_index; otherwise store node directly
5041        if let (Some(parent), Some(idx)) = self.anonymous_block_location(focus_node) {
5042            self.text_selection
5043                .focus
5044                .set_anonymous(parent, idx, focus_offset);
5045        } else {
5046            self.text_selection.set_focus(focus_node, focus_offset);
5047        }
5048    }
5049
5050    /// Extend text selection to the given point. Returns true if selection was updated.
5051    /// This is a convenience method that combines find_text_position and update_selection_focus.
5052    pub fn extend_text_selection_to_point(&mut self, x: f32, y: f32) -> bool {
5053        if !self.text_selection.anchor.is_some() {
5054            return false;
5055        }
5056
5057        if let Some((node, offset)) = self.find_text_position(x, y) {
5058            self.update_selection_focus(node, offset);
5059            self.shell_provider.request_redraw();
5060            true
5061        } else {
5062            false
5063        }
5064    }
5065
5066    /// Find the Nth anonymous block under a parent.
5067    fn find_anonymous_block_by_index(
5068        &self,
5069        parent_id: NodeId,
5070        target_index: usize,
5071    ) -> Option<NodeId> {
5072        let parent = self.get_node(parent_id)?;
5073        let layout_children = parent.layout_children.borrow();
5074        let children = layout_children.as_ref()?;
5075
5076        children
5077            .iter()
5078            .filter(|&&child_id| self.get_node(child_id).is_some_and(|n| n.is_anonymous()))
5079            .nth(target_index)
5080            .copied()
5081    }
5082
5083    /// Check if there is an active (non-empty) text selection
5084    pub fn has_text_selection(&self) -> bool {
5085        self.dom_selection.as_ref().map_or_else(
5086            || self.text_selection.is_active(),
5087            |range| !range.bounds().collapsed(),
5088        )
5089    }
5090
5091    /// Get the selected text content, supporting selection across multiple inline roots.
5092    pub fn get_selected_text(&self) -> Option<String> {
5093        if let Some(range) = &self.dom_selection {
5094            let text = self.range_string(range.bounds());
5095            return (!text.is_empty()).then_some(text);
5096        }
5097        let ranges = self.get_text_selection_ranges();
5098        if ranges.is_empty() {
5099            return None;
5100        }
5101
5102        let mut result = String::new();
5103        for (node_id, start, end) in &ranges {
5104            let node = self.get_node(*node_id)?;
5105            let element_data = node.element_data()?;
5106            let inline_layout = element_data.inline_layout_data.as_ref()?;
5107
5108            if *end > inline_layout.text.len() {
5109                continue;
5110            }
5111
5112            if !result.is_empty() {
5113                result.push(' ');
5114            }
5115            result.push_str(&inline_layout.text[*start..*end]);
5116        }
5117
5118        if result.is_empty() {
5119            None
5120        } else {
5121            Some(result)
5122        }
5123    }
5124
5125    /// The selection as HTML, for a clipboard that can carry formatting.
5126    ///
5127    /// The same ranges [`Self::get_selected_text`] walks, with each one wrapped
5128    /// in the tag of the element it came from, so bold stays bold and a code
5129    /// span stays a code span when it lands in another application. Pasting
5130    /// formatted text out of a Blitz window was impossible before this: plain
5131    /// text was the only payload ever written.
5132    ///
5133    /// Only the element's own tag is reproduced, not its ancestors or its
5134    /// styles. That is deliberate: a paste should carry the structure the author
5135    /// wrote, not this window's theme, and dragging a `class` into another
5136    /// application's document would reference styles that do not exist there.
5137    pub fn get_selected_html(&self) -> Option<String> {
5138        let ranges = self.get_text_selection_ranges();
5139        if ranges.is_empty() {
5140            return None;
5141        }
5142
5143        let mut result = String::new();
5144        for (node_id, start, end) in &ranges {
5145            let Some(node) = self.get_node(*node_id) else {
5146                continue;
5147            };
5148            let Some(element_data) = node.element_data() else {
5149                continue;
5150            };
5151            let Some(inline_layout) = element_data.inline_layout_data.as_ref() else {
5152                continue;
5153            };
5154            if *end > inline_layout.text.len() {
5155                continue;
5156            }
5157
5158            if !result.is_empty() {
5159                result.push(' ');
5160            }
5161
5162            let tag = element_data.name.local.as_ref();
5163            let fragment = escape_html(&inline_layout.text[*start..*end]);
5164            // A block tag around a fragment of a paragraph would be wrong: the
5165            // range is a run of text, and the element it belongs to may be only
5166            // partly selected. Inline tags carry meaning a paste should keep;
5167            // anything else contributes its text and nothing more.
5168            if matches!(
5169                tag,
5170                "b" | "strong" | "i" | "em" | "code" | "kbd" | "mark" | "s" | "u" | "sub" | "sup"
5171            ) {
5172                result.push_str(&format!("<{tag}>{fragment}</{tag}>"));
5173            } else {
5174                result.push_str(&fragment);
5175            }
5176        }
5177
5178        if result.is_empty() {
5179            None
5180        } else {
5181            Some(result)
5182        }
5183    }
5184
5185    /// Get all selection ranges as Vec<(node_id, start_offset, end_offset)>.
5186    /// Returns empty vec if no selection.
5187    pub fn get_text_selection_ranges(&self) -> Vec<(NodeId, usize, usize)> {
5188        if let Some(range) = &self.dom_selection {
5189            return self.range_layout_ranges(range.bounds());
5190        }
5191        let lookup = |parent_id, idx| self.find_anonymous_block_by_index(parent_id, idx);
5192
5193        let anchor_node = match self.text_selection.anchor.resolve_node_id(lookup) {
5194            Some(id) => id,
5195            None => return Vec::new(),
5196        };
5197        let focus_node = match self.text_selection.focus.resolve_node_id(lookup) {
5198            Some(id) => id,
5199            None => return Vec::new(),
5200        };
5201
5202        // Guard against stale selection endpoints: nodes may have been removed from
5203        // the document (e.g. by script) since the selection was made.
5204        let node_is_in_doc = |node_id: NodeId| {
5205            self.nodes
5206                .get(node_id)
5207                .is_some_and(|node| node.flags.is_in_document())
5208        };
5209        if !node_is_in_doc(anchor_node) || !node_is_in_doc(focus_node) {
5210            return Vec::new();
5211        }
5212
5213        // Single node selection
5214        if anchor_node == focus_node {
5215            let start = self
5216                .text_selection
5217                .anchor
5218                .offset
5219                .min(self.text_selection.focus.offset);
5220            let end = self
5221                .text_selection
5222                .anchor
5223                .offset
5224                .max(self.text_selection.focus.offset);
5225
5226            if start == end {
5227                return Vec::new();
5228            }
5229            return vec![(anchor_node, start, end)];
5230        }
5231
5232        // Multi-node selection: collect all inline roots between anchor and focus
5233        let inline_roots = self.collect_inline_roots_in_range(anchor_node, focus_node);
5234        if inline_roots.is_empty() {
5235            return Vec::new();
5236        }
5237
5238        // Determine document order using the collected inline_roots order
5239        // (inline_roots is already in document order from first to last)
5240        let first_in_roots = inline_roots[0];
5241
5242        let (first_node, first_offset, last_node, last_offset) =
5243            if first_in_roots == anchor_node || (first_in_roots != focus_node) {
5244                // anchor is first (or neither endpoint is in roots, which shouldn't happen)
5245                (
5246                    anchor_node,
5247                    self.text_selection.anchor.offset,
5248                    focus_node,
5249                    self.text_selection.focus.offset,
5250                )
5251            } else {
5252                // focus is first
5253                (
5254                    focus_node,
5255                    self.text_selection.focus.offset,
5256                    anchor_node,
5257                    self.text_selection.anchor.offset,
5258                )
5259            };
5260
5261        let mut ranges = Vec::with_capacity(inline_roots.len());
5262
5263        for &node_id in &inline_roots {
5264            let Some(node) = self.get_node(node_id) else {
5265                continue;
5266            };
5267            let Some(element_data) = node.element_data() else {
5268                continue;
5269            };
5270            let Some(inline_layout) = element_data.inline_layout_data.as_ref() else {
5271                continue;
5272            };
5273
5274            let text_len = inline_layout.text.len();
5275
5276            if node_id == first_node && node_id == last_node {
5277                let start = first_offset.min(last_offset);
5278                let end = first_offset.max(last_offset);
5279                if start < end && end <= text_len {
5280                    ranges.push((node_id, start, end));
5281                }
5282            } else if node_id == first_node {
5283                if first_offset < text_len {
5284                    ranges.push((node_id, first_offset, text_len));
5285                }
5286            } else if node_id == last_node {
5287                if last_offset > 0 && last_offset <= text_len {
5288                    ranges.push((node_id, 0, last_offset));
5289                }
5290            } else if text_len > 0 {
5291                ranges.push((node_id, 0, text_len));
5292            }
5293        }
5294
5295        ranges
5296    }
5297}
5298
5299#[derive(Debug, Clone, Copy, PartialEq)]
5300pub struct BoundingRect {
5301    pub x: f64,
5302    pub y: f64,
5303    pub width: f64,
5304    pub height: f64,
5305}
5306
5307/// The record of changes a script's `MutationObserver` reads. See
5308/// [`crate::DomMutation`] for what is recorded and why.
5309impl BaseDocument {
5310    /// Note a `<details open>` presence change for the toggle event. `present` is whether the
5311    /// attribute exists after this write.
5312    pub(crate) fn record_details_open(
5313        &mut self,
5314        node_id: NodeId,
5315        name: &markup5ever::QualName,
5316        present: bool,
5317    ) {
5318        if name.local != local_name!("open") || !name.ns.is_empty() {
5319            return;
5320        }
5321        let Some(element) = self.nodes[node_id].element_data() else {
5322            return;
5323        };
5324        if element.name.local != local_name!("details") || element.name.ns != ns!(html) {
5325            return;
5326        }
5327        let was_open = element.attr(local_name!("open")).is_some();
5328        if was_open != present && !self.details_toggles.iter().any(|(id, _)| *id == node_id) {
5329            self.details_toggles.push((node_id, was_open));
5330        }
5331    }
5332
5333    /// The inert document template contents belong to.
5334    pub(crate) fn template_contents_owner(&mut self) -> NodeId {
5335        if let Some(owner) = self.template_contents_owner {
5336            return owner;
5337        }
5338        let mut data = crate::node::DocumentData::default();
5339        data.content_type = "text/html";
5340        let owner = self.create_node(NodeData::Document(Box::new(data)));
5341        self.template_contents_owner = Some(owner);
5342        owner
5343    }
5344
5345    /// How many Stylo traversals have restyled this document.
5346    pub fn style_revision(&self) -> u64 {
5347        self.style_revision
5348    }
5349
5350    /// How many times layout has been computed for this document.
5351    pub fn layout_revision(&self) -> u64 {
5352        self.layout_revision
5353    }
5354
5355    pub(crate) fn note_layout_resolved(&mut self) {
5356        self.layout_revision = self.layout_revision.wrapping_add(1);
5357    }
5358
5359    /// Changes whenever the device media queries are evaluated against changes.
5360    pub fn viewport_generation(&self) -> u64 {
5361        self.viewport_generation
5362    }
5363
5364    /// The `<details>` toggles recorded since the last call, each with its old open state.
5365    pub fn take_details_toggles(&mut self) -> Vec<(NodeId, bool)> {
5366        std::mem::take(&mut self.details_toggles)
5367    }
5368
5369    /// Update the raw content handler state at the attribute mutation boundary.
5370    /// Equal-value writes still replace a compiled or script-assigned handler.
5371    pub(crate) fn record_inline_handler_attribute(
5372        &mut self,
5373        node_id: NodeId,
5374        name: &crate::QualName,
5375        value: Option<&str>,
5376    ) {
5377        if name.ns != markup5ever::ns!() || !name.local.as_ref().starts_with("on") {
5378            return;
5379        }
5380        let Some(element) = self
5381            .nodes
5382            .get_mut(node_id)
5383            .and_then(|node| node.element_data_mut())
5384        else {
5385            return;
5386        };
5387        let value = value.map(crate::node::AttrAtom::from);
5388        if let Some(value) = &value {
5389            let attributes = element
5390                .inline_event_attributes
5391                .get_or_insert_with(|| Box::new(Vec::new()));
5392            if let Some(attribute) = attributes
5393                .iter_mut()
5394                .find(|attribute| attribute.name.local == name.local)
5395            {
5396                attribute.name = name.clone();
5397                attribute.value = value.clone();
5398            } else {
5399                attributes.push(crate::node::Attribute {
5400                    name: name.clone(),
5401                    value: value.clone(),
5402                });
5403            }
5404        } else {
5405            let Some(attributes) = element.inline_event_attributes.as_mut() else {
5406                return;
5407            };
5408            let Some(index) = attributes
5409                .iter()
5410                .position(|attribute| attribute.name.local == name.local)
5411            else {
5412                return;
5413            };
5414            attributes.remove(index);
5415            if attributes.is_empty() {
5416                element.inline_event_attributes = None;
5417            }
5418        }
5419        self.inline_handler_changes
5420            .push((node_id, name.clone(), value));
5421    }
5422
5423    /// Take content handler changes in mutation order.
5424    pub fn take_inline_handler_changes(
5425        &mut self,
5426    ) -> Vec<(NodeId, crate::QualName, Option<crate::node::AttrAtom>)> {
5427        std::mem::take(&mut self.inline_handler_changes)
5428    }
5429
5430    /// Start or stop recording changes. Stopping discards what was recorded
5431    /// and not yet taken.
5432    pub fn set_recording_mutations(&mut self, recording: bool) {
5433        match (recording, self.mutation_log.is_some()) {
5434            (true, false) => self.mutation_log = Some(Vec::new()),
5435            (false, true) => self.mutation_log = None,
5436            _ => {}
5437        }
5438    }
5439
5440    /// Whether changes are being recorded.
5441    pub fn is_recording_mutations(&self) -> bool {
5442        self.mutation_log.is_some()
5443    }
5444    /// Whether observer delivery still has recorded changes to consume.
5445    pub fn has_pending_mutations(&self) -> bool {
5446        self.mutation_log
5447            .as_ref()
5448            .is_some_and(|records| !records.is_empty())
5449    }
5450
5451    /// Take every change recorded since the last call, oldest first.
5452    pub fn take_mutations(&mut self) -> Vec<crate::DomMutation> {
5453        self.mutation_log
5454            .as_mut()
5455            .map(std::mem::take)
5456            .unwrap_or_default()
5457    }
5458
5459    pub(crate) fn record_mutation(&mut self, mutation: crate::DomMutation) {
5460        if let Some(log) = self.mutation_log.as_mut() {
5461            if log.len() < crate::mutation_record::MUTATION_LOG_CAP {
5462                log.push(mutation);
5463            }
5464        }
5465    }
5466
5467    /// Where `node_id` sits among its siblings: its parent and the siblings on
5468    /// either side, as a child-list record reports them.
5469    pub(crate) fn sibling_context(
5470        &self,
5471        node_id: NodeId,
5472    ) -> Option<(NodeId, Option<NodeId>, Option<NodeId>)> {
5473        let parent_id = self.nodes.get(node_id)?.parent?;
5474        let siblings = &self.nodes.get(parent_id)?.children;
5475        let index = siblings.iter().position(|id| *id == node_id)?;
5476        let previous = index.checked_sub(1).map(|i| siblings[i]);
5477        let next = siblings.get(index + 1).copied();
5478        Some((parent_id, previous, next))
5479    }
5480}
5481
5482impl AsRef<BaseDocument> for BaseDocument {
5483    fn as_ref(&self) -> &BaseDocument {
5484        self
5485    }
5486}
5487
5488impl AsMut<BaseDocument> for BaseDocument {
5489    fn as_mut(&mut self) -> &mut BaseDocument {
5490        self
5491    }
5492}
5493
5494#[cfg(test)]
5495mod hover_state_tests {
5496    use super::*;
5497    use crate::{Attribute, qual_name};
5498    use blitz_traits::shell::ColorScheme;
5499
5500    /// Build `<html><body style="margin:0"><div style="width:300px">some text
5501    /// <div style="height:50px"></div></div></body></html>` manually (the HTML
5502    /// parser lives in blitz-html, which would be a circular dev-dependency).
5503    /// The bare text next to a block sibling gets wrapped in an anonymous
5504    /// block, which becomes the inline root: text hits report the anonymous
5505    /// block as the hit node.
5506    fn make_doc() -> (BaseDocument, NodeId) {
5507        let mut doc = BaseDocument::new(DocumentConfig {
5508            viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
5509            ..Default::default()
5510        });
5511        let root_id = doc.root_node().id;
5512        let style = |value: &str| Attribute {
5513            name: qual_name!("style"),
5514            value: value.into(),
5515        };
5516
5517        let mut mutator = doc.mutate();
5518        let html = mutator.create_element(qual_name!("html"), vec![]);
5519        let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
5520        let container = mutator.create_element(qual_name!("div"), vec![style("width:300px")]);
5521        let text = mutator.create_text_node("some text");
5522        let block = mutator.create_element(qual_name!("div"), vec![style("height:50px")]);
5523        mutator.append_children(container, &[text, block]);
5524        mutator.append_children(body, &[container]);
5525        mutator.append_children(html, &[body]);
5526        mutator.append_children(root_id, &[html]);
5527        drop(mutator);
5528
5529        doc.resolve(0.0);
5530        (doc, container)
5531    }
5532
5533    /// Whether text laid out with a real (non-zero-metric) font. Without the
5534    /// `system-fonts` feature text measures 0x0 and text hits are impossible,
5535    /// making these tests vacuous.
5536    fn text_has_size(doc: &BaseDocument, container: NodeId) -> bool {
5537        doc.nodes[container].final_layout().size.height > 50.0
5538    }
5539
5540    /// Regression test: hovering bare text wrapped in an anonymous block must
5541    /// report a text cursor. The hit node for such text is the anonymous
5542    /// inline root itself, while the *stored* hover target is canonicalized to
5543    /// the containing element — the cursor must be derived from the precise
5544    /// hit node, not the canonical target.
5545    #[test]
5546    fn hovering_text_in_anonymous_block_reports_text_cursor() {
5547        let (mut doc, container) = make_doc();
5548        if !text_has_size(&doc, container) {
5549            eprintln!("skipping: no usable font (text measures 0x0)");
5550            return;
5551        }
5552
5553        doc.set_hover_to(5.0, 8.0);
5554        assert!(doc.hover_node_is_text, "expected a text hit");
5555        let hit_id = doc.hover_hit_node_id.expect("expected a hit node");
5556        assert!(
5557            doc.nodes[hit_id].is_anonymous(),
5558            "expected the hit node to be the anonymous inline root"
5559        );
5560        assert_eq!(
5561            doc.get_hover_node_id(),
5562            Some(container),
5563            "expected the stored hover target to be the containing element"
5564        );
5565        assert_eq!(doc.get_cursor(), Some(CursorIcon::Text));
5566    }
5567
5568    #[test]
5569    fn semantic_hover_keeps_the_resolved_node_instead_of_hit_testing_again() {
5570        let (mut doc, container) = make_doc();
5571
5572        // This coordinate is outside the 300px-wide container. A coordinate
5573        // hit test therefore cannot select it, but semantic automation has
5574        // already selected the container by id and must preserve that target.
5575        doc.set_hover_to_node(container, 350.0, 250.0);
5576
5577        assert_eq!(doc.get_hover_node_id(), Some(container));
5578        assert_eq!(doc.hover_hit_node_id, Some(container));
5579
5580        doc.resolve(0.0);
5581        assert_eq!(
5582            doc.get_hover_node_id(),
5583            Some(container),
5584            "a resolve must not turn semantic identity back into a coordinate hit"
5585        );
5586    }
5587
5588    /// Hovering the empty region of the anonymous block (right of the text) is
5589    /// not a text hit: default cursor, same canonical hover target.
5590    #[test]
5591    fn hovering_anonymous_block_whitespace_reports_default_cursor() {
5592        let (mut doc, container) = make_doc();
5593        if !text_has_size(&doc, container) {
5594            eprintln!("skipping: no usable font (text measures 0x0)");
5595            return;
5596        }
5597
5598        doc.set_hover_to(250.0, 8.0);
5599        assert!(!doc.hover_node_is_text);
5600        assert_eq!(doc.get_hover_node_id(), Some(container));
5601        assert_eq!(doc.get_cursor(), Some(CursorIcon::Default));
5602    }
5603}
5604
5605#[cfg(test)]
5606mod control_scroll_tests {
5607    use super::*;
5608    use crate::{Attribute, qual_name};
5609    use blitz_traits::shell::ColorScheme;
5610
5611    #[test]
5612    fn controlled_scroll_dispatches_the_dom_scroll_event() {
5613        let mut doc = BaseDocument::new(DocumentConfig {
5614            viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
5615            ..Default::default()
5616        });
5617        let root_id = doc.root_node().id;
5618        let style = |value: &str| Attribute {
5619            name: qual_name!("style"),
5620            value: value.into(),
5621        };
5622
5623        let mut mutator = doc.mutate();
5624        let html = mutator.create_element(qual_name!("html"), vec![]);
5625        let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
5626        let scroller = mutator.create_element(
5627            qual_name!("div"),
5628            vec![style("width:200px;height:100px;overflow-y:scroll")],
5629        );
5630        let spacer = mutator.create_element(qual_name!("div"), vec![style("height:400px")]);
5631        let target = mutator.create_element(qual_name!("button"), vec![style("height:40px")]);
5632        let target_text = mutator.create_text_node("Reveal me");
5633        mutator.append_children(target, &[target_text]);
5634        mutator.append_children(scroller, &[spacer, target]);
5635        mutator.append_children(body, &[scroller]);
5636        mutator.append_children(html, &[body]);
5637        mutator.append_children(root_id, &[html]);
5638        drop(mutator);
5639        doc.resolve(0.0);
5640
5641        // The manual mutator deliberately bypasses the HTML/style parser used
5642        // by loaded documents. Give the fixture explicit post-layout geometry
5643        // so this unit test isolates event forwarding rather than CSS parsing.
5644        doc.nodes[html].final_layout_mut().size.height = 300.0;
5645        doc.nodes[html].final_layout_mut().content_size.height = 600.0;
5646        doc.nodes[target].final_layout_mut().location.y = 400.0;
5647
5648        let mut events = Vec::new();
5649        // A semantic client may target the exposed text rather than its
5650        // element. Text has no `final_layout`, so this also pins the host-crash
5651        // regression from revealing a named label.
5652        doc.scroll_to_node_centered_with_events(target_text, |event| events.push(event));
5653
5654        assert!(
5655            (doc.viewport_scroll.y - 270.0).abs() < 0.1,
5656            "the 40px target should be centred in the 300px viewport"
5657        );
5658        assert!(
5659            events
5660                .iter()
5661                .any(|event| { event.target == html && event.name() == "scroll" })
5662        );
5663    }
5664}
5665
5666#[cfg(test)]
5667mod font_face_override_tests {
5668    use super::*;
5669    use crate::net::{FontFaceOverrides, Resource, ResourceLoadResponse};
5670
5671    /// Regression-pin for the `@font-face` descriptor-honouring fix.
5672    ///
5673    /// The bug was that `Resource::Font` carried only the raw font bytes,
5674    /// so `load_resource` registered fonts with `info_override = None` and
5675    /// parley fell back to the TTF's internal `name` table. After the fix,
5676    /// `Resource::Font` carries `FontFaceOverrides` and `load_resource`
5677    /// builds a `FontInfoOverride` from them — meaning a CSS-declared
5678    /// `font-family` alias wins over the file's own metadata.
5679    ///
5680    /// We drive `load_resource` directly with a fabricated response rather
5681    /// than go through HTML parsing → `fetch_font_face`, because the
5682    /// downstream HTML parser lives in `blitz-html` (would be a circular
5683    /// crate dependency). The mapping from `@font-face` descriptors into
5684    /// `FontFaceOverrides` is covered by the unit tests in `net.rs`; this
5685    /// test pins the load-side of the pipeline.
5686    #[test]
5687    fn font_face_overrides_alias_family_name() {
5688        const ALIAS: &str = "AliasedFamily";
5689
5690        let mut document = BaseDocument::new(DocumentConfig::default());
5691
5692        // Sanity: the alias name is not registered before we feed the font.
5693        {
5694            let mut ctx = document.font_ctx.lock().unwrap();
5695            assert!(
5696                ctx.collection.family_id(ALIAS).is_none(),
5697                "alias must not exist before registration",
5698            );
5699        }
5700
5701        // Drive `load_resource` with a `Resource::Font` whose overrides
5702        // assert the CSS-side family name. We use the bullet font as a
5703        // valid font payload — its internal `name` table is irrelevant to
5704        // the assertion; what matters is whether the override wins.
5705        let response = ResourceLoadResponse {
5706            request_id: 0,
5707            node_id: None,
5708            resolved_url: Some(String::from("test://aliased-family")),
5709            result: Ok(Resource::Font(
5710                blitz_traits::net::Bytes::from_static(crate::BULLET_FONT),
5711                FontFaceOverrides {
5712                    family_name: Some(String::from(ALIAS)),
5713                    weight: Some(800.0),
5714                    style: Some(parley::fontique::FontStyle::Italic),
5715                },
5716            )),
5717        };
5718        document.load_resource(response);
5719
5720        // The override must have taken effect: parley's `Collection` now
5721        // resolves the CSS-declared alias to a registered family.
5722        let mut ctx = document.font_ctx.lock().unwrap();
5723        let family_id = ctx
5724            .collection
5725            .family_id(ALIAS)
5726            .expect("CSS-declared family name should be registered as a family alias");
5727        let resolved_name = ctx
5728            .collection
5729            .family_name(family_id)
5730            .expect("family id should resolve back to a name");
5731        assert_eq!(
5732            resolved_name, ALIAS,
5733            "registered family should report the CSS-declared name, \
5734             not the font file's internal `name` table entry",
5735        );
5736    }
5737}
5738
5739#[cfg(test)]
5740mod clipboard_html_tests {
5741    use super::escape_html;
5742
5743    /// A selection is document text, so markup characters in it are content.
5744    /// Unescaped, `a < b` opens a tag in whatever receives the paste.
5745    #[test]
5746    fn markup_characters_in_a_selection_are_escaped() {
5747        assert_eq!(
5748            escape_html("a < b && c > d"),
5749            "a &lt; b &amp;&amp; c &gt; d"
5750        );
5751    }
5752
5753    /// The ampersand has to go first, or escaping the others re-escapes the
5754    /// entities just written and `<` arrives as `&amp;lt;`.
5755    #[test]
5756    fn an_ampersand_is_not_double_escaped() {
5757        assert_eq!(escape_html("&lt;"), "&amp;lt;");
5758    }
5759
5760    /// Ordinary prose is left exactly as it is, including the apostrophes and
5761    /// quotes that a paste should carry through unchanged.
5762    #[test]
5763    fn prose_is_untouched() {
5764        assert_eq!(escape_html("it's \"fine\""), "it's &quot;fine&quot;");
5765    }
5766}