// Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 // cspell:ignore longestword nlongestword // cSpell: ignore sharedparley //! module for basic text layout //! //! The basic algorithm for breaking text into multiple lines: //! 1. First we determine the boundaries for text shaping. As shaping happens based on a single font and we know that different fonts cater different //! writing systems, we split up the text into chunks that maximize our chances of finding a font that covers all glyphs in the chunk. This way for //! example arabic text can be covered by a font that has excellent arabic coverage while latin text is rendered using a different font. //! Shaping boundaries are always also grapheme boundaries. //! 2. Then we shape the text at shaping boundaries, to determine the metrics of glyphs and glyph clusters //! 3. Loop over all glyph clusters as well as the line break opportunities produced by the unicode line break algorithm: //! Sum up the width of all glyph clusters until the next line break opportunity (encapsulated in FragmentIterator), record separately the width of //! trailing space within the fragment. //! ```text //! If the width of the current line (including trailing whitespace) and the new fragment of glyph clusters (without trailing whitespace) is less or //! equal to the available width: //! Add fragment of glyph clusters to the current line //! Else: //! Emit current line as new line //! If encountering a mandatory line break opportunity: //! Emit current line as new line //! ``` use alloc::vec::Vec; use euclid::num::{One, Zero}; use crate::items::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap}; /// The font size to lay text out with when neither the `.slint` code nor the platform /// provide one. Last level of the precedence chain in /// [`crate::items::WindowItem::resolved_default_font_size`]. pub const DEFAULT_FONT_SIZE: crate::lengths::LogicalLength = crate::lengths::LogicalLength::new(12 as crate::Coord); #[cfg(feature = "unicode-linebreak")] mod linebreak_unicode; #[cfg(feature = "unicode-linebreak")] use linebreak_unicode::{BreakOpportunity, LineBreakIterator}; #[cfg(not(feature = "unicode-linebreak"))] mod linebreak_simple; #[cfg(not(feature = "unicode-linebreak"))] use linebreak_simple::{BreakOpportunity, LineBreakIterator}; mod fragments; mod glyphclusters; mod shaping; #[cfg(feature = "shared-parley")] /// cbindgen:ignore pub mod sharedparley; use shaping::ShapeBuffer; pub use shaping::{AbstractFont, CheckedAdd, DivCount, FontMetrics, Glyph, TextShaper}; mod linebreaker; pub use linebreaker::TextLine; 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()) } /// The offset from the top of a line box to the top of the glyph box within it: half of /// the leading, following the CSS convention of centering the glyphs in the line box, /// like the parley based layout. Negative when the line height is smaller than the /// natural font height. pub fn half_leading(&self) -> Font::Length { let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one(); (self.line_height() - self.font.height()) / two } /// The origin (relative to the top of the line box) and height of cursor and selection /// rectangles: the full line box, but never smaller than the glyph box, mirroring /// parley's clamping for negative leading. pub fn cursor_band(&self) -> (Font::Length, Font::Length) { ( euclid::approxord::min(Font::Length::zero(), self.half_leading()), euclid::approxord::max(self.line_height(), 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. pub fn text_size( &self, text: &str, max_width: Option, text_wrap: TextWrap, max_lines: Option, ) -> (Font::Length, Font::Length) where Font::Length: core::fmt::Debug, { let mut max_line_width = Font::Length::zero(); let mut line_count: i16 = 0; let shape_buffer = ShapeBuffer::new(self, text); for line in TextLineBreaker::::new(text, &shape_buffer, max_width, max_lines, text_wrap) { max_line_width = euclid::approxord::max(max_line_width, line.text_width); line_count += 1; } (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 // up (the longest word), and the width the text takes without wrapping. Both are // measured from a single shaping pass. // // `max_lines` drops the paragraphs that are not drawn, from both widths, so the minimum // never asks for room that a word on a dropped line would need. pub fn content_widths( &self, text: &str, max_lines: Option, ) -> (Font::Length, Font::Length) where Font::Length: core::fmt::Debug, { let shape_buffer = ShapeBuffer::new(self, text); // Fragments end at line break opportunities, and their width excludes the // trailing whitespace, so the widest one is the longest word. let mut min = Font::Length::zero(); let mut lines = 0; for fragment in fragments::TextFragmentIterator::new(text, &shape_buffer) { if max_lines.is_some_and(|max_lines| lines >= max_lines) { break; } min = euclid::approxord::max(min, fragment.width); if fragment.trailing_mandatory_break { lines += 1; } } // Without wrapping every paragraph is one line, so this is the max-content width. let mut max = Font::Length::zero(); for line in TextLineBreaker::::new(text, &shape_buffer, None, max_lines, TextWrap::NoWrap) { max = euclid::approxord::max(max, line.text_width); } (min, max) } } pub struct PositionedGlyph { pub x: Length, pub y: Length, pub advance: Length, pub glyph_id: core::num::NonZeroU16, pub text_byte_offset: usize, } pub struct TextParagraphLayout<'a, Font: AbstractFont> { pub string: &'a str, pub layout: TextLayout<'a, Font>, pub max_width: Font::Length, pub max_height: Font::Length, pub horizontal_alignment: TextHorizontalAlignment, pub vertical_alignment: TextVerticalAlignment, pub wrap: TextWrap, pub overflow: TextOverflow, pub single_line: bool, pub max_lines: Option, } impl TextParagraphLayout<'_, Font> { /// Layout the given string in lines, and call the `layout_line` callback with the line to draw at position y. /// The signature of the `layout_line` function is: `(glyph_iterator, line_x, line_y, text_line, selection)`. /// Returns the baseline y coordinate as Ok, or the break value if `line_callback` returns `core::ops::ControlFlow::Break`. pub fn layout_lines( &self, mut line_callback: impl FnMut( &mut dyn Iterator>, Font::Length, Font::Length, &TextLine, Option>, ) -> core::ops::ControlFlow, selection: Option>, ) -> Result { let wrap = self.wrap != TextWrap::NoWrap; let elide = self.overflow == TextOverflow::Elide; let elide_glyph = if elide { self.layout.font.glyph_for_char('…').filter(|glyph| glyph.glyph_id.is_some()) } else { None }; let elide_width = elide_glyph.as_ref().map_or(Font::Length::zero(), |g| g.advance); let max_width_without_elision = self.max_width - elide_width; let shape_buffer = ShapeBuffer::new(&self.layout, self.string); // When eliding, always keep at least the first line: when it is taller than the box, // dropping it would render nothing at all, which is more confusing than a clipped line. // 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.max_lines_that_fit(line_height).max(1)); let max_lines = [self.max_lines, max_lines_from_height].into_iter().flatten().min(); let new_line_break_iter = || { TextLineBreaker::::new( self.string, &shape_buffer, if wrap { Some(self.max_width) } else { None }, max_lines, self.wrap, ) }; let mut text_lines = None; let mut text_height = || { if self.single_line { line_height } else { text_lines = Some(new_line_break_iter().collect::>()); line_height * (text_lines.as_ref().unwrap().len() as i16).into() } }; let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one(); let baseline_y = match self.vertical_alignment { TextVerticalAlignment::Top => Font::Length::zero(), TextVerticalAlignment::Center => self.max_height / two - text_height() / two, TextVerticalAlignment::Bottom => self.max_height - text_height(), }; let mut y = baseline_y; let mut line_index = 0usize; let mut process_line = |line: &TextLine, glyphs: &[Glyph]| { let elide_long_line = elide && (self.single_line || !wrap) && line.text_width > self.max_width; // The last line before the line limit carries the ellipsis just like the last line // that fits the height, so `max-lines` truncation is signalled the same way. 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 + 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 // line breaker already excludes trailing whitespace from `byte_range`, so the glyphs // past its end are exactly that whitespace to trim. This trimmed range is what gets // measured (for alignment) and drawn; other lines keep the full range. let glyph_range = if elide_last_line { let trailing_whitespace_glyphs = glyphs[line.glyph_range.clone()] .iter() .rev() .take_while(|glyph| glyph.text_byte_offset >= line.byte_range.end) .count(); line.glyph_range.start..line.glyph_range.end - trailing_whitespace_glyphs } else { line.glyph_range.clone() }; let text_width = || { if elide_long_line || elide_last_line { let mut text_width = Font::Length::zero(); for glyph in &glyphs[glyph_range.clone()] { if text_width + glyph.advance > max_width_without_elision { break; } text_width += glyph.advance; } return text_width + elide_width; } euclid::approxord::min(self.max_width, line.text_width) }; let x = match self.horizontal_alignment { TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => { Font::Length::zero() } TextHorizontalAlignment::Center => self.max_width / two - text_width() / two, TextHorizontalAlignment::End | TextHorizontalAlignment::Right => { self.max_width - text_width() } }; let mut elide_glyph = elide_glyph.as_ref(); let selection = selection .as_ref() .filter(|selection| { line.byte_range.start < selection.end && selection.start < line.byte_range.end }) .map(|selection| { let mut begin = Font::Length::zero(); let mut end = Font::Length::zero(); for glyph in glyphs[line.glyph_range.clone()].iter() { if glyph.text_byte_offset < selection.start { begin += glyph.advance; } if glyph.text_byte_offset >= selection.end { break; } end += glyph.advance; } begin..end }); let glyph_it = glyphs[glyph_range.clone()].iter(); let mut glyph_x = Font::Length::zero(); // Up to two output glyphs per input glyph: the glyph itself, plus -- on the last glyph // of a vertically truncated line that still fits the width -- an ellipsis appended // after it (trailing whitespace was already trimmed from `glyph_range`). let mut positioned_glyph_it = glyph_it.enumerate().flat_map(|(index, glyph)| { let mut output: [Option>; 2] = [None, None]; // TODO: cut off at grapheme boundaries if glyph_x > self.max_width { return output.into_iter().flatten(); } // A line that is too wide gets the ellipsis placed *instead of* the first glyph // that no longer fits (horizontal elision); the remaining glyphs are dropped. let elide_long_line = (elide_long_line || elide_last_line) && x + glyph_x + glyph.advance > max_width_without_elision; if elide_long_line { if let Some(elide_glyph) = elide_glyph.take() { let x = glyph_x; glyph_x += elide_glyph.advance; output[0] = Some(PositionedGlyph { x, y: Font::Length::zero(), advance: elide_glyph.advance, glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph text_byte_offset: glyph.text_byte_offset, }); } return output.into_iter().flatten(); } let glyph_pos = glyph_x; glyph_x += glyph.advance; output[0] = glyph.glyph_id.map(|existing_glyph_id| PositionedGlyph { x: glyph_pos, y: Font::Length::zero(), advance: glyph.advance, glyph_id: existing_glyph_id, text_byte_offset: glyph.text_byte_offset, }); // A line that only overflows vertically (it fits the width) keeps all its glyphs // and gets the ellipsis *appended* after the last one rather than overwriting it, // matching the parley path which renders e.g. "Line Two..." not "Line Tw...". let last_glyph = glyph_range.start + index == glyph_range.end - 1; if elide_last_line && last_glyph && let Some(elide_glyph) = elide_glyph.take() { let x = glyph_x; glyph_x += elide_glyph.advance; output[1] = Some(PositionedGlyph { x, y: Font::Length::zero(), advance: elide_glyph.advance, glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph text_byte_offset: glyph.text_byte_offset, }); } output.into_iter().flatten() }); if let core::ops::ControlFlow::Break(break_val) = line_callback(&mut positioned_glyph_it, x, y, line, selection) { return core::ops::ControlFlow::Break(break_val); } y += line_height; line_index += 1; core::ops::ControlFlow::Continue(()) }; if let Some(lines_vec) = text_lines.take() { for line in lines_vec { if let core::ops::ControlFlow::Break(break_val) = process_line(&line, &shape_buffer.glyphs) { return Err(break_val); } } } else { for line in new_line_break_iter() { if let core::ops::ControlFlow::Break(break_val) = process_line(&line, &shape_buffer.glyphs) { return Err(break_val); } } } Ok(baseline_y) } /// How many lines of `line_height` fit within `self.max_height`, rounded down. A line /// height of zero (from `line-height-factor: 0`) collapses all lines onto each other, /// so any number of them fit. fn max_lines_that_fit(&self, line_height: Font::Length) -> usize { if line_height <= Font::Length::zero() { return usize::MAX; } self.max_height.div_count(line_height) } /// Returns the leading edge of the glyph at the given byte offset pub fn cursor_pos_for_byte_offset(&self, byte_offset: usize) -> (Font::Length, Font::Length) { let mut last_glyph_right_edge = Font::Length::zero(); let mut last_line_y = Font::Length::zero(); match self.layout_lines( |glyphs, line_x, line_y, line, _| { last_glyph_right_edge = euclid::approxord::min( self.max_width, line_x + line.width_including_trailing_whitespace(), ); last_line_y = line_y; if byte_offset >= line.byte_range.end + line.trailing_whitespace_bytes { return core::ops::ControlFlow::Continue(()); } for positioned_glyph in glyphs { if positioned_glyph.text_byte_offset == byte_offset { return core::ops::ControlFlow::Break(( euclid::approxord::min(self.max_width, line_x + positioned_glyph.x), last_line_y, )); } } core::ops::ControlFlow::Break((last_glyph_right_edge, last_line_y)) }, None, ) { Ok(_) => (last_glyph_right_edge, last_line_y), Err(position) => position, } } /// Returns the bytes offset for the given position pub fn byte_offset_for_position(&self, (pos_x, pos_y): (Font::Length, Font::Length)) -> usize { let mut byte_offset = 0; let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one(); match self.layout_lines( |glyphs, line_x, line_y, line, _| { if pos_y >= line_y + self.layout.line_height() { byte_offset = line.byte_range.end; return core::ops::ControlFlow::Continue(()); } if line.is_empty() { return core::ops::ControlFlow::Break(line.byte_range.start); } while let Some(positioned_glyph) = glyphs.next() { if pos_x >= line_x + positioned_glyph.x && pos_x <= line_x + positioned_glyph.x + positioned_glyph.advance { if pos_x < line_x + positioned_glyph.x + positioned_glyph.advance / two { return core::ops::ControlFlow::Break( positioned_glyph.text_byte_offset, ); } else if let Some(next_glyph) = glyphs.next() { return core::ops::ControlFlow::Break(next_glyph.text_byte_offset); } } } core::ops::ControlFlow::Break(line.byte_range.end) }, None, ) { Ok(_) => byte_offset, Err(position) => position, } } } #[test] fn test_no_linebreak_opportunity_at_eot() { let mut it = LineBreakIterator::new("Hello World"); assert_eq!(it.next(), Some((6, BreakOpportunity::Allowed))); assert_eq!(it.next(), None); } // All glyphs are 10 pixels wide, break on ascii rules #[cfg(test)] pub struct FixedTestFont; #[cfg(test)] impl TextShaper for FixedTestFont { type LengthPrimitive = f32; type Length = f32; fn shape_text>>( &self, text: &str, glyphs: &mut GlyphStorage, ) { let glyph_iter = text.char_indices().map(|(byte_offset, char)| { let mut utf16_buf = [0; 2]; let utf16_char_as_glyph_id = char.encode_utf16(&mut utf16_buf)[0]; Glyph { offset_x: 0., offset_y: 0., glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id), advance: 10., text_byte_offset: byte_offset, } }); glyphs.extend(glyph_iter); } fn glyph_for_char(&self, ch: char) -> Option> { let mut utf16_buf = [0; 2]; let utf16_char_as_glyph_id = ch.encode_utf16(&mut utf16_buf)[0]; Glyph { offset_x: 0., offset_y: 0., glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id), advance: 10., text_byte_offset: 0, } .into() } } #[cfg(test)] impl FontMetrics for FixedTestFont { fn ascent(&self) -> f32 { 5. } fn descent(&self) -> f32 { -5. } fn x_height(&self) -> f32 { 3. } fn cap_height(&self) -> f32 { 4. } } #[test] fn test_elision() { let font = FixedTestFont; let text = "This is a longer piece of text"; let mut lines = Vec::new(); let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 13. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::NoWrap, overflow: TextOverflow::Elide, single_line: true, max_lines: None, }; paragraph .layout_lines::<()>( |glyphs, _, _, _, _| { lines.push( glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::>(), ); core::ops::ControlFlow::Continue(()) }, None, ) .unwrap(); assert_eq!(lines.len(), 1); let rendered_text = lines[0] .iter() .flat_map(|glyph_id| { core::char::decode_utf16(core::iter::once(glyph_id.get())) .map(|r| r.unwrap()) .collect::>() }) .collect::(); debug_assert_eq!(rendered_text, "This is a lo…") } #[test] fn test_elision_vertical_truncation() { // A line that only overflows vertically (more lines below it were dropped for the height) but // fits the width keeps all its glyphs and gets the ellipsis appended -- "AB…", not "A…". The // box is one line tall, so the second line ("CD") is dropped. let font = FixedTestFont; let text = "AB\nCD"; let mut lines = Vec::new(); let paragraph = TextParagraphLayout { string: text, 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, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::NoWrap, overflow: TextOverflow::Elide, single_line: false, max_lines: None, }; paragraph .layout_lines::<()>( |glyphs, _, _, _, _| { lines.push( glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::>(), ); core::ops::ControlFlow::Continue(()) }, None, ) .unwrap(); // Only the first line is drawn (the box is one line tall). assert_eq!(lines.len(), 1); let rendered_text = lines[0] .iter() .flat_map(|glyph_id| { core::char::decode_utf16(core::iter::once(glyph_id.get())) .map(|r| r.unwrap()) .collect::>() }) .collect::(); assert_eq!(rendered_text, "AB…"); } #[test] fn test_exact_fit() { let font = FixedTestFont; let text = "Fits"; let mut lines = Vec::new(); let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 4. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::NoWrap, overflow: TextOverflow::Elide, single_line: true, max_lines: None, }; paragraph .layout_lines::<()>( |glyphs, _, _, _, _| { lines.push( glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::>(), ); core::ops::ControlFlow::Continue(()) }, None, ) .unwrap(); assert_eq!(lines.len(), 1); let rendered_text = lines[0] .iter() .flat_map(|glyph_id| { core::char::decode_utf16(core::iter::once(glyph_id.get())) .map(|r| r.unwrap()) .collect::>() }) .collect::(); debug_assert_eq!(rendered_text, "Fits") } #[test] fn test_no_line_separators_characters_rendered() { let font = FixedTestFont; let text = "Hello\nWorld\n"; let mut lines = Vec::new(); let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 13. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::NoWrap, overflow: TextOverflow::Clip, single_line: true, max_lines: None, }; paragraph .layout_lines::<()>( |glyphs, _, _, _, _| { lines.push( glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::>(), ); core::ops::ControlFlow::Continue(()) }, None, ) .unwrap(); assert_eq!(lines.len(), 2); let rendered_text = lines .iter() .map(|glyphs_per_line| { glyphs_per_line .iter() .flat_map(|glyph_id| { core::char::decode_utf16(core::iter::once(glyph_id.get())) .map(|r| r.unwrap()) .collect::>() }) .collect::() }) .collect::>(); debug_assert_eq!(rendered_text, std::vec!["Hello", "World"]); } #[test] fn test_max_lines_limits_visible_lines() { let font = FixedTestFont; let text = "Hello\nWorld\nAgain"; let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, 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: Some(2), }; assert_eq!(render_lines(¶graph), std::vec!["Hello", "World"]); } #[cfg(test)] fn render_lines(paragraph: &TextParagraphLayout<'_, FixedTestFont>) -> Vec { let mut lines = Vec::new(); paragraph .layout_lines::<()>( |glyphs, _, _, _, _| { lines.push( glyphs .flat_map(|positioned_glyph| { core::char::decode_utf16(core::iter::once( positioned_glyph.glyph_id.get(), )) .map(|r| r.unwrap()) .collect::>() }) .collect::(), ); core::ops::ControlFlow::Continue(()) }, None, ) .unwrap(); lines } #[test] fn test_max_lines_with_word_wrap() { let font = FixedTestFont; // Wraps to one word per line at 60px; the line limit counts the wrapped lines. let text = "Hello World Again"; let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 6. * 10., max_height: 100., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::WordWrap, overflow: TextOverflow::Clip, single_line: false, max_lines: Some(2), }; assert_eq!(render_lines(¶graph), std::vec!["Hello ", "World "]); } #[test] fn test_max_lines_elide_marks_cut_line() { let font = FixedTestFont; let text = "Hello\nWorld\nAgain"; // The box is tall enough for all three lines, but the line limit cuts after the second: // with `overflow: elide` the ellipsis goes on the last kept line. let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 100., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::NoWrap, overflow: TextOverflow::Elide, single_line: false, max_lines: Some(2), }; assert_eq!(render_lines(¶graph), std::vec!["Hello", "World…"]); } #[test] fn test_text_size_with_max_lines() { let font = FixedTestFont; 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); assert_eq!((width, height), (6. * 10., 3. * 10.)); // With a line limit, both the height and the longest-line width only cover the kept lines. let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, Some(2)); 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.)); // The extra leading is split half above, half below the glyphs, like the parley path. assert_eq!(layout.half_leading(), 2.5); assert_eq!(layout.cursor_band(), (0., 15.)); // With negative leading the cursor band is clamped to the glyph box. let tight = TextLayout { font: &font, letter_spacing: None, line_height: Some(6.) }; assert_eq!(tight.half_leading(), -2.); assert_eq!(tight.cursor_band(), (-2., 10.)); 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; let text = "Hello World"; let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 10. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::WordWrap, overflow: TextOverflow::Clip, single_line: false, max_lines: None, }; assert_eq!(paragraph.cursor_pos_for_byte_offset(0), (0., 0.)); let e_offset = text .char_indices() .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.cursor_pos_for_byte_offset(e_offset), (10., 0.)); let w_offset = text .char_indices() .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.cursor_pos_for_byte_offset(w_offset + 1), (10., 10.)); assert_eq!(paragraph.cursor_pos_for_byte_offset(text.len()), (10. * 5., 10.)); let first_space_offset = text.char_indices().find_map(|(offset, ch)| ch.is_whitespace().then_some(offset)).unwrap(); assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset), (5. * 10., 0.)); assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 15), (10. * 10., 0.)); assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 16), (10. * 10., 0.)); } #[test] fn test_cursor_position_with_newline() { let font = FixedTestFont; let text = "Hello\nWorld"; let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::WordWrap, overflow: TextOverflow::Clip, single_line: false, max_lines: None, }; assert_eq!(paragraph.cursor_pos_for_byte_offset(5), (5. * 10., 0.)); } #[test] fn byte_offset_for_empty_line() { let font = FixedTestFont; let text = "Hello\n\nWorld"; let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 100. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::WordWrap, overflow: TextOverflow::Clip, single_line: false, max_lines: None, }; assert_eq!(paragraph.byte_offset_for_position((0., 10.)), 6); } #[test] fn test_byte_offset() { let font = FixedTestFont; let text = "Hello World"; let mut end_helper_text = std::string::String::from(text); end_helper_text.push('!'); let paragraph = TextParagraphLayout { string: text, layout: TextLayout { font: &font, letter_spacing: None, line_height: None }, max_width: 10. * 10., max_height: 10., horizontal_alignment: TextHorizontalAlignment::Left, vertical_alignment: TextVerticalAlignment::Top, wrap: TextWrap::WordWrap, overflow: TextOverflow::Clip, single_line: false, max_lines: None, }; assert_eq!(paragraph.byte_offset_for_position((0., 0.)), 0); let e_offset = text .char_indices() .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((14., 0.)), e_offset); let l_offset = text .char_indices() .find_map(|(offset, ch)| if ch == 'l' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((15., 0.)), l_offset); let w_offset = text .char_indices() .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((10., 10.)), w_offset + 1); let o_offset = text .char_indices() .rev() .find_map(|(offset, ch)| if ch == 'o' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((15., 10.)), o_offset + 1); let d_offset = text .char_indices() .rev() .find_map(|(offset, ch)| if ch == 'd' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((40., 10.)), d_offset); let end_offset = end_helper_text .char_indices() .rev() .find_map(|(offset, ch)| if ch == '!' { Some(offset) } else { None }) .unwrap(); assert_eq!(paragraph.byte_offset_for_position((45., 10.)), end_offset); assert_eq!(paragraph.byte_offset_for_position((0., 20.)), end_offset); } #[test] fn test_content_widths() { // FixedTestFont: every glyph is 10 pixels wide. let font = FixedTestFont; 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, // otherwise this would be 80. assert_eq!(min("a bb longest cc"), 70.); // A single word: the whole string. assert_eq!(min("Hello"), 50.); // Equal words. assert_eq!(min("Hello World"), 50.); // Whitespace at the end of the string doesn't count either. assert_eq!(min("Hello World "), 50.); // Mandatory breaks are break opportunities too. assert_eq!(min("short\nlongestword"), 110.); assert_eq!(min(""), 0.); assert_eq!(min(" "), 0.); // A no-break space is not a break opportunity, so both words are one chunk. assert_eq!(min("aa\u{00a0}bb cc"), 50.); // Without spaces there is nowhere to break, so the minimum is the whole string. assert_eq!(min("abcdefgh"), 80.); // The max-content width is the width of the text on a single line. assert_eq!(layout.content_widths("a bb longest cc", None).1, 150.); assert_eq!(layout.content_widths("short\nlongestword", None).1, 110.); } #[test] fn test_content_widths_max_lines() { let font = FixedTestFont; 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)); assert_eq!(max, 50.); assert_eq!(min, 50.); // The minimum still comes from the words of the lines that are kept. let (min, max) = layout.content_widths("aa bb\nlongestword", Some(1)); assert_eq!(max, 50.); assert_eq!(min, 20.); // Without a limit both lines count again. let (min, max) = layout.content_widths("aa bb\nlongestword", None); assert_eq!(max, 110.); assert_eq!(min, 110.); } #[test] fn test_content_widths_min_never_exceeds_max() { let font = FixedTestFont; 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"] { for max_lines in [None, Some(1), Some(2)] { let (min, max) = layout.content_widths(text, max_lines); assert!(min <= max, "min {min} > max {max} for {text:?} with {max_lines:?}"); } } }