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
245        .pl(px(*cols as f32 * base * 0.5))
246        .child(marker(base, colors.dimmed, "\u{2022}"));
247    }
248    RowKind::Ordered { cols, number } => {
249      line = line.pl(px(*cols as f32 * base * 0.5)).child(marker(
250        base,
251        colors.dimmed,
252        format!("{number}."),
253      ));
254    }
255    RowKind::Task { cols, checked } => {
256      let glyph = if *checked { "\u{2611}" } else { "\u{2610}" };
257      line = line.pl(px(*cols as f32 * base * 0.5)).child(marker(
258        base,
259        if *checked { colors.dimmed } else { colors.text },
260        glyph,
261      ));
262    }
263    RowKind::Heading(_) | RowKind::Paragraph | RowKind::Table => {}
264  }
265
266  line.child(div().flex_1().min_w(px(0.0)).child(text))
267}
268
269/// The bullet, number, or checkbox in a list row's gutter.
270fn marker(base: f32, color: gpui::Hsla, glyph: impl Into<SharedString>) -> gpui::Div {
271  div()
272    .flex_none()
273    .min_w(px(base * 1.1))
274    .mr(px(base * 0.4))
275    .text_color(color)
276    .child(glyph.into())
277}
278
279/// Whether the whole row is already monospaced, so an inline-code run should
280/// not paint its own background on top.
281fn mono_block(kind: &RowKind) -> bool {
282  matches!(kind, RowKind::Code { .. } | RowKind::Fence { .. })
283}