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