repose-text 0.19.4

Text handling (wrappers around cosmic-text apis)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
use cosmic_text::{
    Attrs, Buffer, CacheKey, Family, FontSystem, Metrics, Shaping, SwashCache, SwashContent,
};
use once_cell::sync::OnceCell;
use rapidhash::{HashMapExt, RapidHashMap, fast::RapidHasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
    collections::{HashMap, VecDeque},
    hash::{Hash, Hasher},
    sync::Mutex,
};
use unicode_segmentation::UnicodeSegmentation;

/// Frame counter for cache invalidation strategies.
static FRAME_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Call this at the start of each frame to enable frame-aware caching.
pub fn begin_frame() {
    FRAME_COUNTER.fetch_add(1, Ordering::Relaxed);
}

pub fn current_frame() -> u64 {
    FRAME_COUNTER.load(Ordering::Relaxed)
}

const WRAP_CACHE_CAP: usize = 1024;
const ELLIP_CACHE_CAP: usize = 2048;

static METRICS_LRU: OnceCell<Mutex<Lru<(u64, u32, u64), TextMetrics>>> = OnceCell::new();
fn metrics_cache() -> &'static Mutex<Lru<(u64, u32, u64), TextMetrics>> {
    METRICS_LRU.get_or_init(|| Mutex::new(Lru::new(4096)))
}

struct Lru<K, V> {
    map: RapidHashMap<K, V>,
    order: VecDeque<K>,
    cap: usize,
}
impl<K: std::hash::Hash + Eq + Clone, V> Lru<K, V> {
    fn new(cap: usize) -> Self {
        Self {
            map: RapidHashMap::new(),
            order: VecDeque::new(),
            cap,
        }
    }
    fn get(&mut self, k: &K) -> Option<&V> {
        if self.map.contains_key(k) {
            // move to back
            if let Some(pos) = self.order.iter().position(|x| x == k) {
                let key = self.order.remove(pos).unwrap();
                self.order.push_back(key);
            }
        }
        self.map.get(k)
    }
    fn put(&mut self, k: K, v: V) {
        if self.map.contains_key(&k) {
            self.map.insert(k.clone(), v);
            if let Some(pos) = self.order.iter().position(|x| x == &k) {
                let key = self.order.remove(pos).unwrap();
                self.order.push_back(key);
            }
            return;
        }
        if self.map.len() >= self.cap
            && let Some(old) = self.order.pop_front()
        {
            self.map.remove(&old);
        }
        self.order.push_back(k.clone());
        self.map.insert(k, v);
    }
}

static WRAP_LRU: OnceCell<Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<String>, bool)>>> =
    OnceCell::new();

static WRAP_RANGES_LRU: OnceCell<
    Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<(usize, usize)>, bool)>>,
> = OnceCell::new();

static ELLIP_LRU: OnceCell<Mutex<Lru<(u64, u32, u32), String>>> = OnceCell::new();

fn wrap_cache() -> &'static Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<String>, bool)>> {
    WRAP_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
}

fn wrap_ranges_cache()
-> &'static Mutex<Lru<(u64, u32, u32, u16, bool), (Vec<(usize, usize)>, bool)>> {
    WRAP_RANGES_LRU.get_or_init(|| Mutex::new(Lru::new(WRAP_CACHE_CAP)))
}

fn ellip_cache() -> &'static Mutex<Lru<(u64, u32, u32), String>> {
    ELLIP_LRU.get_or_init(|| Mutex::new(Lru::new(ELLIP_CACHE_CAP)))
}

fn fast_hash(s: &str) -> u64 {
    use std::hash::{Hash, Hasher};
    let mut h = RapidHasher::default();
    s.hash(&mut h);
    h.finish()
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct GlyphKey(pub u64);

pub struct ShapedGlyph {
    pub key: GlyphKey,
    pub x: f32,
    pub y: f32,
    pub w: f32,
    pub h: f32,
    pub bearing_x: f32,
    pub bearing_y: f32,
    pub advance: f32,
}

pub struct GlyphBitmap {
    pub key: GlyphKey,
    pub w: u32,
    pub h: u32,
    pub content: SwashContent,
    pub data: Vec<u8>, // Mask: A8; Color/Subpixel: RGBA8
}

struct Engine {
    fs: FontSystem,
    cache: SwashCache,
    // Map our compact atlas key -> full cosmic_text CacheKey
    key_map: HashMap<GlyphKey, CacheKey>,
}

impl Engine {
    fn get_image(&mut self, key: CacheKey) -> Option<cosmic_text::SwashImage> {
        // inside this method we may freely borrow both fields
        self.cache.get_image(&mut self.fs, key).clone()
    }
}

static ENGINE: OnceCell<Mutex<Engine>> = OnceCell::new();

fn engine() -> &'static Mutex<Engine> {
    ENGINE.get_or_init(|| {
        #[allow(unused_mut)]
        let mut fs = FontSystem::new();

        let cache = SwashCache::new();

        // #[cfg(any(target_os = "android", target_arch = "wasm32"))]
        // // Until cosmic-text has android/web font loading support, would save around 15mb?
        {
            static FALLBACK_TTF: &[u8] = include_bytes!("assets/OpenSans-Regular.ttf"); // GFonts, OFL licensed
            static FALLBACK_EMOJI_TTF: &[u8] = include_bytes!("assets/NotoColorEmoji-Regular.ttf"); // GFonts, OFL licensed
            static FALLBACK_SYMBOLS_TTF: &[u8] =
                include_bytes!("assets/NotoSansSymbols2-Regular.ttf"); // GFonts, OFL licensed
            static MATERIAL_SYMBOLS_TTF: &[u8] =
                include_bytes!("assets/MaterialSymbolsOutlined.ttf"); // Google Fonts, Apache 2.0 licensed
            {
                // Register fallback font data into font DB
                let db = fs.db_mut();
                db.load_font_data(FALLBACK_TTF.to_vec());
                db.set_sans_serif_family("Open Sans".to_string());

                db.load_font_data(FALLBACK_SYMBOLS_TTF.to_vec());
                db.load_font_data(FALLBACK_EMOJI_TTF.to_vec());
                db.load_font_data(MATERIAL_SYMBOLS_TTF.to_vec());
            }
        }
        Mutex::new(Engine {
            fs,
            cache,
            key_map: HashMap::new(),
        })
    })
}

/// Register a font blob into the global FontSystem.
pub fn register_font_data(bytes: &'static [u8]) {
    let mut eng = engine().lock().unwrap();
    eng.fs.db_mut().load_font_data(bytes.to_vec());
}

// Utility: stable u64 key from a CacheKey using its Hash impl
fn key_from_cachekey(k: &CacheKey) -> GlyphKey {
    let mut h = RapidHasher::default();
    k.hash(&mut h);
    GlyphKey(h.finish())
}

// Shape a single-line string (no wrapping). Returns positioned glyphs relative to baseline y=0.
// `font_family` optionally overrides the default font (e.g. "Material Symbols Outlined").
pub fn shape_line(text: &str, px: f32, font_family: Option<&str>) -> Vec<ShapedGlyph> {
    let mut eng = engine().lock().unwrap();

    // Construct a temporary buffer each call; FontSystem and caches are retained globally
    let mut buf = Buffer::new(&mut eng.fs, Metrics::new(px, px * 1.3));
    {
        // Borrow with FS for ergonomic setters (no FS arg)
        let mut b = buf.borrow_with(&mut eng.fs);
        b.set_size(None, None);
        let attrs = match font_family {
            Some(family) => Attrs::new().family(Family::Name(family)),
            None => Attrs::new(),
        };
        b.set_text(text, &attrs, Shaping::Advanced, None);
        b.shape_until_scroll(true);
    }

    let mut out = Vec::new();
    for run in buf.layout_runs() {
        for g in run.glyphs {
            // Compute physical glyph: gives cache_key and integer pixel position
            let phys = g.physical((0.0, run.line_y), 1.0);
            let key = key_from_cachekey(&phys.cache_key);
            eng.key_map.insert(key, phys.cache_key);

            // Query raster cache to get placement for metrics
            let img_opt = eng.get_image(phys.cache_key);
            let (w, h, left, top) = if let Some(img) = img_opt.as_ref() {
                (
                    img.placement.width as f32,
                    img.placement.height as f32,
                    img.placement.left as f32,
                    img.placement.top as f32,
                )
            } else {
                (0.0, 0.0, 0.0, 0.0)
            };

            out.push(ShapedGlyph {
                key,
                x: g.x + g.x_offset, // visual x
                y: run.line_y,       // baseline y
                w,
                h,
                bearing_x: left,
                bearing_y: top,
                advance: g.w,
            });
        }
    }
    out
}

// Rasterize a glyph mask (A8) or color/subpixel (RGBA8) for a given shaped key.
// Returns owned pixels to avoid borrowing from the cache.
pub fn rasterize(key: GlyphKey, _px: f32) -> Option<GlyphBitmap> {
    let mut eng = engine().lock().unwrap();
    let &ck = eng.key_map.get(&key)?;

    let img = eng.get_image(ck).as_ref()?.clone();
    Some(GlyphBitmap {
        key,
        w: img.placement.width,
        h: img.placement.height,
        content: img.content,
        data: img.data, // already a Vec<u8>
    })
}

/// Look up the full CacheKey for a compact GlyphKey.
pub fn lookup_cache_key(key: GlyphKey) -> Option<cosmic_text::CacheKey> {
    let eng = engine().lock().unwrap();
    eng.key_map.get(&key).copied()
}

/// Extract vector outline commands for a glyph (uncached — caller should cache).
/// Returns quadratic + cubic bezier commands in font-unit scaled coordinates.
pub fn extract_outline_commands(
    cache_key: cosmic_text::CacheKey,
) -> Option<Box<[cosmic_text::Command]>> {
    let mut eng = engine().lock().unwrap();
    let Engine {
        ref mut cache,
        ref mut fs,
        ..
    } = *eng;
    cache.get_outline_commands_uncached(fs, cache_key)
}

/// Look up the CacheKey for a GlyphKey and extract outline commands in one engine lock.
/// Returns `None` if the GlyphKey isn't in the key map or extraction fails.
pub fn lookup_and_extract_outline(
    key: GlyphKey,
) -> Option<(cosmic_text::CacheKey, Box<[cosmic_text::Command]>)> {
    let mut eng = engine().lock().unwrap();
    let ck = eng.key_map.get(&key).copied()?;
    let Engine {
        ref mut cache,
        ref mut fs,
        ..
    } = *eng;
    let cmds = cache.get_outline_commands_uncached(fs, ck)?;
    Some((ck, cmds))
}

// Text metrics for TextField: positions per grapheme boundary and byte offsets.
#[derive(Clone)]
pub struct TextMetrics {
    pub positions: Vec<f32>,      // cumulative advance per boundary (len == n+1)
    pub byte_offsets: Vec<usize>, // byte index per boundary (len == n+1)
}

/// Computes caret mapping using shaping (no wrapping).
/// `font_family` optionally overrides the default font (e.g. "Material Symbols Outlined").
pub fn metrics_for_textfield(text: &str, px: f32, font_family: Option<&str>) -> TextMetrics {
    let family_hash = font_family.map(fast_hash).unwrap_or(0);
    let key = (fast_hash(text), (px * 100.0) as u32, family_hash);
    if let Some(m) = metrics_cache().lock().unwrap().get(&key).cloned() {
        return m;
    }
    let mut eng = engine().lock().unwrap();
    let mut buf = Buffer::new(&mut eng.fs, Metrics::new(px, px * 1.3));
    {
        let mut b = buf.borrow_with(&mut eng.fs);
        b.set_size(None, None);
        let attrs = match font_family {
            Some(family) => Attrs::new().family(Family::Name(family)),
            None => Attrs::new(),
        };
        b.set_text(text, &attrs, Shaping::Advanced, None);
        b.shape_until_scroll(true);
    }
    let mut edges: Vec<(usize, f32)> = Vec::new();
    let mut last_x = 0.0f32;
    for run in buf.layout_runs() {
        for g in run.glyphs {
            let right = g.x + g.w;
            last_x = right.max(last_x);
            edges.push((g.end, right));
        }
    }
    if edges.last().map(|e| e.0) != Some(text.len()) {
        edges.push((text.len(), last_x));
    }
    let mut positions = Vec::with_capacity(text.graphemes(true).count() + 1);
    let mut byte_offsets = Vec::with_capacity(positions.capacity());
    positions.push(0.0);
    byte_offsets.push(0);
    let mut last_byte = 0usize;
    for (b, _) in text.grapheme_indices(true) {
        positions
            .push(positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, b));
        byte_offsets.push(b);
        last_byte = b;
    }
    if *byte_offsets.last().unwrap_or(&0) != text.len() {
        positions.push(
            positions.last().copied().unwrap_or(0.0) + width_between(&edges, last_byte, text.len()),
        );
        byte_offsets.push(text.len());
    }
    let m = TextMetrics {
        positions,
        byte_offsets,
    };
    metrics_cache().lock().unwrap().put(key, m.clone());
    m
}

fn width_between(edges: &[(usize, f32)], start_b: usize, end_b: usize) -> f32 {
    let x0 = lookup_right(edges, start_b);
    let x1 = lookup_right(edges, end_b);
    (x1 - x0).max(0.0)
}
fn lookup_right(edges: &[(usize, f32)], b: usize) -> f32 {
    match edges.binary_search_by_key(&b, |e| e.0) {
        Ok(i) => edges[i].1,
        Err(i) => {
            if i == 0 {
                0.0
            } else {
                edges[i - 1].1
            }
        }
    }
}

/// Greedy wrap into lines that fit max_width. Prefers breaking at whitespace,
/// falls back to grapheme boundaries. If max_lines is Some and we truncate,
/// caller can choose to ellipsize the last visible line.
pub fn wrap_lines(
    text: &str,
    px: f32,
    max_width: f32,
    max_lines: Option<usize>,
    soft_wrap: bool,
) -> (Vec<String>, bool) {
    if text.is_empty() || max_width <= 0.0 {
        return (vec![String::new()], false);
    }
    if !soft_wrap {
        return (vec![text.to_string()], false);
    }

    let max_lines_key: u16 = match max_lines {
        None => 0,
        Some(n) => {
            let n = n.min(u16::MAX as usize - 1) as u16;
            n.saturating_add(1)
        }
    };
    let key = (
        fast_hash(text),
        (px * 100.0) as u32,
        (max_width * 100.0) as u32,
        max_lines_key,
        soft_wrap,
    );
    if let Some(h) = wrap_cache().lock().unwrap().get(&key).cloned() {
        return h;
    }

    // Shape once and reuse positions/byte mapping.
    let m = metrics_for_textfield(text, px, None);
    // Fast path: fits
    if let Some(&last) = m.positions.last()
        && last <= max_width + 0.5
    {
        return (vec![text.to_string()], false);
    }

    // Helper: width of substring [start..end] in bytes
    let width_of = |start_b: usize, end_b: usize| -> f32 {
        let i0 = match m.byte_offsets.binary_search(&start_b) {
            Ok(i) | Err(i) => i,
        };
        let i1 = match m.byte_offsets.binary_search(&end_b) {
            Ok(i) | Err(i) => i,
        };
        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
            .max(0.0)
    };

    let mut out: Vec<String> = Vec::new();
    let mut truncated = false;

    let mut line_start = 0usize; // byte index
    let mut best_break = line_start;

    // Iterate word boundaries (keep whitespace tokens so they factor widths)
    for tok in text.split_word_bounds() {
        let tok_start = best_break;
        let tok_end = tok_start + tok.len();
        let w = width_of(line_start, tok_end);

        if w <= max_width + 0.5 {
            best_break = tok_end;
            continue;
        }

        // Need to break the line before tok_end.
        if best_break > line_start {
            // Break at last good boundary
            out.push(text[line_start..best_break].trim_end().to_string());
            line_start = best_break;
        } else {
            // Token itself too wide: force break inside token at grapheme boundaries
            let mut cut = tok_start;
            for g in tok.grapheme_indices(true) {
                let next = tok_start + g.0 + g.1.len();
                if width_of(line_start, next) <= max_width + 0.5 {
                    cut = next;
                } else {
                    break;
                }
            }
            if cut == line_start {
                // nothing fits; fall back to single grapheme
                if let Some((ofs, grapheme)) = tok.grapheme_indices(true).next() {
                    cut = tok_start + ofs + grapheme.len();
                }
            }
            out.push(text[line_start..cut].to_string());
            line_start = cut;
        }

        // Check max_lines
        if let Some(ml) = max_lines
            && out.len() >= ml
        {
            truncated = true;
            // Stop; caller may ellipsize the last line
            line_start = line_start.min(text.len());
            break;
        }

        // Reset best_break for new line
        best_break = line_start;

        // Re-consider current token if not fully consumed
        if line_start < tok_end {
            // recompute width with the remaining token portion
            if width_of(line_start, tok_end) <= max_width + 0.5 {
                best_break = tok_end;
            } else {
                // will be handled in next iterations (or forced again)
            }
        }
    }

    // Push tail if allowed
    if line_start < text.len() && max_lines.is_none_or(|ml| out.len() < ml) {
        out.push(text[line_start..].trim_end().to_string());
    }

    let res = (out, truncated);

    wrap_cache().lock().unwrap().put(key, res.clone());
    res
}

/// Like `wrap_lines`, but returns byte ranges into the original `text`
/// for each visual line. This is required for multi-line editing so
/// caret/selection mapping stays correct.
///
/// Ranges are half-open `[start, end)`, and never include the '\n' char
/// (hard line breaks end a range at the '\n' byte index).
pub fn wrap_line_ranges(
    text: &str,
    px: f32,
    max_width: f32,
    max_lines: Option<usize>,
    soft_wrap: bool,
) -> (Vec<(usize, usize)>, bool) {
    if text.is_empty() || max_width <= 0.0 {
        return (vec![(0, 0)], false);
    }
    if !soft_wrap {
        // Hard lines only (split on '\n' but no width wrapping)
        let mut out = Vec::new();
        let mut start = 0usize;
        for (i, ch) in text.char_indices() {
            if ch == '\n' {
                out.push((start, i));
                start = i + 1;
            }
        }
        out.push((start, text.len()));
        return (out, false);
    }

    let max_lines_key: u16 = match max_lines {
        None => 0,
        Some(n) => {
            let n = n.min(u16::MAX as usize - 1) as u16;
            n.saturating_add(1)
        }
    };
    let key = (
        fast_hash(text),
        (px * 100.0) as u32,
        (max_width * 100.0) as u32,
        max_lines_key,
        soft_wrap,
    );
    if let Some(v) = wrap_ranges_cache().lock().unwrap().get(&key).cloned() {
        return v;
    }

    // Shape once for width queries (whole string)
    let m = metrics_for_textfield(text, px, None);

    // Helper: width of substring [start..end] in bytes using m
    let width_of = |start_b: usize, end_b: usize| -> f32 {
        let i0 = match m.byte_offsets.binary_search(&start_b) {
            Ok(i) | Err(i) => i,
        };
        let i1 = match m.byte_offsets.binary_search(&end_b) {
            Ok(i) | Err(i) => i,
        };
        (m.positions.get(i1).copied().unwrap_or(0.0) - m.positions.get(i0).copied().unwrap_or(0.0))
            .max(0.0)
    };

    let mut out: Vec<(usize, usize)> = Vec::new();
    let mut truncated = false;

    // Process hard lines split by '\n' while preserving original indices.
    let mut line0_start = 0usize;
    for (i, ch) in text.char_indices() {
        if ch == '\n' {
            let (mut ranges, tr) = wrap_one_hard_line_ranges(
                text,
                line0_start,
                i,
                max_width,
                max_lines.map(|ml| ml.saturating_sub(out.len())),
                &width_of,
            );
            out.append(&mut ranges);
            if tr {
                truncated = true;
                break;
            }
            line0_start = i + 1;

            if let Some(ml) = max_lines
                && out.len() >= ml
            {
                truncated = true;
                break;
            }
        }
    }
    if !truncated {
        let (mut ranges, tr) = wrap_one_hard_line_ranges(
            text,
            line0_start,
            text.len(),
            max_width,
            max_lines.map(|ml| ml.saturating_sub(out.len())),
            &width_of,
        );
        out.append(&mut ranges);
        truncated = tr;
    }

    if out.is_empty() {
        out.push((0, 0));
    }

    let res = (out, truncated);
    wrap_ranges_cache().lock().unwrap().put(key, res.clone());
    res
}

fn wrap_one_hard_line_ranges(
    text: &str,
    start: usize,
    end: usize,
    max_width: f32,
    max_lines: Option<usize>,
    width_of: &dyn Fn(usize, usize) -> f32,
) -> (Vec<(usize, usize)>, bool) {
    let mut out = Vec::new();
    let mut t = false;

    if start >= end {
        out.push((start, start));
        return (out, false);
    }

    // Fast path: whole line fits
    if width_of(start, end) <= max_width + 0.5 {
        out.push((start, end));
        return (out, false);
    }

    let mut line_start = start;
    let mut best_break = line_start;
    let mut unconsumed_start = start;

    for tok in text[line_start..end].split_word_bounds() {
        let tok_abs_start = unconsumed_start;
        let tok_abs_end = tok_abs_start + tok.len();
        unconsumed_start = tok_abs_end;

        let w = width_of(line_start, tok_abs_end);
        if w <= max_width + 0.5 {
            best_break = tok_abs_end;
            continue;
        }

        // Need break before tok_abs_end.
        if best_break > line_start {
            out.push((line_start, best_break));
            line_start = best_break;
        } else {
            // Token too wide: force break at grapheme boundaries
            let mut cut = tok_abs_start;
            for (ofs, g) in tok.grapheme_indices(true) {
                let next = tok_abs_start + ofs + g.len();
                if width_of(line_start, next) <= max_width + 0.5 {
                    cut = next;
                } else {
                    break;
                }
            }
            if cut == line_start
                && let Some((ofs, gr)) = tok.grapheme_indices(true).next()
            {
                cut = tok_abs_start + ofs + gr.len();
            }
            out.push((line_start, cut));
            line_start = cut;
        }

        // Max lines check
        if let Some(ml) = max_lines
            && out.len() >= ml
        {
            t = true;
            break;
        }

        best_break = line_start;
    }

    // Tail
    if !t && line_start < end && max_lines.is_none_or(|ml| out.len() < ml) {
        out.push((line_start, end));
    }

    (out, t)
}

/// Return a string truncated to fit max_width at the given px size, appending '…' if truncated.
pub fn ellipsize_line(text: &str, px: f32, max_width: f32) -> String {
    if text.is_empty() || max_width <= 0.0 {
        return String::new();
    }
    let key = (
        fast_hash(text),
        (px * 100.0) as u32,
        (max_width * 100.0) as u32,
    );
    if let Some(s) = ellip_cache().lock().unwrap().get(&key).cloned() {
        return s;
    }
    let m = metrics_for_textfield(text, px, None);
    if let Some(&last) = m.positions.last()
        && last <= max_width + 0.5
    {
        return text.to_string();
    }
    let _el = "";
    let e_w = ellipsis_width(px);
    if e_w >= max_width {
        return String::new();
    }
    // Find last grapheme index whose width + ellipsis fits
    let mut cut_i = 0usize;
    for i in 0..m.positions.len() {
        if m.positions[i] + e_w <= max_width {
            cut_i = i;
        } else {
            break;
        }
    }
    let byte = m
        .byte_offsets
        .get(cut_i)
        .copied()
        .unwrap_or(0)
        .min(text.len());
    let mut out = String::with_capacity(byte + 3);
    out.push_str(&text[..byte]);
    out.push('');

    let s = out;
    ellip_cache().lock().unwrap().put(key, s.clone());

    s
}

fn ellipsis_width(px: f32) -> f32 {
    static ELLIP_W_LRU: OnceCell<Mutex<Lru<u32, f32>>> = OnceCell::new();
    let cache = ELLIP_W_LRU.get_or_init(|| Mutex::new(Lru::new(64)));
    let key = (px * 100.0) as u32;
    if let Some(w) = cache.lock().unwrap().get(&key).copied() {
        return w;
    }
    let w = if let Some(g) = crate::shape_line("", px, None).last() {
        g.x + g.advance
    } else {
        0.0
    };
    cache.lock().unwrap().put(key, w);
    w
}