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