Skip to main content

repose_text/
lib.rs

1use cosmic_text::{
2    Attrs, Buffer, CacheKey, Family, FontSystem, Metrics, Shaping, SwashCache, SwashContent,
3};
4use once_cell::sync::OnceCell;
5use rapidhash::{HashMapExt, RapidHashMap, fast::RapidHasher};
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::{
8    collections::{HashMap, VecDeque},
9    hash::{Hash, Hasher},
10    sync::Mutex,
11};
12use unicode_segmentation::UnicodeSegmentation;
13
14/// Frame counter for cache invalidation strategies.
15static FRAME_COUNTER: AtomicU64 = AtomicU64::new(0);
16
17/// Call this at the start of each frame to enable frame-aware caching.
18pub fn begin_frame() {
19    FRAME_COUNTER.fetch_add(1, Ordering::Relaxed);
20}
21
22pub fn current_frame() -> u64 {
23    FRAME_COUNTER.load(Ordering::Relaxed)
24}
25
26const WRAP_CACHE_CAP: usize = 1024;
27const ELLIP_CACHE_CAP: usize = 2048;
28
29static METRICS_LRU: OnceCell<Mutex<Lru<(u64, u32, u64), TextMetrics>>> = OnceCell::new();
30fn metrics_cache() -> &'static Mutex<Lru<(u64, u32, u64), TextMetrics>> {
31    METRICS_LRU.get_or_init(|| Mutex::new(Lru::new(4096)))
32}
33
34struct Lru<K, V> {
35    map: RapidHashMap<K, V>,
36    order: VecDeque<K>,
37    cap: usize,
38}
39impl<K: std::hash::Hash + Eq + Clone, V> Lru<K, V> {
40    fn new(cap: usize) -> Self {
41        Self {
42            map: RapidHashMap::new(),
43            order: VecDeque::new(),
44            cap,
45        }
46    }
47    fn get(&mut self, k: &K) -> Option<&V> {
48        if self.map.contains_key(k) {
49            // move to back
50            if let Some(pos) = self.order.iter().position(|x| x == k) {
51                let key = self.order.remove(pos).unwrap();
52                self.order.push_back(key);
53            }
54        }
55        self.map.get(k)
56    }
57    fn put(&mut self, k: K, v: V) {
58        if self.map.contains_key(&k) {
59            self.map.insert(k.clone(), v);
60            if let Some(pos) = self.order.iter().position(|x| x == &k) {
61                let key = self.order.remove(pos).unwrap();
62                self.order.push_back(key);
63            }
64            return;
65        }
66        if self.map.len() >= self.cap
67            && let Some(old) = self.order.pop_front()
68        {
69            self.map.remove(&old);
70        }
71        self.order.push_back(k.clone());
72        self.map.insert(k, v);
73    }
74}
75
76static WRAP_LRU: OnceCell<Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<String>, bool)>>> =
77    OnceCell::new();
78
79static WRAP_RANGES_LRU: OnceCell<
80    Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<(usize, usize)>, bool)>>,
81> = OnceCell::new();
82
83static ELLIP_LRU: OnceCell<Mutex<Lru<(u64, u32, u32), String>>> = OnceCell::new();
84
85fn wrap_cache() -> &'static Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<String>, bool)>> {
86    WRAP_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
87}
88
89fn wrap_ranges_cache()
90-> &'static Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<(usize, usize)>, bool)>> {
91    WRAP_RANGES_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
92}
93
94fn ellip_cache() -> &'static Mutex<Lru<(u64, u32, u32), String>> {
95    ELLIP_LRU.get_or_init(|| Mutex::new(Lru::new(ELLIP_CACHE_CAP)))
96}
97
98fn fast_hash(s: &str) -> u64 {
99    use std::hash::{Hash, Hasher};
100    let mut h = RapidHasher::default();
101    s.hash(&mut h);
102    h.finish()
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub struct GlyphKey(pub u64);
107
108pub struct ShapedGlyph {
109    pub key: GlyphKey,
110    pub x: f32,
111    pub y: f32,
112    pub w: f32,
113    pub h: f32,
114    pub bearing_x: f32,
115    pub bearing_y: f32,
116    pub advance: f32,
117}
118
119pub struct GlyphBitmap {
120    pub key: GlyphKey,
121    pub w: u32,
122    pub h: u32,
123    pub content: SwashContent,
124    pub data: Vec<u8>, // Mask: A8; Color/Subpixel: RGBA8
125}
126
127struct Engine {
128    fs: FontSystem,
129    cache: SwashCache,
130    // Map our compact atlas key -> full cosmic_text CacheKey
131    key_map: HashMap<GlyphKey, CacheKey>,
132}
133
134impl Engine {
135    fn get_image(&mut self, key: CacheKey) -> Option<cosmic_text::SwashImage> {
136        // inside this method we may freely borrow both fields
137        self.cache.get_image(&mut self.fs, key).clone()
138    }
139}
140
141static ENGINE: OnceCell<Mutex<Engine>> = OnceCell::new();
142
143fn engine() -> &'static Mutex<Engine> {
144    ENGINE.get_or_init(|| {
145        #[allow(unused_mut)]
146        let mut fs = FontSystem::new();
147
148        let cache = SwashCache::new();
149
150        // #[cfg(any(target_os = "android", target_arch = "wasm32"))]
151        // // Until cosmic-text has android/web font loading support, would save around 15mb?
152        {
153            static FALLBACK_TTF: &[u8] = include_bytes!("assets/OpenSans-Regular.ttf"); // GFonts, OFL licensed
154            static FALLBACK_EMOJI_TTF: &[u8] = include_bytes!("assets/NotoColorEmoji-Regular.ttf"); // GFonts, OFL licensed
155            static FALLBACK_SYMBOLS_TTF: &[u8] =
156                include_bytes!("assets/NotoSansSymbols2-Regular.ttf"); // GFonts, OFL licensed
157            static MATERIAL_SYMBOLS_TTF: &[u8] =
158                include_bytes!("assets/MaterialSymbolsOutlined.ttf"); // Google Fonts, Apache 2.0 licensed
159            {
160                // Register fallback font data into font DB
161                let db = fs.db_mut();
162                db.load_font_data(FALLBACK_TTF.to_vec());
163                db.set_sans_serif_family("Open Sans".to_string());
164
165                db.load_font_data(FALLBACK_SYMBOLS_TTF.to_vec());
166                db.load_font_data(FALLBACK_EMOJI_TTF.to_vec());
167                db.load_font_data(MATERIAL_SYMBOLS_TTF.to_vec());
168            }
169        }
170        Mutex::new(Engine {
171            fs,
172            cache,
173            key_map: HashMap::new(),
174        })
175    })
176}
177
178/// Register a font blob into the global FontSystem.
179pub fn register_font_data(bytes: &'static [u8]) {
180    let mut eng = engine().lock().unwrap();
181    eng.fs.db_mut().load_font_data(bytes.to_vec());
182}
183
184// Utility: stable u64 key from a CacheKey using its Hash impl
185fn key_from_cachekey(k: &CacheKey) -> GlyphKey {
186    let mut h = RapidHasher::default();
187    k.hash(&mut h);
188    GlyphKey(h.finish())
189}
190
191// Shape a single-line string (no wrapping). Returns positioned glyphs relative to baseline y=0.
192// `font_family` optionally overrides the default font (e.g. "Material Symbols Outlined").
193pub fn shape_line(text: &str, px: f32, font_family: Option<&str>) -> Vec<ShapedGlyph> {
194    let mut eng = engine().lock().unwrap();
195
196    // Construct a temporary buffer each call; FontSystem and caches are retained globally
197    let mut buf = Buffer::new(&mut eng.fs, Metrics::new(px, px * 1.3));
198    {
199        // Borrow with FS for ergonomic setters (no FS arg)
200        let mut b = buf.borrow_with(&mut eng.fs);
201        b.set_size(None, None);
202        let attrs = match font_family {
203            Some(family) => Attrs::new().family(Family::Name(family)),
204            None => Attrs::new(),
205        };
206        b.set_text(text, &attrs, Shaping::Advanced, None);
207        b.shape_until_scroll(true);
208    }
209
210    let mut out = Vec::new();
211    for run in buf.layout_runs() {
212        for g in run.glyphs {
213            // Compute physical glyph: gives cache_key and integer pixel position
214            let phys = g.physical((0.0, run.line_y), 1.0);
215            let key = key_from_cachekey(&phys.cache_key);
216            eng.key_map.insert(key, phys.cache_key);
217
218            // Query raster cache to get placement for metrics
219            let img_opt = eng.get_image(phys.cache_key);
220            let (w, h, left, top) = if let Some(img) = img_opt.as_ref() {
221                (
222                    img.placement.width as f32,
223                    img.placement.height as f32,
224                    img.placement.left as f32,
225                    img.placement.top as f32,
226                )
227            } else {
228                (0.0, 0.0, 0.0, 0.0)
229            };
230
231            out.push(ShapedGlyph {
232                key,
233                x: g.x + g.x_offset, // visual x
234                y: run.line_y,       // baseline y
235                w,
236                h,
237                bearing_x: left,
238                bearing_y: top,
239                advance: g.w,
240            });
241        }
242    }
243    out
244}
245
246// Rasterize a glyph mask (A8) or color/subpixel (RGBA8) for a given shaped key.
247// Returns owned pixels to avoid borrowing from the cache.
248pub fn rasterize(key: GlyphKey, _px: f32) -> Option<GlyphBitmap> {
249    let mut eng = engine().lock().unwrap();
250    let &ck = eng.key_map.get(&key)?;
251
252    let img = eng.get_image(ck).as_ref()?.clone();
253    Some(GlyphBitmap {
254        key,
255        w: img.placement.width,
256        h: img.placement.height,
257        content: img.content,
258        data: img.data, // already a Vec<u8>
259    })
260}
261
262/// Look up the full CacheKey for a compact GlyphKey.
263pub fn lookup_cache_key(key: GlyphKey) -> Option<cosmic_text::CacheKey> {
264    let eng = engine().lock().unwrap();
265    eng.key_map.get(&key).copied()
266}
267
268/// Extract vector outline commands for a glyph (uncached — caller should cache).
269/// Returns quadratic + cubic bezier commands in font-unit scaled coordinates.
270pub fn extract_outline_commands(
271    cache_key: cosmic_text::CacheKey,
272) -> Option<Box<[cosmic_text::Command]>> {
273    let mut eng = engine().lock().unwrap();
274    let Engine {
275        ref mut cache,
276        ref mut fs,
277        ..
278    } = *eng;
279    cache.get_outline_commands_uncached(fs, cache_key)
280}
281
282/// Look up the CacheKey for a GlyphKey and extract outline commands in one engine lock.
283/// Returns `None` if the GlyphKey isn't in the key map or extraction fails.
284pub fn lookup_and_extract_outline(
285    key: GlyphKey,
286) -> Option<(cosmic_text::CacheKey, Box<[cosmic_text::Command]>)> {
287    let mut eng = engine().lock().unwrap();
288    let ck = eng.key_map.get(&key).copied()?;
289    let Engine {
290        ref mut cache,
291        ref mut fs,
292        ..
293    } = *eng;
294    let cmds = cache.get_outline_commands_uncached(fs, ck)?;
295    Some((ck, cmds))
296}
297
298// Text metrics for TextField: positions per grapheme boundary and byte offsets.
299#[derive(Clone)]
300pub struct TextMetrics {
301    pub positions: Vec<f32>,      // cumulative advance per boundary (len == n+1)
302    pub byte_offsets: Vec<usize>, // byte index per boundary (len == n+1)
303}
304
305/// Computes caret mapping using shaping (no wrapping).
306/// `font_family` optionally overrides the default font (e.g. "Material Symbols Outlined").
307pub fn metrics_for_textfield(text: &str, px: f32, font_family: Option<&str>) -> TextMetrics {
308    let family_hash = font_family.map(fast_hash).unwrap_or(0);
309    let key = (fast_hash(text), (px * 100.0) as u32, family_hash);
310    if let Some(m) = metrics_cache().lock().unwrap().get(&key).cloned() {
311        return m;
312    }
313    let mut eng = engine().lock().unwrap();
314    let mut buf = Buffer::new(&mut eng.fs, Metrics::new(px, px * 1.3));
315    {
316        let mut b = buf.borrow_with(&mut eng.fs);
317        b.set_size(None, None);
318        let attrs = match font_family {
319            Some(family) => Attrs::new().family(Family::Name(family)),
320            None => Attrs::new(),
321        };
322        b.set_text(text, &attrs, Shaping::Advanced, None);
323        b.shape_until_scroll(true);
324    }
325    let mut edges: Vec<(usize, f32)> = Vec::new();
326    let mut last_x = 0.0f32;
327    for run in buf.layout_runs() {
328        for g in run.glyphs {
329            let right = g.x + g.w;
330            last_x = right.max(last_x);
331            edges.push((g.end, right));
332        }
333    }
334    if edges.last().map(|e| e.0) != Some(text.len()) {
335        edges.push((text.len(), last_x));
336    }
337    let mut positions = Vec::with_capacity(text.graphemes(true).count() + 1);
338    let mut byte_offsets = Vec::with_capacity(positions.capacity());
339    positions.push(0.0);
340    byte_offsets.push(0);
341    let mut last_byte = 0usize;
342    for (b, _) in text.grapheme_indices(true) {
343        positions
344            .push(positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, b));
345        byte_offsets.push(b);
346        last_byte = b;
347    }
348    if *byte_offsets.last().unwrap_or(&0) != text.len() {
349        positions.push(
350            positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, text.len()),
351        );
352        byte_offsets.push(text.len());
353    }
354    let m = TextMetrics {
355        positions,
356        byte_offsets,
357    };
358    metrics_cache().lock().unwrap().put(key, m.clone());
359    m
360}
361
362fn width_between(edges: &[(usize, f32)], start_b: usize, end_b: usize) -> f32 {
363    let x0 = lookup_right(edges, start_b);
364    let x1 = lookup_right(edges, end_b);
365    (x1 - x0).max(0.0)
366}
367fn lookup_right(edges: &[(usize, f32)], b: usize) -> f32 {
368    match edges.binary_search_by_key(&b, |e| e.0) {
369        Ok(i) => edges[i].1,
370        Err(i) => {
371            if i == 0 {
372                0.0
373            } else {
374                edges[i - 1].1
375            }
376        }
377    }
378}
379
380/// Greedy wrap into lines that fit max_width. Prefers breaking at whitespace,
381/// falls back to grapheme boundaries. If max_lines is Some and we truncate,
382/// caller can choose to ellipsize the last visible line.
383pub fn wrap_lines(
384    text: &str,
385    px: f32,
386    max_width: f32,
387    max_lines: Option<usize>,
388    soft_wrap: bool,
389) -> (Vec<String>, bool) {
390    if text.is_empty() || max_width <= 0.0 {
391        return (vec![String::new()], false);
392    }
393    if !soft_wrap {
394        return (vec![text.to_string()], false);
395    }
396
397    let max_lines_key: u16 = match max_lines {
398        None => 0,
399        Some(n) => {
400            let n = n.min(u16::MAX as usize - 1) as u16;
401            n.saturating_add(1)
402        }
403    };
404    let key = (
405        fast_hash(text),
406        (px * 100.0) as u32,
407        (max_width * 100.0) as u32,
408        max_lines_key,
409        soft_wrap,
410    );
411    if let Some(h) = wrap_cache().lock().unwrap().get(&key).cloned() {
412        return h;
413    }
414
415    // Shape once and reuse positions/byte mapping.
416    let m = metrics_for_textfield(text, px, None);
417    // Fast path: fits
418    if let Some(&last) = m.positions.last()
419        && last <= max_width + 0.5
420    {
421        return (vec![text.to_string()], false);
422    }
423
424    // Helper: width of substring [start..end] in bytes
425    let width_of = |start_b: usize, end_b: usize| -> f32 {
426        let i0 = match m.byte_offsets.binary_search(&start_b) {
427            Ok(i) | Err(i) => i,
428        };
429        let i1 = match m.byte_offsets.binary_search(&end_b) {
430            Ok(i) | Err(i) => i,
431        };
432        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
433            .max(0.0)
434    };
435
436    let mut out: Vec<String> = Vec::new();
437    let mut truncated = false;
438
439    let mut line_start = 0usize; // byte index
440    let mut best_break = line_start;
441
442    // Iterate word boundaries (keep whitespace tokens so they factor widths)
443    for tok in text.split_word_bounds() {
444        let tok_start = best_break;
445        let tok_end = tok_start + tok.len();
446        let w = width_of(line_start, tok_end);
447
448        if w <= max_width + 0.5 {
449            best_break = tok_end;
450            continue;
451        }
452
453        // Need to break the line before tok_end.
454        if best_break > line_start {
455            // Break at last good boundary
456            out.push(text[line_start..best_break].trim_end().to_string());
457            line_start = best_break;
458        } else {
459            // Token itself too wide: force break inside token at grapheme boundaries
460            let mut cut = tok_start;
461            for g in tok.grapheme_indices(true) {
462                let next = tok_start + g.0 + g.1.len();
463                if width_of(line_start, next) <= max_width + 0.5 {
464                    cut = next;
465                } else {
466                    break;
467                }
468            }
469            if cut == line_start {
470                // nothing fits; fall back to single grapheme
471                if let Some((ofs, grapheme)) = tok.grapheme_indices(true).next() {
472                    cut = tok_start + ofs + grapheme.len();
473                }
474            }
475            out.push(text[line_start..cut].to_string());
476            line_start = cut;
477        }
478
479        // Check max_lines
480        if let Some(ml) = max_lines
481            && out.len() >= ml
482        {
483            truncated = true;
484            // Stop; caller may ellipsize the last line
485            line_start = line_start.min(text.len());
486            break;
487        }
488
489        // Reset best_break for new line
490        best_break = line_start;
491
492        // Re-consider current token if not fully consumed
493        if line_start < tok_end {
494            // recompute width with the remaining token portion
495            if width_of(line_start, tok_end) <= max_width + 0.5 {
496                best_break = tok_end;
497            } else {
498                // will be handled in next iterations (or forced again)
499            }
500        }
501    }
502
503    // Push tail if allowed
504    if line_start < text.len() && max_lines.is_none_or(|ml| out.len() < ml) {
505        out.push(text[line_start..].trim_end().to_string());
506    }
507
508    let res = (out, truncated);
509
510    wrap_cache().lock().unwrap().put(key, res.clone());
511    res
512}
513
514/// Like `wrap_lines`, but returns byte ranges into the original `text`
515/// for each visual line. This is required for multi-line editing so
516/// caret/selection mapping stays correct.
517///
518/// Ranges are half-open `[start, end)`, and never include the '\n' char
519/// (hard line breaks end a range at the '\n' byte index).
520pub fn wrap_line_ranges(
521    text: &str,
522    px: f32,
523    max_width: f32,
524    max_lines: Option<usize>,
525    soft_wrap: bool,
526) -> (Vec<(usize, usize)>, bool) {
527    if text.is_empty() || max_width <= 0.0 {
528        return (vec![(0, 0)], false);
529    }
530    if !soft_wrap {
531        // Hard lines only (split on '\n' but no width wrapping)
532        let mut out = Vec::new();
533        let mut start = 0usize;
534        for (i, ch) in text.char_indices() {
535            if ch == '\n' {
536                out.push((start, i));
537                start = i + 1;
538            }
539        }
540        out.push((start, text.len()));
541        return (out, false);
542    }
543
544    let max_lines_key: u16 = match max_lines {
545        None => 0,
546        Some(n) => {
547            let n = n.min(u16::MAX as usize - 1) as u16;
548            n.saturating_add(1)
549        }
550    };
551    let key = (
552        fast_hash(text),
553        (px * 100.0) as u32,
554        (max_width * 100.0) as u32,
555        max_lines_key,
556        soft_wrap,
557    );
558    if let Some(v) = wrap_ranges_cache().lock().unwrap().get(&key).cloned() {
559        return v;
560    }
561
562    // Shape once for width queries (whole string)
563    let m = metrics_for_textfield(text, px, None);
564
565    // Helper: width of substring [start..end] in bytes using m
566    let width_of = |start_b: usize, end_b: usize| -> f32 {
567        let i0 = match m.byte_offsets.binary_search(&start_b) {
568            Ok(i) | Err(i) => i,
569        };
570        let i1 = match m.byte_offsets.binary_search(&end_b) {
571            Ok(i) | Err(i) => i,
572        };
573        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
574            .max(0.0)
575    };
576
577    let mut out: Vec<(usize, usize)> = Vec::new();
578    let mut truncated = false;
579
580    // Process hard lines split by '\n' while preserving original indices.
581    let mut line0_start = 0usize;
582    for (i, ch) in text.char_indices() {
583        if ch == '\n' {
584            let (mut ranges, tr) = wrap_one_hard_line_ranges(
585                text,
586                line0_start,
587                i,
588                max_width,
589                max_lines.map(|ml| ml.saturating_sub(out.len())),
590                &width_of,
591            );
592            out.append(&mut ranges);
593            if tr {
594                truncated = true;
595                break;
596            }
597            line0_start = i + 1;
598
599            if let Some(ml) = max_lines
600                && out.len() >= ml
601            {
602                truncated = true;
603                break;
604            }
605        }
606    }
607    if !truncated {
608        let (mut ranges, tr) = wrap_one_hard_line_ranges(
609            text,
610            line0_start,
611            text.len(),
612            max_width,
613            max_lines.map(|ml| ml.saturating_sub(out.len())),
614            &width_of,
615        );
616        out.append(&mut ranges);
617        truncated = tr;
618    }
619
620    if out.is_empty() {
621        out.push((0, 0));
622    }
623
624    let res = (out, truncated);
625    wrap_ranges_cache().lock().unwrap().put(key, res.clone());
626    res
627}
628
629fn wrap_one_hard_line_ranges(
630    text: &str,
631    start: usize,
632    end: usize,
633    max_width: f32,
634    max_lines: Option<usize>,
635    width_of: &dyn Fn(usize, usize) -> f32,
636) -> (Vec<(usize, usize)>, bool) {
637    let mut out = Vec::new();
638    let mut t = false;
639
640    if start >= end {
641        out.push((start, start));
642        return (out, false);
643    }
644
645    // Fast path: whole line fits
646    if width_of(start, end) <= max_width + 0.5 {
647        out.push((start, end));
648        return (out, false);
649    }
650
651    let mut line_start = start;
652    let mut best_break = line_start;
653    let mut unconsumed_start = start;
654
655    for tok in text[line_start..end].split_word_bounds() {
656        let tok_abs_start = unconsumed_start;
657        let tok_abs_end = tok_abs_start + tok.len();
658        unconsumed_start = tok_abs_end;
659
660        let w = width_of(line_start, tok_abs_end);
661        if w <= max_width + 0.5 {
662            best_break = tok_abs_end;
663            continue;
664        }
665
666        // Need break before tok_abs_end.
667        if best_break > line_start {
668            out.push((line_start, best_break));
669            line_start = best_break;
670        } else {
671            // Token too wide: force break at grapheme boundaries
672            let mut cut = tok_abs_start;
673            for (ofs, g) in tok.grapheme_indices(true) {
674                let next = tok_abs_start + ofs + g.len();
675                if width_of(line_start, next) <= max_width + 0.5 {
676                    cut = next;
677                } else {
678                    break;
679                }
680            }
681            if cut == line_start
682                && let Some((ofs, gr)) = tok.grapheme_indices(true).next()
683            {
684                cut = tok_abs_start + ofs + gr.len();
685            }
686            out.push((line_start, cut));
687            line_start = cut;
688        }
689
690        // Max lines check
691        if let Some(ml) = max_lines
692            && out.len() >= ml
693        {
694            t = true;
695            break;
696        }
697
698        best_break = line_start;
699    }
700
701    // Tail
702    if !t && line_start < end && max_lines.is_none_or(|ml| out.len() < ml) {
703        out.push((line_start, end));
704    }
705
706    (out, t)
707}
708
709/// Return a string truncated to fit max_width at the given px size, appending '…' if truncated.
710pub fn ellipsize_line(text: &str, px: f32, max_width: f32) -> String {
711    if text.is_empty() || max_width <= 0.0 {
712        return String::new();
713    }
714    let key = (
715        fast_hash(text),
716        (px * 100.0) as u32,
717        (max_width * 100.0) as u32,
718    );
719    if let Some(s) = ellip_cache().lock().unwrap().get(&key).cloned() {
720        return s;
721    }
722    let m = metrics_for_textfield(text, px, None);
723    if let Some(&last) = m.positions.last()
724        && last <= max_width + 0.5
725    {
726        return text.to_string();
727    }
728    let _el = "…";
729    let e_w = ellipsis_width(px);
730    if e_w >= max_width {
731        return String::new();
732    }
733    // Find last grapheme index whose width + ellipsis fits
734    let mut cut_i = 0usize;
735    for i in 0..m.positions.len() {
736        if m.positions[i] + e_w <= max_width {
737            cut_i = i;
738        } else {
739            break;
740        }
741    }
742    let byte = m
743        .byte_offsets
744        .get(cut_i)
745        .copied()
746        .unwrap_or(0)
747        .min(text.len());
748    let mut out = String::with_capacity(byte + 3);
749    out.push_str(&text[..byte]);
750    out.push('…');
751
752    let s = out;
753    ellip_cache().lock().unwrap().put(key, s.clone());
754
755    s
756}
757
758fn ellipsis_width(px: f32) -> f32 {
759    static ELLIP_W_LRU: OnceCell<Mutex<Lru<u32, f32>>> = OnceCell::new();
760    let cache = ELLIP_W_LRU.get_or_init(|| Mutex::new(Lru::new(64)));
761    let key = (px * 100.0) as u32;
762    if let Some(w) = cache.lock().unwrap().get(&key).copied() {
763        return w;
764    }
765    let w = if let Some(g) = crate::shape_line("…", px, None).last() {
766        g.x + g.advance
767    } else {
768        0.0
769    };
770    cache.lock().unwrap().put(key, w);
771    w
772}