Skip to main content

guise/markdown/
view.rs

1//! `Markdown` — read-only markdown, rendered.
2//!
3//! [`MarkdownEditor`](super::MarkdownEditor) exists to *edit* markdown, so it
4//! carries a caret, a scroll model, and hand-rolled glyph layout. Displaying
5//! markdown needs none of that, and asking for an editor to show a paragraph
6//! is the wrong shape. This walks the same three pure passes the editor does —
7//! [`block::classify`], then [`layout::plan`] with reveal off — and hands each
8//! line to gpui's `StyledText`, which wraps it for us.
9//!
10//! It is what an assistant's reply is drawn with, and it is a plain
11//! `RenderOnce` builder, so it can appear anywhere text can.
12//!
13//! ```ignore
14//! div().child(Markdown::new("# Notes\n\n- **bold** and `code`"))
15//! ```
16
17use gpui::prelude::*;
18use gpui::{
19    div, px, App, Font, FontStyle, FontWeight, IntoElement, SharedString, StrikethroughStyle,
20    StyledText, TextRun, UnderlineStyle, Window,
21};
22
23use super::block::{classify, DocState};
24use super::layout::{metrics, plan, RowKind, RowPlan};
25use crate::devtools::Probed;
26use crate::style::MONO_FAMILY;
27use crate::theme::{theme, ColorName, Size};
28
29/// Read-only markdown. Create with [`Markdown::new`] and drop it anywhere.
30#[derive(IntoElement)]
31pub struct Markdown {
32    source: SharedString,
33    size: Size,
34    /// Colors links with the theme's primary and underlines them.
35    accent: Option<ColorName>,
36    /// Cap on how much of the source is rendered, in lines. Streaming replies
37    /// can get long, and the caller may want a preview.
38    max_lines: Option<usize>,
39}
40
41impl Markdown {
42    pub fn new(source: impl Into<SharedString>) -> Self {
43        Markdown {
44            source: source.into(),
45            size: Size::Sm,
46            accent: None,
47            max_lines: None,
48        }
49    }
50
51    /// Base text size; headings and code scale from it.
52    pub fn size(mut self, size: Size) -> Self {
53        self.size = size;
54        self
55    }
56
57    /// Draw links in this palette color rather than the theme's primary.
58    pub fn accent(mut self, accent: ColorName) -> Self {
59        self.accent = Some(accent);
60        self
61    }
62
63    /// Render at most `lines` source lines.
64    pub fn max_lines(mut self, lines: usize) -> Self {
65        self.max_lines = Some(lines);
66        self
67    }
68}
69
70impl RenderOnce for Markdown {
71    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
72        let t = theme(cx);
73        // Start from the font actually in effect, so bold and italic are that
74        // family's own faces. Building a `Font` from scratch with an empty
75        // family name resolves to nothing and loses the weight with it.
76        let prose = window.text_style().font();
77        let base = t.font_size(self.size);
78        let text_color = t.text().hsla();
79        let dimmed = t.dimmed().hsla();
80        let border = t.border().hsla();
81        let accent = self
82            .accent
83            .map_or_else(|| t.primary(), |name| t.color(name, 6))
84            .hsla();
85        let code_bg = t.surface_hover().alpha(0.7);
86        let mut highlight_bg = t.color(ColorName::Yellow, 3).hsla();
87        highlight_bg.a = 0.45;
88
89        // Cloned once instead of per run: `family` is a `SharedString` and
90        // `..prose.clone()` would bump its refcount for every styled span on
91        // every line, every frame.
92        let prose_family = prose.family.clone();
93        let mono_family: SharedString = MONO_FAMILY.into();
94        let colors = RowColors {
95            text: text_color,
96            dimmed,
97            border,
98            code_bg,
99        };
100
101        let mut state = DocState::default();
102        let mut column = div().flex().flex_col().w_full();
103        let lines = self
104            .source
105            .lines()
106            .take(self.max_lines.unwrap_or(usize::MAX));
107
108        for line in lines {
109            let block = classify(line, &mut state);
110            // `reveal: false` is the whole difference from the editor: markers
111            // stay hidden because there is no cursor that might want to edit
112            // them. The fence's language is passed as `None` because nothing
113            // here highlights code — carrying it would allocate a `String` per
114            // line of every fenced block, every frame, to be thrown away.
115            let row = plan(line, &block, None, false);
116            let row_metrics = metrics(&row.kind);
117            let size = base * row_metrics.scale;
118            let weight = if matches!(row.kind, RowKind::Heading(_)) {
119                FontWeight::BOLD
120            } else {
121                prose.weight
122            };
123
124            let runs: Vec<TextRun> = row
125                .runs
126                .iter()
127                .filter(|run| run.len > 0)
128                .map(|run| {
129                    let s = run.style;
130                    let mono = s.code || matches!(row.kind, RowKind::Code { .. });
131                    TextRun {
132                        len: run.len,
133                        font: Font {
134                            family: if mono {
135                                mono_family.clone()
136                            } else {
137                                prose_family.clone()
138                            },
139                            weight: if s.bold { FontWeight::BOLD } else { weight },
140                            style: if s.italic {
141                                FontStyle::Italic
142                            } else {
143                                prose.style
144                            },
145                            ..prose.clone()
146                        },
147                        color: if run.marker || run.dim {
148                            dimmed
149                        } else if s.link {
150                            accent
151                        } else {
152                            text_color
153                        },
154                        background_color: if s.highlight {
155                            Some(highlight_bg)
156                        } else if s.code && !mono_block(&row.kind) {
157                            Some(code_bg)
158                        } else {
159                            None
160                        },
161                        underline: (s.link && !run.marker).then(|| UnderlineStyle {
162                            thickness: px(1.0),
163                            color: Some(accent),
164                            wavy: false,
165                        }),
166                        strikethrough: s.strike.then(|| StrikethroughStyle {
167                            thickness: px(1.0),
168                            color: Some(dimmed),
169                        }),
170                    }
171                })
172                .collect();
173
174            column = column.child(row_element(row, runs, size, base, &colors));
175        }
176        column.probe("Markdown")
177    }
178}
179
180/// The colors a row draws with, resolved once per render. Passed as one value
181/// because four bare `Hsla` parameters in a row are silently transposable.
182struct RowColors {
183    text: gpui::Hsla,
184    dimmed: gpui::Hsla,
185    border: gpui::Hsla,
186    code_bg: gpui::Hsla,
187}
188
189/// The chrome around one line: bullets, checkboxes, quote bars, code
190/// backgrounds, and the rule.
191fn row_element(
192    row: RowPlan,
193    runs: Vec<TextRun>,
194    size: f32,
195    base: f32,
196    colors: &RowColors,
197) -> gpui::Div {
198    let m = metrics(&row.kind);
199    // `visible` is moved out of the plan rather than cloned: the plan is this
200    // frame's and nothing else reads it.
201    let text = StyledText::new(SharedString::from(row.visible)).with_runs(runs);
202
203    let mut line = div()
204        .flex()
205        .flex_row()
206        .items_start()
207        .w_full()
208        .text_size(px(size))
209        .pt(px(base * m.pad_top))
210        .pb(px(base * m.pad_bottom));
211
212    match &row.kind {
213        // A blank line is vertical space, not an empty text box that would
214        // collapse to nothing.
215        RowKind::Blank => return div().h(px(base * 0.6)),
216        RowKind::Rule => {
217            return div()
218                .py(px(base * 0.5))
219                .child(div().w_full().h(px(1.0)).bg(colors.border))
220        }
221        // The fence markers themselves are syntax, so they leave no row.
222        RowKind::Fence { .. } | RowKind::FrontMatter => return div(),
223        RowKind::Code { .. } => {
224            return div()
225                .w_full()
226                .px(px(base * 0.6))
227                .bg(colors.code_bg)
228                .text_size(px(size))
229                .child(text);
230        }
231        RowKind::Quote { depth } => {
232            for _ in 0..(*depth).max(1) {
233                line = line.child(
234                    div()
235                        .flex_none()
236                        .w(px(2.0))
237                        .h(px(base * 1.4))
238                        .mr(px(base * 0.6))
239                        .bg(colors.border),
240                );
241            }
242        }
243        RowKind::Bullet { cols } => {
244            line = line.pl(px(*cols as f32 * base * 0.5)).child(marker(
245                base,
246                colors.dimmed,
247                "\u{2022}",
248            ));
249        }
250        RowKind::Ordered { cols, number } => {
251            line = line.pl(px(*cols as f32 * base * 0.5)).child(marker(
252                base,
253                colors.dimmed,
254                format!("{number}."),
255            ));
256        }
257        RowKind::Task { cols, checked } => {
258            let glyph = if *checked { "\u{2611}" } else { "\u{2610}" };
259            line = line.pl(px(*cols as f32 * base * 0.5)).child(marker(
260                base,
261                if *checked { colors.dimmed } else { colors.text },
262                glyph,
263            ));
264        }
265        RowKind::Heading(_) | RowKind::Paragraph | RowKind::Table => {}
266    }
267
268    line.child(div().flex_1().min_w(px(0.0)).child(text))
269}
270
271/// The bullet, number, or checkbox in a list row's gutter.
272fn marker(base: f32, color: gpui::Hsla, glyph: impl Into<SharedString>) -> gpui::Div {
273    div()
274        .flex_none()
275        .min_w(px(base * 1.1))
276        .mr(px(base * 0.4))
277        .text_color(color)
278        .child(glyph.into())
279}
280
281/// Whether the whole row is already monospaced, so an inline-code run should
282/// not paint its own background on top.
283fn mono_block(kind: &RowKind) -> bool {
284    matches!(kind, RowKind::Code { .. } | RowKind::Fence { .. })
285}