1#![warn(missing_docs)]
11
12use crate::tokens;
13use crate::tree::{FontFamily, FontWeight, TextWrap};
14use cosmic_text::{
15 Attrs, Buffer, Cursor, Family, FeatureTag, FontFeatures, FontSystem, Metrics, Shaping, Weight,
16 Wrap, fontdb,
17};
18use lru::LruCache;
19use std::cell::RefCell;
20use std::num::NonZeroUsize;
21
22const MONO_CHAR_WIDTH_FACTOR: f32 = 0.60;
25
26fn shaping_family(family: FontFamily, mono: bool) -> FontFamily {
31 if mono {
32 FontFamily::JetBrainsMono
33 } else {
34 family
35 }
36}
37
38const BASELINE_MULTIPLIER: f32 = 0.93;
39
40#[derive(Clone, Debug, PartialEq)]
42pub struct TextLine {
43 pub text: String,
45 pub width: f32,
47 pub y: f32,
49 pub baseline: f32,
51 pub rtl: bool,
53}
54
55#[derive(Clone, Debug, PartialEq)]
59pub struct TextLayout {
60 pub lines: Vec<TextLine>,
62 pub width: f32,
64 pub height: f32,
67 pub line_height: f32,
69}
70
71impl TextLayout {
72 pub fn line_count(&self) -> usize {
74 self.lines.len().max(1)
75 }
76
77 pub fn measured(&self) -> MeasuredText {
79 MeasuredText {
80 width: self.width,
81 height: self.height,
82 line_count: self.line_count(),
83 }
84 }
85}
86
87#[derive(Clone, Debug, PartialEq)]
90pub struct MeasuredText {
91 pub width: f32,
93 pub height: f32,
95 pub line_count: usize,
97}
98
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
105pub struct TextLayoutCacheStats {
106 pub hits: u64,
108 pub misses: u64,
110 pub evictions: u64,
112 pub shaped_bytes: u64,
114}
115
116#[derive(Clone, Debug, PartialEq)]
126pub struct TextGeometry<'a> {
127 text: &'a str,
128 size: f32,
129 family: FontFamily,
130 weight: FontWeight,
131 mono: bool,
132 tabular: bool,
133 wrap: TextWrap,
134 available_width: Option<f32>,
135 layout: TextLayout,
136}
137
138impl<'a> TextGeometry<'a> {
139 pub fn new(
143 text: &'a str,
144 size: f32,
145 weight: FontWeight,
146 mono: bool,
147 wrap: TextWrap,
148 available_width: Option<f32>,
149 ) -> Self {
150 Self::new_with_family(
151 text,
152 size,
153 FontFamily::default(),
154 weight,
155 mono,
156 wrap,
157 available_width,
158 )
159 }
160
161 pub fn new_with_family(
163 text: &'a str,
164 size: f32,
165 family: FontFamily,
166 weight: FontWeight,
167 mono: bool,
168 wrap: TextWrap,
169 available_width: Option<f32>,
170 ) -> Self {
171 let layout = layout_text_with_family(
172 text,
173 size,
174 family,
175 weight,
176 mono,
177 false,
178 wrap,
179 available_width,
180 );
181 Self {
182 text,
183 size,
184 family,
185 weight,
186 mono,
187 tabular: false,
188 wrap,
189 available_width,
190 layout,
191 }
192 }
193
194 pub fn tabular_numerals(mut self) -> Self {
199 self.tabular = true;
200 self.layout = layout_text_with_family(
201 self.text,
202 self.size,
203 self.family,
204 self.weight,
205 self.mono,
206 true,
207 self.wrap,
208 self.available_width,
209 );
210 self
211 }
212
213 pub fn text(&self) -> &'a str {
215 self.text
216 }
217
218 pub fn layout(&self) -> &TextLayout {
220 &self.layout
221 }
222
223 pub fn measured(&self) -> MeasuredText {
225 self.layout.measured()
226 }
227
228 pub fn line_height(&self) -> f32 {
230 self.layout.line_height
231 }
232
233 pub fn width(&self) -> f32 {
235 self.layout.width
236 }
237
238 pub fn height(&self) -> f32 {
240 self.layout.height
241 }
242
243 pub fn hit(&self, x: f32, y: f32) -> Option<TextHit> {
246 hit_text_impl(
247 self.text,
248 self.size,
249 self.family,
250 self.weight,
251 self.tabular,
252 self.wrap,
253 self.available_width,
254 x,
255 y,
256 )
257 }
258
259 pub fn hit_byte(&self, x: f32, y: f32) -> Option<usize> {
264 let hit = self.hit(x, y)?;
265 Some(self.byte_from_line_position(hit.line, hit.byte_index))
266 }
267
268 pub fn caret_xy(&self, byte_index: usize) -> (f32, f32) {
271 caret_xy_impl(
272 self.text,
273 byte_index,
274 self.size,
275 self.family,
276 self.weight,
277 self.tabular,
278 self.wrap,
279 self.available_width,
280 )
281 }
282
283 pub fn prefix_width(&self, byte_index: usize) -> f32 {
287 self.caret_xy(byte_index).0
288 }
289
290 pub fn selection_rects(&self, lo: usize, hi: usize) -> Vec<(f32, f32, f32, f32)> {
293 selection_rects_impl(
294 self.text,
295 lo,
296 hi,
297 self.size,
298 self.family,
299 self.weight,
300 self.tabular,
301 self.wrap,
302 self.available_width,
303 )
304 }
305
306 fn byte_from_line_position(&self, line: usize, byte_in_line: usize) -> usize {
307 line_position_to_byte(self.text, line, byte_in_line)
308 }
309}
310
311pub fn measure_text(
314 text: &str,
315 size: f32,
316 weight: FontWeight,
317 mono: bool,
318 wrap: TextWrap,
319 available_width: Option<f32>,
320) -> MeasuredText {
321 layout_text(text, size, weight, mono, wrap, available_width).measured()
322}
323
324pub fn layout_text(
328 text: &str,
329 size: f32,
330 weight: FontWeight,
331 mono: bool,
332 wrap: TextWrap,
333 available_width: Option<f32>,
334) -> TextLayout {
335 layout_text_with_family(
336 text,
337 size,
338 FontFamily::default(),
339 weight,
340 mono,
341 false,
342 wrap,
343 available_width,
344 )
345}
346
347#[allow(clippy::too_many_arguments)]
349pub fn layout_text_with_family(
350 text: &str,
351 size: f32,
352 family: FontFamily,
353 weight: FontWeight,
354 mono: bool,
355 tabular: bool,
356 wrap: TextWrap,
357 available_width: Option<f32>,
358) -> TextLayout {
359 layout_text_with_line_height_and_family(
360 text,
361 size,
362 line_height(size),
363 family,
364 weight,
365 mono,
366 tabular,
367 0.0,
368 wrap,
369 available_width,
370 )
371}
372
373#[allow(clippy::too_many_arguments)]
377pub fn layout_text_with_line_height(
378 text: &str,
379 size: f32,
380 line_height: f32,
381 weight: FontWeight,
382 mono: bool,
383 wrap: TextWrap,
384 available_width: Option<f32>,
385) -> TextLayout {
386 layout_text_with_line_height_and_family(
387 text,
388 size,
389 line_height,
390 FontFamily::default(),
391 weight,
392 mono,
393 false,
394 0.0,
395 wrap,
396 available_width,
397 )
398}
399
400#[allow(clippy::too_many_arguments)]
405pub fn layout_text_with_line_height_and_family(
406 text: &str,
407 size: f32,
408 line_height: f32,
409 family: FontFamily,
410 weight: FontWeight,
411 mono: bool,
412 tabular: bool,
413 letter_spacing: f32,
414 wrap: TextWrap,
415 available_width: Option<f32>,
416) -> TextLayout {
417 let cached = SHAPE_KEY_SCRATCH.with_borrow_mut(|key| {
432 key.text.clear();
433 key.text.push_str(text);
434 key.size_bits = size.to_bits();
435 key.line_height_bits = line_height.to_bits();
436 key.family = family;
437 key.weight = weight;
438 key.mono = mono;
439 key.tabular = tabular;
440 key.letter_spacing_bits = letter_spacing.to_bits();
441 key.wrap = wrap;
442 key.available_width_bits = available_width.map(f32::to_bits);
443 SHAPE_CACHE.with_borrow_mut(|c| c.get(key).cloned())
444 });
445 if let Some(cached) = cached {
446 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
447 stats.hits += 1;
448 });
449 return cached;
450 }
451 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
452 stats.misses += 1;
453 stats.shaped_bytes += text.len() as u64;
454 });
455 let layout = layout_text_uncached(
456 text,
457 size,
458 line_height,
459 family,
460 weight,
461 mono,
462 tabular,
463 letter_spacing,
464 wrap,
465 available_width,
466 );
467 let key = ShapeKey {
468 text: text.to_owned(),
469 size_bits: size.to_bits(),
470 line_height_bits: line_height.to_bits(),
471 family,
472 weight,
473 mono,
474 tabular,
475 letter_spacing_bits: letter_spacing.to_bits(),
476 wrap,
477 available_width_bits: available_width.map(f32::to_bits),
478 };
479 SHAPE_CACHE.with_borrow_mut(|c| {
480 if c.len() == SHAPE_CACHE_CAPACITY {
481 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
482 stats.evictions += 1;
483 });
484 }
485 c.put(key, layout.clone());
486 });
487 layout
488}
489
490#[allow(clippy::too_many_arguments)]
491fn layout_text_uncached(
492 text: &str,
493 size: f32,
494 line_height: f32,
495 family: FontFamily,
496 weight: FontWeight,
497 mono: bool,
498 tabular: bool,
499 letter_spacing: f32,
500 wrap: TextWrap,
501 available_width: Option<f32>,
502) -> TextLayout {
503 if let Some(layout) = layout_text_cosmic(
508 text,
509 size,
510 line_height,
511 shaping_family(family, mono),
512 weight,
513 tabular,
514 letter_spacing,
515 wrap,
516 available_width,
517 ) {
518 return layout;
519 }
520
521 let raw_lines = match (wrap, available_width) {
522 (TextWrap::Wrap, Some(width)) => {
523 wrap_lines_by_width(text, width, size, family, weight, mono)
524 }
525 _ => text.split('\n').map(str::to_string).collect(),
526 };
527 build_layout(raw_lines, size, line_height, family, weight, mono)
528}
529
530pub fn ellipsize_text(
533 text: &str,
534 size: f32,
535 weight: FontWeight,
536 mono: bool,
537 available_width: f32,
538) -> String {
539 ellipsize_text_with_family(
540 text,
541 size,
542 FontFamily::default(),
543 weight,
544 mono,
545 false,
546 available_width,
547 )
548}
549
550#[allow(clippy::too_many_arguments)]
552pub fn ellipsize_text_with_family(
553 text: &str,
554 size: f32,
555 family: FontFamily,
556 weight: FontWeight,
557 mono: bool,
558 tabular: bool,
559 available_width: f32,
560) -> String {
561 if available_width <= 0.0 || text.is_empty() {
562 return String::new();
563 }
564 let full = layout_text_with_family(
565 text,
566 size,
567 family,
568 weight,
569 mono,
570 tabular,
571 TextWrap::NoWrap,
572 None,
573 );
574 if full.width <= available_width + 0.5 {
575 return text.to_string();
576 }
577
578 let ellipsis = "…";
579 let ellipsis_w = layout_text_with_family(
580 ellipsis,
581 size,
582 family,
583 weight,
584 mono,
585 tabular,
586 TextWrap::NoWrap,
587 None,
588 )
589 .width;
590 if ellipsis_w > available_width + 0.5 {
591 return ellipsis.to_string();
592 }
593
594 let chars: Vec<char> = text.chars().collect();
595 let mut lo = 0usize;
596 let mut hi = chars.len();
597 while lo < hi {
598 let mid = (lo + hi).div_ceil(2);
599 let candidate: String = chars[..mid].iter().collect();
600 let candidate = format!("{candidate}{ellipsis}");
601 let width = layout_text_with_family(
602 &candidate,
603 size,
604 family,
605 weight,
606 mono,
607 tabular,
608 TextWrap::NoWrap,
609 None,
610 )
611 .width;
612 if width <= available_width + 0.5 {
613 lo = mid;
614 } else {
615 hi = mid - 1;
616 }
617 }
618
619 let prefix: String = chars[..lo].iter().collect();
620 format!("{prefix}{ellipsis}")
621}
622
623pub fn clamp_text_to_lines(
626 text: &str,
627 size: f32,
628 weight: FontWeight,
629 mono: bool,
630 available_width: f32,
631 max_lines: usize,
632) -> String {
633 clamp_text_to_lines_with_family(
634 text,
635 size,
636 FontFamily::default(),
637 weight,
638 mono,
639 available_width,
640 max_lines,
641 )
642}
643
644pub fn clamp_text_to_lines_with_family(
646 text: &str,
647 size: f32,
648 family: FontFamily,
649 weight: FontWeight,
650 mono: bool,
651 available_width: f32,
652 max_lines: usize,
653) -> String {
654 if text.is_empty() || available_width <= 0.0 || max_lines == 0 {
655 return String::new();
656 }
657
658 let layout = layout_text_with_family(
659 text,
660 size,
661 family,
662 weight,
663 mono,
664 false,
665 TextWrap::Wrap,
666 Some(available_width),
667 );
668 if layout.lines.len() <= max_lines {
669 return text.to_string();
670 }
671
672 let mut lines: Vec<String> = layout
673 .lines
674 .iter()
675 .take(max_lines)
676 .map(|line| line.text.clone())
677 .collect();
678 if let Some(last) = lines.last_mut() {
679 let marked = format!("{last}…");
680 *last =
681 ellipsize_text_with_family(&marked, size, family, weight, mono, false, available_width);
682 }
683 lines.join("\n")
684}
685
686#[derive(Clone, Copy, Debug, PartialEq, Eq)]
690pub struct TextHit {
691 pub line: usize,
696 pub byte_index: usize,
699}
700
701pub fn hit_text(
712 text: &str,
713 size: f32,
714 weight: FontWeight,
715 wrap: TextWrap,
716 available_width: Option<f32>,
717 x: f32,
718 y: f32,
719) -> Option<TextHit> {
720 hit_text_with_family(
721 text,
722 size,
723 FontFamily::default(),
724 weight,
725 wrap,
726 available_width,
727 x,
728 y,
729 )
730}
731
732#[allow(clippy::too_many_arguments)]
734pub fn hit_text_with_family(
735 text: &str,
736 size: f32,
737 family: FontFamily,
738 weight: FontWeight,
739 wrap: TextWrap,
740 available_width: Option<f32>,
741 x: f32,
742 y: f32,
743) -> Option<TextHit> {
744 hit_text_impl(
745 text,
746 size,
747 family,
748 weight,
749 false,
750 wrap,
751 available_width,
752 x,
753 y,
754 )
755}
756
757#[allow(clippy::too_many_arguments)]
758fn hit_text_impl(
759 text: &str,
760 size: f32,
761 family: FontFamily,
762 weight: FontWeight,
763 tabular: bool,
764 wrap: TextWrap,
765 available_width: Option<f32>,
766 x: f32,
767 y: f32,
768) -> Option<TextHit> {
769 with_font_system(|font_system| {
770 let buffer = build_buffer(
771 font_system,
772 text,
773 size,
774 family,
775 weight,
776 tabular,
777 wrap,
778 available_width,
779 );
780 let cursor = buffer.hit(x, y)?;
781 Some(TextHit {
782 line: cursor.line,
783 byte_index: cursor.index,
784 })
785 })
786}
787
788pub fn caret_xy(
800 text: &str,
801 byte_index: usize,
802 size: f32,
803 weight: FontWeight,
804 wrap: TextWrap,
805 available_width: Option<f32>,
806) -> (f32, f32) {
807 caret_xy_with_family(
808 text,
809 byte_index,
810 size,
811 FontFamily::default(),
812 weight,
813 wrap,
814 available_width,
815 )
816}
817
818pub fn caret_xy_with_family(
820 text: &str,
821 byte_index: usize,
822 size: f32,
823 family: FontFamily,
824 weight: FontWeight,
825 wrap: TextWrap,
826 available_width: Option<f32>,
827) -> (f32, f32) {
828 caret_xy_impl(
829 text,
830 byte_index,
831 size,
832 family,
833 weight,
834 false,
835 wrap,
836 available_width,
837 )
838}
839
840#[allow(clippy::too_many_arguments)]
841fn caret_xy_impl(
842 text: &str,
843 byte_index: usize,
844 size: f32,
845 family: FontFamily,
846 weight: FontWeight,
847 tabular: bool,
848 wrap: TextWrap,
849 available_width: Option<f32>,
850) -> (f32, f32) {
851 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
852 with_font_system(|font_system| {
853 let line_h = line_height(size);
854 let buffer = build_buffer(
855 font_system,
856 text,
857 size,
858 family,
859 weight,
860 tabular,
861 wrap,
862 available_width,
863 );
864 let cursor = Cursor::new(target_line, byte_in_line);
865 if let Some((x, y)) = buffer.cursor_position(&cursor) {
869 return (x, y);
870 }
871 (0.0, target_line as f32 * line_h)
874 })
875}
876
877pub fn selection_rects(
885 text: &str,
886 lo: usize,
887 hi: usize,
888 size: f32,
889 weight: FontWeight,
890 wrap: TextWrap,
891 available_width: Option<f32>,
892) -> Vec<(f32, f32, f32, f32)> {
893 selection_rects_with_family(
894 text,
895 lo,
896 hi,
897 size,
898 FontFamily::default(),
899 weight,
900 wrap,
901 available_width,
902 )
903}
904
905#[allow(clippy::too_many_arguments)]
907pub fn selection_rects_with_family(
908 text: &str,
909 lo: usize,
910 hi: usize,
911 size: f32,
912 family: FontFamily,
913 weight: FontWeight,
914 wrap: TextWrap,
915 available_width: Option<f32>,
916) -> Vec<(f32, f32, f32, f32)> {
917 selection_rects_impl(
918 text,
919 lo,
920 hi,
921 size,
922 family,
923 weight,
924 false,
925 wrap,
926 available_width,
927 )
928}
929
930#[allow(clippy::too_many_arguments)]
931fn selection_rects_impl(
932 text: &str,
933 lo: usize,
934 hi: usize,
935 size: f32,
936 family: FontFamily,
937 weight: FontWeight,
938 tabular: bool,
939 wrap: TextWrap,
940 available_width: Option<f32>,
941) -> Vec<(f32, f32, f32, f32)> {
942 if lo >= hi {
943 return Vec::new();
944 }
945 let (lo_line, lo_in_line) = byte_to_line_position(text, lo);
946 let (hi_line, hi_in_line) = byte_to_line_position(text, hi);
947 with_font_system(|font_system| {
948 let buffer = build_buffer(
949 font_system,
950 text,
951 size,
952 family,
953 weight,
954 tabular,
955 wrap,
956 available_width,
957 );
958 let c_lo = Cursor::new(lo_line, lo_in_line);
959 let c_hi = Cursor::new(hi_line, hi_in_line);
960 let mut rects = Vec::new();
961 for run in buffer.layout_runs() {
962 if run.line_i < lo_line || run.line_i > hi_line {
963 continue;
964 }
965 for (x, w) in run.highlight(c_lo, c_hi) {
966 rects.push((x, run.line_top, w, run.line_height));
967 }
968 }
969 rects
970 })
971}
972
973pub fn visual_line_byte_range(
979 text: &str,
980 byte_index: usize,
981 size: f32,
982 weight: FontWeight,
983 wrap: TextWrap,
984 available_width: Option<f32>,
985) -> (usize, usize) {
986 visual_line_byte_range_with_family(
987 text,
988 byte_index,
989 size,
990 FontFamily::default(),
991 weight,
992 wrap,
993 available_width,
994 )
995}
996
997pub fn visual_line_byte_range_with_family(
1000 text: &str,
1001 byte_index: usize,
1002 size: f32,
1003 family: FontFamily,
1004 weight: FontWeight,
1005 wrap: TextWrap,
1006 available_width: Option<f32>,
1007) -> (usize, usize) {
1008 let byte_index = clamp_to_char_boundary(text, byte_index.min(text.len()));
1009 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
1010 let hard_line_start = line_position_to_byte(text, target_line, 0);
1011 let hard_line_end = line_end_byte(text, hard_line_start);
1012 with_font_system(|font_system| {
1013 let buffer = build_buffer(
1014 font_system,
1015 text,
1016 size,
1017 family,
1018 weight,
1019 false,
1020 wrap,
1021 available_width,
1022 );
1023 let mut last_range = None;
1024 for run in buffer.layout_runs() {
1025 if run.line_i != target_line {
1026 continue;
1027 }
1028 let Some((start, end)) = layout_run_byte_range(&run) else {
1029 continue;
1030 };
1031 last_range = Some((start, end));
1032 if start <= byte_in_line && byte_in_line < end {
1033 return (
1034 line_position_to_byte(text, target_line, start),
1035 line_position_to_byte(text, target_line, end),
1036 );
1037 }
1038 }
1039 if let Some((start, end)) = last_range
1040 && byte_index >= line_position_to_byte(text, target_line, start)
1041 {
1042 return (
1043 line_position_to_byte(text, target_line, start),
1044 line_position_to_byte(text, target_line, end),
1045 );
1046 }
1047 (hard_line_start, hard_line_end)
1048 })
1049}
1050
1051fn byte_to_line_position(text: &str, byte_index: usize) -> (usize, usize) {
1056 let byte_index = byte_index.min(text.len());
1057 let mut line = 0;
1058 let mut line_start = 0;
1059 for (i, ch) in text.char_indices() {
1060 if i >= byte_index {
1061 break;
1062 }
1063 if ch == '\n' {
1064 line += 1;
1065 line_start = i + ch.len_utf8();
1066 }
1067 }
1068 (line, byte_index - line_start)
1069}
1070
1071fn line_position_to_byte(text: &str, line: usize, byte_in_line: usize) -> usize {
1072 let mut current_line = 0;
1073 let mut line_start = 0;
1074 for (i, ch) in text.char_indices() {
1075 if current_line == line {
1076 let candidate = line_start + byte_in_line;
1077 return clamp_to_char_boundary(text, candidate.min(text.len()));
1078 }
1079 if ch == '\n' {
1080 current_line += 1;
1081 line_start = i + ch.len_utf8();
1082 }
1083 }
1084 if current_line == line {
1085 clamp_to_char_boundary(text, (line_start + byte_in_line).min(text.len()))
1086 } else {
1087 text.len()
1088 }
1089}
1090
1091fn line_end_byte(text: &str, line_start: usize) -> usize {
1092 text[line_start..]
1093 .find('\n')
1094 .map(|i| line_start + i)
1095 .unwrap_or(text.len())
1096}
1097
1098fn layout_run_byte_range(run: &cosmic_text::LayoutRun<'_>) -> Option<(usize, usize)> {
1099 let start = run.glyphs.iter().map(|glyph| glyph.start).min()?;
1100 let end = run
1101 .glyphs
1102 .iter()
1103 .map(|glyph| glyph.end)
1104 .max()
1105 .unwrap_or(start);
1106 Some((start, end))
1107}
1108
1109fn clamp_to_char_boundary(text: &str, mut byte: usize) -> usize {
1110 byte = byte.min(text.len());
1111 while byte > 0 && !text.is_char_boundary(byte) {
1112 byte -= 1;
1113 }
1114 byte
1115}
1116
1117#[allow(clippy::too_many_arguments)]
1118fn build_buffer(
1119 font_system: &mut FontSystem,
1120 text: &str,
1121 size: f32,
1122 family: FontFamily,
1123 weight: FontWeight,
1124 tabular: bool,
1125 wrap: TextWrap,
1126 available_width: Option<f32>,
1127) -> Buffer {
1128 let line_h = line_height(size);
1129 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_h));
1130 buffer.set_wrap(match wrap {
1131 TextWrap::NoWrap => Wrap::None,
1132 TextWrap::Wrap => Wrap::WordOrGlyph,
1133 });
1134 buffer.set_size(
1135 match wrap {
1136 TextWrap::NoWrap => None,
1137 TextWrap::Wrap => available_width,
1138 },
1139 None,
1140 );
1141 let mut attrs = Attrs::new()
1142 .family(Family::Name(family.family_name()))
1143 .weight(cosmic_weight(weight));
1144 if tabular {
1145 attrs = attrs.font_features(tabular_features());
1146 }
1147 buffer.set_text(text, &attrs, Shaping::Advanced, None);
1148 buffer.shape_until_scroll(font_system, false);
1149 buffer
1150}
1151
1152pub fn wrap_lines(
1156 text: &str,
1157 max_width: f32,
1158 size: f32,
1159 weight: FontWeight,
1160 mono: bool,
1161) -> Vec<String> {
1162 wrap_lines_with_family(text, max_width, size, FontFamily::default(), weight, mono)
1163}
1164
1165pub fn wrap_lines_with_family(
1167 text: &str,
1168 max_width: f32,
1169 size: f32,
1170 family: FontFamily,
1171 weight: FontWeight,
1172 mono: bool,
1173) -> Vec<String> {
1174 if let Some(layout) = layout_text_cosmic(
1175 text,
1176 size,
1177 line_height(size),
1178 shaping_family(family, mono),
1179 weight,
1180 false,
1181 0.0,
1182 TextWrap::Wrap,
1183 Some(max_width),
1184 ) {
1185 return layout.lines.into_iter().map(|line| line.text).collect();
1186 }
1187 wrap_lines_by_width(text, max_width, size, family, weight, mono)
1188}
1189
1190fn wrap_lines_by_width(
1191 text: &str,
1192 max_width: f32,
1193 size: f32,
1194 family: FontFamily,
1195 weight: FontWeight,
1196 mono: bool,
1197) -> Vec<String> {
1198 if max_width <= 0.0 {
1199 return vec![String::new()];
1200 }
1201
1202 let ctx = WrapMeasure {
1203 max_width,
1204 size,
1205 family,
1206 weight,
1207 mono,
1208 };
1209 let mut out = Vec::new();
1210 for paragraph in text.split('\n') {
1211 if paragraph.is_empty() {
1212 out.push(String::new());
1213 continue;
1214 }
1215
1216 let mut line = String::new();
1217 for word in paragraph.split_whitespace() {
1218 if line.is_empty() {
1219 push_word_wrapped(&mut out, &mut line, word, ctx);
1220 continue;
1221 }
1222
1223 let candidate = format!("{line} {word}");
1224 if line_width_with_family(&candidate, size, family, weight, mono) <= max_width {
1225 line = candidate;
1226 } else {
1227 out.push(std::mem::take(&mut line));
1228 push_word_wrapped(&mut out, &mut line, word, ctx);
1229 }
1230 }
1231
1232 if !line.is_empty() {
1233 out.push(line);
1234 }
1235 }
1236
1237 if out.is_empty() {
1238 out.push(String::new());
1239 }
1240 out
1241}
1242
1243pub fn line_width(text: &str, size: f32, weight: FontWeight, mono: bool) -> f32 {
1246 line_width_with_family(text, size, FontFamily::default(), weight, mono)
1247}
1248
1249pub fn line_width_with_family(
1251 text: &str,
1252 size: f32,
1253 family: FontFamily,
1254 weight: FontWeight,
1255 mono: bool,
1256) -> f32 {
1257 if let Some(layout) = layout_text_cosmic(
1258 text,
1259 size,
1260 line_height(size),
1261 shaping_family(family, mono),
1262 weight,
1263 false,
1264 0.0,
1265 TextWrap::NoWrap,
1266 None,
1267 ) {
1268 return layout.width;
1269 }
1270 line_width_by_ttf(text, size, family, weight, mono)
1271}
1272
1273fn line_width_by_ttf(
1274 text: &str,
1275 size: f32,
1276 family: FontFamily,
1277 weight: FontWeight,
1278 mono: bool,
1279) -> f32 {
1280 if mono {
1281 return text
1282 .chars()
1283 .filter(|c| *c != '\n' && *c != '\r')
1284 .map(|c| if c == '\t' { 4.0 } else { 1.0 })
1285 .sum::<f32>()
1286 * size
1287 * MONO_CHAR_WIDTH_FACTOR;
1288 }
1289
1290 let Ok(face) = ttf_parser::Face::parse(font_bytes(family, weight), 0) else {
1291 return fallback_line_width(text, size, mono);
1292 };
1293 let scale = size / face.units_per_em() as f32;
1294 let fallback_advance = face.units_per_em() as f32 * 0.5;
1295 let mut width = 0.0;
1296 let mut prev = None;
1297
1298 for c in text.chars() {
1299 if c == '\n' || c == '\r' {
1300 continue;
1301 }
1302 if c == '\t' {
1303 width += line_width_with_family(" ", size, family, weight, mono);
1304 prev = None;
1305 continue;
1306 }
1307
1308 let Some(glyph) = glyph_for(&face, c) else {
1309 continue;
1310 };
1311 if let Some(left) = prev {
1312 width += kern(&face, left, glyph) * scale;
1313 }
1314 width += face
1315 .glyph_hor_advance(glyph)
1316 .map(|advance| advance as f32)
1317 .unwrap_or(fallback_advance)
1318 * scale;
1319 prev = Some(glyph);
1320 }
1321 width
1322}
1323
1324pub fn line_height(size: f32) -> f32 {
1326 tokens::line_height_for_size(size)
1331}
1332
1333fn build_layout(
1334 lines: Vec<String>,
1335 size: f32,
1336 line_height: f32,
1337 family: FontFamily,
1338 weight: FontWeight,
1339 mono: bool,
1340) -> TextLayout {
1341 let raw_lines = if lines.is_empty() {
1342 vec![String::new()]
1343 } else {
1344 lines
1345 };
1346 let lines: Vec<TextLine> = raw_lines
1347 .into_iter()
1348 .enumerate()
1349 .map(|(i, text)| {
1350 let y = i as f32 * line_height;
1351 TextLine {
1352 width: line_width_with_family(&text, size, family, weight, mono),
1353 text,
1354 y,
1355 baseline: y + size * BASELINE_MULTIPLIER,
1356 rtl: false,
1357 }
1358 })
1359 .collect();
1360 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1361 TextLayout {
1362 width,
1363 height: lines.len().max(1) as f32 * line_height,
1364 line_height,
1365 lines,
1366 }
1367}
1368
1369pub(crate) fn tabular_features() -> FontFeatures {
1373 let mut features = FontFeatures::new();
1374 features.set(FeatureTag::new(b"tnum"), 1);
1375 features
1376}
1377
1378#[allow(clippy::too_many_arguments)]
1379fn layout_text_cosmic(
1380 text: &str,
1381 size: f32,
1382 line_height: f32,
1383 family: FontFamily,
1384 weight: FontWeight,
1385 tabular: bool,
1386 letter_spacing: f32,
1387 wrap: TextWrap,
1388 available_width: Option<f32>,
1389) -> Option<TextLayout> {
1390 let options = CosmicLayoutOptions {
1391 size,
1392 line_height,
1393 family,
1394 weight,
1395 tabular,
1396 letter_spacing,
1397 wrap,
1398 available_width,
1399 };
1400 with_font_system(|font_system| layout_text_cosmic_with(font_system, text, options))
1401}
1402
1403#[derive(Copy, Clone)]
1404struct CosmicLayoutOptions {
1405 size: f32,
1406 line_height: f32,
1407 family: FontFamily,
1408 weight: FontWeight,
1409 tabular: bool,
1410 letter_spacing: f32,
1411 wrap: TextWrap,
1412 available_width: Option<f32>,
1413}
1414
1415fn layout_text_cosmic_with(
1416 font_system: &mut FontSystem,
1417 text: &str,
1418 options: CosmicLayoutOptions,
1419) -> Option<TextLayout> {
1420 let CosmicLayoutOptions {
1421 size,
1422 line_height,
1423 family,
1424 weight,
1425 tabular,
1426 letter_spacing,
1427 wrap,
1428 available_width,
1429 } = options;
1430 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
1431 buffer.set_wrap(match wrap {
1432 TextWrap::NoWrap => Wrap::None,
1433 TextWrap::Wrap => Wrap::WordOrGlyph,
1434 });
1435 buffer.set_size(
1436 match wrap {
1437 TextWrap::NoWrap => None,
1438 TextWrap::Wrap => available_width,
1439 },
1440 None,
1441 );
1442 let mut attrs = Attrs::new()
1443 .family(Family::Name(family.family_name()))
1444 .weight(cosmic_weight(weight));
1445 if tabular {
1446 attrs = attrs.font_features(tabular_features());
1447 }
1448 if letter_spacing != 0.0 {
1449 attrs = attrs.letter_spacing(letter_spacing / size);
1453 }
1454 buffer.set_text(text, &attrs, Shaping::Advanced, None);
1455 buffer.shape_until_scroll(font_system, false);
1456
1457 let mut lines = Vec::new();
1458 let mut height: f32 = 0.0;
1459 for run in buffer.layout_runs() {
1460 height = height.max(run.line_top + run.line_height);
1461 lines.push(TextLine {
1462 text: layout_run_text(&run),
1463 width: run.line_w,
1464 y: run.line_top,
1465 baseline: run.line_y,
1466 rtl: run.rtl,
1467 });
1468 }
1469
1470 if lines.is_empty() {
1471 return None;
1472 }
1473
1474 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1475 Some(TextLayout {
1476 lines,
1477 width,
1478 height: height.max(line_height),
1479 line_height,
1480 })
1481}
1482
1483thread_local! {
1489 static FONT_SYSTEM: RefCell<FontSystem> = RefCell::new(bundled_font_system());
1490 static FONT_SYSTEM_REGISTRY_LOADED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1494}
1495
1496fn with_font_system<R>(f: impl FnOnce(&mut FontSystem) -> R) -> R {
1501 FONT_SYSTEM.with_borrow_mut(|font_system| {
1502 FONT_SYSTEM_REGISTRY_LOADED.with(|loaded| {
1503 let mut n = loaded.get();
1504 if crate::text::registry::sync_font_system(font_system, &mut n) {
1505 SHAPE_CACHE.with_borrow_mut(|cache| cache.clear());
1506 }
1507 loaded.set(n);
1508 });
1509 f(font_system)
1510 })
1511}
1512
1513#[cfg(test)]
1517pub(crate) fn font_system_face_count_for_tests() -> usize {
1518 with_font_system(|font_system| font_system.db().len())
1519}
1520
1521#[derive(Clone, PartialEq, Eq, Hash)]
1528struct ShapeKey {
1529 text: String,
1532 size_bits: u32,
1533 line_height_bits: u32,
1534 family: FontFamily,
1535 weight: FontWeight,
1536 mono: bool,
1537 tabular: bool,
1538 letter_spacing_bits: u32,
1539 wrap: TextWrap,
1540 available_width_bits: Option<u32>,
1541}
1542
1543const SHAPE_CACHE_CAPACITY: usize = 16_384;
1550thread_local! {
1551 static SHAPE_CACHE: RefCell<LruCache<ShapeKey, TextLayout>> =
1552 RefCell::new(LruCache::new(NonZeroUsize::new(SHAPE_CACHE_CAPACITY).unwrap()));
1553 static SHAPE_CACHE_STATS: RefCell<TextLayoutCacheStats> =
1554 RefCell::new(TextLayoutCacheStats::default());
1555 static SHAPE_KEY_SCRATCH: RefCell<ShapeKey> = RefCell::new(ShapeKey {
1558 text: String::new(),
1559 size_bits: 0,
1560 line_height_bits: 0,
1561 family: FontFamily::default(),
1562 weight: FontWeight::Regular,
1563 mono: false,
1564 tabular: false,
1565 letter_spacing_bits: 0,
1566 wrap: TextWrap::NoWrap,
1567 available_width_bits: None,
1568 });
1569}
1570
1571pub fn take_shape_cache_stats() -> TextLayoutCacheStats {
1575 SHAPE_CACHE_STATS.with_borrow_mut(std::mem::take)
1576}
1577
1578fn bundled_font_system() -> FontSystem {
1579 let mut db = fontdb::Database::new();
1580 db.set_sans_serif_family(FontFamily::default().family_name());
1581 for bytes in damascene_fonts::DEFAULT_FONTS {
1582 db.load_font_data(bytes.to_vec());
1583 }
1584 FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1585}
1586
1587fn cosmic_weight(weight: FontWeight) -> Weight {
1588 match weight {
1589 FontWeight::Regular => Weight::NORMAL,
1590 FontWeight::Medium => Weight::MEDIUM,
1591 FontWeight::Semibold => Weight::SEMIBOLD,
1592 FontWeight::Bold => Weight::BOLD,
1593 }
1594}
1595
1596fn layout_run_text(run: &cosmic_text::LayoutRun<'_>) -> String {
1597 let Some(start) = run.glyphs.iter().map(|glyph| glyph.start).min() else {
1598 return String::new();
1599 };
1600 let end = run
1601 .glyphs
1602 .iter()
1603 .map(|glyph| glyph.end)
1604 .max()
1605 .unwrap_or(start);
1606 run.text
1607 .get(start..end)
1608 .unwrap_or_default()
1609 .trim_end()
1610 .to_string()
1611}
1612
1613#[derive(Copy, Clone)]
1614struct WrapMeasure {
1615 max_width: f32,
1616 size: f32,
1617 family: FontFamily,
1618 weight: FontWeight,
1619 mono: bool,
1620}
1621
1622fn push_word_wrapped(out: &mut Vec<String>, line: &mut String, word: &str, ctx: WrapMeasure) {
1623 let WrapMeasure {
1624 max_width,
1625 size,
1626 family,
1627 weight,
1628 mono,
1629 } = ctx;
1630 if line_width_with_family(word, size, family, weight, mono) <= max_width {
1631 line.push_str(word);
1632 return;
1633 }
1634
1635 for ch in word.chars() {
1636 let candidate = format!("{line}{ch}");
1637 if !line.is_empty()
1638 && line_width_with_family(&candidate, size, family, weight, mono) > max_width
1639 {
1640 out.push(std::mem::take(line));
1641 }
1642 line.push(ch);
1643 }
1644}
1645
1646fn glyph_for(face: &ttf_parser::Face<'_>, c: char) -> Option<ttf_parser::GlyphId> {
1647 face.glyph_index(c)
1648 .or_else(|| face.glyph_index('\u{FFFD}'))
1649 .or_else(|| face.glyph_index('?'))
1650 .or_else(|| face.glyph_index(' '))
1651}
1652
1653fn kern(face: &ttf_parser::Face<'_>, left: ttf_parser::GlyphId, right: ttf_parser::GlyphId) -> f32 {
1654 let Some(kern) = &face.tables().kern else {
1655 return 0.0;
1656 };
1657 kern.subtables
1658 .into_iter()
1659 .filter(|subtable| subtable.horizontal && !subtable.has_cross_stream)
1660 .find_map(|subtable| subtable.glyphs_kerning(left, right))
1661 .map(|value| value as f32)
1662 .unwrap_or(0.0)
1663}
1664
1665fn font_bytes(family: FontFamily, weight: FontWeight) -> &'static [u8] {
1666 match family {
1670 FontFamily::Inter => {
1671 #[cfg(feature = "inter")]
1672 {
1673 let _ = weight;
1674 damascene_fonts::INTER_VARIABLE
1675 }
1676 #[cfg(not(feature = "inter"))]
1677 {
1678 let _ = weight;
1679 &[]
1680 }
1681 }
1682 FontFamily::Roboto => {
1683 #[cfg(feature = "roboto")]
1684 {
1685 match weight {
1686 FontWeight::Regular => damascene_fonts::ROBOTO_REGULAR,
1687 FontWeight::Medium => damascene_fonts::ROBOTO_MEDIUM,
1688 FontWeight::Semibold | FontWeight::Bold => damascene_fonts::ROBOTO_BOLD,
1689 }
1690 }
1691 #[cfg(not(feature = "roboto"))]
1692 {
1693 let _ = weight;
1694 &[]
1695 }
1696 }
1697 FontFamily::JetBrainsMono => {
1698 #[cfg(feature = "jetbrains-mono")]
1699 {
1700 let _ = weight;
1701 damascene_fonts::JETBRAINS_MONO_VARIABLE
1702 }
1703 #[cfg(not(feature = "jetbrains-mono"))]
1704 {
1705 let _ = weight;
1706 &[]
1707 }
1708 }
1709 }
1710}
1711
1712fn fallback_line_width(text: &str, size: f32, mono: bool) -> f32 {
1713 let char_w = size * if mono { MONO_CHAR_WIDTH_FACTOR } else { 0.60 };
1714 text.chars().count() as f32 * char_w
1715}
1716
1717#[cfg(test)]
1718mod tests {
1719 use super::*;
1720
1721 #[test]
1722 fn proportional_measurement_distinguishes_narrow_and_wide_glyphs() {
1723 let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, false);
1724 let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, false);
1725
1726 assert!(wide > narrow * 2.0, "wide={wide} narrow={narrow}");
1727 }
1728
1729 #[test]
1730 fn mono_measurement_uses_real_font_advances() {
1731 let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, true);
1736 let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, true);
1737 assert!(
1738 (narrow - wide).abs() < 0.5,
1739 "monospace: narrow={narrow} wide={wide}"
1740 );
1741
1742 let expected = 6.0 * 16.0 * 0.60;
1743 assert!(
1744 (narrow - expected).abs() < 1.0,
1745 "mono advance must be JetBrains Mono's 0.60em: \
1746 measured={narrow} expected={expected}"
1747 );
1748 }
1749
1750 #[test]
1751 fn letter_spacing_changes_shaped_advances() {
1752 let natural = layout_text_with_line_height_and_family(
1756 "Grumpy wizards make toxic brew",
1757 24.0,
1758 32.0,
1759 FontFamily::default(),
1760 FontWeight::Semibold,
1761 false,
1762 false,
1763 0.0,
1764 TextWrap::NoWrap,
1765 None,
1766 );
1767 let tight = layout_text_with_line_height_and_family(
1768 "Grumpy wizards make toxic brew",
1769 24.0,
1770 32.0,
1771 FontFamily::default(),
1772 FontWeight::Semibold,
1773 false,
1774 false,
1775 crate::tokens::TRACKING_TIGHT_EM * 24.0,
1776 TextWrap::NoWrap,
1777 None,
1778 );
1779 let delta = natural.width - tight.width;
1783 assert!(
1784 (8.0..30.0).contains(&delta),
1785 "tracking-tight must tighten by ~0.6px/glyph: natural {} vs tight {} (delta {delta})",
1786 natural.width,
1787 tight.width
1788 );
1789 }
1790
1791 #[test]
1792 fn tabular_numerals_equalize_digit_advances() {
1793 let width = |text: &str, tabular: bool| {
1794 layout_text_with_family(
1795 text,
1796 16.0,
1797 FontFamily::Inter,
1798 FontWeight::Regular,
1799 false,
1800 tabular,
1801 TextWrap::NoWrap,
1802 None,
1803 )
1804 .width
1805 };
1806 let one = width("11111111", true);
1809 let eight = width("88888888", true);
1810 assert!(
1811 (one - eight).abs() < 0.01,
1812 "tabular digits must align: 1s={one} 8s={eight}"
1813 );
1814 let one_prop = width("11111111", false);
1818 assert!(
1819 one > one_prop + 0.5,
1820 "tabular '1' should be wider than proportional: tnum={one} default={one_prop}"
1821 );
1822 }
1823
1824 #[cfg(feature = "roboto")]
1825 #[test]
1826 fn font_family_changes_proportional_measurement() {
1827 let roboto = line_width_with_family(
1828 "Save changes",
1829 14.0,
1830 FontFamily::Roboto,
1831 FontWeight::Semibold,
1832 false,
1833 );
1834 let inter = line_width_with_family(
1835 "Save changes",
1836 14.0,
1837 FontFamily::Inter,
1838 FontWeight::Semibold,
1839 false,
1840 );
1841
1842 assert!(
1843 (inter - roboto).abs() > 1.0,
1844 "inter={inter} roboto={roboto}"
1845 );
1846 }
1847
1848 #[test]
1849 fn wrap_lines_respects_measured_widths() {
1850 let lines = wrap_lines(
1851 "wide WWW words stay measured",
1852 120.0,
1853 16.0,
1854 FontWeight::Regular,
1855 false,
1856 );
1857
1858 assert!(lines.len() > 1);
1859 for line in lines {
1860 assert!(
1861 line_width(&line, 16.0, FontWeight::Regular, false) <= 121.0,
1862 "{line:?} overflowed"
1863 );
1864 }
1865 }
1866
1867 #[test]
1868 fn layout_text_carries_line_positions_and_measurement() {
1869 let layout = layout_text(
1870 "alpha beta gamma",
1871 16.0,
1872 FontWeight::Regular,
1873 false,
1874 TextWrap::Wrap,
1875 Some(80.0),
1876 );
1877
1878 assert!(layout.lines.len() > 1);
1879 assert_eq!(layout.measured().line_count, layout.lines.len());
1880 assert_eq!(layout.lines[0].y, 0.0);
1881 assert_eq!(layout.lines[1].y, layout.line_height);
1882 assert!(layout.lines[0].baseline > layout.lines[0].y);
1883 assert!(layout.height >= layout.line_height * 2.0);
1884 }
1885
1886 #[test]
1887 fn tokenized_line_heights_match_shadcn_scale() {
1888 assert_eq!(line_height(12.0), 16.0);
1889 assert_eq!(line_height(14.0), 20.0);
1890 assert_eq!(line_height(16.0), 24.0);
1891 assert_eq!(line_height(24.0), 32.0);
1892 assert_eq!(line_height(30.0), 36.0);
1893 }
1894
1895 #[test]
1896 fn hit_text_at_origin_lands_on_first_byte() {
1897 let hit = hit_text(
1898 "hello world",
1899 16.0,
1900 FontWeight::Regular,
1901 TextWrap::NoWrap,
1902 None,
1903 0.0,
1904 8.0,
1905 )
1906 .expect("hit at origin");
1907 assert_eq!(hit.line, 0);
1908 assert_eq!(hit.byte_index, 0);
1909 }
1910
1911 #[test]
1912 fn hit_text_past_last_glyph_clamps_to_end() {
1913 let text = "hello";
1914 let hit = hit_text(
1916 text,
1917 16.0,
1918 FontWeight::Regular,
1919 TextWrap::NoWrap,
1920 None,
1921 1000.0,
1922 8.0,
1923 )
1924 .expect("hit past end");
1925 assert_eq!(hit.line, 0);
1926 assert_eq!(hit.byte_index, text.len());
1927 }
1928
1929 #[test]
1930 fn hit_text_walks_columns_left_to_right() {
1931 let text = "abcdefghij";
1935 let mut prev = 0usize;
1936 for x in [4.0, 16.0, 32.0, 64.0, 96.0] {
1937 let hit = hit_text(
1938 text,
1939 16.0,
1940 FontWeight::Regular,
1941 TextWrap::NoWrap,
1942 None,
1943 x,
1944 8.0,
1945 );
1946 let Some(hit) = hit else { continue };
1947 assert!(
1948 hit.byte_index >= prev,
1949 "byte_index regressed at x={x}: {} < {prev}",
1950 hit.byte_index
1951 );
1952 prev = hit.byte_index;
1953 }
1954 }
1955
1956 #[test]
1957 fn text_geometry_hit_byte_maps_hard_line_offsets_to_source_bytes() {
1958 let text = "alpha\nbeta";
1959 let geometry = TextGeometry::new(
1960 text,
1961 16.0,
1962 FontWeight::Regular,
1963 false,
1964 TextWrap::NoWrap,
1965 None,
1966 );
1967 let y = geometry.line_height() * 1.5;
1968 let byte = geometry.hit_byte(1000.0, y).expect("hit on second line");
1969 assert_eq!(byte, text.len());
1970 }
1971
1972 #[test]
1973 fn text_geometry_prefix_width_matches_caret_x() {
1974 let text = "hello world";
1975 let geometry = TextGeometry::new(
1976 text,
1977 16.0,
1978 FontWeight::Regular,
1979 false,
1980 TextWrap::NoWrap,
1981 None,
1982 );
1983 let (x, _y) = geometry.caret_xy(5);
1984 assert!((geometry.prefix_width(5) - x).abs() < 0.01);
1985 }
1986
1987 #[test]
1988 fn caret_xy_at_origin_is_zero_zero() {
1989 let (x, y) = caret_xy(
1990 "hello",
1991 0,
1992 16.0,
1993 FontWeight::Regular,
1994 TextWrap::NoWrap,
1995 None,
1996 );
1997 assert!(x.abs() < 0.01, "x={x}");
1998 assert_eq!(y, 0.0);
1999 }
2000
2001 #[test]
2002 fn caret_xy_at_end_of_line_is_at_line_width() {
2003 let text = "hello";
2004 let width = line_width(text, 16.0, FontWeight::Regular, false);
2005 let (x, y) = caret_xy(
2006 text,
2007 text.len(),
2008 16.0,
2009 FontWeight::Regular,
2010 TextWrap::NoWrap,
2011 None,
2012 );
2013 assert!((x - width).abs() < 1.0, "x={x} expected~{width}");
2014 assert_eq!(y, 0.0);
2015 }
2016
2017 #[test]
2018 fn caret_xy_drops_to_next_line_after_newline() {
2019 let text = "foo\nbar";
2020 let line_h = line_height(16.0);
2021 let (x, y) = caret_xy(text, 4, 16.0, FontWeight::Regular, TextWrap::NoWrap, None);
2023 assert!(x.abs() < 0.01, "x={x}");
2024 assert!((y - line_h).abs() < 0.01, "y={y} expected~{line_h}");
2025 }
2026
2027 #[test]
2028 fn caret_xy_on_phantom_trailing_line_falls_below_text() {
2029 let text = "foo\n";
2030 let line_h = line_height(16.0);
2031 let (x, y) = caret_xy(
2032 text,
2033 text.len(),
2034 16.0,
2035 FontWeight::Regular,
2036 TextWrap::NoWrap,
2037 None,
2038 );
2039 assert!(x.abs() < 0.01, "x={x}");
2040 assert!(y >= line_h - 0.01, "y={y} expected ≥ line_h={line_h}");
2041 }
2042
2043 #[test]
2044 fn selection_rects_returns_one_per_visual_line() {
2045 let text = "alpha\nbeta\ngamma";
2046 let rects = selection_rects(
2047 text,
2048 0,
2049 text.len(),
2050 16.0,
2051 FontWeight::Regular,
2052 TextWrap::NoWrap,
2053 None,
2054 );
2055 assert_eq!(
2056 rects.len(),
2057 3,
2058 "expected one rect per BufferLine, got {rects:?}"
2059 );
2060 assert!(rects[0].1 < rects[1].1);
2062 assert!(rects[1].1 < rects[2].1);
2063 for (_x, _y, w, _h) in &rects {
2064 assert!(*w > 0.0, "empty width: {rects:?}");
2065 }
2066 }
2067
2068 #[test]
2069 fn selection_rects_for_single_line_range_do_not_highlight_other_lines() {
2070 let text = "alpha\nbeta\ngamma";
2071 let lo = text.find("et").unwrap();
2072 let hi = lo + "et".len();
2073 let rects = selection_rects(
2074 text,
2075 lo,
2076 hi,
2077 16.0,
2078 FontWeight::Regular,
2079 TextWrap::NoWrap,
2080 None,
2081 );
2082 assert_eq!(
2083 rects.len(),
2084 1,
2085 "single-line range should only highlight that line: {rects:?}"
2086 );
2087 let line_h = line_height(16.0);
2088 let y = rects[0].1;
2089 assert!(
2090 (y - line_h).abs() < 0.01,
2091 "expected second line y={line_h}, got {y}; rects={rects:?}"
2092 );
2093 }
2094
2095 #[test]
2096 fn visual_line_byte_range_respects_soft_wraps() {
2097 let text = "alpha beta gamma";
2098 let beta = text.find("beta").unwrap();
2099 let width = line_width("alpha", 16.0, FontWeight::Regular, false) + 2.0;
2100 let (lo, hi) = visual_line_byte_range(
2101 text,
2102 beta,
2103 16.0,
2104 FontWeight::Regular,
2105 TextWrap::Wrap,
2106 Some(width),
2107 );
2108 assert!(
2109 lo > 0 && hi < text.len(),
2110 "soft-wrapped visual line should be narrower than the hard line: {lo}..{hi}"
2111 );
2112 assert!(
2113 (lo..hi).contains(&beta),
2114 "range {lo}..{hi} should contain beta byte {beta}"
2115 );
2116 }
2117
2118 #[test]
2119 fn selection_rects_empty_for_collapsed_range() {
2120 let rects = selection_rects(
2121 "alpha",
2122 2,
2123 2,
2124 16.0,
2125 FontWeight::Regular,
2126 TextWrap::NoWrap,
2127 None,
2128 );
2129 assert!(rects.is_empty());
2130 }
2131
2132 #[test]
2133 fn proportional_layout_uses_cosmic_shaping_widths() {
2134 let layout = layout_text(
2135 "Roboto shaping",
2136 18.0,
2137 FontWeight::Medium,
2138 false,
2139 TextWrap::NoWrap,
2140 None,
2141 );
2142
2143 assert_eq!(layout.lines.len(), 1);
2144 assert!((layout.lines[0].width - layout.width).abs() < 0.01);
2145 assert!(layout.lines[0].baseline > layout.lines[0].y);
2146 }
2147
2148 #[test]
2149 fn ellipsize_text_shortens_to_available_width() {
2150 let source = "this is a long branch name";
2151 let available = line_width("this is a…", 14.0, FontWeight::Regular, false);
2152 let clipped = ellipsize_text(source, 14.0, FontWeight::Regular, false, available);
2153 let width = line_width(&clipped, 14.0, FontWeight::Regular, false);
2154
2155 assert!(clipped.ends_with('…'), "clipped={clipped}");
2156 assert!(clipped.len() < source.len());
2157 assert!(
2158 width <= available + 0.5,
2159 "width={width} available={available}"
2160 );
2161 }
2162
2163 #[test]
2164 fn ellipsize_text_keeps_fitting_text_unchanged() {
2165 let source = "short";
2166 let available = line_width(source, 14.0, FontWeight::Regular, false) + 4.0;
2167 assert_eq!(
2168 ellipsize_text(source, 14.0, FontWeight::Regular, false, available),
2169 source
2170 );
2171 }
2172
2173 #[test]
2174 fn clamp_text_to_lines_caps_wrapped_text_with_final_ellipsis() {
2175 let source = "alpha beta gamma delta epsilon zeta";
2176 let available = line_width("alpha beta", 14.0, FontWeight::Regular, false);
2177 let clamped = clamp_text_to_lines(source, 14.0, FontWeight::Regular, false, available, 2);
2178 let layout = layout_text(
2179 &clamped,
2180 14.0,
2181 FontWeight::Regular,
2182 false,
2183 TextWrap::Wrap,
2184 Some(available),
2185 );
2186
2187 assert!(clamped.ends_with('…'), "clamped={clamped}");
2188 assert!(layout.lines.len() <= 2, "layout={layout:?}");
2189 }
2190}