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, chips).
7pub struct Flat {
8    pub text: SharedString,
9    pub runs: Vec<TextRun>,
10    pub links: Vec<(Range<usize>, String)>,
11    pub code: Vec<Range<usize>>,
12    pub chips: Vec<Range<usize>>,
13}
14
15/// Marks are ranges, gpui wants consecutive runs — so cut the text at every
16/// mark boundary and ask which marks cover each piece.
17pub fn flatten(text: &Text, base_weight: FontWeight, theme: &Theme) -> Flat {
18    flatten_with(text, base_weight, theme, |_| None)
19}
20
21/// [`flatten`] with the app's own marks painted — see [`crate::MarkPaint`]. A
22/// name the app does not paint reads as the text it wraps.
23pub fn flatten_with(
24    text: &Text,
25    base_weight: FontWeight,
26    theme: &Theme,
27    paint: impl Fn(&str) -> Option<crate::MarkPaint>,
28) -> Flat {
29    let mut cuts: Vec<usize> = text
30        .marks
31        .iter()
32        .flat_map(|span| [span.range.start, span.range.end])
33        .chain([0, text.text.len()])
34        .filter(|cut| *cut <= text.text.len())
35        .collect();
36    cuts.sort_unstable();
37    cuts.dedup();
38
39    let mut runs = Vec::new();
40    let mut links: Vec<(Range<usize>, String)> = Vec::new();
41    let mut code: Vec<Range<usize>> = Vec::new();
42    let mut chips: Vec<Range<usize>> = Vec::new();
43
44    for pair in cuts.windows(2) {
45        let (start, end) = (pair[0], pair[1]);
46        let covering = text
47            .marks
48            .iter()
49            .filter(|span| span.range.start <= start && span.range.end >= end);
50
51        let (mut bold, mut italic, mut mono, mut strike) = (false, false, false, false);
52        let mut chip = false;
53        let mut link = None;
54        // The app's own marks, merged in the order they cover this run: the
55        // last one to say something about a field is the one that says it.
56        let mut custom = crate::MarkPaint::default();
57        for span in covering {
58            match &span.mark {
59                Mark::Bold => bold = true,
60                Mark::Italic => italic = true,
61                Mark::Strike => strike = true,
62                Mark::Code => mono = true,
63                Mark::Mention { url, .. } => {
64                    chip = true;
65                    link = Some(url.clone());
66                }
67                Mark::Link(url) | Mark::Image(url) => link = Some(url.clone()),
68                Mark::Custom(name) => {
69                    let Some(painted) = paint(name) else { continue };
70                    custom.color = painted.color.or(custom.color);
71                    custom.background = painted.background.or(custom.background);
72                    custom.weight = painted.weight.or(custom.weight);
73                    custom.italic |= painted.italic;
74                    custom.underline |= painted.underline;
75                    custom.strikethrough |= painted.strikethrough;
76                }
77            }
78        }
79        let (italic, strike) = (italic || custom.italic, strike || custom.strikethrough);
80
81        if mono {
82            match code.last_mut() {
83                Some(range) if range.end == start => range.end = end,
84                _ => code.push(start..end),
85            }
86        }
87        if chip {
88            match chips.last_mut() {
89                Some(range) if range.end == start => range.end = end,
90                _ => chips.push(start..end),
91            }
92        }
93        if let Some(url) = &link {
94            match links.last_mut() {
95                Some((range, last)) if range.end == start && last == url => range.end = end,
96                _ => links.push((start..end, url.clone())),
97            }
98        }
99
100        let mut face = font(if mono {
101            theme.font_mono.clone()
102        } else {
103            theme.font_body.clone()
104        });
105        face.weight = if bold && base_weight.0 < FontWeight::SEMIBOLD.0 {
106            FontWeight::SEMIBOLD
107        } else {
108            custom.weight.unwrap_or(base_weight)
109        };
110        face.style = if italic {
111            FontStyle::Italic
112        } else {
113            FontStyle::Normal
114        };
115
116        runs.push(TextRun {
117            len: end - start,
118            font: face,
119            // Links stay monochrome and underlined; the accent is reserved for
120            // primary actions. A chip carries its own wash, so underlining it
121            // too would say the same thing twice.
122            color: match (mono, custom.color) {
123                (_, Some(color)) => color,
124                (true, None) => theme.code_text,
125                (false, None) => theme.text,
126            },
127            background_color: custom.background,
128            underline: ((link.is_some() && !chip) || custom.underline).then_some(UnderlineStyle {
129                color: Some(theme.text_muted),
130                thickness: px(1.0),
131                wavy: false,
132            }),
133            strikethrough: strike.then_some(StrikethroughStyle {
134                thickness: px(1.0),
135                color: Some(theme.text_muted),
136            }),
137        });
138    }
139
140    Flat {
141        text: text.text.clone().into(),
142        runs,
143        links,
144        code,
145        chips,
146    }
147}
148
149pub(super) fn text_element(
150    text: &Text,
151    size: f32,
152    line_height: f32,
153    weight: FontWeight,
154    overlay: Overlay,
155    theme: &Theme,
156    cx: &App,
157) -> AnyElement {
158    let flat = flatten_with(text, weight, theme, |name| {
159        crate::marks::paint_of(cx, name, theme)
160    });
161    painted_text(flat, text.text.len(), size, line_height, overlay, theme)
162}
163
164/// Shaped inline content with the editing overlay under it: the selection, the
165/// caret, the inline-code wash, and the layout a click resolves against.
166///
167/// Takes a [`Flat`] rather than a [`Text`] because a table has to shape every
168/// cell to measure the columns before it can paint one.
169pub(super) fn painted_text(
170    flat: Flat,
171    len: usize,
172    size: f32,
173    line_height: f32,
174    overlay: Overlay,
175    theme: &Theme,
176) -> AnyElement {
177    let (ix, part) = (overlay.block, overlay.part);
178    let (caret, selected) = (overlay.caret_painted(), overlay.selected(len));
179    let span = 0..len;
180    // Only where the caret already is, and only while there is nothing to
181    // read: a hint on every empty block would be a page of grey.
182    let hint = overlay
183        .placeholder
184        // The caret's own presence, not the blink's phase — a hint that came
185        // and went twice a second would be unreadable.
186        .filter(|_| len == 0 && overlay.caret().is_some())
187        .map(|hint| {
188            div()
189                .absolute()
190                .text_color(theme.text_faint)
191                .child(hint.clone())
192        });
193    let styled = StyledText::new(flat.text).with_runs(flat.runs);
194    let layout = styled.layout().clone();
195
196    let painted: AnyElement = if flat.links.is_empty() {
197        styled.into_any_element()
198    } else {
199        let (ranges, urls): (Vec<_>, Vec<_>) = flat.links.into_iter().unzip();
200        InteractiveText::new(ElementId::named_usize("md-text", ix), styled)
201            .on_click(ranges, move |clicked, _window, cx| {
202                if let Some(url) = urls.get(clicked) {
203                    cx.open_url(url);
204                }
205            })
206            .into_any_element()
207    };
208
209    // The wash is painted before the text — an earlier sibling is underneath —
210    // reading glyph geometry from the text's own layout handle. Pure paint,
211    // never part of layout.
212    let wash = theme.code_wash;
213    let code_ranges = flat.code;
214    let chip_wash = theme.element_hover;
215    let chip_edge = theme.border;
216    let chip_ranges = flat.chips;
217    let caret_color = theme.caret;
218    let selection_color = theme.selection;
219    let annotated = overlay.annotated(len, theme);
220    let layouts = overlay.layouts.cloned();
221    let underlay = canvas(
222        |_, _, _| (),
223        move |_, _, window, _| {
224            if let Some(layouts) = &layouts {
225                layouts.record(ix, part, span.clone(), layout.clone());
226            }
227            // Below the selection, so dragging across a comment still reads as
228            // selected rather than as a third colour nobody chose.
229            for (range, wash) in &annotated {
230                for rect in range_rects(&layout, range, 0.0, 0.0) {
231                    window.paint_quad(quad(
232                        rect,
233                        px(2.0),
234                        *wash,
235                        px(0.0),
236                        gpui::transparent_black(),
237                        BorderStyle::default(),
238                    ));
239                }
240            }
241            // Under the glyphs, like the inline-code wash — one quad per visual
242            // row, so a wrapped selection is a stack of rows rather than a box
243            // around all of them.
244            if let Some(range) = &selected {
245                for rect in range_rects(&layout, range, 0.0, 0.0) {
246                    window.paint_quad(quad(
247                        rect,
248                        px(2.0),
249                        selection_color,
250                        px(0.0),
251                        gpui::transparent_black(),
252                        BorderStyle::default(),
253                    ));
254                }
255            }
256            if let Some(offset) = caret
257                && let Some(head) = layout.position_for_index(offset)
258            {
259                window.paint_quad(quad(
260                    caret_quad(head, size, layout.line_height()),
261                    px(0.0),
262                    caret_color,
263                    px(0.0),
264                    gpui::transparent_black(),
265                    BorderStyle::default(),
266                ));
267            }
268            for range in &code_ranges {
269                for rect in range_rects(&layout, range, INLINE_CODE_PAD_X, INLINE_CODE_INSET_Y) {
270                    window.paint_quad(quad(
271                        rect,
272                        px(INLINE_CODE_RADIUS),
273                        wash,
274                        px(0.0),
275                        gpui::transparent_black(),
276                        BorderStyle::default(),
277                    ));
278                }
279            }
280            // Wider, rounder and outlined, so a chip and an inline code span
281            // never read as the same thing at a glance.
282            for range in &chip_ranges {
283                for rect in range_rects(&layout, range, CHIP_PAD_X, CHIP_INSET_Y) {
284                    window.paint_quad(quad(
285                        rect,
286                        px(Theme::control_radius()),
287                        chip_wash,
288                        px(1.0),
289                        chip_edge,
290                        BorderStyle::Solid,
291                    ));
292                }
293            }
294        },
295    )
296    .absolute()
297    .size_full();
298
299    div()
300        .text_size(px(size))
301        .line_height(px(line_height))
302        .relative()
303        .child(underlay)
304        .children(hint)
305        .child(painted)
306        .into_any_element()
307}
308
309/// The caret's quad: the text's own size, centred in the line box.
310///
311/// The leading is not the caret's to take. A document is set with air around
312/// its lines, and a caret filling all of it reads as a second, larger font
313/// standing where the text should be.
314pub(super) fn caret_quad(head: Point<Pixels>, size: f32, line_height: Pixels) -> Bounds<Pixels> {
315    let inset = (line_height - px(size)) / 2.0;
316    Bounds::new(
317        head + point(px(0.0), inset),
318        gpui::size(px(CARET_WIDTH), px(size)),
319    )
320}
321
322/// The rectangles a byte range occupies, one per visual row.
323pub(super) fn range_rects(
324    layout: &gpui::TextLayout,
325    range: &Range<usize>,
326    pad_x: f32,
327    inset_y: f32,
328) -> Vec<Bounds<Pixels>> {
329    let mut rects = Vec::new();
330    let line_height = layout.line_height();
331    let mut origin = layout.bounds().origin;
332    let mut line_start = 0;
333    for line in layout.line_layouts() {
334        let shaped = &line.unwrapped_layout;
335        // A wrap boundary index is both the end of one row and the start of
336        // the next.
337        let row_ends = line
338            .wrap_boundaries()
339            .iter()
340            .map(|wrap| shaped.runs[wrap.run_ix].glyphs[wrap.glyph_ix].index)
341            .chain([line.len()]);
342        let mut row_start = 0;
343        for (row, row_end) in row_ends.enumerate() {
344            let from = range
345                .start
346                .saturating_sub(line_start)
347                .clamp(row_start, row_end);
348            let to = range.end.saturating_sub(line_start).min(row_end);
349            let row_x = shaped.x_for_index(row_start);
350            let (left, right) = (shaped.x_for_index(from), shaped.x_for_index(to));
351            if from < to && right > left {
352                rects.push(Bounds::new(
353                    origin
354                        + point(
355                            left - row_x - px(pad_x),
356                            line_height * row as f32 + px(inset_y),
357                        ),
358                    size(
359                        right - left + px(2.0 * pad_x),
360                        line_height - px(2.0 * inset_y),
361                    ),
362                ));
363            }
364            row_start = row_end;
365        }
366        origin.y += line.size(line_height).height;
367        // The newline between two lines is a byte of the text and of neither.
368        line_start += line.len() + 1;
369    }
370    rects
371}