1use crate::tokens;
11use crate::tree::{FontFamily, FontWeight, TextWrap};
12use cosmic_text::{
13 Attrs, Buffer, Cursor, Family, FontSystem, Metrics, Shaping, Weight, Wrap, fontdb,
14};
15use lru::LruCache;
16use std::cell::RefCell;
17use std::num::NonZeroUsize;
18
19const MONO_CHAR_WIDTH_FACTOR: f32 = 0.62;
20
21const BASELINE_MULTIPLIER: f32 = 0.93;
22
23#[derive(Clone, Debug, PartialEq)]
24pub struct TextLine {
25 pub text: String,
26 pub width: f32,
27 pub y: f32,
29 pub baseline: f32,
31 pub rtl: bool,
33}
34
35#[derive(Clone, Debug, PartialEq)]
36pub struct TextLayout {
37 pub lines: Vec<TextLine>,
38 pub width: f32,
39 pub height: f32,
40 pub line_height: f32,
41}
42
43impl TextLayout {
44 pub fn line_count(&self) -> usize {
45 self.lines.len().max(1)
46 }
47
48 pub fn measured(&self) -> MeasuredText {
49 MeasuredText {
50 width: self.width,
51 height: self.height,
52 line_count: self.line_count(),
53 }
54 }
55}
56
57#[derive(Clone, Debug, PartialEq)]
58pub struct MeasuredText {
59 pub width: f32,
60 pub height: f32,
61 pub line_count: usize,
62}
63
64#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub struct TextLayoutCacheStats {
71 pub hits: u64,
72 pub misses: u64,
73 pub evictions: u64,
74 pub shaped_bytes: u64,
75}
76
77#[derive(Clone, Debug, PartialEq)]
87pub struct TextGeometry<'a> {
88 text: &'a str,
89 size: f32,
90 family: FontFamily,
91 weight: FontWeight,
92 mono: bool,
93 wrap: TextWrap,
94 available_width: Option<f32>,
95 layout: TextLayout,
96}
97
98impl<'a> TextGeometry<'a> {
99 pub fn new(
100 text: &'a str,
101 size: f32,
102 weight: FontWeight,
103 mono: bool,
104 wrap: TextWrap,
105 available_width: Option<f32>,
106 ) -> Self {
107 Self::new_with_family(
108 text,
109 size,
110 FontFamily::default(),
111 weight,
112 mono,
113 wrap,
114 available_width,
115 )
116 }
117
118 pub fn new_with_family(
119 text: &'a str,
120 size: f32,
121 family: FontFamily,
122 weight: FontWeight,
123 mono: bool,
124 wrap: TextWrap,
125 available_width: Option<f32>,
126 ) -> Self {
127 let layout =
128 layout_text_with_family(text, size, family, weight, mono, wrap, available_width);
129 Self {
130 text,
131 size,
132 family,
133 weight,
134 mono,
135 wrap,
136 available_width,
137 layout,
138 }
139 }
140
141 pub fn text(&self) -> &'a str {
142 self.text
143 }
144
145 pub fn layout(&self) -> &TextLayout {
146 &self.layout
147 }
148
149 pub fn measured(&self) -> MeasuredText {
150 self.layout.measured()
151 }
152
153 pub fn line_height(&self) -> f32 {
154 self.layout.line_height
155 }
156
157 pub fn width(&self) -> f32 {
158 self.layout.width
159 }
160
161 pub fn height(&self) -> f32 {
162 self.layout.height
163 }
164
165 pub fn hit(&self, x: f32, y: f32) -> Option<TextHit> {
166 hit_text_with_family(
167 self.text,
168 self.size,
169 self.family,
170 self.weight,
171 self.wrap,
172 self.available_width,
173 x,
174 y,
175 )
176 }
177
178 pub fn hit_byte(&self, x: f32, y: f32) -> Option<usize> {
183 let hit = self.hit(x, y)?;
184 Some(self.byte_from_line_position(hit.line, hit.byte_index))
185 }
186
187 pub fn caret_xy(&self, byte_index: usize) -> (f32, f32) {
188 caret_xy_with_family(
189 self.text,
190 byte_index,
191 self.size,
192 self.family,
193 self.weight,
194 self.wrap,
195 self.available_width,
196 )
197 }
198
199 pub fn prefix_width(&self, byte_index: usize) -> f32 {
203 self.caret_xy(byte_index).0
204 }
205
206 pub fn selection_rects(&self, lo: usize, hi: usize) -> Vec<(f32, f32, f32, f32)> {
207 selection_rects_with_family(
208 self.text,
209 lo,
210 hi,
211 self.size,
212 self.family,
213 self.weight,
214 self.wrap,
215 self.available_width,
216 )
217 }
218
219 fn byte_from_line_position(&self, line: usize, byte_in_line: usize) -> usize {
220 line_position_to_byte(self.text, line, byte_in_line)
221 }
222}
223
224pub fn measure_text(
227 text: &str,
228 size: f32,
229 weight: FontWeight,
230 mono: bool,
231 wrap: TextWrap,
232 available_width: Option<f32>,
233) -> MeasuredText {
234 layout_text(text, size, weight, mono, wrap, available_width).measured()
235}
236
237pub fn layout_text(
241 text: &str,
242 size: f32,
243 weight: FontWeight,
244 mono: bool,
245 wrap: TextWrap,
246 available_width: Option<f32>,
247) -> TextLayout {
248 layout_text_with_family(
249 text,
250 size,
251 FontFamily::default(),
252 weight,
253 mono,
254 wrap,
255 available_width,
256 )
257}
258
259pub fn layout_text_with_family(
261 text: &str,
262 size: f32,
263 family: FontFamily,
264 weight: FontWeight,
265 mono: bool,
266 wrap: TextWrap,
267 available_width: Option<f32>,
268) -> TextLayout {
269 layout_text_with_line_height_and_family(
270 text,
271 size,
272 line_height(size),
273 family,
274 weight,
275 mono,
276 wrap,
277 available_width,
278 )
279}
280
281#[allow(clippy::too_many_arguments)]
285pub fn layout_text_with_line_height(
286 text: &str,
287 size: f32,
288 line_height: f32,
289 weight: FontWeight,
290 mono: bool,
291 wrap: TextWrap,
292 available_width: Option<f32>,
293) -> TextLayout {
294 layout_text_with_line_height_and_family(
295 text,
296 size,
297 line_height,
298 FontFamily::default(),
299 weight,
300 mono,
301 wrap,
302 available_width,
303 )
304}
305
306#[allow(clippy::too_many_arguments)]
307pub fn layout_text_with_line_height_and_family(
308 text: &str,
309 size: f32,
310 line_height: f32,
311 family: FontFamily,
312 weight: FontWeight,
313 mono: bool,
314 wrap: TextWrap,
315 available_width: Option<f32>,
316) -> TextLayout {
317 let key = ShapeKey {
328 text: Box::from(text),
329 size_bits: size.to_bits(),
330 line_height_bits: line_height.to_bits(),
331 family,
332 weight,
333 mono,
334 wrap,
335 available_width_bits: available_width.map(f32::to_bits),
336 };
337 if let Some(cached) = SHAPE_CACHE.with_borrow_mut(|c| c.get(&key).cloned()) {
338 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
339 stats.hits += 1;
340 });
341 return cached;
342 }
343 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
344 stats.misses += 1;
345 stats.shaped_bytes += text.len() as u64;
346 });
347 let layout = layout_text_uncached(
348 text,
349 size,
350 line_height,
351 family,
352 weight,
353 mono,
354 wrap,
355 available_width,
356 );
357 SHAPE_CACHE.with_borrow_mut(|c| {
358 if c.len() == SHAPE_CACHE_CAPACITY {
359 SHAPE_CACHE_STATS.with_borrow_mut(|stats| {
360 stats.evictions += 1;
361 });
362 }
363 c.put(key, layout.clone());
364 });
365 layout
366}
367
368#[allow(clippy::too_many_arguments)]
369fn layout_text_uncached(
370 text: &str,
371 size: f32,
372 line_height: f32,
373 family: FontFamily,
374 weight: FontWeight,
375 mono: bool,
376 wrap: TextWrap,
377 available_width: Option<f32>,
378) -> TextLayout {
379 if !mono
380 && let Some(layout) = layout_text_cosmic(
381 text,
382 size,
383 line_height,
384 family,
385 weight,
386 wrap,
387 available_width,
388 )
389 {
390 return layout;
391 }
392
393 let raw_lines = match (wrap, available_width) {
394 (TextWrap::Wrap, Some(width)) => {
395 wrap_lines_by_width(text, width, size, family, weight, mono)
396 }
397 _ => text.split('\n').map(str::to_string).collect(),
398 };
399 build_layout(raw_lines, size, line_height, family, weight, mono)
400}
401
402pub fn ellipsize_text(
405 text: &str,
406 size: f32,
407 weight: FontWeight,
408 mono: bool,
409 available_width: f32,
410) -> String {
411 ellipsize_text_with_family(
412 text,
413 size,
414 FontFamily::default(),
415 weight,
416 mono,
417 available_width,
418 )
419}
420
421pub fn ellipsize_text_with_family(
422 text: &str,
423 size: f32,
424 family: FontFamily,
425 weight: FontWeight,
426 mono: bool,
427 available_width: f32,
428) -> String {
429 if available_width <= 0.0 || text.is_empty() {
430 return String::new();
431 }
432 let full = layout_text_with_family(text, size, family, weight, mono, TextWrap::NoWrap, None);
433 if full.width <= available_width + 0.5 {
434 return text.to_string();
435 }
436
437 let ellipsis = "…";
438 let ellipsis_w =
439 layout_text_with_family(ellipsis, size, family, weight, mono, TextWrap::NoWrap, None).width;
440 if ellipsis_w > available_width + 0.5 {
441 return ellipsis.to_string();
442 }
443
444 let chars: Vec<char> = text.chars().collect();
445 let mut lo = 0usize;
446 let mut hi = chars.len();
447 while lo < hi {
448 let mid = (lo + hi).div_ceil(2);
449 let candidate: String = chars[..mid].iter().collect();
450 let candidate = format!("{candidate}{ellipsis}");
451 let width = layout_text_with_family(
452 &candidate,
453 size,
454 family,
455 weight,
456 mono,
457 TextWrap::NoWrap,
458 None,
459 )
460 .width;
461 if width <= available_width + 0.5 {
462 lo = mid;
463 } else {
464 hi = mid - 1;
465 }
466 }
467
468 let prefix: String = chars[..lo].iter().collect();
469 format!("{prefix}{ellipsis}")
470}
471
472pub fn clamp_text_to_lines(
475 text: &str,
476 size: f32,
477 weight: FontWeight,
478 mono: bool,
479 available_width: f32,
480 max_lines: usize,
481) -> String {
482 clamp_text_to_lines_with_family(
483 text,
484 size,
485 FontFamily::default(),
486 weight,
487 mono,
488 available_width,
489 max_lines,
490 )
491}
492
493pub fn clamp_text_to_lines_with_family(
494 text: &str,
495 size: f32,
496 family: FontFamily,
497 weight: FontWeight,
498 mono: bool,
499 available_width: f32,
500 max_lines: usize,
501) -> String {
502 if text.is_empty() || available_width <= 0.0 || max_lines == 0 {
503 return String::new();
504 }
505
506 let layout = layout_text_with_family(
507 text,
508 size,
509 family,
510 weight,
511 mono,
512 TextWrap::Wrap,
513 Some(available_width),
514 );
515 if layout.lines.len() <= max_lines {
516 return text.to_string();
517 }
518
519 let mut lines: Vec<String> = layout
520 .lines
521 .iter()
522 .take(max_lines)
523 .map(|line| line.text.clone())
524 .collect();
525 if let Some(last) = lines.last_mut() {
526 let marked = format!("{last}…");
527 *last = ellipsize_text_with_family(&marked, size, family, weight, mono, available_width);
528 }
529 lines.join("\n")
530}
531
532#[derive(Clone, Copy, Debug, PartialEq, Eq)]
536pub struct TextHit {
537 pub line: usize,
542 pub byte_index: usize,
545}
546
547pub fn hit_text(
558 text: &str,
559 size: f32,
560 weight: FontWeight,
561 wrap: TextWrap,
562 available_width: Option<f32>,
563 x: f32,
564 y: f32,
565) -> Option<TextHit> {
566 hit_text_with_family(
567 text,
568 size,
569 FontFamily::default(),
570 weight,
571 wrap,
572 available_width,
573 x,
574 y,
575 )
576}
577
578#[allow(clippy::too_many_arguments)]
579pub fn hit_text_with_family(
580 text: &str,
581 size: f32,
582 family: FontFamily,
583 weight: FontWeight,
584 wrap: TextWrap,
585 available_width: Option<f32>,
586 x: f32,
587 y: f32,
588) -> Option<TextHit> {
589 FONT_SYSTEM.with_borrow_mut(|font_system| {
590 let line_height = line_height(size);
591 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
592 buffer.set_wrap(match wrap {
593 TextWrap::NoWrap => Wrap::None,
594 TextWrap::Wrap => Wrap::WordOrGlyph,
595 });
596 buffer.set_size(
597 match wrap {
598 TextWrap::NoWrap => None,
599 TextWrap::Wrap => available_width,
600 },
601 None,
602 );
603 let attrs = Attrs::new()
604 .family(Family::Name(family.family_name()))
605 .weight(cosmic_weight(weight));
606 buffer.set_text(text, &attrs, Shaping::Advanced, None);
607 buffer.shape_until_scroll(font_system, false);
608 let cursor = buffer.hit(x, y)?;
609 Some(TextHit {
610 line: cursor.line,
611 byte_index: cursor.index,
612 })
613 })
614}
615
616pub fn caret_xy(
628 text: &str,
629 byte_index: usize,
630 size: f32,
631 weight: FontWeight,
632 wrap: TextWrap,
633 available_width: Option<f32>,
634) -> (f32, f32) {
635 caret_xy_with_family(
636 text,
637 byte_index,
638 size,
639 FontFamily::default(),
640 weight,
641 wrap,
642 available_width,
643 )
644}
645
646pub fn caret_xy_with_family(
647 text: &str,
648 byte_index: usize,
649 size: f32,
650 family: FontFamily,
651 weight: FontWeight,
652 wrap: TextWrap,
653 available_width: Option<f32>,
654) -> (f32, f32) {
655 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
656 FONT_SYSTEM.with_borrow_mut(|font_system| {
657 let line_h = line_height(size);
658 let buffer = build_buffer(
659 font_system,
660 text,
661 size,
662 family,
663 weight,
664 wrap,
665 available_width,
666 );
667 let cursor = Cursor::new(target_line, byte_in_line);
668 if let Some((x, y)) = buffer.cursor_position(&cursor) {
672 return (x, y);
673 }
674 (0.0, target_line as f32 * line_h)
677 })
678}
679
680pub fn selection_rects(
688 text: &str,
689 lo: usize,
690 hi: usize,
691 size: f32,
692 weight: FontWeight,
693 wrap: TextWrap,
694 available_width: Option<f32>,
695) -> Vec<(f32, f32, f32, f32)> {
696 selection_rects_with_family(
697 text,
698 lo,
699 hi,
700 size,
701 FontFamily::default(),
702 weight,
703 wrap,
704 available_width,
705 )
706}
707
708#[allow(clippy::too_many_arguments)]
709pub fn selection_rects_with_family(
710 text: &str,
711 lo: usize,
712 hi: usize,
713 size: f32,
714 family: FontFamily,
715 weight: FontWeight,
716 wrap: TextWrap,
717 available_width: Option<f32>,
718) -> Vec<(f32, f32, f32, f32)> {
719 if lo >= hi {
720 return Vec::new();
721 }
722 let (lo_line, lo_in_line) = byte_to_line_position(text, lo);
723 let (hi_line, hi_in_line) = byte_to_line_position(text, hi);
724 FONT_SYSTEM.with_borrow_mut(|font_system| {
725 let buffer = build_buffer(
726 font_system,
727 text,
728 size,
729 family,
730 weight,
731 wrap,
732 available_width,
733 );
734 let c_lo = Cursor::new(lo_line, lo_in_line);
735 let c_hi = Cursor::new(hi_line, hi_in_line);
736 let mut rects = Vec::new();
737 for run in buffer.layout_runs() {
738 if run.line_i < lo_line || run.line_i > hi_line {
739 continue;
740 }
741 for (x, w) in run.highlight(c_lo, c_hi) {
742 rects.push((x, run.line_top, w, run.line_height));
743 }
744 }
745 rects
746 })
747}
748
749pub fn visual_line_byte_range(
750 text: &str,
751 byte_index: usize,
752 size: f32,
753 weight: FontWeight,
754 wrap: TextWrap,
755 available_width: Option<f32>,
756) -> (usize, usize) {
757 visual_line_byte_range_with_family(
758 text,
759 byte_index,
760 size,
761 FontFamily::default(),
762 weight,
763 wrap,
764 available_width,
765 )
766}
767
768pub fn visual_line_byte_range_with_family(
769 text: &str,
770 byte_index: usize,
771 size: f32,
772 family: FontFamily,
773 weight: FontWeight,
774 wrap: TextWrap,
775 available_width: Option<f32>,
776) -> (usize, usize) {
777 let byte_index = clamp_to_char_boundary(text, byte_index.min(text.len()));
778 let (target_line, byte_in_line) = byte_to_line_position(text, byte_index);
779 let hard_line_start = line_position_to_byte(text, target_line, 0);
780 let hard_line_end = line_end_byte(text, hard_line_start);
781 FONT_SYSTEM.with_borrow_mut(|font_system| {
782 let buffer = build_buffer(
783 font_system,
784 text,
785 size,
786 family,
787 weight,
788 wrap,
789 available_width,
790 );
791 let mut last_range = None;
792 for run in buffer.layout_runs() {
793 if run.line_i != target_line {
794 continue;
795 }
796 let Some((start, end)) = layout_run_byte_range(&run) else {
797 continue;
798 };
799 last_range = Some((start, end));
800 if start <= byte_in_line && byte_in_line < end {
801 return (
802 line_position_to_byte(text, target_line, start),
803 line_position_to_byte(text, target_line, end),
804 );
805 }
806 }
807 if let Some((start, end)) = last_range
808 && byte_index >= line_position_to_byte(text, target_line, start)
809 {
810 return (
811 line_position_to_byte(text, target_line, start),
812 line_position_to_byte(text, target_line, end),
813 );
814 }
815 (hard_line_start, hard_line_end)
816 })
817}
818
819fn byte_to_line_position(text: &str, byte_index: usize) -> (usize, usize) {
824 let byte_index = byte_index.min(text.len());
825 let mut line = 0;
826 let mut line_start = 0;
827 for (i, ch) in text.char_indices() {
828 if i >= byte_index {
829 break;
830 }
831 if ch == '\n' {
832 line += 1;
833 line_start = i + ch.len_utf8();
834 }
835 }
836 (line, byte_index - line_start)
837}
838
839fn line_position_to_byte(text: &str, line: usize, byte_in_line: usize) -> usize {
840 let mut current_line = 0;
841 let mut line_start = 0;
842 for (i, ch) in text.char_indices() {
843 if current_line == line {
844 let candidate = line_start + byte_in_line;
845 return clamp_to_char_boundary(text, candidate.min(text.len()));
846 }
847 if ch == '\n' {
848 current_line += 1;
849 line_start = i + ch.len_utf8();
850 }
851 }
852 if current_line == line {
853 clamp_to_char_boundary(text, (line_start + byte_in_line).min(text.len()))
854 } else {
855 text.len()
856 }
857}
858
859fn line_end_byte(text: &str, line_start: usize) -> usize {
860 text[line_start..]
861 .find('\n')
862 .map(|i| line_start + i)
863 .unwrap_or(text.len())
864}
865
866fn layout_run_byte_range(run: &cosmic_text::LayoutRun<'_>) -> Option<(usize, usize)> {
867 let start = run.glyphs.iter().map(|glyph| glyph.start).min()?;
868 let end = run
869 .glyphs
870 .iter()
871 .map(|glyph| glyph.end)
872 .max()
873 .unwrap_or(start);
874 Some((start, end))
875}
876
877fn clamp_to_char_boundary(text: &str, mut byte: usize) -> usize {
878 byte = byte.min(text.len());
879 while byte > 0 && !text.is_char_boundary(byte) {
880 byte -= 1;
881 }
882 byte
883}
884
885fn build_buffer(
886 font_system: &mut FontSystem,
887 text: &str,
888 size: f32,
889 family: FontFamily,
890 weight: FontWeight,
891 wrap: TextWrap,
892 available_width: Option<f32>,
893) -> Buffer {
894 let line_h = line_height(size);
895 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_h));
896 buffer.set_wrap(match wrap {
897 TextWrap::NoWrap => Wrap::None,
898 TextWrap::Wrap => Wrap::WordOrGlyph,
899 });
900 buffer.set_size(
901 match wrap {
902 TextWrap::NoWrap => None,
903 TextWrap::Wrap => available_width,
904 },
905 None,
906 );
907 let attrs = Attrs::new()
908 .family(Family::Name(family.family_name()))
909 .weight(cosmic_weight(weight));
910 buffer.set_text(text, &attrs, Shaping::Advanced, None);
911 buffer.shape_until_scroll(font_system, false);
912 buffer
913}
914
915pub fn wrap_lines(
919 text: &str,
920 max_width: f32,
921 size: f32,
922 weight: FontWeight,
923 mono: bool,
924) -> Vec<String> {
925 wrap_lines_with_family(text, max_width, size, FontFamily::default(), weight, mono)
926}
927
928pub fn wrap_lines_with_family(
929 text: &str,
930 max_width: f32,
931 size: f32,
932 family: FontFamily,
933 weight: FontWeight,
934 mono: bool,
935) -> Vec<String> {
936 if !mono
937 && let Some(layout) = layout_text_cosmic(
938 text,
939 size,
940 line_height(size),
941 family,
942 weight,
943 TextWrap::Wrap,
944 Some(max_width),
945 )
946 {
947 return layout.lines.into_iter().map(|line| line.text).collect();
948 }
949 wrap_lines_by_width(text, max_width, size, family, weight, mono)
950}
951
952fn wrap_lines_by_width(
953 text: &str,
954 max_width: f32,
955 size: f32,
956 family: FontFamily,
957 weight: FontWeight,
958 mono: bool,
959) -> Vec<String> {
960 if max_width <= 0.0 {
961 return vec![String::new()];
962 }
963
964 let ctx = WrapMeasure {
965 max_width,
966 size,
967 family,
968 weight,
969 mono,
970 };
971 let mut out = Vec::new();
972 for paragraph in text.split('\n') {
973 if paragraph.is_empty() {
974 out.push(String::new());
975 continue;
976 }
977
978 let mut line = String::new();
979 for word in paragraph.split_whitespace() {
980 if line.is_empty() {
981 push_word_wrapped(&mut out, &mut line, word, ctx);
982 continue;
983 }
984
985 let candidate = format!("{line} {word}");
986 if line_width_with_family(&candidate, size, family, weight, mono) <= max_width {
987 line = candidate;
988 } else {
989 out.push(std::mem::take(&mut line));
990 push_word_wrapped(&mut out, &mut line, word, ctx);
991 }
992 }
993
994 if !line.is_empty() {
995 out.push(line);
996 }
997 }
998
999 if out.is_empty() {
1000 out.push(String::new());
1001 }
1002 out
1003}
1004
1005pub fn line_width(text: &str, size: f32, weight: FontWeight, mono: bool) -> f32 {
1008 line_width_with_family(text, size, FontFamily::default(), weight, mono)
1009}
1010
1011pub fn line_width_with_family(
1012 text: &str,
1013 size: f32,
1014 family: FontFamily,
1015 weight: FontWeight,
1016 mono: bool,
1017) -> f32 {
1018 if !mono
1019 && let Some(layout) = layout_text_cosmic(
1020 text,
1021 size,
1022 line_height(size),
1023 family,
1024 weight,
1025 TextWrap::NoWrap,
1026 None,
1027 )
1028 {
1029 return layout.width;
1030 }
1031 line_width_by_ttf(text, size, family, weight, mono)
1032}
1033
1034fn line_width_by_ttf(
1035 text: &str,
1036 size: f32,
1037 family: FontFamily,
1038 weight: FontWeight,
1039 mono: bool,
1040) -> f32 {
1041 if mono {
1042 return text
1043 .chars()
1044 .filter(|c| *c != '\n' && *c != '\r')
1045 .map(|c| if c == '\t' { 4.0 } else { 1.0 })
1046 .sum::<f32>()
1047 * size
1048 * MONO_CHAR_WIDTH_FACTOR;
1049 }
1050
1051 let Ok(face) = ttf_parser::Face::parse(font_bytes(family, weight), 0) else {
1052 return fallback_line_width(text, size, mono);
1053 };
1054 let scale = size / face.units_per_em() as f32;
1055 let fallback_advance = face.units_per_em() as f32 * 0.5;
1056 let mut width = 0.0;
1057 let mut prev = None;
1058
1059 for c in text.chars() {
1060 if c == '\n' || c == '\r' {
1061 continue;
1062 }
1063 if c == '\t' {
1064 width += line_width_with_family(" ", size, family, weight, mono);
1065 prev = None;
1066 continue;
1067 }
1068
1069 let Some(glyph) = glyph_for(&face, c) else {
1070 continue;
1071 };
1072 if let Some(left) = prev {
1073 width += kern(&face, left, glyph) * scale;
1074 }
1075 width += face
1076 .glyph_hor_advance(glyph)
1077 .map(|advance| advance as f32)
1078 .unwrap_or(fallback_advance)
1079 * scale;
1080 prev = Some(glyph);
1081 }
1082 width
1083}
1084
1085pub fn line_height(size: f32) -> f32 {
1086 tokens::line_height_for_size(size)
1091}
1092
1093fn build_layout(
1094 lines: Vec<String>,
1095 size: f32,
1096 line_height: f32,
1097 family: FontFamily,
1098 weight: FontWeight,
1099 mono: bool,
1100) -> TextLayout {
1101 let raw_lines = if lines.is_empty() {
1102 vec![String::new()]
1103 } else {
1104 lines
1105 };
1106 let lines: Vec<TextLine> = raw_lines
1107 .into_iter()
1108 .enumerate()
1109 .map(|(i, text)| {
1110 let y = i as f32 * line_height;
1111 TextLine {
1112 width: line_width_with_family(&text, size, family, weight, mono),
1113 text,
1114 y,
1115 baseline: y + size * BASELINE_MULTIPLIER,
1116 rtl: false,
1117 }
1118 })
1119 .collect();
1120 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1121 TextLayout {
1122 width,
1123 height: lines.len().max(1) as f32 * line_height,
1124 line_height,
1125 lines,
1126 }
1127}
1128
1129fn layout_text_cosmic(
1130 text: &str,
1131 size: f32,
1132 line_height: f32,
1133 family: FontFamily,
1134 weight: FontWeight,
1135 wrap: TextWrap,
1136 available_width: Option<f32>,
1137) -> Option<TextLayout> {
1138 let options = CosmicLayoutOptions {
1139 size,
1140 line_height,
1141 family,
1142 weight,
1143 wrap,
1144 available_width,
1145 };
1146 FONT_SYSTEM.with_borrow_mut(|font_system| layout_text_cosmic_with(font_system, text, options))
1147}
1148
1149#[derive(Copy, Clone)]
1150struct CosmicLayoutOptions {
1151 size: f32,
1152 line_height: f32,
1153 family: FontFamily,
1154 weight: FontWeight,
1155 wrap: TextWrap,
1156 available_width: Option<f32>,
1157}
1158
1159fn layout_text_cosmic_with(
1160 font_system: &mut FontSystem,
1161 text: &str,
1162 options: CosmicLayoutOptions,
1163) -> Option<TextLayout> {
1164 let CosmicLayoutOptions {
1165 size,
1166 line_height,
1167 family,
1168 weight,
1169 wrap,
1170 available_width,
1171 } = options;
1172 let mut buffer = Buffer::new(font_system, Metrics::new(size, line_height));
1173 buffer.set_wrap(match wrap {
1174 TextWrap::NoWrap => Wrap::None,
1175 TextWrap::Wrap => Wrap::WordOrGlyph,
1176 });
1177 buffer.set_size(
1178 match wrap {
1179 TextWrap::NoWrap => None,
1180 TextWrap::Wrap => available_width,
1181 },
1182 None,
1183 );
1184 let attrs = Attrs::new()
1185 .family(Family::Name(family.family_name()))
1186 .weight(cosmic_weight(weight));
1187 buffer.set_text(text, &attrs, Shaping::Advanced, None);
1188 buffer.shape_until_scroll(font_system, false);
1189
1190 let mut lines = Vec::new();
1191 let mut height: f32 = 0.0;
1192 for run in buffer.layout_runs() {
1193 height = height.max(run.line_top + run.line_height);
1194 lines.push(TextLine {
1195 text: layout_run_text(&run),
1196 width: run.line_w,
1197 y: run.line_top,
1198 baseline: run.line_y,
1199 rtl: run.rtl,
1200 });
1201 }
1202
1203 if lines.is_empty() {
1204 return None;
1205 }
1206
1207 let width = lines.iter().map(|line| line.width).fold(0.0, f32::max);
1208 Some(TextLayout {
1209 lines,
1210 width,
1211 height: height.max(line_height),
1212 line_height,
1213 })
1214}
1215
1216thread_local! {
1222 static FONT_SYSTEM: RefCell<FontSystem> = RefCell::new(bundled_font_system());
1223}
1224
1225#[derive(Clone, PartialEq, Eq, Hash)]
1232struct ShapeKey {
1233 text: Box<str>,
1234 size_bits: u32,
1235 line_height_bits: u32,
1236 family: FontFamily,
1237 weight: FontWeight,
1238 mono: bool,
1239 wrap: TextWrap,
1240 available_width_bits: Option<u32>,
1241}
1242
1243const SHAPE_CACHE_CAPACITY: usize = 16_384;
1250thread_local! {
1251 static SHAPE_CACHE: RefCell<LruCache<ShapeKey, TextLayout>> =
1252 RefCell::new(LruCache::new(NonZeroUsize::new(SHAPE_CACHE_CAPACITY).unwrap()));
1253 static SHAPE_CACHE_STATS: RefCell<TextLayoutCacheStats> =
1254 RefCell::new(TextLayoutCacheStats::default());
1255}
1256
1257pub fn take_shape_cache_stats() -> TextLayoutCacheStats {
1261 SHAPE_CACHE_STATS.with_borrow_mut(std::mem::take)
1262}
1263
1264fn bundled_font_system() -> FontSystem {
1265 let mut db = fontdb::Database::new();
1266 db.set_sans_serif_family(FontFamily::default().family_name());
1267 for bytes in aetna_fonts::DEFAULT_FONTS {
1268 db.load_font_data(bytes.to_vec());
1269 }
1270 FontSystem::new_with_locale_and_db("en-US".to_string(), db)
1271}
1272
1273fn cosmic_weight(weight: FontWeight) -> Weight {
1274 match weight {
1275 FontWeight::Regular => Weight::NORMAL,
1276 FontWeight::Medium => Weight::MEDIUM,
1277 FontWeight::Semibold => Weight::SEMIBOLD,
1278 FontWeight::Bold => Weight::BOLD,
1279 }
1280}
1281
1282fn layout_run_text(run: &cosmic_text::LayoutRun<'_>) -> String {
1283 let Some(start) = run.glyphs.iter().map(|glyph| glyph.start).min() else {
1284 return String::new();
1285 };
1286 let end = run
1287 .glyphs
1288 .iter()
1289 .map(|glyph| glyph.end)
1290 .max()
1291 .unwrap_or(start);
1292 run.text
1293 .get(start..end)
1294 .unwrap_or_default()
1295 .trim_end()
1296 .to_string()
1297}
1298
1299#[derive(Copy, Clone)]
1300struct WrapMeasure {
1301 max_width: f32,
1302 size: f32,
1303 family: FontFamily,
1304 weight: FontWeight,
1305 mono: bool,
1306}
1307
1308fn push_word_wrapped(out: &mut Vec<String>, line: &mut String, word: &str, ctx: WrapMeasure) {
1309 let WrapMeasure {
1310 max_width,
1311 size,
1312 family,
1313 weight,
1314 mono,
1315 } = ctx;
1316 if line_width_with_family(word, size, family, weight, mono) <= max_width {
1317 line.push_str(word);
1318 return;
1319 }
1320
1321 for ch in word.chars() {
1322 let candidate = format!("{line}{ch}");
1323 if !line.is_empty()
1324 && line_width_with_family(&candidate, size, family, weight, mono) > max_width
1325 {
1326 out.push(std::mem::take(line));
1327 }
1328 line.push(ch);
1329 }
1330}
1331
1332fn glyph_for(face: &ttf_parser::Face<'_>, c: char) -> Option<ttf_parser::GlyphId> {
1333 face.glyph_index(c)
1334 .or_else(|| face.glyph_index('\u{FFFD}'))
1335 .or_else(|| face.glyph_index('?'))
1336 .or_else(|| face.glyph_index(' '))
1337}
1338
1339fn kern(face: &ttf_parser::Face<'_>, left: ttf_parser::GlyphId, right: ttf_parser::GlyphId) -> f32 {
1340 let Some(kern) = &face.tables().kern else {
1341 return 0.0;
1342 };
1343 kern.subtables
1344 .into_iter()
1345 .filter(|subtable| subtable.horizontal && !subtable.has_cross_stream)
1346 .find_map(|subtable| subtable.glyphs_kerning(left, right))
1347 .map(|value| value as f32)
1348 .unwrap_or(0.0)
1349}
1350
1351fn font_bytes(family: FontFamily, weight: FontWeight) -> &'static [u8] {
1352 match family {
1356 FontFamily::Inter => {
1357 #[cfg(feature = "inter")]
1358 {
1359 let _ = weight;
1360 aetna_fonts::INTER_VARIABLE
1361 }
1362 #[cfg(not(feature = "inter"))]
1363 {
1364 let _ = weight;
1365 &[]
1366 }
1367 }
1368 FontFamily::Roboto => {
1369 #[cfg(feature = "roboto")]
1370 {
1371 match weight {
1372 FontWeight::Regular => aetna_fonts::ROBOTO_REGULAR,
1373 FontWeight::Medium => aetna_fonts::ROBOTO_MEDIUM,
1374 FontWeight::Semibold | FontWeight::Bold => aetna_fonts::ROBOTO_BOLD,
1375 }
1376 }
1377 #[cfg(not(feature = "roboto"))]
1378 {
1379 let _ = weight;
1380 &[]
1381 }
1382 }
1383 FontFamily::JetBrainsMono => {
1384 #[cfg(feature = "jetbrains-mono")]
1385 {
1386 let _ = weight;
1387 aetna_fonts::JETBRAINS_MONO_VARIABLE
1388 }
1389 #[cfg(not(feature = "jetbrains-mono"))]
1390 {
1391 let _ = weight;
1392 &[]
1393 }
1394 }
1395 }
1396}
1397
1398fn fallback_line_width(text: &str, size: f32, mono: bool) -> f32 {
1399 let char_w = size * if mono { MONO_CHAR_WIDTH_FACTOR } else { 0.60 };
1400 text.chars().count() as f32 * char_w
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use super::*;
1406
1407 #[test]
1408 fn proportional_measurement_distinguishes_narrow_and_wide_glyphs() {
1409 let narrow = line_width("iiiiii", 16.0, FontWeight::Regular, false);
1410 let wide = line_width("WWWWWW", 16.0, FontWeight::Regular, false);
1411
1412 assert!(wide > narrow * 2.0, "wide={wide} narrow={narrow}");
1413 }
1414
1415 #[cfg(feature = "roboto")]
1416 #[test]
1417 fn font_family_changes_proportional_measurement() {
1418 let roboto = line_width_with_family(
1419 "Save changes",
1420 14.0,
1421 FontFamily::Roboto,
1422 FontWeight::Semibold,
1423 false,
1424 );
1425 let inter = line_width_with_family(
1426 "Save changes",
1427 14.0,
1428 FontFamily::Inter,
1429 FontWeight::Semibold,
1430 false,
1431 );
1432
1433 assert!(
1434 (inter - roboto).abs() > 1.0,
1435 "inter={inter} roboto={roboto}"
1436 );
1437 }
1438
1439 #[test]
1440 fn wrap_lines_respects_measured_widths() {
1441 let lines = wrap_lines(
1442 "wide WWW words stay measured",
1443 120.0,
1444 16.0,
1445 FontWeight::Regular,
1446 false,
1447 );
1448
1449 assert!(lines.len() > 1);
1450 for line in lines {
1451 assert!(
1452 line_width(&line, 16.0, FontWeight::Regular, false) <= 121.0,
1453 "{line:?} overflowed"
1454 );
1455 }
1456 }
1457
1458 #[test]
1459 fn layout_text_carries_line_positions_and_measurement() {
1460 let layout = layout_text(
1461 "alpha beta gamma",
1462 16.0,
1463 FontWeight::Regular,
1464 false,
1465 TextWrap::Wrap,
1466 Some(80.0),
1467 );
1468
1469 assert!(layout.lines.len() > 1);
1470 assert_eq!(layout.measured().line_count, layout.lines.len());
1471 assert_eq!(layout.lines[0].y, 0.0);
1472 assert_eq!(layout.lines[1].y, layout.line_height);
1473 assert!(layout.lines[0].baseline > layout.lines[0].y);
1474 assert!(layout.height >= layout.line_height * 2.0);
1475 }
1476
1477 #[test]
1478 fn tokenized_line_heights_match_shadcn_scale() {
1479 assert_eq!(line_height(12.0), 16.0);
1480 assert_eq!(line_height(14.0), 20.0);
1481 assert_eq!(line_height(16.0), 24.0);
1482 assert_eq!(line_height(24.0), 32.0);
1483 assert_eq!(line_height(30.0), 36.0);
1484 }
1485
1486 #[test]
1487 fn hit_text_at_origin_lands_on_first_byte() {
1488 let hit = hit_text(
1489 "hello world",
1490 16.0,
1491 FontWeight::Regular,
1492 TextWrap::NoWrap,
1493 None,
1494 0.0,
1495 8.0,
1496 )
1497 .expect("hit at origin");
1498 assert_eq!(hit.line, 0);
1499 assert_eq!(hit.byte_index, 0);
1500 }
1501
1502 #[test]
1503 fn hit_text_past_last_glyph_clamps_to_end() {
1504 let text = "hello";
1505 let hit = hit_text(
1507 text,
1508 16.0,
1509 FontWeight::Regular,
1510 TextWrap::NoWrap,
1511 None,
1512 1000.0,
1513 8.0,
1514 )
1515 .expect("hit past end");
1516 assert_eq!(hit.line, 0);
1517 assert_eq!(hit.byte_index, text.len());
1518 }
1519
1520 #[test]
1521 fn hit_text_walks_columns_left_to_right() {
1522 let text = "abcdefghij";
1526 let mut prev = 0usize;
1527 for x in [4.0, 16.0, 32.0, 64.0, 96.0] {
1528 let hit = hit_text(
1529 text,
1530 16.0,
1531 FontWeight::Regular,
1532 TextWrap::NoWrap,
1533 None,
1534 x,
1535 8.0,
1536 );
1537 let Some(hit) = hit else { continue };
1538 assert!(
1539 hit.byte_index >= prev,
1540 "byte_index regressed at x={x}: {} < {prev}",
1541 hit.byte_index
1542 );
1543 prev = hit.byte_index;
1544 }
1545 }
1546
1547 #[test]
1548 fn text_geometry_hit_byte_maps_hard_line_offsets_to_source_bytes() {
1549 let text = "alpha\nbeta";
1550 let geometry = TextGeometry::new(
1551 text,
1552 16.0,
1553 FontWeight::Regular,
1554 false,
1555 TextWrap::NoWrap,
1556 None,
1557 );
1558 let y = geometry.line_height() * 1.5;
1559 let byte = geometry.hit_byte(1000.0, y).expect("hit on second line");
1560 assert_eq!(byte, text.len());
1561 }
1562
1563 #[test]
1564 fn text_geometry_prefix_width_matches_caret_x() {
1565 let text = "hello world";
1566 let geometry = TextGeometry::new(
1567 text,
1568 16.0,
1569 FontWeight::Regular,
1570 false,
1571 TextWrap::NoWrap,
1572 None,
1573 );
1574 let (x, _y) = geometry.caret_xy(5);
1575 assert!((geometry.prefix_width(5) - x).abs() < 0.01);
1576 }
1577
1578 #[test]
1579 fn caret_xy_at_origin_is_zero_zero() {
1580 let (x, y) = caret_xy(
1581 "hello",
1582 0,
1583 16.0,
1584 FontWeight::Regular,
1585 TextWrap::NoWrap,
1586 None,
1587 );
1588 assert!(x.abs() < 0.01, "x={x}");
1589 assert_eq!(y, 0.0);
1590 }
1591
1592 #[test]
1593 fn caret_xy_at_end_of_line_is_at_line_width() {
1594 let text = "hello";
1595 let width = line_width(text, 16.0, FontWeight::Regular, false);
1596 let (x, y) = caret_xy(
1597 text,
1598 text.len(),
1599 16.0,
1600 FontWeight::Regular,
1601 TextWrap::NoWrap,
1602 None,
1603 );
1604 assert!((x - width).abs() < 1.0, "x={x} expected~{width}");
1605 assert_eq!(y, 0.0);
1606 }
1607
1608 #[test]
1609 fn caret_xy_drops_to_next_line_after_newline() {
1610 let text = "foo\nbar";
1611 let line_h = line_height(16.0);
1612 let (x, y) = caret_xy(text, 4, 16.0, FontWeight::Regular, TextWrap::NoWrap, None);
1614 assert!(x.abs() < 0.01, "x={x}");
1615 assert!((y - line_h).abs() < 0.01, "y={y} expected~{line_h}");
1616 }
1617
1618 #[test]
1619 fn caret_xy_on_phantom_trailing_line_falls_below_text() {
1620 let text = "foo\n";
1621 let line_h = line_height(16.0);
1622 let (x, y) = caret_xy(
1623 text,
1624 text.len(),
1625 16.0,
1626 FontWeight::Regular,
1627 TextWrap::NoWrap,
1628 None,
1629 );
1630 assert!(x.abs() < 0.01, "x={x}");
1631 assert!(y >= line_h - 0.01, "y={y} expected ≥ line_h={line_h}");
1632 }
1633
1634 #[test]
1635 fn selection_rects_returns_one_per_visual_line() {
1636 let text = "alpha\nbeta\ngamma";
1637 let rects = selection_rects(
1638 text,
1639 0,
1640 text.len(),
1641 16.0,
1642 FontWeight::Regular,
1643 TextWrap::NoWrap,
1644 None,
1645 );
1646 assert_eq!(
1647 rects.len(),
1648 3,
1649 "expected one rect per BufferLine, got {rects:?}"
1650 );
1651 assert!(rects[0].1 < rects[1].1);
1653 assert!(rects[1].1 < rects[2].1);
1654 for (_x, _y, w, _h) in &rects {
1655 assert!(*w > 0.0, "empty width: {rects:?}");
1656 }
1657 }
1658
1659 #[test]
1660 fn selection_rects_for_single_line_range_do_not_highlight_other_lines() {
1661 let text = "alpha\nbeta\ngamma";
1662 let lo = text.find("et").unwrap();
1663 let hi = lo + "et".len();
1664 let rects = selection_rects(
1665 text,
1666 lo,
1667 hi,
1668 16.0,
1669 FontWeight::Regular,
1670 TextWrap::NoWrap,
1671 None,
1672 );
1673 assert_eq!(
1674 rects.len(),
1675 1,
1676 "single-line range should only highlight that line: {rects:?}"
1677 );
1678 let line_h = line_height(16.0);
1679 let y = rects[0].1;
1680 assert!(
1681 (y - line_h).abs() < 0.01,
1682 "expected second line y={line_h}, got {y}; rects={rects:?}"
1683 );
1684 }
1685
1686 #[test]
1687 fn visual_line_byte_range_respects_soft_wraps() {
1688 let text = "alpha beta gamma";
1689 let beta = text.find("beta").unwrap();
1690 let width = line_width("alpha", 16.0, FontWeight::Regular, false) + 2.0;
1691 let (lo, hi) = visual_line_byte_range(
1692 text,
1693 beta,
1694 16.0,
1695 FontWeight::Regular,
1696 TextWrap::Wrap,
1697 Some(width),
1698 );
1699 assert!(
1700 lo > 0 && hi < text.len(),
1701 "soft-wrapped visual line should be narrower than the hard line: {lo}..{hi}"
1702 );
1703 assert!(
1704 (lo..hi).contains(&beta),
1705 "range {lo}..{hi} should contain beta byte {beta}"
1706 );
1707 }
1708
1709 #[test]
1710 fn selection_rects_empty_for_collapsed_range() {
1711 let rects = selection_rects(
1712 "alpha",
1713 2,
1714 2,
1715 16.0,
1716 FontWeight::Regular,
1717 TextWrap::NoWrap,
1718 None,
1719 );
1720 assert!(rects.is_empty());
1721 }
1722
1723 #[test]
1724 fn proportional_layout_uses_cosmic_shaping_widths() {
1725 let layout = layout_text(
1726 "Roboto shaping",
1727 18.0,
1728 FontWeight::Medium,
1729 false,
1730 TextWrap::NoWrap,
1731 None,
1732 );
1733
1734 assert_eq!(layout.lines.len(), 1);
1735 assert!((layout.lines[0].width - layout.width).abs() < 0.01);
1736 assert!(layout.lines[0].baseline > layout.lines[0].y);
1737 }
1738
1739 #[test]
1740 fn ellipsize_text_shortens_to_available_width() {
1741 let source = "this is a long branch name";
1742 let available = line_width("this is a…", 14.0, FontWeight::Regular, false);
1743 let clipped = ellipsize_text(source, 14.0, FontWeight::Regular, false, available);
1744 let width = line_width(&clipped, 14.0, FontWeight::Regular, false);
1745
1746 assert!(clipped.ends_with('…'), "clipped={clipped}");
1747 assert!(clipped.len() < source.len());
1748 assert!(
1749 width <= available + 0.5,
1750 "width={width} available={available}"
1751 );
1752 }
1753
1754 #[test]
1755 fn ellipsize_text_keeps_fitting_text_unchanged() {
1756 let source = "short";
1757 let available = line_width(source, 14.0, FontWeight::Regular, false) + 4.0;
1758 assert_eq!(
1759 ellipsize_text(source, 14.0, FontWeight::Regular, false, available),
1760 source
1761 );
1762 }
1763
1764 #[test]
1765 fn clamp_text_to_lines_caps_wrapped_text_with_final_ellipsis() {
1766 let source = "alpha beta gamma delta epsilon zeta";
1767 let available = line_width("alpha beta", 14.0, FontWeight::Regular, false);
1768 let clamped = clamp_text_to_lines(source, 14.0, FontWeight::Regular, false, available, 2);
1769 let layout = layout_text(
1770 &clamped,
1771 14.0,
1772 FontWeight::Regular,
1773 false,
1774 TextWrap::Wrap,
1775 Some(available),
1776 );
1777
1778 assert!(clamped.ends_with('…'), "clamped={clamped}");
1779 assert!(layout.lines.len() <= 2, "layout={layout:?}");
1780 }
1781}