blitz-pdf 0.1.3 → 0.1.4

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 (64) hide show
  1. package/README.md +6 -6
  2. package/THIRD_PARTY_NOTICES.md +5 -2
  3. package/docs/layout-regressions.md +65 -0
  4. package/docs/pagination-regressions.md +7 -6
  5. package/package.json +1 -1
  6. package/vendor/README.md +35 -0
  7. package/vendor/blitz-dom/Cargo.toml +113 -0
  8. package/vendor/blitz-dom/LICENSE-APACHE +176 -0
  9. package/vendor/blitz-dom/LICENSE-MIT +23 -0
  10. package/vendor/blitz-dom/assets/default.css +1142 -0
  11. package/vendor/blitz-dom/assets/moz-bullet-font.otf +0 -0
  12. package/vendor/blitz-dom/src/accessibility.rs +255 -0
  13. package/vendor/blitz-dom/src/config.rs +97 -0
  14. package/vendor/blitz-dom/src/cssom.rs +971 -0
  15. package/vendor/blitz-dom/src/debug.rs +206 -0
  16. package/vendor/blitz-dom/src/document.rs +3395 -0
  17. package/vendor/blitz-dom/src/events/driver.rs +427 -0
  18. package/vendor/blitz-dom/src/events/focus.rs +40 -0
  19. package/vendor/blitz-dom/src/events/ime.rs +31 -0
  20. package/vendor/blitz-dom/src/events/keyboard.rs +174 -0
  21. package/vendor/blitz-dom/src/events/mod.rs +315 -0
  22. package/vendor/blitz-dom/src/events/pointer.rs +849 -0
  23. package/vendor/blitz-dom/src/font_metrics.rs +177 -0
  24. package/vendor/blitz-dom/src/form.rs +463 -0
  25. package/vendor/blitz-dom/src/html.rs +38 -0
  26. package/vendor/blitz-dom/src/iframe.rs +186 -0
  27. package/vendor/blitz-dom/src/layout/construct.rs +1328 -0
  28. package/vendor/blitz-dom/src/layout/damage.rs +642 -0
  29. package/vendor/blitz-dom/src/layout/inline.rs +1160 -0
  30. package/vendor/blitz-dom/src/layout/list.rs +237 -0
  31. package/vendor/blitz-dom/src/layout/mod.rs +969 -0
  32. package/vendor/blitz-dom/src/layout/paint_tree.rs +462 -0
  33. package/vendor/blitz-dom/src/layout/replaced.rs +439 -0
  34. package/vendor/blitz-dom/src/layout/table.rs +967 -0
  35. package/vendor/blitz-dom/src/layout/text_transform.rs +979 -0
  36. package/vendor/blitz-dom/src/layout/writing_mode.rs +437 -0
  37. package/vendor/blitz-dom/src/lib.rs +160 -0
  38. package/vendor/blitz-dom/src/mutator.rs +1818 -0
  39. package/vendor/blitz-dom/src/net.rs +649 -0
  40. package/vendor/blitz-dom/src/node/attributes.rs +60 -0
  41. package/vendor/blitz-dom/src/node/custom_widget.rs +178 -0
  42. package/vendor/blitz-dom/src/node/element.rs +1106 -0
  43. package/vendor/blitz-dom/src/node/mod.rs +30 -0
  44. package/vendor/blitz-dom/src/node/node.rs +2036 -0
  45. package/vendor/blitz-dom/src/node/scrollbar.rs +237 -0
  46. package/vendor/blitz-dom/src/node/serialize.rs +225 -0
  47. package/vendor/blitz-dom/src/node/stylo_data.rs +142 -0
  48. package/vendor/blitz-dom/src/node/svg.rs +265 -0
  49. package/vendor/blitz-dom/src/node/text.rs +804 -0
  50. package/vendor/blitz-dom/src/query_selector.rs +275 -0
  51. package/vendor/blitz-dom/src/resolve.rs +474 -0
  52. package/vendor/blitz-dom/src/resolved_style.rs +797 -0
  53. package/vendor/blitz-dom/src/scrolling.rs +780 -0
  54. package/vendor/blitz-dom/src/selection.rs +122 -0
  55. package/vendor/blitz-dom/src/stylo.rs +1518 -0
  56. package/vendor/blitz-dom/src/stylo_device.rs +88 -0
  57. package/vendor/blitz-dom/src/stylo_to_cursor_icon.rs +49 -0
  58. package/vendor/blitz-dom/src/stylo_to_kurbo.rs +129 -0
  59. package/vendor/blitz-dom/src/stylo_to_parley.rs +655 -0
  60. package/vendor/blitz-dom/src/traversal.rs +600 -0
  61. package/vendor/blitz-dom/src/tree.rs +136 -0
  62. package/vendor/blitz-dom/src/url.rs +63 -0
  63. package/vendor/blitz-dom/src/util.rs +256 -0
  64. package/wasm/blitz_pdf_bg.wasm +0 -0
@@ -0,0 +1,1106 @@
1
+ use blitz_traits::node_id::NodeId;
2
+ use cssparser::Parser;
3
+ use kurbo::{Affine, Rect as KurboRect};
4
+ use linebender_resource_handle::Blob;
5
+ use markup5ever::{LocalName, QualName, local_name};
6
+ use selectors::matching::{ElementSelectorFlags, QuirksMode};
7
+ use std::cell::Cell;
8
+ use std::str::FromStr;
9
+ use std::sync::Arc;
10
+ use std::sync::atomic::AtomicBool;
11
+ use style::Atom;
12
+ use style::parser::ParserContext;
13
+ use style::properties::{Importance, PropertyDeclaration, PropertyId, SourcePropertyDeclaration};
14
+ use style::stylesheets::{DocumentStyleSheet, Origin, UrlExtraData};
15
+ use style::values::computed::Display as StyloDisplay;
16
+ use style::{
17
+ properties::{PropertyDeclarationBlock, parse_style_attribute},
18
+ servo_arc::Arc as ServoArc,
19
+ shared_lock::{Locked, SharedRwLock},
20
+ stylesheets::CssRuleType,
21
+ };
22
+ use style_dom::ElementState;
23
+ use style_traits::ParsingMode;
24
+ use taffy::{Cache, prelude::Layout};
25
+ use thin_vec::ThinVec;
26
+ use url::Url;
27
+
28
+ use super::stylo_data::StyloData;
29
+ #[cfg(feature = "svg")]
30
+ use super::svg::SvgImageData;
31
+ use super::{Attribute, Attributes};
32
+ use crate::Document;
33
+ use crate::layout::table::TableContext;
34
+ use crate::node::{TextBrush, TextInputData, TextLayout};
35
+
36
+ #[cfg(feature = "custom-widget")]
37
+ use super::custom_widget::CustomWidgetData;
38
+
39
+ macro_rules! local_names {
40
+ ($($name:tt),+) => {
41
+ [$(local_name!($name),)+]
42
+ };
43
+ }
44
+
45
+ pub struct ElementData {
46
+ /// The elements tag name, namespace and prefix
47
+ pub name: QualName,
48
+
49
+ /// The elements id attribute parsed as an atom (if it has one)
50
+ pub id: Option<Atom>,
51
+
52
+ /// The element's attributes
53
+ pub attrs: Attributes,
54
+
55
+ /// Whether the element is focussable
56
+ pub is_focussable: bool,
57
+
58
+ /// The element's parsed style attribute (used by stylo)
59
+ pub style_attribute: Option<ServoArc<Locked<PropertyDeclarationBlock>>>,
60
+
61
+ /// Heterogeneous data that depends on the element's type.
62
+ /// For example:
63
+ /// - The image data for \<img\> elements.
64
+ /// - The parley Layout for inline roots.
65
+ /// - The text editor for input/textarea elements
66
+ pub special_data: SpecialElementData,
67
+
68
+ pub background_images: ThinVec<Option<ImageResourceData>>,
69
+
70
+ pub mask_images: ThinVec<Option<ImageResourceData>>,
71
+
72
+ /// Parley text layout (elements with inline inner display mode only)
73
+ pub inline_layout_data: Option<Box<TextLayout>>,
74
+
75
+ /// Data associated with display: list-item. Note that this display mode
76
+ /// does not exclude inline_layout_data
77
+ pub list_item_data: Option<Box<ListItemLayout>>,
78
+
79
+ /// The element's template contents (\<template\> elements only)
80
+ pub template_contents: Option<NodeId>,
81
+ // /// Whether the node is a [HTML integration point] (https://html.spec.whatwg.org/multipage/#html-integration-point)
82
+ // pub mathml_annotation_xml_integration_point: bool,
83
+
84
+ // ---------------------------------------------------------------------
85
+ // Fields moved from `Node`. These live on the element data so that the
86
+ // `Node` struct itself only carries tree-structure information.
87
+ // ---------------------------------------------------------------------
88
+ /// Style data from stylo, plus a lock guard that allows access to it.
89
+ pub stylo_element_data: StyloData,
90
+ pub selector_flags: Cell<ElementSelectorFlags>,
91
+ /// A clone of the document's shared style lock. Set when the owning
92
+ /// [`Node`](super::Node) is constructed.
93
+ pub guard: Option<SharedRwLock>,
94
+ pub element_state: ElementState,
95
+ pub has_snapshot: bool,
96
+ pub snapshot_handled: AtomicBool,
97
+ /// Whether any descendant of this node needs restyling.
98
+ /// Used by Stylo's incremental style traversal to skip unchanged subtrees.
99
+ pub dirty_descendants: AtomicBool,
100
+ /// Whether this node or any of its descendants may carry `RestyleDamage`.
101
+ /// Used by the damage propagation pass to skip unchanged subtrees.
102
+ pub damaged_descendants: AtomicBool,
103
+
104
+ // Pseudo element nodes
105
+ pub before: Option<NodeId>,
106
+ pub after: Option<NodeId>,
107
+
108
+ /// Detailed layout information from the most recent layout (currently
109
+ /// grid track sizing information for grid containers only). Used by
110
+ /// devtools grid inspection and out-of-flow grid-area positioning.
111
+ pub detailed_layout_info: taffy::DetailedLayoutInfo<Atom>,
112
+
113
+ // Taffy layout data:
114
+ pub display_constructed_as: StyloDisplay,
115
+ /// Layout output state (`None` until layout first writes to this node).
116
+ pub layout_data: Option<Box<LayoutData>>,
117
+ pub transform: Option<Box<Affine>>,
118
+ }
119
+
120
+ /// Taffy layout output state (cache, layouts, scroll offset and overflow).
121
+ ///
122
+ /// Lazily boxed on [`ElementData`] / [`DocumentData`]: inline-level elements
123
+ /// are positioned by the inline (parley) layout rather than by taffy, so most
124
+ /// nodes on text-heavy pages never allocate one.
125
+ #[derive(Debug, Clone)]
126
+ pub struct LayoutData {
127
+ pub cache: Cache,
128
+ pub unrounded_layout: Layout,
129
+ pub final_layout: Layout,
130
+ pub scroll_offset: crate::Point<f64>,
131
+ pub scrollable_overflow: KurboRect,
132
+ /// The writing mode this node's layout algorithm ran in (the root takes it from `<body>`),
133
+ /// recorded by the layout pass for CSSOM geometry lookups.
134
+ #[cfg(feature = "writing-mode")]
135
+ pub writing_mode: stylo_taffy::WritingMode,
136
+ }
137
+
138
+ impl LayoutData {
139
+ pub const fn new() -> Self {
140
+ Self {
141
+ cache: Cache::new(),
142
+ unrounded_layout: Layout::new(),
143
+ final_layout: Layout::new(),
144
+ scroll_offset: crate::Point::ZERO,
145
+ scrollable_overflow: KurboRect::ZERO,
146
+ #[cfg(feature = "writing-mode")]
147
+ writing_mode: stylo_taffy::WritingMode::empty(),
148
+ }
149
+ }
150
+ }
151
+
152
+ impl Default for LayoutData {
153
+ fn default() -> Self {
154
+ Self::new()
155
+ }
156
+ }
157
+
158
+ /// Shared read-only default returned by accessors for nodes whose
159
+ /// [`LayoutData`] has never been allocated.
160
+ pub(crate) static DEFAULT_LAYOUT_DATA: LayoutData = LayoutData::new();
161
+
162
+ impl ElementData {
163
+ /// This element's layout output state, or a shared default if layout has
164
+ /// never written to this element.
165
+ #[inline]
166
+ pub fn layout_data(&self) -> &LayoutData {
167
+ self.layout_data.as_deref().unwrap_or(&DEFAULT_LAYOUT_DATA)
168
+ }
169
+
170
+ /// Mutable access to this element's layout output state, allocating it if
171
+ /// it does not yet exist.
172
+ #[inline]
173
+ pub fn layout_data_mut(&mut self) -> &mut LayoutData {
174
+ self.layout_data.get_or_insert_default()
175
+ }
176
+ }
177
+
178
+ impl DocumentData {
179
+ /// The document node's layout output state, or a shared default if layout
180
+ /// has never written to it.
181
+ #[inline]
182
+ pub fn layout_data(&self) -> &LayoutData {
183
+ self.layout_data.as_deref().unwrap_or(&DEFAULT_LAYOUT_DATA)
184
+ }
185
+
186
+ /// Mutable access to the document node's layout output state, allocating
187
+ /// it if it does not yet exist.
188
+ #[inline]
189
+ pub fn layout_data_mut(&mut self) -> &mut LayoutData {
190
+ self.layout_data.get_or_insert_default()
191
+ }
192
+ }
193
+
194
+ /// Data specific to the [`Document`](super::super::Document) root node.
195
+ ///
196
+ /// The document node participates in layout and styling like an element, so it
197
+ /// carries the same style/layout fields that were previously stored directly on
198
+ /// [`Node`](super::Node).
199
+ pub struct DocumentData {
200
+ pub stylo_element_data: StyloData,
201
+ /// Selector flags deposited here by `apply_selector_flags` when a
202
+ /// `for_parent()` flag is applied while matching the root `<html>` element,
203
+ /// whose parent node is the document.
204
+ pub selector_flags: Cell<ElementSelectorFlags>,
205
+ /// A clone of the document's shared style lock. Set when the owning
206
+ /// [`Node`](super::Node) is constructed.
207
+ pub guard: Option<SharedRwLock>,
208
+ pub dirty_descendants: AtomicBool,
209
+ pub damaged_descendants: AtomicBool,
210
+ pub element_state: ElementState,
211
+ pub has_snapshot: bool,
212
+ pub snapshot_handled: AtomicBool,
213
+ pub display_constructed_as: StyloDisplay,
214
+ /// Layout output state (`None` until layout first writes to this node).
215
+ pub layout_data: Option<Box<LayoutData>>,
216
+ pub transform: Option<Box<Affine>>,
217
+ }
218
+
219
+ // Hand-written like `ElementData`'s, because `ElementSelectorFlags` does not
220
+ // implement `Debug`. Every other field is still reported.
221
+ impl std::fmt::Debug for DocumentData {
222
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
223
+ f.debug_struct("DocumentData")
224
+ .field("stylo_element_data", &self.stylo_element_data)
225
+ .field("guard", &self.guard)
226
+ .field("dirty_descendants", &self.dirty_descendants)
227
+ .field("damaged_descendants", &self.damaged_descendants)
228
+ .field("element_state", &self.element_state)
229
+ .field("has_snapshot", &self.has_snapshot)
230
+ .field("snapshot_handled", &self.snapshot_handled)
231
+ .field("display_constructed_as", &self.display_constructed_as)
232
+ .field("layout_data", &self.layout_data)
233
+ .field("transform", &self.transform)
234
+ .finish_non_exhaustive()
235
+ }
236
+ }
237
+
238
+ impl DocumentData {
239
+ pub fn new() -> Self {
240
+ Self {
241
+ stylo_element_data: Default::default(),
242
+ selector_flags: Cell::new(ElementSelectorFlags::empty()),
243
+ guard: None,
244
+ dirty_descendants: AtomicBool::new(true),
245
+ damaged_descendants: AtomicBool::new(true),
246
+ element_state: ElementState::empty(),
247
+ has_snapshot: false,
248
+ snapshot_handled: AtomicBool::new(false),
249
+ display_constructed_as: StyloDisplay::Block,
250
+ layout_data: None,
251
+ transform: None,
252
+ }
253
+ }
254
+ }
255
+
256
+ impl Default for DocumentData {
257
+ fn default() -> Self {
258
+ Self::new()
259
+ }
260
+ }
261
+
262
+ impl Clone for DocumentData {
263
+ fn clone(&self) -> Self {
264
+ // Runtime style/layout state is reset (the document node is not
265
+ // meaningfully cloneable), matching `ElementData`'s clone semantics.
266
+ Self {
267
+ guard: self.guard.clone(),
268
+ ..Self::new()
269
+ }
270
+ }
271
+ }
272
+
273
+ impl std::fmt::Debug for ElementData {
274
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275
+ f.debug_struct("ElementData")
276
+ .field("name", &self.name)
277
+ .field("id", &self.id)
278
+ .field("attrs", &self.attrs)
279
+ .field("is_focussable", &self.is_focussable)
280
+ .field("style_attribute", &self.style_attribute)
281
+ .field("special_data", &self.special_data)
282
+ .field("background_images", &self.background_images)
283
+ .field("mask_images", &self.mask_images)
284
+ .field("inline_layout_data", &self.inline_layout_data)
285
+ .field("list_item_data", &self.list_item_data)
286
+ .field("template_contents", &self.template_contents)
287
+ .field("element_state", &self.element_state)
288
+ .field("display_constructed_as", &self.display_constructed_as)
289
+ .finish_non_exhaustive()
290
+ }
291
+ }
292
+
293
+ impl Clone for ElementData {
294
+ /// Clones the *content* of the element (name, attributes, style attribute,
295
+ /// special data, etc.). Runtime style/layout state (stylo data, taffy
296
+ /// layout, caches, pseudo-element ids, ...) is reset to its default so that
297
+ /// the clone behaves like a freshly-created element that has not yet been
298
+ /// styled or laid out.
299
+ fn clone(&self) -> Self {
300
+ Self {
301
+ name: self.name.clone(),
302
+ id: self.id.clone(),
303
+ attrs: self.attrs.clone(),
304
+ is_focussable: self.is_focussable,
305
+ style_attribute: self.style_attribute.clone(),
306
+ special_data: self.special_data.clone(),
307
+ background_images: self.background_images.clone(),
308
+ mask_images: self.mask_images.clone(),
309
+ inline_layout_data: self.inline_layout_data.clone(),
310
+ list_item_data: self.list_item_data.clone(),
311
+ template_contents: self.template_contents,
312
+
313
+ // Runtime state: reset to defaults.
314
+ stylo_element_data: Default::default(),
315
+ selector_flags: Cell::new(ElementSelectorFlags::empty()),
316
+ guard: self.guard.clone(),
317
+ element_state: self.element_state,
318
+ has_snapshot: false,
319
+ snapshot_handled: AtomicBool::new(false),
320
+ dirty_descendants: AtomicBool::new(true),
321
+ damaged_descendants: AtomicBool::new(true),
322
+ before: None,
323
+ after: None,
324
+ detailed_layout_info: taffy::DetailedLayoutInfo::None,
325
+ display_constructed_as: StyloDisplay::Block,
326
+ layout_data: None,
327
+ transform: None,
328
+ }
329
+ }
330
+ }
331
+
332
+ #[derive(Copy, Clone, Default)]
333
+ #[non_exhaustive]
334
+ pub enum SpecialElementType {
335
+ Stylesheet,
336
+ Image,
337
+ Canvas,
338
+ TableRoot,
339
+ TextInput,
340
+ CheckboxInput,
341
+ #[cfg(feature = "file-input")]
342
+ FileInput,
343
+ #[default]
344
+ None,
345
+ }
346
+
347
+ /// Heterogeneous data that depends on the element's type.
348
+ #[derive(Default)]
349
+ pub enum SpecialElementData {
350
+ /// A sub-document such an \<iframe\> or \<web-view\> element
351
+ SubDocument(Box<dyn Document>),
352
+ /// A custom widget
353
+ #[cfg(feature = "custom-widget")]
354
+ CustomWidget(CustomWidgetData),
355
+ /// A stylesheet
356
+ Stylesheet(DocumentStyleSheet),
357
+ /// An \<img\> element's image data
358
+ Image(Box<ImageData>),
359
+ /// A \<canvas\> element's custom paint source
360
+ Canvas(CanvasData),
361
+ /// Pre-computed table layout data
362
+ TableRoot(Arc<TableContext>),
363
+ /// Parley text editor (text inputs)
364
+ TextInput(TextInputData),
365
+ /// Checkbox checked state
366
+ CheckboxInput(bool),
367
+ /// Selected files
368
+ #[cfg(feature = "file-input")]
369
+ FileInput(FileData),
370
+ /// No data (for nodes that don't need any node-specific data)
371
+ #[default]
372
+ None,
373
+ }
374
+
375
+ impl Clone for SpecialElementData {
376
+ fn clone(&self) -> Self {
377
+ match self {
378
+ Self::SubDocument(_) => Self::None, // TODO
379
+ #[cfg(feature = "custom-widget")]
380
+ Self::CustomWidget(_) => Self::None, // TODO
381
+ Self::Stylesheet(data) => Self::Stylesheet(data.clone()),
382
+ Self::Image(data) => Self::Image(data.clone()),
383
+ Self::Canvas(data) => Self::Canvas(data.clone()),
384
+ Self::TableRoot(data) => Self::TableRoot(data.clone()),
385
+ Self::TextInput(data) => Self::TextInput(data.clone()),
386
+ Self::CheckboxInput(data) => Self::CheckboxInput(*data),
387
+ #[cfg(feature = "file-input")]
388
+ Self::FileInput(data) => Self::FileInput(data.clone()),
389
+ Self::None => Self::None,
390
+ }
391
+ }
392
+ }
393
+
394
+ impl SpecialElementData {
395
+ pub fn take(&mut self) -> Self {
396
+ std::mem::take(self)
397
+ }
398
+ }
399
+
400
+ impl ElementData {
401
+ pub fn new(name: QualName, attrs: Vec<Attribute>) -> Self {
402
+ let id_attr_atom = attrs
403
+ .iter()
404
+ .find(|attr| &attr.name.local == "id")
405
+ .map(|attr| attr.value.as_ref())
406
+ .map(|value: &str| Atom::from(value));
407
+
408
+ let mut data = ElementData {
409
+ name,
410
+ id: id_attr_atom,
411
+ attrs: Attributes::new(attrs),
412
+ is_focussable: false,
413
+ style_attribute: Default::default(),
414
+ inline_layout_data: None,
415
+ list_item_data: None,
416
+ special_data: SpecialElementData::None,
417
+ template_contents: None,
418
+ background_images: ThinVec::new(),
419
+ mask_images: ThinVec::new(),
420
+
421
+ stylo_element_data: Default::default(),
422
+ selector_flags: Cell::new(ElementSelectorFlags::empty()),
423
+ guard: None,
424
+ element_state: ElementState::empty(),
425
+ has_snapshot: false,
426
+ snapshot_handled: AtomicBool::new(false),
427
+ dirty_descendants: AtomicBool::new(true),
428
+ damaged_descendants: AtomicBool::new(true),
429
+ before: None,
430
+ after: None,
431
+ detailed_layout_info: taffy::DetailedLayoutInfo::None,
432
+ display_constructed_as: StyloDisplay::Block,
433
+ layout_data: None,
434
+ transform: None,
435
+ };
436
+ data.flush_is_focussable();
437
+ data.flush_link_state();
438
+
439
+ // Mirror the `checked` attribute into the element state so that `:checked`
440
+ // selectors can be matched (and invalidated) from `ElementState`.
441
+ if data.name.local == local_name!("input")
442
+ && matches!(
443
+ data.attr(local_name!("type")),
444
+ Some("checkbox") | Some("radio")
445
+ )
446
+ && data.has_attr(local_name!("checked"))
447
+ {
448
+ data.element_state.insert(ElementState::CHECKED);
449
+ }
450
+
451
+ // The element state needs to be modified if the element can be disabled.
452
+ if data.can_be_disabled() {
453
+ data.element_state
454
+ .insert(match data.has_attr(local_name!("disabled")) {
455
+ true => ElementState::DISABLED,
456
+ false => ElementState::ENABLED,
457
+ });
458
+ }
459
+
460
+ data
461
+ }
462
+
463
+ pub fn attrs(&self) -> &[Attribute] {
464
+ &self.attrs
465
+ }
466
+
467
+ pub fn attr(&self, name: impl PartialEq<LocalName>) -> Option<&str> {
468
+ let attr = self.attrs.iter().find(|attr| name == attr.name.local)?;
469
+ Some(&attr.value)
470
+ }
471
+
472
+ pub fn attr_parsed<T: FromStr>(&self, name: impl PartialEq<LocalName>) -> Option<T> {
473
+ let attr = self.attrs.iter().find(|attr| name == attr.name.local)?;
474
+ attr.value.parse::<T>().ok()
475
+ }
476
+
477
+ /// Detects the presence of the attribute, treating *any* value as truthy.
478
+ pub fn has_attr(&self, name: impl PartialEq<LocalName>) -> bool {
479
+ self.attrs.iter().any(|attr| name == attr.name.local)
480
+ }
481
+
482
+ pub fn can_be_disabled(&self) -> bool {
483
+ local_names!("button", "input", "select", "textarea").contains(&self.name.local)
484
+ }
485
+
486
+ /// Whether this element is a link (an `<a>` or `<area>` element with an `href` attribute)
487
+ pub fn is_link(&self) -> bool {
488
+ (self.name.local == local_name!("a") || self.name.local == local_name!("area"))
489
+ && self.has_attr(local_name!("href"))
490
+ }
491
+
492
+ /// Sync the visitedness bits of `element_state` with the element's link-ness.
493
+ /// Blitz does not track browsing history, so all links are unvisited.
494
+ ///
495
+ /// Stylo's snapshot invalidation (`ElementWrapper::is_link`) determines link-ness
496
+ /// from these state bits, so they must be kept accurate for `:link`/`:any-link`
497
+ /// selectors to be correctly invalidated. Must be called whenever the `href`
498
+ /// attribute is added or removed.
499
+ pub fn flush_link_state(&mut self) {
500
+ self.element_state
501
+ .remove(ElementState::VISITED_OR_UNVISITED);
502
+ if self.is_link() {
503
+ self.element_state.insert(ElementState::UNVISITED);
504
+ }
505
+ }
506
+
507
+ pub fn image_data(&self) -> Option<&ImageData> {
508
+ match &self.special_data {
509
+ SpecialElementData::Image(data) => Some(&**data),
510
+ _ => None,
511
+ }
512
+ }
513
+
514
+ pub fn image_data_mut(&mut self) -> Option<&mut ImageData> {
515
+ match self.special_data {
516
+ SpecialElementData::Image(ref mut data) => Some(&mut **data),
517
+ _ => None,
518
+ }
519
+ }
520
+
521
+ pub fn raster_image_data(&self) -> Option<&RasterImageData> {
522
+ match self.image_data()? {
523
+ ImageData::Raster(data) => Some(data),
524
+ _ => None,
525
+ }
526
+ }
527
+
528
+ pub fn raster_image_data_mut(&mut self) -> Option<&mut RasterImageData> {
529
+ match self.image_data_mut()? {
530
+ ImageData::Raster(data) => Some(data),
531
+ _ => None,
532
+ }
533
+ }
534
+
535
+ pub fn canvas_data(&self) -> Option<&CanvasData> {
536
+ match &self.special_data {
537
+ SpecialElementData::Canvas(data) => Some(data),
538
+ _ => None,
539
+ }
540
+ }
541
+
542
+ pub fn sub_doc_data(&self) -> Option<&dyn Document> {
543
+ match &self.special_data {
544
+ SpecialElementData::SubDocument(data) => Some(data.as_ref()),
545
+ _ => None,
546
+ }
547
+ }
548
+
549
+ pub fn sub_doc_data_mut(&mut self) -> Option<&mut dyn Document> {
550
+ match &mut self.special_data {
551
+ SpecialElementData::SubDocument(data) => Some(data.as_mut()),
552
+ _ => None,
553
+ }
554
+ }
555
+
556
+ #[cfg(feature = "svg")]
557
+ pub fn svg_data(&self) -> Option<&usvg::Tree> {
558
+ match self.image_data()? {
559
+ ImageData::Svg(data) => Some(&data.tree),
560
+ _ => None,
561
+ }
562
+ }
563
+
564
+ pub fn text_input_data(&self) -> Option<&TextInputData> {
565
+ match &self.special_data {
566
+ SpecialElementData::TextInput(data) => Some(data),
567
+ _ => None,
568
+ }
569
+ }
570
+
571
+ pub fn text_input_data_mut(&mut self) -> Option<&mut TextInputData> {
572
+ match &mut self.special_data {
573
+ SpecialElementData::TextInput(data) => Some(data),
574
+ _ => None,
575
+ }
576
+ }
577
+
578
+ #[cfg(feature = "custom-widget")]
579
+ pub fn custom_widget_data(&self) -> Option<&CustomWidgetData> {
580
+ match &self.special_data {
581
+ SpecialElementData::CustomWidget(data) => Some(data),
582
+ _ => None,
583
+ }
584
+ }
585
+
586
+ #[cfg(feature = "custom-widget")]
587
+ pub fn custom_widget_data_mut(&mut self) -> Option<&mut CustomWidgetData> {
588
+ match &mut self.special_data {
589
+ SpecialElementData::CustomWidget(data) => Some(data),
590
+ _ => None,
591
+ }
592
+ }
593
+
594
+ pub fn checkbox_input_checked(&self) -> Option<bool> {
595
+ match self.special_data {
596
+ SpecialElementData::CheckboxInput(checked) => Some(checked),
597
+ _ => None,
598
+ }
599
+ }
600
+
601
+ pub fn checkbox_input_checked_mut(&mut self) -> Option<&mut bool> {
602
+ match self.special_data {
603
+ SpecialElementData::CheckboxInput(ref mut checked) => Some(checked),
604
+ _ => None,
605
+ }
606
+ }
607
+
608
+ /// Set the checked state of a checkbox/radio input, keeping the
609
+ /// `ElementState::CHECKED` bit (used for `:checked` selector matching and
610
+ /// invalidation) in sync with the special data.
611
+ pub fn set_checkbox_input_checked(&mut self, checked: bool) {
612
+ if let Some(is_checked) = self.checkbox_input_checked_mut() {
613
+ *is_checked = checked;
614
+ }
615
+ self.element_state.set(ElementState::CHECKED, checked);
616
+ }
617
+
618
+ #[cfg(feature = "file-input")]
619
+ pub fn file_data(&self) -> Option<&FileData> {
620
+ match &self.special_data {
621
+ SpecialElementData::FileInput(data) => Some(data),
622
+ _ => None,
623
+ }
624
+ }
625
+
626
+ #[cfg(feature = "file-input")]
627
+ pub fn file_data_mut(&mut self) -> Option<&mut FileData> {
628
+ match &mut self.special_data {
629
+ SpecialElementData::FileInput(data) => Some(data),
630
+ _ => None,
631
+ }
632
+ }
633
+
634
+ pub fn flush_is_focussable(&mut self) {
635
+ let disabled: bool = self.attr_parsed(local_name!("disabled")).unwrap_or(false);
636
+ let tabindex: Option<i32> = self.attr_parsed(local_name!("tabindex"));
637
+ let contains_sub_document: bool = self.sub_doc_data().is_some();
638
+
639
+ self.is_focussable = contains_sub_document
640
+ || (!disabled
641
+ && match tabindex {
642
+ Some(index) => index >= 0,
643
+ None => {
644
+ // Some focusable HTML elements have a default tabindex value of 0 set under the hood by the user agent.
645
+ // These elements are:
646
+ // - <a> or <area> with href attribute
647
+ // - <button>, <frame>, <iframe>, <input>, <object>, <select>, <textarea>, and SVG <a> element
648
+ // - <summary> element that provides summary for a <details> element.
649
+
650
+ if [local_name!("a"), local_name!("area")].contains(&self.name.local) {
651
+ self.attr(local_name!("href")).is_some()
652
+ } else {
653
+ const DEFAULT_FOCUSSABLE_ELEMENTS: [LocalName; 7] = [
654
+ local_name!("button"),
655
+ local_name!("input"),
656
+ local_name!("select"),
657
+ local_name!("textarea"),
658
+ local_name!("frame"),
659
+ local_name!("iframe"),
660
+ local_name!("summary"),
661
+ ];
662
+ DEFAULT_FOCUSSABLE_ELEMENTS.contains(&self.name.local)
663
+ }
664
+ }
665
+ })
666
+ }
667
+
668
+ pub fn flush_style_attribute(&mut self, guard: &SharedRwLock, url_extra_data: &UrlExtraData) {
669
+ self.style_attribute = self.attr(local_name!("style")).map(|style_str| {
670
+ ServoArc::new(guard.wrap(parse_style_attribute(
671
+ style_str,
672
+ url_extra_data,
673
+ None,
674
+ QuirksMode::NoQuirks,
675
+ CssRuleType::Style,
676
+ )))
677
+ });
678
+ }
679
+
680
+ pub fn set_style_property(
681
+ &mut self,
682
+ name: &str,
683
+ value: &str,
684
+ guard: &SharedRwLock,
685
+ url_extra_data: UrlExtraData,
686
+ ) -> bool {
687
+ let context = ParserContext::new(
688
+ Origin::Author,
689
+ &url_extra_data,
690
+ Some(CssRuleType::Style),
691
+ ParsingMode::DEFAULT,
692
+ QuirksMode::NoQuirks,
693
+ /* namespaces = */ Default::default(),
694
+ None,
695
+ None,
696
+ /* attr_taint = */ Default::default(),
697
+ );
698
+
699
+ let Ok(property_id) = PropertyId::parse(name, &context) else {
700
+ #[cfg(feature = "tracing")]
701
+ tracing::warn!(property = name, "Unsupported property");
702
+ return false;
703
+ };
704
+ let mut source_property_declaration = SourcePropertyDeclaration::default();
705
+ let mut parser = Parser::new(value);
706
+ let Ok(_) = PropertyDeclaration::parse_into(
707
+ &mut source_property_declaration,
708
+ property_id,
709
+ &context,
710
+ &mut parser,
711
+ ) else {
712
+ #[cfg(feature = "tracing")]
713
+ tracing::warn!(property = name, value, "Invalid property value");
714
+ return false;
715
+ };
716
+
717
+ self.mutable_style_attribute(guard)
718
+ .write_with(&mut guard.write())
719
+ .extend(source_property_declaration.drain(), Importance::Normal);
720
+
721
+ true
722
+ }
723
+
724
+ // Copy-on-write: once Stylo has put this block in the rule tree it may be
725
+ // shared with other elements' styles (mirrors Gecko's
726
+ // nsDOMCSSDeclaration::EnsureBlockMutable).
727
+ fn mutable_style_attribute(
728
+ &mut self,
729
+ guard: &SharedRwLock,
730
+ ) -> &ServoArc<Locked<PropertyDeclarationBlock>> {
731
+ let cloned = match &self.style_attribute {
732
+ None => Some(PropertyDeclarationBlock::new()),
733
+ Some(arc) => {
734
+ let read = guard.read();
735
+ let block = arc.read_with(&read);
736
+ block
737
+ .immutable
738
+ .load(std::sync::atomic::Ordering::Relaxed)
739
+ .then(|| block.clone())
740
+ }
741
+ };
742
+ if let Some(block) = cloned {
743
+ self.style_attribute = Some(ServoArc::new(guard.wrap(block)));
744
+ }
745
+ self.style_attribute.as_ref().unwrap()
746
+ }
747
+
748
+ pub fn remove_style_property(
749
+ &mut self,
750
+ name: &str,
751
+ guard: &SharedRwLock,
752
+ url_extra_data: UrlExtraData,
753
+ ) -> bool {
754
+ let context = ParserContext::new(
755
+ Origin::Author,
756
+ &url_extra_data,
757
+ Some(CssRuleType::Style),
758
+ ParsingMode::DEFAULT,
759
+ QuirksMode::NoQuirks,
760
+ /* namespaces = */ Default::default(),
761
+ None,
762
+ None,
763
+ /* attr_taint = */ Default::default(),
764
+ );
765
+ let Ok(property_id) = PropertyId::parse(name, &context) else {
766
+ #[cfg(feature = "tracing")]
767
+ tracing::warn!(property = name, "Unsupported property");
768
+ return false;
769
+ };
770
+
771
+ if self.style_attribute.is_none() {
772
+ return false;
773
+ }
774
+ let style = self.mutable_style_attribute(guard);
775
+ let mut guard = guard.write();
776
+ let style = style.write_with(&mut guard);
777
+ if let Some(index) = style.first_declaration_to_remove(&property_id) {
778
+ style.remove_property(&property_id, index);
779
+ return true;
780
+ }
781
+
782
+ false
783
+ }
784
+
785
+ pub fn set_sub_document(&mut self, sub_document: Box<dyn Document>) {
786
+ self.special_data = SpecialElementData::SubDocument(sub_document);
787
+ }
788
+
789
+ pub fn remove_sub_document(&mut self) {
790
+ self.special_data = SpecialElementData::None;
791
+ }
792
+
793
+ #[cfg(feature = "custom-widget")]
794
+ pub fn set_custom_widget(&mut self, widget: Box<dyn crate::Widget>) {
795
+ use crate::node::custom_widget::CustomWidgetData;
796
+ self.special_data = SpecialElementData::CustomWidget(CustomWidgetData::new(widget));
797
+ }
798
+
799
+ #[cfg(feature = "custom-widget")]
800
+ pub fn remove_custom_widget(&mut self) -> Vec<anyrender::ResourceId> {
801
+ let resource_ids = self
802
+ .custom_widget_data_mut()
803
+ .map(|widget_data| widget_data.take_resource_ids())
804
+ .unwrap_or_default();
805
+ self.special_data = SpecialElementData::None;
806
+ resource_ids
807
+ }
808
+
809
+ pub fn take_inline_layout(&mut self) -> Option<Box<TextLayout>> {
810
+ std::mem::take(&mut self.inline_layout_data)
811
+ }
812
+
813
+ pub(crate) fn is_button_input(&self) -> bool {
814
+ self.name.local == local_name!("input")
815
+ && matches!(
816
+ self.attr(local_name!("type")),
817
+ Some("button" | "submit" | "reset")
818
+ )
819
+ }
820
+
821
+ pub fn is_submit_button(&self) -> bool {
822
+ if self.name.local != local_name!("button") {
823
+ return false;
824
+ }
825
+ let type_attr = self.attr(local_name!("type"));
826
+ let is_submit = type_attr == Some("submit");
827
+ let is_auto_submit = type_attr.is_none()
828
+ && self.attr(LocalName::from("command")).is_none()
829
+ && self.attr(LocalName::from("commandfor")).is_none();
830
+ is_submit || is_auto_submit
831
+ }
832
+ }
833
+
834
+ #[derive(Debug, Clone, PartialEq)]
835
+ pub struct RasterImageData {
836
+ /// The width of the image
837
+ pub width: u32,
838
+ /// The height of the image
839
+ pub height: u32,
840
+ /// The raw image data in RGBA8 format
841
+ pub data: Blob<u8>,
842
+ }
843
+ impl RasterImageData {
844
+ pub fn new(width: u32, height: u32, data: Arc<Vec<u8>>) -> Self {
845
+ Self {
846
+ width,
847
+ height,
848
+ data: Blob::new(data),
849
+ }
850
+ }
851
+ }
852
+
853
+ #[derive(Debug, Clone)]
854
+ pub enum ImageData {
855
+ Raster(RasterImageData),
856
+ #[cfg(feature = "svg")]
857
+ Svg(SvgImageData),
858
+ None,
859
+ }
860
+
861
+ #[derive(Debug, Clone, PartialEq)]
862
+ pub enum Status {
863
+ Ok,
864
+ Error,
865
+ Loading,
866
+ }
867
+
868
+ #[derive(Debug, Clone)]
869
+ pub struct ImageResourceData {
870
+ /// The url of the background image
871
+ pub url: ServoArc<Url>,
872
+ /// The loading status of the background image
873
+ pub status: Status,
874
+ /// The image data
875
+ pub image: ImageData,
876
+ }
877
+
878
+ impl ImageResourceData {
879
+ pub fn new(url: ServoArc<Url>) -> Self {
880
+ Self {
881
+ url,
882
+ status: Status::Loading,
883
+ image: ImageData::None,
884
+ }
885
+ }
886
+ }
887
+
888
+ #[derive(Debug, Clone)]
889
+ pub struct CanvasData {
890
+ pub custom_paint_source_id: u64,
891
+ }
892
+
893
+ impl std::fmt::Debug for SpecialElementData {
894
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
895
+ match self {
896
+ SpecialElementData::SubDocument(_) => f.write_str("NodeSpecificData::SubDocument"),
897
+ #[cfg(feature = "custom-widget")]
898
+ SpecialElementData::CustomWidget(_) => f.write_str("NodeSpecificData::CustomWidget"),
899
+ SpecialElementData::Stylesheet(_) => f.write_str("NodeSpecificData::Stylesheet"),
900
+ SpecialElementData::Image(data) => match **data {
901
+ ImageData::Raster(_) => f.write_str("NodeSpecificData::Image(Raster)"),
902
+ #[cfg(feature = "svg")]
903
+ ImageData::Svg(_) => f.write_str("NodeSpecificData::Image(Svg)"),
904
+ ImageData::None => f.write_str("NodeSpecificData::Image(None)"),
905
+ },
906
+ SpecialElementData::Canvas(_) => f.write_str("NodeSpecificData::Canvas"),
907
+ SpecialElementData::TableRoot(_) => f.write_str("NodeSpecificData::TableRoot"),
908
+ SpecialElementData::TextInput(_) => f.write_str("NodeSpecificData::TextInput"),
909
+ SpecialElementData::CheckboxInput(_) => f.write_str("NodeSpecificData::CheckboxInput"),
910
+ #[cfg(feature = "file-input")]
911
+ SpecialElementData::FileInput(_) => f.write_str("NodeSpecificData::FileInput"),
912
+ SpecialElementData::None => f.write_str("NodeSpecificData::None"),
913
+ }
914
+ }
915
+ }
916
+
917
+ #[derive(Clone)]
918
+ pub struct ListItemLayout {
919
+ pub marker: Marker,
920
+ pub position: ListItemLayoutPosition,
921
+ }
922
+
923
+ //We seperate chars from strings in order to optimise rendering - ie not needing to
924
+ //construct a whole parley layout for simple char markers
925
+ #[derive(Debug, PartialEq, Clone)]
926
+ pub enum Marker {
927
+ Char(char),
928
+ String(String),
929
+ }
930
+
931
+ //Value depends on list-style-position, determining whether a seperate layout is created for it
932
+ #[derive(Clone)]
933
+ pub enum ListItemLayoutPosition {
934
+ Inside,
935
+ Outside(Box<parley::Layout<TextBrush>>),
936
+ }
937
+
938
+ impl std::fmt::Debug for ListItemLayout {
939
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
940
+ write!(f, "ListItemLayout - marker {:?}", self.marker)
941
+ }
942
+ }
943
+
944
+ #[cfg(feature = "file-input")]
945
+ mod file_data {
946
+ use std::ops::{Deref, DerefMut};
947
+ use std::path::PathBuf;
948
+
949
+ #[derive(Clone, Debug)]
950
+ pub struct FileData(pub Vec<PathBuf>);
951
+ impl Deref for FileData {
952
+ type Target = Vec<PathBuf>;
953
+
954
+ fn deref(&self) -> &Self::Target {
955
+ &self.0
956
+ }
957
+ }
958
+ impl DerefMut for FileData {
959
+ fn deref_mut(&mut self) -> &mut Self::Target {
960
+ &mut self.0
961
+ }
962
+ }
963
+ impl From<Vec<PathBuf>> for FileData {
964
+ fn from(files: Vec<PathBuf>) -> Self {
965
+ Self(files)
966
+ }
967
+ }
968
+ }
969
+ #[cfg(feature = "file-input")]
970
+ pub use file_data::FileData;
971
+
972
+ #[cfg(test)]
973
+ mod tests {
974
+ use super::TextInputData;
975
+ use parley::{FontContext, LayoutContext};
976
+
977
+ /// Build a [`TextInputData`] with the given text laid out at scale 1.0.
978
+ fn make_input(is_multiline: bool, text: &str) -> TextInputData {
979
+ let mut font_ctx = FontContext::new();
980
+ let mut layout_ctx = LayoutContext::new();
981
+ let mut data = TextInputData::new(is_multiline);
982
+ data.editor.set_scale(1.0);
983
+ data.editor.set_text(text);
984
+ data.editor
985
+ .driver(&mut font_ctx, &mut layout_ctx)
986
+ .refresh_layout();
987
+ data
988
+ }
989
+
990
+ #[test]
991
+ fn short_text_does_not_scroll() {
992
+ let mut data = make_input(false, "hi");
993
+ // A wide content box that comfortably fits the text.
994
+ data.clamp_scroll_offset(1000.0, 100.0);
995
+ assert_eq!(data.scroll_offset, 0.0);
996
+ }
997
+
998
+ #[test]
999
+ fn single_line_scrolls_to_follow_caret() {
1000
+ let text = "the quick brown fox jumps over the lazy dog repeatedly and at length";
1001
+ let mut data = make_input(false, text);
1002
+ let content_box_width = 40.0;
1003
+ let content_box_height = 20.0;
1004
+
1005
+ // Caret at the end of a string that overflows a narrow input should scroll right.
1006
+ data.editor
1007
+ .driver(&mut FontContext::new(), &mut LayoutContext::new())
1008
+ .move_to_text_end();
1009
+ data.clamp_scroll_offset(content_box_width, content_box_height);
1010
+
1011
+ let layout_width = data.editor.try_layout().unwrap().full_width();
1012
+ if layout_width > content_box_width {
1013
+ assert!(
1014
+ data.scroll_offset > 0.0,
1015
+ "expected horizontal scroll for overflowing single-line input"
1016
+ );
1017
+ // The caret must be within the visible region after scrolling.
1018
+ let caret = data.editor.cursor_geometry(1.5).unwrap();
1019
+ assert!(caret.x1 as f32 <= data.scroll_offset + content_box_width + 0.5);
1020
+ assert!(caret.x0 as f32 >= data.scroll_offset - 0.5);
1021
+ }
1022
+
1023
+ // Moving the caret back to the start should reset the scroll offset.
1024
+ data.editor
1025
+ .driver(&mut FontContext::new(), &mut LayoutContext::new())
1026
+ .move_to_text_start();
1027
+ data.clamp_scroll_offset(content_box_width, content_box_height);
1028
+ assert_eq!(data.scroll_offset, 0.0);
1029
+ }
1030
+
1031
+ #[test]
1032
+ fn multiline_scrolls_vertically_not_horizontally() {
1033
+ let text = (0..40)
1034
+ .map(|i| format!("line {i}"))
1035
+ .collect::<Vec<_>>()
1036
+ .join("\n");
1037
+ let mut data = make_input(true, &text);
1038
+ // Constrain the width so wrapping is well-defined.
1039
+ data.editor.set_width(Some(200.0));
1040
+ data.editor
1041
+ .driver(&mut FontContext::new(), &mut LayoutContext::new())
1042
+ .refresh_layout();
1043
+
1044
+ let content_box_width = 200.0;
1045
+ let content_box_height = 30.0;
1046
+
1047
+ data.editor
1048
+ .driver(&mut FontContext::new(), &mut LayoutContext::new())
1049
+ .move_to_text_end();
1050
+ data.clamp_scroll_offset(content_box_width, content_box_height);
1051
+
1052
+ let layout_height = data.editor.try_layout().unwrap().height();
1053
+ if layout_height > content_box_height {
1054
+ assert!(
1055
+ data.scroll_offset > 0.0,
1056
+ "expected vertical scroll for overflowing multi-line input"
1057
+ );
1058
+ }
1059
+ }
1060
+
1061
+ #[test]
1062
+ fn scroll_by_clamps_and_bubbles() {
1063
+ let text = (0..40)
1064
+ .map(|i| format!("line {i}"))
1065
+ .collect::<Vec<_>>()
1066
+ .join("\n");
1067
+ let mut data = make_input(true, &text);
1068
+ data.editor.set_width(Some(200.0));
1069
+ data.editor
1070
+ .driver(&mut FontContext::new(), &mut LayoutContext::new())
1071
+ .refresh_layout();
1072
+
1073
+ let content_box_width = 200.0;
1074
+ let content_box_height = 30.0;
1075
+ let max = data.max_scroll_offset(content_box_width, content_box_height);
1076
+ assert!(max > 0.0, "test text should overflow the content box");
1077
+
1078
+ // Scrolling up (positive delta decreases offset) while already at the top is a no-op and
1079
+ // the whole delta bubbles.
1080
+ assert_eq!(data.scroll_offset, 0.0);
1081
+ let bubbled = data.scroll_by(15.0, content_box_width, content_box_height);
1082
+ assert_eq!(data.scroll_offset, 0.0);
1083
+ assert_eq!(bubbled, 15.0);
1084
+
1085
+ // Scrolling down moves the offset and consumes the delta.
1086
+ let bubbled = data.scroll_by(-10.0, content_box_width, content_box_height);
1087
+ assert_eq!(data.scroll_offset, 10.0);
1088
+ assert_eq!(bubbled, 0.0);
1089
+
1090
+ // Scrolling past the end clamps to the maximum and bubbles the remainder. Starting at
1091
+ // offset 10 with max headroom of `max - 10`, a delta of `-(max + 100)` consumes
1092
+ // `max - 10` and bubbles the rest (`-110`).
1093
+ let bubbled = data.scroll_by(-(max + 100.0), content_box_width, content_box_height);
1094
+ assert_eq!(data.scroll_offset, max);
1095
+ assert!((bubbled - (-110.0)).abs() < 1e-3);
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn single_line_does_not_scroll_when_text_fits() {
1100
+ let mut data = make_input(false, "hi");
1101
+ // Wide content box; nothing to scroll, so all delta bubbles.
1102
+ let bubbled = data.scroll_by(-50.0, 1000.0, 100.0);
1103
+ assert_eq!(data.scroll_offset, 0.0);
1104
+ assert_eq!(bubbled, -50.0);
1105
+ }
1106
+ }