Skip to main content

markdown/render/
text.rs

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