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 | |
24 | use alloc::vec::Vec; |
25 | |
26 | use euclid::num::{One, Zero}; |
27 | |
28 | use crate::items::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap}; |
29 | |
30 | #[cfg (feature = "unicode-linebreak" )] |
31 | mod linebreak_unicode; |
32 | #[cfg (feature = "unicode-linebreak" )] |
33 | use linebreak_unicode::{BreakOpportunity, LineBreakIterator}; |
34 | |
35 | #[cfg (not(feature = "unicode-linebreak" ))] |
36 | mod linebreak_simple; |
37 | #[cfg (not(feature = "unicode-linebreak" ))] |
38 | use linebreak_simple::{BreakOpportunity, LineBreakIterator}; |
39 | |
40 | mod fragments; |
41 | mod glyphclusters; |
42 | mod shaping; |
43 | use shaping::ShapeBuffer; |
44 | pub use shaping::{AbstractFont, FontMetrics, Glyph, TextShaper}; |
45 | |
46 | mod linebreaker; |
47 | pub use linebreaker::TextLine; |
48 | |
49 | pub use linebreaker::TextLineBreaker; |
50 | |
51 | pub struct TextLayout<'a, Font: AbstractFont> { |
52 | pub font: &'a Font, |
53 | pub letter_spacing: Option<<Font as TextShaper>::Length>, |
54 | } |
55 | |
56 | impl<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 | |
82 | pub 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 | |
90 | pub 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 | |
102 | impl<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 ] |
352 | fn 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)] |
360 | pub struct FixedTestFont; |
361 | |
362 | #[cfg (test)] |
363 | impl 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)] |
407 | impl 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 ] |
426 | fn 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 ] |
468 | fn 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 ] |
510 | fn 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 ] |
557 | fn 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 ] |
597 | fn 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 ] |
617 | fn 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 ] |
637 | fn 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 | |