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