Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/platform/mac/text_layout_test.rs
StratoSDK / crates / strato-ui-renderer / src / platform / mac / text_layout_test.rs
1use super::*;
2use crate::fonts::Properties;
3 
4use crate::fonts::{collect_glyph_indices, collect_line_caret_position_starts, init_fonts};
5use crate::platform::FontDB as _;
6use crate::text_layout::DEFAULT_TOP_BOTTOM_RATIO;
7 
8use anyhow::Result;
9use rand::random;
10 
11pub(crate) fn collect_line_caret_position_pairs(line: &Line) -> Vec<(usize, usize)> {
12 line.caret_positions
13 .iter()
14 .map(|pos| (pos.start_offset, pos.last_offset))
15 .collect_vec()
16}
17 
18#[test]
19fn test_char_indices_ligatures() -> Result<()> {
20 let mut font_db = FontDB::new();
21 let zapfino = font_db.load_from_system("Zapfino")?;
22 let menlo = font_db.load_from_system("Menlo")?;
23 
24 let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
25 let line = layout_line(
26 text,
27 LineStyle {
28 font_size: 16.0,
29 line_height_ratio: 1.2,
30 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
31 fixed_width_tab_size: None,
32 },
33 &[
34 (
35 0..9,
36 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
37 ),
38 (
39 9..22,
40 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
41 ),
42 (
43 22..text.encode_utf16().count(),
44 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
45 ),
46 ],
47 &font_db,
48 ClipConfig::default(),
49 );
50 
51 // It's easiest to understand what's happening here by visualizing the text and seeing which
52 // characters get combined to become a single glyph. At a high level, what this is testing
53 // is that after laying out the string, we see some characters get combined into a single
54 // glyph. For example, the text "Zapfino" gets combined into a single glyph, which is why
55 // there is a jump from 23 to 30 in the list of glyph indices below.
56 // See https://docs.google.com/drawings/d/18qOKhzA5rWaMuxKVeWFDXh7ebrDjxongarAckkm0qnE/edit
57 // for a full diagram of what's happening here.
58 assert_eq!(
59 line.runs
60 .iter()
61 .flat_map(|r| r.glyphs.iter())
62 .map(|g| g.index)
63 .collect::<Vec<_>>(),
64 vec![0, 2, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 30, 31]
65 );
66 Ok(())
67}
68 
69#[test]
70fn test_caret_positions_ligatures() -> Result<()> {
71 // There's some overlap between caret positions and the character indices we
72 // store in glyphs. However, a single glyph may have multiple caret positions
73 // because characters/graphemes may get combined into a single glyph.
74 
75 let mut font_db = FontDB::new();
76 let zapfino = font_db.load_from_system("Zapfino")?;
77 let menlo = font_db.load_from_system("Menlo")?;
78 
79 // This string has 32 characters, but 35 UTF-16 code points and 41 UTF-8 code points.
80 // Each '𐍈' character encodes as 2 UTF-16 code points or 4 UTF-8 code points.
81 let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
82 
83 let line = layout_line(
84 text,
85 LineStyle {
86 font_size: 16.0,
87 line_height_ratio: 1.2,
88 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
89 fixed_width_tab_size: None,
90 },
91 &[
92 (
93 0..9,
94 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
95 ),
96 (
97 9..22,
98 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
99 ),
100 (
101 22..35,
102 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
103 ),
104 ],
105 &font_db,
106 ClipConfig::default(),
107 );
108 
109 // There are only 23 glyphs because 'Zapfino', 'Th', and 'is' each have ligatures.
110 assert_eq!(
111 line.runs.iter().map(|run| run.glyphs.len()).sum::<usize>(),
112 23
113 );
114 
115 // There should be a caret position for each character.
116 assert_eq!(
117 line.caret_positions
118 .iter()
119 .map(|pos| pos.start_offset)
120 .collect::<Vec<_>>(),
121 (0..32).collect::<Vec<usize>>()
122 );
123 
124 // There is a caret for the 3rd character at the 3rd position, even though
125 // the first 2 characters are represented with 1 glyph.
126 assert_eq!(
127 line.caret_position_for_index(3),
128 line.caret_positions[3].position_in_line
129 );
130 
131 // Likewise for the second 𐍈, even though it (and the previous one) take
132 // multiple code points.
133 assert_eq!(
134 line.caret_position_for_index(15),
135 line.caret_positions[15].position_in_line
136 );
137 
138 // This tests hit-testing on a regular character.
139 assert_eq!(line.caret_index_for_x(0.), Some(0));
140 assert_eq!(line.caret_index_for_x(20.), Some(1));
141 
142 // This tests hit-testing within a ligature.
143 assert_eq!(line.caret_index_for_x(260.), Some(25));
144 
145 // This tests rounding up to the next character.
146 assert_eq!(line.caret_index_for_x(268.), Some(26));
147 
148 // This tests a few random positions within the bound and before the last character.
149 let last_caret_pos = line
150 .caret_positions
151 .last()
152 .map_or(0., |p| p.position_in_line);
153 // The bounded and unbounded method should return the same result.
154 for _ in 0..5 {
155 let pos: f32 = random();
156 let index = line.caret_index_for_x(pos * last_caret_pos);
157 assert_eq!(
158 index,
159 Some(line.caret_index_for_x_unbounded(pos * last_caret_pos))
160 );
161 }
162 
163 // This tests that the unbounded method returns the first index for out-of-bound position to the left
164 assert_eq!(line.caret_index_for_x_unbounded(-1.), line.first_index());
165 // The bounded method should return `None`
166 assert_eq!(line.caret_index_for_x(-1.), None);
167 
168 // This tests that the unbounded method returns the end index for out-of-bound position to the right
169 assert_eq!(
170 line.caret_index_for_x_unbounded(line.width + 0.1),
171 line.end_index()
172 );
173 assert_eq!(line.caret_index_for_x(line.width + 0.1), None);
174 
175 // This tests that the unbounded method returns the correct index either before or after the last glyph
176 assert_eq!(
177 line.caret_index_for_x_unbounded(0.9 * last_caret_pos + 0.1 * line.width),
178 line.last_index()
179 );
180 assert_eq!(
181 line.caret_index_for_x_unbounded(0.1 * last_caret_pos + 0.9 * line.width),
182 line.end_index()
183 );
184 // The bounded method should always just return the last index
185 assert_eq!(
186 line.caret_index_for_x(0.9 * last_caret_pos + 0.1 * line.width),
187 Some(line.last_index())
188 );
189 assert_eq!(
190 line.caret_index_for_x(0.1 * last_caret_pos + 0.9 * line.width),
191 Some(line.last_index())
192 );
193 
194 Ok(())
195}
196 
197/// The emojis in this test use font fallback, which means it won't behave
198/// consistently across platforms.
199#[test]
200fn test_emoji_caret_positions() -> Result<()> {
201 let (font_db, font_family) = init_fonts();
202 
203 // We're using these emoji specifically because they're represented as multiple
204 // combined characters.
205 let text = "👨‍👧‍👧🇨🇦";
206 
207 let line = font_db.text_layout_system().layout_line(
208 text,
209 LineStyle {
210 font_size: 16.0,
211 line_height_ratio: 1.2,
212 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
213 fixed_width_tab_size: None,
214 },
215 &[(
216 0..12,
217 StyleAndFont::new(font_family, Properties::default(), TextStyle::new()),
218 )],
219 10000.0,
220 ClipConfig::default(),
221 );
222 
223 // We want the leading edge for caret positions, so the first one is at the
224 // start of the line.
225 assert_eq!(line.caret_positions[0].position_in_line, 0.0);
226 
227 assert_eq!(
228 collect_line_caret_position_starts(&line),
229 // CoreText gives us one caret position per visible character.
230 // Each emoji is multiple characters, but one grapheme and therefore one
231 // caret position.
232 vec![0, 5]
233 );
234 
235 // The first character is within the first emoji, so its caret position is
236 // at the start of the line.
237 assert_eq!(line.caret_position_for_index(0), 0.0);
238 // Likewise, the start of the next emoji returns its start position.
239 assert_eq!(
240 line.caret_position_for_index(5),
241 line.caret_positions[1].position_in_line
242 );
243 // Subsequent positions within the emoji also resolve to its starting offset.
244 assert_eq!(
245 line.caret_position_for_index(6),
246 line.caret_positions[1].position_in_line
247 );
248 // Past the end of the last emoji, we clamp to the end of the line.
249 assert_eq!(line.caret_position_for_index(7), line.width);
250 
251 Ok(())
252}
253 
254/// The RTL text and emoji in this test use font fallback, which means
255/// this test won't behave consistently across platforms.
256#[test]
257fn test_bidi_caret_positions() -> Result<()> {
258 let (font_db, font_family) = init_fonts();
259 
260 let text = "a שָׁלוֹם 🇨🇦 test";
261 let line = font_db.text_layout_system().layout_line(
262 text,
263 LineStyle {
264 font_size: 16.0,
265 line_height_ratio: 1.2,
266 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
267 fixed_width_tab_size: None,
268 },
269 &[(
270 0..text.encode_utf16().count(),
271 StyleAndFont::new(font_family, Properties::default(), TextStyle::new()),
272 )],
273 10000.0,
274 ClipConfig::default(),
275 );
276 
277 // Caret positions should account for diacritics in the Hebrew text, as well
278 // as the 🇨🇦 emoji consisting of multiple characters. In addition, they should
279 // be sorted by display order.
280 assert_eq!(
281 collect_line_caret_position_pairs(&line),
282 vec![
283 // "a "
284 (0, 0),
285 (1, 1),
286 // שָׁלוֹם
287 (8, 8),
288 (6, 7),
289 (5, 5),
290 (2, 4),
291 // " "
292 (9, 9),
293 // 🇨🇦
294 (10, 11),
295 // " test"
296 (12, 12),
297 (13, 13),
298 (14, 14),
299 (15, 15),
300 (16, 16)
301 ]
302 );
303 
304 Ok(())
305}
306 
307#[test]
308fn test_layout_text_ligatures() -> Result<()> {
309 let mut font_db = FontDB::new();
310 let zapfino = font_db.load_from_system("Zapfino")?;
311 let menlo = font_db.load_from_system("Menlo")?;
312 
313 let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
314 let frame = layout_text(
315 text,
316 LineStyle {
317 font_size: 16.0,
318 line_height_ratio: 1.2,
319 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
320 fixed_width_tab_size: None,
321 },
322 &[
323 (
324 0..9,
325 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
326 ),
327 (
328 9..22,
329 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
330 ),
331 (
332 22..text.encode_utf16().count(),
333 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
334 ),
335 ],
336 &font_db,
337 125., /* max_width */
338 f32::MAX, /* max_height */
339 Default::default(),
340 None,
341 );
342 
343 // The text should contain multiple lines since it can't fit in 125 pixels on the first
344 // line.
345 assert_eq!(frame.lines().len(), 4);
346 
347 // The text should be wrapped over 4 lines and look like this:
348 // "This is
349 // m𐍈re or
350 // less,
351 // Zapfino!𐍈"
352 assert_eq!(
353 collect_glyph_indices(&frame),
354 vec![
355 vec![0, 2, 4, 5, 7, 8],
356 vec![9, 10, 11, 12, 13, 14, 15, 16],
357 vec![17, 18, 19, 20, 21, 22,],
358 vec![23, 30, 31]
359 ]
360 );
361 
362 Ok(())
363}
364 
365#[test]
366fn test_layout_text_first_line_head_indent_ligatures() -> Result<()> {
367 // Similar test to above, except we add in a left head indent (with reduced max width)!
368 let mut font_db = FontDB::new();
369 let zapfino = font_db.load_from_system("Zapfino")?;
370 let menlo = font_db.load_from_system("Menlo")?;
371 
372 let text = "This is, m𐍈re 𐍈r less, Zapfino!𐍈";
373 let frame = layout_text(
374 text,
375 LineStyle {
376 font_size: 16.0,
377 line_height_ratio: 1.2,
378 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
379 fixed_width_tab_size: None,
380 },
381 &[
382 (
383 0..9,
384 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
385 ),
386 (
387 9..22,
388 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
389 ),
390 (
391 22..text.encode_utf16().count(),
392 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
393 ),
394 ],
395 &font_db,
396 80., /* max_width */
397 f32::MAX, /* max_height */
398 Default::default(),
399 Some(50.), /* first_line_head_indent */
400 );
401 
402 // The text should contain multiple lines since we have a 50px left head indent on the first
403 // line and then each line only has 80px.
404 assert_eq!(frame.lines().len(), 6);
405 
406 assert_eq!(
407 collect_glyph_indices(&frame),
408 vec![
409 vec![0], // left head indent means we don't have much content laid out on this line.
410 vec![1, 2, 4, 5, 7, 8],
411 vec![9, 10, 11, 12, 13, 14, 15, 16],
412 vec![17, 18, 19, 20, 21, 22],
413 vec![23, 24, 25, 27, 28],
414 vec![29, 30, 31],
415 ]
416 );
417 
418 Ok(())
419}
420 
421#[test]
422fn test_tab_stops_affect_line_width() -> Result<()> {
423 let mut font_db = FontDB::new();
424 let menlo = font_db.load_from_system("Menlo")?;
425 
426 let font_size = 13.0;
427 let tab_size = 4;
428 
429 let font_id = font_db.select_font(menlo, Properties::default());
430 let tab_interval = (font_db
431 .space_advance_width(font_id, font_size)
432 .expect("space width should be measurable")
433 * tab_size as f64) as f32;
434 
435 let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new());
436 let strings = "strings";
437 let tabbed = "\t\t\tstrings";
438 
439 let strings_line = layout_line(
440 strings,
441 LineStyle {
442 font_size,
443 line_height_ratio: 1.2,
444 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
445 fixed_width_tab_size: None,
446 },
447 &[(0..strings.chars().count(), style)],
448 &font_db,
449 ClipConfig::default(),
450 );
451 
452 let tabbed_line = layout_line(
453 tabbed,
454 LineStyle {
455 font_size,
456 line_height_ratio: 1.2,
457 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
458 fixed_width_tab_size: Some(tab_size),
459 },
460 &[(0..tabbed.chars().count(), style)],
461 &font_db,
462 ClipConfig::default(),
463 );
464 
465 // Each tab advances to the next stop.
466 let expected_width = (tab_interval * 3.0) + strings_line.width;
467 let error = (tabbed_line.width - expected_width).abs();
468 assert!(
469 error < 1.0,
470 "expected tabbed width ~{}, got {} (error {})",
471 expected_width,
472 tabbed_line.width,
473 error
474 );
475 
476 Ok(())
477}
478 
479#[test]
480fn test_tab_stops_do_not_drift_over_long_runs() -> Result<()> {
481 let mut font_db = FontDB::new();
482 let menlo = font_db.load_from_system("Menlo")?;
483 
484 let font_size = 13.0;
485 let tab_size = 4;
486 
487 let font_id = font_db.select_font(menlo, Properties::default());
488 let tab_interval = (font_db
489 .space_advance_width(font_id, font_size)
490 .expect("space width should be measurable")
491 * tab_size as f64) as f32;
492 
493 let style = StyleAndFont::new(menlo, Properties::default(), TextStyle::new());
494 let strings = "strings";
495 
496 let strings_line = layout_line(
497 strings,
498 LineStyle {
499 font_size,
500 line_height_ratio: 1.2,
501 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
502 fixed_width_tab_size: None,
503 },
504 &[(0..strings.chars().count(), style)],
505 &font_db,
506 ClipConfig::default(),
507 );
508 
509 let tab_count = 100;
510 let tabbed = format!("{}{}", "\t".repeat(tab_count), strings);
511 
512 let tabbed_line = layout_line(
513 &tabbed,
514 LineStyle {
515 font_size,
516 line_height_ratio: 1.2,
517 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
518 fixed_width_tab_size: Some(tab_size),
519 },
520 &[(0..tabbed.chars().count(), style)],
521 &font_db,
522 ClipConfig::default(),
523 );
524 
525 let expected_width = (tab_interval * tab_count as f32) + strings_line.width;
526 let error = (tabbed_line.width - expected_width).abs();
527 assert!(
528 error < 1.0,
529 "expected tabbed width ~{}, got {} (error {})",
530 expected_width,
531 tabbed_line.width,
532 error
533 );
534 
535 Ok(())
536}
537 
538#[test]
539fn test_layout_text_large_first_line_head_indent_ligatures() -> Result<()> {
540 // Similar test to above, except we have a large first line head indent which goes beyond the
541 // max_width of the first line! We expect an empty line at the start to account for this (post-layout).
542 let mut font_db = FontDB::new();
543 let zapfino = font_db.load_from_system("Zapfino")?;
544 let menlo = font_db.load_from_system("Menlo")?;
545 
546 let text = "This is, some text, in Zapfino being laid out!";
547 let frame = layout_text(
548 text,
549 LineStyle {
550 font_size: 16.0,
551 line_height_ratio: 1.2,
552 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
553 fixed_width_tab_size: None,
554 },
555 &[
556 (
557 0..9,
558 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
559 ),
560 (
561 9..22,
562 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
563 ),
564 (
565 22..text.encode_utf16().count(),
566 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
567 ),
568 ],
569 &font_db,
570 80., /* max_width */
571 f32::MAX, /* max_height */
572 Default::default(),
573 Some(80.), /* first_line_head_indent */
574 );
575 
576 // We expect 1 empty line at the start and then 7 lines of content.
577 assert_eq!(frame.lines().len(), 8);
578 
579 assert_eq!(
580 collect_glyph_indices(&frame),
581 vec![
582 vec![], // first line head indent takes up entire line!
583 vec![0, 2, 4],
584 vec![5, 7, 8, 9, 10, 11, 12, 13],
585 vec![14, 15, 16, 17, 18, 19, 20, 21, 22],
586 vec![23, 24, 25, 27, 28],
587 vec![29, 30, 31, 32, 33, 34, 35, 36],
588 vec![37, 38, 39, 40, 41],
589 vec![42, 44, 45],
590 ]
591 );
592 
593 Ok(())
594}
595 
596#[test]
597fn test_layout_text_last_line_clipped_ligatures() -> Result<()> {
598 let mut font_db = FontDB::new();
599 let zapfino = font_db.load_from_system("Zapfino")?;
600 let menlo = font_db.load_from_system("Menlo")?;
601 
602 let text = "m𐍈re, Zapfino!ll𐍈, qqqq";
603 let max_width = 180.;
604 
605 let frame = layout_text(
606 text,
607 LineStyle {
608 font_size: 16.0,
609 line_height_ratio: 1.2,
610 baseline_ratio: DEFAULT_TOP_BOTTOM_RATIO,
611 fixed_width_tab_size: None,
612 },
613 &[
614 (
615 0..5,
616 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
617 ),
618 (
619 5..13,
620 StyleAndFont::new(zapfino, Properties::default(), TextStyle::new()),
621 ),
622 (
623 13..16,
624 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
625 ),
626 (
627 16..text.encode_utf16().count(),
628 StyleAndFont::new(menlo, Properties::default(), TextStyle::new()),
629 ),
630 ],
631 &font_db,
632 max_width,
633 70., /* max_height */
634 Default::default(),
635 None,
636 );
637 
638 // The text should only fit one line.
639 assert_eq!(frame.lines().len(), 1);
640 
641 // The text is one line long and should be clipped like so: "m𐍈re, Zapfin𐍈!l...".
642 // Note that the contents are not clipped, but the width being greater than the max width
643 // indicates that when we paint, this is clipped.
644 assert_eq!(
645 collect_glyph_indices(&frame),
646 vec![[0, 1, 2, 3, 4, 5, 6, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22],]
647 );
648 let first_line = frame.lines().first().unwrap();
649 assert!(first_line.width > max_width);
650 
651 Ok(())
652}
653