git.lucas.co / cce-ui
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("&amp;"),
 82             '<' => out.push_str("&lt;"),
 83             '>' => out.push_str("&gt;"),
 84             '"' => out.push_str("&quot;"),
 85             '\'' => out.push_str("&apos;"),
 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&amp;D &lt;x&gt; &quot;q&quot; &apos;a&apos;");
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