Files
Ely-GPUI-Components/src/documents/render.rs
T
ZacharyZhang-NYandClaude Opus 5.5 2b6f5513aa
CI / site (push) Successful in 34s
CI / platforms (windows-latest) (push) Successful in 5m7s
CI / crate (push) Failing after 26m45s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
MarkdownDocument: a Markdown file in one CodeEditor, shown visual, source, split or preview
Visual draws the top-level blocks in a gpui list; a pressed block opens as
its source and its typing is the text at once, written back as one undo
step. CodeEditor takes the owner's line colors, resets with a fresh
history and keeps at most 64 MiB of undo text. MarkdownMode gains Preview.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 00:44:17 -04:00

447 lines
16 KiB
Rust

use std::{path::Path, rc::Rc, sync::Arc};
use gpui::{
AnyElement, App, ElementId, Entity, FontStyle, FontWeight, HighlightStyle, InteractiveText,
IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText,
UnderlineStyle, Window, div, prelude::*,
};
use markdown::mdast::AlignKind;
use super::{
contents::anchor,
diagram::Diagram,
picture::Picture,
tree::{Inline, Node, Style, parse},
};
use crate::{
editor::code_colors,
navigation::{Anchor, Sections},
primitives::{FocusRing as _, Icon, IconName},
theme::{ActiveTheme, IconSize, Radius, TextSize},
typography::{Latex, failed},
};
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
type DrawCode = Rc<dyn Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement>;
/// Markdown as a document: headings, prose with its styles and links, lists and tasks, quotes, code colored as code, tables, rules, math and footnotes. A press on a link opens it, or hands it to the host.
#[derive(IntoElement)]
pub struct MarkdownRenderer {
id: ElementId,
source: SharedString,
on_link: Option<OnLink>,
size: TextSize,
sections: Option<Entity<Sections>>,
code: Option<DrawCode>,
base: Option<Arc<Path>>,
}
impl MarkdownRenderer {
pub fn new(id: impl Into<ElementId>, source: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
source: source.into(),
on_link: None,
size: TextSize::Base,
sections: None,
code: None,
base: None,
}
}
/// The folder relative picture addresses start from.
pub fn base(mut self, folder: impl Into<Arc<Path>>) -> Self {
self.base = Some(folder.into());
self
}
/// The body's size; headings keep theirs.
pub fn text_size(mut self, size: TextSize) -> Self {
self.size = size;
self
}
/// Marks each heading for a table of contents over `sections`; `outline` gives its entries.
pub fn sections(mut self, sections: &Entity<Sections>) -> Self {
self.sections = Some(sections.clone());
self
}
/// Draws code blocks with the owner's element, given an id, the language and the code.
pub fn code(
mut self,
draw: impl Fn(ElementId, Option<SharedString>, SharedString) -> AnyElement + 'static,
) -> Self {
self.code = Some(Rc::new(draw));
self
}
/// Takes link presses instead of the browser.
pub fn on_link(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_link = Some(Rc::new(handler));
self
}
}
/// Renders nodes with one set of links handlers and ids under one owner.
struct Draw<'a> {
id: &'a ElementId,
on_link: &'a Option<OnLink>,
next: std::cell::Cell<usize>,
sections: &'a Option<Entity<Sections>>,
headings: std::cell::Cell<usize>,
code: &'a Option<DrawCode>,
base: &'a Option<Arc<Path>>,
}
impl Draw<'_> {
fn key(&self, what: &str) -> ElementId {
let at = self.next.get();
self.next.set(at + 1);
(self.id.clone(), format!("{what}-{at}")).into()
}
fn inline(&self, inline: &Inline, cx: &App) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let rule = theme.underline_thickness();
let styles: Vec<_> = inline
.styles
.iter()
.map(|(range, style)| {
let highlight = match style {
Style::Strong => HighlightStyle {
font_weight: Some(FontWeight::SEMIBOLD),
..HighlightStyle::default()
},
Style::Emphasis => HighlightStyle {
font_style: Some(FontStyle::Italic),
..HighlightStyle::default()
},
Style::Strike => HighlightStyle {
strikethrough: Some(StrikethroughStyle {
thickness: rule,
color: Some(colors.fg_muted),
}),
..HighlightStyle::default()
},
Style::Code | Style::Math => HighlightStyle {
background_color: Some(colors.sunken),
color: Some(if *style == Style::Math {
colors.syntax.function
} else {
colors.fg
}),
..HighlightStyle::default()
},
Style::Link => HighlightStyle {
color: Some(colors.link),
underline: Some(UnderlineStyle {
thickness: rule,
color: Some(colors.link.opacity(0.4)),
wavy: false,
}),
..HighlightStyle::default()
},
Style::Footnote => HighlightStyle {
color: Some(colors.fg_subtle),
..HighlightStyle::default()
},
};
(range.clone(), highlight)
})
.collect();
let styled =
StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles));
if inline.links.is_empty() {
return styled.into_any_element();
}
let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip();
let on_link = self.on_link.clone();
InteractiveText::new(self.key("links"), styled)
.on_click(ranges, move |ix, window, cx| {
open_link(&on_link, &urls[ix], window, cx)
})
.into_any_element()
}
fn nodes(&self, nodes: &[Node], cx: &App) -> Vec<AnyElement> {
nodes.iter().map(|node| self.node(node, cx)).collect()
}
fn node(&self, node: &Node, cx: &App) -> AnyElement {
let theme = cx.theme();
let colors = theme.colors.clone();
match node {
Node::Paragraph(inline) => div()
.text_color(colors.fg)
.child(self.inline(inline, cx))
.into_any_element(),
Node::Heading(level, inline) => {
let size = match level {
1 => TextSize::Xxl,
2 => TextSize::Xl,
3 => TextSize::Lg,
_ => TextSize::Md,
};
let heading = div()
.pt_2()
.text_size(theme.text_size(size))
.font_weight(if *level <= 2 {
FontWeight::SEMIBOLD
} else {
FontWeight::MEDIUM
})
.text_color(colors.fg)
.child(self.inline(inline, cx));
let ordinal = self.headings.get();
self.headings.set(ordinal + 1);
match self.sections {
Some(sections) => Anchor::new(sections, anchor(ordinal))
.child(heading)
.into_any_element(),
None => heading.into_any_element(),
}
}
Node::Quote(inner) => div()
.flex()
.flex_col()
.gap_2()
.pl_4()
.border_l_2()
.border_color(colors.border_strong)
.text_color(colors.fg_muted)
.children(self.nodes(inner, cx))
.into_any_element(),
Node::Code { language, text } => {
if language.as_deref() == Some("mermaid") {
return Diagram::new(self.key("diagram"), text.clone()).into_any_element();
}
if let Some(draw) = self.code {
return draw(
self.key("code"),
language.clone().map(Into::into),
text.clone().into(),
);
}
let styles = code_colors(text, cx);
div()
.flex()
.flex_col()
.gap_1()
.p_3()
.rounded(theme.radius(Radius::Md))
.bg(colors.sunken)
.font_family(theme.mono_family.clone())
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.syntax.variable)
.children(language.clone().map(|name| {
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_subtle)
.child(name)
}))
.child(StyledText::new(text.clone()).with_highlights(styles))
.into_any_element()
}
Node::List { start, items } => div()
.flex()
.flex_col()
.gap_1()
.children(items.iter().enumerate().map(|(ix, (task, body))| {
let marker: AnyElement = match (task, start) {
(Some(done), _) => Icon::new(if *done {
IconName::SquareCheck
} else {
IconName::Square
})
.size(IconSize::Sm)
.color(if *done {
colors.focus
} else {
colors.fg_subtle
})
.into_any_element(),
(None, Some(first)) => div()
.text_color(colors.fg_muted)
.child(format!("{}.", first + ix as u64))
.into_any_element(),
(None, None) => div()
.text_color(colors.fg_muted)
.child("•")
.into_any_element(),
};
div()
.flex()
.gap_2()
.child(
div()
.flex_none()
.min_w_5()
.flex()
.justify_end()
.child(marker),
)
.child(
div()
.flex_1()
.min_w_0()
.flex()
.flex_col()
.gap_1()
.when(*task == Some(true), |body| {
body.text_color(colors.fg_subtle)
})
.children(self.nodes(body, cx)),
)
}))
.into_any_element(),
Node::Table { aligns, head, rows } => {
let cell = |inline: &Inline, column: usize, header: bool, cx: &App| {
div()
.flex_1()
.flex()
.px_3()
.py_1p5()
.when(aligns.get(column) == Some(&AlignKind::Right), |cell| {
cell.justify_end()
})
.when(aligns.get(column) == Some(&AlignKind::Center), |cell| {
cell.justify_center()
})
.when(header, |cell| {
cell.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg_muted)
})
.child(self.inline(inline, cx))
};
div()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.overflow_hidden()
.child(
div().flex().bg(colors.hover).children(
head.iter()
.enumerate()
.map(|(column, inline)| cell(inline, column, true, cx)),
),
)
.children(rows.iter().map(|row| {
div()
.flex()
.border_t_1()
.border_color(colors.border)
.children(
row.iter()
.enumerate()
.map(|(column, inline)| cell(inline, column, false, cx)),
)
}))
.into_any_element()
}
Node::Rule => div()
.my_2()
.h_0()
.border_t_1()
.border_color(colors.border)
.into_any_element(),
Node::Math(tex) => div()
.flex()
.justify_center()
.py_2()
.child(match Latex::parse(self.key("math"), tex) {
Ok(math) => math.display().into_any_element(),
Err(error) => failed(&error, tex, cx),
})
.into_any_element(),
Node::Image { alt, url, link } => {
let picture = div().id(self.key("image")).child(Picture {
id: self.key("picture"),
alt: alt.clone(),
url: url.clone(),
base: self.base.clone(),
});
let Some(link) = link.clone() else {
return picture.into_any_element();
};
let on_link = self.on_link.clone();
picture
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_click(move |_, window, cx| open_link(&on_link, &link, window, cx))
.into_any_element()
}
Node::Footnote { label, body } => div()
.flex()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(div().flex_none().child(format!("[{label}]")))
.child(
div()
.flex_1()
.flex()
.flex_col()
.children(self.nodes(body, cx)),
)
.into_any_element(),
}
}
}
/// Opens `url` through the owner, else the system.
fn open_link(on_link: &Option<OnLink>, url: &str, window: &mut Window, cx: &mut App) {
log::info!("markdown: open {url}");
match on_link {
Some(on_link) => on_link(url, window, cx),
None => cx.open_url(url),
}
}
/// Parsed `nodes` drawn under `id`; pictures read from `base`.
pub(crate) fn draw_nodes(
id: &ElementId,
nodes: &[Node],
base: &Option<Arc<Path>>,
on_link: &Option<OnLink>,
cx: &App,
) -> Vec<AnyElement> {
let draw = Draw {
id,
on_link,
next: std::cell::Cell::new(0),
sections: &None,
headings: std::cell::Cell::new(0),
code: &None,
base,
};
draw.nodes(nodes, cx)
}
impl RenderOnce for MarkdownRenderer {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let nodes = parse(&self.source);
let draw = Draw {
id: &self.id,
on_link: &self.on_link,
next: std::cell::Cell::new(0),
sections: &self.sections,
headings: std::cell::Cell::new(0),
code: &self.code,
base: &self.base,
};
div()
.flex()
.flex_col()
.gap_3()
.text_size(theme.text_size(self.size))
.line_height(theme.text_size(self.size) * 1.6)
.children(draw.nodes(&nodes, cx))
}
}