GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/display/text_sizer.rs (5.8K)
1 use std::collections::HashMap;
2 use std::sync::RwLock;
3 use std::sync::OnceLock;
4 use crate::widget::display::TextLabel;
5
6 #[derive(Hash, Eq, PartialEq, Clone, Debug)]
7 struct TextMeasureKey {
8 text: String,
9 font_family: String,
10 font_size_bits: u32,
11 scale_bits: u32,
12 }
13
14 static TEXT_SIZE_CACHE: OnceLock<RwLock<HashMap<TextMeasureKey, f32>>> = OnceLock::new();
15
16 pub fn measure_text_width(text: &str, font_family: &str, font_size: f32) -> f32 {
17 let scale = crate::scale::scale_factor().max(1.0);
18
19 let key = TextMeasureKey {
20 text: text.trim().to_string(),
21 font_family: font_family.to_string(),
22 font_size_bits: font_size.to_bits(),
23 scale_bits: scale.to_bits(),
24 };
25
26 let cache = TEXT_SIZE_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
27 if let Ok(lock) = cache.read() {
28 if let Some(&exact_width) = lock.get(&key) {
29 return exact_width;
30 }
31 }
32
33 let exact_width = perform_svg_measurement(&key.text, &key.font_family, font_size, scale);
34
35 if let Ok(mut lock) = cache.write() {
36 lock.insert(key, exact_width);
37 }
38
39 exact_width
40 }
41
42 pub fn measure_text(text: &str, font_size: f32) -> f32 {
43 let font_family = crate::layout::menubar_font_parsed().0;
44 measure_text_width(text, &font_family, font_size)
45 }
46
47 /// Truncate to at most `max_chars` characters, replacing the tail with "..."
48 /// (for names/titles where the head identifies the item). Char-boundary safe —
49 /// byte-slicing a multi-byte string panics; this never does.
50 pub fn truncate_tail(s: &str, max_chars: usize) -> String {
51 if s.chars().count() <= max_chars {
52 return s.to_string();
53 }
54 let keep = max_chars.saturating_sub(3);
55 let mut out: String = s.chars().take(keep).collect();
56 out.push_str("...");
57 out
58 }
59
60 /// Truncate to at most `max_chars` characters, replacing the head with "..."
61 /// (for paths/targets where the tail identifies the item). Char-boundary safe.
62 pub fn truncate_head(s: &str, max_chars: usize) -> String {
63 let count = s.chars().count();
64 if count <= max_chars {
65 return s.to_string();
66 }
67 let keep = max_chars.saturating_sub(3);
68 let tail: String = s.chars().skip(count - keep).collect();
69 format!("...{tail}")
70 }
71
72 fn perform_svg_measurement(text: &str, font_family: &str, font_size: f32, scale: f32) -> f32 {
73 if text.is_empty() {
74 return 0.0;
75 }
76
77 let canvas_w = 1000.0;
78 let canvas_h = font_size * 2.5;
79
80 let w_px = (canvas_w * scale) as u32;
81 let h_px = (canvas_h * scale) as u32;
82
83 let svg_data = format!(
84 r##"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg">
85 <text x="{}" y="{}" font-family="{}" font-size="{}" fill="#000000" text-anchor="middle" dominant-baseline="middle">{}</text>
86 </svg>"##,
87 w_px, h_px,
88 canvas_w, canvas_h,
89 canvas_w / 2.0, canvas_h / 2.0,
90 font_family,
91 font_size,
92 text
93 );
94
95 let opt = resvg::usvg::Options::default();
96 let fontdb = crate::widget::input::get_font_db();
97
98 if let Ok(tree) = resvg::usvg::Tree::from_data(svg_data.as_bytes(), &opt, fontdb) {
99 if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(w_px, h_px) {
100 resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
101 let pixels = pixmap.data();
102
103 let mut min_col = None;
104 let mut max_col = None;
105
106 for row in 0..h_px {
107 for col in 0..w_px {
108 let idx = ((row * w_px + col) * 4) as usize;
109 if idx + 3 < pixels.len() && pixels[idx + 3] > 0 {
110 if min_col.is_none() || col < min_col.unwrap() {
111 min_col = Some(col);
112 }
113 if max_col.is_none() || col > max_col.unwrap() {
114 max_col = Some(col);
115 }
116 }
117 }
118 }
119
120 if let (Some(min), Some(max)) = (min_col, max_col) {
121 return (max - min + 1) as f32 / scale;
122 }
123 }
124 }
125
126 TextLabel::estimate_width(text, font_size)
127 }
128
129 #[cfg(test)]
130 mod tests {
131 use super::{truncate_head, truncate_tail};
132
133 #[test]
134 fn short_strings_pass_through() {
135 assert_eq!(truncate_tail("abc", 30), "abc");
136 assert_eq!(truncate_head("abc", 30), "abc");
137 assert_eq!(truncate_tail("", 5), "");
138 assert_eq!(truncate_head("", 5), "");
139 }
140
141 #[test]
142 fn exact_length_passes_through() {
143 let s = "a".repeat(30);
144 assert_eq!(truncate_tail(&s, 30), s);
145 assert_eq!(truncate_head(&s, 30), s);
146 }
147
148 #[test]
149 fn tail_truncates_to_max() {
150 let s = "abcdefghij";
151 assert_eq!(truncate_tail(s, 8), "abcde...");
152 assert_eq!(truncate_tail(s, 8).chars().count(), 8);
153 }
154
155 #[test]
156 fn head_truncates_keeping_tail() {
157 let s = "/very/long/path/to/file";
158 // "..." + 7 tail chars = 10 visible chars budgeted
159 assert_eq!(truncate_head(s, 10), "...to/file");
160 assert_eq!(truncate_head(s, 10).chars().count(), 10);
161 }
162
163 #[test]
164 fn multibyte_at_the_old_panic_boundary() {
165 // 30+ two-byte chars: the old `&name[..27]` byte-slice panicked when
166 // byte 27 fell inside a code point. Char-based truncation must not.
167 let s = "é".repeat(35);
168 let t = truncate_tail(&s, 30);
169 assert_eq!(t.chars().count(), 30);
170 assert!(t.ends_with("..."));
171 let h = truncate_head(&s, 40);
172 assert_eq!(h, s); // 35 chars <= 40: untouched despite 70 bytes
173 let h2 = truncate_head(&s, 30);
174 assert!(h2.starts_with("..."));
175 assert_eq!(h2.chars().count(), 30);
176 }
177
178 #[test]
179 fn tiny_budget_degrades_gracefully() {
180 assert_eq!(truncate_tail("abcdef", 3), "...");
181 assert_eq!(truncate_head("abcdef", 2), "...");
182 }
183 }
184
185