diff --git a/docs/development/text-layout.md b/docs/development/text-layout.md index ab224bd5bdb..3a77710c1ff 100644 --- a/docs/development/text-layout.md +++ b/docs/development/text-layout.md @@ -14,6 +14,7 @@ Slint's text layout system handles the complex process of converting text string - **Script-aware boundaries**: Splitting text by Unicode script for font selection - **Line breaking**: Unicode-compliant line break algorithm - **Text wrapping**: Word wrap, character wrap, and no wrap modes +- **Line height**: Natural font metrics or an explicit line height - **Text overflow**: Clipping and elision (ellipsis) - **Styled text**: Markdown parsing with formatting spans @@ -111,8 +112,8 @@ pub trait TextShaper { /// Get glyph for a single character (e.g., ellipsis) fn glyph_for_char(&self, ch: char) -> Option>; - /// Calculate max lines that fit in height - fn max_lines(&self, max_height: Self::Length) -> usize; + /// Returns how many lines of `line_height` fit in `max_height`. + fn max_lines(&self, max_height: Self::Length, line_height: Self::Length) -> usize; } ``` @@ -138,6 +139,22 @@ Combined trait for fonts: pub trait AbstractFont: TextShaper + FontMetrics<::Length> {} ``` +### TextLayout + +`TextLayout` combines a font with paragraph-wide spacing options: + +```rust +pub struct TextLayout<'a, Font: AbstractFont> { + pub font: &'a Font, + pub letter_spacing: Option<::Length>, + pub line_height: Option<::Length>, +} +``` + +Both spacing values use the font's `Length` unit. +`None` leaves letter spacing unchanged and uses `FontMetrics::height()` for line height. +The `.slint` `line-height` multiplier is converted to an absolute value before this stage. + ## Script Boundary Detection The `ShapeBoundaries` iterator splits text by Unicode script for optimal font selection: @@ -273,6 +290,7 @@ pub struct TextParagraphLayout<'a, Font: AbstractFont> { pub wrap: TextWrap, pub overflow: TextOverflow, pub single_line: bool, + pub max_lines: Option, } ``` @@ -432,11 +450,12 @@ impl StyledText { ### Measuring Text ```rust -let layout = TextLayout { font: &font, letter_spacing: None }; +let layout = TextLayout { font: &font, letter_spacing: None, line_height: None }; let (width, height) = layout.text_size( "Hello World", Some(max_width), // None for unconstrained TextWrap::WordWrap, + None, // No line limit ); ``` @@ -445,7 +464,7 @@ let (width, height) = layout.text_size( ```rust let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 200.0, max_height: 100.0, horizontal_alignment: TextHorizontalAlignment::Left, @@ -453,6 +472,7 @@ let paragraph = TextParagraphLayout { wrap: TextWrap::WordWrap, overflow: TextOverflow::Elide, single_line: false, + max_lines: None, }; paragraph.layout_lines::<()>( @@ -501,8 +521,8 @@ impl TextShaper for MyFont { // ... build glyph } - fn max_lines(&self, max_height: f32) -> usize { - (max_height / self.height()).floor() as usize + fn max_lines(&self, max_height: f32, line_height: f32) -> usize { + (max_height / line_height).floor() as usize } } ``` diff --git a/internal/backends/testing/testing_backend.rs b/internal/backends/testing/testing_backend.rs index 0b110f1ed71..ecb855a59c7 100644 --- a/internal/backends/testing/testing_backend.rs +++ b/internal/backends/testing/testing_backend.rs @@ -2,7 +2,10 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 use i_slint_core::api::PhysicalSize; -use i_slint_core::graphics::euclid::{Point2D, Size2D}; +use i_slint_core::graphics::{ + FontRequest, + euclid::{Point2D, Size2D}, +}; use i_slint_core::item_rendering::HasFont; use i_slint_core::lengths::{LogicalLength, LogicalPoint, LogicalRect, LogicalSize}; use i_slint_core::platform::PlatformError; @@ -140,6 +143,10 @@ fn is_fixed_test_font(family: &Option) -> bool { family.as_ref().is_some_and(|f| f == FIXED_TEST_FONT) } +fn fixed_test_font_line_height(font_request: &FontRequest, pixel_size: f32) -> f32 { + font_request.line_height_for_font_size(pixel_size).unwrap_or(pixel_size) +} + #[derive(Default)] pub struct TestingBackendOptions { pub mock_time: bool, @@ -523,7 +530,8 @@ impl RendererSealed for TestingWindow { .take(max_lines) .fold((0, 0), |(len, count), line| (len.max(line.len()), count + 1)); let width = max_line_len as f32 * pixel_size; - let height = num_lines.max(1) as f32 * pixel_size; + let height = + num_lines.max(1) as f32 * fixed_test_font_line_height(&font_request, pixel_size); LogicalSize::new(width, height) } else { sharedparley::text_size(self, text_item, item_rc, max_width, text_wrap, None) @@ -568,7 +576,7 @@ impl RendererSealed for TestingWindow { let font_request = text_item.font_request(item_rc); if is_fixed_test_font(&font_request.family) { let pixel_size = font_request.pixel_size.map_or(10., |s| s.get()); - LogicalSize::new(pixel_size, pixel_size) + LogicalSize::new(pixel_size, fixed_test_font_line_height(&font_request, pixel_size)) } else { let Some(ctx) = self.slint_context() else { return LogicalSize::default(); @@ -608,16 +616,13 @@ impl RendererSealed for TestingWindow { let font_request = text_input.font_request(item_rc); if is_fixed_test_font(&font_request.family) { let pixel_size = font_request.pixel_size.map_or(10., |s| s.get()); + let line_height = fixed_test_font_line_height(&font_request, pixel_size); let text = text_input.text(); if pos.y < 0. { return 0; } - let line = (pos.y / pixel_size) as usize; - let offset = if line >= 1 { - text.split('\n').take(line - 1).map(|l| l.len() + 1).sum() - } else { - 0 - }; + let line = (pos.y / line_height) as usize; + let offset: usize = text.split('\n').take(line).map(|l| l.len() + 1).sum(); let Some(line) = text.split('\n').nth(line) else { return text.len(); }; @@ -637,12 +642,13 @@ impl RendererSealed for TestingWindow { let font_request = text_input.font_request(item_rc); if is_fixed_test_font(&font_request.family) { let pixel_size = font_request.pixel_size.map_or(10., |s| s.get()); + let line_height = fixed_test_font_line_height(&font_request, pixel_size); let text = text_input.text(); let line = text[..byte_offset].chars().filter(|c| *c == '\n').count(); let column = text[..byte_offset].split('\n').nth(line).unwrap_or("").len(); LogicalRect::new( - Point2D::new(column as f32 * pixel_size, line as f32 * pixel_size), - Size2D::new(1., pixel_size), + Point2D::new(column as f32 * pixel_size, line as f32 * line_height), + Size2D::new(1., line_height), ) } else { sharedparley::text_input_cursor_rect_for_byte_offset( diff --git a/internal/compiler/builtins.slint b/internal/compiler/builtins.slint index 41917a1733c..001879c0710 100644 --- a/internal/compiler/builtins.slint +++ b/internal/compiler/builtins.slint @@ -658,6 +658,20 @@ component ComplexText inherits SimpleText { /// } /// ``` in property letter-spacing; + /// The line height as a unitless multiple of the font size or as a percentage. + /// Values less than or equal to zero use the natural line height. + /// The CSS `normal` keyword and length values aren't supported. + /// + /// ```slint "line-height: 1.5;" imageAlt="text with increased line height" width="200" height="200" needsBackground + /// Text { + /// text: "Two lines\nof text"; + /// color: black; + /// font-size: 30pt; + /// line-height: 1.5; + /// } + /// ``` + /// \default 0 + in property line-height; /// The brush used for the text outline. /// ```slint "stroke: darkblue;" imageAlt="text stroke" width="300" height="200" needsBackground /// Text { @@ -747,6 +761,11 @@ component StyledTextItem inherits Empty { in property default-font-family; /// The default font size used to render the text, when no size is specified via markup. If unset (or zero), the value falls back to the enclosing `Window`'s `default-font-size`. in property default-font-size; + /// The line height as a unitless multiple of the font size or as a percentage. + /// Values less than or equal to zero use the natural line height. + /// The CSS `normal` keyword and length values aren't supported. + /// \default 0 + in property line-height; /// The horizontal alignment of the text. in property horizontal-alignment; /// The color used for rendering links in the text. @@ -1741,6 +1760,11 @@ export component TextInput { /// The letter spacing allows changing the spacing between the glyphs. A positive value increases the spacing and a negative value decreases the distance. /// \default 0 in property letter-spacing; + /// The line height as a unitless multiple of the font size or as a percentage. + /// Values less than or equal to zero use the natural line height. + /// The CSS `normal` keyword and length values aren't supported. + /// \default 0 + in property line-height; in property width; in property height; /// The height of the page used to compute how much to scroll when the user presses page up or page down. diff --git a/internal/compiler/passes/binding_analysis.rs b/internal/compiler/passes/binding_analysis.rs index 490cd1af064..a41e179ec67 100644 --- a/internal/compiler/passes/binding_analysis.rs +++ b/internal/compiler/passes/binding_analysis.rs @@ -947,6 +947,7 @@ fn visit_implicit_layout_info_dependencies( vis(&NamedReference::new(item, SmolStr::new_static("font-size")).into(), N); vis(&NamedReference::new(item, SmolStr::new_static("font-weight")).into(), N); vis(&NamedReference::new(item, SmolStr::new_static("letter-spacing")).into(), N); + vis(&NamedReference::new(item, SmolStr::new_static("line-height")).into(), N); vis(&NamedReference::new(item, SmolStr::new_static("wrap")).into(), N); let wrap_set = item.borrow().is_binding_set("wrap", false) || item diff --git a/internal/core/graphics.rs b/internal/core/graphics.rs index 5a9e7902259..52a39bb7ee2 100644 --- a/internal/core/graphics.rs +++ b/internal/core/graphics.rs @@ -99,10 +99,21 @@ pub struct FontRequest { /// The additional spacing (or shrinking if negative) between glyphs. This is usually not submitted to /// the font-subsystem but collected here for API convenience pub letter_spacing: Option, + /// The line height as a multiple of the font size. + /// `None` uses the font's natural line height. + pub line_height: Option, /// Whether to select an italic face of the font family. pub italic: bool, } +impl FontRequest { + /// Returns the configured line height in the same unit as `font_size`, + /// or `None` when the font's natural line height applies. + pub fn line_height_for_font_size(&self, font_size: f32) -> Option { + self.line_height.map(|line_height| font_size * line_height) + } +} + #[cfg(feature = "shared-fontique")] impl FontRequest { /// Attempts to query the fontique font collection for a matching font. diff --git a/internal/core/item_rendering.rs b/internal/core/item_rendering.rs index 23c7538cd34..a1acf7149b5 100644 --- a/internal/core/item_rendering.rs +++ b/internal/core/item_rendering.rs @@ -394,6 +394,7 @@ impl HasFont for (SharedString, Brush) { 0, LogicalLength::default(), LogicalLength::default(), + 0.0, false, ) } diff --git a/internal/core/items.rs b/internal/core/items.rs index e8e1f3048db..49bc3bc3ac9 100644 --- a/internal/core/items.rs +++ b/internal/core/items.rs @@ -1453,6 +1453,7 @@ impl WindowItem { local_font_weight: i32, local_font_size: LogicalLength, local_letter_spacing: LogicalLength, + local_line_height: f32, local_italic: bool, ) -> FontRequest { let Some(window_item_rc) = next_window_item(self_rc) else { @@ -1492,6 +1493,8 @@ impl WindowItem { } }, letter_spacing: Some(local_letter_spacing), + line_height: (local_line_height.is_finite() && local_line_height > 0.0) + .then_some(local_line_height), italic: local_italic, } } diff --git a/internal/core/items/text.rs b/internal/core/items/text.rs index cf272c77bc8..4664086e206 100644 --- a/internal/core/items/text.rs +++ b/internal/core/items/text.rs @@ -59,6 +59,7 @@ pub struct ComplexText { pub wrap: Property, pub overflow: Property, pub letter_spacing: Property, + pub line_height: Property, pub stroke: Property, pub stroke_width: Property, pub stroke_style: Property, @@ -173,6 +174,7 @@ impl HasFont for ComplexText { self.font_weight(), self.font_size(), self.letter_spacing(), + self.line_height(), self.font_italic(), ) } @@ -246,6 +248,7 @@ pub struct StyledTextItem { pub default_color: Property, pub default_font_size: Property, pub default_font_family: Property, + pub line_height: Property, pub horizontal_alignment: Property, pub vertical_alignment: Property, pub max_lines: Property, @@ -401,6 +404,7 @@ impl HasFont for StyledTextItem { Default::default(), self.default_font_size(), Default::default(), + self.line_height(), Default::default(), ) } @@ -589,6 +593,7 @@ impl HasFont for SimpleText { self.font_weight(), self.font_size(), LogicalLength::default(), + 0.0, false, ) } @@ -772,6 +777,7 @@ pub struct TextInput { pub input_type: Property, pub input_method_hints: Property, pub letter_spacing: Property, + pub line_height: Property, pub width: Property, pub height: Property, pub cursor_position_byte_offset: Property, @@ -1322,6 +1328,7 @@ impl HasFont for TextInput { self.font_weight(), self.font_size(), self.letter_spacing(), + self.line_height(), self.font_italic(), ) } diff --git a/internal/core/textlayout.rs b/internal/core/textlayout.rs index c4cd120189f..a55205f2dee 100644 --- a/internal/core/textlayout.rs +++ b/internal/core/textlayout.rs @@ -64,9 +64,15 @@ pub use linebreaker::TextLineBreaker; pub struct TextLayout<'a, Font: AbstractFont> { pub font: &'a Font, pub letter_spacing: Option<::Length>, + /// The absolute line height, or `None` to use [`FontMetrics::height`]. + pub line_height: Option<::Length>, } impl TextLayout<'_, Font> { + fn line_height(&self) -> Font::Length { + self.line_height.unwrap_or_else(|| self.font.height()) + } + // Measures the size of the given text when rendered with the specified font and optionally constrained // by the provided `max_width`. // Returns a tuple of the width of the longest line as well as height of all lines. @@ -91,7 +97,7 @@ impl TextLayout<'_, Font> { line_count += 1; } - (max_line_width, self.font.height() * line_count.into()) + (max_line_width, self.line_height() * line_count.into()) } // The min- and max-content width: the width of the widest chunk that cannot be broken @@ -189,8 +195,9 @@ impl TextParagraphLayout<'_, Font> { // The software renderer already clips glyphs to the Text geometry, so the vertical // overflow is trimmed; horizontal elision still places an ellipsis if it is too // wide. Mirrors the parley path, which always keeps line index 0. + let line_height = self.layout.line_height(); let max_lines_from_height = - elide.then(|| self.layout.font.max_lines(self.max_height).max(1)); + elide.then(|| self.layout.font.max_lines(self.max_height, line_height).max(1)); let max_lines = [self.max_lines, max_lines_from_height].into_iter().flatten().min(); let new_line_break_iter = || { @@ -206,10 +213,10 @@ impl TextParagraphLayout<'_, Font> { let mut text_height = || { if self.single_line { - self.layout.font.height() + line_height } else { text_lines = Some(new_line_break_iter().collect::>()); - self.layout.font.height() * (text_lines.as_ref().unwrap().len() as i16).into() + line_height * (text_lines.as_ref().unwrap().len() as i16).into() } }; @@ -232,7 +239,7 @@ impl TextParagraphLayout<'_, Font> { let reached_line_limit = max_lines.is_some_and(|max_lines| line_index + 1 == max_lines); let elide_last_line = elide && line.glyph_range.end < glyphs.len() - && (y + self.layout.font.height() * two > self.max_height || reached_line_limit); + && (y + line_height * two > self.max_height || reached_line_limit); // On a vertically truncated line the ellipsis is anchored right after the text by // ignoring trailing whitespace, so it reads "please…" rather than "please …". The @@ -363,7 +370,7 @@ impl TextParagraphLayout<'_, Font> { { return core::ops::ControlFlow::Break(break_val); } - y += self.layout.font.height(); + y += line_height; line_index += 1; core::ops::ControlFlow::Continue(()) @@ -431,7 +438,7 @@ impl TextParagraphLayout<'_, Font> { match self.layout_lines( |glyphs, line_x, line_y, line, _| { - if pos_y >= line_y + self.layout.font.height() { + if pos_y >= line_y + self.layout.line_height() { byte_offset = line.byte_range.end; return core::ops::ControlFlow::Continue(()); } @@ -513,9 +520,8 @@ impl TextShaper for FixedTestFont { .into() } - fn max_lines(&self, max_height: f32) -> usize { - let height = self.ascent() - self.descent(); - (max_height / height).floor() as _ + fn max_lines(&self, max_height: f32, line_height: f32) -> usize { + (max_height / line_height).floor() as _ } } @@ -547,7 +553,7 @@ fn test_elision() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 13. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -593,7 +599,7 @@ fn test_elision_vertical_truncation() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 4. * 10., // room for "AB…" (3 glyphs) and more max_height: 10., // one line tall horizontal_alignment: TextHorizontalAlignment::Left, @@ -637,7 +643,7 @@ fn test_exact_fit() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 4. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -680,7 +686,7 @@ fn test_no_line_separators_characters_rendered() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 13. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -726,7 +732,7 @@ fn test_max_lines_limits_visible_lines() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 100., horizontal_alignment: TextHorizontalAlignment::Left, @@ -772,7 +778,7 @@ fn test_max_lines_with_word_wrap() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 6. * 10., max_height: 100., horizontal_alignment: TextHorizontalAlignment::Left, @@ -794,7 +800,7 @@ fn test_max_lines_elide_marks_cut_line() { // with `overflow: elide` the ellipsis goes on the last kept line. let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 100., horizontal_alignment: TextHorizontalAlignment::Left, @@ -810,7 +816,7 @@ fn test_max_lines_elide_marks_cut_line() { #[test] fn test_text_size_with_max_lines() { let font = FixedTestFont; - let layout = TextLayout { font: &font, letter_spacing: None }; + let layout = TextLayout { font: &font, letter_spacing: None, line_height: None }; let text = "On\nFour\nLonger"; let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, None); @@ -821,6 +827,46 @@ fn test_text_size_with_max_lines() { assert_eq!((width, height), (4. * 10., 2. * 10.)); } +#[test] +fn test_line_height() { + let font = FixedTestFont; + let layout = TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) }; + let text = "One\nTwo"; + + assert_eq!(layout.text_size(text, None, TextWrap::NoWrap, None), (3. * 10., 2. * 15.)); + + let paragraph = TextParagraphLayout { + string: text, + layout, + max_width: 100. * 10., + max_height: 100., + horizontal_alignment: TextHorizontalAlignment::Left, + vertical_alignment: TextVerticalAlignment::Top, + wrap: TextWrap::NoWrap, + overflow: TextOverflow::Clip, + single_line: false, + max_lines: None, + }; + + assert_eq!(paragraph.cursor_pos_for_byte_offset(4), (0., 15.)); + assert_eq!(paragraph.byte_offset_for_position((0., 16.)), 4); + + let paragraph = TextParagraphLayout { + string: "One\nTwo\nThree", + layout: TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) }, + max_width: 100. * 10., + max_height: 29., + horizontal_alignment: TextHorizontalAlignment::Left, + vertical_alignment: TextVerticalAlignment::Top, + wrap: TextWrap::NoWrap, + overflow: TextOverflow::Elide, + single_line: false, + max_lines: None, + }; + + assert_eq!(render_lines(¶graph), std::vec!["One…"]); +} + #[test] fn test_cursor_position() { let font = FixedTestFont; @@ -828,7 +874,7 @@ fn test_cursor_position() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 10. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -869,7 +915,7 @@ fn test_cursor_position_with_newline() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -890,7 +936,7 @@ fn byte_offset_for_empty_line() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -913,7 +959,7 @@ fn test_byte_offset() { let paragraph = TextParagraphLayout { string: text, - layout: TextLayout { font: &font, letter_spacing: None }, + layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 10. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, @@ -976,7 +1022,7 @@ fn test_byte_offset() { fn test_content_widths() { // FixedTestFont: every glyph is 10 pixels wide. let font = FixedTestFont; - let layout = TextLayout { font: &font, letter_spacing: None }; + let layout = TextLayout { font: &font, letter_spacing: None, line_height: None }; let min = |text| layout.content_widths(text, None).0; // The longest word wins. The space after it is not part of its width, @@ -1005,7 +1051,7 @@ fn test_content_widths() { #[test] fn test_content_widths_max_lines() { let font = FixedTestFont; - let layout = TextLayout { font: &font, letter_spacing: None }; + let layout = TextLayout { font: &font, letter_spacing: None, line_height: None }; // Only the first line is drawn, so neither width may account for the second one. let (min, max) = layout.content_widths("short\nlongestword", Some(1)); @@ -1026,7 +1072,7 @@ fn test_content_widths_max_lines() { #[test] fn test_content_widths_min_never_exceeds_max() { let font = FixedTestFont; - let layout = TextLayout { font: &font, letter_spacing: None }; + let layout = TextLayout { font: &font, letter_spacing: None, line_height: None }; // A minimum above the preferred width makes preferred_bounded() clamp the preferred // width back up, silently widening the item. for text in ["", " ", "Hello", "a bb longest cc", "short\nlongestword", "aa\u{00a0}bb cc"] { diff --git a/internal/core/textlayout/fragments.rs b/internal/core/textlayout/fragments.rs index f50eab54ab1..4924dacfaf7 100644 --- a/internal/core/textlayout/fragments.rs +++ b/internal/core/textlayout/fragments.rs @@ -126,7 +126,10 @@ use std::{vec, vec::Vec}; fn fragment_iterator_simple() { let font = FixedTestFont; let text = "H WX"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); let expected = vec![ TextFragment { @@ -153,7 +156,10 @@ fn fragment_iterator_simple() { fn fragment_iterator_simple_v2() { let font = FixedTestFont; let text = "Hello World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); let expected = vec![ TextFragment { @@ -180,7 +186,10 @@ fn fragment_iterator_simple_v2() { fn fragment_iterator_forced_break() { let font = FixedTestFont; let text = "H\nW"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); assert_eq!( fragments, @@ -209,7 +218,10 @@ fn fragment_iterator_forced_break() { fn fragment_iterator_forced_break_multi() { let font = FixedTestFont; let text = "H\n\n\nW"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); assert_eq!( fragments, @@ -254,7 +266,10 @@ fn fragment_iterator_forced_break_multi() { fn fragment_iterator_nbsp() { let font = FixedTestFont; let text = "X H\u{00a0}W"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); assert_eq!( fragments, @@ -283,7 +298,10 @@ fn fragment_iterator_nbsp() { fn fragment_iterator_break_anywhere() { let font = FixedTestFont; let text = "AB\nCD\nEF"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let mut fragments = TextFragmentIterator::new(text, &shape_buffer); assert_eq!( fragments.next(), @@ -336,7 +354,10 @@ fn fragment_iterator_break_anywhere() { fn fragment_iterator_leading_nbsp() { let font = FixedTestFont; let text = "A\n\u{00a0}\u{00a0}AB"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let fragments = TextFragmentIterator::new(text, &shape_buffer).collect::>(); assert_eq!( fragments, diff --git a/internal/core/textlayout/linebreaker.rs b/internal/core/textlayout/linebreaker.rs index 918d161fa81..4426da64054 100644 --- a/internal/core/textlayout/linebreaker.rs +++ b/internal/core/textlayout/linebreaker.rs @@ -200,7 +200,10 @@ use super::{FixedTestFont, TextLayout}; fn test_empty_line_break() { let font = FixedTestFont; let text = ""; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -218,7 +221,10 @@ fn test_basic_line_break_char_wrap() { // The available width is half-way into the next word let font = FixedTestFont; let text = "Hello World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -236,7 +242,10 @@ fn test_basic_line_break_char_wrap() { fn test_basic_line_break() { let font = FixedTestFont; let text = "Hello World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -254,7 +263,10 @@ fn test_basic_line_break() { fn test_basic_line_break_max_lines() { let font = FixedTestFont; let text = "Hello World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -271,7 +283,10 @@ fn test_basic_line_break_max_lines() { fn test_linebreak_trailing_space() { let font = FixedTestFont; let text = "Hello "; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -288,7 +303,10 @@ fn test_linebreak_trailing_space() { fn test_forced_break() { let font = FixedTestFont; let text = "Hello\nWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new(text, &shape_buffer, None, None, TextWrap::WordWrap) .collect::>(); @@ -301,7 +319,10 @@ fn test_forced_break() { fn test_forced_break_multi() { let font = FixedTestFont; let text = "Hello\n\n\nWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new(text, &shape_buffer, None, None, TextWrap::WordWrap) .collect::>(); @@ -316,7 +337,10 @@ fn test_forced_break_multi() { fn test_forced_break_multi_char_wrap() { let font = FixedTestFont; let text = "Hello\n\n\nWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -338,7 +362,10 @@ fn test_forced_break_multi_char_wrap() { fn test_forced_break_max_lines() { let font = FixedTestFont; let text = "Hello\n\n\nWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -356,7 +383,10 @@ fn test_forced_break_max_lines() { fn test_nbsp_break() { let font = FixedTestFont; let text = "Ok Hello\u{00a0}World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -374,7 +404,10 @@ fn test_nbsp_break() { fn test_single_line_multi_break_opportunity() { let font = FixedTestFont; let text = "a b c"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new(text, &shape_buffer, None, None, TextWrap::WordWrap) .collect::>(); @@ -386,7 +419,10 @@ fn test_single_line_multi_break_opportunity() { fn test_basic_line_break_anywhere_fallback() { let font = FixedTestFont; let text = "HelloWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -404,7 +440,10 @@ fn test_basic_line_break_anywhere_fallback() { fn test_basic_line_break_anywhere_fallback_multi_line() { let font = FixedTestFont; let text = "HelloWorld\nHelloWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -424,7 +463,10 @@ fn test_basic_line_break_anywhere_fallback_multi_line() { fn test_basic_line_break_anywhere_fallback_multi_line_char_wrap() { let font = FixedTestFont; let text = "HelloWorld\nHelloWorld"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -444,7 +486,10 @@ fn test_basic_line_break_anywhere_fallback_multi_line_char_wrap() { fn test_basic_line_break_anywhere_fallback_multi_line_v2() { let font = FixedTestFont; let text = "HelloW orldHellow"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -464,7 +509,10 @@ fn test_basic_line_break_anywhere_fallback_multi_line_v2() { fn test_basic_line_break_anywhere_fallback_max_lines() { let font = FixedTestFont; let text = "HelloW orldHellow"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -484,7 +532,10 @@ fn test_basic_line_break_space() { // The available width is half-way into the trailing "W" let font = FixedTestFont; let text = "H W"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -503,7 +554,10 @@ fn test_basic_line_break_space_char_wrap() { // The available width is half-way into the trailing "W" let font = FixedTestFont; let text = "H W"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -522,7 +576,10 @@ fn test_basic_line_break_space_v2() { // The available width is half-way into the trailing "W" let font = FixedTestFont; let text = "B B W"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -541,7 +598,10 @@ fn test_basic_line_break_space_v3() { // The available width is half-way into the trailing "W" let font = FixedTestFont; let text = "H W"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -560,7 +620,10 @@ fn test_basic_line_break_space_v4() { // The available width is half-way into the trailing space let font = FixedTestFont; let text = "H W H "; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -577,7 +640,10 @@ fn test_basic_line_break_space_v4() { fn test_line_width_with_whitespace() { let font = FixedTestFont; let text = "Hello World"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -594,7 +660,10 @@ fn test_line_width_with_whitespace() { fn zero_width() { let font = FixedTestFont; let text = "He\nHe o"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -611,7 +680,10 @@ fn zero_width() { fn zero_width_char_wrap() { let font = FixedTestFont; let text = "He\nHe o"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, @@ -628,7 +700,10 @@ fn zero_width_char_wrap() { fn char_wrap_sentences() { let font = FixedTestFont; let text = "Hello world\nHow are you?"; - let shape_buffer = ShapeBuffer::new(&TextLayout { font: &font, letter_spacing: None }, text); + let shape_buffer = ShapeBuffer::new( + &TextLayout { font: &font, letter_spacing: None, line_height: None }, + text, + ); let lines = TextLineBreaker::::new( text, &shape_buffer, diff --git a/internal/core/textlayout/shaping.rs b/internal/core/textlayout/shaping.rs index 30844a72500..29d8522a532 100644 --- a/internal/core/textlayout/shaping.rs +++ b/internal/core/textlayout/shaping.rs @@ -65,7 +65,8 @@ pub trait TextShaper { glyphs: &mut GlyphStorage, ); fn glyph_for_char(&self, ch: char) -> Option>; - fn max_lines(&self, max_height: Self::Length) -> usize; + /// Returns how many lines of `line_height` fit in `max_height`. + fn max_lines(&self, max_height: Self::Length, line_height: Self::Length) -> usize; } pub trait FontMetrics> { @@ -286,8 +287,8 @@ impl TextShaper for &rustybuzz::Face<'_> { todo!() } - fn max_lines(&self, max_height: f32) -> usize { - (max_height / self.height()).floor() as _ + fn max_lines(&self, max_height: f32, line_height: f32) -> usize { + (max_height / line_height).floor() as _ } } @@ -390,7 +391,7 @@ fn test_letter_spacing() { shaped_glyphs.iter().map(|g| g.advance).collect::>() }; - let layout = TextLayout { font: &face, letter_spacing: Some(20.) }; + let layout = TextLayout { font: &face, letter_spacing: Some(20.), line_height: None }; let buffer = ShapeBuffer::new(&layout, text); assert_eq!(buffer.glyphs.len(), advances.len()); diff --git a/internal/core/textlayout/sharedparley.rs b/internal/core/textlayout/sharedparley.rs index a34f26678ad..073c8317ed7 100644 --- a/internal/core/textlayout/sharedparley.rs +++ b/internal/core/textlayout/sharedparley.rs @@ -255,10 +255,14 @@ impl LayoutWithoutLineBreaksBuilder { font_ctx: &'a mut parley::FontContext, text: &'a str, ) -> parley::RangedBuilder<'a, Brush> { - // Pin the line height to the requested font's metrics so fallback runs (e.g. - // password chars rendered by a wider-descender symbol font) don't grow the - // line box. FontSizeRelative makes the ratio scale with any per-span FontSize. + // Use the requested font's natural line-height ratio for every run so fallback fonts, + // such as the symbol font used for password characters, don't enlarge the line box. + // An explicit line height replaces this ratio. + // `FontSizeRelative` scales the result with each styled span's font size. let line_height_ratio = self.font_request.as_ref().and_then(|font_request| { + if let Some(line_height) = font_request.line_height { + return Some(line_height); + } let font = font_request .clone() .query_fontique(&mut font_ctx.collection, &mut font_ctx.source_cache)?; @@ -1932,11 +1936,11 @@ pub fn char_size( skrifa::instance::Size::new(pixel_size.get()), &location, ); + let line_height = font_request + .line_height_for_font_size(pixel_size.get()) + .unwrap_or(font_metrics.ascent - font_metrics.descent); - Some(LogicalSize::from_lengths( - advance_width, - LogicalLength::new(font_metrics.ascent - font_metrics.descent), - )) + Some(LogicalSize::from_lengths(advance_width, LogicalLength::new(line_height))) } pub fn font_metrics( diff --git a/internal/renderers/software/fonts.rs b/internal/renderers/software/fonts.rs index fb4d56a45f1..33f4870c3c6 100644 --- a/internal/renderers/software/fonts.rs +++ b/internal/renderers/software/fonts.rs @@ -222,10 +222,78 @@ where { let letter_spacing = font_request.letter_spacing.map(|spacing| (spacing.cast() * scale_factor).cast()); + let requested_pixel_size = + (font_request.pixel_size.unwrap_or(DEFAULT_FONT_SIZE).cast() * scale_factor).get(); + let line_height = + font_request.line_height_for_font_size(requested_pixel_size).map(|line_height| { + let line_height = num_traits::Float::round(line_height); + PhysicalLength::new(if line_height >= 1.0 { line_height as i16 } else { 1 }) + }); - TextLayout { font, letter_spacing } + TextLayout { font, letter_spacing, line_height } } pub fn register_bitmap_font(font_data: &'static BitmapFont) { BITMAP_FONTS.with(|fonts| fonts.borrow_mut().push(font_data)) } + +#[cfg(test)] +mod tests { + use super::*; + use i_slint_core::lengths::LogicalLength; + use i_slint_core::textlayout::{FontMetrics, Glyph, TextShaper}; + + struct TestFont; + + impl FontMetrics for TestFont { + fn ascent(&self) -> PhysicalLength { + PhysicalLength::new(18) + } + + fn descent(&self) -> PhysicalLength { + PhysicalLength::new(-6) + } + + fn x_height(&self) -> PhysicalLength { + PhysicalLength::new(10) + } + + fn cap_height(&self) -> PhysicalLength { + PhysicalLength::new(14) + } + } + + impl TextShaper for TestFont { + type LengthPrimitive = i16; + type Length = PhysicalLength; + + fn shape_text>>( + &self, + _text: &str, + _glyphs: &mut GlyphStorage, + ) { + } + + fn glyph_for_char(&self, _ch: char) -> Option> { + None + } + + fn max_lines(&self, max_height: Self::Length, line_height: Self::Length) -> usize { + (max_height.get() / line_height.get()) as usize + } + } + + #[test] + fn custom_line_height_uses_requested_font_size() { + let font_request = FontRequest { + pixel_size: Some(LogicalLength::new(20.)), + line_height: Some(1.), + ..Default::default() + }; + + let layout = text_layout_for_font(&TestFont, &font_request, ScaleFactor::new(1.)); + + assert_eq!(TestFont.height(), PhysicalLength::new(24)); + assert_eq!(layout.line_height, Some(PhysicalLength::new(20))); + } +} diff --git a/internal/renderers/software/fonts/pixelfont.rs b/internal/renderers/software/fonts/pixelfont.rs index 1ddfbb9e023..a4249cd19ec 100644 --- a/internal/renderers/software/fonts/pixelfont.rs +++ b/internal/renderers/software/fonts/pixelfont.rs @@ -132,8 +132,8 @@ impl TextShaper for PixelFont { }) } - fn max_lines(&self, max_height: PhysicalLength) -> usize { - (max_height / self.height()).get() as _ + fn max_lines(&self, max_height: PhysicalLength, line_height: PhysicalLength) -> usize { + (max_height / line_height).get() as _ } } diff --git a/internal/renderers/software/fonts/vectorfont.rs b/internal/renderers/software/fonts/vectorfont.rs index 9c4fbbb773b..2c18218d374 100644 --- a/internal/renderers/software/fonts/vectorfont.rs +++ b/internal/renderers/software/fonts/vectorfont.rs @@ -275,8 +275,8 @@ impl TextShaper for VectorFont { }) } - fn max_lines(&self, max_height: PhysicalLength) -> usize { - (max_height / self.height).get() as _ + fn max_lines(&self, max_height: PhysicalLength, line_height: PhysicalLength) -> usize { + (max_height / line_height).get() as _ } } diff --git a/tests/cases/text/line_height.slint b/tests/cases/text/line_height.slint new file mode 100644 index 00000000000..2c77ea8d6a3 --- /dev/null +++ b/tests/cases/text/line_height.slint @@ -0,0 +1,86 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +import "../../../demos/printerdemo/ui/fonts/Inter-24pt-Regular.ttf"; + +export component TestCase inherits Window { + width: 500phx; + height: 500phx; + default-font-size: 10px; + default-font-family: "FixedTestFont"; + + VerticalLayout { + alignment: start; + + default-text := Text { + text: "One\nTwo"; + } + + text := Text { + text: "One\nTwo"; + line-height: 2; + } + + text-input := TextInput { + text: "One\nTwo"; + single-line: false; + line-height: 2; + } + + styled-text := StyledText { + text: @markdown("One"); + line-height: 2; + } + + parley-text := Text { + text: "One\nTwo"; + font-family: "Inter 24pt"; + line-height: 2; + } + + parley-styled-text := StyledText { + text: @markdown("One"); + default-font-family: "Inter 24pt"; + line-height: 2; + } + } + + hit-test-input := TextInput { + x: 300phx; + y: 300phx; + width: 100phx; + height: 60phx; + text: "AA\nBB\nCC"; + single-line: false; + line-height: 2; + } + + out property hit-test-offset: hit-test-input.cursor-position-byte-offset; + out property test: default-text.height == 20phx + && text.height == 40phx + && text-input.height == 40phx + && styled-text.height == 20phx + && parley-text.height == 40phx + && parley-styled-text.height == 20phx; +} + +/* +```rust +let instance = TestCase::new().unwrap(); +assert!(instance.get_test()); + +slint_testing::send_mouse_click(&instance, 305., 325.); +assert_eq!(instance.get_hit_test_offset(), 3); +``` + +```cpp +auto handle = TestCase::create(); +const TestCase &instance = *handle; +assert(instance.get_test()); +``` + +```js +let instance = new slint.TestCase({}); +assert(instance.test); +``` +*/