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 sharedparley
5//! module for basic text layout
6//!
7//! The basic algorithm for breaking text into multiple lines:
8//! 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
9//!    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
10//!    example arabic text can be covered by a font that has excellent arabic coverage while latin text is rendered using a different font.
11//!    Shaping boundaries are always also grapheme boundaries.
12//! 2. Then we shape the text at shaping boundaries, to determine the metrics of glyphs and glyph clusters
13//! 3. Loop over all glyph clusters as well as the line break opportunities produced by the unicode line break algorithm:
14//!    Sum up the width of all glyph clusters until the next line break opportunity (encapsulated in FragmentIterator), record separately the width of
15//!    trailing space within the fragment.
16//!    ```text
17//!    If the width of the current line (including trailing whitespace) and the new fragment of glyph clusters (without trailing whitespace) is less or
18//!    equal to the available width:
19//!        Add fragment of glyph clusters to the current line
20//!    Else:
21//!        Emit current line as new line
22//!    If encountering a mandatory line break opportunity:
23//!        Emit current line as new line
24//!    ```
25
26use alloc::vec::Vec;
27
28use euclid::num::{One, Zero};
29
30use crate::items::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap};
31
32/// The font size to lay text out with when neither the `.slint` code nor the platform
33/// provide one. Last level of the precedence chain in
34/// [`crate::items::WindowItem::resolved_default_font_size`].
35pub const DEFAULT_FONT_SIZE: crate::lengths::LogicalLength =
36    crate::lengths::LogicalLength::new(12 as crate::Coord);
37
38#[cfg(feature = "unicode-linebreak")]
39mod linebreak_unicode;
40#[cfg(feature = "unicode-linebreak")]
41use linebreak_unicode::{BreakOpportunity, LineBreakIterator};
42
43#[cfg(not(feature = "unicode-linebreak"))]
44mod linebreak_simple;
45#[cfg(not(feature = "unicode-linebreak"))]
46use linebreak_simple::{BreakOpportunity, LineBreakIterator};
47
48mod fragments;
49mod glyphclusters;
50mod shaping;
51#[cfg(feature = "shared-parley")]
52/// cbindgen:ignore
53pub mod sharedparley;
54use shaping::ShapeBuffer;
55pub use shaping::{AbstractFont, FontMetrics, Glyph, TextShaper};
56
57mod linebreaker;
58pub use linebreaker::TextLine;
59
60pub use linebreaker::TextLineBreaker;
61
62pub struct TextLayout<'a, Font: AbstractFont> {
63    pub font: &'a Font,
64    pub letter_spacing: Option<<Font as TextShaper>::Length>,
65}
66
67impl<Font: AbstractFont> TextLayout<'_, Font> {
68    // Measures the size of the given text when rendered with the specified font and optionally constrained
69    // by the provided `max_width`.
70    // Returns a tuple of the width of the longest line as well as height of all lines.
71    pub fn text_size(
72        &self,
73        text: &str,
74        max_width: Option<Font::Length>,
75        text_wrap: TextWrap,
76    ) -> (Font::Length, Font::Length)
77    where
78        Font::Length: core::fmt::Debug,
79    {
80        let mut max_line_width = Font::Length::zero();
81        let mut line_count: i16 = 0;
82        let shape_buffer = ShapeBuffer::new(self, text);
83
84        for line in TextLineBreaker::<Font>::new(text, &shape_buffer, max_width, None, text_wrap) {
85            max_line_width = euclid::approxord::max(max_line_width, line.text_width);
86            line_count += 1;
87        }
88
89        (max_line_width, self.font.height() * line_count.into())
90    }
91}
92
93pub struct PositionedGlyph<Length> {
94    pub x: Length,
95    pub y: Length,
96    pub advance: Length,
97    pub glyph_id: core::num::NonZeroU16,
98    pub text_byte_offset: usize,
99}
100
101pub struct TextParagraphLayout<'a, Font: AbstractFont> {
102    pub string: &'a str,
103    pub layout: TextLayout<'a, Font>,
104    pub max_width: Font::Length,
105    pub max_height: Font::Length,
106    pub horizontal_alignment: TextHorizontalAlignment,
107    pub vertical_alignment: TextVerticalAlignment,
108    pub wrap: TextWrap,
109    pub overflow: TextOverflow,
110    pub single_line: bool,
111}
112
113impl<Font: AbstractFont> TextParagraphLayout<'_, Font> {
114    /// Layout the given string in lines, and call the `layout_line` callback with the line to draw at position y.
115    /// The signature of the `layout_line` function is: `(glyph_iterator, line_x, line_y, text_line, selection)`.
116    /// Returns the baseline y coordinate as Ok, or the break value if `line_callback` returns `core::ops::ControlFlow::Break`.
117    pub fn layout_lines<R>(
118        &self,
119        mut line_callback: impl FnMut(
120            &mut dyn Iterator<Item = PositionedGlyph<Font::Length>>,
121            Font::Length,
122            Font::Length,
123            &TextLine<Font::Length>,
124            Option<core::ops::Range<Font::Length>>,
125        ) -> core::ops::ControlFlow<R>,
126        selection: Option<core::ops::Range<usize>>,
127    ) -> Result<Font::Length, R> {
128        let wrap = self.wrap != TextWrap::NoWrap;
129        let elide = self.overflow == TextOverflow::Elide;
130        let elide_glyph = if elide {
131            self.layout.font.glyph_for_char('…').filter(|glyph| glyph.glyph_id.is_some())
132        } else {
133            None
134        };
135        let elide_width = elide_glyph.as_ref().map_or(Font::Length::zero(), |g| g.advance);
136        let max_width_without_elision = self.max_width - elide_width;
137
138        let shape_buffer = ShapeBuffer::new(&self.layout, self.string);
139
140        let new_line_break_iter = || {
141            TextLineBreaker::<Font>::new(
142                self.string,
143                &shape_buffer,
144                if wrap { Some(self.max_width) } else { None },
145                // Always keep at least the first line: when it is taller than the box, dropping it
146                // would render nothing at all, which is more confusing than a clipped line. The
147                // software renderer already clips glyphs to the Text geometry, so the vertical
148                // overflow is trimmed; horizontal elision still places an ellipsis if it is too
149                // wide. Mirrors the parley path, which always keeps line index 0.
150                if elide { Some(self.layout.font.max_lines(self.max_height).max(1)) } else { None },
151                self.wrap,
152            )
153        };
154        let mut text_lines = None;
155
156        let mut text_height = || {
157            if self.single_line {
158                self.layout.font.height()
159            } else {
160                text_lines = Some(new_line_break_iter().collect::<Vec<_>>());
161                self.layout.font.height() * (text_lines.as_ref().unwrap().len() as i16).into()
162            }
163        };
164
165        let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
166
167        let baseline_y = match self.vertical_alignment {
168            TextVerticalAlignment::Top => Font::Length::zero(),
169            TextVerticalAlignment::Center => self.max_height / two - text_height() / two,
170            TextVerticalAlignment::Bottom => self.max_height - text_height(),
171        };
172
173        let mut y = baseline_y;
174
175        let mut process_line = |line: &TextLine<Font::Length>, glyphs: &[Glyph<Font::Length>]| {
176            let elide_long_line =
177                elide && (self.single_line || !wrap) && line.text_width > self.max_width;
178            let elide_last_line = elide
179                && line.glyph_range.end < glyphs.len()
180                && y + self.layout.font.height() * two > self.max_height;
181
182            // On a vertically truncated line the ellipsis is anchored right after the text by
183            // ignoring trailing whitespace, so it reads "please…" rather than "please   …". The
184            // line breaker already excludes trailing whitespace from `byte_range`, so the glyphs
185            // past its end are exactly that whitespace to trim. This trimmed range is what gets
186            // measured (for alignment) and drawn; other lines keep the full range.
187            let glyph_range = if elide_last_line {
188                let trailing_whitespace_glyphs = glyphs[line.glyph_range.clone()]
189                    .iter()
190                    .rev()
191                    .take_while(|glyph| glyph.text_byte_offset >= line.byte_range.end)
192                    .count();
193                line.glyph_range.start..line.glyph_range.end - trailing_whitespace_glyphs
194            } else {
195                line.glyph_range.clone()
196            };
197
198            let text_width = || {
199                if elide_long_line || elide_last_line {
200                    let mut text_width = Font::Length::zero();
201                    for glyph in &glyphs[glyph_range.clone()] {
202                        if text_width + glyph.advance > max_width_without_elision {
203                            break;
204                        }
205                        text_width += glyph.advance;
206                    }
207                    return text_width + elide_width;
208                }
209                euclid::approxord::min(self.max_width, line.text_width)
210            };
211
212            let x = match self.horizontal_alignment {
213                TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => {
214                    Font::Length::zero()
215                }
216                TextHorizontalAlignment::Center => self.max_width / two - text_width() / two,
217                TextHorizontalAlignment::End | TextHorizontalAlignment::Right => {
218                    self.max_width - text_width()
219                }
220            };
221
222            let mut elide_glyph = elide_glyph.as_ref();
223
224            let selection = selection
225                .as_ref()
226                .filter(|selection| {
227                    line.byte_range.start < selection.end && selection.start < line.byte_range.end
228                })
229                .map(|selection| {
230                    let mut begin = Font::Length::zero();
231                    let mut end = Font::Length::zero();
232                    for glyph in glyphs[line.glyph_range.clone()].iter() {
233                        if glyph.text_byte_offset < selection.start {
234                            begin += glyph.advance;
235                        }
236                        if glyph.text_byte_offset >= selection.end {
237                            break;
238                        }
239                        end += glyph.advance;
240                    }
241                    begin..end
242                });
243
244            let glyph_it = glyphs[glyph_range.clone()].iter();
245            let mut glyph_x = Font::Length::zero();
246            // Up to two output glyphs per input glyph: the glyph itself, plus -- on the last glyph
247            // of a vertically truncated line that still fits the width -- an ellipsis appended
248            // after it (trailing whitespace was already trimmed from `glyph_range`).
249            let mut positioned_glyph_it = glyph_it.enumerate().flat_map(|(index, glyph)| {
250                let mut output: [Option<PositionedGlyph<Font::Length>>; 2] = [None, None];
251                // TODO: cut off at grapheme boundaries
252                if glyph_x > self.max_width {
253                    return output.into_iter().flatten();
254                }
255                // A line that is too wide gets the ellipsis placed *instead of* the first glyph
256                // that no longer fits (horizontal elision); the remaining glyphs are dropped.
257                let elide_long_line = (elide_long_line || elide_last_line)
258                    && x + glyph_x + glyph.advance > max_width_without_elision;
259                if elide_long_line {
260                    if let Some(elide_glyph) = elide_glyph.take() {
261                        let x = glyph_x;
262                        glyph_x += elide_glyph.advance;
263                        output[0] = Some(PositionedGlyph {
264                            x,
265                            y: Font::Length::zero(),
266                            advance: elide_glyph.advance,
267                            glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
268                            text_byte_offset: glyph.text_byte_offset,
269                        });
270                    }
271                    return output.into_iter().flatten();
272                }
273
274                let glyph_pos = glyph_x;
275                glyph_x += glyph.advance;
276                output[0] = glyph.glyph_id.map(|existing_glyph_id| PositionedGlyph {
277                    x: glyph_pos,
278                    y: Font::Length::zero(),
279                    advance: glyph.advance,
280                    glyph_id: existing_glyph_id,
281                    text_byte_offset: glyph.text_byte_offset,
282                });
283
284                // A line that only overflows vertically (it fits the width) keeps all its glyphs
285                // and gets the ellipsis *appended* after the last one rather than overwriting it,
286                // matching the parley path which renders e.g. "Line Two..." not "Line Tw...".
287                let last_glyph = glyph_range.start + index == glyph_range.end - 1;
288                if elide_last_line
289                    && last_glyph
290                    && let Some(elide_glyph) = elide_glyph.take()
291                {
292                    let x = glyph_x;
293                    glyph_x += elide_glyph.advance;
294                    output[1] = Some(PositionedGlyph {
295                        x,
296                        y: Font::Length::zero(),
297                        advance: elide_glyph.advance,
298                        glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
299                        text_byte_offset: glyph.text_byte_offset,
300                    });
301                }
302
303                output.into_iter().flatten()
304            });
305
306            if let core::ops::ControlFlow::Break(break_val) =
307                line_callback(&mut positioned_glyph_it, x, y, line, selection)
308            {
309                return core::ops::ControlFlow::Break(break_val);
310            }
311            y += self.layout.font.height();
312
313            core::ops::ControlFlow::Continue(())
314        };
315
316        if let Some(lines_vec) = text_lines.take() {
317            for line in lines_vec {
318                if let core::ops::ControlFlow::Break(break_val) =
319                    process_line(&line, &shape_buffer.glyphs)
320                {
321                    return Err(break_val);
322                }
323            }
324        } else {
325            for line in new_line_break_iter() {
326                if let core::ops::ControlFlow::Break(break_val) =
327                    process_line(&line, &shape_buffer.glyphs)
328                {
329                    return Err(break_val);
330                }
331            }
332        }
333
334        Ok(baseline_y)
335    }
336
337    /// Returns the leading edge of the glyph at the given byte offset
338    pub fn cursor_pos_for_byte_offset(&self, byte_offset: usize) -> (Font::Length, Font::Length) {
339        let mut last_glyph_right_edge = Font::Length::zero();
340        let mut last_line_y = Font::Length::zero();
341
342        match self.layout_lines(
343            |glyphs, line_x, line_y, line, _| {
344                last_glyph_right_edge = euclid::approxord::min(
345                    self.max_width,
346                    line_x + line.width_including_trailing_whitespace(),
347                );
348                last_line_y = line_y;
349                if byte_offset >= line.byte_range.end + line.trailing_whitespace_bytes {
350                    return core::ops::ControlFlow::Continue(());
351                }
352
353                for positioned_glyph in glyphs {
354                    if positioned_glyph.text_byte_offset == byte_offset {
355                        return core::ops::ControlFlow::Break((
356                            euclid::approxord::min(self.max_width, line_x + positioned_glyph.x),
357                            last_line_y,
358                        ));
359                    }
360                }
361
362                core::ops::ControlFlow::Break((last_glyph_right_edge, last_line_y))
363            },
364            None,
365        ) {
366            Ok(_) => (last_glyph_right_edge, last_line_y),
367            Err(position) => position,
368        }
369    }
370
371    /// Returns the bytes offset for the given position
372    pub fn byte_offset_for_position(&self, (pos_x, pos_y): (Font::Length, Font::Length)) -> usize {
373        let mut byte_offset = 0;
374        let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
375
376        match self.layout_lines(
377            |glyphs, line_x, line_y, line, _| {
378                if pos_y >= line_y + self.layout.font.height() {
379                    byte_offset = line.byte_range.end;
380                    return core::ops::ControlFlow::Continue(());
381                }
382
383                if line.is_empty() {
384                    return core::ops::ControlFlow::Break(line.byte_range.start);
385                }
386
387                while let Some(positioned_glyph) = glyphs.next() {
388                    if pos_x >= line_x + positioned_glyph.x
389                        && pos_x <= line_x + positioned_glyph.x + positioned_glyph.advance
390                    {
391                        if pos_x < line_x + positioned_glyph.x + positioned_glyph.advance / two {
392                            return core::ops::ControlFlow::Break(
393                                positioned_glyph.text_byte_offset,
394                            );
395                        } else if let Some(next_glyph) = glyphs.next() {
396                            return core::ops::ControlFlow::Break(next_glyph.text_byte_offset);
397                        }
398                    }
399                }
400
401                core::ops::ControlFlow::Break(line.byte_range.end)
402            },
403            None,
404        ) {
405            Ok(_) => byte_offset,
406            Err(position) => position,
407        }
408    }
409}
410
411#[test]
412fn test_no_linebreak_opportunity_at_eot() {
413    let mut it = LineBreakIterator::new("Hello World");
414    assert_eq!(it.next(), Some((6, BreakOpportunity::Allowed)));
415    assert_eq!(it.next(), None);
416}
417
418// All glyphs are 10 pixels wide, break on ascii rules
419#[cfg(test)]
420pub struct FixedTestFont;
421
422#[cfg(test)]
423impl TextShaper for FixedTestFont {
424    type LengthPrimitive = f32;
425    type Length = f32;
426    fn shape_text<GlyphStorage: std::iter::Extend<Glyph<f32>>>(
427        &self,
428        text: &str,
429        glyphs: &mut GlyphStorage,
430    ) {
431        let glyph_iter = text.char_indices().map(|(byte_offset, char)| {
432            let mut utf16_buf = [0; 2];
433            let utf16_char_as_glyph_id = char.encode_utf16(&mut utf16_buf)[0];
434
435            Glyph {
436                offset_x: 0.,
437                offset_y: 0.,
438                glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
439                advance: 10.,
440                text_byte_offset: byte_offset,
441            }
442        });
443        glyphs.extend(glyph_iter);
444    }
445
446    fn glyph_for_char(&self, ch: char) -> Option<Glyph<f32>> {
447        let mut utf16_buf = [0; 2];
448        let utf16_char_as_glyph_id = ch.encode_utf16(&mut utf16_buf)[0];
449
450        Glyph {
451            offset_x: 0.,
452            offset_y: 0.,
453            glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
454            advance: 10.,
455            text_byte_offset: 0,
456        }
457        .into()
458    }
459
460    fn max_lines(&self, max_height: f32) -> usize {
461        let height = self.ascent() - self.descent();
462        (max_height / height).floor() as _
463    }
464}
465
466#[cfg(test)]
467impl FontMetrics<f32> for FixedTestFont {
468    fn ascent(&self) -> f32 {
469        5.
470    }
471
472    fn descent(&self) -> f32 {
473        -5.
474    }
475
476    fn x_height(&self) -> f32 {
477        3.
478    }
479
480    fn cap_height(&self) -> f32 {
481        4.
482    }
483}
484
485#[test]
486fn test_elision() {
487    let font = FixedTestFont;
488    let text = "This is a longer piece of text";
489
490    let mut lines = Vec::new();
491
492    let paragraph = TextParagraphLayout {
493        string: text,
494        layout: TextLayout { font: &font, letter_spacing: None },
495        max_width: 13. * 10.,
496        max_height: 10.,
497        horizontal_alignment: TextHorizontalAlignment::Left,
498        vertical_alignment: TextVerticalAlignment::Top,
499        wrap: TextWrap::NoWrap,
500        overflow: TextOverflow::Elide,
501        single_line: true,
502    };
503    paragraph
504        .layout_lines::<()>(
505            |glyphs, _, _, _, _| {
506                lines.push(
507                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
508                );
509                core::ops::ControlFlow::Continue(())
510            },
511            None,
512        )
513        .unwrap();
514
515    assert_eq!(lines.len(), 1);
516    let rendered_text = lines[0]
517        .iter()
518        .flat_map(|glyph_id| {
519            core::char::decode_utf16(core::iter::once(glyph_id.get()))
520                .map(|r| r.unwrap())
521                .collect::<Vec<char>>()
522        })
523        .collect::<std::string::String>();
524    debug_assert_eq!(rendered_text, "This is a lo…")
525}
526
527#[test]
528fn test_elision_vertical_truncation() {
529    // A line that only overflows vertically (more lines below it were dropped for the height) but
530    // fits the width keeps all its glyphs and gets the ellipsis appended -- "AB…", not "A…". The
531    // box is one line tall, so the second line ("CD") is dropped.
532    let font = FixedTestFont;
533    let text = "AB\nCD";
534
535    let mut lines = Vec::new();
536
537    let paragraph = TextParagraphLayout {
538        string: text,
539        layout: TextLayout { font: &font, letter_spacing: None },
540        max_width: 4. * 10., // room for "AB…" (3 glyphs) and more
541        max_height: 10.,     // one line tall
542        horizontal_alignment: TextHorizontalAlignment::Left,
543        vertical_alignment: TextVerticalAlignment::Top,
544        wrap: TextWrap::NoWrap,
545        overflow: TextOverflow::Elide,
546        single_line: false,
547    };
548    paragraph
549        .layout_lines::<()>(
550            |glyphs, _, _, _, _| {
551                lines.push(
552                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
553                );
554                core::ops::ControlFlow::Continue(())
555            },
556            None,
557        )
558        .unwrap();
559
560    // Only the first line is drawn (the box is one line tall).
561    assert_eq!(lines.len(), 1);
562    let rendered_text = lines[0]
563        .iter()
564        .flat_map(|glyph_id| {
565            core::char::decode_utf16(core::iter::once(glyph_id.get()))
566                .map(|r| r.unwrap())
567                .collect::<Vec<char>>()
568        })
569        .collect::<std::string::String>();
570    assert_eq!(rendered_text, "AB…");
571}
572
573#[test]
574fn test_exact_fit() {
575    let font = FixedTestFont;
576    let text = "Fits";
577
578    let mut lines = Vec::new();
579
580    let paragraph = TextParagraphLayout {
581        string: text,
582        layout: TextLayout { font: &font, letter_spacing: None },
583        max_width: 4. * 10.,
584        max_height: 10.,
585        horizontal_alignment: TextHorizontalAlignment::Left,
586        vertical_alignment: TextVerticalAlignment::Top,
587        wrap: TextWrap::NoWrap,
588        overflow: TextOverflow::Elide,
589        single_line: true,
590    };
591    paragraph
592        .layout_lines::<()>(
593            |glyphs, _, _, _, _| {
594                lines.push(
595                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
596                );
597                core::ops::ControlFlow::Continue(())
598            },
599            None,
600        )
601        .unwrap();
602
603    assert_eq!(lines.len(), 1);
604    let rendered_text = lines[0]
605        .iter()
606        .flat_map(|glyph_id| {
607            core::char::decode_utf16(core::iter::once(glyph_id.get()))
608                .map(|r| r.unwrap())
609                .collect::<Vec<char>>()
610        })
611        .collect::<std::string::String>();
612    debug_assert_eq!(rendered_text, "Fits")
613}
614
615#[test]
616fn test_no_line_separators_characters_rendered() {
617    let font = FixedTestFont;
618    let text = "Hello\nWorld\n";
619
620    let mut lines = Vec::new();
621
622    let paragraph = TextParagraphLayout {
623        string: text,
624        layout: TextLayout { font: &font, letter_spacing: None },
625        max_width: 13. * 10.,
626        max_height: 10.,
627        horizontal_alignment: TextHorizontalAlignment::Left,
628        vertical_alignment: TextVerticalAlignment::Top,
629        wrap: TextWrap::NoWrap,
630        overflow: TextOverflow::Clip,
631        single_line: true,
632    };
633    paragraph
634        .layout_lines::<()>(
635            |glyphs, _, _, _, _| {
636                lines.push(
637                    glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
638                );
639                core::ops::ControlFlow::Continue(())
640            },
641            None,
642        )
643        .unwrap();
644
645    assert_eq!(lines.len(), 2);
646    let rendered_text = lines
647        .iter()
648        .map(|glyphs_per_line| {
649            glyphs_per_line
650                .iter()
651                .flat_map(|glyph_id| {
652                    core::char::decode_utf16(core::iter::once(glyph_id.get()))
653                        .map(|r| r.unwrap())
654                        .collect::<Vec<char>>()
655                })
656                .collect::<std::string::String>()
657        })
658        .collect::<Vec<_>>();
659    debug_assert_eq!(rendered_text, std::vec!["Hello", "World"]);
660}
661
662#[test]
663fn test_cursor_position() {
664    let font = FixedTestFont;
665    let text = "Hello                    World";
666
667    let paragraph = TextParagraphLayout {
668        string: text,
669        layout: TextLayout { font: &font, letter_spacing: None },
670        max_width: 10. * 10.,
671        max_height: 10.,
672        horizontal_alignment: TextHorizontalAlignment::Left,
673        vertical_alignment: TextVerticalAlignment::Top,
674        wrap: TextWrap::WordWrap,
675        overflow: TextOverflow::Clip,
676        single_line: false,
677    };
678
679    assert_eq!(paragraph.cursor_pos_for_byte_offset(0), (0., 0.));
680
681    let e_offset = text
682        .char_indices()
683        .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
684        .unwrap();
685    assert_eq!(paragraph.cursor_pos_for_byte_offset(e_offset), (10., 0.));
686
687    let w_offset = text
688        .char_indices()
689        .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
690        .unwrap();
691    assert_eq!(paragraph.cursor_pos_for_byte_offset(w_offset + 1), (10., 10.));
692
693    assert_eq!(paragraph.cursor_pos_for_byte_offset(text.len()), (10. * 5., 10.));
694
695    let first_space_offset =
696        text.char_indices().find_map(|(offset, ch)| ch.is_whitespace().then_some(offset)).unwrap();
697    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset), (5. * 10., 0.));
698    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 15), (10. * 10., 0.));
699    assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 16), (10. * 10., 0.));
700}
701
702#[test]
703fn test_cursor_position_with_newline() {
704    let font = FixedTestFont;
705    let text = "Hello\nWorld";
706
707    let paragraph = TextParagraphLayout {
708        string: text,
709        layout: TextLayout { font: &font, letter_spacing: None },
710        max_width: 100. * 10.,
711        max_height: 10.,
712        horizontal_alignment: TextHorizontalAlignment::Left,
713        vertical_alignment: TextVerticalAlignment::Top,
714        wrap: TextWrap::WordWrap,
715        overflow: TextOverflow::Clip,
716        single_line: false,
717    };
718
719    assert_eq!(paragraph.cursor_pos_for_byte_offset(5), (5. * 10., 0.));
720}
721
722#[test]
723fn byte_offset_for_empty_line() {
724    let font = FixedTestFont;
725    let text = "Hello\n\nWorld";
726
727    let paragraph = TextParagraphLayout {
728        string: text,
729        layout: TextLayout { font: &font, letter_spacing: None },
730        max_width: 100. * 10.,
731        max_height: 10.,
732        horizontal_alignment: TextHorizontalAlignment::Left,
733        vertical_alignment: TextVerticalAlignment::Top,
734        wrap: TextWrap::WordWrap,
735        overflow: TextOverflow::Clip,
736        single_line: false,
737    };
738
739    assert_eq!(paragraph.byte_offset_for_position((0., 10.)), 6);
740}
741
742#[test]
743fn test_byte_offset() {
744    let font = FixedTestFont;
745    let text = "Hello                    World";
746    let mut end_helper_text = std::string::String::from(text);
747    end_helper_text.push('!');
748
749    let paragraph = TextParagraphLayout {
750        string: text,
751        layout: TextLayout { font: &font, letter_spacing: None },
752        max_width: 10. * 10.,
753        max_height: 10.,
754        horizontal_alignment: TextHorizontalAlignment::Left,
755        vertical_alignment: TextVerticalAlignment::Top,
756        wrap: TextWrap::WordWrap,
757        overflow: TextOverflow::Clip,
758        single_line: false,
759    };
760
761    assert_eq!(paragraph.byte_offset_for_position((0., 0.)), 0);
762
763    let e_offset = text
764        .char_indices()
765        .find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
766        .unwrap();
767
768    assert_eq!(paragraph.byte_offset_for_position((14., 0.)), e_offset);
769
770    let l_offset = text
771        .char_indices()
772        .find_map(|(offset, ch)| if ch == 'l' { Some(offset) } else { None })
773        .unwrap();
774    assert_eq!(paragraph.byte_offset_for_position((15., 0.)), l_offset);
775
776    let w_offset = text
777        .char_indices()
778        .find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
779        .unwrap();
780
781    assert_eq!(paragraph.byte_offset_for_position((10., 10.)), w_offset + 1);
782
783    let o_offset = text
784        .char_indices()
785        .rev()
786        .find_map(|(offset, ch)| if ch == 'o' { Some(offset) } else { None })
787        .unwrap();
788
789    assert_eq!(paragraph.byte_offset_for_position((15., 10.)), o_offset + 1);
790
791    let d_offset = text
792        .char_indices()
793        .rev()
794        .find_map(|(offset, ch)| if ch == 'd' { Some(offset) } else { None })
795        .unwrap();
796
797    assert_eq!(paragraph.byte_offset_for_position((40., 10.)), d_offset);
798
799    let end_offset = end_helper_text
800        .char_indices()
801        .rev()
802        .find_map(|(offset, ch)| if ch == '!' { Some(offset) } else { None })
803        .unwrap();
804
805    assert_eq!(paragraph.byte_offset_for_position((45., 10.)), end_offset);
806    assert_eq!(paragraph.byte_offset_for_position((0., 20.)), end_offset);
807}