Skip to main content

gpui_kit/content/
markdown.rs

1//! Read-only Markdown, rendered to GPUI elements.
2//!
3//! # What this component will not do
4//!
5//! **It does not execute HTML, and it does not swallow it.** A fragment of raw
6//! HTML is rendered as the literal characters that were written, marked as
7//! unrendered. Interpreting it would let a document reach outside the reader's
8//! text; dropping it would let a document delete its own content from view by
9//! wrapping it in a tag, which is worse than showing the tag.
10//!
11//! **It does not open links.** A link shows its destination before it is
12//! taken — in the node it publishes and in hover help — and reports
13//! [`MarkdownEvent::LinkClicked`]. Whether that destination may be opened, and
14//! by what, is the host's to decide.
15//!
16//! **It does not fetch images.** This crate has no network and no asset
17//! resolution. An image is drawn as a placeholder naming its alt text and its
18//! source, and reported once as [`MarkdownEvent::ImageRequested`]; a host that
19//! has the bytes hands an element back through [`Markdown::image`].
20//!
21//! **It does not guess at syntax.** A code block is set in the mono face and
22//! publishes the fence's info string exactly as it was written. Colour comes
23//! from host-supplied [`CodeSpan`]s or from nowhere.
24
25pub mod parse;
26
27use std::collections::{HashMap, HashSet};
28use std::ops::Range;
29use std::rc::Rc;
30
31use gpui::{
32    AnyElement, App, ClipboardItem, FontWeight, InteractiveElement, IntoElement, ParentElement,
33    RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
34    prelude::FluentBuilder, px,
35};
36use gpui_kit_semantics::{NodeSpec, Role, Semantic};
37use gpui_kit_theme::{ActiveTheme, Elevation, Radius, Space, Surface, Theme, TypeScale};
38
39use crate::controls::button::Button;
40use crate::display::badge::Tone;
41use crate::foundation::{Ident, Sizable, StyledExt};
42use crate::motion::keyed;
43use crate::overlay::Tooltipped;
44use crate::strings::{ActiveStrings, StringKey};
45
46pub use parse::{Block, CellAlign, Document, Inline, ListEntry};
47
48/// What a rendered document reports. It applies none of it.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum MarkdownEvent {
51    /// A link was taken. The crate opens nothing.
52    LinkClicked { href: SharedString },
53    /// An image the host has not supplied was drawn as a placeholder.
54    ///
55    /// Reported once per source per rendered document, not once per frame, so
56    /// a host may answer it by supplying the image without the answer
57    /// provoking another request.
58    ImageRequested {
59        src: SharedString,
60        alt: SharedString,
61    },
62    /// A code block's exact text was put on the clipboard.
63    CodeCopied {
64        language: Option<SharedString>,
65        text: SharedString,
66    },
67    /// The reader asked for the lines [`Markdown::max_lines`] left out.
68    MoreRequested { lines: usize },
69}
70
71/// An image the document referred to and this crate did not fetch.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct ImageRequest {
74    pub src: SharedString,
75    pub alt: SharedString,
76    pub title: Option<SharedString>,
77}
78
79/// A fenced block, as handed to a host that colours code.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CodeBlock {
82    /// The fence's info string, exactly as written. Nothing here parses it.
83    pub language: Option<SharedString>,
84    pub text: SharedString,
85}
86
87/// One coloured run of a code block, in byte offsets into its text.
88///
89/// The tone vocabulary is the library's existing one rather than a set of
90/// syntax categories, because deciding that a word is a keyword is the
91/// grammar's judgement and this crate has no grammar.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CodeSpan {
94    pub range: Range<usize>,
95    pub tone: Tone,
96}
97
98type EventHandler = Rc<dyn Fn(&MarkdownEvent, &mut Window, &mut App)>;
99type ImageSource = Rc<dyn Fn(&ImageRequest, &mut Window, &mut App) -> Option<AnyElement>>;
100type Highlighter = Rc<dyn Fn(&CodeBlock) -> Vec<CodeSpan>>;
101
102/// A rendered Markdown document.
103#[derive(IntoElement)]
104pub struct Markdown {
105    ident: Ident,
106    source: SharedString,
107    max_lines: Option<usize>,
108    on_event: Option<EventHandler>,
109    image: Option<ImageSource>,
110    highlighter: Option<Highlighter>,
111}
112
113impl std::fmt::Debug for Markdown {
114    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        formatter
116            .debug_struct("Markdown")
117            .field("ident", &self.ident)
118            .field("bytes", &self.source.len())
119            .field("max_lines", &self.max_lines)
120            .field("has_images", &self.image.is_some())
121            .field("has_highlighter", &self.highlighter.is_some())
122            .finish()
123    }
124}
125
126impl Markdown {
127    pub fn new(ident: impl Into<Ident>, source: impl Into<SharedString>) -> Self {
128        Self {
129            ident: ident.into(),
130            source: source.into(),
131            max_lines: None,
132            on_event: None,
133            image: None,
134            highlighter: None,
135        }
136    }
137
138    /// Shows at most `lines` of the document's own structure, and says how
139    /// many were left out.
140    ///
141    /// A line is a heading, a paragraph, one line of a code fence, one list
142    /// entry, or one table row — not a line of wrapped text, whose count only
143    /// layout knows.
144    pub fn max_lines(mut self, lines: usize) -> Self {
145        self.max_lines = Some(lines);
146        self
147    }
148
149    pub fn on_event(
150        mut self,
151        handler: impl Fn(&MarkdownEvent, &mut Window, &mut App) + 'static,
152    ) -> Self {
153        self.on_event = Some(Rc::new(handler));
154        self
155    }
156
157    /// Supplies the element to draw for an image the host already holds.
158    ///
159    /// Answering `None` leaves the placeholder, which names the source, so a
160    /// host that cannot supply one has said so rather than left a gap.
161    pub fn image(
162        mut self,
163        source: impl Fn(&ImageRequest, &mut Window, &mut App) -> Option<AnyElement> + 'static,
164    ) -> Self {
165        self.image = Some(Rc::new(source));
166        self
167    }
168
169    /// Colours code blocks from spans the host computed.
170    ///
171    /// Without this every fence renders plain. Guessing a language from its
172    /// text, or a grammar from its name, would put colour on a claim nobody
173    /// made.
174    pub fn highlight(
175        mut self,
176        highlighter: impl Fn(&CodeBlock) -> Vec<CodeSpan> + 'static,
177    ) -> Self {
178        self.highlighter = Some(Rc::new(highlighter));
179        self
180    }
181}
182
183impl RenderOnce for Markdown {
184    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
185        let theme = cx.theme().clone();
186        let ident = self.ident.clone();
187        let parsed = Document::parse(self.source.as_ref());
188        let (document, hidden) = match self.max_lines {
189            Some(max) => parsed.truncate(max),
190            None => (parsed, 0),
191        };
192
193        let mut painter = Painter {
194            ident: ident.clone(),
195            theme: theme.clone(),
196            on_event: self.on_event.clone(),
197            image: self.image.clone(),
198            highlighter: self.highlighter.clone(),
199            used: HashMap::new(),
200            requested: Vec::new(),
201        };
202
203        let mut column = div().column().w_full().gap_token(&theme, Space::Md);
204        for block in &document.blocks {
205            column = column.child(painter.block(block, window, cx));
206        }
207
208        if hidden > 0 {
209            column = column.child(painter.more(hidden, cx));
210        }
211
212        let requests = std::mem::take(&mut painter.requested);
213        report_images(&ident, requests, self.on_event.as_ref(), window, cx);
214
215        column.semantic_in(
216            cx,
217            NodeSpec::new(ident.semantic_id(), Role::Region)
218                .value(format!("{} blocks", document.blocks.len())),
219        )
220    }
221}
222
223/// The image sources already reported for one document.
224#[derive(Debug, Default)]
225struct Requested(HashSet<SharedString>);
226
227/// Reports each unfetched image once, keyed by the document's identity.
228///
229/// The report happens while the document renders, because that is when the
230/// crate learns an image was asked for. Reporting it again every frame would
231/// hand the host a request it has already answered, so the sources already
232/// reported are remembered for as long as the document is on screen.
233fn report_images(
234    ident: &Ident,
235    requests: Vec<ImageRequest>,
236    handler: Option<&EventHandler>,
237    window: &mut Window,
238    cx: &mut App,
239) {
240    if requests.is_empty() {
241        return;
242    }
243    let Some(handler) = handler.cloned() else {
244        return;
245    };
246    let cell = keyed::slot::<Requested>(&ident.child("images").semantic_id(), cx);
247    let fresh: Vec<ImageRequest> = {
248        let mut seen = cell.borrow_mut();
249        requests
250            .into_iter()
251            .filter(|request| seen.0.insert(request.src.clone()))
252            .collect()
253    };
254    for request in fresh {
255        handler(
256            &MarkdownEvent::ImageRequested {
257                src: request.src,
258                alt: request.alt,
259            },
260            window,
261            cx,
262        );
263    }
264}
265
266/// How a run of prose is set.
267#[derive(Debug, Clone, Copy, Default)]
268struct RunStyle {
269    strong: bool,
270    emphasis: bool,
271    struck: bool,
272}
273
274/// Walks the tree once, drawing it and minting one id per addressable part.
275struct Painter {
276    ident: Ident,
277    theme: Theme,
278    on_event: Option<EventHandler>,
279    image: Option<ImageSource>,
280    highlighter: Option<Highlighter>,
281    /// How many parts have already claimed each id stem, so a document that
282    /// links to the same place twice still publishes two distinct nodes.
283    used: HashMap<String, usize>,
284    requested: Vec<ImageRequest>,
285}
286
287impl Painter {
288    /// An identity derived from what a part *is*, never from where it sits.
289    ///
290    /// Two links to the same destination are the same thing said twice, so the
291    /// repeat is numbered rather than the position.
292    fn ident_for(&mut self, kind: &str, name: &str) -> Ident {
293        let stem = format!("{kind}-{}", slug(name));
294        let count = self.used.entry(stem.clone()).or_insert(0);
295        *count += 1;
296        match *count {
297            1 => self.ident.child(stem),
298            repeat => self.ident.child(format!("{stem}-{repeat}")),
299        }
300    }
301
302    fn report(&self, event: MarkdownEvent) -> Option<impl Fn(&mut Window, &mut App) + use<>> {
303        let handler = self.on_event.clone()?;
304        Some(move |window: &mut Window, cx: &mut App| handler(&event, window, cx))
305    }
306
307    fn block(&mut self, block: &Block, window: &mut Window, cx: &mut App) -> AnyElement {
308        match block {
309            Block::Heading { level, content } => self.heading(*level, content, window, cx),
310            Block::Paragraph(inlines) => self.paragraph(inlines, window, cx),
311            Block::Code { language, text } => self.code(language.clone(), text.clone(), cx),
312            Block::Quote(blocks) => self.quote(blocks, window, cx),
313            Block::List {
314                ordered,
315                start,
316                entries,
317            } => self.list(*ordered, *start, entries, window, cx),
318            Block::Rule => self.rule(cx),
319            Block::Table {
320                alignment,
321                head,
322                rows,
323            } => self.table(alignment, head, rows, window, cx),
324            Block::Html(text) => self.html_block(text.clone(), cx),
325        }
326    }
327
328    fn heading(
329        &mut self,
330        level: u8,
331        content: &[Inline],
332        window: &mut Window,
333        cx: &mut App,
334    ) -> AnyElement {
335        let text = flatten(content);
336        let ident = self.ident_for("heading", &text);
337        let theme = self.theme.clone();
338        // Only the first two steps get their own size; deeper headings are
339        // distinguished by weight, because the type scale has five steps and a
340        // document may have six levels.
341        let scale = match level {
342            1 => TypeScale::Title,
343            2 => TypeScale::Body,
344            _ => TypeScale::Label,
345        };
346        let runs = self.inline_row(
347            content,
348            RunStyle {
349                strong: true,
350                ..RunStyle::default()
351            },
352            window,
353            cx,
354        );
355
356        div()
357            .w_full()
358            .type_scale(&theme, scale)
359            .text_color(theme.colors.text)
360            .child(runs)
361            .semantic_in(
362                cx,
363                NodeSpec::new(ident.semantic_id(), Role::Heading)
364                    .parent(self.ident.semantic_id())
365                    .text(text)
366                    // The outline is what assistive technology reads a
367                    // document's shape from, so the level is published rather
368                    // than left to be inferred from the type size.
369                    .level(u32::from(level)),
370            )
371            .into_any_element()
372    }
373
374    fn paragraph(&mut self, inlines: &[Inline], window: &mut Window, cx: &mut App) -> AnyElement {
375        let theme = self.theme.clone();
376        div()
377            .w_full()
378            .type_scale(&theme, TypeScale::Body)
379            .text_color(theme.colors.text)
380            .child(self.inline_row(inlines, RunStyle::default(), window, cx))
381            .into_any_element()
382    }
383
384    /// One line of prose, as a wrapping row of separately addressable runs.
385    ///
386    /// A link and an image each need their own bounds so a test and a pointer
387    /// can find them, and GPUI has no way to hang a probe on a byte range
388    /// inside a shaped line. The cost is stated in `docs/content.md`: a line
389    /// breaks between runs rather than inside one that spans two styles.
390    fn inline_row(
391        &mut self,
392        inlines: &[Inline],
393        style: RunStyle,
394        window: &mut Window,
395        cx: &mut App,
396    ) -> AnyElement {
397        let mut row = div().flex().flex_row().flex_wrap().w_full();
398        for element in self.inlines(inlines, style, window, cx) {
399            row = row.child(element);
400        }
401        row.into_any_element()
402    }
403
404    fn inlines(
405        &mut self,
406        inlines: &[Inline],
407        style: RunStyle,
408        window: &mut Window,
409        cx: &mut App,
410    ) -> Vec<AnyElement> {
411        let theme = self.theme.clone();
412        let mut elements = Vec::new();
413        for inline in inlines {
414            match inline {
415                Inline::Text(text) => elements.push(run(&theme, text.clone(), style)),
416                Inline::Code(text) => elements.push(
417                    div()
418                        .px(px(theme.space(Space::Xs)))
419                        .radius(&theme, Radius::Small)
420                        .bg(theme.colors.raised)
421                        .font_family(theme.typography.mono.clone())
422                        .text_size(px(theme.typography.code.size))
423                        .child(text.clone())
424                        .into_any_element(),
425                ),
426                Inline::Emphasis(inner) => elements.extend(self.inlines(
427                    inner,
428                    RunStyle {
429                        emphasis: true,
430                        ..style
431                    },
432                    window,
433                    cx,
434                )),
435                Inline::Strong(inner) => elements.extend(self.inlines(
436                    inner,
437                    RunStyle {
438                        strong: true,
439                        ..style
440                    },
441                    window,
442                    cx,
443                )),
444                Inline::Struck(inner) => elements.extend(self.inlines(
445                    inner,
446                    RunStyle {
447                        struck: true,
448                        ..style
449                    },
450                    window,
451                    cx,
452                )),
453                Inline::Link {
454                    href,
455                    title,
456                    content,
457                } => elements.push(self.link(href, title.as_ref(), content, style, window, cx)),
458                Inline::Image { src, alt, title } => {
459                    elements.push(self.image(src, alt, title.as_ref(), window, cx))
460                }
461                Inline::Html(html) => elements.push(self.html_inline(html.clone(), cx)),
462                Inline::SoftBreak => elements.push(run(&theme, " ".into(), style)),
463                Inline::HardBreak => {
464                    elements.push(div().w_full().h(px(0.0)).flex_none().into_any_element())
465                }
466            }
467        }
468        elements
469    }
470
471    fn link(
472        &mut self,
473        href: &SharedString,
474        title: Option<&SharedString>,
475        content: &[Inline],
476        style: RunStyle,
477        window: &mut Window,
478        cx: &mut App,
479    ) -> AnyElement {
480        let theme = self.theme.clone();
481        let label = {
482            let text = flatten(content);
483            if text.trim().is_empty() {
484                href.clone()
485            } else {
486                text
487            }
488        };
489        let ident = self.ident_for("link", href.as_ref());
490        // Hover help states the destination before the reader commits, and the
491        // title the author wrote is shown beside it rather than instead of it.
492        let help = match title {
493            Some(title) => SharedString::from(format!("{title} — {href}")),
494            None => href.clone(),
495        };
496        let runs = self.inlines(content, style, window, cx);
497        let taken = self.report(MarkdownEvent::LinkClicked { href: href.clone() });
498
499        div()
500            .id(ident.element_id())
501            .flex()
502            .flex_row()
503            .flex_wrap()
504            .cursor_pointer()
505            .text_color(theme.colors.accent)
506            .underline()
507            .tip(ident.clone(), help)
508            .children(if runs.is_empty() {
509                vec![run(&theme, label.clone(), style)]
510            } else {
511                runs
512            })
513            .when_some(taken, |element, taken| {
514                element.on_click(move |_, window, cx| taken(window, cx))
515            })
516            .semantic_in(
517                cx,
518                NodeSpec::new(ident.semantic_id(), Role::Link)
519                    .parent(self.ident.semantic_id())
520                    .text(label)
521                    // The destination is the fact a reader would otherwise
522                    // have to take on trust, so it is published, not hinted.
523                    .value(href.clone()),
524            )
525            .into_any_element()
526    }
527
528    fn image(
529        &mut self,
530        src: &SharedString,
531        alt: &SharedString,
532        title: Option<&SharedString>,
533        window: &mut Window,
534        cx: &mut App,
535    ) -> AnyElement {
536        let theme = self.theme.clone();
537        let ident = self.ident_for("image", src.as_ref());
538        let request = ImageRequest {
539            src: src.clone(),
540            alt: alt.clone(),
541            title: title.cloned(),
542        };
543        let supplied = self
544            .image
545            .as_ref()
546            .and_then(|source| source(&request, window, cx));
547        let named = if alt.trim().is_empty() {
548            cx.strings().text(StringKey::MarkdownImageAlt)
549        } else {
550            alt.clone()
551        };
552
553        if supplied.is_none() {
554            self.requested.push(request);
555        }
556
557        let spec = NodeSpec::new(ident.semantic_id(), Role::Image)
558            .parent(self.ident.semantic_id())
559            .text(named.clone())
560            .value(if supplied.is_some() {
561                SharedString::new_static("supplied")
562            } else {
563                SharedString::new_static("not fetched")
564            });
565
566        match supplied {
567            Some(element) => div()
568                .child(element)
569                .semantic_in(cx, spec)
570                .into_any_element(),
571            // The placeholder names both the alt text and the source, because
572            // an image nobody fetched is a fact about the document, and a grey
573            // rectangle would hide which image is missing.
574            None => div()
575                .column()
576                .gap(px(2.0))
577                .px_token(&theme, Space::Sm)
578                .py_token(&theme, Space::Xs)
579                .radius(&theme, Radius::Small)
580                .frame(&theme, Surface::Raised, Elevation::Raised)
581                .child(
582                    div()
583                        .type_scale(&theme, TypeScale::Label)
584                        .text_color(theme.colors.text)
585                        .child(named),
586                )
587                .child(
588                    div()
589                        .type_scale(&theme, TypeScale::Caption)
590                        .text_color(theme.colors.text_faint)
591                        .child(
592                            cx.strings()
593                                .format(StringKey::MarkdownImageNotFetched, &[src.as_ref()]),
594                        ),
595                )
596                .semantic_in(cx, spec)
597                .into_any_element(),
598        }
599    }
600
601    fn code(
602        &mut self,
603        language: Option<SharedString>,
604        text: SharedString,
605        cx: &mut App,
606    ) -> AnyElement {
607        let theme = self.theme.clone();
608        // The id seed stays English on purpose: a semantic id that moved when
609        // the host installed a translation would not be a stable id.
610        let ident = self.ident_for("code", language.as_deref().unwrap_or(PLAIN_TEXT_ID));
611        let label = language
612            .clone()
613            .unwrap_or_else(|| cx.strings().text(StringKey::MarkdownPlainText));
614        let block = CodeBlock {
615            language: language.clone(),
616            text: text.clone(),
617        };
618        let spans = self
619            .highlighter
620            .as_ref()
621            .map(|highlight| highlight(&block))
622            .unwrap_or_default();
623        let lines = text.lines().count().max(1);
624
625        let copied = self.report(MarkdownEvent::CodeCopied {
626            language: language.clone(),
627            text: text.clone(),
628        });
629        let copy_ident = ident.child("copy");
630        let clipboard = text.clone();
631
632        let body = div()
633            .column()
634            .w_full()
635            .font_family(theme.typography.mono.clone())
636            .text_size(px(theme.typography.code.size))
637            .line_height(px(theme.typography.code.line_height))
638            .text_color(theme.colors.text)
639            .children(code_lines(&theme, text.as_ref(), &spans));
640
641        div()
642            .column()
643            .w_full()
644            .gap_token(&theme, Space::Xs)
645            .p_token(&theme, Space::Sm)
646            .radius(&theme, Radius::Card)
647            .frame(&theme, Surface::Raised, Elevation::Raised)
648            .child(
649                div()
650                    .row()
651                    .w_full()
652                    .justify_between()
653                    .type_scale(&theme, TypeScale::Caption)
654                    .text_color(theme.colors.text_faint)
655                    .child(label.clone())
656                    .child(
657                        Button::new(copy_ident)
658                            .label(cx.strings().text(StringKey::Copy))
659                            .ghost()
660                            .control_size(gpui_kit_theme::ControlSize::Xs)
661                            .on_click(move |window, cx| {
662                                cx.write_to_clipboard(ClipboardItem::new_string(
663                                    clipboard.to_string(),
664                                ));
665                                if let Some(copied) = &copied {
666                                    copied(window, cx);
667                                }
668                            }),
669                    ),
670            )
671            .child(body)
672            .semantic_in(
673                cx,
674                NodeSpec::new(ident.semantic_id(), Role::Text)
675                    .parent(self.ident.semantic_id())
676                    .text(label)
677                    .value(format!("{lines} lines")),
678            )
679            .into_any_element()
680    }
681
682    fn quote(&mut self, blocks: &[Block], window: &mut Window, cx: &mut App) -> AnyElement {
683        let theme = self.theme.clone();
684        let mut column = div()
685            .column()
686            .flex_1()
687            .min_w_0()
688            .gap_token(&theme, Space::Sm);
689        for block in blocks {
690            column = column.child(self.block(block, window, cx));
691        }
692        div()
693            .row()
694            .items_stretch()
695            .w_full()
696            .gap_token(&theme, Space::Sm)
697            .child(
698                div()
699                    .w(px(theme.borders.thick))
700                    .flex_none()
701                    .bg(theme.colors.hairline_strong),
702            )
703            .child(
704                div()
705                    .flex_1()
706                    .min_w_0()
707                    .text_color(theme.colors.text_muted)
708                    .child(column),
709            )
710            .into_any_element()
711    }
712
713    fn list(
714        &mut self,
715        ordered: bool,
716        start: u64,
717        entries: &[ListEntry],
718        window: &mut Window,
719        cx: &mut App,
720    ) -> AnyElement {
721        let theme = self.theme.clone();
722        let mut column = div().column().w_full().gap_token(&theme, Space::Xs);
723        for (offset, entry) in entries.iter().enumerate() {
724            column = column.child(self.entry(ordered, start, offset, entry, window, cx));
725        }
726        column.into_any_element()
727    }
728
729    fn entry(
730        &mut self,
731        ordered: bool,
732        start: u64,
733        offset: usize,
734        entry: &ListEntry,
735        window: &mut Window,
736        cx: &mut App,
737    ) -> AnyElement {
738        let theme = self.theme.clone();
739        let marker = match (entry.task, ordered) {
740            (Some(true), _) => SharedString::new_static("☑"),
741            (Some(false), _) => SharedString::new_static("☐"),
742            (None, true) => SharedString::from(format!("{}.", start + offset as u64)),
743            (None, false) => SharedString::new_static("•"),
744        };
745
746        let mut body = div()
747            .column()
748            .flex_1()
749            .min_w_0()
750            .gap_token(&theme, Space::Xs);
751        for block in &entry.blocks {
752            body = body.child(self.block(block, window, cx));
753        }
754
755        let row = div()
756            .row()
757            .items_start()
758            .w_full()
759            .gap_token(&theme, Space::Sm)
760            .child(
761                div()
762                    .flex_none()
763                    .min_w(px(18.0))
764                    .type_scale(&theme, TypeScale::Body)
765                    .text_color(theme.colors.text_faint)
766                    .child(marker),
767            )
768            .child(body);
769
770        // A task in a rendered document is a fact about the document, not a
771        // control: this component ticks nothing, so the box publishes its
772        // state and says it cannot be operated.
773        match entry.task {
774            Some(checked) => {
775                let stated = entry.blocks.iter().find_map(|block| match block {
776                    Block::Paragraph(inlines) => Some(flatten(inlines)),
777                    _ => None,
778                });
779                // The id is seeded from the task's own words, or from an
780                // English constant when it has none: a semantic id that moved
781                // when the host installed a translation would not be stable.
782                let ident = self.ident_for("task", stated.as_deref().unwrap_or(TASK_ID));
783                let text = stated.unwrap_or_else(|| cx.strings().text(StringKey::MarkdownTask));
784                row.semantic_in(
785                    cx,
786                    NodeSpec::new(ident.semantic_id(), Role::Checkbox)
787                        .parent(self.ident.semantic_id())
788                        .text(text)
789                        .checked(checked)
790                        .disabled(true),
791                )
792                .into_any_element()
793            }
794            None => row.into_any_element(),
795        }
796    }
797
798    fn rule(&mut self, cx: &mut App) -> AnyElement {
799        let theme = self.theme.clone();
800        let ident = self.ident_for("rule", "break");
801        div()
802            .w_full()
803            .h(px(theme.borders.hairline))
804            .bg(theme.colors.hairline)
805            .semantic_in(
806                cx,
807                NodeSpec::new(ident.semantic_id(), Role::Separator)
808                    .parent(self.ident.semantic_id()),
809            )
810            .into_any_element()
811    }
812
813    fn table(
814        &mut self,
815        alignment: &[CellAlign],
816        head: &[Vec<Inline>],
817        rows: &[Vec<Vec<Inline>>],
818        window: &mut Window,
819        cx: &mut App,
820    ) -> AnyElement {
821        let theme = self.theme.clone();
822        let name = flatten(head.first().map(Vec::as_slice).unwrap_or_default());
823        let ident = self.ident_for("table", name.as_ref());
824
825        let mut frame = div()
826            .column()
827            .w_full()
828            .radius(&theme, Radius::Card)
829            .frame(&theme, Surface::Panel, Elevation::Raised)
830            .overflow_hidden();
831
832        if !head.is_empty() {
833            let mut header = div()
834                .row()
835                .items_stretch()
836                .w_full()
837                .bg(theme.colors.raised)
838                .type_scale(&theme, TypeScale::Caption)
839                .text_color(theme.colors.text_muted);
840            for (column, cell) in head.iter().enumerate() {
841                let content = self.inline_row(cell, RunStyle::default(), window, cx);
842                header =
843                    header.child(cell_frame(&theme, aligned(alignment, column)).child(content));
844            }
845            frame = frame.child(header);
846        }
847
848        for row in rows {
849            let mut line = div()
850                .row()
851                .items_stretch()
852                .w_full()
853                .type_scale(&theme, TypeScale::Label)
854                .text_color(theme.colors.text);
855            for (column, cell) in row.iter().enumerate() {
856                let content = self.inline_row(cell, RunStyle::default(), window, cx);
857                line = line.child(cell_frame(&theme, aligned(alignment, column)).child(content));
858            }
859            frame = frame.child(line);
860        }
861
862        frame
863            .semantic_in(
864                cx,
865                NodeSpec::new(ident.semantic_id(), Role::Table)
866                    .parent(self.ident.semantic_id())
867                    .text(name)
868                    .value(format!("{} rows", rows.len())),
869            )
870            .into_any_element()
871    }
872
873    fn html_block(&mut self, html: SharedString, cx: &mut App) -> AnyElement {
874        let theme = self.theme.clone();
875        let ident = self.ident_for("html", "block");
876        div()
877            .column()
878            .w_full()
879            .gap(px(2.0))
880            .px_token(&theme, Space::Sm)
881            .py_token(&theme, Space::Xs)
882            .radius(&theme, Radius::Small)
883            .border(px(theme.borders.hairline))
884            .border_color(theme.colors.warning.opacity(0.4))
885            .bg(theme.colors.warning.opacity(0.06))
886            .child(
887                div()
888                    .type_scale(&theme, TypeScale::Caption)
889                    .text_color(theme.colors.warning)
890                    .child(cx.strings().text(StringKey::MarkdownUnrenderedHtml)),
891            )
892            .child(
893                div()
894                    .font_family(theme.typography.mono.clone())
895                    .text_size(px(theme.typography.code.size))
896                    .line_height(px(theme.typography.code.line_height))
897                    .text_color(theme.colors.text_muted)
898                    .children(
899                        html.lines()
900                            .map(|line| div().child(SharedString::from(line.to_string()))),
901                    ),
902            )
903            .semantic_in(
904                cx,
905                NodeSpec::new(ident.semantic_id(), Role::Text)
906                    .parent(self.ident.semantic_id())
907                    .text(html.clone())
908                    .value(UNRENDERED),
909            )
910            .into_any_element()
911    }
912
913    fn html_inline(&mut self, html: SharedString, cx: &mut App) -> AnyElement {
914        let theme = self.theme.clone();
915        let ident = self.ident_for("html", "inline");
916        div()
917            .px(px(2.0))
918            .radius(&theme, Radius::Small)
919            .bg(theme.colors.warning.opacity(0.1))
920            .font_family(theme.typography.mono.clone())
921            .text_size(px(theme.typography.code.size))
922            .text_color(theme.colors.warning)
923            .child(html.clone())
924            .semantic_in(
925                cx,
926                NodeSpec::new(ident.semantic_id(), Role::Text)
927                    .parent(self.ident.semantic_id())
928                    .text(html)
929                    .value(UNRENDERED),
930            )
931            .into_any_element()
932    }
933
934    /// What a truncated document left out, and a way to ask for it.
935    ///
936    /// A count and a named action, rather than a fade: a gradient over the
937    /// last line says something was cut without saying how much, which is a
938    /// decoration standing where a fact belongs.
939    fn more(&mut self, hidden: usize, cx: &mut App) -> AnyElement {
940        let theme = self.theme.clone();
941        let ident = self.ident.child("truncated");
942        let label = if hidden == 1 {
943            cx.strings().text(StringKey::MarkdownShowMoreOne)
944        } else {
945            cx.strings()
946                .format(StringKey::MarkdownShowMoreMany, &[&hidden.to_string()])
947        };
948        let asked = self.report(MarkdownEvent::MoreRequested { lines: hidden });
949
950        div()
951            .row()
952            .w_full()
953            .gap_token(&theme, Space::Sm)
954            .child(
955                Button::new(ident.child("more"))
956                    .label(label.clone())
957                    .link()
958                    .on_click(move |window, cx| {
959                        if let Some(asked) = &asked {
960                            asked(window, cx);
961                        }
962                    }),
963            )
964            .semantic_in(
965                cx,
966                NodeSpec::new(ident.semantic_id(), Role::Status)
967                    .parent(self.ident.semantic_id())
968                    .text(label)
969                    .value(hidden.to_string()),
970            )
971            .into_any_element()
972    }
973}
974
975/// The published value for a fragment of HTML that was neither run nor
976/// dropped. It is a state name a test asserts, not a word a reader reads;
977/// [`StringKey::MarkdownUnrenderedHtml`](crate::strings::StringKey) is the
978/// reader's copy.
979const UNRENDERED: SharedString = SharedString::new_static("unrendered html");
980
981/// Id seeds for the two blocks whose id would otherwise come from a
982/// translated word.
983const PLAIN_TEXT_ID: &str = "plain text";
984const TASK_ID: &str = "task";
985
986fn run(theme: &Theme, text: SharedString, style: RunStyle) -> AnyElement {
987    div()
988        .when(style.strong, |element| {
989            element.font_weight(FontWeight::BOLD)
990        })
991        .when(style.emphasis, gpui::Styled::italic)
992        .when(style.struck, |element| {
993            element.line_through().text_color(theme.colors.text_muted)
994        })
995        .child(text)
996        .into_any_element()
997}
998
999fn cell_frame(theme: &Theme, align: CellAlign) -> gpui::Div {
1000    div()
1001        .flex_1()
1002        .min_w_0()
1003        .px_token(theme, Space::Sm)
1004        .py_token(theme, Space::Xs)
1005        .when(align == CellAlign::Center, |element| element.items_center())
1006        .when(align == CellAlign::End, |element| element.items_end())
1007}
1008
1009fn aligned(alignment: &[CellAlign], column: usize) -> CellAlign {
1010    alignment.get(column).copied().unwrap_or_default()
1011}
1012
1013/// One element per line of a code block, coloured only where the host said so.
1014fn code_lines(theme: &Theme, text: &str, spans: &[CodeSpan]) -> Vec<AnyElement> {
1015    let mut elements = Vec::new();
1016    let mut offset = 0;
1017    for line in text.lines() {
1018        let range = offset..offset + line.len();
1019        offset = range.end + 1;
1020        let mut row = div().flex().flex_row().flex_wrap().w_full();
1021        let mut cut = range.start;
1022        for span in spans
1023            .iter()
1024            .filter(|span| span.range.start < range.end && span.range.end > range.start)
1025        {
1026            let start = span.range.start.max(range.start);
1027            let end = span.range.end.min(range.end);
1028            if start > cut
1029                && let Some(before) = text.get(cut..start)
1030            {
1031                row = row.child(div().child(SharedString::from(before.to_string())));
1032            }
1033            if let Some(inside) = text.get(start..end) {
1034                row = row.child(
1035                    div()
1036                        .text_color(span.tone.color(theme))
1037                        .child(SharedString::from(inside.to_string())),
1038                );
1039            }
1040            cut = end;
1041        }
1042        if let Some(tail) = text.get(cut..range.end) {
1043            row = row.child(div().child(SharedString::from(tail.to_string())));
1044        }
1045        // A blank line still occupies one, so a fence keeps its shape.
1046        elements.push(
1047            row.min_h(px(theme.typography.code.line_height))
1048                .into_any_element(),
1049        );
1050    }
1051    if elements.is_empty() {
1052        elements.push(
1053            div()
1054                .w_full()
1055                .min_h(px(theme.typography.code.line_height))
1056                .into_any_element(),
1057        );
1058    }
1059    elements
1060}
1061
1062/// The text a run of inlines reads as, for a name and for an id stem.
1063fn flatten(inlines: &[Inline]) -> SharedString {
1064    fn walk(inlines: &[Inline], into: &mut String) {
1065        for inline in inlines {
1066            match inline {
1067                Inline::Text(text) | Inline::Code(text) | Inline::Html(text) => into.push_str(text),
1068                Inline::Emphasis(inner) | Inline::Strong(inner) | Inline::Struck(inner) => {
1069                    walk(inner, into)
1070                }
1071                Inline::Link { content, .. } => walk(content, into),
1072                Inline::Image { alt, .. } => into.push_str(alt),
1073                Inline::SoftBreak | Inline::HardBreak => into.push(' '),
1074            }
1075        }
1076    }
1077    let mut text = String::new();
1078    walk(inlines, &mut text);
1079    SharedString::from(text.trim().to_string())
1080}
1081
1082/// An id-safe stem for a name, bounded so one long heading cannot mint an
1083/// unreadable identity.
1084fn slug(name: &str) -> String {
1085    let mut slug = String::new();
1086    for character in name.chars().take(64) {
1087        if character.is_ascii_alphanumeric() {
1088            slug.extend(character.to_lowercase());
1089        } else if !slug.ends_with('-') {
1090            slug.push('-');
1091        }
1092    }
1093    let slug = slug.trim_matches('-').to_string();
1094    if slug.is_empty() {
1095        "item".to_string()
1096    } else {
1097        slug
1098    }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    #[test]
1106    fn a_slug_never_carries_punctuation_or_an_empty_stem() {
1107        assert_eq!(
1108            slug("https://example.test/a?b=1"),
1109            "https-example-test-a-b-1"
1110        );
1111        assert_eq!(slug("   "), "item");
1112        assert_eq!(slug("Getting started!"), "getting-started");
1113    }
1114
1115    #[test]
1116    fn flattening_reads_a_line_the_way_it_is_spoken() {
1117        let document = Document::parse("**bold** and `code` and [a link](x)");
1118        let Some(Block::Paragraph(inlines)) = document.blocks.first() else {
1119            panic!("expected a paragraph");
1120        };
1121        assert_eq!(flatten(inlines).as_ref(), "bold and code and a link");
1122    }
1123}