Skip to main content

blitz_dom/node/
element.rs

1use cssparser::ParserInput;
2use linebender_resource_handle::Blob;
3use markup5ever::{LocalName, QualName, local_name};
4use selectors::matching::QuirksMode;
5use std::str::FromStr;
6use std::sync::Arc;
7use style::Atom;
8use style::parser::ParserContext;
9use style::properties::{Importance, PropertyDeclaration, PropertyId, SourcePropertyDeclaration};
10use style::stylesheets::{DocumentStyleSheet, Origin, UrlExtraData};
11use style::{
12    properties::{PropertyDeclarationBlock, parse_style_attribute},
13    servo_arc::Arc as ServoArc,
14    shared_lock::{Locked, SharedRwLock},
15    stylesheets::CssRuleType,
16};
17use style_traits::ParsingMode;
18use url::Url;
19
20use super::{Attribute, Attributes};
21use crate::Document;
22use crate::layout::table::TableContext;
23use crate::node::{TextBrush, TextInputData, TextLayout};
24
25#[cfg(feature = "custom-widget")]
26use super::custom_widget::CustomWidgetData;
27
28macro_rules! local_names {
29    ($($name:tt),+) => {
30        [$(local_name!($name),)+]
31    };
32}
33
34#[derive(Debug, Clone)]
35pub struct ElementData {
36    /// The elements tag name, namespace and prefix
37    pub name: QualName,
38
39    /// The elements id attribute parsed as an atom (if it has one)
40    pub id: Option<Atom>,
41
42    /// The element's attributes
43    pub attrs: Attributes,
44
45    /// Whether the element is focussable
46    pub is_focussable: bool,
47
48    /// The element's parsed style attribute (used by stylo)
49    pub style_attribute: Option<ServoArc<Locked<PropertyDeclarationBlock>>>,
50
51    /// Heterogeneous data that depends on the element's type.
52    /// For example:
53    ///   - The image data for \<img\> elements.
54    ///   - The parley Layout for inline roots.
55    ///   - The text editor for input/textarea elements
56    pub special_data: SpecialElementData,
57
58    pub background_images: Vec<Option<ImageResourceData>>,
59
60    pub mask_images: Vec<Option<ImageResourceData>>,
61
62    /// Parley text layout (elements with inline inner display mode only)
63    pub inline_layout_data: Option<Box<TextLayout>>,
64
65    /// Data associated with display: list-item. Note that this display mode
66    /// does not exclude inline_layout_data
67    pub list_item_data: Option<Box<ListItemLayout>>,
68
69    /// The element's template contents (\<template\> elements only)
70    pub template_contents: Option<usize>,
71    // /// Whether the node is a [HTML integration point] (https://html.spec.whatwg.org/multipage/#html-integration-point)
72    // pub mathml_annotation_xml_integration_point: bool,
73}
74
75#[derive(Copy, Clone, Default)]
76#[non_exhaustive]
77pub enum SpecialElementType {
78    Stylesheet,
79    Image,
80    Canvas,
81    TableRoot,
82    TextInput,
83    CheckboxInput,
84    #[cfg(feature = "file-input")]
85    FileInput,
86    #[default]
87    None,
88}
89
90/// Heterogeneous data that depends on the element's type.
91#[derive(Default)]
92pub enum SpecialElementData {
93    /// A sub-document such an \<iframe\> or \<web-view\> element
94    SubDocument(Box<dyn Document>),
95    /// A custom widget
96    #[cfg(feature = "custom-widget")]
97    CustomWidget(CustomWidgetData),
98    /// A stylesheet
99    Stylesheet(DocumentStyleSheet),
100    /// An \<img\> element's image data
101    Image(Box<ImageData>),
102    /// A \<canvas\> element's custom paint source
103    Canvas(CanvasData),
104    /// Pre-computed table layout data
105    TableRoot(Arc<TableContext>),
106    /// Parley text editor (text inputs)
107    TextInput(TextInputData),
108    /// Checkbox checked state
109    CheckboxInput(bool),
110    /// Selected files
111    #[cfg(feature = "file-input")]
112    FileInput(FileData),
113    /// No data (for nodes that don't need any node-specific data)
114    #[default]
115    None,
116}
117
118impl Clone for SpecialElementData {
119    fn clone(&self) -> Self {
120        match self {
121            Self::SubDocument(_) => Self::None, // TODO
122            #[cfg(feature = "custom-widget")]
123            Self::CustomWidget(_) => Self::None, // TODO
124            Self::Stylesheet(data) => Self::Stylesheet(data.clone()),
125            Self::Image(data) => Self::Image(data.clone()),
126            Self::Canvas(data) => Self::Canvas(data.clone()),
127            Self::TableRoot(data) => Self::TableRoot(data.clone()),
128            Self::TextInput(data) => Self::TextInput(data.clone()),
129            Self::CheckboxInput(data) => Self::CheckboxInput(*data),
130            #[cfg(feature = "file-input")]
131            Self::FileInput(data) => Self::FileInput(data.clone()),
132            Self::None => Self::None,
133        }
134    }
135}
136
137impl SpecialElementData {
138    pub fn take(&mut self) -> Self {
139        std::mem::take(self)
140    }
141}
142
143impl ElementData {
144    pub fn new(name: QualName, attrs: Vec<Attribute>) -> Self {
145        let id_attr_atom = attrs
146            .iter()
147            .find(|attr| &attr.name.local == "id")
148            .map(|attr| attr.value.as_ref())
149            .map(|value: &str| Atom::from(value));
150
151        let mut data = ElementData {
152            name,
153            id: id_attr_atom,
154            attrs: Attributes::new(attrs),
155            is_focussable: false,
156            style_attribute: Default::default(),
157            inline_layout_data: None,
158            list_item_data: None,
159            special_data: SpecialElementData::None,
160            template_contents: None,
161            background_images: Vec::new(),
162            mask_images: Vec::new(),
163        };
164        data.flush_is_focussable();
165        data
166    }
167
168    pub fn attrs(&self) -> &[Attribute] {
169        &self.attrs
170    }
171
172    pub fn attr(&self, name: impl PartialEq<LocalName>) -> Option<&str> {
173        let attr = self.attrs.iter().find(|attr| name == attr.name.local)?;
174        Some(&attr.value)
175    }
176
177    pub fn attr_parsed<T: FromStr>(&self, name: impl PartialEq<LocalName>) -> Option<T> {
178        let attr = self.attrs.iter().find(|attr| name == attr.name.local)?;
179        attr.value.parse::<T>().ok()
180    }
181
182    /// Detects the presence of the attribute, treating *any* value as truthy.
183    pub fn has_attr(&self, name: impl PartialEq<LocalName>) -> bool {
184        self.attrs.iter().any(|attr| name == attr.name.local)
185    }
186
187    pub fn can_be_disabled(&self) -> bool {
188        local_names!("button", "input", "select", "textarea").contains(&self.name.local)
189    }
190
191    pub fn image_data(&self) -> Option<&ImageData> {
192        match &self.special_data {
193            SpecialElementData::Image(data) => Some(&**data),
194            _ => None,
195        }
196    }
197
198    pub fn image_data_mut(&mut self) -> Option<&mut ImageData> {
199        match self.special_data {
200            SpecialElementData::Image(ref mut data) => Some(&mut **data),
201            _ => None,
202        }
203    }
204
205    pub fn raster_image_data(&self) -> Option<&RasterImageData> {
206        match self.image_data()? {
207            ImageData::Raster(data) => Some(data),
208            _ => None,
209        }
210    }
211
212    pub fn raster_image_data_mut(&mut self) -> Option<&mut RasterImageData> {
213        match self.image_data_mut()? {
214            ImageData::Raster(data) => Some(data),
215            _ => None,
216        }
217    }
218
219    pub fn canvas_data(&self) -> Option<&CanvasData> {
220        match &self.special_data {
221            SpecialElementData::Canvas(data) => Some(data),
222            _ => None,
223        }
224    }
225
226    pub fn sub_doc_data(&self) -> Option<&dyn Document> {
227        match &self.special_data {
228            SpecialElementData::SubDocument(data) => Some(data.as_ref()),
229            _ => None,
230        }
231    }
232
233    pub fn sub_doc_data_mut(&mut self) -> Option<&mut dyn Document> {
234        match &mut self.special_data {
235            SpecialElementData::SubDocument(data) => Some(data.as_mut()),
236            _ => None,
237        }
238    }
239
240    #[cfg(feature = "svg")]
241    pub fn svg_data(&self) -> Option<&usvg::Tree> {
242        match self.image_data()? {
243            ImageData::Svg(data) => Some(&data.tree),
244            _ => None,
245        }
246    }
247
248    pub fn text_input_data(&self) -> Option<&TextInputData> {
249        match &self.special_data {
250            SpecialElementData::TextInput(data) => Some(data),
251            _ => None,
252        }
253    }
254
255    pub fn text_input_data_mut(&mut self) -> Option<&mut TextInputData> {
256        match &mut self.special_data {
257            SpecialElementData::TextInput(data) => Some(data),
258            _ => None,
259        }
260    }
261
262    #[cfg(feature = "custom-widget")]
263    pub fn custom_widget_data(&self) -> Option<&CustomWidgetData> {
264        match &self.special_data {
265            SpecialElementData::CustomWidget(data) => Some(data),
266            _ => None,
267        }
268    }
269
270    #[cfg(feature = "custom-widget")]
271    pub fn custom_widget_data_mut(&mut self) -> Option<&mut CustomWidgetData> {
272        match &mut self.special_data {
273            SpecialElementData::CustomWidget(data) => Some(data),
274            _ => None,
275        }
276    }
277
278    pub fn checkbox_input_checked(&self) -> Option<bool> {
279        match self.special_data {
280            SpecialElementData::CheckboxInput(checked) => Some(checked),
281            _ => None,
282        }
283    }
284
285    pub fn checkbox_input_checked_mut(&mut self) -> Option<&mut bool> {
286        match self.special_data {
287            SpecialElementData::CheckboxInput(ref mut checked) => Some(checked),
288            _ => None,
289        }
290    }
291
292    #[cfg(feature = "file-input")]
293    pub fn file_data(&self) -> Option<&FileData> {
294        match &self.special_data {
295            SpecialElementData::FileInput(data) => Some(data),
296            _ => None,
297        }
298    }
299
300    #[cfg(feature = "file-input")]
301    pub fn file_data_mut(&mut self) -> Option<&mut FileData> {
302        match &mut self.special_data {
303            SpecialElementData::FileInput(data) => Some(data),
304            _ => None,
305        }
306    }
307
308    pub fn flush_is_focussable(&mut self) {
309        let disabled: bool = self.attr_parsed(local_name!("disabled")).unwrap_or(false);
310        let tabindex: Option<i32> = self.attr_parsed(local_name!("tabindex"));
311        let contains_sub_document: bool = self.sub_doc_data().is_some();
312
313        self.is_focussable = contains_sub_document
314            || (!disabled
315                && match tabindex {
316                    Some(index) => index >= 0,
317                    None => {
318                        // Some focusable HTML elements have a default tabindex value of 0 set under the hood by the user agent.
319                        // These elements are:
320                        //   - <a> or <area> with href attribute
321                        //   - <button>, <frame>, <iframe>, <input>, <object>, <select>, <textarea>, and SVG <a> element
322                        //   - <summary> element that provides summary for a <details> element.
323
324                        if [local_name!("a"), local_name!("area")].contains(&self.name.local) {
325                            self.attr(local_name!("href")).is_some()
326                        } else {
327                            const DEFAULT_FOCUSSABLE_ELEMENTS: [LocalName; 7] = [
328                                local_name!("button"),
329                                local_name!("input"),
330                                local_name!("select"),
331                                local_name!("textarea"),
332                                local_name!("frame"),
333                                local_name!("iframe"),
334                                local_name!("summary"),
335                            ];
336                            DEFAULT_FOCUSSABLE_ELEMENTS.contains(&self.name.local)
337                        }
338                    }
339                })
340    }
341
342    pub fn flush_style_attribute(&mut self, guard: &SharedRwLock, url_extra_data: &UrlExtraData) {
343        self.style_attribute = self.attr(local_name!("style")).map(|style_str| {
344            ServoArc::new(guard.wrap(parse_style_attribute(
345                style_str,
346                url_extra_data,
347                None,
348                QuirksMode::NoQuirks,
349                CssRuleType::Style,
350            )))
351        });
352    }
353
354    pub fn set_style_property(
355        &mut self,
356        name: &str,
357        value: &str,
358        guard: &SharedRwLock,
359        url_extra_data: UrlExtraData,
360    ) -> bool {
361        let context = ParserContext::new(
362            Origin::Author,
363            &url_extra_data,
364            Some(CssRuleType::Style),
365            ParsingMode::DEFAULT,
366            QuirksMode::NoQuirks,
367            /* namespaces = */ Default::default(),
368            None,
369            None,
370            /* attr_taint = */ Default::default(),
371        );
372
373        let Ok(property_id) = PropertyId::parse(name, &context) else {
374            #[cfg(feature = "tracing")]
375            tracing::warn!(property = name, "Unsupported property");
376            return false;
377        };
378        let mut source_property_declaration = SourcePropertyDeclaration::default();
379        let mut input = ParserInput::new(value);
380        let mut parser = style::values::Parser::new(&mut input);
381        let Ok(_) = PropertyDeclaration::parse_into(
382            &mut source_property_declaration,
383            property_id,
384            &context,
385            &mut parser,
386        ) else {
387            #[cfg(feature = "tracing")]
388            tracing::warn!(property = name, value, "Invalid property value");
389            return false;
390        };
391
392        if self.style_attribute.is_none() {
393            self.style_attribute = Some(ServoArc::new(guard.wrap(PropertyDeclarationBlock::new())));
394        }
395        self.style_attribute
396            .as_mut()
397            .unwrap()
398            .write_with(&mut guard.write())
399            .extend(source_property_declaration.drain(), Importance::Normal);
400
401        true
402    }
403
404    pub fn remove_style_property(
405        &mut self,
406        name: &str,
407        guard: &SharedRwLock,
408        url_extra_data: UrlExtraData,
409    ) -> bool {
410        let context = ParserContext::new(
411            Origin::Author,
412            &url_extra_data,
413            Some(CssRuleType::Style),
414            ParsingMode::DEFAULT,
415            QuirksMode::NoQuirks,
416            /* namespaces = */ Default::default(),
417            None,
418            None,
419            /* attr_taint = */ Default::default(),
420        );
421        let Ok(property_id) = PropertyId::parse(name, &context) else {
422            #[cfg(feature = "tracing")]
423            tracing::warn!(property = name, "Unsupported property");
424            return false;
425        };
426
427        if let Some(style) = &mut self.style_attribute {
428            let mut guard = guard.write();
429            let style = style.write_with(&mut guard);
430            if let Some(index) = style.first_declaration_to_remove(&property_id) {
431                style.remove_property(&property_id, index);
432                return true;
433            }
434        }
435
436        false
437    }
438
439    pub fn set_sub_document(&mut self, sub_document: Box<dyn Document>) {
440        self.special_data = SpecialElementData::SubDocument(sub_document);
441    }
442
443    pub fn remove_sub_document(&mut self) {
444        self.special_data = SpecialElementData::None;
445    }
446
447    #[cfg(feature = "custom-widget")]
448    pub fn set_custom_widget(&mut self, widget: Box<dyn crate::Widget>) {
449        use crate::node::custom_widget::CustomWidgetData;
450        self.special_data = SpecialElementData::CustomWidget(CustomWidgetData::new(widget));
451    }
452
453    #[cfg(feature = "custom-widget")]
454    pub fn remove_custom_widget(&mut self) -> Vec<anyrender::ResourceId> {
455        let resource_ids = self
456            .custom_widget_data_mut()
457            .map(|widget_data| widget_data.take_resource_ids())
458            .unwrap_or_default();
459        self.special_data = SpecialElementData::None;
460        resource_ids
461    }
462
463    pub fn take_inline_layout(&mut self) -> Option<Box<TextLayout>> {
464        std::mem::take(&mut self.inline_layout_data)
465    }
466
467    pub fn is_submit_button(&self) -> bool {
468        if self.name.local != local_name!("button") {
469            return false;
470        }
471        let type_attr = self.attr(local_name!("type"));
472        let is_submit = type_attr == Some("submit");
473        let is_auto_submit = type_attr.is_none()
474            && self.attr(LocalName::from("command")).is_none()
475            && self.attr(LocalName::from("commandfor")).is_none();
476        is_submit || is_auto_submit
477    }
478}
479
480#[derive(Debug, Clone, PartialEq)]
481pub struct RasterImageData {
482    /// The width of the image
483    pub width: u32,
484    /// The height of the image
485    pub height: u32,
486    /// The raw image data in RGBA8 format
487    pub data: Blob<u8>,
488}
489impl RasterImageData {
490    pub fn new(width: u32, height: u32, data: Arc<Vec<u8>>) -> Self {
491        Self {
492            width,
493            height,
494            data: Blob::new(data),
495        }
496    }
497}
498
499/// A parsed SVG image together with its CSS intrinsic dimensions.
500///
501/// usvg always resolves the root `<svg>` to a concrete [`usvg::Tree::size`],
502/// falling back to the `viewBox` size when `width`/`height` are absent or given
503/// as percentages. For CSS sizing purposes, however, such an SVG has *no*
504/// intrinsic width/height (only an intrinsic aspect ratio). We record which
505/// dimensions were actually declared as absolute lengths so the paint layer can
506/// apply the CSS default sizing algorithm correctly.
507#[cfg(feature = "svg")]
508#[derive(Debug, Clone)]
509pub struct SvgImageData {
510    /// The parsed SVG tree.
511    pub tree: Arc<usvg::Tree>,
512    /// The intrinsic width in CSS px, present only when the root `<svg>`
513    /// declared an absolute (non-percentage) `width`.
514    pub intrinsic_width: Option<f32>,
515    /// The intrinsic height in CSS px, present only when the root `<svg>`
516    /// declared an absolute (non-percentage) `height`.
517    pub intrinsic_height: Option<f32>,
518}
519
520#[cfg(feature = "svg")]
521impl SvgImageData {
522    /// The intrinsic aspect ratio of the SVG (always available: usvg resolves
523    /// the `viewBox` or declared size into a non-zero [`usvg::Tree::size`]).
524    pub fn aspect_ratio(&self) -> f32 {
525        let size = self.tree.size();
526        size.width() / size.height()
527    }
528}
529
530#[derive(Debug, Clone)]
531pub enum ImageData {
532    Raster(RasterImageData),
533    #[cfg(feature = "svg")]
534    Svg(SvgImageData),
535    None,
536}
537
538#[derive(Debug, Clone, PartialEq)]
539pub enum Status {
540    Ok,
541    Error,
542    Loading,
543}
544
545#[derive(Debug, Clone)]
546pub struct ImageResourceData {
547    /// The url of the background image
548    pub url: ServoArc<Url>,
549    /// The loading status of the background image
550    pub status: Status,
551    /// The image data
552    pub image: ImageData,
553}
554
555impl ImageResourceData {
556    pub fn new(url: ServoArc<Url>) -> Self {
557        Self {
558            url,
559            status: Status::Loading,
560            image: ImageData::None,
561        }
562    }
563}
564
565#[derive(Debug, Clone)]
566pub struct CanvasData {
567    pub custom_paint_source_id: u64,
568}
569
570impl std::fmt::Debug for SpecialElementData {
571    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
572        match self {
573            SpecialElementData::SubDocument(_) => f.write_str("NodeSpecificData::SubDocument"),
574            #[cfg(feature = "custom-widget")]
575            SpecialElementData::CustomWidget(_) => f.write_str("NodeSpecificData::CustomWidget"),
576            SpecialElementData::Stylesheet(_) => f.write_str("NodeSpecificData::Stylesheet"),
577            SpecialElementData::Image(data) => match **data {
578                ImageData::Raster(_) => f.write_str("NodeSpecificData::Image(Raster)"),
579                #[cfg(feature = "svg")]
580                ImageData::Svg(_) => f.write_str("NodeSpecificData::Image(Svg)"),
581                ImageData::None => f.write_str("NodeSpecificData::Image(None)"),
582            },
583            SpecialElementData::Canvas(_) => f.write_str("NodeSpecificData::Canvas"),
584            SpecialElementData::TableRoot(_) => f.write_str("NodeSpecificData::TableRoot"),
585            SpecialElementData::TextInput(_) => f.write_str("NodeSpecificData::TextInput"),
586            SpecialElementData::CheckboxInput(_) => f.write_str("NodeSpecificData::CheckboxInput"),
587            #[cfg(feature = "file-input")]
588            SpecialElementData::FileInput(_) => f.write_str("NodeSpecificData::FileInput"),
589            SpecialElementData::None => f.write_str("NodeSpecificData::None"),
590        }
591    }
592}
593
594#[derive(Clone)]
595pub struct ListItemLayout {
596    pub marker: Marker,
597    pub position: ListItemLayoutPosition,
598}
599
600//We seperate chars from strings in order to optimise rendering - ie not needing to
601//construct a whole parley layout for simple char markers
602#[derive(Debug, PartialEq, Clone)]
603pub enum Marker {
604    Char(char),
605    String(String),
606}
607
608//Value depends on list-style-position, determining whether a seperate layout is created for it
609#[derive(Clone)]
610pub enum ListItemLayoutPosition {
611    Inside,
612    Outside(Box<parley::Layout<TextBrush>>),
613}
614
615impl std::fmt::Debug for ListItemLayout {
616    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
617        write!(f, "ListItemLayout - marker {:?}", self.marker)
618    }
619}
620
621#[cfg(feature = "file-input")]
622mod file_data {
623    use std::ops::{Deref, DerefMut};
624    use std::path::PathBuf;
625
626    #[derive(Clone, Debug)]
627    pub struct FileData(pub Vec<PathBuf>);
628    impl Deref for FileData {
629        type Target = Vec<PathBuf>;
630
631        fn deref(&self) -> &Self::Target {
632            &self.0
633        }
634    }
635    impl DerefMut for FileData {
636        fn deref_mut(&mut self) -> &mut Self::Target {
637            &mut self.0
638        }
639    }
640    impl From<Vec<PathBuf>> for FileData {
641        fn from(files: Vec<PathBuf>) -> Self {
642            Self(files)
643        }
644    }
645}
646#[cfg(feature = "file-input")]
647pub use file_data::FileData;
648
649#[cfg(test)]
650mod tests {
651    use super::TextInputData;
652    use parley::{FontContext, LayoutContext};
653
654    /// Build a [`TextInputData`] with the given text laid out at scale 1.0.
655    fn make_input(is_multiline: bool, text: &str) -> TextInputData {
656        let mut font_ctx = FontContext::new();
657        let mut layout_ctx = LayoutContext::new();
658        let mut data = TextInputData::new(is_multiline);
659        data.editor.set_scale(1.0);
660        data.editor.set_text(text);
661        data.editor
662            .driver(&mut font_ctx, &mut layout_ctx)
663            .refresh_layout();
664        data
665    }
666
667    #[test]
668    fn short_text_does_not_scroll() {
669        let mut data = make_input(false, "hi");
670        // A wide content box that comfortably fits the text.
671        data.clamp_scroll_offset(1000.0, 100.0);
672        assert_eq!(data.scroll_offset, 0.0);
673    }
674
675    #[test]
676    fn single_line_scrolls_to_follow_caret() {
677        let text = "the quick brown fox jumps over the lazy dog repeatedly and at length";
678        let mut data = make_input(false, text);
679        let content_box_width = 40.0;
680        let content_box_height = 20.0;
681
682        // Caret at the end of a string that overflows a narrow input should scroll right.
683        data.editor
684            .driver(&mut FontContext::new(), &mut LayoutContext::new())
685            .move_to_text_end();
686        data.clamp_scroll_offset(content_box_width, content_box_height);
687
688        let layout_width = data.editor.try_layout().unwrap().full_width();
689        if layout_width > content_box_width {
690            assert!(
691                data.scroll_offset > 0.0,
692                "expected horizontal scroll for overflowing single-line input"
693            );
694            // The caret must be within the visible region after scrolling.
695            let caret = data.editor.cursor_geometry(1.5).unwrap();
696            assert!(caret.x1 as f32 <= data.scroll_offset + content_box_width + 0.5);
697            assert!(caret.x0 as f32 >= data.scroll_offset - 0.5);
698        }
699
700        // Moving the caret back to the start should reset the scroll offset.
701        data.editor
702            .driver(&mut FontContext::new(), &mut LayoutContext::new())
703            .move_to_text_start();
704        data.clamp_scroll_offset(content_box_width, content_box_height);
705        assert_eq!(data.scroll_offset, 0.0);
706    }
707
708    #[test]
709    fn multiline_scrolls_vertically_not_horizontally() {
710        let text = (0..40)
711            .map(|i| format!("line {i}"))
712            .collect::<Vec<_>>()
713            .join("\n");
714        let mut data = make_input(true, &text);
715        // Constrain the width so wrapping is well-defined.
716        data.editor.set_width(Some(200.0));
717        data.editor
718            .driver(&mut FontContext::new(), &mut LayoutContext::new())
719            .refresh_layout();
720
721        let content_box_width = 200.0;
722        let content_box_height = 30.0;
723
724        data.editor
725            .driver(&mut FontContext::new(), &mut LayoutContext::new())
726            .move_to_text_end();
727        data.clamp_scroll_offset(content_box_width, content_box_height);
728
729        let layout_height = data.editor.try_layout().unwrap().height();
730        if layout_height > content_box_height {
731            assert!(
732                data.scroll_offset > 0.0,
733                "expected vertical scroll for overflowing multi-line input"
734            );
735        }
736    }
737
738    #[test]
739    fn scroll_by_clamps_and_bubbles() {
740        let text = (0..40)
741            .map(|i| format!("line {i}"))
742            .collect::<Vec<_>>()
743            .join("\n");
744        let mut data = make_input(true, &text);
745        data.editor.set_width(Some(200.0));
746        data.editor
747            .driver(&mut FontContext::new(), &mut LayoutContext::new())
748            .refresh_layout();
749
750        let content_box_width = 200.0;
751        let content_box_height = 30.0;
752        let max = data.max_scroll_offset(content_box_width, content_box_height);
753        assert!(max > 0.0, "test text should overflow the content box");
754
755        // Scrolling up (positive delta decreases offset) while already at the top is a no-op and
756        // the whole delta bubbles.
757        assert_eq!(data.scroll_offset, 0.0);
758        let bubbled = data.scroll_by(15.0, content_box_width, content_box_height);
759        assert_eq!(data.scroll_offset, 0.0);
760        assert_eq!(bubbled, 15.0);
761
762        // Scrolling down moves the offset and consumes the delta.
763        let bubbled = data.scroll_by(-10.0, content_box_width, content_box_height);
764        assert_eq!(data.scroll_offset, 10.0);
765        assert_eq!(bubbled, 0.0);
766
767        // Scrolling past the end clamps to the maximum and bubbles the remainder. Starting at
768        // offset 10 with max headroom of `max - 10`, a delta of `-(max + 100)` consumes
769        // `max - 10` and bubbles the rest (`-110`).
770        let bubbled = data.scroll_by(-(max + 100.0), content_box_width, content_box_height);
771        assert_eq!(data.scroll_offset, max);
772        assert!((bubbled - (-110.0)).abs() < 1e-3);
773    }
774
775    #[test]
776    fn single_line_does_not_scroll_when_text_fits() {
777        let mut data = make_input(false, "hi");
778        // Wide content box; nothing to scroll, so all delta bubbles.
779        let bubbled = data.scroll_by(-50.0, 1000.0, 100.0);
780        assert_eq!(data.scroll_offset, 0.0);
781        assert_eq!(bubbled, -50.0);
782    }
783}