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}
702