diff --git a/Cargo.lock b/Cargo.lock index 483cb6d..cf5f467 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1912,8 +1912,10 @@ version = "0.1.0" dependencies = [ "gpui", "gpui-native-core", + "gpui-native-icons", "gpui-native-theme", "gpui-native-utils", + "pulldown-cmark", "similar", "tree-sitter", "tree-sitter-javascript", @@ -4235,6 +4237,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" +dependencies = [ + "bitflags 2.13.2", + "memchr", + "unicase", +] + [[package]] name = "pulp" version = "0.22.3" @@ -5978,6 +5991,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index a7326fc..0f00bc7 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -7,10 +7,12 @@ description = "Read-only code surfaces for gpui applications, one cargo feature [dependencies] gpui = { workspace = true } gpui-native-core = { workspace = true, optional = true } +gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } gpui-native-utils = { workspace = true } tree-sitter = "0.25" similar = "2" +pulldown-cmark = { version = "0.13", default-features = false, optional = true } tree-sitter-rust = { version = "0.24", optional = true } tree-sitter-typescript = { version = "0.23", optional = true } tree-sitter-javascript = { version = "0.23", optional = true } @@ -24,6 +26,7 @@ default = ["full"] full = [ "code_view", "diff_view", + "markdown_view", "rust", "typescript", "javascript", @@ -35,6 +38,7 @@ code_view = [ "gpui-native-core/scrollbar", ] diff_view = ["code_view"] +markdown_view = ["code_view", "dep:pulldown-cmark"] rust = ["dep:tree-sitter-rust"] typescript = ["dep:tree-sitter-typescript"] javascript = ["dep:tree-sitter-javascript"] diff --git a/crates/editor/src/lib.rs b/crates/editor/src/lib.rs index 947718d..4b63f15 100644 --- a/crates/editor/src/lib.rs +++ b/crates/editor/src/lib.rs @@ -8,6 +8,9 @@ pub mod code_view; #[cfg(feature = "diff_view")] pub mod diff_view; +#[cfg(feature = "markdown_view")] +pub mod markdown; + pub mod highlight; mod utils; diff --git a/crates/editor/src/markdown.rs b/crates/editor/src/markdown.rs new file mode 100644 index 0000000..64bef30 --- /dev/null +++ b/crates/editor/src/markdown.rs @@ -0,0 +1,1128 @@ +//! The markdown view: a read-only rendering of a markdown document as +//! formatted text. Parsing runs once per source change and produces blocks; +//! rendering virtualizes the visible blocks and resolves every style from +//! the theme at draw time. Images, footnotes, math, and HTML render nothing; +//! long code lines clip rather than scroll. + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Context, Div, ElementId, FontStyle, FontWeight, HighlightStyle, + InteractiveElement as _, InteractiveText, IntoElement, ListAlignment, ListState, + ParentElement as _, Pixels, Render, SharedString, StatefulInteractiveElement as _, + StrikethroughStyle, Styled as _, StyledText, UnderlineStyle, Window, div, list, px, relative, +}; +use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::ActiveTheme as _; +use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; + +use std::ops::Range; + +use crate::highlight::{HighlightedCode, highlight_with_tree, plain}; +use crate::utils::{code_line, grammar_language}; + +/// Heading scale steps relative to the root font size, H1 first. +const HEADING_SCALES: [f32; 6] = [1.9, 1.5, 1.25, 1.1, 1.0, 0.95]; +/// The bottom padding between sibling blocks, in root font sizes. +const BLOCK_GAP: f32 = 0.75; +/// The tighter bottom padding between list items, in root font sizes. +const ITEM_GAP: f32 = 0.2; +/// The room a list marker column takes, in root font sizes. +const MARKER_WIDTH: f32 = 1.75; +/// The text inset behind a quote bar, in root font sizes. +const QUOTE_INSET: f32 = 1.0; +/// The indent each list level adds, in root font sizes. +const INDENT_STEP: f32 = 1.5; +/// The body line height. +const BODY_LINE_HEIGHT: f32 = 1.6; +/// The code line height inside fenced blocks. +const CODE_LINE_HEIGHT: f32 = 1.5; + +/// A heading level, 1 to 6. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MarkdownHeading { + H1, + H2, + H3, + H4, + H5, + H6, +} + +impl MarkdownHeading { + /// The level as a number, 1 to 6. + pub fn level(self) -> usize { + match self { + MarkdownHeading::H1 => 1, + MarkdownHeading::H2 => 2, + MarkdownHeading::H3 => 3, + MarkdownHeading::H4 => 4, + MarkdownHeading::H5 => 5, + MarkdownHeading::H6 => 6, + } + } +} + +/// The marker drawn before a list item. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ListMarker { + /// An unordered bullet. + Bullet, + /// An ordered item, carrying its number in the list. + Ordered(u64), + /// A task item, `true` when checked. + Task(bool), +} + +/// One run of inline text sharing a style. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineSpan { + pub text: SharedString, + pub style: InlineStyle, +} + +/// The inline style a text run carries. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InlineStyle { + Text, + Emphasis, + Strong, + StrongEmphasis, + Strikethrough, + /// Inline code. + Code, + /// A link with its destination. + Link { + destination: SharedString, + }, +} + +/// One cell of a table row. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MarkdownTableCell { + pub spans: Vec, + /// 1-based column position, so renderers can align to the table's columns. + pub column: usize, +} + +/// A table row; the header row precedes the body rows. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MarkdownTableRow { + pub cells: Vec, + pub header: bool, +} + +/// A table, header row first. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MarkdownTable { + pub rows: Vec, +} + +/// One rendered block of a markdown document. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MarkdownBlock { + Heading { + level: MarkdownHeading, + spans: Vec, + /// How many lists the heading's content sits under. + indent: usize, + /// Whether a blockquote wraps the heading. + quote: bool, + }, + Paragraph { + spans: Vec, + /// How many lists the paragraph's content sits under. + indent: usize, + /// Whether a blockquote wraps the paragraph. + quote: bool, + }, + ListItem { + marker: ListMarker, + spans: Vec, + /// How many lists wrap this item's list, zero for the outermost. + indent: usize, + quote: bool, + }, + Code { + language: Option, + lines: HighlightedCode, + /// How many lists the block sits under. + indent: usize, + /// Whether a blockquote wraps the block. + quote: bool, + }, + ThematicBreak { + indent: usize, + quote: bool, + }, + Table { + table: MarkdownTable, + indent: usize, + quote: bool, + }, +} + +/// Retained state for a markdown view. +pub struct MarkdownViewState { + id: ElementId, + source: SharedString, + blocks: Vec, + /// Whether fenced code blocks highlight with the code view's palette. + highlight_code: bool, + /// Drives the vertical scroll of the virtualized block list. + handle: ListState, + a11y_label: Option, +} + +impl MarkdownViewState { + /// Builds the state, parsing `source`. + pub fn new(id: I, source: S) -> Self + where + I: Into, + S: Into, + { + let source = source.into(); + let blocks = parse(&source, true); + let count = blocks.len(); + + Self { + id: id.into(), + source, + blocks, + highlight_code: true, + handle: ListState::new(count, ListAlignment::Top, px(400.)).measure_all(), + a11y_label: None, + } + } + + /// Renders fenced code blocks without the code view's highlighting. + pub fn plain_code(mut self) -> Self { + self.highlight_code = false; + self.reparse(); + self + } + + /// Replaces the source and re-parses it. + pub fn set_source(&mut self, source: S, cx: &mut Context) + where + S: Into, + { + self.source = source.into(); + self.reparse(); + cx.notify(); + } + + fn reparse(&mut self) { + self.blocks = parse(&self.source, self.highlight_code); + self.handle.reset(self.blocks.len()); + self.handle.scroll_to_reveal_item(0); + } + + /// The markdown source the view renders. + pub fn source(&self) -> &str { + &self.source + } + + /// The parsed blocks. + pub fn blocks(&self) -> &[MarkdownBlock] { + &self.blocks + } + + /// Whether fenced code highlights with the code view's palette. + pub fn highlight_code(&self) -> bool { + self.highlight_code + } + + /// Scrolls `block` into view. + pub fn scroll_to_block(&self, block: usize) { + self.handle + .scroll_to_reveal_item(block.min(self.blocks.len().saturating_sub(1))); + } + + /// Overrides the accessibility label of the view. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl Render for MarkdownViewState { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let id = self.id.clone(); + let state = cx.entity(); + + let blocks = list(self.handle.clone(), move |block_ix, _window, cx| { + let view = state.read(cx); + match view.blocks.get(block_ix) { + Some(block) => render_block(block, block_ix, &view.id, cx), + None => div().into_any_element(), + } + }) + .h_full() + .w_full(); + + div() + .id((id.clone(), "view")) + .relative() + .size_full() + .child(blocks) + .child( + Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), + ) + .when_some(self.a11y_label.clone(), |view, label| { + view.aria_label(label) + }) + } +} + +/// One open list, tracking ordered numbering and an in-flight task marker. +struct ListFrame { + ordered: bool, + next_number: u64, + task: Option, +} + +/// The table being assembled, when the parser is inside one. +enum TableBuilder { + None, + Body { rows: Vec }, +} + +/// One open code block, accumulating its raw text. +#[derive(Default)] +struct CodeBuilder { + text: String, +} + +impl CodeBuilder { + fn push(&mut self, text: &str) { + self.text.push_str(text); + } +} + +/// Accumulates inline spans across inline events. +#[derive(Default)] +struct InlineBuilder { + runs: Vec<(String, InlineStyle)>, + styles: Vec>, +} + +impl InlineBuilder { + fn push_style(&mut self, style: Option) { + self.styles.push(style); + } + + fn pop_style(&mut self) { + self.styles.pop(); + } + + fn active(&self) -> InlineStyle { + let mut strong = false; + let mut emphasis = false; + + for style in self.styles.iter().rev().flatten() { + match style { + InlineStyle::Strong if emphasis => return InlineStyle::StrongEmphasis, + InlineStyle::Strong => strong = true, + InlineStyle::Emphasis if strong => return InlineStyle::StrongEmphasis, + InlineStyle::Emphasis => emphasis = true, + other => return other.clone(), + } + } + + match (strong, emphasis) { + (true, true) => InlineStyle::StrongEmphasis, + (true, false) => InlineStyle::Strong, + (false, true) => InlineStyle::Emphasis, + (false, false) => InlineStyle::Text, + } + } + + fn push_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + let style = self.active(); + if let Some((run, run_style)) = self.runs.last_mut() + && *run_style == style + { + run.push_str(text); + return; + } + self.runs.push((text.to_string(), style)); + } + + /// Emits one span per contiguous style run. + fn finish(&mut self) -> Vec { + self.runs + .drain(..) + .map(|(text, style)| InlineSpan { + text: SharedString::from(text), + style, + }) + .collect() + } +} + +/// The markdown extensions the view parses, on top of CommonMark. +fn options() -> Options { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options +} + +fn heading(level: HeadingLevel) -> MarkdownHeading { + match level { + HeadingLevel::H1 => MarkdownHeading::H1, + HeadingLevel::H2 => MarkdownHeading::H2, + HeadingLevel::H3 => MarkdownHeading::H3, + HeadingLevel::H4 => MarkdownHeading::H4, + HeadingLevel::H5 => MarkdownHeading::H5, + HeadingLevel::H6 => MarkdownHeading::H6, + } +} + +/// Parses `source` into blocks. +fn parse(source: &str, highlight_code: bool) -> Vec { + let mut blocks = Vec::new(); + let mut inline = InlineBuilder::default(); + let mut lists: Vec = Vec::new(); + // Whether each open list item already flushed content rows. + let mut items: Vec = Vec::new(); + let mut quote_depth = 0usize; + let mut table = TableBuilder::None; + let mut code: Option = None; + // The fence info string arrives on Start, its text follows. + let mut code_language: Option = None; + + for event in Parser::new_ext(source, options()) { + match event { + Event::Start(tag) => match tag { + Tag::Paragraph | Tag::TableCell | Tag::Item => { + inline = InlineBuilder::default(); + if matches!(tag, Tag::Item) { + // The marker is only in flight while its own item is open. + if let Some(frame) = lists.last_mut() { + frame.task = None; + } + items.push(false); + } + } + Tag::Heading { level, .. } => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + inline = InlineBuilder::default(); + blocks.push(MarkdownBlock::Heading { + level: heading(level), + spans: Vec::new(), + indent: lists.len(), + quote: quote_depth > 0, + }); + } + Tag::BlockQuote(_) => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + quote_depth += 1; + } + Tag::List(start) => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + lists.push(ListFrame { + ordered: start.is_some(), + next_number: start.unwrap_or_default(), + task: None, + }); + } + Tag::CodeBlock(kind) => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + code = Some(CodeBuilder::default()); + if let CodeBlockKind::Fenced(info) = kind { + let info = info.trim(); + code_language = + (!info.is_empty()).then(|| SharedString::from(info.to_string())); + } + } + Tag::Table(_) => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + table = TableBuilder::Body { rows: Vec::new() } + } + Tag::TableHead => { + if let TableBuilder::Body { rows } = &mut table { + rows.push(MarkdownTableRow { + cells: Vec::new(), + header: true, + }); + } + } + Tag::TableRow => { + if let TableBuilder::Body { rows } = &mut table { + rows.push(MarkdownTableRow { + cells: Vec::new(), + header: false, + }); + } + } + Tag::Emphasis | Tag::Strong | Tag::Strikethrough => { + let style = match tag { + Tag::Emphasis => InlineStyle::Emphasis, + Tag::Strong => InlineStyle::Strong, + _ => InlineStyle::Strikethrough, + }; + inline.push_style(Some(style)); + } + Tag::Link { dest_url, .. } => inline.push_style(Some(InlineStyle::Link { + destination: SharedString::from(dest_url.to_string()), + })), + // Images render as their alt text. + Tag::Image { .. } => inline.push_style(None), + Tag::HtmlBlock | Tag::MetadataBlock(_) => {} + _ => {} + }, + Event::End(tag_end) => match tag_end { + TagEnd::Paragraph => { + let spans = inline.finish(); + let quote = quote_depth > 0; + match items.last_mut() { + // The item's first paragraph carries its marker row. + Some(flushed @ false) => { + *flushed = true; + blocks.push(MarkdownBlock::ListItem { + marker: take_marker(&mut lists), + spans, + indent: lists.len().saturating_sub(1), + quote, + }); + } + _ => { + if let Some(item) = items.last_mut() { + *item = true; + } + blocks.push(MarkdownBlock::Paragraph { + spans, + indent: lists.len(), + quote, + }); + } + } + } + TagEnd::Heading(_) => { + let spans = inline.finish(); + if let Some(MarkdownBlock::Heading { spans: slots, .. }) = blocks.last_mut() { + *slots = spans; + } + } + TagEnd::BlockQuote(_) => quote_depth = quote_depth.saturating_sub(1), + TagEnd::List(_) => { + lists.pop(); + } + TagEnd::Item => { + let flushed = items.pop().unwrap_or(false); + let spans = inline.finish(); + + if spans.is_empty() && flushed { + continue; + } + + let indent = lists.len(); + let quote = quote_depth > 0; + + if flushed { + blocks.push(MarkdownBlock::Paragraph { + spans, + indent, + quote, + }); + } else { + blocks.push(MarkdownBlock::ListItem { + marker: take_marker(&mut lists), + spans, + indent: indent.saturating_sub(1), + quote, + }); + } + } + TagEnd::CodeBlock => { + let built = code.take().unwrap_or_default(); + let text = built.text.trim_end_matches('\n'); + let language = code_language.take(); + let lines = match highlight_code + .then(|| language.as_deref().and_then(grammar_language)) + .flatten() + { + Some(grammar) => highlight_with_tree(text, grammar, None).0, + None => plain(text), + }; + blocks.push(MarkdownBlock::Code { + language, + lines, + indent: lists.len(), + quote: quote_depth > 0, + }); + } + TagEnd::Table => { + let built = std::mem::replace(&mut table, TableBuilder::None); + if let TableBuilder::Body { rows } = built { + blocks.push(MarkdownBlock::Table { + table: MarkdownTable { rows }, + indent: lists.len(), + quote: quote_depth > 0, + }); + } + } + TagEnd::TableCell => { + let spans = inline.finish(); + let TableBuilder::Body { rows } = &mut table else { + continue; + }; + let Some(row) = rows.last_mut() else { + continue; + }; + row.cells.push(MarkdownTableCell { + spans, + column: row.cells.len() + 1, + }); + } + TagEnd::Emphasis + | TagEnd::Strong + | TagEnd::Strikethrough + | TagEnd::Link + | TagEnd::Image => inline.pop_style(), + _ => {} + }, + Event::Text(text) => { + if let Some(built) = code.as_mut() { + built.push(&text); + } else { + inline.push_text(&text); + } + } + Event::Code(text) => { + inline.push_style(Some(InlineStyle::Code)); + inline.push_text(&text); + inline.pop_style(); + } + Event::SoftBreak | Event::HardBreak => inline.push_text(" "), + Event::Rule => { + flush_item_inline( + &mut inline, + &mut items, + &mut lists, + &mut blocks, + quote_depth, + ); + blocks.push(MarkdownBlock::ThematicBreak { + indent: lists.len(), + quote: quote_depth > 0, + }); + } + Event::TaskListMarker(checked) => { + if let Some(frame) = lists.last_mut() { + frame.task = Some(checked); + } + } + Event::FootnoteReference(label) => inline.push_text(&label), + // HTML and math render nothing in this view. + Event::InlineHtml(_) + | Event::Html(_) + | Event::InlineMath(_) + | Event::DisplayMath(_) => {} + } + } + + blocks +} + +/// Computes the current item's marker and advances ordered numbering. +fn take_marker(lists: &mut [ListFrame]) -> ListMarker { + let Some(frame) = lists.last_mut() else { + return ListMarker::Bullet; + }; + + let marker = match frame.task { + Some(checked) => ListMarker::Task(checked), + None if frame.ordered => ListMarker::Ordered(frame.next_number), + None => ListMarker::Bullet, + }; + + if frame.ordered { + frame.next_number += 1; + } + + marker +} + +/// Flushes an open tight item's inline text as its marker row. +fn flush_item_inline( + inline: &mut InlineBuilder, + items: &mut [bool], + lists: &mut [ListFrame], + blocks: &mut Vec, + quote_depth: usize, +) { + let Some(item) = items.last_mut() else { + return; + }; + + let spans = inline.finish(); + if spans.is_empty() { + return; + } + *item = true; + + blocks.push(MarkdownBlock::ListItem { + marker: take_marker(lists), + spans, + indent: lists.len().saturating_sub(1), + quote: quote_depth > 0, + }); +} + +/// Renders one parsed block as a list row. +fn render_block(block: &MarkdownBlock, block_ix: usize, id: &ElementId, cx: &App) -> AnyElement { + let theme = cx.theme(); + let gap = theme.root_font_size * BLOCK_GAP; + let block_id = ElementId::from(SharedString::from(format!("{id}-block-{block_ix}"))); + + match block { + MarkdownBlock::Heading { + level, + spans, + indent, + quote, + } => heading_row( + *level, + spans, + *indent, + *quote, + if block_ix == 0 { px(0.) } else { gap * 0.5 }, + &block_id, + cx, + ), + MarkdownBlock::Paragraph { + spans, + indent, + quote, + } => text_row(spans, *indent, *quote, gap, None, &block_id, cx), + MarkdownBlock::ListItem { + marker, + spans, + indent, + quote, + } => text_row( + spans, + *indent, + *quote, + theme.root_font_size * ITEM_GAP, + Some(*marker), + &block_id, + cx, + ), + MarkdownBlock::Code { + lines, + indent, + quote, + .. + } => code_row(lines, *indent, *quote, &block_id, cx), + MarkdownBlock::ThematicBreak { indent, quote } => rule_row(*indent, *quote, &block_id, cx), + MarkdownBlock::Table { + table, + indent, + quote, + } => table_row(table, *indent, *quote, &block_id, cx), + } +} + +/// The shared block frame: ambient text style, indent, spacing, quote bar. +fn block_frame( + indent: usize, + quote: bool, + top: Pixels, + bottom: Pixels, + id: &ElementId, + cx: &App, +) -> Div { + let theme = cx.theme(); + let content = div() + .debug_selector({ + let name = SharedString::from(format!("{id}")); + move || name.to_string() + }) + .w_full() + .flex() + .flex_col() + .font_family(".SystemUIFont") + .text_size(theme.sizes.small.text_size) + .line_height(relative(BODY_LINE_HEIGHT)) + .text_color(theme.colors.foreground) + .pt(top) + .pb(bottom) + .when(quote, |frame| { + frame + .border_l_2() + .border_color(theme.colors.border) + .pl(theme.root_font_size * QUOTE_INSET) + }); + + let indent_px = theme.root_font_size * INDENT_STEP * indent as f32; + if indent_px > px(0.) { + div().w_full().pl(indent_px).child(content) + } else { + content + } +} + +fn heading_row( + level: MarkdownHeading, + spans: &[InlineSpan], + indent: usize, + quote: bool, + top: Pixels, + id: &ElementId, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let scale = HEADING_SCALES[(level.level() - 1).min(HEADING_SCALES.len() - 1)]; + + block_frame(indent, quote, top, theme.root_font_size * BLOCK_GAP, id, cx) + .text_size(theme.root_font_size * scale) + .line_height(relative(1.3)) + .font_weight(FontWeight::SEMIBOLD) + .child(text_child(spans, id, cx)) + .into_any_element() +} + +fn text_row( + spans: &[InlineSpan], + indent: usize, + quote: bool, + bottom: Pixels, + marker: Option, + id: &ElementId, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let mut frame = block_frame(indent, quote, px(0.), bottom, id, cx); + + if let Some(marker) = marker { + let line_height = theme.sizes.small.text_size * BODY_LINE_HEIGHT; + let marker_cell = div() + .flex_none() + .w(theme.root_font_size * MARKER_WIDTH) + .h(line_height) + .flex() + .items_center() + .justify_end() + .pr_1() + .child(match marker { + ListMarker::Bullet => div() + .text_color(theme.colors.muted_foreground) + .child("•") + .into_any_element(), + ListMarker::Ordered(number) => div() + .text_color(theme.colors.muted_foreground) + .whitespace_nowrap() + .child(format!("{number}.")) + .into_any_element(), + ListMarker::Task(checked) => task_box(checked, cx), + }); + + frame = frame.child( + div() + .flex() + .items_start() + .child(marker_cell) + .child(div().flex_1().min_w_0().child(text_child(spans, id, cx))), + ); + } else { + frame = frame.child(text_child(spans, id, cx)); + } + + frame.into_any_element() +} + +/// A read-only task item indicator, matching the checkbox component. +fn task_box(checked: bool, cx: &App) -> AnyElement { + let theme = cx.theme(); + let side = theme.sizes.small.dense_icon_size; + let glyph = side * 0.8; + + div() + .flex_none() + .size(side) + .rounded(theme.radius.sm) + .border_1() + .when(checked, |box_element| { + box_element + .bg(theme.ramps.primary.base) + .border_color(theme.ramps.primary.base) + .child( + icon(IconRole::Check) + .size(glyph) + .color(theme.colors.primary_foreground), + ) + }) + .when(!checked, |box_element| { + box_element + .bg(theme.colors.input) + .border_color(theme.colors.border) + }) + .into_any_element() +} + +fn code_row( + lines: &HighlightedCode, + indent: usize, + quote: bool, + id: &ElementId, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let text_size = theme.sizes.small.text_size; + let line_height = text_size * CODE_LINE_HEIGHT; + + let mut plate = div() + .w_full() + .flex() + .flex_col() + .rounded(theme.radius.control) + .bg(theme.colors.muted) + .p_2() + .font_family(theme.mono_font) + .text_size(text_size) + .line_height(relative(CODE_LINE_HEIGHT)) + .overflow_hidden(); + + for line in &lines.lines { + plate = plate.child( + div() + .h(line_height) + .whitespace_nowrap() + .overflow_hidden() + .child(code_line(line, theme.colors, theme.ramps)), + ); + } + + block_frame( + indent, + quote, + px(0.), + theme.root_font_size * BLOCK_GAP, + id, + cx, + ) + .child(plate) + .into_any_element() +} + +fn rule_row(indent: usize, quote: bool, id: &ElementId, cx: &App) -> AnyElement { + let theme = cx.theme(); + block_frame( + indent, + quote, + px(0.), + theme.root_font_size * BLOCK_GAP, + id, + cx, + ) + .child(div().h(px(1.)).w_full().bg(theme.colors.border)) + .into_any_element() +} + +fn table_row( + table: &MarkdownTable, + indent: usize, + quote: bool, + id: &ElementId, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let colors = theme.colors; + let width = table + .rows + .iter() + .map(|row| row.cells.len()) + .max() + .unwrap_or(0); + let row_count = table.rows.len(); + + let mut grid = div() + .w_full() + .flex() + .flex_col() + .border_1() + .border_color(colors.border) + .rounded(theme.radius.control) + .overflow_hidden(); + + for (row_ix, row) in table.rows.iter().enumerate() { + let mut row_element = div().w_full().flex(); + if row_ix + 1 < row_count { + row_element = row_element.border_b_1().border_color(colors.border); + } + if row.header { + row_element = row_element.bg(colors.muted); + } + + for (column_ix, cell) in row.cells.iter().enumerate() { + let cell_id = ElementId::from(SharedString::from(format!( + "{id}-cell-{row_ix}-{column_ix}" + ))); + row_element = row_element.child( + div() + .flex_1() + .min_w_0() + .px_2() + .py_1() + .when(row.header, |cell_element| { + cell_element.font_weight(FontWeight::SEMIBOLD) + }) + .child(text_child(&cell.spans, &cell_id, cx)), + ); + } + + // Ragged tables pad missing cells so rows align. + for _ in row.cells.len()..width { + row_element = row_element.child(div().flex_1().min_w_0().px_2().py_1()); + } + + grid = grid.child(row_element); + } + + block_frame( + indent, + quote, + px(0.), + theme.root_font_size * BLOCK_GAP, + id, + cx, + ) + .child(grid) + .into_any_element() +} + +/// Builds the block's text element. +fn text_child(spans: &[InlineSpan], id: &ElementId, cx: &App) -> AnyElement { + let theme = cx.theme(); + let mut text = String::new(); + let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + let mut family_overrides: Vec<(Range, SharedString)> = Vec::new(); + let mut link_ranges: Vec> = Vec::new(); + let mut destinations: Vec = Vec::new(); + + for span in spans { + let start = text.len(); + text.push_str(&span.text); + + let range = start..text.len(); + + match &span.style { + InlineStyle::Text => {} + InlineStyle::Emphasis => highlights.push(( + range, + HighlightStyle { + font_style: Some(FontStyle::Italic), + ..Default::default() + }, + )), + InlineStyle::Strong => highlights.push(( + range, + HighlightStyle { + font_weight: Some(FontWeight::SEMIBOLD), + ..Default::default() + }, + )), + InlineStyle::StrongEmphasis => highlights.push(( + range, + HighlightStyle { + font_style: Some(FontStyle::Italic), + font_weight: Some(FontWeight::SEMIBOLD), + ..Default::default() + }, + )), + InlineStyle::Strikethrough => highlights.push(( + range, + HighlightStyle { + strikethrough: Some(StrikethroughStyle { + thickness: px(1.), + color: Some(theme.colors.muted_foreground), + }), + ..Default::default() + }, + )), + InlineStyle::Code => { + highlights.push(( + range.clone(), + HighlightStyle { + background_color: Some(theme.colors.muted), + ..Default::default() + }, + )); + family_overrides.push((range, SharedString::from(theme.mono_font))); + } + InlineStyle::Link { destination } => { + highlights.push(( + range.clone(), + HighlightStyle { + color: Some(theme.colors.accent), + underline: Some(UnderlineStyle { + thickness: px(1.), + color: None, + wavy: false, + }), + ..Default::default() + }, + )); + link_ranges.push(range); + destinations.push(destination.clone()); + } + } + } + + let styled = StyledText::new(text) + .with_highlights(highlights) + .with_font_family_overrides(family_overrides); + + if destinations.is_empty() { + return styled.into_any_element(); + } + + InteractiveText::new(id.clone(), styled) + .on_click(link_ranges, move |range_ix, _, cx| { + if let Some(destination) = destinations.get(range_ix) { + cx.open_url(destination.as_ref()); + } + }) + .into_any_element() +} diff --git a/crates/editor/src/utils.rs b/crates/editor/src/utils.rs index cace8c4..e9c8d83 100644 --- a/crates/editor/src/utils.rs +++ b/crates/editor/src/utils.rs @@ -1,9 +1,11 @@ -//! Shared helpers for the editor's views: gutter cells, role colors, change -//! washes and diff row building. +//! Shared helpers for the editor's views: gutter cells, role colors, code +//! line text, language mapping, change washes and diff row building. -#[cfg(feature = "diff_view")] +#[cfg(any(feature = "diff_view", feature = "markdown_view"))] use std::ops::Range; +#[cfg(feature = "markdown_view")] +use gpui::StyledText; use gpui::{App, HighlightStyle, Hsla, IntoElement, ParentElement as _, Styled as _, div}; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; #[cfg(feature = "diff_view")] @@ -14,6 +16,8 @@ use crate::diff_view::{DiffRow, DiffRowKind}; #[cfg(feature = "diff_view")] use crate::highlight::HighlightedCode; use crate::highlight::Role; +#[cfg(feature = "markdown_view")] +use crate::highlight::{Language, Line}; /// Maps a highlight role to the theme's semantic palette. pub(crate) fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla { @@ -136,3 +140,38 @@ pub(crate) fn wash_gutter(colors: &ColorTokens) -> Hsla { ..colors.muted } } + +/// One highlighted code line as styled text, roles resolved from the theme. +#[cfg(feature = "markdown_view")] +pub(crate) fn code_line(line: &Line, colors: ColorTokens, ramps: Ramps) -> StyledText { + let mut text = String::new(); + let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + + for span in &line.spans { + let start = text.len(); + text.push_str(&span.text); + if span.role != Role::Text { + highlights.push(( + start..text.len(), + HighlightStyle { + color: Some(role_color(span.role, colors, ramps)), + ..Default::default() + }, + )); + } + } + + StyledText::new(text).with_highlights(highlights) +} + +/// Maps a fence info string to a bundled grammar. +#[cfg(feature = "markdown_view")] +pub(crate) fn grammar_language(info: &str) -> Option { + match info.to_ascii_lowercase().as_str() { + "rust" | "rs" => Some(Language::Rust), + "typescript" | "ts" => Some(Language::TypeScript), + "javascript" | "js" => Some(Language::JavaScript), + "python" | "py" => Some(Language::Python), + _ => None, + } +} diff --git a/crates/editor/tests/markdown.rs b/crates/editor/tests/markdown.rs new file mode 100644 index 0000000..f53c9ce --- /dev/null +++ b/crates/editor/tests/markdown.rs @@ -0,0 +1,449 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, MouseButton, MouseDownEvent, MouseUpEvent, + ParentElement as _, Render, Styled as _, TestAppContext, VisualTestContext, Window, div, point, + px, +}; +use gpui_native_editor::highlight::Role; +use gpui_native_editor::markdown::{ + InlineSpan, InlineStyle, ListMarker, MarkdownBlock, MarkdownHeading, MarkdownViewState, +}; + +fn spans_text(spans: &[InlineSpan]) -> String { + spans + .iter() + .map(|span| span.text.as_ref()) + .collect::>() + .join("") +} + +fn paragraph_of(blocks: &[MarkdownBlock], index: usize) -> (&Vec, usize, bool) { + match &blocks[index] { + MarkdownBlock::Paragraph { + spans, + indent, + quote, + } => (spans, *indent, *quote), + other => panic!("block {index} is {other:?}, not a paragraph"), + } +} + +fn item_of(blocks: &[MarkdownBlock], index: usize) -> (ListMarker, String, usize, bool) { + match &blocks[index] { + MarkdownBlock::ListItem { + marker, + spans, + indent, + quote, + } => (*marker, spans_text(spans), *indent, *quote), + other => panic!("block {index} is {other:?}, not a list item"), + } +} + +#[test] +fn inline_styles_survive_nesting_and_merge_runs() { + let blocks = parse_body("a **bold *both* b** `code` [link](https://example.com) done"); + assert_eq!(blocks.len(), 1); + let (spans, ..) = paragraph_of(&blocks, 0); + + let styled: Vec<(String, &InlineStyle)> = spans + .iter() + .map(|span| (span.text.to_string(), &span.style)) + .collect(); + + assert!(styled.contains(&("bold ".to_string(), &InlineStyle::Strong))); + assert!( + styled.contains(&("both".to_string(), &InlineStyle::StrongEmphasis)), + "strong wrapping emphasis combines into one style" + ); + assert!(styled.contains(&(" b".to_string(), &InlineStyle::Strong))); + assert!(styled.contains(&("code".to_string(), &InlineStyle::Code))); + assert!(styled.contains(&( + "link".to_string(), + &InlineStyle::Link { + destination: "https://example.com".into() + } + ))); + assert!(styled.contains(&("a ".to_string(), &InlineStyle::Text))); + assert!(styled.contains(&(" done".to_string(), &InlineStyle::Text))); +} + +#[test] +fn headings_headings_and_thematic_breaks_parse() { + let blocks = parse_body("# Title\n\ntext\n\n---\n\n###### small"); + assert_eq!(blocks.len(), 4); + assert!(matches!( + &blocks[0], + MarkdownBlock::Heading { + level: MarkdownHeading::H1, + .. + } + )); + assert!(matches!(&blocks[2], MarkdownBlock::ThematicBreak { .. })); + assert!(matches!( + &blocks[3], + MarkdownBlock::Heading { + level: MarkdownHeading::H6, + .. + } + )); +} + +#[test] +fn lists_number_order_and_nest() { + let source = "- one\n- two\n 1. inner\n 2. more\n3. third\n"; + let blocks = parse_body(source); + + let markers: Vec = blocks + .iter() + .filter_map(|block| match block { + MarkdownBlock::ListItem { marker, .. } => Some(*marker), + _ => None, + }) + .collect(); + assert_eq!( + markers, + vec![ + ListMarker::Bullet, + ListMarker::Bullet, + ListMarker::Ordered(1), + ListMarker::Ordered(2), + ListMarker::Ordered(3), + ], + "ordered numbering continues across nested lists" + ); + + let (_, _, outer_indent, _) = item_of(&blocks, 0); + assert_eq!(outer_indent, 0); + let (_, _, inner_indent, _) = item_of(&blocks, 2); + assert_eq!(inner_indent, 1, "nested items indent one level deeper"); +} + +#[test] +fn tight_items_stay_tight_but_block_content_flushes_before_them() { + let source = "- item text\n\n second paragraph\n- next\n"; + let blocks = parse_body(source); + + // The first paragraph of a loose item carries its marker row; the + // continuation flushes as a plain paragraph at the same indent. + let (marker, text, indent, _) = item_of(&blocks, 0); + assert_eq!(marker, ListMarker::Bullet); + assert_eq!(text, "item text"); + assert_eq!(indent, 0); + + let (spans, indent, _) = paragraph_of(&blocks, 1); + assert_eq!(spans_text(spans), "second paragraph"); + assert_eq!( + indent, 1, + "loose item continuations indent under the item's text" + ); + + let (marker, text, ..) = item_of(&blocks, 2); + assert_eq!(marker, ListMarker::Bullet); + assert_eq!(text, "next"); +} + +#[test] +fn tight_item_content_after_a_nested_block_stays_in_the_item() { + let blocks = parse_body( + "- doc + ```rust + fn x() {} + ``` + after +", + ); + assert_eq!( + blocks.len(), + 3, + "marker row, code, and one continuation row" + ); + + let (marker, text, indent, _) = item_of(&blocks, 0); + assert_eq!(marker, ListMarker::Bullet); + assert_eq!(text, "doc"); + assert_eq!(indent, 0); + assert!(matches!( + &blocks[1], + MarkdownBlock::Code { + indent: 1, + quote: false, + .. + } + )); + + let (spans, indent, _) = paragraph_of(&blocks, 2); + assert_eq!(spans_text(spans), "after"); + assert_eq!(indent, 1, "the continuation stays under the item's text"); +} + +#[test] +fn task_markers_do_not_leak_into_following_items() { + let blocks = parse_body("- [ ] todo\n- plain\n1. [x] done\n2. next\n"); + assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false)); + assert_eq!(item_of(&blocks, 1).0, ListMarker::Bullet); + assert_eq!(item_of(&blocks, 2).0, ListMarker::Task(true)); + assert_eq!(item_of(&blocks, 3).0, ListMarker::Ordered(2)); +} + +#[test] +fn quoted_text_after_an_item_flushes_the_item_first() { + let blocks = parse_body("- text\n > quoted\n"); + assert_eq!(blocks.len(), 2); + assert_eq!(item_of(&blocks, 0).1, "text"); + let (_, indent, quote) = paragraph_of(&blocks, 1); + assert!(quote, "the quoted paragraph carries the quote flag"); + assert_eq!(indent, 1, "quote content stays under the item's text"); +} + +#[test] +fn a_rule_after_item_text_keeps_its_order() { + let blocks = parse_body("- text\n ***\n"); + assert_eq!(blocks.len(), 2); + assert_eq!(item_of(&blocks, 0).1, "text"); + assert!(matches!( + &blocks[1], + MarkdownBlock::ThematicBreak { + indent: 1, + quote: false + } + )); +} + +#[test] +fn nested_code_and_tables_carry_indent_and_quote() { + let blocks = + parse_body("- fenced\n\n ```rust\n fn x() {}\n ```\n\n> | a |\n> |---|\n> | b |\n"); + assert_eq!(blocks.len(), 3); + + let (marker, text, indent, _) = item_of(&blocks, 0); + assert_eq!(marker, ListMarker::Bullet); + assert_eq!(text, "fenced"); + assert_eq!(indent, 0); + + assert!(matches!( + &blocks[1], + MarkdownBlock::Code { + indent: 1, + quote: false, + .. + } + )); + assert!(matches!( + &blocks[2], + MarkdownBlock::Table { + indent: 0, + quote: true, + .. + } + )); +} + +#[test] +fn task_markers_carry_their_checked_state() { + let blocks = parse_body("- [ ] todo\n- [x] done\n"); + assert_eq!(item_of(&blocks, 0).0, ListMarker::Task(false)); + assert_eq!(item_of(&blocks, 1).0, ListMarker::Task(true)); +} + +#[test] +fn quotes_mark_their_paragraphs_and_lists() { + let blocks = parse_body("> quoted\n>\n> - listed\n\nplain"); + let (_, _, quote) = paragraph_of(&blocks, 0); + assert!(quote, "quoted paragraphs carry the quote flag"); + assert!(item_of(&blocks, 1).3, "quoted list items carry it too"); + assert!(!paragraph_of(&blocks, 2).2); +} + +#[test] +fn fenced_code_highlights_known_languages_and_keeps_the_info_string() { + let blocks = parse_body("```rust\nfn main() {}\n```\n\n```text\nplain\n```"); + assert_eq!(blocks.len(), 2); + + let MarkdownBlock::Code { + language, lines, .. + } = &blocks[0] + else { + panic!("the first block is code"); + }; + assert_eq!(language.as_deref(), Some("rust")); + assert!( + lines + .lines + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.role == Role::Keyword), + "rust fences highlight through the grammar" + ); + + let MarkdownBlock::Code { + language, lines, .. + } = &blocks[1] + else { + panic!("the second block is code"); + }; + assert_eq!(language.as_deref(), Some("text")); + assert!( + lines + .lines + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.role == Role::Text), + "unknown languages render plain" + ); +} + +#[test] +fn tables_parse_header_first_with_column_positions() { + let blocks = parse_body("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n"); + let MarkdownBlock::Table { table, .. } = &blocks[0] else { + panic!("the block is a table"); + }; + + assert_eq!(table.rows.len(), 3); + assert!(table.rows[0].header); + assert!(!table.rows[1].header); + + let header: Vec = table.rows[0] + .cells + .iter() + .map(|cell| spans_text(&cell.spans)) + .collect(); + assert_eq!(header, vec!["a", "b"]); + assert_eq!( + table.rows[0] + .cells + .iter() + .map(|cell| cell.column) + .collect::>(), + vec![1, 2] + ); + assert_eq!(spans_text(&table.rows[2].cells[1].spans), "4"); +} + +#[test] +fn images_render_their_alt_text_and_html_renders_nothing() { + let blocks = parse_body("see ![the logo](logo.png) now\n\n
hidden
\n"); + assert_eq!(blocks.len(), 1, "the html block renders no block at all"); + let (spans, ..) = paragraph_of(&blocks, 0); + assert_eq!(spans_text(spans), "see the logo now"); +} + +#[test] +fn set_source_reparses_and_blocks_reset() { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let state = cx.new(|_| MarkdownViewState::new("test-md", "# old\n")); + + cx.update(|cx| { + state.update(cx, |state, cx| state.set_source("- a\n- b\n", cx)); + }); + + let (source, count) = cx.update(|cx| { + let state = state.read(cx); + (state.source().to_string(), state.blocks().len()) + }); + assert_eq!(source, "- a\n- b\n"); + assert_eq!(count, 2, "the block list reflects the new source"); +} + +struct Host { + view: Entity, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.view.clone()) + } +} + +fn view_window(source: &str) -> (TestAppContext, VisualTestContext, Entity) { + let mut cx = TestAppContext::single(); + cx.update(gpui_native_theme::Theme::init); + let state = MarkdownViewState::new("test-md", source); + let state_cell: Rc>>> = Rc::new(RefCell::new(None)); + let cell = state_cell.clone(); + let window = cx.add_window(move |_, cx| { + let view = cx.new(|_| state); + cell.borrow_mut().replace(view.clone()); + Host { view } + }); + let state = state_cell.borrow().clone().expect("state captured"); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, state) +} + +#[test] +fn blocks_render_virtualized_and_scroll_to_a_block() { + let mut source = String::new(); + for paragraph in 0..200 { + source.push_str(&format!("paragraph {paragraph} with some body text\n\n")); + } + let (_cx, mut visual, state) = view_window(&source); + + assert!( + visual.debug_bounds("test-md-block-0").is_some(), + "the first block renders" + ); + assert!( + visual.debug_bounds("test-md-block-150").is_none(), + "blocks past the viewport never render" + ); + + state.update_in(&mut visual, |state, _window, _cx| { + state.scroll_to_block(150) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + assert!( + visual.debug_bounds("test-md-block-150").is_some(), + "scrolling brings the target block in" + ); + assert!( + visual.debug_bounds("test-md-block-0").is_none(), + "the first block scrolled out" + ); + assert!( + visual.debug_bounds("test-md-block-199").is_none(), + "blocks past the target stay unrendered" + ); +} + +#[test] +fn clicking_a_link_opens_its_destination() { + let (_cx, mut visual, _state) = view_window("[click me](https://example.com/welcome)"); + + let block = visual + .debug_bounds("test-md-block-0") + .expect("the paragraph renders"); + + visual.simulate_event(MouseDownEvent { + position: point(block.left() + px(4.), block.center().y), + button: MouseButton::Left, + ..Default::default() + }); + visual.simulate_event(MouseUpEvent { + position: point(block.left() + px(4.), block.center().y), + button: MouseButton::Left, + ..Default::default() + }); + + assert_eq!( + visual.opened_url().as_deref(), + Some("https://example.com/welcome"), + "the link's destination opens through the platform" + ); +} + +/// Parses without an app context; the view reuses this path. +fn parse_body(source: &str) -> Vec { + let state = MarkdownViewState::new("parse-md", source); + state.blocks().to_vec() +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index af7d752..02dd340 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,15 +9,13 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms. - - [ ] API sketch - - [ ] Implementation - - [ ] Tests - - [ ] Gallery panel - - [ ] Review - ## Queue — to follow +- [ ] **Markdown view follow-ups** — streaming `append` with a coalesced + background parse, text selection with copy, per-block a11y roles, + GFM alerts + table column alignment, per-fence highlight reuse, + `soft_break_as_hard_break`; ranked notes in + [markdown-view-comparison.md](markdown-view-comparison.md). - [ ] **Editor follow-ups** — language detection by file path, meta line overlay, per-view config overrides (line height, gutter visibility). - [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at @@ -76,8 +74,10 @@ is the first downstream consumer; its screens drive the order. incremental line re-highlight - [x] Diff view — unified rows via `similar`, highlighted with the code view's palette; change washes from the ramps, `scroll_to_first_change` -- [x] Diff view — unified rows via `similar`, highlighted with the code - view's palette; change washes from the ramps, `scroll_to_first_change` +- [x] Markdown view — `pulldown-cmark` blocks rendered as formatted text: + headings, emphasis runs, ordered/task lists, quotes, fenced code + highlighted with the code view's palette, tables, clickable links; + virtualized variable-height rows over gpui's `list` ### layout (`crates/layout`) diff --git a/docs/markdown-view-comparison.md b/docs/markdown-view-comparison.md new file mode 100644 index 0000000..f53cd5e --- /dev/null +++ b/docs/markdown-view-comparison.md @@ -0,0 +1,136 @@ +# Markdown view — comparison with bezel and gpuikit + +Peer read for API ideas only; nothing ported, nothing depended on +([AGENTS.md](../AGENTS.md)). Assessed 2026-11 against `main` of +[bezel](https://github.com/crabtalk/bezel) (`crates/markdown`) and +[gpuikit](https://github.com/iamnbutler/gpuikit) (`src/markdown`). Both move +fast and bezel's APIs are explicitly still evolving, so details may have +shifted when this is revisited. Queue entry: +[CHECKLIST.md](CHECKLIST.md) — "Markdown view follow-ups". + +## Positioning — three different products + +| | gpui-native-kit (ours) | bezel | gpuikit | +|---|---|---|---| +| Ambition | Read-only document viewer | Editable Notion-style document engine, markdown as wire form | Streaming reader built for LLM/chat output | +| Core model | `Vec` IR | `Doc` = flat block list + indent; `Text` = string + marks over byte ranges | Pulldown events kept with source ranges, rendered straight to elements | +| Editing | None (Input covers fields) | Full: splice/shortcut edits, caret, selection, parse↔serialize fixed point | None | +| gpui coupling | One crate, feature-gated | Pure core + `gpui` feature; builds for wasm | All gpui | + +## Dimension by dimension + +**Parsing.** All three use pulldown-cmark. gpuikit's option set is the most +disciplined: GFM + footnotes on, every option left off documented with what +turning it on would silently break (`~x~` becoming subscript instead of +strikethrough, wikilinks, math), and exhaustive `Start`/`End` matches with no +wildcard arm, so a pulldown upgrade is a compile error instead of silently +dropped styling. We match on tables/strikethrough/tasklists but skip footnotes +and GFM alerts, and use `_ => {}` wildcard arms in two places. + +**Block model.** bezel is the outlier: flat blocks with a one-level-deeper +indent invariant; marks as ordered spans (`**_x_**` differs from `_**x**_`, +which run flags cannot express); block-level Bookmark/Mention rich links; +image width in the title slot (`![alt|480](url)`); GFM alert quotes +(`[!NOTE]`); GitHub-style heading slugs; a parse↔serialize fixed-point +guarantee so an edit/save cycle never drifts. Neither viewer attempts +round-tripping — fine for us, and the reason bezel's parse layer is its own +crate. + +**Streaming (gpuikit's headline).** `append()` for LLM deltas: parse on the +background executor, previous events keep rendering (never blanks), deltas +arriving mid-parse coalesce into one follow-up parse, first parse synchronous +so the document is never empty on frame one. Two extra streaming details: a +`stitch` preprocessing pass closes unterminated inline syntax (`**bold`) +before parsing so text does not flicker between plain and styled mid-stream, +and an unclosed fence is drawn plain until its closer arrives — a streaming +fence is a new cache key per delta, so highlighting it costs a full pass per +frame. Their open-fence byte scan errs only toward "closed", the harmless +direction. We parse synchronously in `set_source` and re-highlight every fence +on each parse. + +**Code highlighting.** tree-sitter for bezel, syntect for gpuikit +(feature-gated, with a highlight cache and theme selection), tree-sitter with +per-language cargo features for us. Ours shares one grammar stack with the +code view — one dependency set across both surfaces — but re-highlights every +block on every parse where gpuikit caches settled blocks and suspends the open +one. + +**Layout and performance.** The one place we are clearly ahead: neither +library virtualizes. gpuikit builds one flex column of every block in the +document each frame (confirmed in its `render_events`); bezel renders blocks +directly (not assessed in depth). We window over blocks like the code and diff +views do. The trade: an all-in-one-column layout makes text selection +tractable, which our windowing makes harder. + +**Text selection.** Both ship it, we do not. gpuikit's is the deeper +implementation: a document-wide selection of `(run, byte offset)` positions, a +per-frame run registry, `append` preserves the selection while `set_source` +drops it, `selected_text()` for the copy binding. bezel's rides on its +editable model. This is our biggest functional gap against both. + +**Accessibility.** gpuikit is far ahead: `Role::Document` on the root, per-run +roles (heading with level, paragraph, list item, blockquote, code), and +element ids stable across frames because assistive technology reads a changed +node id as a replaced element. Tested through a recorded-run harness. We ship +one optional `aria_label` on the root. bezel's a11y not assessed. + +**Tables.** gpuikit honors GFM column alignment, stripes body rows, borders +per column. We parse cell order but dropped `Alignment` — cheap to add since +pulldown hands it over. + +**Images.** gpuikit renders real image elements with alt text; bezel supports +captions and widths. We render alt text only (documented M16 scope). + +**Styling philosophy.** gpuikit: a per-instance `MarkdownStyle` struct with +per-key theme fallbacks, plus knobs like `soft_break_as_hard_break` (LLM +output treats single newlines as breaks). bezel: nothing passed at all — style +flows through the environment, `Brand`/`Tint` rotate one palette. Ours is +closest to bezel's philosophy: everything resolves from the native system +theme, zero per-instance color knobs, which is the kit's stated goal. We have +no soft-break knob yet. + +**Lists.** All three solved the same loose/tight and multi-block-item +problems. gpuikit's mechanism is elegant: the item's first block takes the +marker (`Shown`), later blocks reserve the marker's width but draw it +`Hidden`, so ordinals are not burned and continuation text aligns exactly with +the first block's text column. Ours reaches the same semantics (marker on the +first row, continuations as deeper paragraphs) with approximate alignment +(indent step 1.5rem vs marker column 1.75rem). + +## Ideas worth stealing + +Ranked by value to downstream consumers, signed first. API ideas only — +implementation stays ours. + +1. Streaming `append` + coalesced background parse (gpuikit). For an LLM + client this is the headline feature; our synchronous `set_source` blocks + the UI thread on large documents. +2. Text selection with copy (both). The pattern exists in the code view; a + wrapped-text version is real work but the biggest functional gap. +3. Per-block a11y roles + stable ids (gpuikit). Medium effort, high + correctness value; the recorded-run test technique is reusable as an idea. +4. Incremental highlight caching (gpuikit). Cheap version for us: reuse + per-block tree-sitter trees across parses — `highlight_with_tree` already + takes `old_tree` — instead of re-parsing every fence per `set_source`. +5. GFM alerts + table column alignment (both). Nearly free; pulldown emits + `BlockQuoteKind` and `Alignment` today and we ignore both. +6. `soft_break_as_hard_break` (gpuikit). One option, real payoff for agent + output. +7. Exhaustive match, no wildcard arms (gpuikit). Turns pulldown upgrades into + compile errors. +8. Heading anchors/slugs (bezel). Useful when a consumer wants in-document + navigation. +9. Per-view typography overrides (gpuikit's `MarkdownStyle`). Conflicts with + the theme-global discipline; the checklist's editor follow-ups already + gesture at per-view config — decide deliberately, not by accident. + +## Deliberate differences + +- Virtualization is ours alone and is the right call for a kit whose gallery + ships 5,000-line perf tests. +- Native theme resolution (platform sources → tokens) instead of per-instance + style structs is the kit's identity, not a gap. +- No editing or serialization. bezel's editor-grade model is a different + product; if a consumer ever wants an editable markdown surface, bezel's + flat-block model is the architecture study to make first, and its pure-core + split is the cleanest layering of the three. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 927073d..983488d 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -43,6 +43,7 @@ use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_editor::code_view::CodeViewState; use gpui_native_editor::diff_view::{DiffRowKind, DiffViewState}; use gpui_native_editor::highlight::Language; +use gpui_native_editor::markdown::{MarkdownBlock, MarkdownViewState}; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_layout::dock::{Dock, DockSidebar, DockState, PanelDescriptor, SplitDirection}; use gpui_native_layout::resize_handle::ResizeHandle; @@ -87,7 +88,11 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ), ( "Editor", - &[("code-view", "Code view"), ("diff-view", "Diff view")], + &[ + ("code-view", "Code view"), + ("diff-view", "Diff view"), + ("markdown-view", "Markdown view"), + ], ), ( "Feedback", @@ -642,6 +647,12 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("markdown-view", "Markdown view", |_, cx| { + cx.new(MarkdownViewDemo::new).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("sidebar", "Sidebar", |_, cx| { cx.new(|_| SidebarDemo { selected: None }).into() @@ -3102,6 +3113,161 @@ impl Render for DiffViewDemo { } } +/// The markdown view demo's sample document. +const MARKDOWN_SAMPLE: &str = r###"# Release notes + +Everything shipped this week, with **bold claims**, *quiet fixes*, and ~~regressions~~. + +## Highlights + +- The relay pool now [retries automatically](https://example.com/relay) on stale sockets +- Inline `code spans` and keyboard chords read fine + +### Checklist + +- [x] Ship the diff view +- [x] Ship the markdown view + - [ ] Wire it into signed + - [ ] Profile the large file + +1. Cut the branch +2. Run the gallery + 1. Open this panel + 2. Scroll the large file + +> Notes from the field: **measure first**, then optimize. + +```rust +fn bind_all(&self) -> Result<(), String> { + for port in &self.ports { + if port > &1024 { + continue; + } + return Err(format!("port {} is privileged", port)); + } + Ok(()) +} +``` + +| Area | Status | Owner | +|------|--------|-------| +| Editor | shipping | reya | +| Layout | staged | team | + +--- + +That is the news. +"###; + +/// The section count of the large markdown sample. +const LARGE_MARKDOWN_SECTIONS: usize = 1000; + +/// Generates a long markdown document, so scrolling the view can be +/// exercised on realistic content. +fn large_markdown(sections: usize) -> String { + let mut source = String::new(); + for section in 0..sections { + source.push_str(&format!("## Section {section}\n\n")); + source.push_str(&format!( + "Handler {section} retries stale sockets and logs every outcome.\n\n" + )); + source.push_str(&format!( + "- [ ] Audit handler {section}\n- See [issue {section}](https://example.com/issues/{section})\n\n" + )); + source.push_str("```rust\nfn handle() -> bool { true }\n```\n\n"); + } + source +} + +struct MarkdownViewDemo { + view: Entity, + sample: MarkdownSample, + large_source: SharedString, + _subscription: gpui::Subscription, +} + +/// The markdown view demo's sample choice. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum MarkdownSample { + #[default] + Guide, + /// A generated document with thousands of blocks, to test scroll + /// performance. + Large, +} + +impl MarkdownViewDemo { + fn new(cx: &mut Context) -> Self { + let view = cx.new(|_| MarkdownViewState::new("markdown-view-demo", MARKDOWN_SAMPLE)); + // The panel is a cached view in the dock: it re-renders only when + // the markdown state it renders is notified. + let subscription = cx.observe(&view, |_, _, cx| cx.notify()); + Self { + view, + sample: MarkdownSample::Guide, + large_source: large_markdown(LARGE_MARKDOWN_SECTIONS).into(), + _subscription: subscription, + } + } +} + +impl Render for MarkdownViewDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + let radius = cx.theme().radius; + let view = cx.entity(); + let blocks = self.view.read(cx).blocks(); + let block_count = blocks.len(); + let heading_count = blocks + .iter() + .filter(|block| matches!(block, MarkdownBlock::Heading { .. })) + .count(); + + ScrollArea::new("markdown-view-demo").size_full().p_4().child( + v_flex() + .gap_3() + .child(demo_section( + "document", + colors, + Segmented::new("markdown-view-demo-sample", self.sample) + .options(vec![ + (MarkdownSample::Guide, "Release notes"), + (MarkdownSample::Large, "Large file"), + ]) + .on_change(move |next, _, cx| { + view.update(cx, |demo, cx| { + demo.sample = next; + let source = match next { + MarkdownSample::Guide => MARKDOWN_SAMPLE.into(), + MarkdownSample::Large => demo.large_source.clone(), + }; + demo.view.update(cx, |view, cx| view.set_source(source, cx)); + }); + }), + )) + .child(demo_section( + "rendered, virtualized, links clickable", + colors, + div() + .h(px(480.)) + .rounded(radius.control) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child(self.view.clone()), + )) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!( + "{block_count} blocks, {heading_count} headings \u{2014} scroll to test performance" + )), + ), + ) + } +} + struct SidebarDemo { selected: Option, }