Skip to main content

i_slint_core/
textlayout.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4// cspell:ignore longestword nlongestword
5
6// cSpell: ignore sharedparley
7//! module for basic text layout
8//!
9//! The basic algorithm for breaking text into multiple lines:
10//! 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
11//!    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
12//!    example arabic text can be covered by a font that has excellent arabic coverage while latin text is rendered using a different font.
13//!    Shaping boundaries are always also grapheme boundaries.
14//! 2. Then we shape the text at shaping boundaries, to determine the metrics of glyphs and glyph clusters
15//! 3. Loop over all glyph clusters as well as the line break opportunities produced by the unicode line break algorithm:
16//!    Sum up the width of all glyph clusters until the next line break opportunity (encapsulated in FragmentIterator), record separately the width of
17//!    trailing space within the fragment.
18//!    ```text
19//!    If the width of the current line (including trailing whitespace) and the new fragment of glyph clusters (without trailing whitespace) is less or
20//!    equal to the available width:
21//!        Add fragment of glyph clusters to the current line
22//!    Else:
23//!        Emit current line as new line
24//!    If encountering a mandatory line break opportunity:
25//!        Emit current line as new line
26//!    ```
27
28use alloc::vec::Vec;
29
30use euclid::num::{One, Zero};
31
32use crate::items::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap};
33
34/// The font size to lay text out with when neither the `.slint` code nor the platform
35/// provide one. Last level of the precedence chain in
36/// [`crate::items::WindowItem::resolved_default_font_size`].
37pub const DEFAULT_FONT_SIZE: crate::lengths::LogicalLength =
38    crate::lengths::LogicalLength::new(12 as crate::Coord);
39
40#[cfg(feature = "unicode-linebreak")]
41mod linebreak_unicode;
42#[cfg(feature = "unicode-linebreak")]
43use linebreak_unicode::{BreakOpportunity, LineBreakIterator};
44
45#[cfg(not(feature = "unicode-linebreak"))]
46mod linebreak_simple;
47#[cfg(not(feature = "unicode-linebreak"))]
48use linebreak_simple::{BreakOpportunity, LineBreakIterator};
49
50mod fragments;
51mod glyphclusters;
52mod shaping;
53#[cfg(feature = "shared-parley")]
54/// cbindgen:ignore
55pub mod sharedparley;
56use shaping::ShapeBuffer;
57pub use shaping::{AbstractFont, CheckedAdd, DivCount, FontMetrics, Glyph, TextShaper};
58
59mod linebreaker;
60pub use linebreaker::TextLine;
61
62pub use linebreaker::TextLineBreaker;
63
64pub struct TextLayout<'a, Font: AbstractFont> {
65    pub font: &'a Font,
66    pub letter_spacing: Option<<Font as TextShaper>::Length>,
67    /// The absolute line height, or `None` to use [`FontMetrics::height`].
68    pub line_height: Option<<Font as TextShaper>::Length>,
69}
70
71impl<Font: AbstractFont> TextLayout<'_, Font> {
72    fn line_height(&self) -> Font::Length {
73        self.line_height.unwrap_or_else(|| self.font.height())
74    }
75
76    /// The offset from the top of a line box to the top of the glyph box within it: half of
77    /// the leading, following the CSS convention of centering the glyphs in the line box,
78    /// like the parley based layout. Negative when the line height is smaller than the
79    /// natural font height.
80    pub fn half_leading(&self) -> Font::Length {
81        let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
82        (self.line_height() - self.font.height()) / two
83    }
84
85    /// The origin (relative to the top of the line box) and height of cursor and selection
86    /// rectangles: the full line box, but never smaller than the glyph box, mirroring
87    /// parley's clamping for negative leading.
88    pub fn cursor_band(&self) -> (Font::Length, Font::Length) {
89        (
90            euclid::approxord::min(Font::Length::zero(), self.half_leading()),
91            euclid::approxord::max(self.line_height(), self.font.height()),
92        )
93    }
94
95    // Measures the size of the given text when rendered with the specified font and optionally constrained
96    // by the provided `max_width`.
97    // Returns a tuple of the width of the longest line as well as height of all lines.
98    pub fn text_size(
99        &self,
100        text: &str,
101        max_width: Option<Font::Length>,
102        text_wrap: TextWrap,
103        max_lines: Option<usize>,
104    ) -> (Font::Length, Font::Length)
105    where
106        Font::Length: core::fmt::Debug,
107    {
108        let mut max_line_width = Font::Length::zero();
109        let mut line_count: i16 = 0;
110        let shape_buffer = ShapeBuffer::new(self, text);
111
112        for line in
113            TextLineBreaker::<Font>::new(text, &shape_buffer, max_width, max_lines, text_wrap)
114        {
115            max_line_width = euclid::approxord::max(max_line_width, line.text_width);
116            line_count += 1;
117        }
118
119        (max_line_width, self.line_height() * line_count.into())
120    }
121
122    // The min- and max-content width: the width of the widest chunk that cannot be broken
123    // up (the longest word), and the width the text takes without wrapping. Both are
124    // measured from a single shaping pass.
125    //
126    // `max_lines` drops the paragraphs that are not drawn, from both widths, so the minimum
127    // never asks for room that a word on a dropped line would need.
128    pub fn content_widths(
129        &self,
130        text: &str,
131        max_lines: Option<usize>,
132    ) -> (Font::Length, Font::Length)
133    where
134        Font::Length: core::fmt::Debug,
135    {
136        let shape_buffer = ShapeBuffer::new(self, text);
137
138        // Fragments end at line break opportunities, and their width excludes the
139        // trailing whitespace, so the widest one is the longest word.
140        let mut min = Font::Length::zero();
141        let mut lines = 0;
142        for fragment in fragments::TextFragmentIterator::new(text, &shape_buffer) {
143            if max_lines.is_some_and(|max_lines| lines >= max_lines) {
144                break;
145            }
146            min = euclid::approxord::max(min, fragment.width);
147            if fragment.trailing_mandatory_break {
148                lines += 1;
149            }
150        }
151
152        // Without wrapping every paragraph is one line, so this is the max-content width.
153        let mut max = Font::Length::zero();
154        for line in
155            TextLineBreaker::<Font>::new(text, &shape_buffer, None, max_lines, TextWrap::NoWrap)
156        {
157            max = euclid::approxord::max(max, line.text_width);
158        }
159
160        (min, max)
161    }
162}
163
164pub struct PositionedGlyph<Length> {
165    pub x: Length,
166    pub y: Length,
167    pub advance: Length,
168    pub glyph_id: core::num::NonZeroU16,
169    pub text_byte_offset: usize,
170}
171
172pub struct TextParagraphLayout<'a, Font: AbstractFont> {
173    pub string: &'a str,
174    pub layout: TextLayout<'a, Font>,
175    pub max_width: Font::Length,
176    pub max_height: Font::Length,
177    pub horizontal_alignment: TextHorizontalAlignment,
178    pub vertical_alignment: TextVerticalAlignment,
179    pub wrap: TextWrap,
180    pub overflow: TextOverflow,
181    pub single_line: bool,
182    pub max_lines: Option<usize>,
183}
184
185impl<Font: AbstractFont> TextParagraphLayout<'_, Font> {
186    /// Layout the given string in lines, and call the `layout_line` callback with the line to draw at position y.
187    /// The signature of the `layout_line` function is: `(glyph_iterator, line_x, line_y, text_line, selection)`.
188    /// Returns the baseline y coordinate as Ok, or the break value if `line_callback` returns `core::ops::ControlFlow::Break`.
189    pub fn layout_lines<R>(
190        &self,
191        mut line_callback: impl FnMut(
192            &mut dyn Iterator<Item = PositionedGlyph<Font::Length>>,
193            Font::Length,
194            Font::Length,
195            &TextLine<Font::Length>,
196            Option<core::ops::Range<Font::Length>>,
197        ) -> core::ops::ControlFlow<R>,
198        selection: Option<core::ops::Range<usize>>,
199    ) -> Result<Font::Length, R> {
200        let wrap = self.wrap != TextWrap::NoWrap;
201        let elide = self.overflow == TextOverflow::Elide;
202        let elide_glyph = if elide {
203            self.layout.font.glyph_for_char('…').filter(|glyph| glyph.glyph_id.is_some())
204        } else {
205            None
206        };
207        let elide_width = elide_glyph.as_ref().map_or(Font::Length::zero(), |g| g.advance);
208        let max_width_without_elision = self.max_width - elide_width;
209
210        let shape_buffer = ShapeBuffer::new(&self.layout, self.string);
211
212        // When eliding, always keep at least the first line: when it is taller than the box,
213        // dropping it would render nothing at all, which is more confusing than a clipped line.
214        // The software renderer already clips glyphs to the Text geometry, so the vertical
215        // overflow is trimmed; horizontal elision still places an ellipsis if it is too
216        // wide. Mirrors the parley path, which always keeps line index 0.
217        let line_height = self.layout.line_height();
218        let max_lines_from_height = elide.then(|| self.max_lines_that_fit(line_height).max(1));
219        let max_lines = [self.max_lines, max_lines_from_height].into_iter().flatten().min();
220
221        let new_line_break_iter = || {
222            TextLineBreaker::<Font>::new(
223                self.string,
224                &shape_buffer,
225                if wrap { Some(self.max_width) } else { None },
226                max_lines,
227                self.wrap,
228            )
229        };
230        let mut text_lines = None;
231
232        let mut text_height = || {
233            if self.single_line {
234                line_height
235            } else {
236                text_lines = Some(new_line_break_iter().collect::<Vec<_>>());
237                line_height * (text_lines.as_ref().unwrap().len() as i16).into()
238            }
239        };
240
241        let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
242
243        let baseline_y = match self.vertical_alignment {
244            TextVerticalAlignment::Top => Font::Length::zero(),
245            TextVerticalAlignment::Center => self.max_height / two - text_height() / two,
246            TextVerticalAlignment::Bottom => self.max_height - text_height(),
247        };
248
249        let mut y = baseline_y;
250        let mut line_index = 0usize;
251
252        let mut process_line = |line: &TextLine<Font::Length>, glyphs: &[Glyph<Font::Length>]| {
253            let elide_long_line =
254                elide && (self.single_line || !wrap) && line.text_width > self.max_width;
255            // The last line before the line limit carries the ellipsis just like the last line
256            // that fits the height, so `max-lines` truncation is signalled the same way.
257            let reached_line_limit = max_lines.is_some_and(|max_lines| line_index + 1 == max_lines);
258            let elide_last_line = elide
259                && line.glyph_range.end < glyphs.len()
260                && (y + line_height * two > self.max_height || reached_line_limit);
261
262            // On a vertically truncated line the ellipsis is anchored right after the text by
263            // ignoring trailing whitespace, so it reads "please…" rather than "please   …". The
264            // line breaker already excludes trailing whitespace from `byte_range`, so the glyphs
265            // past its end are exactly that whitespace to trim. This trimmed range is what gets
266            // measured (for alignment) and drawn; other lines keep the full range.
267            let glyph_range = if elide_last_line {
268                let trailing_whitespace_glyphs = glyphs[line.glyph_range.clone()]
269                    .iter()
270                    .rev()
271                    .take_while(|glyph| glyph.text_byte_offset >= line.byte_range.end)
272                    .count();
273                line.glyph_range.start..line.glyph_range.end - trailing_whitespace_glyphs
274            } else {
275                line.glyph_range.clone()
276            };
277
278            let text_width = || {
279                if elide_long_line || elide_last_line {
280                    let mut text_width = Font::Length::zero();
281                    for glyph in &glyphs[glyph_range.clone()] {
282                        if text_width + glyph.advance > max_width_without_elision {
283                            break;
284                        }
285                        text_width += glyph.advance;
286                    }
287                    return text_width + elide_width;
288                }
289                euclid::approxord::min(self.max_width, line.text_width)
290            };
291
292            let x = match self.horizontal_alignment {
293                TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => {
294                    Font::Length::zero()
295                }
296                TextHorizontalAlignment::Center => self.max_width / two - text_width() / two,
297                TextHorizontalAlignment::End | TextHorizontalAlignment::Right => {
298                    self.max_width - text_width()
299                }
300            };
301
302            let mut elide_glyph = elide_glyph.as_ref();
303
304            let selection = selection
305                .as_ref()
306                .filter(|selection| {
307                    line.byte_range.start < selection.end && selection.start < line.byte_range.end
308                })
309                .map(|selection| {
310                    let mut begin = Font::Length::zero();
311                    let mut end = Font::Length::zero();
312                    for glyph in glyphs[line.glyph_range.clone()].iter() {
313                        if glyph.text_byte_offset < selection.start {
314                            begin += glyph.advance;
315                        }
316                        if glyph.text_byte_offset >= selection.end {
317                            break;
318                        }
319                        end += glyph.advance;
320                    }
321                    begin..end
322                });
323
324            let glyph_it = glyphs[glyph_range.clone()].iter();
325            let mut glyph_x = Font::Length::zero();
326            // Up to two output glyphs per input glyph: the glyph itself, plus -- on the last glyph
327            // of a vertically truncated line that still fits the width -- an ellipsis appended
328            // after it (trailing whitespace was already trimmed from `glyph_range`).
329            let mut positioned_glyph_it = glyph_it.enumerate().flat_map(|(index, glyph)| {
330                let mut output: [Option<PositionedGlyph<Font::Length>>; 2] = [None, None];
331                // TODO: cut off at grapheme boundaries
332                if glyph_x > self.max_width {
333                    return output.into_iter().flatten();
334                }
335                // A line that is too wide gets the ellipsis placed *instead of* the first glyph
336                // that no longer fits (horizontal elision); the remaining glyphs are dropped.
337                let elide_long_line = (elide_long_line || elide_last_line)
338                    && x + glyph_x + glyph.advance > max_width_without_elision;
339                if elide_long_line {
340                    if let Some(elide_glyph) = elide_glyph.take() {
341                        let x = glyph_x;
342                        glyph_x += elide_glyph.advance;
343                        output[0] = Some(PositionedGlyph {
344                            x,
345                            y: Font::Length::zero(),
346                            advance: elide_glyph.advance,
347                            glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
348                            text_byte_offset: glyph.text_byte_offset,
349                        });
350                    }
351                    return output.into_iter().flatten();
352                }
353
354                let glyph_pos = glyph_x;
355                glyph_x += glyph.advance;
356                output[0] = glyph.glyph_id.map(|existing_glyph_id| PositionedGlyph {
357                    x: glyph_pos,
358                    y: Font::Length::zero(),
359                    advance: glyph.advance,
360                    glyph_id: existing_glyph_id,
361                    text_byte_offset: glyph.text_byte_offset,
362                });
363
364                // A line that only overflows vertically (it fits the width) keeps all its glyphs
365                // and gets the ellipsis *appended* after the last one rather than overwriting it,
366                // matching the parley path which renders e.g. "Line Two..." not "Line Tw...".
367                let last_glyph = glyph_range.start + index == glyph_range.end - 1;
368                if elide_last_line
369                    && last_glyph
370                    && let Some(elide_glyph) = elide_glyph.take()
371                {
372                    let x = glyph_x;
373                    glyph_x += elide_glyph.advance;
374                    output[1] = Some(PositionedGlyph {
375                        x,
376                        y: Font::Length::zero(),
377                        advance: elide_glyph.advance,
378                        glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
379                        text_byte_offset: glyph.text_byte_offset,
380                    });
381                }
382
383                output.into_iter().flatten()
384            });
385
386            if let core::ops::ControlFlow::Break(break_val) =
387                line_callback(&mut positioned_glyph_it, x, y, line, selection)
388            {
389                return core::ops::ControlFlow::Break(break_val);
390            }
391            y += line_height;
392            line_index += 1;
393
394            core::ops::ControlFlow::Continue(())
395        };
396
397        if let Some(lines_vec) = text_lines.take() {
398            for line in lines_vec {
399                if let core::ops::ControlFlow::Break(break_val) =
400                    process_line(&line, &shape_buffer.glyphs)
401                {
402                    return Err(break_val);
403                }
404            }
405        } else {
406            for line in new_line_break_iter() {
407                if let core::ops::ControlFlow::Break(break_val) =
408                    process_line(&line, &shape_buffer.glyphs)
409                {
410                    return Err(break_val);
411                }
412            }
413        }
414
415        Ok(baseline_y)
416    }
417
418    /// How many lines of `line_height` fit within `self.max_height`, rounded down. A line
419    /// height of zero (from `line-height-factor: 0`) collapses all lines onto each other,
420    /// so any number of them fit.
421    fn max_lines_that_fit(&self, line_height: Font::Length) -> usize {
422        if line_height <= Font::Length::zero() {
423            return usize::MAX;
424        }
425        self.max_height.div_count(line_height)
426    }
427
428    /// Returns the leading edge of the glyph at the given byte offset
429    pub fn cursor_pos_for_byte_offset(&self, byte_offset: usize) -> (Font::Length, Font::Length) {
430        let mut last_glyph_right_edge = Font::Length::zero();
431        let mut last_line_y = Font::Length::zero();
432
433        match self.layout_lines(
434            |glyphs, line_x, line_y, line, _| {
435                last_glyph_right_edge = euclid::approxord::min(
436                    self.max_width,
437                    line_x + line.width_including_trailing_whitespace(),
438                );
439                last_line_y = line_y;
440                if byte_offset >= line.byte_range.end + line.trailing_whitespace_bytes {
441                    return core::ops::ControlFlow::Continue(());
442                }
443
444                for positioned_glyph in glyphs {
445                    if positioned_glyph.text_byte_offset == byte_offset {
446                        return core::ops::ControlFlow::Break((
447                            euclid::approxord::min(self.max_width, line_x + positioned_glyph.x),
448                            last_line_y,
449                        ));
450                    }
451                }
452
453                core::ops::ControlFlow::Break((last_glyph_right_edge, last_line_y))
454            },
455            None,
456        ) {
457            Ok(_) => (last_glyph_right_edge, last_line_y),
458            Err(position) => position,
459        }
460    }
461
462    /// Returns the bytes offset for the given position
463    pub fn byte_offset_for_position(&self, (pos_x, pos_y): (Font::Length, Font::Length)) -> usize {
464        let mut byte_offset = 0;
465        let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
466
467        match self.layout_lines(
468            |glyphs, line_x, line_y, line, _| {
469                if pos_y >= line_y + self.layout.line_height() {
470                    byte_offset = line.byte_range.end;
471                    return core::ops::ControlFlow::Continue(());
472                }
473
474                if line.is_empty() {
475                    return core::ops::ControlFlow::Break(line.byte_range.start);
476                }
477
478                while let Some(positioned_glyph) = glyphs.next() {
479                    if pos_x >= line_x + positioned_glyph.x
480                        && pos_x <= line_x + positioned_glyph.x + positioned_glyph.advance
481                    {
482                        if pos_x < line_x + positioned_glyph.x + positioned_glyph.advance / two {
483                            return core::ops::ControlFlow::Break(
484                                positioned_glyph.text_byte_offset,
485                            );
486                        } else if let Some(next_glyph) = glyphs.next() {
487                            return core::ops::ControlFlow::Break(next_glyph.text_byte_offset);
488                        }
489                    }
490                }
491
492                core::ops::ControlFlow::Break(line.byte_range.end)
493            },
494            None,
495        ) {
496            Ok(_) => byte_offset,
497            Err(position) => position,
498        }
499    }
500}
501
502#[test]
503fn test_no_linebreak_opportunity_at_eot() {
504    let mut it = LineBreakIterator::new("Hello World");
505    assert_eq!(it.next(), Some((6, BreakOpportunity::Allowed)));
506    assert_eq!(it.next(), None);
507}
508
509// All glyphs are 10 pixels wide, break on ascii rules
510#[cfg(test)]
511pub struct FixedTestFont;
512
513#[cfg(test)]
514impl TextShaper for FixedTestFont {
515    type LengthPrimitive = f32;
516    type Length = f32;
517    fn shape_text<GlyphStorage: std::iter::Extend<Glyph<f32>>>(
518        &self,
519        text: &str,
520        glyphs: &mut GlyphStorage,
521    ) {
522        let glyph_iter = text.char_indices().map(|(byte_offset, char)| {
523            let mut utf16_buf = [0; 2];
524            let utf16_char_as_glyph_id = char.encode_utf16(&mut utf16_buf)[0];
525
526            Glyph {
527                offset_x: 0.,
528                offset_y: 0.,
529                glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
530                advance: 10.,
531                text_byte_offset: byte_offset,
532            }
533        });
534        glyphs.extend(glyph_iter);
535    }
536
537    fn glyph_for_char(&self, ch: char) -> Option<Glyph<f32>> {
538        let mut utf16_buf = [0; 2];
539        let utf16_char_as_glyph_id = ch.encode_utf16(&mut utf16_buf)[0];
540
541        Glyph {
542            offset_x: 0.,
543            offset_y: 0.,
544            glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
545            advance: 10.,
546            text_byte_offset: 0,
547        }
548        .into()
549    }
550}
551
552#[cfg(test)]
553impl FontMetrics<f32> for FixedTestFont {
554    fn ascent(&self) -> f32 {
555        5.
556    }
557
558    fn descent(&self) -> f32 {
559        -5.
560    }
561
562    fn x_height(&self) -> f32 {
563        3.
564    }
565
566    fn cap_height(&self) -> f32 {
567        4.
568    }
569}
570
571#[test]
572fn test_elision() {
573    let font = FixedTestFont;
574    let text = "This is a longer piece of text";
575
576    let mut lines = Vec::new();
577
578    let paragraph = TextParagraphLayout {
579        string: text,
580        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
581        max_width: 13. * 10.,
582        max_height: 10.,
583        horizontal_alignment: TextHorizontalAlignment::Left,
584        vertical_alignment: TextVerticalAlignment::Top,
585        wrap: TextWrap::NoWrap,
586        overflow: TextOverflow::Elide,
587        single_line: true,
588        max_lines: None,
589    };
590    paragraph
591        .layout_lines::<()>(
592            |glyphs, _, _, _, _| {
593                lines.push(
594                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
595                );
596                core::ops::ControlFlow::Continue(())
597            },
598            None,
599        )
600        .unwrap();
601
602    assert_eq!(lines.len(), 1);
603    let rendered_text = lines[0]
604        .iter()
605        .flat_map(|glyph_id| {
606            core::char::decode_utf16(core::iter::once(glyph_id.get()))
607                .map(|r| r.unwrap())
608                .collect::<Vec<char>>()
609        })
610        .collect::<std::string::String>();
611    debug_assert_eq!(rendered_text, "This is a lo…")
612}
613
614#[test]
615fn test_elision_vertical_truncation() {
616    // A line that only overflows vertically (more lines below it were dropped for the height) but
617    // fits the width keeps all its glyphs and gets the ellipsis appended -- "AB…", not "A…". The
618    // box is one line tall, so the second line ("CD") is dropped.
619    let font = FixedTestFont;
620    let text = "AB\nCD";
621
622    let mut lines = Vec::new();
623
624    let paragraph = TextParagraphLayout {
625        string: text,
626        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
627        max_width: 4. * 10., // room for "AB…" (3 glyphs) and more
628        max_height: 10.,     // one line tall
629        horizontal_alignment: TextHorizontalAlignment::Left,
630        vertical_alignment: TextVerticalAlignment::Top,
631        wrap: TextWrap::NoWrap,
632        overflow: TextOverflow::Elide,
633        single_line: false,
634        max_lines: None,
635    };
636    paragraph
637        .layout_lines::<()>(
638            |glyphs, _, _, _, _| {
639                lines.push(
640                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
641                );
642                core::ops::ControlFlow::Continue(())
643            },
644            None,
645        )
646        .unwrap();
647
648    // Only the first line is drawn (the box is one line tall).
649    assert_eq!(lines.len(), 1);
650    let rendered_text = lines[0]
651        .iter()
652        .flat_map(|glyph_id| {
653            core::char::decode_utf16(core::iter::once(glyph_id.get()))
654                .map(|r| r.unwrap())
655                .collect::<Vec<char>>()
656        })
657        .collect::<std::string::String>();
658    assert_eq!(rendered_text, "AB…");
659}
660
661#[test]
662fn test_exact_fit() {
663    let font = FixedTestFont;
664    let text = "Fits";
665
666    let mut lines = Vec::new();
667
668    let paragraph = TextParagraphLayout {
669        string: text,
670        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
671        max_width: 4. * 10.,
672        max_height: 10.,
673        horizontal_alignment: TextHorizontalAlignment::Left,
674        vertical_alignment: TextVerticalAlignment::Top,
675        wrap: TextWrap::NoWrap,
676        overflow: TextOverflow::Elide,
677        single_line: true,
678        max_lines: None,
679    };
680    paragraph
681        .layout_lines::<()>(
682            |glyphs, _, _, _, _| {
683                lines.push(
684                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
685                );
686                core::ops::ControlFlow::Continue(())
687            },
688            None,
689        )
690        .unwrap();
691
692    assert_eq!(lines.len(), 1);
693    let rendered_text = lines[0]
694        .iter()
695        .flat_map(|glyph_id| {
696            core::char::decode_utf16(core::iter::once(glyph_id.get()))
697                .map(|r| r.unwrap())
698                .collect::<Vec<char>>()
699        })
700        .collect::<std::string::String>();
701    debug_assert_eq!(rendered_text, "Fits")
702}
703
704#[test]
705fn test_no_line_separators_characters_rendered() {
706    let font = FixedTestFont;
707    let text = "Hello\nWorld\n";
708
709    let mut lines = Vec::new();
710
711    let paragraph = TextParagraphLayout {
712        string: text,
713        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
714        max_width: 13. * 10.,
715        max_height: 10.,
716        horizontal_alignment: TextHorizontalAlignment::Left,
717        vertical_alignment: TextVerticalAlignment::Top,
718        wrap: TextWrap::NoWrap,
719        overflow: TextOverflow::Clip,
720        single_line: true,
721        max_lines: None,
722    };
723    paragraph
724        .layout_lines::<()>(
725            |glyphs, _, _, _, _| {
726                lines.push(
727                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
728                );
729                core::ops::ControlFlow::Continue(())
730            },
731            None,
732        )
733        .unwrap();
734
735    assert_eq!(lines.len(), 2);
736    let rendered_text = lines
737        .iter()
738        .map(|glyphs_per_line| {
739            glyphs_per_line
740                .iter()
741                .flat_map(|glyph_id| {
742                    core::char::decode_utf16(core::iter::once(glyph_id.get()))
743                        .map(|r| r.unwrap())
744                        .collect::<Vec<char>>()
745                })
746                .collect::<std::string::String>()
747        })
748        .collect::<Vec<_>>();
749    debug_assert_eq!(rendered_text, std::vec!["Hello", "World"]);
750}
751
752#[test]
753fn test_max_lines_limits_visible_lines() {
754    let font = FixedTestFont;
755    let text = "Hello\nWorld\nAgain";
756
757    let paragraph = TextParagraphLayout {
758        string: text,
759        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
760        max_width: 100. * 10.,
761        max_height: 100.,
762        horizontal_alignment: TextHorizontalAlignment::Left,
763        vertical_alignment: TextVerticalAlignment::Top,
764        wrap: TextWrap::NoWrap,
765        overflow: TextOverflow::Clip,
766        single_line: false,
767        max_lines: Some(2),
768    };
769    assert_eq!(render_lines(&paragraph), std::vec!["Hello", "World"]);
770}
771
772#[cfg(test)]
773fn render_lines(paragraph: &TextParagraphLayout<'_, FixedTestFont>) -> Vec<std::string::String> {
774    let mut lines = Vec::new();
775    paragraph
776        .layout_lines::<()>(
777            |glyphs, _, _, _, _| {
778                lines.push(
779                    glyphs
780                        .flat_map(|positioned_glyph| {
781                            core::char::decode_utf16(core::iter::once(
782                                positioned_glyph.glyph_id.get(),
783                            ))
784                            .map(|r| r.unwrap())
785                            .collect::<Vec<char>>()
786                        })
787                        .collect::<std::string::String>(),
788                );
789                core::ops::ControlFlow::Continue(())
790            },
791            None,
792        )
793        .unwrap();
794    lines
795}
796
797#[test]
798fn test_max_lines_with_word_wrap() {
799    let font = FixedTestFont;
800    // Wraps to one word per line at 60px; the line limit counts the wrapped lines.
801    let text = "Hello World Again";
802
803    let paragraph = TextParagraphLayout {
804        string: text,
805        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
806        max_width: 6. * 10.,
807        max_height: 100.,
808        horizontal_alignment: TextHorizontalAlignment::Left,
809        vertical_alignment: TextVerticalAlignment::Top,
810        wrap: TextWrap::WordWrap,
811        overflow: TextOverflow::Clip,
812        single_line: false,
813        max_lines: Some(2),
814    };
815    assert_eq!(render_lines(&paragraph), std::vec!["Hello ", "World "]);
816}
817
818#[test]
819fn test_max_lines_elide_marks_cut_line() {
820    let font = FixedTestFont;
821    let text = "Hello\nWorld\nAgain";
822
823    // The box is tall enough for all three lines, but the line limit cuts after the second:
824    // with `overflow: elide` the ellipsis goes on the last kept line.
825    let paragraph = TextParagraphLayout {
826        string: text,
827        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
828        max_width: 100. * 10.,
829        max_height: 100.,
830        horizontal_alignment: TextHorizontalAlignment::Left,
831        vertical_alignment: TextVerticalAlignment::Top,
832        wrap: TextWrap::NoWrap,
833        overflow: TextOverflow::Elide,
834        single_line: false,
835        max_lines: Some(2),
836    };
837    assert_eq!(render_lines(&paragraph), std::vec!["Hello", "World…"]);
838}
839
840#[test]
841fn test_text_size_with_max_lines() {
842    let font = FixedTestFont;
843    let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
844    let text = "On\nFour\nLonger";
845
846    let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, None);
847    assert_eq!((width, height), (6. * 10., 3. * 10.));
848
849    // With a line limit, both the height and the longest-line width only cover the kept lines.
850    let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, Some(2));
851    assert_eq!((width, height), (4. * 10., 2. * 10.));
852}
853
854#[test]
855fn test_line_height() {
856    let font = FixedTestFont;
857    let layout = TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) };
858    let text = "One\nTwo";
859
860    assert_eq!(layout.text_size(text, None, TextWrap::NoWrap, None), (3. * 10., 2. * 15.));
861
862    // The extra leading is split half above, half below the glyphs, like the parley path.
863    assert_eq!(layout.half_leading(), 2.5);
864    assert_eq!(layout.cursor_band(), (0., 15.));
865
866    // With negative leading the cursor band is clamped to the glyph box.
867    let tight = TextLayout { font: &font, letter_spacing: None, line_height: Some(6.) };
868    assert_eq!(tight.half_leading(), -2.);
869    assert_eq!(tight.cursor_band(), (-2., 10.));
870
871    let paragraph = TextParagraphLayout {
872        string: text,
873        layout,
874        max_width: 100. * 10.,
875        max_height: 100.,
876        horizontal_alignment: TextHorizontalAlignment::Left,
877        vertical_alignment: TextVerticalAlignment::Top,
878        wrap: TextWrap::NoWrap,
879        overflow: TextOverflow::Clip,
880        single_line: false,
881        max_lines: None,
882    };
883
884    assert_eq!(paragraph.cursor_pos_for_byte_offset(4), (0., 15.));
885    assert_eq!(paragraph.byte_offset_for_position((0., 16.)), 4);
886
887    let paragraph = TextParagraphLayout {
888        string: "One\nTwo\nThree",
889        layout: TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) },
890        max_width: 100. * 10.,
891        max_height: 29.,
892        horizontal_alignment: TextHorizontalAlignment::Left,
893        vertical_alignment: TextVerticalAlignment::Top,
894        wrap: TextWrap::NoWrap,
895        overflow: TextOverflow::Elide,
896        single_line: false,
897        max_lines: None,
898    };
899
900    assert_eq!(render_lines(&paragraph), std::vec!["One…"]);
901}
902
903#[test]
904fn test_cursor_position() {
905    let font = FixedTestFont;
906    let text = "Hello                    World";
907
908    let paragraph = TextParagraphLayout {
909        string: text,
910        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
911        max_width: 10. * 10.,
912        max_height: 10.,
913        horizontal_alignment: TextHorizontalAlignment::Left,
914        vertical_alignment: TextVerticalAlignment::Top,
915        wrap: TextWrap::WordWrap,
916        overflow: TextOverflow::Clip,
917        single_line: false,
918        max_lines: None,
919    };
920
921    assert_eq!(paragraph.cursor_pos_for_byte_offset(0), (0., 0.));
922
923    let e_offset = text
924        .char_indices()
925        .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
926        .unwrap();
927    assert_eq!(paragraph.cursor_pos_for_byte_offset(e_offset), (10., 0.));
928
929    let w_offset = text
930        .char_indices()
931        .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
932        .unwrap();
933    assert_eq!(paragraph.cursor_pos_for_byte_offset(w_offset + 1), (10., 10.));
934
935    assert_eq!(paragraph.cursor_pos_for_byte_offset(text.len()), (10. * 5., 10.));
936
937    let first_space_offset =
938        text.char_indices().find_map(|(offset, ch)| ch.is_whitespace().then_some(offset)).unwrap();
939    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset), (5. * 10., 0.));
940    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 15), (10. * 10., 0.));
941    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 16), (10. * 10., 0.));
942}
943
944#[test]
945fn test_cursor_position_with_newline() {
946    let font = FixedTestFont;
947    let text = "Hello\nWorld";
948
949    let paragraph = TextParagraphLayout {
950        string: text,
951        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
952        max_width: 100. * 10.,
953        max_height: 10.,
954        horizontal_alignment: TextHorizontalAlignment::Left,
955        vertical_alignment: TextVerticalAlignment::Top,
956        wrap: TextWrap::WordWrap,
957        overflow: TextOverflow::Clip,
958        single_line: false,
959        max_lines: None,
960    };
961
962    assert_eq!(paragraph.cursor_pos_for_byte_offset(5), (5. * 10., 0.));
963}
964
965#[test]
966fn byte_offset_for_empty_line() {
967    let font = FixedTestFont;
968    let text = "Hello\n\nWorld";
969
970    let paragraph = TextParagraphLayout {
971        string: text,
972        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
973        max_width: 100. * 10.,
974        max_height: 10.,
975        horizontal_alignment: TextHorizontalAlignment::Left,
976        vertical_alignment: TextVerticalAlignment::Top,
977        wrap: TextWrap::WordWrap,
978        overflow: TextOverflow::Clip,
979        single_line: false,
980        max_lines: None,
981    };
982
983    assert_eq!(paragraph.byte_offset_for_position((0., 10.)), 6);
984}
985
986#[test]
987fn test_byte_offset() {
988    let font = FixedTestFont;
989    let text = "Hello                    World";
990    let mut end_helper_text = std::string::String::from(text);
991    end_helper_text.push('!');
992
993    let paragraph = TextParagraphLayout {
994        string: text,
995        layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
996        max_width: 10. * 10.,
997        max_height: 10.,
998        horizontal_alignment: TextHorizontalAlignment::Left,
999        vertical_alignment: TextVerticalAlignment::Top,
1000        wrap: TextWrap::WordWrap,
1001        overflow: TextOverflow::Clip,
1002        single_line: false,
1003        max_lines: None,
1004    };
1005
1006    assert_eq!(paragraph.byte_offset_for_position((0., 0.)), 0);
1007
1008    let e_offset = text
1009        .char_indices()
1010        .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
1011        .unwrap();
1012
1013    assert_eq!(paragraph.byte_offset_for_position((14., 0.)), e_offset);
1014
1015    let l_offset = text
1016        .char_indices()
1017        .find_map(|(offset, ch)| if ch == 'l' { Some(offset) } else { None })
1018        .unwrap();
1019    assert_eq!(paragraph.byte_offset_for_position((15., 0.)), l_offset);
1020
1021    let w_offset = text
1022        .char_indices()
1023        .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
1024        .unwrap();
1025
1026    assert_eq!(paragraph.byte_offset_for_position((10., 10.)), w_offset + 1);
1027
1028    let o_offset = text
1029        .char_indices()
1030        .rev()
1031        .find_map(|(offset, ch)| if ch == 'o' { Some(offset) } else { None })
1032        .unwrap();
1033
1034    assert_eq!(paragraph.byte_offset_for_position((15., 10.)), o_offset + 1);
1035
1036    let d_offset = text
1037        .char_indices()
1038        .rev()
1039        .find_map(|(offset, ch)| if ch == 'd' { Some(offset) } else { None })
1040        .unwrap();
1041
1042    assert_eq!(paragraph.byte_offset_for_position((40., 10.)), d_offset);
1043
1044    let end_offset = end_helper_text
1045        .char_indices()
1046        .rev()
1047        .find_map(|(offset, ch)| if ch == '!' { Some(offset) } else { None })
1048        .unwrap();
1049
1050    assert_eq!(paragraph.byte_offset_for_position((45., 10.)), end_offset);
1051    assert_eq!(paragraph.byte_offset_for_position((0., 20.)), end_offset);
1052}
1053
1054#[test]
1055fn test_content_widths() {
1056    // FixedTestFont: every glyph is 10 pixels wide.
1057    let font = FixedTestFont;
1058    let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
1059    let min = |text| layout.content_widths(text, None).0;
1060
1061    // The longest word wins. The space after it is not part of its width,
1062    // otherwise this would be 80.
1063    assert_eq!(min("a bb longest cc"), 70.);
1064    // A single word: the whole string.
1065    assert_eq!(min("Hello"), 50.);
1066    // Equal words.
1067    assert_eq!(min("Hello World"), 50.);
1068    // Whitespace at the end of the string doesn't count either.
1069    assert_eq!(min("Hello World   "), 50.);
1070    // Mandatory breaks are break opportunities too.
1071    assert_eq!(min("short\nlongestword"), 110.);
1072    assert_eq!(min(""), 0.);
1073    assert_eq!(min("   "), 0.);
1074    // A no-break space is not a break opportunity, so both words are one chunk.
1075    assert_eq!(min("aa\u{00a0}bb cc"), 50.);
1076    // Without spaces there is nowhere to break, so the minimum is the whole string.
1077    assert_eq!(min("abcdefgh"), 80.);
1078
1079    // The max-content width is the width of the text on a single line.
1080    assert_eq!(layout.content_widths("a bb longest cc", None).1, 150.);
1081    assert_eq!(layout.content_widths("short\nlongestword", None).1, 110.);
1082}
1083
1084#[test]
1085fn test_content_widths_max_lines() {
1086    let font = FixedTestFont;
1087    let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
1088
1089    // Only the first line is drawn, so neither width may account for the second one.
1090    let (min, max) = layout.content_widths("short\nlongestword", Some(1));
1091    assert_eq!(max, 50.);
1092    assert_eq!(min, 50.);
1093
1094    // The minimum still comes from the words of the lines that are kept.
1095    let (min, max) = layout.content_widths("aa bb\nlongestword", Some(1));
1096    assert_eq!(max, 50.);
1097    assert_eq!(min, 20.);
1098
1099    // Without a limit both lines count again.
1100    let (min, max) = layout.content_widths("aa bb\nlongestword", None);
1101    assert_eq!(max, 110.);
1102    assert_eq!(min, 110.);
1103}
1104
1105#[test]
1106fn test_content_widths_min_never_exceeds_max() {
1107    let font = FixedTestFont;
1108    let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
1109    // A minimum above the preferred width makes preferred_bounded() clamp the preferred
1110    // width back up, silently widening the item.
1111    for text in ["", "   ", "Hello", "a bb longest cc", "short\nlongestword", "aa\u{00a0}bb cc"] {
1112        for max_lines in [None, Some(1), Some(2)] {
1113            let (min, max) = layout.content_widths(text, max_lines);
1114            assert!(min <= max, "min {min} > max {max} for {text:?} with {max_lines:?}");
1115        }
1116    }
1117}