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 (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