Skip to main content

markdown/render/
text.rs

1//! Inline text: flattening to runs, painting, carets and selection rects.
2
3use super::*;
4
5/// Inline content flattened for shaping: one string, its runs, and the ranges
6/// that need painting underneath (link clicks, inline-code washes, mentions).
7///
8/// [`Self::text`] is the [`Text`] with every mention replaced by what it shows,
9/// so every range here is in its offsets; [`Self::shown`] maps the two.
10pub struct Flat {
11    pub text: SharedString,
12    pub runs: Vec<TextRun>,
13    pub links: Vec<(Range<usize>, String)>,
14    pub code: Vec<Range<usize>>,
15    pub mentions: Vec<Mention>,
16    pub shown: Shown,
17}
18
19/// A mention as it is shown: a favicon slot, then its label and title.
20#[derive(Clone, Debug)]
21pub struct Mention {
22    pub url: String,
23    /// Everything it shows, the favicon slot included.
24    pub range: Range<usize>,
25    /// The favicon slot: one em space.
26    pub icon: Range<usize>,
27    pub favicon: Option<SharedString>,
28    /// The app's mark, painted in the slot instead of a favicon.
29    pub glyph: Option<ui::icons::Icon>,
30    /// What stands in the slot while no favicon has loaded.
31    pub initial: SharedString,
32}
33
34/// Where a mention's range in a [`Text`] and its range in a [`Flat`] line up,
35/// in document order, as `(text, flat)` pairs.
36///
37/// An offset inside a mention's text has no place in what it shows, and one
38/// inside what it shows has none in the text: each lands on an end.
39#[derive(Clone, Debug, Default)]
40pub struct Shown(Rc<[(Range<usize>, Range<usize>)]>);
41
42impl Shown {
43    /// Where a text offset shows. One inside a mention lands on its start.
44    pub fn at(&self, offset: usize) -> usize {
45        self.map(offset, false)
46    }
47
48    /// Where a text range shows. An end inside a mention takes all of it.
49    pub fn range(&self, range: &Range<usize>) -> Range<usize> {
50        self.map(range.start, false)..self.map(range.end, true)
51    }
52
53    fn map(&self, offset: usize, end: bool) -> usize {
54        let mut shift = 0isize;
55        for (text, flat) in self.0.iter() {
56            if offset <= text.start {
57                break;
58            }
59            if offset < text.end {
60                return if end { flat.end } else { flat.start };
61            }
62            shift = flat.end as isize - text.end as isize;
63        }
64        offset.saturating_add_signed(shift)
65    }
66
67    /// The text offset of a shown one. One inside a mention lands on the
68    /// nearer end.
69    pub fn offset(&self, shown: usize) -> usize {
70        let mut shift = 0isize;
71        for (text, flat) in self.0.iter() {
72            if shown <= flat.start {
73                break;
74            }
75            if shown < flat.end {
76                return if shown - flat.start <= flat.end - shown {
77                    text.start
78                } else {
79                    text.end
80                };
81            }
82            shift = text.end as isize - flat.end as isize;
83        }
84        shown.saturating_add_signed(shift)
85    }
86}
87
88/// What stands in a mention's favicon slot. An em space is as wide as the
89/// type is tall.
90const ICON_SLOT: &str = "\u{2003}";
91
92/// Marks are ranges, gpui wants consecutive runs — so cut the text at every
93/// mark boundary and ask which marks cover each piece.
94pub fn flatten(text: &Text, base_weight: FontWeight, theme: &Theme) -> Flat {
95    flatten_with(text, base_weight, theme, |_| None, |_| None)
96}
97
98/// [`flatten`] with the app's own marks painted — see [`crate::MarkPaint`] —
99/// and each mention described by `preview`. A name the app does not paint
100/// reads as the text it wraps.
101pub fn flatten_with(
102    text: &Text,
103    base_weight: FontWeight,
104    theme: &Theme,
105    paint: impl Fn(&str) -> Option<crate::MarkPaint>,
106    preview: impl Fn(&str) -> Option<preview::Preview>,
107) -> Flat {
108    let mut cuts: Vec<usize> = text
109        .marks
110        .iter()
111        .flat_map(|span| [span.range.start, span.range.end])
112        .chain([0, text.text.len()])
113        .filter(|cut| *cut <= text.text.len())
114        .collect();
115    cuts.sort_unstable();
116    cuts.dedup();
117
118    // Mentions in order; one overlapping an earlier one is text.
119    let mut atoms: Vec<(Range<usize>, &str)> = text
120        .marks
121        .iter()
122        .filter_map(|span| match &span.mark {
123            Mark::Mention { url, .. } if !span.range.is_empty() => {
124                Some((span.range.clone(), url.as_str()))
125            }
126            _ => None,
127        })
128        .collect();
129    atoms.sort_by_key(|(range, _)| range.start);
130    atoms.dedup_by(|later, earlier| later.0.start < earlier.0.end);
131
132    let mut shown = String::with_capacity(text.text.len());
133    let mut runs = Vec::new();
134    let mut links: Vec<(Range<usize>, String)> = Vec::new();
135    let mut code: Vec<Range<usize>> = Vec::new();
136    let mut mentions: Vec<Mention> = Vec::new();
137    let mut map: Vec<(Range<usize>, Range<usize>)> = Vec::new();
138
139    for pair in cuts.windows(2) {
140        let (start, end) = (pair[0], pair[1]);
141        let atom = atoms
142            .iter()
143            .find(|(range, _)| range.start <= start && end <= range.end);
144        if atom.is_some_and(|(range, _)| range.start != start) {
145            continue;
146        }
147        let covering = text
148            .marks
149            .iter()
150            .filter(|span| span.range.start <= start && span.range.end >= end);
151
152        let (mut bold, mut italic, mut mono, mut strike) = (false, false, false, false);
153        let mut link = None;
154        // The app's own marks, merged in the order they cover this run: the
155        // last one to say something about a field is the one that says it.
156        let mut custom = crate::MarkPaint::default();
157        for span in covering {
158            match &span.mark {
159                Mark::Bold => bold = true,
160                Mark::Italic => italic = true,
161                Mark::Strike => strike = true,
162                Mark::Code => mono = true,
163                Mark::Mention { .. } => {}
164                Mark::Link(url) | Mark::Image(url) => link = Some(url.clone()),
165                Mark::Custom(name) => {
166                    let Some(painted) = paint(name) else { continue };
167                    custom.color = painted.color.or(custom.color);
168                    custom.background = painted.background.or(custom.background);
169                    custom.weight = painted.weight.or(custom.weight);
170                    custom.italic |= painted.italic;
171                    custom.underline |= painted.underline;
172                    custom.strikethrough |= painted.strikethrough;
173                }
174            }
175        }
176        let (italic, strike) = (italic || custom.italic, strike || custom.strikethrough);
177
178        let mut face = font(if mono && atom.is_none() {
179            theme.font_mono.clone()
180        } else {
181            theme.font_body.clone()
182        });
183        face.weight = if bold && base_weight.0 < FontWeight::SEMIBOLD.0 {
184            FontWeight::SEMIBOLD
185        } else {
186            custom.weight.unwrap_or(base_weight)
187        };
188        face.style = if italic {
189            FontStyle::Italic
190        } else {
191            FontStyle::Normal
192        };
193        let underline = UnderlineStyle {
194            color: Some(theme.text_muted),
195            thickness: px(1.0),
196            wavy: false,
197        };
198
199        if let Some((range, url)) = atom {
200            let described = preview(url).unwrap_or_default();
201            let host = preview::host(url);
202            let own = &text.text[range.clone()];
203            let title: SharedString = match &described.title {
204                Some(title) => title.clone(),
205                None if own != *url => own.to_string().into(),
206                None => host.to_string().into(),
207            };
208            let from = shown.len();
209            let mut run = |shown: &mut String,
210                           piece: &str,
211                           color: Hsla,
212                           underline: Option<UnderlineStyle>| {
213                shown.push_str(piece);
214                runs.push(TextRun {
215                    len: piece.len(),
216                    font: face.clone(),
217                    color,
218                    background_color: None,
219                    underline,
220                    strikethrough: None,
221                });
222            };
223            run(&mut shown, ICON_SLOT, gpui::transparent_black(), None);
224            run(&mut shown, " ", theme.text, None);
225            if let Some(label) = described.label.filter(|_| described.title.is_some()) {
226                run(&mut shown, &label, theme.text_muted, None);
227                run(&mut shown, " ", theme.text, None);
228            }
229            run(&mut shown, &title, theme.text, Some(underline));
230            let to = shown.len();
231            links.push((from..to, url.to_string()));
232            mentions.push(Mention {
233                url: url.to_string(),
234                range: from..to,
235                icon: from..from + ICON_SLOT.len(),
236                favicon: described.icon,
237                glyph: described.glyph,
238                initial: host
239                    .chars()
240                    .next()
241                    .unwrap_or('?')
242                    .to_uppercase()
243                    .to_string()
244                    .into(),
245            });
246            map.push((range.clone(), from..to));
247            continue;
248        }
249
250        let (from, to) = (shown.len(), shown.len() + end - start);
251        shown.push_str(&text.text[start..end]);
252        if mono {
253            match code.last_mut() {
254                Some(range) if range.end == from => range.end = to,
255                _ => code.push(from..to),
256            }
257        }
258        if let Some(url) = &link {
259            match links.last_mut() {
260                Some((range, last)) if range.end == from && last == url => range.end = to,
261                _ => links.push((from..to, url.clone())),
262            }
263        }
264
265        runs.push(TextRun {
266            len: end - start,
267            font: face,
268            // Links stay monochrome and underlined; the accent is reserved for
269            // primary actions.
270            color: match (mono, custom.color) {
271                (_, Some(color)) => color,
272                (true, None) => theme.code_text,
273                (false, None) => theme.text,
274            },
275            background_color: custom.background,
276            underline: (link.is_some() || custom.underline).then_some(underline),
277            strikethrough: strike.then_some(StrikethroughStyle {
278                thickness: px(1.0),
279                color: Some(theme.text_muted),
280            }),
281        });
282    }
283
284    Flat {
285        text: shown.into(),
286        runs,
287        links,
288        code,
289        mentions,
290        shown: Shown(map.into()),
291    }
292}
293
294pub(super) fn text_element(
295    text: &Text,
296    size: f32,
297    line_height: f32,
298    weight: FontWeight,
299    overlay: Overlay,
300    theme: &Theme,
301    cx: &App,
302) -> AnyElement {
303    let flat = flatten_with(
304        text,
305        weight,
306        theme,
307        |name| crate::marks::paint_of(cx, name, theme),
308        |url| preview::of(cx, url),
309    );
310    painted_text(flat, text.text.len(), size, line_height, overlay, theme)
311}
312
313/// Shaped inline content with the editing overlay under it: the selection, the
314/// caret, the inline-code wash, and the layout a click resolves against.
315///
316/// Takes a [`Flat`] rather than a [`Text`] because a table has to shape every
317/// cell to measure the columns before it can paint one.
318pub(super) fn painted_text(
319    flat: Flat,
320    len: usize,
321    size: f32,
322    line_height: f32,
323    overlay: Overlay,
324    theme: &Theme,
325) -> AnyElement {
326    let (ix, part) = (overlay.block, overlay.part);
327    let shown = flat.shown.clone();
328    let caret = overlay.caret_painted().map(|offset| shown.at(offset));
329    let selected = overlay.selected(len).map(|range| shown.range(&range));
330    let span = 0..len;
331    // Only where the caret already is, and only while there is nothing to
332    // read: a hint on every empty block would be a page of grey.
333    let hint = overlay
334        .placeholder
335        // The caret's own presence, not the blink's phase — a hint that came
336        // and went twice a second would be unreadable.
337        .filter(|_| len == 0 && overlay.caret().is_some())
338        .map(|hint| {
339            div()
340                .absolute()
341                .text_color(theme.text_faint)
342                .child(hint.clone())
343        });
344    let (shape, height, hollow) = (
345        overlay.caret_shape,
346        overlay.caret_height,
347        overlay.caret_hollow(),
348    );
349    let glyph = caret
350        .filter(|_| shape.cuts_out(hollow))
351        .and_then(|offset| glyph_at(&flat.text, offset));
352    let runs = match &glyph {
353        Some(glyph) => ui::input::caret::recoloured(flat.runs, glyph, theme.bg),
354        None => flat.runs,
355    };
356    let styled = StyledText::new(flat.text).with_runs(runs);
357    let layout = styled.layout().clone();
358
359    let mentions = flat.mentions;
360    let painted: AnyElement =
361        if flat.links.is_empty() {
362            styled.into_any_element()
363        } else {
364            let (ranges, urls): (Vec<_>, Vec<_>) = flat.links.into_iter().unzip();
365            let hovered: Vec<(Range<usize>, String)> = mentions
366                .iter()
367                .filter(|mention| mention.glyph.is_none())
368                .map(|mention| (mention.range.clone(), mention.url.clone()))
369                .collect();
370            let text = InteractiveText::new(ElementId::named_usize("md-text", ix), styled)
371                .on_click(ranges, move |clicked, window, cx| {
372                    if let Some(url) = urls.get(clicked) {
373                        crate::link::open(url, window, cx);
374                    }
375                });
376            match hovered.is_empty() {
377                true => text.into_any_element(),
378                false => text
379                    .tooltip(move |at, _window, cx| {
380                        let (_, url) = hovered.iter().find(|(range, _)| range.contains(&at))?;
381                        Some(MentionCard::view(url, cx))
382                    })
383                    .into_any_element(),
384            }
385        };
386
387    // The wash is painted before the text — an earlier sibling is underneath —
388    // reading glyph geometry from the text's own layout handle. Pure paint,
389    // never part of layout.
390    let wash = theme.code_wash;
391    let code_ranges = flat.code;
392    let (icon_color, icon_wash) = (theme.text_muted, theme.element_hover);
393    let caret_color = theme.caret;
394    let selection_color = theme.selection;
395    let annotated: Vec<_> = overlay
396        .annotated(len, theme)
397        .into_iter()
398        .map(|(range, wash)| (shown.range(&range), wash))
399        .collect();
400    let layouts = overlay.layouts.cloned();
401    let underlay = canvas(
402        {
403            let mentions = mentions.clone();
404            move |_, window, cx| {
405                mentions
406                    .iter()
407                    .map(|mention| {
408                        let favicon = mention.favicon.clone()?;
409                        let source = gpui::Resource::Uri(favicon.to_string().into());
410                        window
411                            .use_asset::<gpui::ImgResourceLoader>(&source, cx)?
412                            .ok()
413                    })
414                    .collect::<Vec<_>>()
415            }
416        },
417        move |_, favicons, window, cx| {
418            if let Some(layouts) = &layouts {
419                layouts.record(ix, part, span.clone(), layout.clone(), shown.clone());
420            }
421            // Below the selection, so dragging across a comment still reads as
422            // selected rather than as a third colour nobody chose.
423            for (range, wash) in &annotated {
424                for rect in range_rects(&layout, range, 0.0, 0.0) {
425                    window.paint_quad(quad(
426                        rect,
427                        px(2.0),
428                        *wash,
429                        px(0.0),
430                        gpui::transparent_black(),
431                        BorderStyle::default(),
432                    ));
433                }
434            }
435            // Under the glyphs, like the inline-code wash — one quad per visual
436            // row, so a wrapped selection is a stack of rows rather than a box
437            // around all of them.
438            if let Some(range) = &selected {
439                for rect in range_rects(&layout, range, 0.0, 0.0) {
440                    window.paint_quad(quad(
441                        rect,
442                        px(2.0),
443                        selection_color,
444                        px(0.0),
445                        gpui::transparent_black(),
446                        BorderStyle::default(),
447                    ));
448                }
449            }
450            if let Some(offset) = caret {
451                let face = font(Theme::of(cx).font_body.clone());
452                paint_caret(
453                    &layout,
454                    offset,
455                    glyph.as_ref(),
456                    CaretPaint {
457                        shape,
458                        height,
459                        hollow,
460                        color: caret_color,
461                        size,
462                        face,
463                    },
464                    window,
465                );
466            }
467            for range in &code_ranges {
468                for rect in range_rects(&layout, range, INLINE_CODE_PAD_X, INLINE_CODE_INSET_Y) {
469                    window.paint_quad(quad(
470                        rect,
471                        px(INLINE_CODE_RADIUS),
472                        wash,
473                        px(0.0),
474                        gpui::transparent_black(),
475                        BorderStyle::default(),
476                    ));
477                }
478            }
479            for (mention, favicon) in mentions.iter().zip(favicons) {
480                let Some(slot) = range_rects(&layout, &mention.icon, 0.0, 0.0).pop() else {
481                    continue;
482                };
483                let side = slot.size.width.min(px(size));
484                let icon = Bounds::new(
485                    slot.origin + point(px(0.0), (slot.size.height - side) / 2.0),
486                    gpui::size(side, side),
487                );
488                let radius = gpui::Corners::all(side / 4.0);
489                if let Some(data) = mention.glyph.as_ref().and_then(|glyph| glyph.data()) {
490                    let path = SharedString::from(format!("markdown-glyph-{:p}", data.as_ptr()));
491                    window
492                        .paint_svg(
493                            icon,
494                            path,
495                            Some(data),
496                            gpui::TransformationMatrix::unit(),
497                            icon_color,
498                            cx,
499                        )
500                        .ok();
501                    continue;
502                }
503                if let Some(favicon) = favicon {
504                    window
505                        .paint_image(icon, icon, radius, favicon, 0, false)
506                        .ok();
507                    continue;
508                }
509                window.paint_quad(quad(
510                    icon,
511                    side / 4.0,
512                    icon_wash,
513                    px(0.0),
514                    gpui::transparent_black(),
515                    BorderStyle::default(),
516                ));
517                let font = font(Theme::of(cx).font_body.clone());
518                let letter = window.text_system().shape_line(
519                    mention.initial.clone(),
520                    side * 0.55,
521                    &[TextRun {
522                        len: mention.initial.len(),
523                        font,
524                        color: icon_color,
525                        background_color: None,
526                        underline: None,
527                        strikethrough: None,
528                    }],
529                    None,
530                );
531                let at = point(icon.center().x - letter.width / 2.0, icon.origin.y);
532                letter
533                    .paint(at, side, gpui::TextAlign::Left, None, window, cx)
534                    .ok();
535            }
536        },
537    )
538    .absolute()
539    .size_full();
540
541    div()
542        .text_size(px(size))
543        .line_height(px(line_height))
544        .relative()
545        .child(underlay)
546        .children(hint)
547        .child(painted)
548        .into_any_element()
549}
550
551/// The caret's quad: the text's own size, centred in the line box.
552///
553/// The leading is not the caret's to take. A document is set with air around
554/// its lines, and a caret filling all of it reads as a second, larger font
555/// standing where the text should be.
556pub(super) fn caret_quad(head: Point<Pixels>, size: f32, line_height: Pixels) -> Bounds<Pixels> {
557    let inset = (line_height - px(size)) / 2.0;
558    Bounds::new(
559        head + point(px(0.0), inset),
560        gpui::size(px(CARET_WIDTH), px(size)),
561    )
562}
563
564/// The grapheme after `offset` on its line, if there is one.
565pub(super) fn glyph_at(text: &str, offset: usize) -> Option<Range<usize>> {
566    (offset < text.len() && text.is_char_boundary(offset) && !text[offset..].starts_with('\n'))
567        .then(|| offset..ui::input::next_boundary(text, offset))
568}
569
570/// How a caret is drawn, apart from where.
571pub(super) struct CaretPaint {
572    pub shape: ui::input::CaretShape,
573    pub height: ui::input::CaretHeight,
574    pub hollow: bool,
575    pub color: Hsla,
576    /// The text's font size, in pixels.
577    pub size: f32,
578    /// The font a wide caret measures its empty slot in.
579    pub face: gpui::Font,
580}
581
582/// Paints the caret at `offset` in `layout`. A `glyph` — the range recoloured
583/// for a solid block — is what the block covers, on whichever row it shaped.
584pub(super) fn paint_caret(
585    layout: &TextLayout,
586    offset: usize,
587    glyph: Option<&Range<usize>>,
588    paint: CaretPaint,
589    window: &mut Window,
590) {
591    let line_height = layout.line_height();
592    let covered = glyph.and_then(|glyph| range_rects(layout, glyph, 0.0, 0.0).into_iter().next());
593    let (head, width) = match covered {
594        Some(rect) => (rect.origin, rect.size.width),
595        None => {
596            let Some(head) = layout.position_for_index(offset) else {
597                return;
598            };
599            let width = match paint.shape {
600                ui::input::CaretShape::Bar => px(0.0),
601                _ => caret_advance(layout, offset).unwrap_or_else(|| {
602                    ui::input::caret::zero_width(paint.face, px(paint.size), window)
603                }),
604            };
605            (head, width)
606        }
607    };
608    window.paint_quad(paint.shape.quad(
609        caret_quad(head, paint.size, line_height),
610        line_height,
611        paint.height,
612        width,
613        paint.color,
614        paint.hollow,
615    ));
616}
617
618/// The rectangles a byte range occupies, one per visual row.
619pub(super) fn range_rects(
620    layout: &gpui::TextLayout,
621    range: &Range<usize>,
622    pad_x: f32,
623    inset_y: f32,
624) -> Vec<Bounds<Pixels>> {
625    let mut rects = Vec::new();
626    let line_height = layout.line_height();
627    let mut origin = layout.bounds().origin;
628    let mut line_start = 0;
629    for line in layout.line_layouts() {
630        let shaped = &line.unwrapped_layout;
631        // A wrap boundary index is both the end of one row and the start of
632        // the next.
633        let row_ends = line
634            .wrap_boundaries()
635            .iter()
636            .map(|wrap| shaped.runs[wrap.run_ix].glyphs[wrap.glyph_ix].index)
637            .chain([line.len()]);
638        let mut row_start = 0;
639        for (row, row_end) in row_ends.enumerate() {
640            let from = range
641                .start
642                .saturating_sub(line_start)
643                .clamp(row_start, row_end);
644            let to = range.end.saturating_sub(line_start).min(row_end);
645            let row_x = shaped.x_for_index(row_start);
646            let (left, right) = (shaped.x_for_index(from), shaped.x_for_index(to));
647            if from < to && right > left {
648                rects.push(Bounds::new(
649                    origin
650                        + point(
651                            left - row_x - px(pad_x),
652                            line_height * row as f32 + px(inset_y),
653                        ),
654                    size(
655                        right - left + px(2.0 * pad_x),
656                        line_height - px(2.0 * inset_y),
657                    ),
658                ));
659            }
660            row_start = row_end;
661        }
662        origin.y += line.size(line_height).height;
663        // The newline between two lines is a byte of the text and of neither.
664        line_start += line.len() + 1;
665    }
666    rects
667}
668
669/// Resolve the advance in the same shaped line that owns the caret position.
670pub(super) fn caret_advance(layout: &TextLayout, offset: usize) -> Option<Pixels> {
671    let text = layout.text();
672    let mut start = 0;
673    for line in layout.line_layouts() {
674        let end = start + line.len();
675        if offset <= end {
676            return ui::input::caret::character_advance(&line, &text[start..end], offset - start);
677        }
678        start = end + 1;
679    }
680    None
681}