blitz-pdf 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +35 -6
  2. package/THIRD_PARTY_NOTICES.md +5 -2
  3. package/dist/api.d.ts +9 -1
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +25 -27
  6. package/dist/api.js.map +1 -1
  7. package/dist/browser.d.ts +1 -1
  8. package/dist/browser.d.ts.map +1 -1
  9. package/dist/browser.js +13 -1
  10. package/dist/browser.js.map +1 -1
  11. package/dist/node-worker.d.ts +2 -0
  12. package/dist/node-worker.d.ts.map +1 -0
  13. package/dist/node-worker.js +10 -0
  14. package/dist/node-worker.js.map +1 -0
  15. package/dist/node.d.ts +1 -1
  16. package/dist/node.d.ts.map +1 -1
  17. package/dist/node.js +18 -6
  18. package/dist/node.js.map +1 -1
  19. package/dist/render.d.ts +8 -0
  20. package/dist/render.d.ts.map +1 -0
  21. package/dist/render.js +28 -0
  22. package/dist/render.js.map +1 -0
  23. package/dist/wasm.d.ts +3 -0
  24. package/dist/wasm.d.ts.map +1 -0
  25. package/dist/wasm.js +18 -0
  26. package/dist/wasm.js.map +1 -0
  27. package/dist/worker-host.d.ts +16 -0
  28. package/dist/worker-host.d.ts.map +1 -0
  29. package/dist/worker-host.js +31 -0
  30. package/dist/worker-host.js.map +1 -0
  31. package/dist/worker-protocol.d.ts +22 -0
  32. package/dist/worker-protocol.d.ts.map +1 -0
  33. package/dist/worker-protocol.js +2 -0
  34. package/dist/worker-protocol.js.map +1 -0
  35. package/dist/worker-runtime.d.ts +8 -0
  36. package/dist/worker-runtime.d.ts.map +1 -0
  37. package/dist/worker-runtime.js +34 -0
  38. package/dist/worker-runtime.js.map +1 -0
  39. package/dist/worker.d.ts +2 -0
  40. package/dist/worker.d.ts.map +1 -0
  41. package/dist/worker.js +7 -0
  42. package/dist/worker.js.map +1 -0
  43. package/docs/layout-regressions.md +65 -0
  44. package/docs/pagination-regressions.md +7 -6
  45. package/package.json +5 -2
  46. package/src/api.ts +33 -22
  47. package/src/browser.ts +13 -3
  48. package/src/node-worker.ts +9 -0
  49. package/src/node.ts +13 -4
  50. package/src/render.ts +28 -0
  51. package/src/wasm.ts +17 -0
  52. package/src/worker-host.ts +45 -0
  53. package/src/worker-protocol.ts +10 -0
  54. package/src/worker-runtime.ts +35 -0
  55. package/src/worker.ts +12 -0
  56. package/vendor/README.md +35 -0
  57. package/vendor/blitz-dom/Cargo.toml +113 -0
  58. package/vendor/blitz-dom/LICENSE-APACHE +176 -0
  59. package/vendor/blitz-dom/LICENSE-MIT +23 -0
  60. package/vendor/blitz-dom/assets/default.css +1142 -0
  61. package/vendor/blitz-dom/assets/moz-bullet-font.otf +0 -0
  62. package/vendor/blitz-dom/src/accessibility.rs +255 -0
  63. package/vendor/blitz-dom/src/config.rs +97 -0
  64. package/vendor/blitz-dom/src/cssom.rs +971 -0
  65. package/vendor/blitz-dom/src/debug.rs +206 -0
  66. package/vendor/blitz-dom/src/document.rs +3395 -0
  67. package/vendor/blitz-dom/src/events/driver.rs +427 -0
  68. package/vendor/blitz-dom/src/events/focus.rs +40 -0
  69. package/vendor/blitz-dom/src/events/ime.rs +31 -0
  70. package/vendor/blitz-dom/src/events/keyboard.rs +174 -0
  71. package/vendor/blitz-dom/src/events/mod.rs +315 -0
  72. package/vendor/blitz-dom/src/events/pointer.rs +849 -0
  73. package/vendor/blitz-dom/src/font_metrics.rs +177 -0
  74. package/vendor/blitz-dom/src/form.rs +463 -0
  75. package/vendor/blitz-dom/src/html.rs +38 -0
  76. package/vendor/blitz-dom/src/iframe.rs +186 -0
  77. package/vendor/blitz-dom/src/layout/construct.rs +1328 -0
  78. package/vendor/blitz-dom/src/layout/damage.rs +642 -0
  79. package/vendor/blitz-dom/src/layout/inline.rs +1160 -0
  80. package/vendor/blitz-dom/src/layout/list.rs +237 -0
  81. package/vendor/blitz-dom/src/layout/mod.rs +969 -0
  82. package/vendor/blitz-dom/src/layout/paint_tree.rs +462 -0
  83. package/vendor/blitz-dom/src/layout/replaced.rs +439 -0
  84. package/vendor/blitz-dom/src/layout/table.rs +967 -0
  85. package/vendor/blitz-dom/src/layout/text_transform.rs +979 -0
  86. package/vendor/blitz-dom/src/layout/writing_mode.rs +437 -0
  87. package/vendor/blitz-dom/src/lib.rs +160 -0
  88. package/vendor/blitz-dom/src/mutator.rs +1818 -0
  89. package/vendor/blitz-dom/src/net.rs +649 -0
  90. package/vendor/blitz-dom/src/node/attributes.rs +60 -0
  91. package/vendor/blitz-dom/src/node/custom_widget.rs +178 -0
  92. package/vendor/blitz-dom/src/node/element.rs +1106 -0
  93. package/vendor/blitz-dom/src/node/mod.rs +30 -0
  94. package/vendor/blitz-dom/src/node/node.rs +2036 -0
  95. package/vendor/blitz-dom/src/node/scrollbar.rs +237 -0
  96. package/vendor/blitz-dom/src/node/serialize.rs +225 -0
  97. package/vendor/blitz-dom/src/node/stylo_data.rs +142 -0
  98. package/vendor/blitz-dom/src/node/svg.rs +265 -0
  99. package/vendor/blitz-dom/src/node/text.rs +804 -0
  100. package/vendor/blitz-dom/src/query_selector.rs +275 -0
  101. package/vendor/blitz-dom/src/resolve.rs +474 -0
  102. package/vendor/blitz-dom/src/resolved_style.rs +797 -0
  103. package/vendor/blitz-dom/src/scrolling.rs +780 -0
  104. package/vendor/blitz-dom/src/selection.rs +122 -0
  105. package/vendor/blitz-dom/src/stylo.rs +1518 -0
  106. package/vendor/blitz-dom/src/stylo_device.rs +88 -0
  107. package/vendor/blitz-dom/src/stylo_to_cursor_icon.rs +49 -0
  108. package/vendor/blitz-dom/src/stylo_to_kurbo.rs +129 -0
  109. package/vendor/blitz-dom/src/stylo_to_parley.rs +655 -0
  110. package/vendor/blitz-dom/src/traversal.rs +600 -0
  111. package/vendor/blitz-dom/src/tree.rs +136 -0
  112. package/vendor/blitz-dom/src/url.rs +63 -0
  113. package/vendor/blitz-dom/src/util.rs +256 -0
  114. package/wasm/blitz_pdf_bg.wasm +0 -0
@@ -0,0 +1,3395 @@
1
+ use crate::NodeTree;
2
+ use crate::events::{DragMode, handle_dom_event};
3
+ use crate::layout::construct::ConstructionTask;
4
+ use crate::layout::damage::ALL_DAMAGE;
5
+ use crate::mutator::ViewportMut;
6
+ use crate::net::{
7
+ Resource, ResourceHandler, ResourceLoadResponse, StylesheetHandler, StylesheetLoader,
8
+ };
9
+ use crate::node::{ImageData, NodeFlags, RasterImageData, SpecialElementData, Status, TextBrush};
10
+ use crate::scrolling::ScrollAnimationState;
11
+ use crate::selection::TextSelection;
12
+ use crate::stylo_device::{DeviceChanges, make_device};
13
+ use crate::stylo_to_cursor_icon::stylo_to_cursor_icon;
14
+ use crate::traversal::TreeTraverser;
15
+ use crate::url::DocumentUrl;
16
+ use crate::util::ImageType;
17
+ use crate::{
18
+ DEFAULT_CSS, DocumentConfig, DocumentMutator, DummyHtmlParserProvider, ElementData,
19
+ EventDriver, HtmlParserProvider, Node, NodeData, NoopEventHandler, StyleThreading,
20
+ TextNodeData,
21
+ };
22
+ use blitz_traits::devtools::DevtoolSettings;
23
+ use blitz_traits::events::{DomEvent, HitResult, UiEvent};
24
+ use blitz_traits::navigation::{DummyNavigationProvider, NavigationProvider};
25
+ use blitz_traits::net::{AbortSignal, DummyNetProvider, NetProvider, Request};
26
+ use blitz_traits::node_id::NodeId;
27
+ use blitz_traits::shell::{DummyShellProvider, ShellProvider, Viewport};
28
+ use cursor_icon::CursorIcon;
29
+ use linebender_resource_handle::Blob;
30
+ use markup5ever::{LocalName, local_name};
31
+ use parley::{FontContext, PlainEditorDriver};
32
+ use selectors::{Element, matching::QuirksMode};
33
+ use smallvec::SmallVec;
34
+ use std::any::Any;
35
+ use std::cell::RefCell;
36
+ use std::collections::{BTreeMap, Bound, HashMap, HashSet};
37
+ use std::ops::{Deref, DerefMut};
38
+ use std::rc::Rc;
39
+ use std::str::FromStr;
40
+ use std::sync::atomic::{AtomicUsize, Ordering};
41
+ use std::sync::mpsc::{Receiver, Sender, channel};
42
+ use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock, RwLockReadGuard, RwLockWriteGuard};
43
+ use std::task::{Context as TaskContext, Waker};
44
+ use style::Atom;
45
+ use style::animation::DocumentAnimationSet;
46
+ use style::attr::{AttrIdentifier, AttrValue};
47
+ use style::computed_value_flags::ComputedValueFlags;
48
+ use style::data::{ElementData as StyloElementData, ElementStyles};
49
+ use style::invalidation::element::restyle_hints::RestyleHint;
50
+ use style::media_queries::MediaType;
51
+ use style::properties::ComputedValues;
52
+ use style::properties::style_structs::Font;
53
+ use style::selector_parser::ServoElementSnapshot;
54
+ use style::servo_arc::Arc as ServoArc;
55
+ use style::values::GenericAtomIdent;
56
+ use style::values::computed::UserSelect;
57
+ use style::values::computed::ui::CursorKind;
58
+ use style::{
59
+ device::Device,
60
+ dom::{TDocument, TNode},
61
+ media_queries::MediaList,
62
+ selector_parser::SnapshotMap,
63
+ shared_lock::{SharedRwLock, StylesheetGuards},
64
+ stylesheets::{AllowImportRules, DocumentStyleSheet, Origin, Stylesheet},
65
+ stylist::Stylist,
66
+ };
67
+ use style_dom::ElementState;
68
+ use thin_vec::ThinVec;
69
+ use url::Url;
70
+ use web_time::Instant;
71
+
72
+ #[cfg(feature = "parallel-construct")]
73
+ use thread_local::ThreadLocal;
74
+
75
+ /// A user-agent stylesheet, parsed with a read-only lock so that it can be shared between documents.
76
+ ///
77
+ /// Documents with the same user-agent stylesheets (in the same order) also share Stylo's
78
+ /// cached user-agent cascade data. `@import` rules are ignored.
79
+ #[derive(Clone, Debug, PartialEq)]
80
+ pub struct UserAgentStylesheet(DocumentStyleSheet);
81
+
82
+ impl UserAgentStylesheet {
83
+ pub fn parse(css: &str) -> Self {
84
+ let lock = SharedRwLock::read_only();
85
+ let data = Stylesheet::from_str(
86
+ css,
87
+ DocumentUrl::default().url_extra_data(),
88
+ Origin::UserAgent,
89
+ ServoArc::new(lock.wrap(MediaList::empty())),
90
+ lock,
91
+ None,
92
+ None,
93
+ QuirksMode::NoQuirks,
94
+ AllowImportRules::No,
95
+ );
96
+ Self(DocumentStyleSheet(ServoArc::new(data)))
97
+ }
98
+
99
+ /// The default user-agent stylesheet ([`DEFAULT_CSS`]), parsed once.
100
+ pub fn default_sheet() -> Self {
101
+ static SHEET: LazyLock<UserAgentStylesheet> =
102
+ LazyLock::new(|| UserAgentStylesheet::parse(DEFAULT_CSS));
103
+ SHEET.clone()
104
+ }
105
+
106
+ pub fn sheet(&self) -> &DocumentStyleSheet {
107
+ &self.0
108
+ }
109
+ }
110
+
111
+ pub enum DocGuard<'a> {
112
+ Ref(&'a BaseDocument),
113
+ RefCell(std::cell::Ref<'a, BaseDocument>),
114
+ RwLock(RwLockReadGuard<'a, BaseDocument>),
115
+ Mutex(MutexGuard<'a, BaseDocument>),
116
+ }
117
+
118
+ impl Deref for DocGuard<'_> {
119
+ type Target = BaseDocument;
120
+ #[inline(always)]
121
+ fn deref(&self) -> &Self::Target {
122
+ match self {
123
+ Self::Ref(base_document) => base_document,
124
+ Self::RefCell(refcell_guard) => refcell_guard,
125
+ Self::RwLock(rw_lock_read_guard) => rw_lock_read_guard,
126
+ Self::Mutex(mutex_guard) => mutex_guard,
127
+ }
128
+ }
129
+ }
130
+
131
+ pub enum DocGuardMut<'a> {
132
+ Ref(&'a mut BaseDocument),
133
+ RefCell(std::cell::RefMut<'a, BaseDocument>),
134
+ RwLock(RwLockWriteGuard<'a, BaseDocument>),
135
+ Mutex(MutexGuard<'a, BaseDocument>),
136
+ }
137
+
138
+ impl Deref for DocGuardMut<'_> {
139
+ type Target = BaseDocument;
140
+ #[inline(always)]
141
+ fn deref(&self) -> &Self::Target {
142
+ match self {
143
+ Self::Ref(base_document) => base_document,
144
+ Self::RefCell(refcell_guard) => refcell_guard,
145
+ Self::RwLock(rw_lock_read_guard) => rw_lock_read_guard,
146
+ Self::Mutex(mutex_guard) => mutex_guard,
147
+ }
148
+ }
149
+ }
150
+
151
+ impl DerefMut for DocGuardMut<'_> {
152
+ #[inline(always)]
153
+ fn deref_mut(&mut self) -> &mut Self::Target {
154
+ match self {
155
+ Self::Ref(base_document) => base_document,
156
+ Self::RefCell(refcell_guard) => &mut *refcell_guard,
157
+ Self::RwLock(rw_lock_read_guard) => &mut *rw_lock_read_guard,
158
+ Self::Mutex(mutex_guard) => &mut *mutex_guard,
159
+ }
160
+ }
161
+ }
162
+
163
+ /// Abstraction over wrappers around [`BaseDocument`] to allow for them all to
164
+ /// be driven by [`blitz-shell`](https://docs.rs/blitz-shell)
165
+ pub trait Document: Any + 'static {
166
+ fn inner(&self) -> DocGuard<'_>;
167
+ fn inner_mut(&mut self) -> DocGuardMut<'_>;
168
+
169
+ /// Update the [`Document`] in response to a [`UiEvent`] (click, keypress, etc)
170
+ fn handle_ui_event(&mut self, event: UiEvent) {
171
+ let mut doc = self.inner_mut();
172
+ let mut driver = EventDriver::new(&mut *doc, NoopEventHandler);
173
+ driver.handle_ui_event(event);
174
+ }
175
+
176
+ /// Poll any pending async operations, and flush changes to the underlying [`BaseDocument`]
177
+ fn poll(&mut self, task_context: Option<TaskContext>) -> bool {
178
+ // Default implementation does nothing
179
+ let _ = task_context;
180
+ false
181
+ }
182
+
183
+ /// Get the [`Document`]'s id
184
+ fn id(&self) -> usize {
185
+ self.inner().id
186
+ }
187
+ }
188
+
189
+ pub struct PlainDocument(pub BaseDocument);
190
+ impl Document for PlainDocument {
191
+ fn inner(&self) -> DocGuard<'_> {
192
+ DocGuard::Ref(&self.0)
193
+ }
194
+ fn inner_mut(&mut self) -> DocGuardMut<'_> {
195
+ DocGuardMut::Ref(&mut self.0)
196
+ }
197
+ }
198
+
199
+ impl Document for BaseDocument {
200
+ fn inner(&self) -> DocGuard<'_> {
201
+ DocGuard::Ref(self)
202
+ }
203
+ fn inner_mut(&mut self) -> DocGuardMut<'_> {
204
+ DocGuardMut::Ref(self)
205
+ }
206
+ }
207
+
208
+ impl Document for Rc<RefCell<BaseDocument>> {
209
+ fn inner(&self) -> DocGuard<'_> {
210
+ DocGuard::RefCell(self.borrow())
211
+ }
212
+
213
+ fn inner_mut(&mut self) -> DocGuardMut<'_> {
214
+ DocGuardMut::RefCell(self.borrow_mut())
215
+ }
216
+ }
217
+
218
+ pub enum DocumentEvent {
219
+ ResourceLoad(ResourceLoadResponse),
220
+ /// A navigation originating from within an iframe's sub-document
221
+ /// (e.g. a link click), to be applied to the iframe identified by `node_id`.
222
+ NavigateIframe {
223
+ node_id: NodeId,
224
+ url: Url,
225
+ },
226
+ }
227
+
228
+ pub struct BaseDocument {
229
+ /// ID of the document
230
+ id: usize,
231
+
232
+ // Config
233
+ /// Base url for resolving linked resources (stylesheets, images, fonts, etc)
234
+ pub(crate) url: DocumentUrl,
235
+ // Devtool settings. Currently used to render debug overlays
236
+ pub(crate) devtool_settings: DevtoolSettings,
237
+ // Viewport details such as the dimensions, HiDPI scale, and zoom factor,
238
+ pub(crate) viewport: Viewport,
239
+ pub(crate) layout_viewport: Option<(f32, f32)>,
240
+ pub(crate) round_layout: bool,
241
+ /// CSS media type used to evaluate `@media` rules.
242
+ pub(crate) media_type: MediaType,
243
+ /// Changes to the stylist [`Device`] that have been requested since the
244
+ /// last [`resolve`](Self::resolve), to be applied (coalesced into a single
245
+ /// device rebuild) at the start of the next resolve.
246
+ pub(crate) pending_device_changes: DeviceChanges,
247
+ /// Strategy for Stylo's style traversal during `resolve`.
248
+ pub(crate) style_threading: StyleThreading,
249
+ /// Whether incremental layout is enabled for this document. When disabled,
250
+ /// every node is treated as damaged on every `resolve`, so the same damage
251
+ /// pipeline reconstructs and relays out the whole tree.
252
+ pub(crate) incremental_layout: bool,
253
+ /// How deeply this document is nested within other documents
254
+ /// (0 for a root document). Used to limit `<iframe>` nesting depth.
255
+ pub(crate) subdocument_depth: usize,
256
+
257
+ // Events
258
+ pub(crate) tx: Sender<DocumentEvent>,
259
+ // rx will always be Some, except temporarily while processing events
260
+ pub(crate) rx: Option<Receiver<DocumentEvent>>,
261
+
262
+ /// A slotmap-backed tree of nodes
263
+ ///
264
+ /// We pin the tree to a guarantee to the nodes it creates that the tree is stable in memory.
265
+ /// There is no way to create the tree - publicly or privately - that would invalidate that invariant.
266
+ pub(crate) nodes: Box<NodeTree>,
267
+
268
+ /// The id of the root node (a Document node)
269
+ pub(crate) root_node_id: NodeId,
270
+
271
+ // Stylo
272
+ /// The Stylo engine
273
+ pub(crate) stylist: Stylist,
274
+ pub(crate) animations: DocumentAnimationSet,
275
+ /// Stylo shared lock
276
+ pub(crate) guard: SharedRwLock,
277
+ /// Stylo invalidation map. We insert into this map prior to mutating nodes.
278
+ pub(crate) snapshots: SnapshotMap,
279
+
280
+ // Parley contexts
281
+ /// A Parley font context
282
+ pub(crate) font_ctx: Arc<Mutex<parley::FontContext>>,
283
+ #[cfg(feature = "parallel-construct")]
284
+ /// Thread-and-document-local copies to the font context
285
+ pub(crate) thread_font_contexts: ThreadLocal<RefCell<Box<FontContext>>>,
286
+ /// A Parley layout context
287
+ pub(crate) layout_ctx: parley::LayoutContext<TextBrush>,
288
+
289
+ /// The real (non-anonymous) node which is currently hovered (if any).
290
+ /// This is never a layout-generated (anonymous) node, so it remains valid
291
+ /// across box-tree reconstruction.
292
+ pub(crate) hover_node_id: Option<NodeId>,
293
+ /// The precise (may be anonymous) layout node under the pointer (if any).
294
+ /// This can be invalidated by box-tree reconstruction, and is re-resolved against
295
+ /// fresh layout at the end of every `resolve` pass.
296
+ pub(crate) hover_hit_node_id: Option<NodeId>,
297
+ /// Whether the node which is currently hovered is a text node/span
298
+ pub(crate) hover_node_is_text: bool,
299
+ /// The last known pointer position in client coordinates (viewport-relative, unscrolled).
300
+ pub(crate) last_client_pointer_position: Option<taffy::Point<f32>>,
301
+ /// The node which is currently focussed (if any)
302
+ pub(crate) focus_node_id: Option<NodeId>,
303
+ /// The node which is currently active (if any)
304
+ pub(crate) active_node_id: Option<NodeId>,
305
+ /// The node which recieved a mousedown event (if any)
306
+ pub(crate) mousedown_node_id: Option<NodeId>,
307
+ /// The last time a mousedown was made (for double-click detection)
308
+ pub(crate) last_mousedown_time: Option<Instant>,
309
+ /// The position where mousedown occurred (for selection drags and double-click detection)
310
+ pub(crate) mousedown_position: taffy::Point<f32>,
311
+ /// How many clicks have been made in quick succession
312
+ pub(crate) click_count: u16,
313
+ /// Whether we're currently in a text selection drag (moved 2px+ from mousedown)
314
+ pub(crate) drag_mode: DragMode,
315
+ /// The scrollbar thumb currently under the pointer, if any
316
+ pub(crate) hovered_scrollbar: Option<crate::node::ScrollbarRef>,
317
+ /// When each scroll container's overlay scrollbars were last shown
318
+ /// (scrolled, or the pointer left the thumb); drives their fade-out
319
+ pub(crate) scrollbar_activity: HashMap<NodeId, Instant>,
320
+ /// Whether and what kind of scroll animation is currently in progress
321
+ pub(crate) scroll_animation: ScrollAnimationState,
322
+
323
+ /// Text selection state (for non-input text)
324
+ pub(crate) text_selection: TextSelection,
325
+
326
+ // TODO: collapse animating state into a bitflags
327
+ /// Whether there are active CSS animations/transitions (so we should re-render every frame)
328
+ pub(crate) has_active_animations: bool,
329
+ /// Whether there is a `<canvas>` element in the DOM (so we should re-render every frame)
330
+ pub(crate) has_canvas: bool,
331
+ /// Whether there are subdocuments that are animating (so we should re-render every frame)
332
+ pub(crate) subdoc_is_animating: bool,
333
+
334
+ /// Map of id attribute values to node IDs for fast lookups.
335
+ /// May contain multiple nodes for the same id: `get_element_by_id`
336
+ /// returns the first in tree order.
337
+ pub(crate) nodes_to_id: HashMap<String, SmallVec<[NodeId; 1]>>,
338
+ /// Map of `<style>` and `<link>` node IDs to their associated stylesheet
339
+ pub(crate) nodes_to_stylesheet: BTreeMap<NodeId, DocumentStyleSheet>,
340
+ /// Incremented whenever `nodes_to_stylesheet` changes (a stylesheet is
341
+ /// added, replaced or removed), so that CSSOM wrappers can detect that
342
+ /// their cached rule data is stale.
343
+ pub(crate) stylesheet_generation: u64,
344
+ /// Map from form control node ID's to their associated forms node ID's
345
+ pub(crate) controls_to_form: HashMap<NodeId, NodeId>,
346
+ /// Nodes that contain sub documents
347
+ pub(crate) sub_document_nodes: HashSet<NodeId>,
348
+ /// Load state (abort controller and in-flight request id) for each
349
+ /// `<iframe>` element whose sub-document is loaded automatically
350
+ pub(crate) iframe_loads: HashMap<NodeId, crate::iframe::IframeLoad>,
351
+ /// Set of changed nodes for updating the accessibility tree
352
+ pub(crate) changed_nodes: HashSet<NodeId>,
353
+ /// Set of changed nodes for updating the accessibility tree
354
+ pub(crate) deferred_construction_nodes: Vec<ConstructionTask>,
355
+
356
+ /// Nodes that contain custom widgets
357
+ #[cfg(feature = "custom-widget")]
358
+ pub(crate) custom_widget_nodes: HashSet<NodeId>,
359
+ /// Rendering resources allocated by custom widgets that should be deallocated during the next render
360
+ #[cfg(feature = "custom-widget")]
361
+ pub(crate) pending_resource_deallocations: Vec<anyrender::ResourceId>,
362
+
363
+ /// Cache of loaded images, keyed by URL. Allows reusing images across multiple
364
+ /// elements without re-fetching from the network.
365
+ pub(crate) image_cache: HashMap<String, ImageData>,
366
+
367
+ /// Tracks in-flight image requests. When an image is being fetched, additional
368
+ /// requests for the same URL are queued here instead of starting new fetches.
369
+ /// Value is a list of (node_id, image_type) pairs waiting for the image.
370
+ pub(crate) pending_images: HashMap<String, Vec<(NodeId, ImageType)>>,
371
+
372
+ /// Nodes whose `background-image`/`mask-image` layers need flushing to
373
+ /// dedicated storage on the node because their style changed (populated by
374
+ /// the style traversal and by pseudo-element box construction).
375
+ pub(crate) pending_style_image_nodes: Vec<NodeId>,
376
+
377
+ // Tracks in-flight "critical" resources (e.g. stylesheets linked from the `<head>`),
378
+ // keyed by request id
379
+ pub(crate) pending_critical_resources: HashSet<usize>,
380
+
381
+ // Service providers
382
+ /// Network provider. Can be used to fetch assets.
383
+ pub net_provider: Arc<dyn NetProvider>,
384
+ /// Navigation provider. Can be used to navigate to a new page (bubbles up the event
385
+ /// on e.g. clicking a Link)
386
+ pub navigation_provider: Arc<dyn NavigationProvider>,
387
+ /// Shell provider. Can be used to request a redraw or set the cursor icon
388
+ pub shell_provider: Arc<dyn ShellProvider>,
389
+ /// HTML parser provider. Used to parse HTML for setInnerHTML
390
+ pub html_parser_provider: Arc<dyn HtmlParserProvider>,
391
+ /// Carried on every sub-resource `Request` this document issues; aborting
392
+ /// it cancels all in-flight fetches tied to this document. Set via
393
+ /// [`DocumentConfig::abort_signal`].
394
+ pub(crate) abort_signal: Option<AbortSignal>,
395
+ }
396
+
397
+ impl BaseDocument {
398
+ /// Create a new (empty) [`BaseDocument`] with the specified configuration
399
+ pub fn new(config: DocumentConfig) -> Self {
400
+ static ID_GENERATOR: AtomicUsize = AtomicUsize::new(1);
401
+
402
+ let id = ID_GENERATOR.fetch_add(1, Ordering::SeqCst);
403
+
404
+ let font_ctx = config
405
+ .font_ctx
406
+ .map(|mut font_ctx| {
407
+ font_ctx.source_cache.make_shared();
408
+ // font_ctx.collection.make_shared();
409
+ font_ctx
410
+ })
411
+ .unwrap_or_else(|| {
412
+ use parley::fontique::{Collection, CollectionOptions, SourceCache};
413
+ let mut font_ctx = FontContext {
414
+ source_cache: SourceCache::new_shared(),
415
+ collection: Collection::new(CollectionOptions {
416
+ shared: false,
417
+ system_fonts: cfg!(all(
418
+ feature = "system-fonts",
419
+ not(target_arch = "wasm32")
420
+ )),
421
+ }),
422
+ };
423
+ font_ctx
424
+ .collection
425
+ .register_fonts(Blob::new(Arc::new(crate::BULLET_FONT) as _), None);
426
+ font_ctx
427
+ });
428
+ let font_ctx = Arc::new(Mutex::new(font_ctx));
429
+
430
+ // Make sure we turn on stylo features *before* creating the Stylist
431
+ style_config::set_pref!("layout.grid.enabled", true);
432
+ style_config::set_pref!("layout.flexbox.balance", true);
433
+ style_config::set_pref!("layout.unimplemented", true);
434
+ style_config::set_pref!("layout.columns.enabled", true);
435
+ style_config::set_pref!("layout.css.basic-shape-shape.enabled", true);
436
+ style_config::set_pref!("layout.css.tree-counting-functions.enabled", true);
437
+ style_config::set_pref!("layout.css.progress-function.enabled", true);
438
+ style_config::set_pref!("layout.variable_fonts.enabled", true);
439
+ #[cfg(feature = "writing-mode")]
440
+ style_config::set_pref!("layout.writing-mode.enabled", true);
441
+ style_config::set_pref!("layout.threads", -1);
442
+
443
+ let viewport = config.viewport.unwrap_or_default();
444
+ let media_type = config.media_type.unwrap_or_else(MediaType::screen);
445
+ let device = make_device(
446
+ &viewport,
447
+ config.layout_viewport,
448
+ media_type.clone(),
449
+ font_ctx.clone(),
450
+ );
451
+ let stylist = Stylist::new(device, QuirksMode::NoQuirks);
452
+ let snapshots = SnapshotMap::new();
453
+ let nodes = Box::new(NodeTree::new());
454
+ let guard = SharedRwLock::new();
455
+ let nodes_to_id = HashMap::new();
456
+
457
+ let base_url = config
458
+ .base_url
459
+ .and_then(|url| DocumentUrl::from_str(&url).ok())
460
+ .unwrap_or_default();
461
+
462
+ let net_provider = config
463
+ .net_provider
464
+ .unwrap_or_else(|| Arc::new(DummyNetProvider));
465
+ let navigation_provider = config
466
+ .navigation_provider
467
+ .unwrap_or_else(|| Arc::new(DummyNavigationProvider));
468
+ let shell_provider = config
469
+ .shell_provider
470
+ .unwrap_or_else(|| Arc::new(DummyShellProvider));
471
+ let html_parser_provider = config
472
+ .html_parser_provider
473
+ .unwrap_or_else(|| Arc::new(DummyHtmlParserProvider));
474
+
475
+ let (tx, rx) = channel();
476
+
477
+ let mut doc = Self {
478
+ id,
479
+ tx,
480
+ rx: Some(rx),
481
+
482
+ guard,
483
+ nodes,
484
+ root_node_id: NodeId::default(),
485
+ stylist,
486
+ animations: DocumentAnimationSet::default(),
487
+ snapshots,
488
+ nodes_to_id,
489
+ viewport,
490
+ layout_viewport: config.layout_viewport,
491
+ round_layout: config.round_layout,
492
+ media_type,
493
+ pending_device_changes: DeviceChanges::empty(),
494
+ style_threading: config.style_threading,
495
+ incremental_layout: config.incremental.unwrap_or(true),
496
+ subdocument_depth: config.subdocument_depth,
497
+ devtool_settings: DevtoolSettings::default(),
498
+ url: base_url,
499
+ nodes_to_stylesheet: BTreeMap::new(),
500
+ stylesheet_generation: 0,
501
+ font_ctx,
502
+ #[cfg(feature = "parallel-construct")]
503
+ thread_font_contexts: ThreadLocal::new(),
504
+ layout_ctx: parley::LayoutContext::new(),
505
+
506
+ hover_node_id: None,
507
+ hover_hit_node_id: None,
508
+ hover_node_is_text: false,
509
+ last_client_pointer_position: None,
510
+ focus_node_id: None,
511
+ active_node_id: None,
512
+ mousedown_node_id: None,
513
+ has_active_animations: false,
514
+ subdoc_is_animating: false,
515
+ has_canvas: false,
516
+ sub_document_nodes: HashSet::new(),
517
+ iframe_loads: HashMap::new(),
518
+
519
+ #[cfg(feature = "custom-widget")]
520
+ custom_widget_nodes: HashSet::new(),
521
+ #[cfg(feature = "custom-widget")]
522
+ pending_resource_deallocations: Vec::new(),
523
+
524
+ changed_nodes: HashSet::new(),
525
+ deferred_construction_nodes: Vec::new(),
526
+ image_cache: HashMap::new(),
527
+ pending_images: HashMap::new(),
528
+ pending_style_image_nodes: Vec::new(),
529
+ pending_critical_resources: HashSet::new(),
530
+ controls_to_form: HashMap::new(),
531
+ net_provider,
532
+ navigation_provider,
533
+ shell_provider,
534
+ html_parser_provider,
535
+ abort_signal: config.abort_signal,
536
+ last_mousedown_time: None,
537
+ mousedown_position: taffy::Point::ZERO,
538
+ click_count: 0,
539
+ drag_mode: DragMode::None,
540
+ hovered_scrollbar: None,
541
+ scrollbar_activity: HashMap::new(),
542
+ scroll_animation: ScrollAnimationState::None,
543
+ text_selection: TextSelection::default(),
544
+ };
545
+
546
+ // Initialise document with root Document node
547
+ doc.root_node_id = doc.create_node(NodeData::Document(Box::default()));
548
+ doc.root_node_mut().flags.insert(NodeFlags::IS_IN_DOCUMENT);
549
+
550
+ for sheet in config.ua_stylesheets {
551
+ doc.add_user_agent_stylesheet(sheet);
552
+ }
553
+
554
+ // Stylo data on the root node container is needed to render the node
555
+ let stylo_element_data = StyloElementData {
556
+ styles: ElementStyles {
557
+ primary: Some(
558
+ ComputedValues::initial_values_with_font_override(Font::initial_values())
559
+ .to_arc(),
560
+ ),
561
+ ..Default::default()
562
+ },
563
+ ..Default::default()
564
+ };
565
+ let stylo_data = doc.root_node_mut().stylo_element_data_mut();
566
+ *stylo_data.ensure_init_mut() = stylo_element_data;
567
+
568
+ doc
569
+ }
570
+
571
+ /// Set the Document's networking provider
572
+ pub fn set_net_provider(&mut self, net_provider: Arc<dyn NetProvider>) {
573
+ self.net_provider = net_provider;
574
+ }
575
+
576
+ /// Set the Document's navigation provider
577
+ pub fn set_navigation_provider(&mut self, navigation_provider: Arc<dyn NavigationProvider>) {
578
+ self.navigation_provider = navigation_provider;
579
+ }
580
+
581
+ /// Set the Document's shell provider
582
+ pub fn set_shell_provider(&mut self, shell_provider: Arc<dyn ShellProvider>) {
583
+ self.shell_provider = shell_provider;
584
+ }
585
+
586
+ /// Set the Document's html parser provider
587
+ pub fn set_html_parser_provider(&mut self, html_parser_provider: Arc<dyn HtmlParserProvider>) {
588
+ self.html_parser_provider = html_parser_provider;
589
+ }
590
+
591
+ /// Set base url for resolving linked resources (stylesheets, images, fonts, etc)
592
+ pub fn set_base_url(&mut self, url: &str) {
593
+ self.url = DocumentUrl::from(Url::parse(url).unwrap());
594
+ }
595
+
596
+ /// The base url used for resolving linked resources (stylesheets, images, fonts, etc)
597
+ pub fn base_url(&self) -> &Url {
598
+ &self.url
599
+ }
600
+
601
+ pub fn guard(&self) -> &SharedRwLock {
602
+ &self.guard
603
+ }
604
+
605
+ pub fn tree(&self) -> &NodeTree {
606
+ &self.nodes
607
+ }
608
+
609
+ pub fn id(&self) -> usize {
610
+ self.id
611
+ }
612
+
613
+ /// Wrapper around [`crate::net::stamped_request`]. Use the free function
614
+ /// when `&self` would conflict with a held `&mut` borrow on a field.
615
+ pub(crate) fn build_request(&self, url: url::Url) -> Request {
616
+ crate::net::stamped_request(url, self.abort_signal.as_ref())
617
+ }
618
+
619
+ pub fn favicon_url(&self) -> Option<String> {
620
+ self.tree().iter().find_map(|(_, node)| {
621
+ let data = &node.data;
622
+ if !data.is_element_with_tag_name(&local_name!("link")) {
623
+ return None;
624
+ }
625
+ let rel = data.attr(local_name!("rel"))?;
626
+ if !rel
627
+ .split_ascii_whitespace()
628
+ .any(|v| v.eq_ignore_ascii_case("icon"))
629
+ {
630
+ return None;
631
+ }
632
+ data.attr(local_name!("href")).map(|s| s.to_string())
633
+ })
634
+ }
635
+
636
+ pub fn get_node(&self, node_id: NodeId) -> Option<&Node> {
637
+ self.nodes.get(node_id)
638
+ }
639
+
640
+ pub fn get_node_mut(&mut self, node_id: NodeId) -> Option<&mut Node> {
641
+ self.nodes.get_mut(node_id)
642
+ }
643
+
644
+ pub fn get_focussed_node_id(&self) -> Option<NodeId> {
645
+ self.focus_node_id
646
+ .or(self.try_root_element().map(|el| el.id))
647
+ }
648
+
649
+ pub fn mutate<'doc>(&'doc mut self) -> DocumentMutator<'doc> {
650
+ DocumentMutator::new(self)
651
+ }
652
+
653
+ pub fn handle_dom_event<F: FnMut(DomEvent)>(
654
+ &mut self,
655
+ event: &mut DomEvent,
656
+ dispatch_event: F,
657
+ ) {
658
+ handle_dom_event(self, event, dispatch_event)
659
+ }
660
+
661
+ pub fn as_any_mut(&mut self) -> &mut dyn Any {
662
+ self
663
+ }
664
+
665
+ /// Find the label's bound input elements:
666
+ /// the element id referenced by the "for" attribute of a given label element
667
+ /// or the first input element which is nested in the label
668
+ /// Note that although there should only be one bound element,
669
+ /// we return all possibilities instead of just the first
670
+ /// in order to allow the caller to decide which one is correct
671
+ pub fn label_bound_input_element(&self, label_node_id: NodeId) -> Option<&Node> {
672
+ let label_element = self.nodes[label_node_id].element_data()?;
673
+ if let Some(target_element_dom_id) = label_element.attr(local_name!("for")) {
674
+ TreeTraverser::new(self)
675
+ .filter_map(|id| {
676
+ let node = self.get_node(id)?;
677
+ let element_data = node.element_data()?;
678
+ if element_data.name.local != local_name!("input") {
679
+ return None;
680
+ }
681
+ let id = element_data.id.as_ref()?;
682
+ if *id == *target_element_dom_id {
683
+ Some(node)
684
+ } else {
685
+ None
686
+ }
687
+ })
688
+ .next()
689
+ } else {
690
+ TreeTraverser::new_with_root(self, label_node_id)
691
+ .filter_map(|child_id| {
692
+ let node = self.get_node(child_id)?;
693
+ let element_data = node.element_data()?;
694
+ if element_data.name.local == local_name!("input") {
695
+ Some(node)
696
+ } else {
697
+ None
698
+ }
699
+ })
700
+ .next()
701
+ }
702
+ }
703
+
704
+ pub fn toggle_checkbox(el: &mut ElementData) -> bool {
705
+ let Some(is_checked) = el.checkbox_input_checked() else {
706
+ return false;
707
+ };
708
+ let checked = !is_checked;
709
+ el.set_checkbox_input_checked(checked);
710
+
711
+ checked
712
+ }
713
+
714
+ pub fn toggle_radio(&mut self, radio_set_name: String, target_radio_id: NodeId) {
715
+ let radio_ids: Vec<NodeId> = self
716
+ .nodes
717
+ .iter()
718
+ .filter_map(|(i, node)| {
719
+ let el = node.data.downcast_element()?;
720
+ (el.attr(local_name!("name")) == Some(&radio_set_name)
721
+ && el.checkbox_input_checked().is_some())
722
+ .then_some(i)
723
+ })
724
+ .collect();
725
+
726
+ for i in radio_ids {
727
+ let checked = i == target_radio_id;
728
+ self.snapshot_node_and(i, ElementState::CHECKED, |node| {
729
+ if let Some(el) = node.element_data_mut() {
730
+ el.set_checkbox_input_checked(checked);
731
+ }
732
+ node.mark_ancestors_dirty();
733
+ });
734
+ }
735
+ }
736
+
737
+ /// Toggle the `open` attribute of a `<details>` element, expanding or
738
+ /// collapsing it. This is the default action triggered when the element's
739
+ /// first `<summary>` child is activated.
740
+ pub fn toggle_details_open(&mut self, details_id: NodeId) {
741
+ use crate::qual_name;
742
+
743
+ let node = &self.nodes[details_id];
744
+ if !node.data.is_element_with_tag_name(&local_name!("details")) {
745
+ return;
746
+ }
747
+ let is_open = node.data.has_attr(local_name!("open"));
748
+
749
+ // Note: HTML attributes are in the empty (null) namespace, so the
750
+ // QualName must not use the html namespace here, else it won't match
751
+ // an `open` attribute created by the HTML parser.
752
+ let mut mutator = self.mutate();
753
+ if is_open {
754
+ mutator.clear_attribute(details_id, qual_name!("open"));
755
+ } else {
756
+ mutator.set_attribute(details_id, qual_name!("open"), "");
757
+ }
758
+ drop(mutator);
759
+
760
+ self.shell_provider.request_redraw();
761
+ }
762
+
763
+ pub fn set_style_property(&mut self, node_id: NodeId, name: &str, value: &str) {
764
+ let node = &mut self.nodes[node_id];
765
+ let did_change = node.element_data_mut().unwrap().set_style_property(
766
+ name,
767
+ value,
768
+ &self.guard,
769
+ self.url.url_extra_data(),
770
+ );
771
+ if did_change {
772
+ node.set_restyle_hint(RestyleHint::RESTYLE_STYLE_ATTRIBUTE);
773
+ }
774
+ }
775
+
776
+ pub fn remove_style_property(&mut self, node_id: NodeId, name: &str) {
777
+ let node = &mut self.nodes[node_id];
778
+ let did_change = node.element_data_mut().unwrap().remove_style_property(
779
+ name,
780
+ &self.guard,
781
+ self.url.url_extra_data(),
782
+ );
783
+ if did_change {
784
+ node.set_restyle_hint(RestyleHint::RESTYLE_STYLE_ATTRIBUTE);
785
+ }
786
+ }
787
+
788
+ pub fn sub_document_node_ids(&self) -> Vec<NodeId> {
789
+ self.sub_document_nodes.iter().copied().collect()
790
+ }
791
+
792
+ pub fn set_sub_document(&mut self, node_id: NodeId, sub_document: Box<dyn Document>) {
793
+ self.nodes[node_id]
794
+ .element_data_mut()
795
+ .unwrap()
796
+ .set_sub_document(sub_document);
797
+ self.sub_document_nodes.insert(node_id);
798
+ }
799
+
800
+ pub fn remove_sub_document(&mut self, node_id: NodeId) {
801
+ self.nodes[node_id]
802
+ .element_data_mut()
803
+ .unwrap()
804
+ .remove_sub_document();
805
+ self.sub_document_nodes.remove(&node_id);
806
+ if let Some(load) = self.iframe_loads.remove(&node_id) {
807
+ load.abort_controller.abort();
808
+ }
809
+ }
810
+
811
+ /// Poll all sub-documents (see [`Document::poll`]), allowing them to make progress
812
+ /// on any pending async operations (e.g. JavaScript timers). Hosts which poll a
813
+ /// wrapper around a [`BaseDocument`] should call this from their `poll` implementation.
814
+ ///
815
+ /// Returns `true` if any sub-document reported changes.
816
+ pub fn poll_subdocuments(&mut self, waker: Option<&Waker>) -> bool {
817
+ let mut has_changes = false;
818
+ for node_id in self.sub_document_nodes.iter().copied() {
819
+ let Some(sub_doc) = self
820
+ .nodes
821
+ .get_mut(node_id)
822
+ .and_then(|node| node.subdoc_mut())
823
+ else {
824
+ continue;
825
+ };
826
+ let task_context = waker.map(TaskContext::from_waker);
827
+ has_changes |= sub_doc.poll(task_context);
828
+ }
829
+ has_changes
830
+ }
831
+
832
+ #[cfg(feature = "custom-widget")]
833
+ pub fn custom_widget_node_ids(&self) -> Vec<NodeId> {
834
+ self.custom_widget_nodes.iter().copied().collect()
835
+ }
836
+
837
+ #[cfg(feature = "custom-widget")]
838
+ pub fn take_pending_resource_deallocations(&mut self) -> Vec<anyrender::ResourceId> {
839
+ std::mem::take(&mut self.pending_resource_deallocations)
840
+ }
841
+
842
+ #[cfg(feature = "custom-widget")]
843
+ pub fn set_custom_widget(&mut self, node_id: NodeId, widget: Box<dyn crate::Widget>) {
844
+ self.nodes[node_id]
845
+ .element_data_mut()
846
+ .unwrap()
847
+ .set_custom_widget(widget);
848
+ self.custom_widget_nodes.insert(node_id);
849
+ }
850
+
851
+ #[cfg(feature = "custom-widget")]
852
+ pub fn remove_custom_widget(&mut self, node_id: NodeId) {
853
+ let resources_to_deallocate = self.nodes[node_id]
854
+ .element_data_mut()
855
+ .unwrap()
856
+ .remove_custom_widget();
857
+ self.pending_resource_deallocations
858
+ .extend_from_slice(&resources_to_deallocate);
859
+ self.custom_widget_nodes.remove(&node_id);
860
+ }
861
+
862
+ pub fn root_node(&self) -> &Node {
863
+ &self.nodes[self.root_node_id]
864
+ }
865
+
866
+ pub fn root_node_mut(&mut self) -> &mut Node {
867
+ &mut self.nodes[self.root_node_id]
868
+ }
869
+
870
+ pub fn try_root_element(&self) -> Option<&Node> {
871
+ TDocument::as_node(&self.root_node()).first_element_child()
872
+ }
873
+
874
+ pub fn root_element(&self) -> &Node {
875
+ TDocument::as_node(&self.root_node())
876
+ .first_element_child()
877
+ .unwrap()
878
+ .as_element()
879
+ .unwrap()
880
+ }
881
+
882
+ pub fn create_node(&mut self, node_data: NodeData) -> NodeId {
883
+ let tree_ptr = self.nodes.as_mut() as *mut NodeTree;
884
+ let guard = self.guard.clone();
885
+
886
+ let id = self
887
+ .nodes
888
+ .insert_with_key(|id| Node::new(tree_ptr, id, guard, node_data));
889
+
890
+ // Mark the new node as changed.
891
+ self.changed_nodes.insert(id);
892
+ id
893
+ }
894
+
895
+ /// Remove a node from the node tree, clearing any interaction state
896
+ /// (hover/active/focus/mousedown/selection/drag/scrollbar) that references
897
+ /// it so that stale NodeIds are never dereferenced after the slot is freed.
898
+ pub(crate) fn remove_node_from_tree(&mut self, node_id: NodeId) -> Option<Node> {
899
+ self.clear_interaction_state_for_removed_node(node_id);
900
+ self.nodes.remove(node_id)
901
+ }
902
+
903
+ /// The nearest element ancestor of `node_id` that is still in the
904
+ /// document. Used to retarget hover/active state when the node they
905
+ /// reference is removed. Tolerates already-removed ancestors (subtree
906
+ /// teardown proceeds root-first) by giving up and returning `None`.
907
+ fn nearest_surviving_element_ancestor(&self, node_id: NodeId) -> Option<NodeId> {
908
+ let mut current = self.get_node(node_id)?.parent;
909
+ while let Some(id) = current {
910
+ let node = self.get_node(id)?;
911
+ if node.is_element() && node.flags.is_in_document() {
912
+ return Some(id);
913
+ }
914
+ current = node.parent;
915
+ }
916
+ None
917
+ }
918
+
919
+ /// Clear any interaction state (hover/active/focus/mousedown/selection/
920
+ /// drag/scrollbar) that references `node_id`, which is being removed from
921
+ /// the document, running the usual teardown steps. `node_id` must still be
922
+ /// present in the slab.
923
+ ///
924
+ /// This matches browser semantics (WebKit `hoveredElementDidDetach` /
925
+ /// `elementInActiveChainDidDetach`, Blink `HoveredElementDetached` /
926
+ /// `ActiveChainNodeDetached`):
927
+ /// - Hover and active retarget to the nearest surviving element ancestor
928
+ /// as a *transient bridge*: the HOVER/ACTIVE element-state bits along
929
+ /// the surviving chain stay lit (no one-frame gap in `:hover`/`:active`
930
+ /// styling), and the subsequent hover diff can unset exactly the right
931
+ /// bits. Hover is then re-resolved against the pointer position by
932
+ /// [`Self::refresh_hover`] at the end of the next resolve pass (the
933
+ /// analogue of WebKit's "fake mouse move"), which corrects the bridge
934
+ /// value — including cases where the removed node overflowed its
935
+ /// ancestor's box, so the ancestor was never truly under the pointer.
936
+ /// - Focus resets to the body (encoded as `None`), running blur
937
+ /// side-effects (clearing focus element state and disabling IME for
938
+ /// text inputs).
939
+ pub(crate) fn clear_interaction_state_for_removed_node(&mut self, node_id: NodeId) {
940
+ if !self.nodes.contains_key(node_id) {
941
+ return;
942
+ }
943
+
944
+ if self.hover_node_id == Some(node_id) {
945
+ self.hover_node_id = self.nearest_surviving_element_ancestor(node_id);
946
+ self.hover_node_is_text = false;
947
+ }
948
+ if self.hover_hit_node_id == Some(node_id) {
949
+ self.hover_hit_node_id = None;
950
+ }
951
+ if self.active_node_id == Some(node_id) {
952
+ self.active_node_id = self.nearest_surviving_element_ancestor(node_id);
953
+ }
954
+ if self.focus_node_id == Some(node_id) {
955
+ let shell_provider = self.shell_provider.clone();
956
+ self.nodes[node_id].blur(shell_provider);
957
+ self.focus_node_id = None;
958
+ }
959
+ if self.mousedown_node_id == Some(node_id) {
960
+ self.mousedown_node_id = None;
961
+ }
962
+ if self.text_selection.anchor.node_or_parent == Some(node_id)
963
+ || self.text_selection.focus.node_or_parent == Some(node_id)
964
+ {
965
+ self.text_selection.clear();
966
+ }
967
+ if self
968
+ .hovered_scrollbar
969
+ .is_some_and(|scrollbar| scrollbar.node_id == node_id)
970
+ {
971
+ self.hovered_scrollbar = None;
972
+ }
973
+ let drag_references_node = match &self.drag_mode {
974
+ DragMode::Panning(state) => state.target == node_id,
975
+ DragMode::ScrollbarDrag(state) => state.scrollbar.node_id == node_id,
976
+ DragMode::Selecting | DragMode::None => false,
977
+ };
978
+ if drag_references_node {
979
+ self.drag_mode = DragMode::None;
980
+ }
981
+ self.scrollbar_activity.remove(&node_id);
982
+ }
983
+
984
+ pub(crate) fn drop_node_ignoring_parent(&mut self, node_id: NodeId) -> Option<Node> {
985
+ self.drop_node_ignoring_parent_with(node_id, &mut |_| {})
986
+ }
987
+
988
+ /// Like [`Self::drop_node_ignoring_parent`], but calls `on_drop` with the id of
989
+ /// every dropped node (the node itself and all of its descendants).
990
+ pub(crate) fn drop_node_ignoring_parent_with(
991
+ &mut self,
992
+ node_id: NodeId,
993
+ on_drop: &mut dyn FnMut(NodeId),
994
+ ) -> Option<Node> {
995
+ let mut node = self.remove_node_from_tree(node_id);
996
+ if let Some(node) = &mut node {
997
+ on_drop(node_id);
998
+ if let Some(before) = node.before() {
999
+ self.drop_node_ignoring_parent_with(before, on_drop);
1000
+ }
1001
+ if let Some(after) = node.after() {
1002
+ self.drop_node_ignoring_parent_with(after, on_drop);
1003
+ }
1004
+
1005
+ for &child in &node.children {
1006
+ self.drop_node_ignoring_parent_with(child, on_drop);
1007
+ }
1008
+
1009
+ // Anonymous blocks live only in the slab, so deallocate the ones this
1010
+ // node owns rather than leaking them.
1011
+ for &anon_id in &node.anonymous_blocks {
1012
+ self.deallocate_anonymous_block(anon_id);
1013
+ }
1014
+ }
1015
+ node
1016
+ }
1017
+
1018
+ /// Deallocate an anonymous block created in a previous construction
1019
+ /// round, along with any anonymous blocks nested within it.
1020
+ pub(crate) fn deallocate_anonymous_block(&mut self, anon_id: NodeId) {
1021
+ // The block may already have been removed from the slab (e.g. a
1022
+ // whitespace-only anonymous block dropped during construction).
1023
+ if !self.nodes.contains_key(anon_id) {
1024
+ return;
1025
+ }
1026
+
1027
+ // Free any anonymous blocks that this block owns before removing it.
1028
+ let nested = std::mem::take(&mut self.nodes[anon_id].anonymous_blocks);
1029
+ for nested_id in nested {
1030
+ self.deallocate_anonymous_block(nested_id);
1031
+ }
1032
+
1033
+ self.remove_node_from_tree(anon_id);
1034
+ }
1035
+
1036
+ /// Whether the document has been mutated
1037
+ pub fn has_changes(&self) -> bool {
1038
+ self.changed_nodes.is_empty()
1039
+ }
1040
+
1041
+ pub fn create_text_node(&mut self, text: &str) -> NodeId {
1042
+ let content = text.to_string();
1043
+ let data = NodeData::Text(TextNodeData::new(content));
1044
+ self.create_node(data)
1045
+ }
1046
+
1047
+ pub fn deep_clone_node(&mut self, node_id: NodeId) -> NodeId {
1048
+ // Load existing node
1049
+ let node = &self.nodes[node_id];
1050
+ let mut data = node.data.clone();
1051
+
1052
+ match &mut data {
1053
+ NodeData::Element(elem) | NodeData::AnonymousBlock(elem) => {
1054
+ if let Some(arc) = elem.style_attribute.as_mut() {
1055
+ let read_guard = self.guard().read();
1056
+ let block = arc.read_with(&read_guard);
1057
+ *arc = ServoArc::new(self.guard().wrap(block.clone()));
1058
+ }
1059
+ }
1060
+ _ => {}
1061
+ }
1062
+
1063
+ let children = node.children.clone();
1064
+
1065
+ // Create new node
1066
+ let new_node_id = self.create_node(data);
1067
+
1068
+ // Recursively clone children
1069
+ let new_children: ThinVec<NodeId> = children
1070
+ .into_iter()
1071
+ .map(|child_id| self.deep_clone_node(child_id))
1072
+ .collect();
1073
+ for &child_id in &new_children {
1074
+ self.nodes[child_id].parent = Some(new_node_id);
1075
+ }
1076
+ self.nodes[new_node_id].children = new_children;
1077
+
1078
+ new_node_id
1079
+ }
1080
+
1081
+ pub(crate) fn remove_and_drop_pe(&mut self, node_id: NodeId) -> Option<Node> {
1082
+ fn remove_pe_ignoring_parent(doc: &mut BaseDocument, node_id: NodeId) -> Option<Node> {
1083
+ let mut node = doc.remove_node_from_tree(node_id);
1084
+ if let Some(node) = &mut node {
1085
+ for &child in &node.children {
1086
+ remove_pe_ignoring_parent(doc, child);
1087
+ }
1088
+ for &anon_id in &node.anonymous_blocks {
1089
+ doc.deallocate_anonymous_block(anon_id);
1090
+ }
1091
+ }
1092
+ node
1093
+ }
1094
+
1095
+ let node = remove_pe_ignoring_parent(self, node_id);
1096
+
1097
+ // Update child_idx values
1098
+ if let Some(parent_id) = node.as_ref().and_then(|node| node.parent) {
1099
+ let parent = &mut self.nodes[parent_id];
1100
+ parent.children.retain(|id| *id != node_id);
1101
+ }
1102
+
1103
+ node
1104
+ }
1105
+
1106
+ pub(crate) fn resolve_url(&self, raw: &str) -> url::Url {
1107
+ self.url.resolve_relative(raw).unwrap_or_else(|| {
1108
+ panic!(
1109
+ "to be able to resolve {raw} with the base_url: {:?}",
1110
+ *self.url
1111
+ )
1112
+ })
1113
+ }
1114
+
1115
+ pub fn print_tree(&self) {
1116
+ crate::util::walk_tree(0, self.root_node());
1117
+ }
1118
+
1119
+ pub fn print_subtree(&self, node_id: NodeId) {
1120
+ crate::util::walk_tree(0, &self.nodes[node_id]);
1121
+ }
1122
+
1123
+ pub fn reload_resource_by_href(&mut self, href_to_reload: &str) {
1124
+ for &node_id in self.nodes_to_stylesheet.keys() {
1125
+ let node = &self.nodes[node_id];
1126
+ let Some(element) = node.element_data() else {
1127
+ continue;
1128
+ };
1129
+
1130
+ if element.name.local == local_name!("link") {
1131
+ if let Some(href) = element.attr(local_name!("href")) {
1132
+ // println!("Node {node_id} {href} {href_to_reload} {} {}", resolved_href.as_str(), resolved_href.as_str() == url_to_reload);
1133
+ if href == href_to_reload {
1134
+ let resolved_href = self.resolve_url(href);
1135
+ self.net_provider.fetch(
1136
+ self.id(),
1137
+ self.build_request(resolved_href.clone()),
1138
+ ResourceHandler::boxed(
1139
+ self.tx.clone(),
1140
+ self.id,
1141
+ Some(node_id),
1142
+ self.shell_provider.clone(),
1143
+ StylesheetHandler {
1144
+ source_url: resolved_href,
1145
+ guard: self.guard.clone(),
1146
+ net_provider: self.net_provider.clone(),
1147
+ abort_signal: self.abort_signal.clone(),
1148
+ },
1149
+ ),
1150
+ );
1151
+ }
1152
+ }
1153
+ }
1154
+ }
1155
+ }
1156
+
1157
+ pub fn process_style_element(&mut self, target_id: NodeId) {
1158
+ let css = self.nodes[target_id].text_content();
1159
+ let css = html_escape::decode_html_entities(&css);
1160
+ let sheet = self.make_stylesheet(&css, Origin::Author);
1161
+ self.add_stylesheet_for_node(sheet, target_id);
1162
+ }
1163
+
1164
+ pub fn remove_user_agent_stylesheet(&mut self, sheet: &UserAgentStylesheet) {
1165
+ if self.useragent_stylesheets().any(|s| *s == sheet.0) {
1166
+ self.stylist
1167
+ .remove_stylesheet(sheet.0.clone(), &self.guard.read());
1168
+ }
1169
+ }
1170
+
1171
+ /// The document's base URL
1172
+ pub fn url(&self) -> &url::Url {
1173
+ &self.url
1174
+ }
1175
+
1176
+ /// Iterate over the author stylesheets (from `<style>` and `<link>` nodes)
1177
+ /// currently associated with this document
1178
+ pub fn author_stylesheets(&self) -> impl Iterator<Item = &DocumentStyleSheet> {
1179
+ self.nodes_to_stylesheet.values()
1180
+ }
1181
+
1182
+ /// Iterate over the user-agent stylesheets currently associated with this document
1183
+ pub fn useragent_stylesheets(&self) -> impl Iterator<Item = &DocumentStyleSheet> {
1184
+ (0..self.stylist.sheet_count(Origin::UserAgent))
1185
+ .filter_map(|i| self.stylist.sheet_at(Origin::UserAgent, i))
1186
+ }
1187
+
1188
+ /// Append a user-agent stylesheet. Adding a sheet that is already present is a no-op.
1189
+ pub fn add_user_agent_stylesheet(&mut self, sheet: UserAgentStylesheet) {
1190
+ if self.useragent_stylesheets().any(|s| *s == sheet.0) {
1191
+ return;
1192
+ }
1193
+ self.stylist.append_stylesheet(sheet.0, &self.guard.read());
1194
+ }
1195
+
1196
+ pub fn make_stylesheet(&self, css: impl AsRef<str>, origin: Origin) -> DocumentStyleSheet {
1197
+ let data = Stylesheet::from_str(
1198
+ css.as_ref(),
1199
+ self.url.url_extra_data(),
1200
+ origin,
1201
+ ServoArc::new(self.guard.wrap(MediaList::empty())),
1202
+ self.guard.clone(),
1203
+ Some(&StylesheetLoader {
1204
+ tx: self.tx.clone(),
1205
+ doc_id: self.id,
1206
+ net_provider: self.net_provider.clone(),
1207
+ shell_provider: self.shell_provider.clone(),
1208
+ abort_signal: self.abort_signal.clone(),
1209
+ import_depth: 0,
1210
+ }),
1211
+ None,
1212
+ QuirksMode::NoQuirks,
1213
+ AllowImportRules::Yes,
1214
+ );
1215
+
1216
+ DocumentStyleSheet(ServoArc::new(data))
1217
+ }
1218
+
1219
+ pub fn upsert_stylesheet_for_node(&mut self, node_id: NodeId) {
1220
+ let raw_styles = self.nodes[node_id].text_content();
1221
+ let sheet = self.make_stylesheet(raw_styles, Origin::Author);
1222
+ self.add_stylesheet_for_node(sheet, node_id);
1223
+ }
1224
+
1225
+ pub fn add_stylesheet_for_node(&mut self, stylesheet: DocumentStyleSheet, node_id: NodeId) {
1226
+ let old = self.nodes_to_stylesheet.insert(node_id, stylesheet.clone());
1227
+ self.stylesheet_generation += 1;
1228
+
1229
+ if let Some(old) = old {
1230
+ self.stylist.remove_stylesheet(old, &self.guard.read())
1231
+ }
1232
+
1233
+ // Fetch @font-face fonts
1234
+ crate::net::fetch_font_face(
1235
+ self.tx.clone(),
1236
+ self.id,
1237
+ Some(node_id),
1238
+ &stylesheet.0,
1239
+ &self.net_provider,
1240
+ &self.shell_provider,
1241
+ &self.guard.read(),
1242
+ self.abort_signal.as_ref(),
1243
+ );
1244
+
1245
+ // Store data on element
1246
+ let element = &mut self.nodes[node_id].element_data_mut().unwrap();
1247
+ element.special_data = SpecialElementData::Stylesheet(stylesheet.clone());
1248
+
1249
+ // TODO: Nodes could potentially get reused so ordering by node_id might be wrong.
1250
+ let insertion_point = self
1251
+ .nodes_to_stylesheet
1252
+ .range((Bound::Excluded(node_id), Bound::Unbounded))
1253
+ .next()
1254
+ .map(|(_, sheet)| sheet);
1255
+
1256
+ if let Some(insertion_point) = insertion_point {
1257
+ self.stylist.insert_stylesheet_before(
1258
+ stylesheet,
1259
+ insertion_point.clone(),
1260
+ &self.guard.read(),
1261
+ )
1262
+ } else {
1263
+ self.stylist
1264
+ .append_stylesheet(stylesheet, &self.guard.read())
1265
+ }
1266
+ }
1267
+
1268
+ pub fn handle_messages(&mut self) {
1269
+ // Remove event Reciever from the Document so that we can process events
1270
+ // without holding a borrow to the Document
1271
+ let rx = self.rx.take().unwrap();
1272
+
1273
+ while let Ok(msg) = rx.try_recv() {
1274
+ self.handle_message(msg);
1275
+ }
1276
+
1277
+ // Put Reciever back
1278
+ self.rx = Some(rx);
1279
+ }
1280
+
1281
+ pub fn handle_message(&mut self, msg: DocumentEvent) {
1282
+ match msg {
1283
+ DocumentEvent::ResourceLoad(resource) => self.load_resource(resource),
1284
+ DocumentEvent::NavigateIframe { node_id, url } => self.navigate_iframe(node_id, url),
1285
+ }
1286
+ }
1287
+
1288
+ /// Whether the Document has pending requests for "critical" resources (that should block rendering)
1289
+ pub fn has_pending_critical_resources(&self) -> bool {
1290
+ !self.pending_critical_resources.is_empty()
1291
+ }
1292
+
1293
+ pub fn load_resource(&mut self, res: ResourceLoadResponse) {
1294
+ self.pending_critical_resources.remove(&res.request_id);
1295
+
1296
+ let resource = match res.result {
1297
+ Ok(resource) => resource,
1298
+ Err(err) => {
1299
+ if let Some(url) = res.resolved_url.as_ref() {
1300
+ let waiting_nodes = self.pending_images.remove(url).unwrap_or_default();
1301
+ #[cfg(feature = "tracing")]
1302
+ tracing::warn!(
1303
+ url = url.as_str(),
1304
+ waiting_nodes = waiting_nodes.len(),
1305
+ error = err.as_str(),
1306
+ "Resource load failed"
1307
+ );
1308
+ #[cfg(not(feature = "tracing"))]
1309
+ let _ = (waiting_nodes, err);
1310
+ } else {
1311
+ #[cfg(feature = "tracing")]
1312
+ tracing::warn!(error = err.as_str(), "Resource load failed (no url)");
1313
+ #[cfg(not(feature = "tracing"))]
1314
+ let _ = err;
1315
+ }
1316
+ return;
1317
+ }
1318
+ };
1319
+
1320
+ match resource {
1321
+ Resource::Css(css) => {
1322
+ let node_id = res.node_id.unwrap();
1323
+ self.add_stylesheet_for_node(css, node_id);
1324
+ }
1325
+ Resource::ImportedCss(import_rule, sheet) => {
1326
+ let mut guard = self.guard.write();
1327
+ import_rule.write_with(&mut guard).stylesheet =
1328
+ style::stylesheets::import_rule::ImportSheet::Sheet(sheet);
1329
+ drop(guard);
1330
+ self.stylist
1331
+ .force_stylesheet_origins_dirty(style::stylesheets::OriginSet::all());
1332
+ }
1333
+ Resource::Image(_kind, width, height, image_data) => {
1334
+ // Create the ImageData and cache it
1335
+ let image = ImageData::Raster(RasterImageData::new(width, height, image_data));
1336
+
1337
+ let Some(url) = res.resolved_url.as_ref() else {
1338
+ return;
1339
+ };
1340
+
1341
+ self.apply_loaded_image(url, image);
1342
+ }
1343
+ #[cfg(feature = "svg")]
1344
+ Resource::Svg(_kind, svg) => {
1345
+ // Create the ImageData and cache it
1346
+ let image = ImageData::Svg(svg);
1347
+
1348
+ let Some(url) = res.resolved_url.as_ref() else {
1349
+ return;
1350
+ };
1351
+
1352
+ self.apply_loaded_image(url, image);
1353
+ }
1354
+ Resource::DocumentSrc(html) => {
1355
+ let Some(node_id) = res.node_id else {
1356
+ return;
1357
+ };
1358
+ self.apply_iframe_html(node_id, res.request_id, res.resolved_url, &html);
1359
+ }
1360
+ Resource::Font(bytes, overrides) => {
1361
+ let font = Blob::new(Arc::new(bytes));
1362
+
1363
+ // Build a `FontInfoOverride` from the `@font-face` descriptors
1364
+ // captured during stylesheet parsing. Without this, parley
1365
+ // reads the family name from the TTF's own metadata, which
1366
+ // means CSS `font-family: 'Avenir Book'` won't match a font
1367
+ // file that internally identifies as `Avenir 45 Book`.
1368
+ let weight_override = overrides.weight.map(parley::fontique::FontWeight::new);
1369
+ let info_override = parley::fontique::FontInfoOverride {
1370
+ family_name: overrides.family_name.as_deref(),
1371
+ weight: weight_override,
1372
+ style: overrides.style,
1373
+ ..Default::default()
1374
+ };
1375
+
1376
+ // TODO: Investigate eliminating double-box
1377
+ let mut global_font_ctx = self.font_ctx.lock().unwrap();
1378
+ global_font_ctx
1379
+ .collection
1380
+ .register_fonts(font.clone(), Some(info_override));
1381
+
1382
+ #[cfg(feature = "parallel-construct")]
1383
+ {
1384
+ rayon::broadcast(|_ctx| {
1385
+ let mut font_ctx = self
1386
+ .thread_font_contexts
1387
+ .get_or(|| RefCell::new(Box::new(global_font_ctx.clone())))
1388
+ .borrow_mut();
1389
+ font_ctx
1390
+ .collection
1391
+ .register_fonts(font.clone(), Some(info_override));
1392
+ });
1393
+ }
1394
+ drop(global_font_ctx);
1395
+
1396
+ // TODO: see if we can only invalidate if resolved fonts may have changed
1397
+ self.invalidate_inline_contexts();
1398
+ }
1399
+ Resource::None => {
1400
+ // Do nothing
1401
+ }
1402
+ }
1403
+ }
1404
+
1405
+ /// Cache a loaded image and apply it to all nodes waiting on it
1406
+ /// (`<img>` elements, `background-image` layers and `mask-image` layers).
1407
+ fn apply_loaded_image(&mut self, url: &str, image: ImageData) {
1408
+ // Get all nodes waiting for this image
1409
+ let waiting_nodes = self.pending_images.remove(url).unwrap_or_default();
1410
+
1411
+ #[cfg(feature = "tracing")]
1412
+ tracing::info!(
1413
+ "Image {url} loaded, applying to {} nodes",
1414
+ waiting_nodes.len()
1415
+ );
1416
+
1417
+ // Cache the image
1418
+ self.image_cache.insert(url.to_string(), image.clone());
1419
+
1420
+ // Apply to all waiting nodes
1421
+ for (node_id, image_type) in waiting_nodes {
1422
+ let Some(node) = self.get_node_mut(node_id) else {
1423
+ continue;
1424
+ };
1425
+
1426
+ match image_type {
1427
+ ImageType::Image => {
1428
+ let element = node.element_data_mut().unwrap();
1429
+ #[cfg(feature = "custom-widget")]
1430
+ if element.custom_widget_data().is_some() {
1431
+ continue;
1432
+ }
1433
+ element.special_data = SpecialElementData::Image(Box::new(image.clone()));
1434
+
1435
+ // Clear layout cache
1436
+ node.clear_layout_cache();
1437
+ node.insert_damage(ALL_DAMAGE);
1438
+ }
1439
+ ImageType::Background(idx) | ImageType::Mask(idx) => {
1440
+ let layer_image = node.element_data_mut().and_then(|el| {
1441
+ let images = match image_type {
1442
+ ImageType::Background(_) => &mut el.background_images,
1443
+ ImageType::Mask(_) => &mut el.mask_images,
1444
+ ImageType::Image => unreachable!(),
1445
+ };
1446
+ images.get_mut(idx)
1447
+ });
1448
+ if let Some(Some(layer_image)) = layer_image {
1449
+ layer_image.status = Status::Ok;
1450
+ layer_image.image = image.clone();
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ /// Snapshot the node's pre-mutation state (element state and attributes) ahead of
1458
+ /// an attribute mutation, so that the next style traversal can diff selector matches
1459
+ /// then-vs-now and invalidate the affected elements.
1460
+ pub fn snapshot_node(&mut self, node_id: NodeId) {
1461
+ self.snapshot_node_impl(node_id, true)
1462
+ }
1463
+
1464
+ /// Snapshot only the node's pre-change [`ElementState`] ahead of a state change
1465
+ /// (hover/focus/active/etc). Cheaper than [`Self::snapshot_node`] as it does not
1466
+ /// copy attributes or trigger attribute/class/id invalidation work.
1467
+ pub fn snapshot_node_state_only(&mut self, node_id: NodeId) {
1468
+ self.snapshot_node_impl(node_id, false)
1469
+ }
1470
+
1471
+ fn snapshot_node_impl(&mut self, node_id: NodeId, capture_attrs: bool) {
1472
+ let node = &mut self.nodes[node_id];
1473
+
1474
+ // Do not snapshot nodes that have never been styled. A snapshot records an element's
1475
+ // pre-mutation state so a restyle can diff selector matches then-vs-now. An element
1476
+ // that has never been styled has no "then" to diff against. Snapshotting it anyway
1477
+ // makes Stylo's invalidation unwrap its (absent) primary style and panic.
1478
+ let has_been_styled = node.primary_styles().is_some();
1479
+ if !has_been_styled {
1480
+ return;
1481
+ }
1482
+
1483
+ let opaque_node_id = TNode::opaque(&&*node);
1484
+ node.set_has_snapshot(true);
1485
+ node.snapshot_handled()
1486
+ .store(false, std::sync::atomic::Ordering::SeqCst);
1487
+
1488
+ // A snapshot records the element's state/attributes as they were *before the first
1489
+ // mutation* since the last style flush (matching Gecko's ServoElementSnapshot
1490
+ // semantics): state is captured at most once, and attributes are captured at most
1491
+ // once, but a state-only snapshot is upgraded to also capture attributes if an
1492
+ // attribute mutation follows.
1493
+ let needs_attrs = capture_attrs
1494
+ && self
1495
+ .snapshots
1496
+ .get_mut(&opaque_node_id)
1497
+ .is_none_or(|snapshot| snapshot.attrs.is_none());
1498
+
1499
+ let (attrs, changed_attrs) = if needs_attrs {
1500
+ let node = &self.nodes[node_id];
1501
+ let attrs: Option<Vec<_>> = node.attrs().map(|attrs| {
1502
+ attrs
1503
+ .iter()
1504
+ .map(|attr| {
1505
+ let ident = AttrIdentifier {
1506
+ local_name: GenericAtomIdent(attr.name.local.clone()),
1507
+ name: GenericAtomIdent(attr.name.local.clone()),
1508
+ namespace: GenericAtomIdent(attr.name.ns.clone()),
1509
+ prefix: None,
1510
+ };
1511
+
1512
+ let value = if attr.name.local == local_name!("id") {
1513
+ AttrValue::Atom(Atom::from(&*attr.value))
1514
+ } else if attr.name.local == local_name!("class") {
1515
+ let classes = attr
1516
+ .value
1517
+ .split_ascii_whitespace()
1518
+ .map(Atom::from)
1519
+ .collect();
1520
+ AttrValue::TokenList(OnceLock::from(attr.value.clone()), classes)
1521
+ } else {
1522
+ AttrValue::String(attr.value.clone())
1523
+ };
1524
+
1525
+ (ident, value)
1526
+ })
1527
+ .collect()
1528
+ });
1529
+
1530
+ let changed_attrs: Vec<_> = attrs
1531
+ .as_ref()
1532
+ .map(|attrs| attrs.iter().map(|attr| attr.0.name.clone()).collect())
1533
+ .unwrap_or_default();
1534
+
1535
+ (attrs, changed_attrs)
1536
+ } else {
1537
+ (None, Vec::new())
1538
+ };
1539
+
1540
+ if let Some(snapshot) = self.snapshots.get_mut(&opaque_node_id) {
1541
+ // The existing snapshot's state is preserved: it records the state before
1542
+ // the *first* change since the last style flush.
1543
+ if needs_attrs {
1544
+ snapshot.attrs = attrs;
1545
+ snapshot.changed_attrs = changed_attrs;
1546
+ snapshot.class_changed = true;
1547
+ snapshot.id_changed = true;
1548
+ snapshot.other_attributes_changed = true;
1549
+ }
1550
+ } else {
1551
+ self.snapshots.insert(
1552
+ opaque_node_id,
1553
+ ServoElementSnapshot {
1554
+ state: Some(*self.nodes[node_id].element_state()),
1555
+ attrs,
1556
+ changed_attrs,
1557
+ class_changed: needs_attrs,
1558
+ id_changed: needs_attrs,
1559
+ other_attributes_changed: needs_attrs,
1560
+ },
1561
+ );
1562
+ }
1563
+ }
1564
+
1565
+ /// Returns whether any style rule depends on any of the given [`ElementState`] bits.
1566
+ /// If not, changing those bits cannot affect styling and snapshotting can be skipped.
1567
+ pub fn style_depends_on_state(&self, state: ElementState) -> bool {
1568
+ self.stylist.iter_origins().any(|(data, _)| {
1569
+ data.has_state_dependency(state) || data.has_nth_of_state_dependency(state)
1570
+ })
1571
+ }
1572
+
1573
+ /// Apply a state change (hover/focus/active/etc affecting the given [`ElementState`]
1574
+ /// bits) to a node, taking a state-only snapshot beforehand if any style rule
1575
+ /// depends on those bits.
1576
+ pub fn snapshot_node_and(
1577
+ &mut self,
1578
+ node_id: NodeId,
1579
+ state: ElementState,
1580
+ cb: impl FnOnce(&mut Node),
1581
+ ) {
1582
+ if self.style_depends_on_state(state) {
1583
+ self.snapshot_node_state_only(node_id);
1584
+ }
1585
+ cb(&mut self.nodes[node_id]);
1586
+ }
1587
+
1588
+ // Takes (x, y) co-ordinates (relative to the )
1589
+ pub fn hit(&self, x: f32, y: f32) -> Option<HitResult> {
1590
+ self.hit_with_scrollbar(x, y).0
1591
+ }
1592
+
1593
+ /// The topmost element at viewport coordinates (x, y), or `None` if the point
1594
+ /// is outside the viewport. Anonymous boxes and text nodes are resolved to
1595
+ /// their nearest element; a point over the background hits the root element.
1596
+ ///
1597
+ /// This implements the hit-testing semantics of `document.elementFromPoint()`.
1598
+ /// Hit testing consults layout, so [`resolve`](Self::resolve) should be called
1599
+ /// before this method to ensure layout is up to date.
1600
+ pub fn element_from_point(&self, x: f32, y: f32) -> Option<NodeId> {
1601
+ let viewport = self.viewport();
1602
+ let scale = viewport.scale();
1603
+ let (viewport_width, viewport_height) = (
1604
+ viewport.window_size.0 as f32 / scale,
1605
+ viewport.window_size.1 as f32 / scale,
1606
+ );
1607
+ if x < 0.0 || y < 0.0 || x > viewport_width || y > viewport_height {
1608
+ return None;
1609
+ }
1610
+
1611
+ let Some(hit) = self.hit(x, y) else {
1612
+ // The point is within the viewport but over no box: the root element
1613
+ // (which covers the viewport in an HTML document) is the hit target
1614
+ return self.try_root_element().map(|root| root.id);
1615
+ };
1616
+
1617
+ // Resolve anonymous boxes / pseudo-elements, then text nodes, to elements
1618
+ let mut node_id = self.nearest_non_anonymous_ancestor(hit.node_id)?;
1619
+ loop {
1620
+ let node = self.get_node(node_id)?;
1621
+ if node.is_element() {
1622
+ return Some(node_id);
1623
+ }
1624
+ node_id = node.parent?;
1625
+ }
1626
+ }
1627
+
1628
+ /// All elements at viewport coordinates (x, y), from topmost to bottommost,
1629
+ /// as for `document.elementsFromPoint()`.
1630
+ ///
1631
+ /// The spec's paint-order list is approximated with the hit element followed
1632
+ /// by its ancestor elements (which is correct for non-overlapping content).
1633
+ /// As with [`element_from_point`](Self::element_from_point), layout should be
1634
+ /// resolved before calling this method.
1635
+ pub fn elements_from_point(&self, x: f32, y: f32) -> Vec<NodeId> {
1636
+ let mut element_ids = Vec::new();
1637
+ let mut current = self.element_from_point(x, y);
1638
+ while let Some(node_id) = current {
1639
+ element_ids.push(node_id);
1640
+ current = self
1641
+ .get_node(node_id)
1642
+ .and_then(|node| node.parent)
1643
+ .filter(|parent_id| {
1644
+ self.get_node(*parent_id)
1645
+ .is_some_and(|node| node.is_element())
1646
+ });
1647
+ }
1648
+ element_ids
1649
+ }
1650
+
1651
+ /// Walk up the tree to the nearest DOM node whose id is stable across
1652
+ /// box-tree reconstruction, so canonicalized interaction state never goes
1653
+ /// stale.
1654
+ ///
1655
+ /// Layout-generated nodes (anonymous blocks and `::before`/`::after`
1656
+ /// pseudo-elements, both stored as anonymous blocks) get new ids on every
1657
+ /// reconstruction, so we skip any anonymous node *and* a non-anonymous node
1658
+ /// whose parent is anonymous (the pseudo's text content). The first
1659
+ /// non-anonymous node with a non-anonymous parent is a real DOM node; the
1660
+ /// root element's `Document` parent guarantees termination.
1661
+ ///
1662
+ /// Returns `None` if `node_id` (or an ancestor) no longer exists.
1663
+ pub fn nearest_non_anonymous_ancestor(&self, node_id: NodeId) -> Option<NodeId> {
1664
+ // Recurse up the tree keeping a window of the current node and its
1665
+ // parent, advancing one step per iteration so each node is looked up
1666
+ // exactly once.
1667
+ let mut node = self.get_node(node_id)?;
1668
+ loop {
1669
+ let parent = match node.parent {
1670
+ Some(parent_id) => self.get_node(parent_id)?,
1671
+ None => return Some(node.id),
1672
+ };
1673
+ if !node.is_anonymous() && !parent.is_anonymous() {
1674
+ return Some(node.id);
1675
+ }
1676
+ node = parent;
1677
+ }
1678
+ }
1679
+
1680
+ pub fn focus_next_node(&mut self) -> Option<NodeId> {
1681
+ let focussed_node_id = self.get_focussed_node_id()?;
1682
+ let id = self.next_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?;
1683
+ self.set_focus_to(id);
1684
+ Some(id)
1685
+ }
1686
+
1687
+ /// Move focus to the previous focussable node in the document
1688
+ pub fn focus_prev_node(&mut self) -> Option<NodeId> {
1689
+ let focussed_node_id = self.get_focussed_node_id()?;
1690
+ let id = self.prev_node(&self.nodes[focussed_node_id], |node| node.is_focussable())?;
1691
+ self.set_focus_to(id);
1692
+ Some(id)
1693
+ }
1694
+
1695
+ /// Clear the focussed node
1696
+ pub fn clear_focus(&mut self) {
1697
+ if let Some(id) = self.focus_node_id {
1698
+ let shell_provider = self.shell_provider.clone();
1699
+ self.snapshot_node_and(id, ElementState::FOCUS | ElementState::FOCUSRING, |node| {
1700
+ node.blur(shell_provider)
1701
+ });
1702
+ self.focus_node_id = None;
1703
+ }
1704
+ }
1705
+
1706
+ pub fn set_mousedown_node_id(&mut self, node_id: Option<NodeId>) {
1707
+ self.mousedown_node_id = node_id.and_then(|id| self.nearest_non_anonymous_ancestor(id));
1708
+ }
1709
+ pub fn set_focus_to(&mut self, focus_node_id: NodeId) -> bool {
1710
+ let Some(focus_node_id) = self.nearest_non_anonymous_ancestor(focus_node_id) else {
1711
+ return false;
1712
+ };
1713
+ if Some(focus_node_id) == self.focus_node_id {
1714
+ return false;
1715
+ }
1716
+
1717
+ #[cfg(feature = "tracing")]
1718
+ tracing::info!("Focussed node {focus_node_id}");
1719
+
1720
+ let shell_provider = self.shell_provider.clone();
1721
+
1722
+ // Remove focus from the old node
1723
+ if let Some(id) = self.focus_node_id {
1724
+ self.snapshot_node_and(id, ElementState::FOCUS | ElementState::FOCUSRING, |node| {
1725
+ node.blur(shell_provider.clone())
1726
+ });
1727
+ }
1728
+
1729
+ // Focus the new node
1730
+ self.snapshot_node_and(
1731
+ focus_node_id,
1732
+ ElementState::FOCUS | ElementState::FOCUSRING,
1733
+ |node| node.focus(shell_provider),
1734
+ );
1735
+
1736
+ self.focus_node_id = Some(focus_node_id);
1737
+
1738
+ true
1739
+ }
1740
+
1741
+ pub fn active_node(&mut self) -> bool {
1742
+ let Some(hover_node_id) = self.get_hover_node_id() else {
1743
+ return false;
1744
+ };
1745
+
1746
+ if let Some(active_node_id) = self.active_node_id {
1747
+ if active_node_id == hover_node_id {
1748
+ return true;
1749
+ }
1750
+ self.unactive_node();
1751
+ }
1752
+
1753
+ // hover_node_id is canonicalized when stored, so this always holds.
1754
+ debug_assert!(
1755
+ self.get_node(hover_node_id)
1756
+ .is_some_and(|node| !node.is_anonymous()),
1757
+ "interaction state must reference DOM nodes, not layout-generated nodes"
1758
+ );
1759
+ let active_node_id = Some(hover_node_id);
1760
+
1761
+ let node_path = self.maybe_node_element_ancestors(active_node_id);
1762
+ for &id in node_path.iter() {
1763
+ self.snapshot_node_and(id, ElementState::ACTIVE, |node| node.active());
1764
+ }
1765
+
1766
+ self.active_node_id = active_node_id;
1767
+
1768
+ true
1769
+ }
1770
+
1771
+ pub fn unactive_node(&mut self) -> bool {
1772
+ let Some(active_node_id) = self.active_node_id.take() else {
1773
+ return false;
1774
+ };
1775
+
1776
+ let node_path = self.maybe_node_element_ancestors(Some(active_node_id));
1777
+ for &id in node_path.iter() {
1778
+ self.snapshot_node_and(id, ElementState::ACTIVE, |node| node.unactive());
1779
+ }
1780
+
1781
+ true
1782
+ }
1783
+
1784
+ /// The scrollbar thumb currently under the pointer, if any.
1785
+ pub fn hovered_scrollbar(&self) -> Option<crate::node::ScrollbarRef> {
1786
+ self.hovered_scrollbar
1787
+ }
1788
+
1789
+ /// The scrollbar thumb currently being dragged, if any.
1790
+ pub fn scrollbar_drag_target(&self) -> Option<crate::node::ScrollbarRef> {
1791
+ match &self.drag_mode {
1792
+ DragMode::ScrollbarDrag(state) => Some(state.scrollbar),
1793
+ _ => None,
1794
+ }
1795
+ }
1796
+
1797
+ /// The current opacity of `node_id`'s overlay scrollbars. They show at
1798
+ /// full opacity on scroll and fade out after a delay (Chromium's overlay
1799
+ /// timings); the pointer resting on a thumb, or dragging it, holds them
1800
+ /// visible.
1801
+ pub fn scrollbar_opacity(&self, node_id: NodeId) -> f32 {
1802
+ let interacting = |scrollbar: &crate::node::ScrollbarRef| scrollbar.node_id == node_id;
1803
+ if self.hovered_scrollbar.as_ref().is_some_and(interacting)
1804
+ || self
1805
+ .scrollbar_drag_target()
1806
+ .as_ref()
1807
+ .is_some_and(interacting)
1808
+ {
1809
+ return 1.0;
1810
+ }
1811
+ self.scrollbar_activity.get(&node_id).map_or(0.0, |last| {
1812
+ crate::node::scrollbar::opacity_at(last.elapsed())
1813
+ })
1814
+ }
1815
+
1816
+ /// Show `node_id`'s overlay scrollbars at full opacity and restart their
1817
+ /// fade-out delay.
1818
+ pub(crate) fn show_scrollbars(&mut self, node_id: NodeId) {
1819
+ if cfg!(feature = "scrollbars") {
1820
+ self.scrollbar_activity.insert(node_id, Instant::now());
1821
+ }
1822
+ }
1823
+
1824
+ /// Whether any overlay scrollbars are awaiting or animating their
1825
+ /// fade-out (so frames must keep rendering until they finish).
1826
+ fn scrollbars_animating(&self) -> bool {
1827
+ use crate::node::scrollbar::{FADE_DELAY, FADE_DURATION};
1828
+ self.scrollbar_activity
1829
+ .values()
1830
+ .any(|last| last.elapsed() < FADE_DELAY + FADE_DURATION)
1831
+ }
1832
+
1833
+ /// [`hit`](Self::hit), also resolving the innermost overlay scrollbar
1834
+ /// thumb under the point (shares the traversal, so it costs nothing
1835
+ /// extra).
1836
+ pub(crate) fn hit_with_scrollbar(
1837
+ &self,
1838
+ x: f32,
1839
+ y: f32,
1840
+ ) -> (Option<HitResult>, Option<crate::node::ScrollbarRef>) {
1841
+ if TDocument::as_node(&self.root_node())
1842
+ .first_element_child()
1843
+ .is_none()
1844
+ {
1845
+ #[cfg(feature = "tracing")]
1846
+ tracing::warn!("No DOM - not resolving hit test");
1847
+ return (None, None);
1848
+ }
1849
+ let mut scrollbar = None;
1850
+ let hit = self
1851
+ .root_element()
1852
+ .hit_inner(x, y, self.viewport().scale_f64(), &mut scrollbar);
1853
+ (hit, scrollbar)
1854
+ }
1855
+
1856
+ pub fn set_hover_to(&mut self, x: f32, y: f32) -> bool {
1857
+ // Record the pointer position in client (unscrolled) coordinates so
1858
+ // that `refresh_hover` can re-resolve hover state after layout or
1859
+ // scroll changes.
1860
+ self.last_client_pointer_position = Some(taffy::Point {
1861
+ x: x - self.viewport_scroll().x as f32,
1862
+ y: y - self.viewport_scroll().y as f32,
1863
+ });
1864
+
1865
+ let (hit, hovered_scrollbar) = self.hit_with_scrollbar(x, y);
1866
+ // A faded-out thumb is not interactive: pointer moves never fade
1867
+ // overlay scrollbars back in (only scrolling shows them).
1868
+ let hovered_scrollbar =
1869
+ hovered_scrollbar.filter(|scrollbar| self.scrollbar_opacity(scrollbar.node_id) > 0.0);
1870
+ // Scrollbar-thumb hover is part of hover state: track it here so a
1871
+ // pointer crossing a thumb restyles it even when the hit node (the
1872
+ // content under the overlay thumb) is unchanged.
1873
+ let scrollbar_changed = hovered_scrollbar != self.hovered_scrollbar;
1874
+ if scrollbar_changed {
1875
+ // Entering a thumb restores full opacity mid-fade; leaving one
1876
+ // restarts the fade-out delay.
1877
+ for scrollbar in [self.hovered_scrollbar, hovered_scrollbar]
1878
+ .into_iter()
1879
+ .flatten()
1880
+ {
1881
+ self.show_scrollbars(scrollbar.node_id);
1882
+ }
1883
+ }
1884
+ self.hovered_scrollbar = hovered_scrollbar;
1885
+
1886
+ // Store both the precise layout node that was hit (transient: used for
1887
+ // cursor/style queries) and its canonical DOM target (persistent: must
1888
+ // not reference layout-generated nodes, whose ids die on box-tree
1889
+ // reconstruction).
1890
+ let hit_node_id = hit.map(|hit| hit.node_id);
1891
+ let hover_node_id = hit_node_id.and_then(|id| self.nearest_non_anonymous_ancestor(id));
1892
+ let new_is_text = hit.map(|hit| hit.is_text).unwrap_or(false);
1893
+
1894
+ self.hover_hit_node_id = hit_node_id;
1895
+ self.hover_node_is_text = new_is_text;
1896
+
1897
+ // Return early if the new node is the same as the already-hovered node
1898
+ if hover_node_id == self.hover_node_id {
1899
+ return scrollbar_changed;
1900
+ }
1901
+
1902
+ let old_node_path = self.maybe_node_element_ancestors(self.hover_node_id);
1903
+ let new_node_path = self.maybe_node_element_ancestors(hover_node_id);
1904
+ let same_count = old_node_path
1905
+ .iter()
1906
+ .zip(&new_node_path)
1907
+ .take_while(|(o, n)| o == n)
1908
+ .count();
1909
+ for &id in old_node_path.iter().skip(same_count) {
1910
+ self.snapshot_node_and(id, ElementState::HOVER, |node| node.unhover());
1911
+ }
1912
+ for &id in new_node_path.iter().skip(same_count) {
1913
+ self.snapshot_node_and(id, ElementState::HOVER, |node| node.hover());
1914
+ }
1915
+
1916
+ self.hover_node_id = hover_node_id;
1917
+
1918
+ // Request redraw
1919
+ self.shell_provider.request_redraw();
1920
+
1921
+ true
1922
+ }
1923
+
1924
+ pub fn clear_hover(&mut self) -> bool {
1925
+ // The pointer is no longer over the document, so stop re-resolving
1926
+ // hover state against it.
1927
+ self.last_client_pointer_position = None;
1928
+ self.hover_hit_node_id = None;
1929
+
1930
+ let Some(hover_node_id) = self.hover_node_id else {
1931
+ return false;
1932
+ };
1933
+
1934
+ let old_node_path = self.maybe_node_element_ancestors(Some(hover_node_id));
1935
+ for &id in old_node_path.iter() {
1936
+ self.snapshot_node_and(id, ElementState::HOVER, |node| node.unhover());
1937
+ }
1938
+
1939
+ self.hover_node_id = None;
1940
+ self.hover_node_is_text = false;
1941
+
1942
+ // Request redraw
1943
+ self.shell_provider.request_redraw();
1944
+
1945
+ true
1946
+ }
1947
+
1948
+ /// Re-resolve hover state against the current layout using the last known
1949
+ /// pointer position.
1950
+ ///
1951
+ /// TODO: synthesizing pointerenter/pointerleave DOM events for
1952
+ /// hover changes caused by layout shifts.
1953
+ pub fn refresh_hover(&mut self) -> bool {
1954
+ let Some(pos) = self.last_client_pointer_position else {
1955
+ return false;
1956
+ };
1957
+ let x = pos.x + self.viewport_scroll().x as f32;
1958
+ let y = pos.y + self.viewport_scroll().y as f32;
1959
+ let prev_hover = self.hover_state();
1960
+ let changed = self.set_hover_to(x, y);
1961
+ if self.hover_state() != prev_hover {
1962
+ self.update_cursor();
1963
+ }
1964
+ changed
1965
+ }
1966
+
1967
+ /// The hover state that determines the cursor: the hovered node, the precise hit
1968
+ /// node and whether the hit is text
1969
+ pub(crate) fn hover_state(&self) -> (Option<NodeId>, Option<NodeId>, bool) {
1970
+ (
1971
+ self.hover_node_id,
1972
+ self.hover_hit_node_id,
1973
+ self.hover_node_is_text,
1974
+ )
1975
+ }
1976
+
1977
+ /// Send the current cursor to the shell.
1978
+ ///
1979
+ /// Hover updates don't send the cursor themselves: the cursor of a node hosting a
1980
+ /// sub-document is only known once the sub-document has handled the event.
1981
+ pub(crate) fn update_cursor(&self) {
1982
+ self.shell_provider.set_cursor(self.get_cursor());
1983
+ }
1984
+
1985
+ pub fn get_hover_node_id(&self) -> Option<NodeId> {
1986
+ self.hover_node_id
1987
+ }
1988
+
1989
+ pub fn get_mousedown_node_id(&self) -> Option<NodeId> {
1990
+ self.mousedown_node_id
1991
+ }
1992
+
1993
+ pub fn set_viewport(&mut self, viewport: Viewport) {
1994
+ let changes = DeviceChanges::from_viewports(&self.viewport, &viewport);
1995
+ self.viewport = viewport;
1996
+ self.queue_device_changes(changes);
1997
+ }
1998
+
1999
+ /// Set exact CSS dimensions for a vector/paged layout without changing its scale.
2000
+ pub fn set_layout_viewport(&mut self, size: Option<(f32, f32)>) {
2001
+ if self.layout_viewport != size {
2002
+ self.layout_viewport = size;
2003
+ self.queue_device_changes(DeviceChanges::VIEWPORT_SIZE);
2004
+ }
2005
+ }
2006
+
2007
+ /// Returns the current CSS media type used to evaluate `@media` rules.
2008
+ pub fn media_type(&self) -> &MediaType {
2009
+ &self.media_type
2010
+ }
2011
+
2012
+ /// Sets the CSS media type used to evaluate `@media` rules (e.g. `screen` or `print`)
2013
+ /// and queues a stylist device rebuild so updated rules apply on the next restyle.
2014
+ pub fn set_media_type(&mut self, media_type: MediaType) {
2015
+ if self.media_type == media_type {
2016
+ return;
2017
+ }
2018
+ self.media_type = media_type;
2019
+ self.queue_device_changes(DeviceChanges::MEDIA_TYPE);
2020
+ }
2021
+
2022
+ pub fn viewport(&self) -> &Viewport {
2023
+ &self.viewport
2024
+ }
2025
+
2026
+ pub fn viewport_mut(&mut self) -> ViewportMut<'_> {
2027
+ ViewportMut::new(self)
2028
+ }
2029
+
2030
+ pub fn zoom_by(&mut self, increment: f32) {
2031
+ *self.viewport_mut().zoom_mut() += increment;
2032
+ }
2033
+
2034
+ pub fn zoom_to(&mut self, zoom: f32) {
2035
+ *self.viewport_mut().zoom_mut() = zoom;
2036
+ }
2037
+
2038
+ pub fn get_viewport(&self) -> Viewport {
2039
+ self.viewport.clone()
2040
+ }
2041
+
2042
+ /// Returns whether incremental layout is currently enabled for this document.
2043
+ ///
2044
+ /// Both modes run the same style → damage → box construction → layout
2045
+ /// pipeline. In incremental mode only nodes carrying `RestyleDamage` (from
2046
+ /// mutations or restyles) are reconstructed and have their layout caches
2047
+ /// cleared; in non-incremental mode every node is marked damaged at the
2048
+ /// start of each `resolve`, so everything is rebuilt each frame.
2049
+ pub fn incremental_layout(&self) -> bool {
2050
+ self.incremental_layout
2051
+ }
2052
+
2053
+ /// Enables or disables incremental layout for this document.
2054
+ ///
2055
+ /// Disabling it does not switch to a separate code path: all nodes are
2056
+ /// simply treated as damaged on every `resolve` (see [`Self::incremental_layout`]).
2057
+ pub fn set_incremental_layout(&mut self, enabled: bool) {
2058
+ self.incremental_layout = enabled;
2059
+ }
2060
+
2061
+ pub fn devtools(&self) -> &DevtoolSettings {
2062
+ &self.devtool_settings
2063
+ }
2064
+
2065
+ pub fn devtools_mut(&mut self) -> &mut DevtoolSettings {
2066
+ &mut self.devtool_settings
2067
+ }
2068
+
2069
+ pub fn subdoc(&self, node_id: NodeId) -> Option<&dyn Document> {
2070
+ self.get_node(node_id)
2071
+ .and_then(|node| node.element_data())
2072
+ .and_then(|el| el.sub_doc_data())
2073
+ }
2074
+
2075
+ pub fn subdoc_mut(&mut self, node_id: NodeId) -> Option<&mut dyn Document> {
2076
+ self.get_node_mut(node_id)
2077
+ .and_then(|node| node.element_data_mut())
2078
+ .and_then(|el| el.sub_doc_data_mut())
2079
+ }
2080
+
2081
+ pub fn is_animating(&self) -> bool {
2082
+ #[cfg(feature = "custom-widget")]
2083
+ let custom_widget_is_animating = self.custom_widget_nodes.iter().any(|&node_id| {
2084
+ self.nodes[node_id]
2085
+ .element_data()
2086
+ .and_then(|el| el.custom_widget_data())
2087
+ .is_some_and(|data| data.widget.requires_redraw())
2088
+ });
2089
+ #[cfg(not(feature = "custom-widget"))]
2090
+ let custom_widget_is_animating = false;
2091
+
2092
+ self.has_canvas
2093
+ | self.has_active_animations
2094
+ | self.subdoc_is_animating
2095
+ | custom_widget_is_animating
2096
+ | (self.scroll_animation != ScrollAnimationState::None)
2097
+ | self.scrollbars_animating()
2098
+ }
2099
+
2100
+ /// Record pending [`DeviceChanges`] and request a redraw so that they are
2101
+ /// flushed (via [`Self::flush_pending_device_changes`]) on the next resolve.
2102
+ pub(crate) fn queue_device_changes(&mut self, changes: DeviceChanges) {
2103
+ if changes.is_empty() {
2104
+ return;
2105
+ }
2106
+ self.pending_device_changes |= changes;
2107
+ self.shell_provider.request_redraw();
2108
+ }
2109
+
2110
+ /// Apply any pending device changes to the stylist, coalescing all changes
2111
+ /// since the last flush into a single device rebuild.
2112
+ pub(crate) fn flush_pending_device_changes(&mut self) {
2113
+ let changes = std::mem::take(&mut self.pending_device_changes);
2114
+ if changes.is_empty() {
2115
+ return;
2116
+ }
2117
+
2118
+ self.set_stylist_device(make_device(
2119
+ &self.viewport,
2120
+ self.layout_viewport,
2121
+ self.media_type.clone(),
2122
+ self.font_ctx.clone(),
2123
+ ));
2124
+ self.scroll_viewport_by(0.0, 0.0); // Clamp scroll offset
2125
+
2126
+ // Text is shaped at a specific scale factor, so cached inline layouts
2127
+ // must be invalidated when the scale changes.
2128
+ if changes.contains(DeviceChanges::SCALE) {
2129
+ self.invalidate_inline_contexts();
2130
+ }
2131
+
2132
+ // Color-scheme changes affect values that are resolved at cascade time
2133
+ // (`light-dark()`, system colors) without necessarily flipping any
2134
+ // media query result, so conservatively recascade the whole tree.
2135
+ if changes.contains(DeviceChanges::COLOR_SCHEME) {
2136
+ if let Some(root_id) = self.try_root_element().map(|el| el.id) {
2137
+ self.nodes[root_id].set_restyle_hint(RestyleHint::recascade_subtree());
2138
+ }
2139
+ }
2140
+ }
2141
+
2142
+ /// Update the device and reset the stylist to process the new size
2143
+ pub fn set_stylist_device(&mut self, device: Device) {
2144
+ // Seed the new device with the root element's current style and font-relative
2145
+ // unit state (used to resolve rem/rlh/rex/rch/rcap/ric units). Stylo only
2146
+ // updates this state when the root element's style *changes* during a restyle,
2147
+ // so a freshly-built device would otherwise resolve these units against the
2148
+ // default font-size (16px) until the root's font-size next changes.
2149
+ let root_styles = self
2150
+ .try_root_element()
2151
+ .and_then(|root| root.primary_styles());
2152
+ if let Some(root_style) = root_styles.as_deref() {
2153
+ device.set_root_style(root_style);
2154
+
2155
+ let font = root_style.get_font();
2156
+ let font_size = font.clone_font_size().computed_size();
2157
+ device.set_root_font_size(root_style.effective_zoom.unzoom(font_size.px()));
2158
+
2159
+ let line_height = device
2160
+ .calc_line_height(font, root_style.writing_mode, None)
2161
+ .0;
2162
+ device.set_root_line_height(root_style.effective_zoom.unzoom(line_height.px()));
2163
+ }
2164
+ drop(root_styles);
2165
+
2166
+ let old_device = self.stylist.device();
2167
+ let viewport_size_changed = old_device.au_viewport_size() != device.au_viewport_size();
2168
+ let styles_use_viewport_units = old_device.used_viewport_size();
2169
+
2170
+ let origins = {
2171
+ let guard = &self.guard;
2172
+ let guards = StylesheetGuards {
2173
+ author: &guard.read(),
2174
+ ua_or_user: &guard.read(),
2175
+ };
2176
+ self.stylist.set_device(device, &guards)
2177
+ };
2178
+
2179
+ // Only fully invalidate element styles when the media query results of
2180
+ // some origin actually changed. `force_stylesheet_origins_dirty` fully
2181
+ // invalidates styles even when `origins` is empty, which would force a
2182
+ // full restyle on every viewport resize.
2183
+ if !origins.is_empty() {
2184
+ self.stylist.force_stylesheet_origins_dirty(origins);
2185
+ }
2186
+
2187
+ // Styles that resolve viewport units (vw/vh/etc) still need recomputing
2188
+ // when the viewport size changes, even if no media query results
2189
+ // changed. Invalidate just the elements whose styles use viewport
2190
+ // units (tracked via per-element computed value flags).
2191
+ if viewport_size_changed && styles_use_viewport_units {
2192
+ if self
2193
+ .stylist
2194
+ .get_custom_property_initial_values_flags()
2195
+ .intersects(ComputedValueFlags::USES_VIEWPORT_UNITS)
2196
+ {
2197
+ self.stylist.rebuild_initial_values_for_custom_properties();
2198
+ }
2199
+ if let Some(root) = self.try_root_element() {
2200
+ style::invalidation::viewport_units::invalidate(root);
2201
+ }
2202
+ }
2203
+ }
2204
+
2205
+ pub fn stylist_device(&mut self) -> &Device {
2206
+ self.flush_pending_device_changes();
2207
+ self.stylist.device()
2208
+ }
2209
+
2210
+ pub fn get_cursor(&self) -> Option<CursorIcon> {
2211
+ // Prefer the precise hit node: `cursor` and `user-select` may be set on
2212
+ // a pseudo-element or resolved on an anonymous box, and text hits carry
2213
+ // is_text via the hit node. Fall back to the canonical hover node if
2214
+ // the hit node has been removed (it is transient across resolves).
2215
+ let node_id = self
2216
+ .hover_hit_node_id
2217
+ .filter(|&id| self.nodes.contains_key(id))
2218
+ .or(self.get_hover_node_id())?;
2219
+ let node = &self.nodes[node_id];
2220
+
2221
+ if let Some(subdoc) = node.subdoc().map(|doc| doc.inner())
2222
+ && subdoc.get_hover_node_id().is_some()
2223
+ {
2224
+ return subdoc.get_cursor();
2225
+ }
2226
+
2227
+ let style = node.primary_styles()?;
2228
+ let user_select = style.clone_user_select();
2229
+ let keyword = style.clone_cursor().keyword;
2230
+
2231
+ // Return cursor from style if it is non-auto
2232
+ if keyword != CursorKind::Auto {
2233
+ return stylo_to_cursor_icon(keyword);
2234
+ }
2235
+
2236
+ // Return text cursor for text inputs
2237
+ if node
2238
+ .element_data()
2239
+ .is_some_and(|e| e.text_input_data().is_some())
2240
+ {
2241
+ return Some(CursorIcon::Text);
2242
+ }
2243
+
2244
+ // Use "pointer" cursor if any ancestor is a link
2245
+ let mut maybe_node = Some(node);
2246
+ while let Some(node) = maybe_node {
2247
+ if node.is_link() {
2248
+ return Some(CursorIcon::Pointer);
2249
+ }
2250
+
2251
+ maybe_node = node.layout_parent.get().map(|node_id| node.with(node_id));
2252
+ }
2253
+
2254
+ // Return text cursor for text nodes
2255
+ if self.hover_node_is_text {
2256
+ return Some(match user_select {
2257
+ UserSelect::Text | UserSelect::All | UserSelect::Auto | UserSelect::Contain => {
2258
+ CursorIcon::Text
2259
+ }
2260
+ UserSelect::None => CursorIcon::Default,
2261
+ });
2262
+ }
2263
+
2264
+ // Else fallback to default cursor
2265
+ Some(CursorIcon::Default)
2266
+ }
2267
+
2268
+ pub fn viewport_scroll(&self) -> crate::Point<f64> {
2269
+ self.nodes.viewport_scroll()
2270
+ }
2271
+
2272
+ pub fn set_viewport_scroll(&mut self, scroll: crate::Point<f64>) {
2273
+ self.nodes.set_viewport_scroll(scroll);
2274
+ }
2275
+
2276
+ /// Find the node targeted by a URL fragment (the `#...` part of a URL).
2277
+ ///
2278
+ /// Per the HTML spec, this is the element whose `id` matches the fragment, falling
2279
+ /// back to the first `<a>` element whose `name` attribute matches.
2280
+ pub fn get_fragment_target(&self, fragment: &str) -> Option<NodeId> {
2281
+ if let Some(node_id) = self.get_element_by_id(fragment) {
2282
+ return Some(node_id);
2283
+ }
2284
+
2285
+ // Fall back to a named anchor: `<a name="...">`
2286
+ self.nodes.iter().find_map(|(id, node)| {
2287
+ let el = node.element_data()?;
2288
+ (el.name.local == local_name!("a") && el.attr(local_name!("name")) == Some(fragment))
2289
+ .then_some(id)
2290
+ })
2291
+ }
2292
+
2293
+ /// `node_id`'s document-relative position and unrounded (sub-pixel) layout in physical
2294
+ /// coordinates, for CSSOM geometry APIs such as `getBoundingClientRect`.
2295
+ #[cfg(not(feature = "writing-mode"))]
2296
+ pub(crate) fn physical_unrounded_geometry(
2297
+ &self,
2298
+ node_id: NodeId,
2299
+ ) -> (crate::util::Point<f32>, taffy::Layout) {
2300
+ let mut pos = crate::util::Point { x: 0.0, y: 0.0 };
2301
+ let mut current = Some(node_id);
2302
+ while let Some(id) = current {
2303
+ let node = &self.nodes[id];
2304
+ pos.x += node.unrounded_layout().location.x - node.scroll_offset().x as f32;
2305
+ pos.y += node.unrounded_layout().location.y - node.scroll_offset().y as f32;
2306
+ current = node.containing_block();
2307
+ }
2308
+ (pos, *self.nodes[node_id].unrounded_layout())
2309
+ }
2310
+
2311
+ /// Computes the size and position of the `Node` relative to the viewport
2312
+ pub fn get_client_bounding_rect(&self, node_id: NodeId) -> Option<BoundingRect> {
2313
+ // Non-atomic inline elements have no layout box of their own: return
2314
+ // the union of their per-line-box fragment rects.
2315
+ if let Some(rects) = self.inline_fragment_rects(node_id) {
2316
+ return rects.reduce(BoundingRect::union);
2317
+ }
2318
+
2319
+ self.get_node(node_id)?;
2320
+ let (pos, layout) = self.physical_unrounded_geometry(node_id);
2321
+ let size = layout.size;
2322
+
2323
+ Some(BoundingRect {
2324
+ x: snap_to_layout_unit(pos.x as f64 - self.viewport_scroll().x),
2325
+ y: snap_to_layout_unit(pos.y as f64 - self.viewport_scroll().y),
2326
+ width: snap_to_layout_unit(size.width as f64),
2327
+ height: snap_to_layout_unit(size.height as f64),
2328
+ })
2329
+ }
2330
+
2331
+ /// CSSOM View's `offsetLeft`/`offsetTop`/`offsetWidth`/`offsetHeight` as a single rect:
2332
+ /// the node's border box, positioned relative to the padding edge of its `offsetParent`
2333
+ /// (or to the initial containing block when the `offsetParent` is a non-positioned
2334
+ /// `body`, or there is none).
2335
+ ///
2336
+ /// Nodes with their own layout box use that box directly. Non-atomic inline elements
2337
+ /// have no layout box: they use the bounding box of their per-line-box fragments, and
2338
+ /// their `offsetParent` is resolved from the containing inline root.
2339
+ pub fn offset_rect(&self, node_id: NodeId) -> Option<BoundingRect> {
2340
+ let node = self.get_node(node_id)?;
2341
+
2342
+ // Nodes with their own layout box: use it directly
2343
+ let Some(rects) = self.inline_fragment_rects(node_id) else {
2344
+ let pos = node.offset_top_left();
2345
+ let size = node.unrounded_layout().size;
2346
+ #[cfg(feature = "writing-mode")]
2347
+ let size = match node.containing_block() {
2348
+ Some(parent) if self.nodes[parent].layout_data().writing_mode.is_vertical() => {
2349
+ size.transpose()
2350
+ }
2351
+ _ => size,
2352
+ };
2353
+ return Some(BoundingRect {
2354
+ x: pos.x as f64,
2355
+ y: pos.y as f64,
2356
+ width: size.width as f64,
2357
+ height: size.height as f64,
2358
+ });
2359
+ };
2360
+
2361
+ // Union the per-line-box fragments into a single bounding box
2362
+ let bounds = rects.reduce(BoundingRect::union)?;
2363
+
2364
+ // Fragment rects are viewport-relative; convert to document-relative
2365
+ let scroll = self.viewport_scroll();
2366
+ let mut x = bounds.x + scroll.x;
2367
+ let mut y = bounds.y + scroll.y;
2368
+
2369
+ // Resolve the offsetParent from the inline root (the root itself if it is positioned)
2370
+ let inline_root = node.inline_root_ancestor()?;
2371
+ let offset_parent = if inline_root.is_offset_parent() {
2372
+ Some(inline_root)
2373
+ } else {
2374
+ inline_root.offset_parent()
2375
+ };
2376
+ // Make the position relative to the offsetParent's padding edge
2377
+ if let Some(parent) = offset_parent.filter(|parent| !parent.is_static_body()) {
2378
+ let (parent_pos, parent_layout) = self.physical_unrounded_geometry(parent.id);
2379
+ let border = parent_layout.border;
2380
+ x -= (parent_pos.x + border.left) as f64;
2381
+ y -= (parent_pos.y + border.top) as f64;
2382
+ }
2383
+
2384
+ Some(BoundingRect {
2385
+ x,
2386
+ y,
2387
+ width: bounds.width,
2388
+ height: bounds.height,
2389
+ })
2390
+ }
2391
+
2392
+ /// Computes the sizes and positions of the `Node`'s box fragments relative to the
2393
+ /// viewport (CSSOM `getClientRects()` semantics). Nodes with their own layout box
2394
+ /// return a single rect. Non-atomic inline elements (which are laid out as style
2395
+ /// spans within an inline root's text layout) return one rect per line box.
2396
+ pub fn node_client_rects(&self, node_id: NodeId) -> Vec<BoundingRect> {
2397
+ match self.inline_fragment_rects(node_id) {
2398
+ Some(rects) => rects.collect(),
2399
+ None => self.get_client_bounding_rect(node_id).into_iter().collect(),
2400
+ }
2401
+ }
2402
+
2403
+ /// Computes per-line-box fragment rects for a non-atomic inline element by walking
2404
+ /// the containing inline root's text layout. Returns `None` for nodes that have
2405
+ /// their own layout box (which should use `get_client_bounding_rect` instead).
2406
+ pub fn inline_fragment_rects(
2407
+ &self,
2408
+ node_id: NodeId,
2409
+ ) -> Option<impl Iterator<Item = BoundingRect> + '_> {
2410
+ let node = self.get_node(node_id)?;
2411
+
2412
+ // Only non-atomic inline elements lack their own layout box: they are
2413
+ // flattened into the containing inline root's text layout as style spans.
2414
+ let fragments = node.inline_fragment_boxes()?;
2415
+ let inline_root = node.inline_root_ancestor()?;
2416
+
2417
+ // Fragment boxes are relative to the inline root's border box.
2418
+ let (root_pos, _) = self.physical_unrounded_geometry(inline_root.id);
2419
+ let origin_x = root_pos.x as f64 - self.viewport_scroll().x;
2420
+ let origin_y = root_pos.y as f64 - self.viewport_scroll().y;
2421
+
2422
+ Some(fragments.map(move |rect| BoundingRect {
2423
+ x: snap_to_layout_unit(origin_x + rect.left as f64),
2424
+ y: snap_to_layout_unit(origin_y + rect.top as f64),
2425
+ width: snap_to_layout_unit((rect.right - rect.left) as f64),
2426
+ height: snap_to_layout_unit((rect.bottom - rect.top) as f64),
2427
+ }))
2428
+ }
2429
+
2430
+ /// The first element in tree order with the given tag name. The root element and
2431
+ /// its children are checked first as a fast path before a full tree search, making
2432
+ /// this suitable for the `documentElement`/`head`/`body` document accessors.
2433
+ pub fn find_element_by_tag_name(&self, tag: &LocalName) -> Option<&Node> {
2434
+ let root = self.try_root_element()?;
2435
+ if root.data.is_element_with_tag_name(tag) {
2436
+ return Some(root);
2437
+ }
2438
+ root.children
2439
+ .iter()
2440
+ .copied()
2441
+ .find(|child_id| {
2442
+ self.get_node(*child_id)
2443
+ .is_some_and(|child| child.data.is_element_with_tag_name(tag))
2444
+ })
2445
+ .or_else(|| {
2446
+ TreeTraverser::new(self)
2447
+ .find(|node_id| self.nodes[*node_id].data.is_element_with_tag_name(tag))
2448
+ })
2449
+ .map(|node_id| &self.nodes[node_id])
2450
+ }
2451
+
2452
+ /// The document's `body` element, as for `document.body`
2453
+ pub fn find_body_node(&self) -> Option<&Node> {
2454
+ self.find_element_by_tag_name(&local_name!("body"))
2455
+ }
2456
+
2457
+ /// The document's `head` element, as for `document.head`
2458
+ pub fn find_head_node(&self) -> Option<&Node> {
2459
+ self.find_element_by_tag_name(&local_name!("head"))
2460
+ }
2461
+
2462
+ /// The document's `title` element
2463
+ pub fn find_title_node(&self) -> Option<&Node> {
2464
+ self.find_element_by_tag_name(&local_name!("title"))
2465
+ }
2466
+
2467
+ pub fn with_text_input(
2468
+ &mut self,
2469
+ node_id: NodeId,
2470
+ cb: impl FnOnce(PlainEditorDriver<TextBrush>),
2471
+ ) {
2472
+ let Some(node) = self.nodes.get_mut(node_id) else {
2473
+ return;
2474
+ };
2475
+
2476
+ if let Some(text_input) = node
2477
+ .element_data_mut()
2478
+ .and_then(|el| el.text_input_data_mut())
2479
+ {
2480
+ let mut font_ctx = self.font_ctx.lock().unwrap();
2481
+ let layout_ctx = &mut self.layout_ctx;
2482
+ let driver = text_input.editor.driver(&mut font_ctx, layout_ctx);
2483
+ cb(driver)
2484
+ }
2485
+ }
2486
+
2487
+ /// Recompute the scroll offset of the text input at `node_id` (if any) so that its caret
2488
+ /// remains visible within the input's content box.
2489
+ pub(crate) fn clamp_text_input_scroll(&mut self, node_id: NodeId) {
2490
+ let Some(node) = self.nodes.get_mut(node_id) else {
2491
+ return;
2492
+ };
2493
+
2494
+ let content_box_width = node.final_layout().content_box_width();
2495
+ let content_box_height = node.final_layout().content_box_height();
2496
+
2497
+ if let Some(text_input) = node
2498
+ .element_data_mut()
2499
+ .and_then(|el| el.text_input_data_mut())
2500
+ {
2501
+ text_input.clamp_scroll_offset(content_box_width, content_box_height);
2502
+ }
2503
+ }
2504
+
2505
+ pub(crate) fn compute_has_canvas(&self) -> bool {
2506
+ TreeTraverser::new(self).any(|node_id| {
2507
+ let node = &self.nodes[node_id];
2508
+ let Some(element) = node.element_data() else {
2509
+ return false;
2510
+ };
2511
+ if element.name.local == local_name!("canvas") && element.has_attr(local_name!("src")) {
2512
+ return true;
2513
+ }
2514
+
2515
+ false
2516
+ })
2517
+ }
2518
+
2519
+ // Text selection methods
2520
+
2521
+ /// Find the text position (inline_root_id, byte_offset) at a given point.
2522
+ /// Uses hit() for proper coordinate transformation, then finds the inline root
2523
+ /// and byte offset.
2524
+ pub fn find_text_position(&self, x: f32, y: f32) -> Option<(NodeId, usize)> {
2525
+ let hit = self.hit(x, y)?;
2526
+ let hit_node = self.get_node(hit.node_id)?;
2527
+ let inline_root = hit_node.inline_root_ancestor()?;
2528
+ let byte_offset = inline_root.text_offset_at_point(hit.x, hit.y)?;
2529
+ Some((inline_root.id, byte_offset))
2530
+ }
2531
+
2532
+ /// Set the text selection range (creates a new selection from anchor to focus)
2533
+ pub fn set_text_selection(
2534
+ &mut self,
2535
+ anchor_node: NodeId,
2536
+ anchor_offset: usize,
2537
+ focus_node: NodeId,
2538
+ focus_offset: usize,
2539
+ ) {
2540
+ self.text_selection =
2541
+ TextSelection::new(anchor_node, anchor_offset, focus_node, focus_offset);
2542
+
2543
+ // For anonymous blocks, switch to storing parent+sibling_index (stable reference)
2544
+ if let (Some(parent), Some(idx)) = self.anonymous_block_location(anchor_node) {
2545
+ self.text_selection
2546
+ .anchor
2547
+ .set_anonymous(parent, idx, anchor_offset);
2548
+ }
2549
+ if let (Some(parent), Some(idx)) = self.anonymous_block_location(focus_node) {
2550
+ self.text_selection
2551
+ .focus
2552
+ .set_anonymous(parent, idx, focus_offset);
2553
+ }
2554
+ }
2555
+
2556
+ /// Get the parent ID and sibling index for a node if it's an anonymous block.
2557
+ /// Returns (None, None) for non-anonymous blocks.
2558
+ fn anonymous_block_location(&self, node_id: NodeId) -> (Option<NodeId>, Option<usize>) {
2559
+ let Some(node) = self.get_node(node_id) else {
2560
+ return (None, None);
2561
+ };
2562
+
2563
+ if !node.is_anonymous() {
2564
+ return (None, None);
2565
+ }
2566
+
2567
+ let Some(parent_id) = node.parent else {
2568
+ return (None, None);
2569
+ };
2570
+
2571
+ let Some(parent) = self.get_node(parent_id) else {
2572
+ return (Some(parent_id), None);
2573
+ };
2574
+
2575
+ let layout_children = parent.layout_children.borrow();
2576
+ let Some(children) = layout_children.as_ref() else {
2577
+ return (Some(parent_id), None);
2578
+ };
2579
+
2580
+ // Find the index of this anonymous block among siblings
2581
+ let mut anon_index = 0;
2582
+ for &child_id in children.iter() {
2583
+ if child_id == node_id {
2584
+ return (Some(parent_id), Some(anon_index));
2585
+ }
2586
+ if self.get_node(child_id).is_some_and(|n| n.is_anonymous()) {
2587
+ anon_index += 1;
2588
+ }
2589
+ }
2590
+
2591
+ (Some(parent_id), None)
2592
+ }
2593
+
2594
+ /// Clear the text selection
2595
+ pub fn clear_text_selection(&mut self) {
2596
+ self.text_selection.clear();
2597
+ }
2598
+
2599
+ /// Update the selection focus point (used during mouse drag to extend selection).
2600
+ pub fn update_selection_focus(&mut self, focus_node: NodeId, focus_offset: usize) {
2601
+ // For anonymous blocks, store parent+sibling_index; otherwise store node directly
2602
+ if let (Some(parent), Some(idx)) = self.anonymous_block_location(focus_node) {
2603
+ self.text_selection
2604
+ .focus
2605
+ .set_anonymous(parent, idx, focus_offset);
2606
+ } else {
2607
+ self.text_selection.set_focus(focus_node, focus_offset);
2608
+ }
2609
+ }
2610
+
2611
+ /// Extend text selection to the given point. Returns true if selection was updated.
2612
+ /// This is a convenience method that combines find_text_position and update_selection_focus.
2613
+ pub fn extend_text_selection_to_point(&mut self, x: f32, y: f32) -> bool {
2614
+ if !self.text_selection.anchor.is_some() {
2615
+ return false;
2616
+ }
2617
+
2618
+ if let Some((node, offset)) = self.find_text_position(x, y) {
2619
+ self.update_selection_focus(node, offset);
2620
+ self.shell_provider.request_redraw();
2621
+ true
2622
+ } else {
2623
+ false
2624
+ }
2625
+ }
2626
+
2627
+ /// Find the Nth anonymous block under a parent.
2628
+ fn find_anonymous_block_by_index(
2629
+ &self,
2630
+ parent_id: NodeId,
2631
+ target_index: usize,
2632
+ ) -> Option<NodeId> {
2633
+ let parent = self.get_node(parent_id)?;
2634
+ let layout_children = parent.layout_children.borrow();
2635
+ let children = layout_children.as_ref()?;
2636
+
2637
+ children
2638
+ .iter()
2639
+ .filter(|&&child_id| self.get_node(child_id).is_some_and(|n| n.is_anonymous()))
2640
+ .nth(target_index)
2641
+ .copied()
2642
+ }
2643
+
2644
+ /// Check if there is an active (non-empty) text selection
2645
+ pub fn has_text_selection(&self) -> bool {
2646
+ self.text_selection.is_active()
2647
+ }
2648
+
2649
+ /// Get the selected text content, supporting selection across multiple inline roots.
2650
+ pub fn get_selected_text(&self) -> Option<String> {
2651
+ let ranges = self.get_text_selection_ranges();
2652
+ if ranges.is_empty() {
2653
+ return None;
2654
+ }
2655
+
2656
+ let mut result = String::new();
2657
+ for (node_id, start, end) in &ranges {
2658
+ let node = self.get_node(*node_id)?;
2659
+ let element_data = node.element_data()?;
2660
+ let inline_layout = element_data.inline_layout_data.as_ref()?;
2661
+
2662
+ if *end > inline_layout.text.len() {
2663
+ continue;
2664
+ }
2665
+
2666
+ if !result.is_empty() {
2667
+ result.push(' ');
2668
+ }
2669
+ result.push_str(&inline_layout.text[*start..*end]);
2670
+ }
2671
+
2672
+ if result.is_empty() {
2673
+ None
2674
+ } else {
2675
+ Some(result)
2676
+ }
2677
+ }
2678
+
2679
+ /// Get all selection ranges as Vec<(node_id, start_offset, end_offset)>.
2680
+ /// Returns empty vec if no selection.
2681
+ pub fn get_text_selection_ranges(&self) -> Vec<(NodeId, usize, usize)> {
2682
+ let lookup = |parent_id, idx| self.find_anonymous_block_by_index(parent_id, idx);
2683
+
2684
+ let anchor_node = match self.text_selection.anchor.resolve_node_id(lookup) {
2685
+ Some(id) => id,
2686
+ None => return Vec::new(),
2687
+ };
2688
+ let focus_node = match self.text_selection.focus.resolve_node_id(lookup) {
2689
+ Some(id) => id,
2690
+ None => return Vec::new(),
2691
+ };
2692
+
2693
+ // Guard against stale selection endpoints: nodes may have been removed from
2694
+ // the document (e.g. by script) since the selection was made.
2695
+ let node_is_in_doc = |node_id: NodeId| {
2696
+ self.nodes
2697
+ .get(node_id)
2698
+ .is_some_and(|node| node.flags.is_in_document())
2699
+ };
2700
+ if !node_is_in_doc(anchor_node) || !node_is_in_doc(focus_node) {
2701
+ return Vec::new();
2702
+ }
2703
+
2704
+ // Single node selection
2705
+ if anchor_node == focus_node {
2706
+ let start = self
2707
+ .text_selection
2708
+ .anchor
2709
+ .offset
2710
+ .min(self.text_selection.focus.offset);
2711
+ let end = self
2712
+ .text_selection
2713
+ .anchor
2714
+ .offset
2715
+ .max(self.text_selection.focus.offset);
2716
+
2717
+ if start == end {
2718
+ return Vec::new();
2719
+ }
2720
+ return vec![(anchor_node, start, end)];
2721
+ }
2722
+
2723
+ // Multi-node selection: collect all inline roots between anchor and focus
2724
+ let inline_roots = self.collect_inline_roots_in_range(anchor_node, focus_node);
2725
+ if inline_roots.is_empty() {
2726
+ return Vec::new();
2727
+ }
2728
+
2729
+ // Determine document order using the collected inline_roots order
2730
+ // (inline_roots is already in document order from first to last)
2731
+ let first_in_roots = inline_roots[0];
2732
+
2733
+ let (first_node, first_offset, last_node, last_offset) =
2734
+ if first_in_roots == anchor_node || (first_in_roots != focus_node) {
2735
+ // anchor is first (or neither endpoint is in roots, which shouldn't happen)
2736
+ (
2737
+ anchor_node,
2738
+ self.text_selection.anchor.offset,
2739
+ focus_node,
2740
+ self.text_selection.focus.offset,
2741
+ )
2742
+ } else {
2743
+ // focus is first
2744
+ (
2745
+ focus_node,
2746
+ self.text_selection.focus.offset,
2747
+ anchor_node,
2748
+ self.text_selection.anchor.offset,
2749
+ )
2750
+ };
2751
+
2752
+ let mut ranges = Vec::with_capacity(inline_roots.len());
2753
+
2754
+ for &node_id in &inline_roots {
2755
+ let Some(node) = self.get_node(node_id) else {
2756
+ continue;
2757
+ };
2758
+ let Some(element_data) = node.element_data() else {
2759
+ continue;
2760
+ };
2761
+ let Some(inline_layout) = element_data.inline_layout_data.as_ref() else {
2762
+ continue;
2763
+ };
2764
+
2765
+ let text_len = inline_layout.text.len();
2766
+
2767
+ if node_id == first_node && node_id == last_node {
2768
+ let start = first_offset.min(last_offset);
2769
+ let end = first_offset.max(last_offset);
2770
+ if start < end && end <= text_len {
2771
+ ranges.push((node_id, start, end));
2772
+ }
2773
+ } else if node_id == first_node {
2774
+ if first_offset < text_len {
2775
+ ranges.push((node_id, first_offset, text_len));
2776
+ }
2777
+ } else if node_id == last_node {
2778
+ if last_offset > 0 && last_offset <= text_len {
2779
+ ranges.push((node_id, 0, last_offset));
2780
+ }
2781
+ } else if text_len > 0 {
2782
+ ranges.push((node_id, 0, text_len));
2783
+ }
2784
+ }
2785
+
2786
+ ranges
2787
+ }
2788
+ }
2789
+
2790
+ #[derive(Debug, Clone, Copy, PartialEq)]
2791
+ pub struct BoundingRect {
2792
+ pub x: f64,
2793
+ pub y: f64,
2794
+ pub width: f64,
2795
+ pub height: f64,
2796
+ }
2797
+
2798
+ impl BoundingRect {
2799
+ /// The smallest rect containing both `self` and `other`.
2800
+ pub fn union(self, other: BoundingRect) -> BoundingRect {
2801
+ let x = self.x.min(other.x);
2802
+ let y = self.y.min(other.y);
2803
+ BoundingRect {
2804
+ x,
2805
+ y,
2806
+ width: (self.x + self.width).max(other.x + other.width) - x,
2807
+ height: (self.y + self.height).max(other.y + other.height) - y,
2808
+ }
2809
+ }
2810
+ }
2811
+
2812
+ /// Snap a CSSOM geometry value to a 1/64px grid (the precision of Blink's `LayoutUnit`).
2813
+ /// Layout values are accumulated in `f32`, so e.g. seven `55/7`-wide flex items would
2814
+ /// otherwise end at `55.0000005` and appear to overflow their 55px container.
2815
+ fn snap_to_layout_unit(value: f64) -> f64 {
2816
+ (value * 64.0).round() / 64.0
2817
+ }
2818
+
2819
+ impl AsRef<BaseDocument> for BaseDocument {
2820
+ fn as_ref(&self) -> &BaseDocument {
2821
+ self
2822
+ }
2823
+ }
2824
+
2825
+ impl AsMut<BaseDocument> for BaseDocument {
2826
+ fn as_mut(&mut self) -> &mut BaseDocument {
2827
+ self
2828
+ }
2829
+ }
2830
+
2831
+ #[cfg(test)]
2832
+ mod zoom_tests {
2833
+ use super::*;
2834
+ use blitz_traits::shell::ColorScheme;
2835
+ use std::sync::atomic::{AtomicUsize, Ordering};
2836
+
2837
+ #[derive(Default)]
2838
+ struct CountingShellProvider {
2839
+ redraw_count: AtomicUsize,
2840
+ }
2841
+ impl ShellProvider for CountingShellProvider {
2842
+ fn request_redraw(&self) {
2843
+ self.redraw_count.fetch_add(1, Ordering::SeqCst);
2844
+ }
2845
+ }
2846
+
2847
+ #[test]
2848
+ fn zoom_requests_redraw() {
2849
+ let shell_provider = Arc::new(CountingShellProvider::default());
2850
+ let mut doc = BaseDocument::new(DocumentConfig {
2851
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
2852
+ shell_provider: Some(shell_provider.clone() as _),
2853
+ ..Default::default()
2854
+ });
2855
+ doc.resolve(0.0);
2856
+
2857
+ let base = shell_provider.redraw_count.load(Ordering::SeqCst);
2858
+ doc.zoom_by(0.5);
2859
+ assert!(shell_provider.redraw_count.load(Ordering::SeqCst) > base);
2860
+
2861
+ let base = shell_provider.redraw_count.load(Ordering::SeqCst);
2862
+ doc.zoom_to(1.0);
2863
+ assert!(shell_provider.redraw_count.load(Ordering::SeqCst) > base);
2864
+ }
2865
+ }
2866
+
2867
+ #[cfg(test)]
2868
+ mod hover_state_tests {
2869
+ use super::*;
2870
+ use crate::{Attribute, qual_name};
2871
+ use blitz_traits::shell::ColorScheme;
2872
+
2873
+ /// Build `<html><body style="margin:0"><div style="width:300px">some text
2874
+ /// <div style="height:50px"></div></div></body></html>` manually (the HTML
2875
+ /// parser lives in blitz-html, which would be a circular dev-dependency).
2876
+ /// The bare text next to a block sibling gets wrapped in an anonymous
2877
+ /// block, which becomes the inline root: text hits report the anonymous
2878
+ /// block as the hit node.
2879
+ fn make_doc() -> (BaseDocument, NodeId) {
2880
+ let mut doc = BaseDocument::new(DocumentConfig {
2881
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
2882
+ ..Default::default()
2883
+ });
2884
+ let root_id = doc.root_node().id;
2885
+ let style = |value: &str| Attribute {
2886
+ name: qual_name!("style"),
2887
+ value: value.to_string(),
2888
+ };
2889
+
2890
+ let mut mutator = doc.mutate();
2891
+ let html = mutator.create_element(qual_name!("html"), vec![]);
2892
+ let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
2893
+ let container = mutator.create_element(qual_name!("div"), vec![style("width:300px")]);
2894
+ let text = mutator.create_text_node("some text");
2895
+ let block = mutator.create_element(qual_name!("div"), vec![style("height:50px")]);
2896
+ mutator.append_children(container, &[text, block]);
2897
+ mutator.append_children(body, &[container]);
2898
+ mutator.append_children(html, &[body]);
2899
+ mutator.append_children(root_id, &[html]);
2900
+ drop(mutator);
2901
+
2902
+ doc.resolve(0.0);
2903
+ (doc, container)
2904
+ }
2905
+
2906
+ /// Whether text laid out with a real (non-zero-metric) font. Without the
2907
+ /// `system-fonts` feature text measures 0x0 and text hits are impossible,
2908
+ /// making these tests vacuous.
2909
+ fn text_has_size(doc: &BaseDocument, container: NodeId) -> bool {
2910
+ doc.nodes[container].final_layout().size.height > 50.0
2911
+ }
2912
+
2913
+ /// Regression test: hovering bare text wrapped in an anonymous block must
2914
+ /// report a text cursor. The hit node for such text is the anonymous
2915
+ /// inline root itself, while the *stored* hover target is canonicalized to
2916
+ /// the containing element — the cursor must be derived from the precise
2917
+ /// hit node, not the canonical target.
2918
+ #[test]
2919
+ fn hovering_text_in_anonymous_block_reports_text_cursor() {
2920
+ let (mut doc, container) = make_doc();
2921
+ if !text_has_size(&doc, container) {
2922
+ eprintln!("skipping: no usable font (text measures 0x0)");
2923
+ return;
2924
+ }
2925
+
2926
+ doc.set_hover_to(5.0, 8.0);
2927
+ assert!(doc.hover_node_is_text, "expected a text hit");
2928
+ let hit_id = doc.hover_hit_node_id.expect("expected a hit node");
2929
+ assert!(
2930
+ doc.nodes[hit_id].is_anonymous(),
2931
+ "expected the hit node to be the anonymous inline root"
2932
+ );
2933
+ assert_eq!(
2934
+ doc.get_hover_node_id(),
2935
+ Some(container),
2936
+ "expected the stored hover target to be the containing element"
2937
+ );
2938
+ assert_eq!(doc.get_cursor(), Some(CursorIcon::Text));
2939
+ }
2940
+
2941
+ /// Hovering the empty region of the anonymous block (right of the text) is
2942
+ /// not a text hit: default cursor, same canonical hover target.
2943
+ #[test]
2944
+ fn hovering_anonymous_block_whitespace_reports_default_cursor() {
2945
+ let (mut doc, container) = make_doc();
2946
+ if !text_has_size(&doc, container) {
2947
+ eprintln!("skipping: no usable font (text measures 0x0)");
2948
+ return;
2949
+ }
2950
+
2951
+ doc.set_hover_to(250.0, 8.0);
2952
+ assert!(!doc.hover_node_is_text);
2953
+ assert_eq!(doc.get_hover_node_id(), Some(container));
2954
+ assert_eq!(doc.get_cursor(), Some(CursorIcon::Default));
2955
+ }
2956
+ }
2957
+
2958
+ #[cfg(test)]
2959
+ mod hover_invalidation_tests {
2960
+ use super::*;
2961
+ use crate::{Attribute, QualName, qual_name};
2962
+ use blitz_traits::shell::ColorScheme;
2963
+
2964
+ /// Build `<html><body style="margin:0"><div style="width:300px;height:100px">
2965
+ /// <span>text</span></div></body></html>` with a `div:hover` rule.
2966
+ fn make_doc() -> (BaseDocument, NodeId, NodeId) {
2967
+ let mut doc = BaseDocument::new(DocumentConfig {
2968
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
2969
+ ..Default::default()
2970
+ });
2971
+ let sheet = UserAgentStylesheet::parse(
2972
+ "div:hover { background-color: rgb(255, 0, 0); } div:hover span { color: rgb(0, 255, 0); }",
2973
+ );
2974
+ doc.add_user_agent_stylesheet(sheet);
2975
+ let root_id = doc.root_node().id;
2976
+ let style = |value: &str| Attribute {
2977
+ name: qual_name!("style"),
2978
+ value: value.to_string(),
2979
+ };
2980
+
2981
+ let mut mutator = doc.mutate();
2982
+ let html = mutator.create_element(qual_name!("html"), vec![]);
2983
+ let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
2984
+ let div =
2985
+ mutator.create_element(qual_name!("div"), vec![style("width:300px;height:100px")]);
2986
+ let span = mutator.create_element(qual_name!("span"), vec![]);
2987
+ let text = mutator.create_text_node("some text");
2988
+ mutator.append_children(span, &[text]);
2989
+ mutator.append_children(div, &[span]);
2990
+ mutator.append_children(body, &[div]);
2991
+ mutator.append_children(html, &[body]);
2992
+ mutator.append_children(root_id, &[html]);
2993
+ drop(mutator);
2994
+
2995
+ doc.resolve(0.0);
2996
+ (doc, div, span)
2997
+ }
2998
+
2999
+ fn bg_color(doc: &BaseDocument, id: NodeId) -> String {
3000
+ format!(
3001
+ "{:?}",
3002
+ doc.nodes[id]
3003
+ .primary_styles()
3004
+ .unwrap()
3005
+ .get_background()
3006
+ .background_color
3007
+ )
3008
+ }
3009
+
3010
+ fn text_color(doc: &BaseDocument, id: NodeId) -> String {
3011
+ format!(
3012
+ "{:?}",
3013
+ doc.nodes[id].primary_styles().unwrap().clone_color()
3014
+ )
3015
+ }
3016
+
3017
+ #[test]
3018
+ fn hover_styles_apply_and_clear() {
3019
+ let (mut doc, div, span) = make_doc();
3020
+ let initial_bg = bg_color(&doc, div);
3021
+ let initial_color = text_color(&doc, span);
3022
+
3023
+ // Hover the div
3024
+ doc.set_hover_to(10.0, 10.0);
3025
+ assert!(doc.nodes[div].is_hovered());
3026
+ doc.resolve(0.0);
3027
+ let hovered_bg = bg_color(&doc, div);
3028
+ let hovered_color = text_color(&doc, span);
3029
+ assert_ne!(initial_bg, hovered_bg, "hover should change div background");
3030
+ assert_ne!(
3031
+ initial_color, hovered_color,
3032
+ "hover should change span color"
3033
+ );
3034
+
3035
+ // Move the pointer off the div (below it, over the body)
3036
+ doc.set_hover_to(10.0, 200.0);
3037
+ assert!(!doc.nodes[div].is_hovered());
3038
+ doc.resolve(0.0);
3039
+ assert_eq!(
3040
+ bg_color(&doc, div),
3041
+ initial_bg,
3042
+ "unhover should restore div background"
3043
+ );
3044
+ assert_eq!(
3045
+ text_color(&doc, span),
3046
+ initial_color,
3047
+ "unhover should restore span color"
3048
+ );
3049
+ }
3050
+
3051
+ /// Mimics BBC's headline hover pattern: `a:link:hover .headline` with the
3052
+ /// headline several levels below the anchor.
3053
+ #[test]
3054
+ fn ancestor_hover_with_link_state_updates_descendant() {
3055
+ let mut doc = BaseDocument::new(DocumentConfig {
3056
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
3057
+ ..Default::default()
3058
+ });
3059
+ let sheet = UserAgentStylesheet::parse(
3060
+ ".promo:link:hover .headline, .promo:visited:hover .headline { color: rgb(184, 0, 0); text-decoration-line: underline; }",
3061
+ );
3062
+ doc.add_user_agent_stylesheet(sheet);
3063
+ let root_id = doc.root_node().id;
3064
+ let attr = |name: QualName, value: &str| Attribute {
3065
+ name,
3066
+ value: value.to_string(),
3067
+ };
3068
+
3069
+ let mut mutator = doc.mutate();
3070
+ let html = mutator.create_element(qual_name!("html"), vec![]);
3071
+ let body = mutator.create_element(
3072
+ qual_name!("body"),
3073
+ vec![attr(qual_name!("style"), "margin:0")],
3074
+ );
3075
+ let a = mutator.create_element(
3076
+ qual_name!("a"),
3077
+ vec![
3078
+ attr(qual_name!("href"), "https://example.com"),
3079
+ attr(qual_name!("class"), "promo"),
3080
+ attr(
3081
+ qual_name!("style"),
3082
+ "display:block;width:300px;height:100px",
3083
+ ),
3084
+ ],
3085
+ );
3086
+ let p = mutator.create_element(qual_name!("p"), vec![]);
3087
+ let span = mutator.create_element(
3088
+ qual_name!("span"),
3089
+ vec![attr(qual_name!("class"), "headline")],
3090
+ );
3091
+ let text = mutator.create_text_node("Headline text");
3092
+ mutator.append_children(span, &[text]);
3093
+ mutator.append_children(p, &[span]);
3094
+ mutator.append_children(a, &[p]);
3095
+ mutator.append_children(body, &[a]);
3096
+ mutator.append_children(html, &[body]);
3097
+ mutator.append_children(root_id, &[html]);
3098
+ drop(mutator);
3099
+
3100
+ doc.resolve(0.0);
3101
+ let initial_color = text_color(&doc, span);
3102
+
3103
+ doc.set_hover_to(10.0, 10.0);
3104
+ assert!(doc.nodes[a].is_hovered());
3105
+ doc.resolve(0.0);
3106
+ let hovered_color = text_color(&doc, span);
3107
+ assert_ne!(
3108
+ initial_color, hovered_color,
3109
+ "hovering the anchor should change the headline color"
3110
+ );
3111
+
3112
+ doc.set_hover_to(10.0, 200.0);
3113
+ assert!(!doc.nodes[a].is_hovered());
3114
+ doc.resolve(0.0);
3115
+ assert_eq!(
3116
+ text_color(&doc, span),
3117
+ initial_color,
3118
+ "unhovering the anchor should restore the headline color"
3119
+ );
3120
+ }
3121
+
3122
+ /// Toggling a checkbox must invalidate `:checked`-dependent styles.
3123
+ #[test]
3124
+ fn checkbox_toggle_updates_checked_styles() {
3125
+ let mut doc = BaseDocument::new(DocumentConfig {
3126
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
3127
+ ..Default::default()
3128
+ });
3129
+ let sheet = UserAgentStylesheet::parse(
3130
+ "input:checked { color: rgb(184, 0, 0); } input:checked + label { color: rgb(0, 184, 0); }",
3131
+ );
3132
+ doc.add_user_agent_stylesheet(sheet);
3133
+ let root_id = doc.root_node().id;
3134
+ let attr = |name: QualName, value: &str| Attribute {
3135
+ name,
3136
+ value: value.to_string(),
3137
+ };
3138
+
3139
+ let mut mutator = doc.mutate();
3140
+ let html = mutator.create_element(qual_name!("html"), vec![]);
3141
+ let body = mutator.create_element(
3142
+ qual_name!("body"),
3143
+ vec![attr(qual_name!("style"), "margin:0")],
3144
+ );
3145
+ let input = mutator.create_element(
3146
+ qual_name!("input"),
3147
+ vec![attr(qual_name!("type"), "checkbox")],
3148
+ );
3149
+ let label = mutator.create_element(qual_name!("label"), vec![]);
3150
+ let text = mutator.create_text_node("label text");
3151
+ mutator.append_children(label, &[text]);
3152
+ mutator.append_children(body, &[input, label]);
3153
+ mutator.append_children(html, &[body]);
3154
+ mutator.append_children(root_id, &[html]);
3155
+ drop(mutator);
3156
+
3157
+ doc.resolve(0.0);
3158
+ let initial_input_color = text_color(&doc, input);
3159
+ let initial_label_color = text_color(&doc, label);
3160
+
3161
+ // Toggle the checkbox on (as the click handler does)
3162
+ doc.snapshot_node_and(input, ElementState::CHECKED, |node| {
3163
+ if let Some(el) = node.element_data_mut() {
3164
+ BaseDocument::toggle_checkbox(el);
3165
+ }
3166
+ node.mark_ancestors_dirty();
3167
+ });
3168
+ doc.resolve(0.0);
3169
+ assert_ne!(
3170
+ text_color(&doc, input),
3171
+ initial_input_color,
3172
+ "checking should change the input color"
3173
+ );
3174
+ assert_ne!(
3175
+ text_color(&doc, label),
3176
+ initial_label_color,
3177
+ "checking should change the sibling label color"
3178
+ );
3179
+
3180
+ // Toggle the checkbox back off
3181
+ doc.snapshot_node_and(input, ElementState::CHECKED, |node| {
3182
+ if let Some(el) = node.element_data_mut() {
3183
+ BaseDocument::toggle_checkbox(el);
3184
+ }
3185
+ node.mark_ancestors_dirty();
3186
+ });
3187
+ doc.resolve(0.0);
3188
+ assert_eq!(
3189
+ text_color(&doc, input),
3190
+ initial_input_color,
3191
+ "unchecking should restore the input color"
3192
+ );
3193
+ assert_eq!(
3194
+ text_color(&doc, label),
3195
+ initial_label_color,
3196
+ "unchecking should restore the sibling label color"
3197
+ );
3198
+ }
3199
+
3200
+ /// A repaint-only style change to an existing `::before` pseudo-element
3201
+ /// (no box construction damage) must be flushed to the pseudo-element's
3202
+ /// anonymous node during the style traversal.
3203
+ #[test]
3204
+ fn hover_updates_existing_pseudo_element_style() {
3205
+ let mut doc = BaseDocument::new(DocumentConfig {
3206
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
3207
+ ..Default::default()
3208
+ });
3209
+ let sheet = UserAgentStylesheet::parse(
3210
+ "div::before { content: \"x\"; color: rgb(1, 2, 3); } \
3211
+ div:hover::before { color: rgb(0, 0, 255); }",
3212
+ );
3213
+ doc.add_user_agent_stylesheet(sheet);
3214
+ let root_id = doc.root_node().id;
3215
+ let style = |value: &str| Attribute {
3216
+ name: qual_name!("style"),
3217
+ value: value.to_string(),
3218
+ };
3219
+
3220
+ let mut mutator = doc.mutate();
3221
+ let html = mutator.create_element(qual_name!("html"), vec![]);
3222
+ let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
3223
+ let div = mutator.create_element(
3224
+ qual_name!("div"),
3225
+ vec![style("display:block;width:300px;height:100px")],
3226
+ );
3227
+ let text = mutator.create_text_node("some text");
3228
+ mutator.append_children(div, &[text]);
3229
+ mutator.append_children(body, &[div]);
3230
+ mutator.append_children(html, &[body]);
3231
+ mutator.append_children(root_id, &[html]);
3232
+ drop(mutator);
3233
+
3234
+ doc.resolve(0.0);
3235
+ let before = doc.nodes[div].before().expect("::before node should exist");
3236
+ let initial_color = text_color(&doc, before);
3237
+
3238
+ doc.set_hover_to(10.0, 10.0);
3239
+ assert!(doc.nodes[div].is_hovered());
3240
+ doc.resolve(0.0);
3241
+ assert_ne!(
3242
+ text_color(&doc, before),
3243
+ initial_color,
3244
+ "hover should change the ::before color"
3245
+ );
3246
+
3247
+ doc.set_hover_to(10.0, 200.0);
3248
+ assert!(!doc.nodes[div].is_hovered());
3249
+ doc.resolve(0.0);
3250
+ assert_eq!(
3251
+ text_color(&doc, before),
3252
+ initial_color,
3253
+ "unhover should restore the ::before color"
3254
+ );
3255
+ }
3256
+
3257
+ /// Background-image layers must be flushed to the node's dedicated image
3258
+ /// storage when its style changes (queued during the style traversal),
3259
+ /// now that image flushing no longer runs on every node in the flush pass.
3260
+ #[test]
3261
+ fn hover_updates_background_image_layers() {
3262
+ let mut doc = BaseDocument::new(DocumentConfig {
3263
+ viewport: Some(Viewport::new(400, 300, 1.0, ColorScheme::Light)),
3264
+ ..Default::default()
3265
+ });
3266
+ let sheet = UserAgentStylesheet::parse(
3267
+ "div { background-image: url(\"https://example.com/a.png\"); } \
3268
+ div:hover { background-image: url(\"https://example.com/b.png\"); }",
3269
+ );
3270
+ doc.add_user_agent_stylesheet(sheet);
3271
+ let root_id = doc.root_node().id;
3272
+ let style = |value: &str| Attribute {
3273
+ name: qual_name!("style"),
3274
+ value: value.to_string(),
3275
+ };
3276
+
3277
+ let mut mutator = doc.mutate();
3278
+ let html = mutator.create_element(qual_name!("html"), vec![]);
3279
+ let body = mutator.create_element(qual_name!("body"), vec![style("margin:0")]);
3280
+ let div = mutator.create_element(
3281
+ qual_name!("div"),
3282
+ vec![style("display:block;width:300px;height:100px")],
3283
+ );
3284
+ mutator.append_children(body, &[div]);
3285
+ mutator.append_children(html, &[body]);
3286
+ mutator.append_children(root_id, &[html]);
3287
+ drop(mutator);
3288
+
3289
+ let background_image_url = |doc: &BaseDocument, id: NodeId| -> Option<String> {
3290
+ let elem = doc.nodes[id].data.downcast_element().unwrap();
3291
+ elem.background_images
3292
+ .first()
3293
+ .and_then(|img| img.as_ref())
3294
+ .map(|img| img.url.as_str().to_string())
3295
+ };
3296
+
3297
+ doc.resolve(0.0);
3298
+ assert_eq!(
3299
+ background_image_url(&doc, div).as_deref(),
3300
+ Some("https://example.com/a.png"),
3301
+ "initial resolve should flush the background image"
3302
+ );
3303
+
3304
+ doc.set_hover_to(10.0, 10.0);
3305
+ assert!(doc.nodes[div].is_hovered());
3306
+ doc.resolve(0.0);
3307
+ assert_eq!(
3308
+ background_image_url(&doc, div).as_deref(),
3309
+ Some("https://example.com/b.png"),
3310
+ "hover should flush the changed background image"
3311
+ );
3312
+
3313
+ doc.set_hover_to(10.0, 200.0);
3314
+ assert!(!doc.nodes[div].is_hovered());
3315
+ doc.resolve(0.0);
3316
+ assert_eq!(
3317
+ background_image_url(&doc, div).as_deref(),
3318
+ Some("https://example.com/a.png"),
3319
+ "unhover should flush the restored background image"
3320
+ );
3321
+ }
3322
+ }
3323
+
3324
+ #[cfg(test)]
3325
+ mod font_face_override_tests {
3326
+ use super::*;
3327
+ use crate::net::{FontFaceOverrides, Resource, ResourceLoadResponse};
3328
+
3329
+ /// Regression-pin for the `@font-face` descriptor-honouring fix.
3330
+ ///
3331
+ /// The bug was that `Resource::Font` carried only the raw font bytes,
3332
+ /// so `load_resource` registered fonts with `info_override = None` and
3333
+ /// parley fell back to the TTF's internal `name` table. After the fix,
3334
+ /// `Resource::Font` carries `FontFaceOverrides` and `load_resource`
3335
+ /// builds a `FontInfoOverride` from them — meaning a CSS-declared
3336
+ /// `font-family` alias wins over the file's own metadata.
3337
+ ///
3338
+ /// We drive `load_resource` directly with a fabricated response rather
3339
+ /// than go through HTML parsing → `fetch_font_face`, because the
3340
+ /// downstream HTML parser lives in `blitz-html` (would be a circular
3341
+ /// crate dependency). The mapping from `@font-face` descriptors into
3342
+ /// `FontFaceOverrides` is covered by the unit tests in `net.rs`; this
3343
+ /// test pins the load-side of the pipeline.
3344
+ #[test]
3345
+ fn font_face_overrides_alias_family_name() {
3346
+ const ALIAS: &str = "AliasedFamily";
3347
+
3348
+ let mut document = BaseDocument::new(DocumentConfig::default());
3349
+
3350
+ // Sanity: the alias name is not registered before we feed the font.
3351
+ {
3352
+ let mut ctx = document.font_ctx.lock().unwrap();
3353
+ assert!(
3354
+ ctx.collection.family_id(ALIAS).is_none(),
3355
+ "alias must not exist before registration",
3356
+ );
3357
+ }
3358
+
3359
+ // Drive `load_resource` with a `Resource::Font` whose overrides
3360
+ // assert the CSS-side family name. We use the bullet font as a
3361
+ // valid font payload — its internal `name` table is irrelevant to
3362
+ // the assertion; what matters is whether the override wins.
3363
+ let response = ResourceLoadResponse {
3364
+ request_id: 0,
3365
+ node_id: None,
3366
+ resolved_url: Some(String::from("test://aliased-family")),
3367
+ result: Ok(Resource::Font(
3368
+ blitz_traits::net::Bytes::from_static(crate::BULLET_FONT),
3369
+ FontFaceOverrides {
3370
+ family_name: Some(String::from(ALIAS)),
3371
+ weight: Some(800.0),
3372
+ style: Some(parley::fontique::FontStyle::Italic),
3373
+ },
3374
+ )),
3375
+ };
3376
+ document.load_resource(response);
3377
+
3378
+ // The override must have taken effect: parley's `Collection` now
3379
+ // resolves the CSS-declared alias to a registered family.
3380
+ let mut ctx = document.font_ctx.lock().unwrap();
3381
+ let family_id = ctx
3382
+ .collection
3383
+ .family_id(ALIAS)
3384
+ .expect("CSS-declared family name should be registered as a family alias");
3385
+ let resolved_name = ctx
3386
+ .collection
3387
+ .family_name(family_id)
3388
+ .expect("family id should resolve back to a name");
3389
+ assert_eq!(
3390
+ resolved_name, ALIAS,
3391
+ "registered family should report the CSS-declared name, \
3392
+ not the font file's internal `name` table entry",
3393
+ );
3394
+ }
3395
+ }