1#![warn(missing_docs)]
12
13use crate::tokens;
14use crate::tree::{FontFamily, FontWeight, TextWrap};
15use cosmic_text::{
16 Attrs, Buffer, Cursor, Family, FeatureTag, FontFeatures, FontSystem, Metrics, Shaping, Weight,
17 Wrap, fontdb,
18};
19use lru::LruCache;
20use std::cell::RefCell;
21use std::num::NonZeroUsize;
22
23const MONO_CHAR_WIDTH_FACTOR: f32 = 0.62;
24
25const BASELINE_MULTIPLIER: f32 = 0.93;
26
27#[derive(Clone, Debug, PartialEq)]
29pub struct TextLine {
30 pub text: String,
32 pub width: f32,
34 pub y: f32,
36 pub baseline: f32,
38 pub rtl: bool,
40}
41
42#[derive(Clone, Debug, PartialEq)]
46pub struct TextLayout {
47 pub lines: Vec<TextLine>,
49 pub width: f32,
51 pub height: f32,
54 pub line_height: f32,
56}
57
58impl TextLayout {
59 pub fn line_count(&self) -> usize {
61 self.lines.len().max(1)
62 }
63
64 pub fn measured(&self) -> MeasuredText {
66 MeasuredText {
67 width: self.width,
68 height: self.height,
69 line_count: self.line_count(),
70 }
71 }
72}
73
74#[derive(Clone, Debug, PartialEq)]
77pub struct MeasuredText {
78 pub width: f32,
80 pub height: f32,
82 pub line_count: usize,
84}
85
86#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub struct TextLayoutCacheStats {
93 pub hits: u64,
95 pub misses: u64,
97 pub evictions: u64,
99 pub shaped_bytes: u64,
101}
102
103#[derive(Clone, Debug, PartialEq)]
113pub struct TextGeometry<'a> {
114 text: &'a str,
115 size: f32,
116 family: FontFamily,
117 weight: FontWeight,
118 mono: bool,
119 wrap: TextWrap,
120 available_width: Option<f32>,
121 layout: TextLayout,
122}
123
124impl<'a> TextGeometry<'a> {
125 pub fn new(
129 text: &'a str,
130 size: f32,
131 weight: FontWeight,
132 mono: bool,
133 wrap: TextWrap,
134 available_width: Option<f32>,
135 ) -> Self {
136 Self::new_with_family(
137 text,
138 size,
139 FontFamily::default(),
140 weight,
141 mono,
142 wrap,
143 available_width,
144 )
145 }
146
147 pub fn new_with_family(
149 text: &'a str,
150 size: f32,
151 family: FontFamily,
152 weight: FontWeight,
153 mono: bool,
154 wrap: TextWrap,
155 available_width: Option<f32>,
156 ) -> Self {
157 let layout = layout_text_with_family(
158 text,
159 size,
160 family,
161 weight,
162 mono,
163 false,
164 wrap,
165 available_width,
166 );
167 Self {
168 text,
169 size,
170 family,
171 weight,
172 mono,
173 wrap,
174 available_width,
175 layout,
176 }
177 }
178
179 pub fn text(&self) -> &'a str {
181 self.text
182 }
183
184 pub fn layout(&self) -> &TextLayout {
186 &self.layout
187 }
188
189 pub fn measured(&self) -> MeasuredText {
191 self.layout.measured()
192 }
193
194 pub fn line_height(&self) -> f32 {
196 self.layout.line_height
197 }
198
199 pub fn width(&self) -> f32 {
201 self.layout.width
202 }
203
204 pub fn height(&self) -> f32 {
206 self.layout.height
207 }
208
209 pub fn hit(&self, x: f32, y: f32) -> Option<TextHit> {
212 hit_text_with_family(
213 self.text,
214 self.size,
215 self.family,
216 self.weight,
217 self.wrap,
218 self.available_width,
219 x,
220 y,
221 )
222 }
223
224 pub fn hit_byte(&self, x: f32, y: f32) -> Option<usize> {
229 let hit = self.hit(x, y)?;
230 Some(self.byte_from_line_position(hit.line, hit.byte_index))
231 }
232
233 pub fn caret_xy(&self, byte_index: usize) -> (f32, f32) {
236 caret_xy_with_family(
237 self.text,
238 byte_index,
239 self.size,
240 self.family,
241 self.weight,
242 self.wrap,
243 self.available_width,
244 )
245 }
246
247 pub fn prefix_width(&self, byte_index: usize) -> f32 {
251 self.caret_xy(byte_index).0
252 }
253
254 pub fn selection_rects(&self, lo: usize, hi: usize) -> Vec<(f32, f32, f32, f32)> {
257 selection_rects_with_family(
258 self.text,
259 lo,
260 hi,
261 self.size,
262 self.family,
263 self.weight,
264 self.wrap,
265 self.available_width,
266 )
267 }
268
269 fn byte_from_line_position(&self, line: usize, byte_in_line: usize) -> usize {
270 line_position_to_byte(self.text, line, byte_in_line)
271 }
272}
273
274pub fn measure_text(
277 text: &str,
278 size: f32,
279 weight: FontWeight,
280 mono: bool,
281 wrap: TextWrap,
282 available_width: Option<f32>,
283) -> MeasuredText {
284 layout_text(text, size, weight, mono, wrap, available_width).measured()
285}
286
287pub fn layout_text(
291 text: &str,
292 size: f32,
293 weight: FontWeight,
294 mono: bool,
295 wrap: TextWrap,
296 available_width: Option<f32>,
297) -> TextLayout {
298 layout_text_with_family(
299 text,
300 size,
301 FontFamily::default(),
302 weight,
303 mono,
304 false,
305 wrap,
306 available_width,
307 )
308}
309
310#[allow(clippy::too_many_arguments)]
312pub fn layout_text_with_family(
313 text: &str,
314 size: f32,
315 family: FontFamily,
316 weight: FontWeight,
317 mono: bool,
318 tabular: bool,
319 wrap: TextWrap,
320 available_width: Option<f32>,
321) -> TextLayout {
322 layout_text_with_line_height_and_family(
323 text,
324 size,
325 line_height(size),
326 family,
327 weight,
328 mono,
329 tabular,
330 wrap,
331 available_width,
332 )
333}
334
335#[allow(clippy::too_many_arguments)]
339pub fn layout_text_with_line_height(
340 text: &str,
341 size: f32,
342 line_height: f32,
343 weight: FontWeight,
344 mono: bool,
345 wrap: TextWrap,
346 available_width: Option<f32>,
347) -> TextLayout {
348 layout_text_with_line_height_and_family(
349 text,
350 size,
351 line_height,
352 FontFamily::default(),
353 weight,
354 mono,
355 false,
356 wrap,
357 available_width,
358 )
359}
360
361#[allow(clippy::too_many_arguments)]
366pub fn layout_text_with_line_height_and_family(
367 text: &str,
368 size: f32,
369 line_height: f32,
370 family: FontFamily,
371 weight: FontWeight,
372 mono: bool,
373 tabular: bool,
374 wrap: TextWrap,
375 available_width: Option<f32>,
376) -> TextLayout {
377 let cached = SHAPE_KEY_SCRATCH.with_borrow_mut(|key| {
392 key.text.clear();
393 key.text.push_str(text);
394 key.size_bits = size.to_bits();
395 key.line_height_bits = line_height.to_bits();
396 key.family = family;
397 key.weight = weight;
398 key.mono = mono;
399 key.tabular = tabular;
400 key.wrap = wrap;
401 key.available_width_bits = available_width.map(f32::to_bits);
402 SHAPE_CACHE.with_borrow_mut(|c| c.get(key).cloned())
403 });
404 if let Some(cached) = cached {
405 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
406 stats.hits += 1;
407 });
408 return cached;
409 }
410 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
411 stats.misses += 1;
412 stats.shaped_bytes += text.len() as u64;
413 });
414 let layout = layout_text_uncached(
415 text,
416 size,
417 line_height,
418 family,
419 weight,
420 mono,
421 tabular,
422 wrap,
423 available_width,
424 );
425 let key = ShapeKey {
426 text: text.to_owned(),
427 size_bits: size.to_bits(),
428 line_height_bits: line_height.to_bits(),
429 family,
430 weight,
431 mono,
432 tabular,
433 wrap,
434 available_width_bits: available_width.map(f32::to_bits),
435 };
436 SHAPE_CACHE.with_borrow_mut(|c| {
437 if c.len() == SHAPE_CACHE_CAPACITY {
438 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
439 stats.evictions += 1;
440 });
441 }
442 c.put(key, layout.clone());
443 });
444 layout
445}
446
447#[allow(clippy::too_many_arguments)]
448fn layout_text_uncached(
449 text: &str,
450 size: f32,
451 line_height: f32,
452 family: FontFamily,
453 weight: FontWeight,
454 mono: bool,
455 tabular: bool,
456 wrap: TextWrap,
457 available_width: Option<f32>,
458) -> TextLayout {
459 if !mono
462 && let Some(layout) = layout_text_cosmic(
463 text,
464 size,
465 line_height,
466 family,
467 weight,
468 tabular,
469 wrap,
470 available_width,
471 )
472 {
473 return layout;
474 }
475
476 let raw_lines = match (wrap, available_width) {
477 (TextWrap::Wrap, Some(width)) => {
478 wrap_lines_by_width(text, width, size, family, weight, mono)
479 }
480 _ => text.split('\n').map(str::to_string).collect(),
481 };
482 build_layout(raw_lines, size, line_height, family, weight, mono)
483}
484
485pub fn ellipsize_text(
488 text: &str,
489 size: f32,
490 weight: FontWeight,
491 mono: bool,
492 available_width: f32,
493) -> String {
494 ellipsize_text_with_family(
495 text,
496 size,
497 FontFamily::default(),
498 weight,
499 mono,
500 false,
501 available_width,
502 )
503}
504
505#[allow(clippy::too_many_arguments)]
507pub fn ellipsize_text_with_family(
508 text: &str,
509 size: f32,
510 family: FontFamily,
511 weight: FontWeight,
512 mono: bool,
513 tabular: bool,
514 available_width: f32,
515) -> String {
516 if available_width <= 0.0 || text.is_empty() {
517 return String::new();
518 }
519 let full = layout_text_with_family(
520 text,
521 size,
522 family,
523 weight,
524 mono,
525 tabular,
526 TextWrap::NoWrap,
527 None,
528 );
529 if full.width <= available_width + 0.5 {
530 return text.to_string();
531 }
532
533 let ellipsis = "…";
534 let ellipsis_w = layout_text_with_family(
535 ellipsis,
536 size,
537 family,
538 weight,
539 mono,
540 tabular,
541 TextWrap::NoWrap,
542 None,
543 )
544 .width;
545 if ellipsis_w > available_width + 0.5 {
546 return ellipsis.to_string();
547 }
548
549 let chars: Vec<char> = text.chars().collect();
550 let mut lo = 0usize;
551 let mut hi = chars.len();
552 while lo < hi {
553 let mid = (lo + hi).div_ceil(2);
554 let candidate: String = chars[..mid].iter().collect();
555 let candidate = format!("{candidate}{ellipsis}");
556 let width = layout_text_with_family(
557 &candidate,
558 size,
559 family,
560 weight,
561 mono,
562 tabular,
563 TextWrap::NoWrap,
564 None,
565 )
566 .width;
567 if width <= available_width + 0.5 {
568 lo = mid;
569 } else {
570 hi = mid - 1;
571 }
572 }
573
574 let prefix: String = chars[..lo].iter().collect();
575 format!("{prefix}{ellipsis}")
576}
577
578pub fn clamp_text_to_lines(
581 text: &str,
582 size: f32,
583 weight: FontWeight,
584 mono: bool,
585 available_width: f32,
586 max_lines: usize,
587) -> String {
588 clamp_text_to_lines_with_family(
589 text,
590 size,
591 FontFamily::default(),
592 weight,
593 mono,
594 available_width,
595 max_lines,
596 )
597}
598
599pub fn clamp_text_to_lines_with_family(
601 text: &str,
602 size: f32,
603 family: FontFamily,
604 weight: FontWeight,
605 mono: bool,
606 available_width: f32,
607 max_lines: usize,
608) -> String {
609 if text.is_empty() || available_width <= 0.0 || max_lines == 0 {
610 return String::new();
611 }
612
613 let layout = layout_text_with_family(
614 text,
615 size,
616 family,
617 weight,
618 mono,
619 false,
620 TextWrap::Wrap,
621 Some(available_width),
622 );
623 if layout.lines.len() <= max_lines {
624 return text.to_string();
625 }
626
627 let mut lines: Vec<String> = layout
628 .lines
629 .iter()
630 .take(max_lines)
631 .map(|line| line.text.clone())
632 .collect();
633 if let Some(last) = lines.last_mut() {
634 let marked = format!("{last}…");
635 *last =
636 ellipsize_text_with_family(&marked, size, family, weight, mono, false, available_width);
637 }
638 lines.join("\n")
639}
640
641#[derive(Clone, Copy, Debug, PartialEq, Eq)]
645pub struct TextHit {
646 pub line: usize,
651 pub byte_index: usize,
654}
655
656pub fn hit_text(
667 text: &str,
668 size: f32,
669 weight: FontWeight,
670 wrap: TextWrap,
671 available_width: Option<f32>,
672 x: f32,
673 y: f32,
674) -> Option<TextHit> {
675 hit_text_with_family(
676 text,
677 size,
678 FontFamily::default(),
679 weight,
680 wrap,
681 available_width,
682 x,
683 y,
684 )
685}
686
687#[allow(clippy::too_many_arguments)]
689pub fn hit_text_with_family(
690 text: &str,
691 size: f32,
692 family: FontFamily,
693 weight: FontWeight,
694 wrap: TextWrap,
695 available_width: Option<f32>,
696 x: f32,
697 y: f32,
698) -> Option<TextHit> {
699 with_font_system(|font_system| {
700 let line_height = line_height(size);
701 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
702 buffer.set_wrap(match wrap {
703 TextWrap::NoWrap => Wrap::None,
704 TextWrap::Wrap => Wrap::WordOrGlyph,
705 });
706 buffer.set_size(
707 match wrap {
708 TextWrap::NoWrap => None,
709 TextWrap::Wrap => available_width,
710 },
711 None,
712 );
713 let attrs = Attrs::new()
714 .family(Family::Name(family.family_name()))
715 .weight(cosmic_weight(weight));
716 buffer.set_text(text, &attrs, Shaping::Advanced, None);
717 buffer.shape_until_scroll(font_system, false);
718 let cursor = buffer.hit(x, y)?;
719 Some(TextHit {
720 line: cursor.line,
721 byte_index: cursor.index,
722 })
723 })
724}
725
726pub fn caret_xy(
738 text: &str,
739 byte_index: usize,
740 size: f32,
741 weight: FontWeight,
742 wrap: TextWrap,
743 available_width: Option<f32>,
744) -> (f32, f32) {
745 caret_xy_with_family(
746 text,
747 byte_index,
748 size,
749 FontFamily::default(),
750 weight,
751 wrap,
752 available_width,
753 )
754}
755
756pub fn caret_xy_with_family(
758 text: &str,
759 byte_index: usize,
760 size: f32,
761 family: FontFamily,
762 weight: FontWeight,
763 wrap: TextWrap,
764 available_width: Option<f32>,
765) -> (f32, f32) {
766 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
767 with_font_system(|font_system| {
768 let line_h = line_height(size);
769 let buffer = build_buffer(
770 font_system,
771 text,
772 size,
773 family,
774 weight,
775 wrap,
776 available_width,
777 );
778 let cursor = Cursor::new(target_line, byte_in_line);
779 if let Some((x, y)) = buffer.cursor_position(&cursor) {
783 return (x, y);
784 }
785 (0.0, target_line as f32 * line_h)
788 })
789}
790
791pub fn selection_rects(
799 text: &str,
800 lo: usize,
801 hi: usize,
802 size: f32,
803 weight: FontWeight,
804 wrap: TextWrap,
805 available_width: Option<f32>,
806) -> Vec<(f32, f32, f32, f32)> {
807 selection_rects_with_family(
808 text,
809 lo,
810 hi,
811 size,
812 FontFamily::default(),
813 weight,
814 wrap,
815 available_width,
816 )
817}
818
819#[allow(clippy::too_many_arguments)]
821pub fn selection_rects_with_family(
822 text: &str,
823 lo: usize,
824 hi: usize,
825 size: f32,
826 family: FontFamily,
827 weight: FontWeight,
828 wrap: TextWrap,
829 available_width: Option<f32>,
830) -> Vec<(f32, f32, f32, f32)> {
831 if lo >= hi {
832 return Vec::new();
833 }
834 let (lo_line, lo_in_line) = byte_to_line_position(text, lo);
835 let (hi_line, hi_in_line) = byte_to_line_position(text, hi);
836 with_font_system(|font_system| {
837 let buffer = build_buffer(
838 font_system,
839 text,
840 size,
841 family,
842 weight,
843 wrap,
844 available_width,
845 );
846 let c_lo = Cursor::new(lo_line, lo_in_line);
847 let c_hi = Cursor::new(hi_line, hi_in_line);
848 let mut rects = Vec::new();
849 for run in buffer.layout_runs() {
850 if run.line_i < lo_line || run.line_i > hi_line {
851 continue;
852 }
853 for (x, w) in run.highlight(c_lo, c_hi) {
854 rects.push((x, run.line_top, w, run.line_height));
855 }
856 }
857 rects
858 })
859}
860
861pub fn visual_line_byte_range(
867 text: &str,
868 byte_index: usize,
869 size: f32,
870 weight: FontWeight,
871 wrap: TextWrap,
872 available_width: Option<f32>,
873) -> (usize, usize) {
874 visual_line_byte_range_with_family(
875 text,
876 byte_index,
877 size,
878 FontFamily::default(),
879 weight,
880 wrap,
881 available_width,
882 )
883}
884
885pub fn visual_line_byte_range_with_family(
888 text: &str,
889 byte_index: usize,
890 size: f32,
891 family: FontFamily,
892 weight: FontWeight,
893 wrap: TextWrap,
894 available_width: Option<f32>,
895) -> (usize, usize) {
896 let byte_index = clamp_to_char_boundary(text, byte_index.min(text.len()));
897 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
898 let hard_line_start = line_position_to_byte(text, target_line, 0);
899 let hard_line_end = line_end_byte(text, hard_line_start);
900 with_font_system(|font_system| {
901 let buffer = build_buffer(
902 font_system,
903 text,
904 size,
905 family,
906 weight,
907 wrap,
908 available_width,
909 );
910 let mut last_range = None;
911 for run in buffer.layout_runs() {
912 if run.line_i != target_line {
913 continue;
914 }
915 let Some((start, end)) = layout_run_byte_range(&run) else {
916 continue;
917 };
918 last_range = Some((start, end));
919 if start <= byte_in_line && byte_in_line < end {
920 return (
921 line_position_to_byte(text, target_line, start),
922 line_position_to_byte(text, target_line, end),
923 );
924 }
925 }
926 if let Some((start, end)) = last_range
927 && byte_index >= line_position_to_byte(text, target_line, start)
928 {
929 return (
930 line_position_to_byte(text, target_line, start),
931 line_position_to_byte(text, target_line, end),
932 );
933 }
934 (hard_line_start, hard_line_end)
935 })
936}
937
938fn byte_to_line_position(text: &str, byte_index: usize) -> (usize, usize) {
943 let byte_index = byte_index.min(text.len());
944 let mut line = 0;
945 let mut line_start = 0;
946 for (i, ch) in text.char_indices() {
947 if i >= byte_index {
948 break;
949 }
950 if ch == '\n' {
951 line += 1;
952 line_start = i + ch.len_utf8();
953 }
954 }
955 (line, byte_index - line_start)
956}
957
958fn line_position_to_byte(text: &str, line: usize, byte_in_line: usize) -> usize {
959 let mut current_line = 0;
960 let mut line_start = 0;
961 for (i, ch) in text.char_indices() {
962 if current_line == line {
963 let candidate = line_start + byte_in_line;
964 return clamp_to_char_boundary(text, candidate.min(text.len()));
965 }
966 if ch == '\n' {
967 current_line += 1;
968 line_start = i + ch.len_utf8();
969 }
970 }
971 if current_line == line {
972 clamp_to_char_boundary(text, (line_start + byte_in_line).min(text.len()))
973 } else {
974 text.len()
975 }
976}
977
978fn line_end_byte(text: &str, line_start: usize) -> usize {
979 text[line_start..]
980 .find('\n')
981 .map(|i| line_start + i)
982 .unwrap_or(text.len())
983}
984
985fn layout_run_byte_range(run: &cosmic_text::LayoutRun<'_>) -> Option<(usize, usize)> {
986 let start = run.glyphs.iter().map(|glyph| glyph.start).min()?;
987 let end = run
988 .glyphs
989 .iter()
990 .map(|glyph| glyph.end)
991 .max()
992 .unwrap_or(start);
993 Some((start, end))
994}
995
996fn clamp_to_char_boundary(text: &str, mut byte: usize) -> usize {
997 byte = byte.min(text.len());
998 while byte > 0 && !text.is_char_boundary(byte) {
999 byte -= 1;
1000 }
1001 byte
1002}
1003
1004fn build_buffer(
1005 font_system: &mut FontSystem,
1006 text: &str,
1007 size: f32,
1008 family: FontFamily,
1009 weight: FontWeight,
1010 wrap: TextWrap,
1011 available_width: Option<f32>,
1012) -> Buffer {
1013 let line_h = line_height(size);
1014 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_h));
1015 buffer.set_wrap(match wrap {
1016 TextWrap::NoWrap => Wrap::None,
1017 TextWrap::Wrap => Wrap::WordOrGlyph,
1018 });
1019 buffer.set_size(
1020 match wrap {
1021 TextWrap::NoWrap => None,
1022 TextWrap::Wrap => available_width,
1023 },
1024 None,
1025 );
1026 let attrs = Attrs::new()
1027 .family(Family::Name(family.family_name()))
1028 .weight(cosmic_weight(weight));
1029 buffer.set_text(text, &attrs, Shaping::Advanced, None);
1030 buffer.shape_until_scroll(font_system, false);
1031 buffer
1032}
1033
1034pub fn wrap_lines(
1038 text: &str,
1039 max_width: f32,
1040 size: f32,
1041 weight: FontWeight,
1042 mono: bool,
1043) -> Vec<String> {
1044 wrap_lines_with_family(text, max_width, size, FontFamily::default(), weight, mono)
1045}
1046
1047pub fn wrap_lines_with_family(
1049 text: &str,
1050 max_width: f32,
1051 size: f32,
1052 family: FontFamily,
1053 weight: FontWeight,
1054 mono: bool,
1055) -> Vec<String> {
1056 if !mono
1057 && let Some(layout) = layout_text_cosmic(
1058 text,
1059 size,
1060 line_height(size),
1061 family,
1062 weight,
1063 false,
1064 TextWrap::Wrap,
1065 Some(max_width),
1066 )
1067 {
1068 return layout.lines.into_iter().map(|line| line.text).collect();
1069 }
1070 wrap_lines_by_width(text, max_width, size, family, weight, mono)
1071}
1072
1073fn wrap_lines_by_width(
1074 text: &str,
1075 max_width: f32,
1076 size: f32,
1077 family: FontFamily,
1078 weight: FontWeight,
1079 mono: bool,
1080) -> Vec<String> {
1081 if max_width <= 0.0 {
1082 return vec![String::new()];
1083 }
1084
1085 let ctx = WrapMeasure {
1086 max_width,
1087 size,
1088 family,
1089 weight,
1090 mono,
1091 };
1092 let mut out = Vec::new();
1093 for paragraph in text.split('\n') {
1094 if paragraph.is_empty() {
1095 out.push(String::new());
1096 continue;
1097 }
1098
1099 let mut line = String::new();
1100 for word in paragraph.split_whitespace() {
1101 if line.is_empty() {
1102 push_word_wrapped(&mut out, &mut line, word, ctx);
1103 continue;
1104 }
1105
1106 let candidate = format!("{line} {word}");
1107 if line_width_with_family(&candidate, size, family, weight, mono) <= max_width {
1108 line = candidate;
1109 } else {
1110 out.push(std::mem::take(&mut line));
1111 push_word_wrapped(&mut out, &mut line, word, ctx);
1112 }
1113 }
1114
1115 if !line.is_empty() {
1116 out.push(line);
1117 }
1118 }
1119
1120 if out.is_empty() {
1121 out.push(String::new());
1122 }
1123 out
1124}
1125
1126pub fn line_width(text: &str, size: f32, weight: FontWeight, mono: bool) -> f32 {
1129 line_width_with_family(text, size, FontFamily::default(), weight, mono)
1130}
1131
1132pub fn line_width_with_family(
1134 text: &str,
1135 size: f32,
1136 family: FontFamily,
1137 weight: FontWeight,
1138 mono: bool,
1139) -> f32 {
1140 if !mono
1141 && let Some(layout) = layout_text_cosmic(
1142 text,
1143 size,
1144 line_height(size),
1145 family,
1146 weight,
1147 false,
1148 TextWrap::NoWrap,
1149 None,
1150 )
1151 {
1152 return layout.width;
1153 }
1154 line_width_by_ttf(text, size, family, weight, mono)
1155}
1156
1157fn line_width_by_ttf(
1158 text: &str,
1159 size: f32,
1160 family: FontFamily,
1161 weight: FontWeight,
1162 mono: bool,
1163) -> f32 {
1164 if mono {
1165 return text
1166 .chars()
1167 .filter(|c| *c != '\n' && *c != '\r')
1168 .map(|c| if c == '\t' { 4.0 } else { 1.0 })
1169 .sum::<f32>()
1170 * size
1171 * MONO_CHAR_WIDTH_FACTOR;
1172 }
1173
1174 let Ok(face) = ttf_parser::Face::parse(font_bytes(family, weight), 0) else {
1175 return fallback_line_width(text, size, mono);
1176 };
1177 let scale = size / face.units_per_em() as f32;
1178 let fallback_advance = face.units_per_em() as f32 * 0.5;
1179 let mut width = 0.0;
1180 let mut prev = None;
1181
1182 for c in text.chars() {
1183 if c == '\n' || c == '\r' {
1184 continue;
1185 }
1186 if c == '\t' {
1187 width += line_width_with_family(" ", size, family, weight, mono);
1188 prev = None;
1189 continue;
1190 }
1191
1192 let Some(glyph) = glyph_for(&face, c) else {
1193 continue;
1194 };
1195 if let Some(left) = prev {
1196 width += kern(&face, left, glyph) * scale;
1197 }
1198 width += face
1199 .glyph_hor_advance(glyph)
1200 .map(|advance| advance as f32)
1201 .unwrap_or(fallback_advance)
1202 * scale;
1203 prev = Some(glyph);
1204 }
1205 width
1206}
1207
1208pub fn line_height(size: f32) -> f32 {
1210 tokens::line_height_for_size(size)
1215}
1216
1217fn build_layout(
1218 lines: Vec<String>,
1219 size: f32,
1220 line_height: f32,
1221 family: FontFamily,
1222 weight: FontWeight,
1223 mono: bool,
1224) -> TextLayout {
1225 let raw_lines = if lines.is_empty() {
1226 vec![String::new()]
1227 } else {
1228 lines
1229 };
1230 let lines: Vec<TextLine> = raw_lines
1231 .into_iter()
1232 .enumerate()
1233 .map(|(i, text)| {
1234 let y = i as f32 * line_height;
1235 TextLine {
1236 width: line_width_with_family(&text, size, family, weight, mono),
1237 text,
1238 y,
1239 baseline: y + size * BASELINE_MULTIPLIER,
1240 rtl: false,
1241 }
1242 })
1243 .collect();
1244 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1245 TextLayout {
1246 width,
1247 height: lines.len().max(1) as f32 * line_height,
1248 line_height,
1249 lines,
1250 }
1251}
1252
1253pub(crate) fn tabular_features() -> FontFeatures {
1257 let mut features = FontFeatures::new();
1258 features.set(FeatureTag::new(b"tnum"), 1);
1259 features
1260}
1261
1262#[allow(clippy::too_many_arguments)]
1263fn layout_text_cosmic(
1264 text: &str,
1265 size: f32,
1266 line_height: f32,
1267 family: FontFamily,
1268 weight: FontWeight,
1269 tabular: bool,
1270 wrap: TextWrap,
1271 available_width: Option<f32>,
1272) -> Option<TextLayout> {
1273 let options = CosmicLayoutOptions {
1274 size,
1275 line_height,
1276 family,
1277 weight,
1278 tabular,
1279 wrap,
1280 available_width,
1281 };
1282 with_font_system(|font_system| layout_text_cosmic_with(font_system, text, options))
1283}
1284
1285#[derive(Copy, Clone)]
1286struct CosmicLayoutOptions {
1287 size: f32,
1288 line_height: f32,
1289 family: FontFamily,
1290 weight: FontWeight,
1291 tabular: bool,
1292 wrap: TextWrap,
1293 available_width: Option<f32>,
1294}
1295
1296fn layout_text_cosmic_with(
1297 font_system: &mut FontSystem,
1298 text: &str,
1299 options: CosmicLayoutOptions,
1300) -> Option<TextLayout> {
1301 let CosmicLayoutOptions {
1302 size,
1303 line_height,
1304 family,
1305 weight,
1306 tabular,
1307 wrap,
1308 available_width,
1309 } = options;
1310 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
1311 buffer.set_wrap(match wrap {
1312 TextWrap::NoWrap => Wrap::None,
1313 TextWrap::Wrap => Wrap::WordOrGlyph,
1314 });
1315 buffer.set_size(
1316 match wrap {
1317 TextWrap::NoWrap => None,
1318 TextWrap::Wrap => available_width,
1319 },
1320 None,
1321 );
1322 let mut attrs = Attrs::new()
1323 .family(Family::Name(family.family_name()))
1324 .weight(cosmic_weight(weight));
1325 if tabular {
1326 attrs = attrs.font_features(tabular_features());
1327 }
1328 buffer.set_text(text, &attrs, Shaping::Advanced, None);
1329 buffer.shape_until_scroll(font_system, false);
1330
1331 let mut lines = Vec::new();
1332 let mut height: f32 = 0.0;
1333 for run in buffer.layout_runs() {
1334 height = height.max(run.line_top + run.line_height);
1335 lines.push(TextLine {
1336 text: layout_run_text(&run),
1337 width: run.line_w,
1338 y: run.line_top,
1339 baseline: run.line_y,
1340 rtl: run.rtl,
1341 });
1342 }
1343
1344 if lines.is_empty() {
1345 return None;
1346 }
1347
1348 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1349 Some(TextLayout {
1350 lines,
1351 width,
1352 height: height.max(line_height),
1353 line_height,
1354 })
1355}
1356
1357thread_local! {
1363 static FONT_SYSTEM: RefCell<FontSystem> = RefCell::new(bundled_font_system());
1364 static FONT_SYSTEM_REGISTRY_LOADED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1368}
1369
1370fn with_font_system<R>(f: impl FnOnce(&mut FontSystem) -> R) -> R {
1375 FONT_SYSTEM.with_borrow_mut(|font_system| {
1376 FONT_SYSTEM_REGISTRY_LOADED.with(|loaded| {
1377 let mut n = loaded.get();
1378 if crate::text::registry::sync_font_system(font_system, &mut n) {
1379 SHAPE_CACHE.with_borrow_mut(|cache| cache.clear());
1380 }
1381 loaded.set(n);
1382 });
1383 f(font_system)
1384 })
1385}
1386
1387#[cfg(test)]
1391pub(crate) fn font_system_face_count_for_tests() -> usize {
1392 with_font_system(|font_system| font_system.db().len())
1393}
1394
1395#[derive(Clone, PartialEq, Eq, Hash)]
1402struct ShapeKey {
1403 text: String,
1406 size_bits: u32,
1407 line_height_bits: u32,
1408 family: FontFamily,
1409 weight: FontWeight,
1410 mono: bool,
1411 tabular: bool,
1412 wrap: TextWrap,
1413 available_width_bits: Option<u32>,
1414}
1415
1416const SHAPE_CACHE_CAPACITY: usize = 16_384;
1423thread_local! {
1424 static SHAPE_CACHE: RefCell<LruCache<ShapeKey, TextLayout>> =
1425 RefCell::new(LruCache::new(NonZeroUsize::new(SHAPE_CACHE_CAPACITY).unwrap()));
1426 static SHAPE_CACHE_STATS: RefCell<TextLayoutCacheStats> =
1427 RefCell::new(TextLayoutCacheStats::default());
1428 static SHAPE_KEY_SCRATCH: RefCell<ShapeKey> = RefCell::new(ShapeKey {
1431 text: String::new(),
1432 size_bits: 0,
1433 line_height_bits: 0,
1434 family: FontFamily::default(),
1435 weight: FontWeight::Regular,
1436 mono: false,
1437 tabular: false,
1438 wrap: TextWrap::NoWrap,
1439 available_width_bits: None,
1440 });
1441}
1442
1443pub fn take_shape_cache_stats() -> TextLayoutCacheStats {
1447 SHAPE_CACHE_STATS.with_borrow_mut(std::mem::take)
1448}
1449
1450fn bundled_font_system() -> FontSystem {
1451 let mut db = fontdb::Database::new();
1452 db.set_sans_serif_family(FontFamily::default().family_name());
1453 for bytes in damascene_fonts::DEFAULT_FONTS {
1454 db.load_font_data(bytes.to_vec());
1455 }
1456 FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1457}
1458
1459fn cosmic_weight(weight: FontWeight) -> Weight {
1460 match weight {
1461 FontWeight::Regular => Weight::NORMAL,
1462 FontWeight::Medium => Weight::MEDIUM,
1463 FontWeight::Semibold => Weight::SEMIBOLD,
1464 FontWeight::Bold => Weight::BOLD,
1465 }
1466}
1467
1468fn layout_run_text(run: &cosmic_text::LayoutRun<'_>) -> String {
1469 let Some(start) = run.glyphs.iter().map(|glyph| glyph.start).min() else {
1470 return String::new();
1471 };
1472 let end = run
1473 .glyphs
1474 .iter()
1475 .map(|glyph| glyph.end)
1476 .max()
1477 .unwrap_or(start);
1478 run.text
1479 .get(start..end)
1480 .unwrap_or_default()
1481 .trim_end()
1482 .to_string()
1483}
1484
1485#[derive(Copy, Clone)]
1486struct WrapMeasure {
1487 max_width: f32,
1488 size: f32,
1489 family: FontFamily,
1490 weight: FontWeight,
1491 mono: bool,
1492}
1493
1494fn push_word_wrapped(out: &mut Vec<String>, line: &mut String, word: &str, ctx: WrapMeasure) {
1495 let WrapMeasure {
1496 max_width,
1497 size,
1498 family,
1499 weight,
1500 mono,
1501 } = ctx;
1502 if line_width_with_family(word, size, family, weight, mono) <= max_width {
1503 line.push_str(word);
1504 return;
1505 }
1506
1507 for ch in word.chars() {
1508 let candidate = format!("{line}{ch}");
1509 if !line.is_empty()
1510 && line_width_with_family(&candidate, size, family, weight, mono) > max_width
1511 {
1512 out.push(std::mem::take(line));
1513 }
1514 line.push(ch);
1515 }
1516}
1517
1518fn glyph_for(face: &ttf_parser::Face<'_>, c: char) -> Option<ttf_parser::GlyphId> {
1519 face.glyph_index(c)
1520 .or_else(|| face.glyph_index('\u{FFFD}'))
1521 .or_else(|| face.glyph_index('?'))
1522 .or_else(|| face.glyph_index(' '))
1523}
1524
1525fn kern(face: &ttf_parser::Face<'_>, left: ttf_parser::GlyphId, right: ttf_parser::GlyphId) -> f32 {
1526 let Some(kern) = &face.tables().kern else {
1527 return 0.0;
1528 };
1529 kern.subtables
1530 .into_iter()
1531 .filter(|subtable| subtable.horizontal && !subtable.has_cross_stream)
1532 .find_map(|subtable| subtable.glyphs_kerning(left, right))
1533 .map(|value| value as f32)
1534 .unwrap_or(0.0)
1535}
1536
1537fn font_bytes(family: FontFamily, weight: FontWeight) -> &'static [u8] {
1538 match family {
1542 FontFamily::Inter => {
1543 #[cfg(feature = "inter")]
1544 {
1545 let _ = weight;
1546 damascene_fonts::INTER_VARIABLE
1547 }
1548 #[cfg(not(feature = "inter"))]
1549 {
1550 let _ = weight;
1551 &[]
1552 }
1553 }
1554 FontFamily::Roboto => {
1555 #[cfg(feature = "roboto")]
1556 {
1557 match weight {
1558 FontWeight::Regular => damascene_fonts::ROBOTO_REGULAR,
1559 FontWeight::Medium => damascene_fonts::ROBOTO_MEDIUM,
1560 FontWeight::Semibold | FontWeight::Bold => damascene_fonts::ROBOTO_BOLD,
1561 }
1562 }
1563 #[cfg(not(feature = "roboto"))]
1564 {
1565 let _ = weight;
1566 &[]
1567 }
1568 }
1569 FontFamily::JetBrainsMono => {
1570 #[cfg(feature = "jetbrains-mono")]
1571 {
1572 let _ = weight;
1573 damascene_fonts::JETBRAINS_MONO_VARIABLE
1574 }
1575 #[cfg(not(feature = "jetbrains-mono"))]
1576 {
1577 let _ = weight;
1578 &[]
1579 }
1580 }
1581 }
1582}
1583
1584fn fallback_line_width(text: &str, size: f32, mono: bool) -> f32 {
1585 let char_w = size * if mono { MONO_CHAR_WIDTH_FACTOR } else { 0.60 };
1586 text.chars().count() as f32 * char_w
1587}
1588
1589#[cfg(test)]
1590mod tests {
1591 use super::*;
1592
1593 #[test]
1594 fn proportional_measurement_distinguishes_narrow_and_wide_glyphs() {
1595 let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, false);
1596 let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, false);
1597
1598 assert!(wide > narrow * 2.0, "wide={wide} narrow={narrow}");
1599 }
1600
1601 #[test]
1602 fn tabular_numerals_equalize_digit_advances() {
1603 let width = |text: &str, tabular: bool| {
1604 layout_text_with_family(
1605 text,
1606 16.0,
1607 FontFamily::Inter,
1608 FontWeight::Regular,
1609 false,
1610 tabular,
1611 TextWrap::NoWrap,
1612 None,
1613 )
1614 .width
1615 };
1616 let one = width("11111111", true);
1619 let eight = width("88888888", true);
1620 assert!(
1621 (one - eight).abs() < 0.01,
1622 "tabular digits must align: 1s={one} 8s={eight}"
1623 );
1624 let one_prop = width("11111111", false);
1628 assert!(
1629 one > one_prop + 0.5,
1630 "tabular '1' should be wider than proportional: tnum={one} default={one_prop}"
1631 );
1632 }
1633
1634 #[cfg(feature = "roboto")]
1635 #[test]
1636 fn font_family_changes_proportional_measurement() {
1637 let roboto = line_width_with_family(
1638 "Save changes",
1639 14.0,
1640 FontFamily::Roboto,
1641 FontWeight::Semibold,
1642 false,
1643 );
1644 let inter = line_width_with_family(
1645 "Save changes",
1646 14.0,
1647 FontFamily::Inter,
1648 FontWeight::Semibold,
1649 false,
1650 );
1651
1652 assert!(
1653 (inter - roboto).abs() > 1.0,
1654 "inter={inter} roboto={roboto}"
1655 );
1656 }
1657
1658 #[test]
1659 fn wrap_lines_respects_measured_widths() {
1660 let lines = wrap_lines(
1661 "wide WWW words stay measured",
1662 120.0,
1663 16.0,
1664 FontWeight::Regular,
1665 false,
1666 );
1667
1668 assert!(lines.len() > 1);
1669 for line in lines {
1670 assert!(
1671 line_width(&line, 16.0, FontWeight::Regular, false) <= 121.0,
1672 "{line:?} overflowed"
1673 );
1674 }
1675 }
1676
1677 #[test]
1678 fn layout_text_carries_line_positions_and_measurement() {
1679 let layout = layout_text(
1680 "alpha beta gamma",
1681 16.0,
1682 FontWeight::Regular,
1683 false,
1684 TextWrap::Wrap,
1685 Some(80.0),
1686 );
1687
1688 assert!(layout.lines.len() > 1);
1689 assert_eq!(layout.measured().line_count, layout.lines.len());
1690 assert_eq!(layout.lines[0].y, 0.0);
1691 assert_eq!(layout.lines[1].y, layout.line_height);
1692 assert!(layout.lines[0].baseline > layout.lines[0].y);
1693 assert!(layout.height >= layout.line_height * 2.0);
1694 }
1695
1696 #[test]
1697 fn tokenized_line_heights_match_shadcn_scale() {
1698 assert_eq!(line_height(12.0), 16.0);
1699 assert_eq!(line_height(14.0), 20.0);
1700 assert_eq!(line_height(16.0), 24.0);
1701 assert_eq!(line_height(24.0), 32.0);
1702 assert_eq!(line_height(30.0), 36.0);
1703 }
1704
1705 #[test]
1706 fn hit_text_at_origin_lands_on_first_byte() {
1707 let hit = hit_text(
1708 "hello world",
1709 16.0,
1710 FontWeight::Regular,
1711 TextWrap::NoWrap,
1712 None,
1713 0.0,
1714 8.0,
1715 )
1716 .expect("hit at origin");
1717 assert_eq!(hit.line, 0);
1718 assert_eq!(hit.byte_index, 0);
1719 }
1720
1721 #[test]
1722 fn hit_text_past_last_glyph_clamps_to_end() {
1723 let text = "hello";
1724 let hit = hit_text(
1726 text,
1727 16.0,
1728 FontWeight::Regular,
1729 TextWrap::NoWrap,
1730 None,
1731 1000.0,
1732 8.0,
1733 )
1734 .expect("hit past end");
1735 assert_eq!(hit.line, 0);
1736 assert_eq!(hit.byte_index, text.len());
1737 }
1738
1739 #[test]
1740 fn hit_text_walks_columns_left_to_right() {
1741 let text = "abcdefghij";
1745 let mut prev = 0usize;
1746 for x in [4.0, 16.0, 32.0, 64.0, 96.0] {
1747 let hit = hit_text(
1748 text,
1749 16.0,
1750 FontWeight::Regular,
1751 TextWrap::NoWrap,
1752 None,
1753 x,
1754 8.0,
1755 );
1756 let Some(hit) = hit else { continue };
1757 assert!(
1758 hit.byte_index >= prev,
1759 "byte_index regressed at x={x}: {} < {prev}",
1760 hit.byte_index
1761 );
1762 prev = hit.byte_index;
1763 }
1764 }
1765
1766 #[test]
1767 fn text_geometry_hit_byte_maps_hard_line_offsets_to_source_bytes() {
1768 let text = "alpha\nbeta";
1769 let geometry = TextGeometry::new(
1770 text,
1771 16.0,
1772 FontWeight::Regular,
1773 false,
1774 TextWrap::NoWrap,
1775 None,
1776 );
1777 let y = geometry.line_height() * 1.5;
1778 let byte = geometry.hit_byte(1000.0, y).expect("hit on second line");
1779 assert_eq!(byte, text.len());
1780 }
1781
1782 #[test]
1783 fn text_geometry_prefix_width_matches_caret_x() {
1784 let text = "hello world";
1785 let geometry = TextGeometry::new(
1786 text,
1787 16.0,
1788 FontWeight::Regular,
1789 false,
1790 TextWrap::NoWrap,
1791 None,
1792 );
1793 let (x, _y) = geometry.caret_xy(5);
1794 assert!((geometry.prefix_width(5) - x).abs() < 0.01);
1795 }
1796
1797 #[test]
1798 fn caret_xy_at_origin_is_zero_zero() {
1799 let (x, y) = caret_xy(
1800 "hello",
1801 0,
1802 16.0,
1803 FontWeight::Regular,
1804 TextWrap::NoWrap,
1805 None,
1806 );
1807 assert!(x.abs() < 0.01, "x={x}");
1808 assert_eq!(y, 0.0);
1809 }
1810
1811 #[test]
1812 fn caret_xy_at_end_of_line_is_at_line_width() {
1813 let text = "hello";
1814 let width = line_width(text, 16.0, FontWeight::Regular, false);
1815 let (x, y) = caret_xy(
1816 text,
1817 text.len(),
1818 16.0,
1819 FontWeight::Regular,
1820 TextWrap::NoWrap,
1821 None,
1822 );
1823 assert!((x - width).abs() < 1.0, "x={x} expected~{width}");
1824 assert_eq!(y, 0.0);
1825 }
1826
1827 #[test]
1828 fn caret_xy_drops_to_next_line_after_newline() {
1829 let text = "foo\nbar";
1830 let line_h = line_height(16.0);
1831 let (x, y) = caret_xy(text, 4, 16.0, FontWeight::Regular, TextWrap::NoWrap, None);
1833 assert!(x.abs() < 0.01, "x={x}");
1834 assert!((y - line_h).abs() < 0.01, "y={y} expected~{line_h}");
1835 }
1836
1837 #[test]
1838 fn caret_xy_on_phantom_trailing_line_falls_below_text() {
1839 let text = "foo\n";
1840 let line_h = line_height(16.0);
1841 let (x, y) = caret_xy(
1842 text,
1843 text.len(),
1844 16.0,
1845 FontWeight::Regular,
1846 TextWrap::NoWrap,
1847 None,
1848 );
1849 assert!(x.abs() < 0.01, "x={x}");
1850 assert!(y >= line_h - 0.01, "y={y} expected ≥ line_h={line_h}");
1851 }
1852
1853 #[test]
1854 fn selection_rects_returns_one_per_visual_line() {
1855 let text = "alpha\nbeta\ngamma";
1856 let rects = selection_rects(
1857 text,
1858 0,
1859 text.len(),
1860 16.0,
1861 FontWeight::Regular,
1862 TextWrap::NoWrap,
1863 None,
1864 );
1865 assert_eq!(
1866 rects.len(),
1867 3,
1868 "expected one rect per BufferLine, got {rects:?}"
1869 );
1870 assert!(rects[0].1 < rects[1].1);
1872 assert!(rects[1].1 < rects[2].1);
1873 for (_x, _y, w, _h) in &rects {
1874 assert!(*w > 0.0, "empty width: {rects:?}");
1875 }
1876 }
1877
1878 #[test]
1879 fn selection_rects_for_single_line_range_do_not_highlight_other_lines() {
1880 let text = "alpha\nbeta\ngamma";
1881 let lo = text.find("et").unwrap();
1882 let hi = lo + "et".len();
1883 let rects = selection_rects(
1884 text,
1885 lo,
1886 hi,
1887 16.0,
1888 FontWeight::Regular,
1889 TextWrap::NoWrap,
1890 None,
1891 );
1892 assert_eq!(
1893 rects.len(),
1894 1,
1895 "single-line range should only highlight that line: {rects:?}"
1896 );
1897 let line_h = line_height(16.0);
1898 let y = rects[0].1;
1899 assert!(
1900 (y - line_h).abs() < 0.01,
1901 "expected second line y={line_h}, got {y}; rects={rects:?}"
1902 );
1903 }
1904
1905 #[test]
1906 fn visual_line_byte_range_respects_soft_wraps() {
1907 let text = "alpha beta gamma";
1908 let beta = text.find("beta").unwrap();
1909 let width = line_width("alpha", 16.0, FontWeight::Regular, false) + 2.0;
1910 let (lo, hi) = visual_line_byte_range(
1911 text,
1912 beta,
1913 16.0,
1914 FontWeight::Regular,
1915 TextWrap::Wrap,
1916 Some(width),
1917 );
1918 assert!(
1919 lo > 0 && hi < text.len(),
1920 "soft-wrapped visual line should be narrower than the hard line: {lo}..{hi}"
1921 );
1922 assert!(
1923 (lo..hi).contains(&beta),
1924 "range {lo}..{hi} should contain beta byte {beta}"
1925 );
1926 }
1927
1928 #[test]
1929 fn selection_rects_empty_for_collapsed_range() {
1930 let rects = selection_rects(
1931 "alpha",
1932 2,
1933 2,
1934 16.0,
1935 FontWeight::Regular,
1936 TextWrap::NoWrap,
1937 None,
1938 );
1939 assert!(rects.is_empty());
1940 }
1941
1942 #[test]
1943 fn proportional_layout_uses_cosmic_shaping_widths() {
1944 let layout = layout_text(
1945 "Roboto shaping",
1946 18.0,
1947 FontWeight::Medium,
1948 false,
1949 TextWrap::NoWrap,
1950 None,
1951 );
1952
1953 assert_eq!(layout.lines.len(), 1);
1954 assert!((layout.lines[0].width - layout.width).abs() < 0.01);
1955 assert!(layout.lines[0].baseline > layout.lines[0].y);
1956 }
1957
1958 #[test]
1959 fn ellipsize_text_shortens_to_available_width() {
1960 let source = "this is a long branch name";
1961 let available = line_width("this is a…", 14.0, FontWeight::Regular, false);
1962 let clipped = ellipsize_text(source, 14.0, FontWeight::Regular, false, available);
1963 let width = line_width(&clipped, 14.0, FontWeight::Regular, false);
1964
1965 assert!(clipped.ends_with('…'), "clipped={clipped}");
1966 assert!(clipped.len() < source.len());
1967 assert!(
1968 width <= available + 0.5,
1969 "width={width} available={available}"
1970 );
1971 }
1972
1973 #[test]
1974 fn ellipsize_text_keeps_fitting_text_unchanged() {
1975 let source = "short";
1976 let available = line_width(source, 14.0, FontWeight::Regular, false) + 4.0;
1977 assert_eq!(
1978 ellipsize_text(source, 14.0, FontWeight::Regular, false, available),
1979 source
1980 );
1981 }
1982
1983 #[test]
1984 fn clamp_text_to_lines_caps_wrapped_text_with_final_ellipsis() {
1985 let source = "alpha beta gamma delta epsilon zeta";
1986 let available = line_width("alpha beta", 14.0, FontWeight::Regular, false);
1987 let clamped = clamp_text_to_lines(source, 14.0, FontWeight::Regular, false, available, 2);
1988 let layout = layout_text(
1989 &clamped,
1990 14.0,
1991 FontWeight::Regular,
1992 false,
1993 TextWrap::Wrap,
1994 Some(available),
1995 );
1996
1997 assert!(clamped.ends_with('…'), "clamped={clamped}");
1998 assert!(layout.lines.len() <= 2, "layout={layout:?}");
1999 }
2000}