1use 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
20const KEEP_FRAMES: u64 = 120;
22const SWEEP_EVERY: u64 = 60;
24
25struct Entry {
26 text: Box<str>,
27 layout: Arc<LineLayout>,
28 last_frame: u64,
29}
30
31const NUMERIC: &str = "0123456789.,-+ ";
35const 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 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 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 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
118pub(crate) type GlyphArena = Vec<(FontId, GlyphId, Pixels)>;
120
121pub(crate) struct NumericLine {
124 glyphs: Range<usize>,
125 width: Pixels,
126 ascent: Pixels,
127 descent: Pixels,
128 font_size: Pixels,
129}
130
131pub(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 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#[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 pub fn hits(&self) -> u64 {
168 self.hits
169 }
170
171 pub fn misses(&self) -> u64 {
173 self.misses
174 }
175
176 pub fn numeric(&self) -> u64 {
178 self.numeric
179 }
180
181 pub fn entries(&self) -> usize {
183 self.entries
184 }
185
186 pub fn bold_entries(&self) -> usize {
189 self.bold_entries
190 }
191
192 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
202pub(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 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 pub fn layout(&mut self, text: &str, window: &Window, arena: &mut GlyphArena) -> TextLayout {
261 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 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 pub fn stats(&self) -> TextStats {
310 TextStats {
311 entries: self.entries.len(),
312 ..self.stats
313 }
314 }
315}
316
317#[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 fn sweep(&mut self, frame: u64) {
356 self.entries
357 .retain(|_, entry| entry.last_frame + KEEP_FRAMES >= frame);
358 }
359}
360
361struct WeightCache {
364 weight: FontWeight,
365 cache: TextCache,
366 last_frame: u64,
367}
368
369#[derive(Default)]
376pub(crate) struct TextCaches {
377 regular: TextCache,
378 by_weight: Vec<WeightCache>,
379 icon_paths: IconPaths,
381 frame: u64,
382}
383
384impl TextCaches {
385 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 pub fn icon_path(&mut self, path: &str) -> SharedString {
400 self.icon_paths.get(path, self.frame)
401 }
402
403 pub fn regular(&mut self) -> &mut TextCache {
405 &mut self.regular
406 }
407
408 #[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 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
468pub(crate) struct TextItem {
470 pub origin: Point<Pixels>,
472 pub layout: TextLayout,
473 pub max_width: Pixels,
475 pub color: Hsla,
476 pub ellipsis: Option<Arc<LineLayout>>,
478}
479
480pub(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 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}