Skip to main content

markdown/render/
code.rs

1//! Code blocks, source mode, and the copy button.
2
3use super::*;
4
5/// Paint a document's own markdown source: a fence's caret, selection and hit
6/// testing, without a fence's box, band or copy button.
7///
8/// The caret is a [`Cursor`] at block 0 in [`Part::Code`] — what a document
9/// held as one fence answers to, which is how an editor holds its source.
10/// Wrapping is not optional here: a paragraph is one line of markdown, and a
11/// source view that scrolled sideways would hide most of it.
12pub fn render_source(code: &str, editing: Editing, cx: &mut App) -> AnyElement {
13    let Editing {
14        selection,
15        caret_on,
16        layouts,
17        annotations,
18        typography,
19        ..
20    } = editing;
21    // The same reset `render_with` opens with, and for the same reason: the
22    // positions this frame records are the ones the next click resolves
23    // against, and last frame's have to go first.
24    let reset = layouts.map(|layouts| {
25        let layouts = layouts.clone();
26        canvas(move |_, _, _| layouts.clear(), |_, _, _, _| ())
27            .absolute()
28            .size(px(0.0))
29    });
30    let theme = Theme::of(cx).clone();
31    let typography = typography.unwrap_or_else(|| Typography::of(cx));
32    let overlay = Overlay {
33        block: 0,
34        part: Part::Code,
35        selection,
36        caret_on,
37        layouts,
38        annotations,
39        placeholder: None,
40        caption: Caption::default(),
41        // The source view is one fence and holds no task block.
42        toggle: None,
43        // It paints no band, so there is nowhere for the button to float.
44        copy: CopyButton::Hidden,
45        base: None,
46        highlight: crate::marks::highlight_paint_of(cx),
47    };
48    let (underlay, lines) = code_lines(
49        Some(crate::source::LANGUAGES[0]),
50        code,
51        overlay,
52        &typography,
53        &theme,
54        cx,
55    );
56    // Keep each number beside its source line, including wrapped and empty lines.
57    let style = crate::SourceStyle::of(cx);
58    let digits = lines.len().to_string().len().max(style.gutter_min_digits);
59    let gap = style.gutter_gap.max(0.0) * typography.code.size();
60    let gutter_width = digits as f32 * typography.code.size() + gap;
61    let lines = lines
62        .into_iter()
63        .enumerate()
64        .map(|(index, line)| {
65            if !style.line_numbers {
66                return line;
67            }
68            div()
69                .flex()
70                .items_start()
71                .child(
72                    div()
73                        .w(px(gutter_width))
74                        .flex_shrink_0()
75                        .pr(px(gap))
76                        .font_family(theme.font_mono.clone())
77                        .text_color(style.gutter_color.unwrap_or(theme.text_faint))
78                        .text_right()
79                        .child((index + 1).to_string()),
80                )
81                .child(div().flex_1().min_w_0().child(line))
82                .into_any_element()
83        })
84        .collect();
85    div()
86        .flex()
87        .flex_col()
88        .children(reset)
89        .child(code_body(0, underlay, lines, &typography, true))
90        .into_any_element()
91}
92
93/// The shaped lines of a fence, and the canvas that paints the caret, the
94/// selection and the annotations over them.
95pub(super) fn code_lines(
96    language: Option<&str>,
97    code: &str,
98    overlay: Overlay,
99    typography: &Typography,
100    theme: &Theme,
101    cx: &App,
102) -> (AnyElement, Vec<AnyElement>) {
103    let ix = overlay.block;
104    // Highlighting recolors runs only — layout does not move, so a build with
105    // no highlighter installed paints the same block in one plain run.
106    // Markdown is the one language this crate can colour on its own, which is
107    // what a source view is painted with where no highlighter reaches.
108    let spans = crate::highlight::spans(cx, language, code).or_else(|| {
109        language
110            .filter(|language| crate::source::is_markdown(language))
111            .map(|_| crate::source::spans(code))
112    });
113    let mono = font(theme.font_mono.clone());
114    let run = |len: usize, color: Hsla| TextRun {
115        len,
116        font: mono.clone(),
117        color,
118        background_color: None,
119        underline: None,
120        strikethrough: None,
121    };
122    // Each source line's own layout, with the slice of the code it covers —
123    // the caret and a click both resolve through these. A wrapped line is
124    // several rows of one layout, which is the case `range_rects` already
125    // walks for a paragraph.
126    let mut rows: Vec<(Range<usize>, TextLayout)> = Vec::new();
127    let mut offset = 0usize;
128    let lines: Vec<AnyElement> = code
129        .split('\n')
130        .map(|line| {
131            let start = offset;
132            offset += line.len() + 1;
133            let mut runs = Vec::new();
134            // Runs are measured within the line; spans are byte ranges over the
135            // whole block, so every span is clipped to the line and rebased.
136            let mut pos = 0usize;
137            if let Some(spans) = &spans {
138                let end = start + line.len();
139                for (range, kind) in spans.iter().filter(|(r, _)| r.end > start && r.start < end) {
140                    let s = range.start.clamp(start, end) - start;
141                    let e = range.end.min(end) - start;
142                    if s > pos {
143                        runs.push(run(s - pos, theme.text));
144                    }
145                    runs.push(run(e - s, theme.syntax.color(*kind)));
146                    pos = e;
147                }
148            }
149            if pos < line.len() {
150                runs.push(run(line.len() - pos, theme.text));
151            }
152            if runs.is_empty() {
153                runs.push(run(0, theme.text));
154            }
155            let styled = StyledText::new(SharedString::from(line.to_string())).with_runs(runs);
156            rows.push((start..start + line.len(), styled.layout().clone()));
157            styled.into_any_element()
158        })
159        .collect();
160
161    let caret = overlay.caret_painted();
162    let selected = overlay.selected(code.len());
163    let sink = overlay.layouts.cloned();
164    let code_size = typography.code.size();
165    let annotated = overlay.annotated(code.len(), theme);
166    let (caret_color, selection_color) = (theme.caret, theme.selection);
167    let underlay = canvas(
168        |_, _, _| (),
169        move |_, _, window, _| {
170            for (span, layout) in &rows {
171                if let Some(sink) = &sink {
172                    sink.record(ix, Part::Code, span.clone(), layout.clone());
173                }
174                for (range, wash) in &annotated {
175                    let (from, to) = (range.start.max(span.start), range.end.min(span.end));
176                    if from < to {
177                        for rect in
178                            range_rects(layout, &(from - span.start..to - span.start), 0.0, 0.0)
179                        {
180                            window.paint_quad(quad(
181                                rect,
182                                px(2.0),
183                                *wash,
184                                px(0.0),
185                                gpui::transparent_black(),
186                                BorderStyle::default(),
187                            ));
188                        }
189                    }
190                }
191                if let Some(range) = &selected {
192                    let (from, to) = (range.start.max(span.start), range.end.min(span.end));
193                    if from < to {
194                        for rect in
195                            range_rects(layout, &(from - span.start..to - span.start), 0.0, 0.0)
196                        {
197                            window.paint_quad(quad(
198                                rect,
199                                px(2.0),
200                                selection_color,
201                                px(0.0),
202                                gpui::transparent_black(),
203                                BorderStyle::default(),
204                            ));
205                        }
206                    }
207                }
208                if let Some(offset) = caret.filter(|at| span.contains(at) || *at == span.end)
209                    && let Some(head) = layout.position_for_index(offset - span.start)
210                {
211                    window.paint_quad(quad(
212                        caret_quad(head, code_size, layout.line_height()),
213                        px(0.0),
214                        caret_color,
215                        px(0.0),
216                        gpui::transparent_black(),
217                        BorderStyle::default(),
218                    ));
219                }
220            }
221        },
222    )
223    .absolute()
224    .size_full();
225
226    (underlay.into_any_element(), lines)
227}
228
229pub(super) fn code_block(
230    language: Option<&str>,
231    code: &str,
232    overlay: Overlay,
233    typography: &Typography,
234    theme: &Theme,
235    window: &mut Window,
236    cx: &mut App,
237) -> AnyElement {
238    let ix = overlay.block;
239    let (underlay, lines) = code_lines(language, code, overlay, typography, theme, cx);
240    let body = code_body(ix, underlay, lines, typography, Layout::of(cx).wrap_code);
241
242    div()
243        .rounded(px(Theme::panel_radius()))
244        .bg(theme.ink(0.035))
245        .border_1()
246        .border_color(theme.border)
247        .overflow_hidden()
248        .relative()
249        // The band is unconditional: it is where the copy button already floats,
250        // and where a host puts its language control — which needs somewhere to
251        // sit on a block that has no language yet.
252        .child(
253            div()
254                .relative()
255                .flex()
256                .flex_row()
257                .items_center()
258                .px(px(CODE_PADDING_X))
259                .py(px(5.0))
260                .border_b_1()
261                .border_color(theme.border)
262                .bg(theme.ink(0.02))
263                .text_style(TextStyle::Subheadline)
264                .text_color(match language {
265                    Some(_) => theme.text_muted,
266                    None => theme.text_faint,
267                })
268                // The label's own box, not the band's: a host hanging a picker
269                // here wants it around the word, and only the word knows how
270                // wide the word is.
271                .child(
272                    div()
273                        .relative()
274                        .children(overlay.layouts.map(|layouts| {
275                            let layouts = layouts.clone();
276                            canvas(
277                                move |bounds, _, _| layouts.record_language(ix, bounds),
278                                |_, _, _, _| (),
279                            )
280                            .absolute()
281                            .size_full()
282                        }))
283                        .child(SharedString::from(
284                            language.unwrap_or(PLAIN_LANGUAGE).to_string(),
285                        )),
286                ),
287        )
288        .child(body)
289        .children(
290            (overlay.copy == CopyButton::Shown).then(|| copy_button(code, ix, theme, window, cx)),
291        )
292        .into_any_element()
293}
294
295/// The lines of a fence, wrapped to the block or scrolling sideways under it.
296pub(super) fn code_body(
297    ix: usize,
298    underlay: AnyElement,
299    lines: Vec<AnyElement>,
300    typography: &Typography,
301    wrap: bool,
302) -> AnyElement {
303    let column = div()
304        .flex()
305        .flex_col()
306        .px(px(CODE_PADDING_X))
307        .children(lines);
308    let body = div()
309        .id(ElementId::named_usize("md-code", ix))
310        .relative()
311        .py(px(CODE_PADDING_Y))
312        .text_size(px(typography.code.size()))
313        .line_height(px(typography.code.line_height()))
314        .child(underlay);
315    if wrap {
316        // The column is the block's width here rather than its widest line's,
317        // which is what gives the text something to wrap against.
318        body.child(column.w_full()).into_any_element()
319    } else {
320        ui::scroll::Viewport::new(
321            format!("md-code-scroll-{ix}"),
322            body.flex()
323                .flex_row()
324                .whitespace_nowrap()
325                // The padding belongs to the lines, not to the scroller: a scroll
326                // container's trailing padding is not part of what it will scroll
327                // to, so the last characters of a long line sit behind the right
328                // edge with nowhere left to go. As a row's only item this column is
329                // sized by its widest line, and the padding rides along inside that
330                // width.
331                .child(column.items_start()),
332            gpui::Axis::Horizontal,
333        )
334        .into_any_element()
335    }
336}
337
338/// A copy button that owns its own feedback.
339///
340/// The state is the element's, not the caller's: a component library cannot ask
341/// every host to thread a handler and a "which block is showing Copied" index
342/// through its render tree just to put a button on a code block. It resets when
343/// the pointer leaves, which needs no clock.
344pub(super) fn copy_button(
345    code: &str,
346    ix: usize,
347    theme: &Theme,
348    window: &mut Window,
349    cx: &mut App,
350) -> AnyElement {
351    let copied = window.use_keyed_state(ElementId::named_usize("md-copied", ix), cx, |_, _| false);
352    let showing = *copied.read(cx);
353    let text: SharedString = code.to_string().into();
354
355    div()
356        .id(ElementId::named_usize("md-copy", ix))
357        .absolute()
358        .top(px(3.0))
359        .right(px(5.0))
360        .h(px(20.0))
361        .px(px(6.0))
362        .rounded(px(5.0))
363        .flex()
364        .items_center()
365        .cursor_pointer()
366        .text_style(TextStyle::Caption)
367        .text_color(theme.text_muted)
368        .hover(|el| el.bg(theme.element_hover))
369        .child(if showing { "Copied" } else { "Copy" })
370        .on_click({
371            let copied = copied.clone();
372            move |_, _, cx| {
373                cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string()));
374                copied.update(cx, |state, cx| {
375                    *state = true;
376                    cx.notify();
377                });
378            }
379        })
380        .on_hover(move |hovering, _, cx| {
381            if !*hovering && *copied.read(cx) {
382                copied.update(cx, |state, cx| {
383                    *state = false;
384                    cx.notify();
385                });
386            }
387        })
388        .into_any_element()
389}