GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
src/widget/display/text_sizer.rs (7.9K)
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 /// `s` with the five XML specials escaped, for text and attribute values
73 /// alike.
74 fn xml_escape(s: &str) -> std::borrow::Cow<'_, str> {
75 if !s.contains(['&', '<', '>', '"', '\'']) {
76 return std::borrow::Cow::Borrowed(s);
77 }
78 let mut out = String::with_capacity(s.len() + 8);
79 for c in s.chars() {
80 match c {
81 '&' => out.push_str("&"),
82 '<' => out.push_str("<"),
83 '>' => out.push_str(">"),
84 '"' => out.push_str("""),
85 '\'' => out.push_str("'"),
86 c => out.push(c),
87 }
88 }
89 std::borrow::Cow::Owned(out)
90 }
91
92 /// The one-line SVG the measurement renders: `text` centered on a canvas
93 /// 1000 logical px wide. Both the text and the family are escaped — until
94 /// 2026-10-06 they went in raw, so a label with `&` or `<` (a family with a
95 /// quote) was not XML, failed to parse, and fell back to the estimate.
96 fn measurement_svg(text: &str, font_family: &str, font_size: f32, scale: f32) -> (String, u32, u32) {
97 let canvas_w = 1000.0;
98 let canvas_h = font_size * 2.5;
99
100 let w_px = (canvas_w * scale) as u32;
101 let h_px = (canvas_h * scale) as u32;
102
103 let svg_data = format!(
104 r##"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg">
105 <text x="{}" y="{}" font-family="{}" font-size="{}" fill="#000000" text-anchor="middle" dominant-baseline="middle">{}</text>
106 </svg>"##,
107 w_px, h_px,
108 canvas_w, canvas_h,
109 canvas_w / 2.0, canvas_h / 2.0,
110 xml_escape(font_family),
111 font_size,
112 xml_escape(text)
113 );
114 (svg_data, w_px, h_px)
115 }
116
117 fn perform_svg_measurement(text: &str, font_family: &str, font_size: f32, scale: f32) -> f32 {
118 if text.is_empty() {
119 return 0.0;
120 }
121 let (svg_data, w_px, h_px) = measurement_svg(text, font_family, font_size, scale);
122
123 let opt = resvg::usvg::Options::default();
124 let fontdb = crate::widget::input::get_font_db();
125
126 if let Ok(tree) = resvg::usvg::Tree::from_data(svg_data.as_bytes(), &opt, fontdb) {
127 if let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(w_px, h_px) {
128 resvg::render(&tree, resvg::tiny_skia::Transform::default(), &mut pixmap.as_mut());
129 let pixels = pixmap.data();
130
131 let mut min_col = None;
132 let mut max_col = None;
133
134 for row in 0..h_px {
135 for col in 0..w_px {
136 let idx = ((row * w_px + col) * 4) as usize;
137 if idx + 3 < pixels.len() && pixels[idx + 3] > 0 {
138 if min_col.is_none() || col < min_col.unwrap() {
139 min_col = Some(col);
140 }
141 if max_col.is_none() || col > max_col.unwrap() {
142 max_col = Some(col);
143 }
144 }
145 }
146 }
147
148 if let (Some(min), Some(max)) = (min_col, max_col) {
149 return (max - min + 1) as f32 / scale;
150 }
151 }
152 }
153
154 TextLabel::estimate_width(text, font_size)
155 }
156
157 #[cfg(test)]
158 mod tests {
159 use super::{measurement_svg, truncate_head, truncate_tail, xml_escape};
160
161 #[test]
162 fn xml_specials_are_escaped() {
163 assert_eq!(xml_escape("plain"), "plain");
164 assert_eq!(xml_escape(r#"R&D <x> "q" 'a'"#), "R&D <x> "q" 'a'");
165 }
166
167 #[test]
168 fn a_label_with_xml_specials_still_parses() {
169 // The raw interpolation made each of these an XML error, and the
170 // width fell back to the character-count estimate.
171 let opt = resvg::usvg::Options::default();
172 let fontdb = resvg::usvg::fontdb::Database::new();
173 for (text, family) in [("Tom & Jerry", "Sans"), ("a < b", "Sans"), ("x > y", "Sans"), ("plain", r#"My "Font""#)] {
174 let (svg, _, _) = measurement_svg(text, family, 14.0, 1.0);
175 assert!(
176 resvg::usvg::Tree::from_data(svg.as_bytes(), &opt, &fontdb).is_ok(),
177 "{text:?} in {family:?} did not parse: {svg}"
178 );
179 }
180 }
181
182 #[test]
183 fn short_strings_pass_through() {
184 assert_eq!(truncate_tail("abc", 30), "abc");
185 assert_eq!(truncate_head("abc", 30), "abc");
186 assert_eq!(truncate_tail("", 5), "");
187 assert_eq!(truncate_head("", 5), "");
188 }
189
190 #[test]
191 fn exact_length_passes_through() {
192 let s = "a".repeat(30);
193 assert_eq!(truncate_tail(&s, 30), s);
194 assert_eq!(truncate_head(&s, 30), s);
195 }
196
197 #[test]
198 fn tail_truncates_to_max() {
199 let s = "abcdefghij";
200 assert_eq!(truncate_tail(s, 8), "abcde...");
201 assert_eq!(truncate_tail(s, 8).chars().count(), 8);
202 }
203
204 #[test]
205 fn head_truncates_keeping_tail() {
206 let s = "/very/long/path/to/file";
207 // "..." + 7 tail chars = 10 visible chars budgeted
208 assert_eq!(truncate_head(s, 10), "...to/file");
209 assert_eq!(truncate_head(s, 10).chars().count(), 10);
210 }
211
212 #[test]
213 fn multibyte_at_the_old_panic_boundary() {
214 // 30+ two-byte chars: the old `&name[..27]` byte-slice panicked when
215 // byte 27 fell inside a code point. Char-based truncation must not.
216 let s = "é".repeat(35);
217 let t = truncate_tail(&s, 30);
218 assert_eq!(t.chars().count(), 30);
219 assert!(t.ends_with("..."));
220 let h = truncate_head(&s, 40);
221 assert_eq!(h, s); // 35 chars <= 40: untouched despite 70 bytes
222 let h2 = truncate_head(&s, 30);
223 assert!(h2.starts_with("..."));
224 assert_eq!(h2.chars().count(), 30);
225 }
226
227 #[test]
228 fn tiny_budget_degrades_gracefully() {
229 assert_eq!(truncate_tail("abcdef", 3), "...");
230 assert_eq!(truncate_head("abcdef", 2), "...");
231 }
232 }
233
234