Skip to main content

repose_text/
lib.rs

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