Skip to main content

gpui_kit_grid/
text.rs

1//! Shaped-text cache and glyph painting for the grid body.
2//!
3//! GPUI's own line cache keeps two frames and looks up by hash with a linear
4//! scan, and `ShapedLine` bakes the colour in. The grid keeps colour-free
5//! `Arc<LineLayout>`s in a hash map instead and paints glyphs with the colour
6//! of the current frame.
7
8use std::{
9    hash::{Hash, Hasher},
10    ops::Range,
11    sync::Arc,
12};
13
14use gpui_kit::{
15    Font, FontId, FontWeight, GlyphId, Hsla, LineLayout, Pixels, Point, SharedString, TextRun,
16    Window, black, point, px,
17};
18use rustc_hash::{FxHashMap, FxHasher};
19
20/// Frames an unused entry survives before it is evicted.
21const KEEP_FRAMES: u64 = 120;
22/// How often (in frames) the cache is swept.
23const SWEEP_EVERY: u64 = 60;
24
25struct Entry {
26    text: Box<str>,
27    layout: Arc<LineLayout>,
28    last_frame: u64,
29}
30
31/// Characters a number is made of. With tabular figures they have fixed
32/// advances and no kerning, so numbers are laid out from a per-font table
33/// instead of being shaped.
34const NUMERIC: &str = "0123456789.,-+ ";
35/// Characters the table takes if the font shapes each to one glyph: the
36/// `:` of a time (`2026-11-04 12:34:56`). Without one of them the table
37/// still lays out numbers; text with it is shaped.
38const NUMERIC_OPTIONAL: &str = ":";
39
40#[derive(Clone, Copy)]
41struct NumericGlyph {
42    font_id: FontId,
43    glyph_id: GlyphId,
44    advance: Pixels,
45}
46
47struct NumericTable {
48    glyphs: [Option<NumericGlyph>; 128],
49    ascent: Pixels,
50    descent: Pixels,
51}
52
53impl NumericTable {
54    /// Shapes each numeric character once. `None` if any of [`NUMERIC`]
55    /// does not shape to exactly one glyph (then numbers are shaped
56    /// normally); a character of [`NUMERIC_OPTIONAL`] that does not is left
57    /// out of the table.
58    fn build(shape: impl Fn(&str) -> Arc<LineLayout>) -> Option<Self> {
59        let mut glyphs = [None; 128];
60        let mut metrics = None;
61        for ch in NUMERIC.chars() {
62            let layout = shape(ch.encode_utf8(&mut [0; 4]));
63            glyphs[ch as usize] = Some(Self::glyph(&layout)?);
64            metrics.get_or_insert((layout.ascent, layout.descent));
65        }
66        for ch in NUMERIC_OPTIONAL.chars() {
67            glyphs[ch as usize] = Self::glyph(&shape(ch.encode_utf8(&mut [0; 4])));
68        }
69        let (ascent, descent) = metrics?;
70        Some(Self {
71            glyphs,
72            ascent,
73            descent,
74        })
75    }
76
77    /// The one glyph a character shaped to, if it shaped to exactly one.
78    fn glyph(layout: &LineLayout) -> Option<NumericGlyph> {
79        let [run] = layout.runs.as_slice() else {
80            return None;
81        };
82        let [glyph] = run.glyphs.as_slice() else {
83            return None;
84        };
85        Some(NumericGlyph {
86            font_id: run.font_id,
87            glyph_id: glyph.id,
88            advance: layout.width,
89        })
90    }
91
92    /// The layout of `text` if it only has numeric characters. Its glyphs go
93    /// to the frame's arena, so a number allocates nothing of its own.
94    fn layout(&self, text: &str, font_size: Pixels, arena: &mut GlyphArena) -> Option<NumericLine> {
95        if !text
96            .bytes()
97            .all(|byte| self.glyphs.get(byte as usize).is_some_and(Option::is_some))
98        {
99            return None;
100        }
101        let start = arena.len();
102        let mut x = px(0.);
103        for byte in text.bytes() {
104            let glyph = self.glyphs[byte as usize].expect("checked above");
105            arena.push((glyph.font_id, glyph.glyph_id, x));
106            x += glyph.advance;
107        }
108        Some(NumericLine {
109            glyphs: start..arena.len(),
110            width: x,
111            ascent: self.ascent,
112            descent: self.descent,
113            font_size,
114        })
115    }
116}
117
118/// Positioned glyphs of every number in one frame: `(font, glyph, x)`.
119pub(crate) type GlyphArena = Vec<(FontId, GlyphId, Pixels)>;
120
121/// A number laid out from the glyph table; its glyphs are a range of the
122/// frame's [`GlyphArena`].
123pub(crate) struct NumericLine {
124    glyphs: Range<usize>,
125    width: Pixels,
126    ascent: Pixels,
127    descent: Pixels,
128    font_size: Pixels,
129}
130
131/// The text of one cell: shaped and cached, or a number from the glyph table.
132pub(crate) enum TextLayout {
133    Shaped(Arc<LineLayout>),
134    Numeric(NumericLine),
135}
136
137impl TextLayout {
138    pub fn width(&self) -> Pixels {
139        match self {
140            Self::Shaped(layout) => layout.width,
141            Self::Numeric(line) => line.width,
142        }
143    }
144
145    /// The height above and below the baseline.
146    pub fn ascent_descent(&self) -> (Pixels, Pixels) {
147        match self {
148            Self::Shaped(layout) => (layout.ascent, layout.descent),
149            Self::Numeric(line) => (line.ascent, line.descent),
150        }
151    }
152}
153
154/// Hit and miss counters of the text caches: the grid's font and, apart,
155/// each other weight a cell style asks for.
156#[derive(Clone, Copy, Debug, Default)]
157pub struct TextStats {
158    hits: u64,
159    misses: u64,
160    numeric: u64,
161    entries: usize,
162    bold_entries: usize,
163}
164
165impl TextStats {
166    /// Lookups served from the cache.
167    pub fn hits(&self) -> u64 {
168        self.hits
169    }
170
171    /// Lookups not in the cache: text shaped by the platform shaper.
172    pub fn misses(&self) -> u64 {
173        self.misses
174    }
175
176    /// Numbers laid out from the glyph table, without shaping or caching.
177    pub fn numeric(&self) -> u64 {
178        self.numeric
179    }
180
181    /// Entries currently cached, at every weight.
182    pub fn entries(&self) -> usize {
183        self.entries
184    }
185
186    /// Entries currently cached at a weight other than the grid font's
187    /// (`CellStyle::set_font_weight`), such as bold totals.
188    pub fn bold_entries(&self) -> usize {
189        self.bold_entries
190    }
191
192    /// Adds the counters of another cache.
193    fn add(&mut self, other: TextStats) {
194        self.hits += other.hits;
195        self.misses += other.misses;
196        self.numeric += other.numeric;
197        self.entries += other.entries;
198        self.bold_entries += other.bold_entries;
199    }
200}
201
202/// Colour-free shaped lines keyed by text, for one font and size.
203pub(crate) struct TextCache {
204    entries: FxHashMap<u64, Entry>,
205    frame: u64,
206    font: Option<Font>,
207    font_size: Pixels,
208    ellipsis: Option<Arc<LineLayout>>,
209    numeric: Option<Option<NumericTable>>,
210    stats: TextStats,
211}
212
213impl Default for TextCache {
214    fn default() -> Self {
215        Self {
216            entries: FxHashMap::default(),
217            frame: 0,
218            font: None,
219            font_size: px(0.),
220            ellipsis: None,
221            numeric: None,
222            stats: TextStats::default(),
223        }
224    }
225}
226
227impl TextCache {
228    /// Starts a frame. A new font or size drops every entry.
229    pub fn begin_frame(&mut self, font: &Font, font_size: Pixels) {
230        if self.font.as_ref() != Some(font) || self.font_size != font_size {
231            self.entries.clear();
232            self.ellipsis = None;
233            self.numeric = None;
234            self.font = Some(font.clone());
235            self.font_size = font_size;
236        }
237        self.frame += 1;
238        if self.frame % SWEEP_EVERY == 0 {
239            let frame = self.frame;
240            self.entries
241                .retain(|_, entry| entry.last_frame + KEEP_FRAMES >= frame);
242        }
243    }
244
245    fn shape(&self, text: &str, window: &Window) -> Arc<LineLayout> {
246        let runs = [TextRun {
247            len: text.len(),
248            font: self.font.clone().expect("begin_frame sets the font"),
249            color: black(),
250            background_color: None,
251            underline: None,
252            strikethrough: None,
253        }];
254        window
255            .text_system()
256            .layout_line(text, self.font_size, &runs, None)
257    }
258
259    /// The layout of `text`. Numbers put their glyphs into `arena`.
260    pub fn layout(&mut self, text: &str, window: &Window, arena: &mut GlyphArena) -> TextLayout {
261        // Numbers change on every update and would churn the cache (and its
262        // rehashing) during fast scrolling; laying them out from the glyph
263        // table is cheaper than caching them.
264        if self.numeric.is_none() {
265            self.numeric = Some(NumericTable::build(|text| self.shape(text, window)));
266        }
267        if let Some(layout) = self
268            .numeric
269            .as_ref()
270            .and_then(|table| table.as_ref())
271            .and_then(|table| table.layout(text, self.font_size, arena))
272        {
273            self.stats.numeric += 1;
274            return TextLayout::Numeric(layout);
275        }
276
277        let mut hasher = FxHasher::default();
278        text.hash(&mut hasher);
279        let key = hasher.finish();
280        if let Some(entry) = self.entries.get_mut(&key)
281            && &*entry.text == text
282        {
283            entry.last_frame = self.frame;
284            self.stats.hits += 1;
285            return TextLayout::Shaped(entry.layout.clone());
286        }
287        self.stats.misses += 1;
288        let layout = self.shape(text, window);
289        self.entries.insert(
290            key,
291            Entry {
292                text: text.into(),
293                layout: layout.clone(),
294                last_frame: self.frame,
295            },
296        );
297        TextLayout::Shaped(layout)
298    }
299
300    /// The shaped "…" used for truncated text.
301    pub fn ellipsis(&mut self, window: &Window) -> Arc<LineLayout> {
302        if self.ellipsis.is_none() {
303            self.ellipsis = Some(self.shape("…", window));
304        }
305        self.ellipsis.clone().expect("set above")
306    }
307
308    /// Counters since the grid was created.
309    pub fn stats(&self) -> TextStats {
310        TextStats {
311            entries: self.entries.len(),
312            ..self.stats
313        }
314    }
315}
316
317/// The asset paths of the icons in cells (`CellValue::Icon`), as the shared
318/// strings GPUI paints an SVG by: a path a row lends is copied once, when
319/// first painted, not in every frame.
320#[derive(Default)]
321struct IconPaths {
322    entries: FxHashMap<u64, IconPath>,
323}
324
325struct IconPath {
326    path: SharedString,
327    last_frame: u64,
328}
329
330impl IconPaths {
331    fn get(&mut self, path: &str, frame: u64) -> SharedString {
332        let mut hasher = FxHasher::default();
333        path.hash(&mut hasher);
334        let key = hasher.finish();
335        match self.entries.get_mut(&key) {
336            Some(entry) if entry.path.as_ref() == path => {
337                entry.last_frame = frame;
338                entry.path.clone()
339            }
340            _ => {
341                let shared = SharedString::from(path.to_owned());
342                self.entries.insert(
343                    key,
344                    IconPath {
345                        path: shared.clone(),
346                        last_frame: frame,
347                    },
348                );
349                shared
350            }
351        }
352    }
353
354    /// Drops the paths no frame painted for [`KEEP_FRAMES`] frames.
355    fn sweep(&mut self, frame: u64) {
356        self.entries
357            .retain(|_, entry| entry.last_frame + KEEP_FRAMES >= frame);
358    }
359}
360
361/// A cache of another weight than the grid font's, and the last frame that
362/// used it.
363struct WeightCache {
364    weight: FontWeight,
365    cache: TextCache,
366    last_frame: u64,
367}
368
369/// The text caches of a grid: the grid's font, and one cache per other
370/// weight a cell style asks for, made on first use. A cache of its own per
371/// weight, because a cache drops its entries when its font changes; each
372/// has its own glyph table for numbers. A weight cache starts its frame
373/// when a frame first uses it, and goes once no frame used it for
374/// [`KEEP_FRAMES`] frames.
375#[derive(Default)]
376pub(crate) struct TextCaches {
377    regular: TextCache,
378    by_weight: Vec<WeightCache>,
379    /// The paths of icons in cells.
380    icon_paths: IconPaths,
381    frame: u64,
382}
383
384impl TextCaches {
385    /// Starts a frame in the grid's font.
386    pub fn begin_frame(&mut self, font: &Font, font_size: Pixels) {
387        self.frame += 1;
388        self.regular.begin_frame(font, font_size);
389        if self.frame % SWEEP_EVERY == 0 {
390            let frame = self.frame;
391            self.by_weight
392                .retain(|cache| cache.last_frame + KEEP_FRAMES >= frame);
393            self.icon_paths.sweep(frame);
394        }
395    }
396
397    /// The shared path of an icon in a cell: made on the first frame that
398    /// paints it, then looked up by hash.
399    pub fn icon_path(&mut self, path: &str) -> SharedString {
400        self.icon_paths.get(path, self.frame)
401    }
402
403    /// The cache of the grid's font.
404    pub fn regular(&mut self) -> &mut TextCache {
405        &mut self.regular
406    }
407
408    /// The cache for text of `weight`; `None`, or the grid font's own
409    /// weight, is the grid's font.
410    #[inline]
411    pub fn for_weight(&mut self, weight: Option<FontWeight>) -> &mut TextCache {
412        match weight {
413            None => &mut self.regular,
414            Some(weight) => self.weighted(weight),
415        }
416    }
417
418    fn weighted(&mut self, weight: FontWeight) -> &mut TextCache {
419        let Some(font) = self
420            .regular
421            .font
422            .as_ref()
423            .filter(|font| font.weight != weight)
424        else {
425            return &mut self.regular;
426        };
427        let ix = match self
428            .by_weight
429            .iter()
430            .position(|cache| cache.weight == weight)
431        {
432            Some(ix) => ix,
433            None => {
434                self.by_weight.push(WeightCache {
435                    weight,
436                    cache: TextCache::default(),
437                    last_frame: 0,
438                });
439                self.by_weight.len() - 1
440            }
441        };
442        let cache = &mut self.by_weight[ix];
443        if cache.last_frame != self.frame {
444            cache.last_frame = self.frame;
445            let font = Font {
446                weight,
447                ..font.clone()
448            };
449            cache.cache.begin_frame(&font, self.regular.font_size);
450        }
451        &mut cache.cache
452    }
453
454    /// Counters of every cache since the grid was created.
455    pub fn stats(&self) -> TextStats {
456        let mut stats = self.regular.stats();
457        for cache in &self.by_weight {
458            let weighted = cache.cache.stats();
459            stats.add(TextStats {
460                bold_entries: weighted.entries,
461                ..weighted
462            });
463        }
464        stats
465    }
466}
467
468/// A line of text to paint in one cell.
469pub(crate) struct TextItem {
470    /// Top-left corner of the line box.
471    pub origin: Point<Pixels>,
472    pub layout: TextLayout,
473    /// Text wider than this is cut and ends with an ellipsis.
474    pub max_width: Pixels,
475    pub color: Hsla,
476    /// The ellipsis of text in another weight; `None`: the frame's.
477    pub ellipsis: Option<Arc<LineLayout>>,
478}
479
480/// Paints a line glyph by glyph. Truncation cuts at a glyph boundary from
481/// the laid-out positions, so it costs no extra shaping.
482pub(crate) fn paint_text(
483    item: &TextItem,
484    arena: &GlyphArena,
485    ellipsis: &LineLayout,
486    line_height: Pixels,
487    window: &mut Window,
488) {
489    match &item.layout {
490        TextLayout::Shaped(layout) => paint_glyphs(
491            item,
492            layout
493                .runs
494                .iter()
495                .flat_map(|run| run.glyphs.iter().map(move |glyph| (run.font_id, glyph)))
496                .map(|(font_id, glyph)| Glyph {
497                    font_id,
498                    glyph_id: glyph.id,
499                    x: glyph.position.x,
500                    y: glyph.position.y,
501                    is_emoji: glyph.is_emoji,
502                }),
503            Metrics {
504                width: layout.width,
505                ascent: layout.ascent,
506                descent: layout.descent,
507                font_size: layout.font_size,
508            },
509            ellipsis,
510            line_height,
511            window,
512        ),
513        TextLayout::Numeric(line) => paint_glyphs(
514            item,
515            arena[line.glyphs.clone()]
516                .iter()
517                .map(|(font_id, glyph_id, x)| Glyph {
518                    font_id: *font_id,
519                    glyph_id: *glyph_id,
520                    x: *x,
521                    y: px(0.),
522                    is_emoji: false,
523                }),
524            Metrics {
525                width: line.width,
526                ascent: line.ascent,
527                descent: line.descent,
528                font_size: line.font_size,
529            },
530            ellipsis,
531            line_height,
532            window,
533        ),
534    }
535}
536
537struct Glyph {
538    font_id: FontId,
539    glyph_id: GlyphId,
540    x: Pixels,
541    y: Pixels,
542    is_emoji: bool,
543}
544
545struct Metrics {
546    width: Pixels,
547    ascent: Pixels,
548    descent: Pixels,
549    font_size: Pixels,
550}
551
552fn paint_glyphs(
553    item: &TextItem,
554    glyphs: impl Iterator<Item = Glyph>,
555    metrics: Metrics,
556    ellipsis: &LineLayout,
557    line_height: Pixels,
558    window: &mut Window,
559) {
560    let padding_top = (line_height - metrics.ascent - metrics.descent) / 2.;
561    let baseline_y = item.origin.y + padding_top + metrics.ascent;
562    let truncate = metrics.width > item.max_width;
563    // A cell narrower than the ellipsis shows nothing rather than spilling
564    // into its neighbour.
565    if truncate && item.max_width < ellipsis.width {
566        return;
567    }
568    let limit = if truncate {
569        item.max_width - ellipsis.width
570    } else {
571        metrics.width
572    };
573
574    let mut cut_x = metrics.width;
575    let mut glyphs = glyphs.peekable();
576    while let Some(glyph) = glyphs.next() {
577        let end = glyphs.peek().map_or(metrics.width, |next| next.x);
578        if truncate && end > limit {
579            cut_x = glyph.x;
580            break;
581        }
582        let origin = point(item.origin.x + glyph.x, baseline_y + glyph.y);
583        if glyph.is_emoji {
584            let _ = window.paint_emoji(origin, glyph.font_id, glyph.glyph_id, metrics.font_size);
585        } else {
586            let _ = window.paint_glyph(
587                origin,
588                glyph.font_id,
589                glyph.glyph_id,
590                metrics.font_size,
591                item.color,
592            );
593        }
594    }
595
596    if truncate {
597        for run in &ellipsis.runs {
598            for glyph in &run.glyphs {
599                let origin = point(
600                    item.origin.x + cut_x + glyph.position.x,
601                    baseline_y + glyph.position.y,
602                );
603                let _ = window.paint_glyph(
604                    origin,
605                    run.font_id,
606                    glyph.id,
607                    ellipsis.font_size,
608                    item.color,
609                );
610            }
611        }
612    }
613}