From 2eec25140deaad5047d5f66d80d0950b7c3dda84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 13 Oct 2026 23:08:51 -0400 Subject: [PATCH] documents: GitHub alerts, a [toc] list of headings, front matter in a box, HTML blocks as text, a visible rule and bullets by depth At the root a quote marked [!NOTE] and the rest, as GitHub's API reads it, is an alert; a [toc] paragraph lists the headings and a press reveals one; an HTML block shows its text with the inline tags, white space as a browser lays it, scripts and styles dropped. The rule is 2px; bullets turn by depth. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- assets/icons/message-square-warning.svg | 17 ++ src/documents/document/mod.rs | 16 +- src/documents/document/parse.rs | 49 +--- src/documents/document/place.rs | 10 + src/documents/document/tests/mod.rs | 1 + src/documents/document/tests/toc.rs | 36 +++ src/documents/document/visual.rs | 1 + src/documents/render.rs | 81 ++---- src/documents/render/blocks.rs | 172 +++++++++++++ src/documents/render/tests.rs | 53 ++++ src/documents/tree/blocks.rs | 326 ++++++++++++++++++++++++ src/documents/tree/html.rs | 109 +++++++- src/documents/tree/mod.rs | 54 +++- src/primitives/icon.rs | 1 + 15 files changed, 810 insertions(+), 118 deletions(-) create mode 100644 assets/icons/message-square-warning.svg create mode 100644 src/documents/document/tests/toc.rs create mode 100644 src/documents/render/blocks.rs create mode 100644 src/documents/render/tests.rs create mode 100644 src/documents/tree/blocks.rs diff --git a/AGENTS.md b/AGENTS.md index 33ce612..695400a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean and other tags, hyphenated names too, vanish, leaving their text; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of a paragraph's lines: 40,000 lines take 0.9 s). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean and other tags, hyphenated names too, vanish, leaving their text; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, spaces around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`); front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines, comments, `script` and `style` dropped, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of a paragraph's lines: 40,000 lines take 0.9 s). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. - Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames and rims map tiles. - Math and diagrams: RaTeX 0.1.14 (`ratex-parser`, `ratex-layout`, `ratex-svg` with `embed-fonts`, `ratex-types`; MIT, KaTeX's fonts OFL) typesets `Latex` to SVG with outlined glyphs; `mermaid-rs-renderer` 0.3.1 (MIT, default features off) draws `Diagram`. Both go through gpui's `SvgRenderer` at the window's scale, loaded as gpui assets off the main thread; `usvg` 0.46, gpui's own, reads a diagram's size. A RaTeX or Mermaid panic on parsed input becomes the element's error. Each element holds its picture through `primitives::held`: a new source, or the element leaving the window, removes the old asset from gpui's cache and drops its image from the atlas after the frame. - Maps: Natural Earth's 110m countries (public domain). `assets/maps/countries.geojson`, trimmed by `scripts/countries.py` to an ISO code, a name and positions to 0.01°, backs `maps::WorldMap`; the gallery's tiles are cut from the same file, z0 to z3, by `scripts/tiles.py` with Pillow. diff --git a/assets/icons/message-square-warning.svg b/assets/icons/message-square-warning.svg new file mode 100644 index 0000000..b360ede --- /dev/null +++ b/assets/icons/message-square-warning.svg @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 0f086ae..b30387b 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -19,13 +19,18 @@ use gpui::{ ListState, Pixels, Subscription, Task, Window, }; +pub use crate::documents::tree::DocumentHeading; pub use attach::Attachment; pub use look::DocumentLook; -pub use parse::DocumentHeading; use parse::{Parsed, alike, parsed}; pub use tables::{TableEdit, TableRefusal}; -use super::{MarkdownMode, markdown::markdown_highlights, modes::joint, render::OnLink}; +use super::{ + MarkdownMode, + markdown::markdown_highlights, + modes::joint, + render::{OnHeading, OnLink}, +}; use crate::{ editor::{CodeEditor, EditorEvent, LineNumbers}, forms::TextInput, @@ -94,6 +99,7 @@ pub struct MarkdownDocument { centered: Option<(usize, u64, u64, [u64; 2])>, focus: FocusHandle, on_link: Option, + on_heading: Option, _subscriptions: Vec, } @@ -132,6 +138,11 @@ impl MarkdownDocument { cx.on_focus(&focus, window, Self::focused), ]; let overdraw = window.rem_size() * 40.0; + let this = cx.entity().downgrade(); + let on_heading: OnHeading = Rc::new(move |at, window, cx| { + this.update(cx, |document, cx| document.reveal_at(at, window, cx)) + .expect("a heading list lives in its document"); + }); let mut document = Self { editor, mode, @@ -151,6 +162,7 @@ impl MarkdownDocument { room: Pixels::ZERO, focus, on_link: None, + on_heading: Some(on_heading), _subscriptions: subscriptions, }; document.reparse(true, cx); diff --git a/src/documents/document/parse.rs b/src/documents/document/parse.rs index 6daf2e9..4061b12 100644 --- a/src/documents/document/parse.rs +++ b/src/documents/document/parse.rs @@ -3,27 +3,18 @@ use std::ops::Range; use markdown::mdast; use crate::documents::{ - markdown::{file_tree, span, walk}, - tree::{Node, Reader}, + markdown::{file_tree, span}, + tree::{DocumentHeading, Node, Reader}, }; /// A top-level block: its source, and what it draws. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Block { pub range: Range, - /// None shows the source as written: definitions, HTML, front matter. + /// None shows a link definition as written. pub nodes: Option>, } -/// A heading anywhere in the file. -#[derive(Clone, Debug, PartialEq)] -pub struct DocumentHeading { - pub depth: u8, - pub title: String, - /// Where its source starts, in bytes. - pub at: usize, -} - /// A file read as blocks and headings. #[derive(Clone, Debug, Default, PartialEq)] pub(crate) struct Parsed { @@ -44,16 +35,10 @@ pub(crate) fn parsed(text: &str) -> Parsed { .map(|node| { let range = span(node); let kept = text[range.clone()].trim_end_matches(['\n', '\r']).len(); - let raw = matches!( - node, - mdast::Node::Definition(_) - | mdast::Node::Html(_) - | mdast::Node::Yaml(_) - | mdast::Node::Toml(_) - ); + let raw = matches!(node, mdast::Node::Definition(_)); let nodes = (!raw).then(|| { let mut out = Vec::new(); - reader.block(node, &mut out); + reader.top(node, &mut out); out }); Block { @@ -62,23 +47,10 @@ pub(crate) fn parsed(text: &str) -> Parsed { } }) .collect(); - let mut nodes = Vec::new(); - walk(&root, &mut nodes); - let headings = nodes - .into_iter() - .filter_map(|node| match node { - mdast::Node::Heading(heading) => Some(DocumentHeading { - depth: heading.depth, - title: reader.inline(&heading.children).text, - at: span(node).start, - }), - _ => None, - }) - .collect(); Parsed { text: text.to_string(), blocks, - headings, + headings: reader.headings, } } @@ -111,7 +83,7 @@ mod tests { use crate::documents::tree::Style; #[test] - fn every_block_is_shown_raw_ones_as_written() { + fn every_block_is_shown_definitions_as_written() { let text = "---\ntitle: a\n---\n\n# Lift\n\n[site]: https://ely.dev\n\n
x
\n\n> ## Quoted\n"; let found = parsed(text); let shown: Vec<(&str, bool)> = found @@ -122,13 +94,16 @@ mod tests { assert_eq!( shown, [ - ("---\ntitle: a\n---", false), + ("---\ntitle: a\n---", true), ("# Lift", true), ("[site]: https://ely.dev", false), - ("
x
", false), + ("
x
", true), ("> ## Quoted", true), ] ); + let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn"); + assert_eq!(drawn(0), [Node::FrontMatter("title: a".into())]); + assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x")); let headings: Vec<(u8, &str, usize)> = found .headings .iter() diff --git a/src/documents/document/place.rs b/src/documents/document/place.rs index 3cdb5ad..e50459c 100644 --- a/src/documents/document/place.rs +++ b/src/documents/document/place.rs @@ -189,6 +189,16 @@ impl MarkdownDocument { .saturating_sub(1) } + /// Reveals the line holding byte `at` of the text. + pub(super) fn reveal_at(&mut self, at: usize, window: &mut Window, cx: &mut Context) { + let editor = self.editor.read(cx); + if at > editor.text().len() { + return log::error!("markdown document: a heading past the text"); + } + let line = editor.buffer.line_of(at); + self.reveal(line, window, cx); + } + /// Line `line` at top and focused, caret at start. pub fn reveal(&mut self, line: usize, window: &mut Window, cx: &mut Context) { self.set_caret(line, 0, cx); diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 159bb2f..410036e 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -370,6 +370,7 @@ mod history; mod links; mod look; mod place; +mod toc; mod tools; #[gpui::test] diff --git a/src/documents/document/tests/toc.rs b/src/documents/document/tests/toc.rs new file mode 100644 index 0000000..16d5dc9 --- /dev/null +++ b/src/documents/document/tests/toc.rs @@ -0,0 +1,36 @@ +use gpui::{Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; + +use super::{MarkdownMode, open}; + +const LISTED: &str = "[toc]\n\n# A\n\ntext\n\n## B\n"; + +/// Presses entry `ix` of the drawn heading list. +fn press_entry(ix: usize, modifiers: Modifiers, cx: &mut VisualTestContext) { + let bounds = cx + .debug_bounds(Box::leak(format!("markdown-toc-{ix}").into_boxed_str())) + .expect("the entry is drawn"); + let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0)); + cx.simulate_mouse_down(at, MouseButton::Left, modifiers); + cx.simulate_mouse_up(at, MouseButton::Left, modifiers); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_press_in_preview_reveals_the_heading(cx: &mut TestAppContext) { + let (document, cx) = open(LISTED, MarkdownMode::Preview, cx); + press_entry(1, Modifiers::none(), cx); + let caret = document.read_with(cx, |document, cx| document.caret(cx)); + assert_eq!(caret, (6, 0)); +} + +#[gpui::test] +fn visual_reveals_on_a_secondary_press_and_edits_on_a_plain_one(cx: &mut TestAppContext) { + let (document, cx) = open(LISTED, MarkdownMode::Visual, cx); + press_entry(1, Modifiers::secondary_key(), cx); + let (caret, open) = document.read_with(cx, |document, cx| { + (document.caret(cx), document.open.is_some()) + }); + assert_eq!((caret, open), ((6, 0), false)); + press_entry(0, Modifiers::none(), cx); + assert!(document.read_with(cx, |document, _| document.open.is_some())); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 7cac6e7..0dfa918 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -101,6 +101,7 @@ impl MarkdownDocument { nodes, &self.base, &self.on_link, + &self.on_heading, self.look.scale, cx, )), diff --git a/src/documents/render.rs b/src/documents/render.rs index b2808c6..349f850 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -16,12 +16,16 @@ use super::{ use crate::{ editor::{code_colors, code_colors_with}, navigation::{Anchor, Sections}, - primitives::{FocusRing as _, Icon, IconName}, - theme::{ActiveTheme, IconSize, Radius, TextSize}, + primitives::FocusRing as _, + theme::{ActiveTheme, Radius, TextSize}, typography::{Latex, failed}, }; +mod blocks; + pub(crate) type OnLink = Rc; +/// Reveals the heading whose source starts at a byte. +pub(crate) type OnHeading = Rc; type DrawCode = Rc, SharedString) -> AnyElement>; /// Markdown drawn as a document; links go to the host. @@ -94,6 +98,9 @@ struct Draw<'a> { base: &'a Option>, /// Text size against the theme's. scale: f32, + /// Lists open around the node drawn. + lists: std::cell::Cell, + on_heading: &'a Option, } impl Draw<'_> { @@ -263,58 +270,7 @@ impl Draw<'_> { .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::List { start, items } => self.list(start, items, cx), Node::Table { aligns, head, rows } => { let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| { div() @@ -370,10 +326,10 @@ impl Draw<'_> { .into_any_element() } Node::Rule => div() + .debug_selector(|| "markdown-rule".into()) .my_2() - .h_0() - .border_t_1() - .border_color(colors.border) + .border_t_2() + .border_color(colors.border_strong) .into_any_element(), Node::Math(tex) => div() .flex() @@ -416,6 +372,9 @@ impl Draw<'_> { .children(self.nodes(body, cx)), ) .into_any_element(), + Node::Alert { kind, body } => self.alert(*kind, body, cx), + Node::Toc(headings) => self.toc(headings, cx), + Node::FrontMatter(text) => self.front_matter(text, cx), } } } @@ -436,6 +395,7 @@ pub(crate) fn draw_nodes( nodes: &[Node], base: &Option>, on_link: &Option, + on_heading: &Option, scale: f32, cx: &App, ) -> Vec { @@ -448,6 +408,8 @@ pub(crate) fn draw_nodes( code: &None, base, scale, + lists: std::cell::Cell::new(0), + on_heading, }; draw.nodes(nodes, cx) } @@ -465,6 +427,8 @@ impl RenderOnce for MarkdownRenderer { code: &self.code, base: &self.base, scale: 1.0, + lists: std::cell::Cell::new(0), + on_heading: &None, }; div() .flex() @@ -475,3 +439,6 @@ impl RenderOnce for MarkdownRenderer { .children(draw.nodes(&nodes, cx)) } } + +#[cfg(test)] +mod tests; diff --git a/src/documents/render/blocks.rs b/src/documents/render/blocks.rs new file mode 100644 index 0000000..b080869 --- /dev/null +++ b/src/documents/render/blocks.rs @@ -0,0 +1,172 @@ +use gpui::{AnyElement, App, FontWeight, IntoElement, ParentElement, Styled, div, prelude::*}; + +use super::Draw; +use crate::{ + documents::tree::{AlertKind, DocumentHeading, Node}, + primitives::{FocusRing as _, Icon, IconName}, + theme::{ActiveTheme, IconSize, Radius, TextSize}, +}; + +impl Draw<'_> { + /// A list; bullets turn with depth, as browsers draw them. + pub(super) fn list( + &self, + start: &Option, + items: &[(Option, Vec)], + cx: &App, + ) -> AnyElement { + let colors = cx.theme().colors.clone(); + let depth = self.lists.get(); + self.lists.set(depth + 1); + let bullet = match depth { + 0 => "•", + 1 => "◦", + _ => "▪", + }; + let list = 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() + .debug_selector(move || format!("markdown-bullet-{depth}")) + .text_color(colors.fg_muted) + .child(bullet) + .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(); + self.lists.set(depth); + list + } + + /// A GitHub alert: a colored rule, icon, title and body. + pub(super) fn alert(&self, kind: AlertKind, body: &[Node], cx: &App) -> AnyElement { + let colors = &cx.theme().colors; + let (tone, icon) = match kind { + AlertKind::Note => (colors.info, IconName::Info), + AlertKind::Tip => (colors.success, IconName::Lightbulb), + // The palette's violet, read at 4.5:1. + AlertKind::Important => (colors.syntax.keyword, IconName::MessageSquareWarning), + AlertKind::Warning => (colors.warning, IconName::TriangleAlert), + AlertKind::Caution => (colors.danger, IconName::OctagonAlert), + }; + div() + .flex() + .flex_col() + .gap_1() + .py_1() + .pl_4() + .border_l_2() + .border_color(tone) + .child( + div() + .flex() + .items_center() + .gap_2() + .font_weight(FontWeight::MEDIUM) + .text_color(tone) + .child(Icon::new(icon).size(IconSize::Sm).color(tone)) + .child(kind.title()), + ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .text_color(colors.fg) + .children(self.nodes(body, cx)), + ) + .into_any_element() + } + + /// `[toc]`: each heading by depth; a press reveals it. + pub(super) fn toc(&self, headings: &[DocumentHeading], cx: &App) -> AnyElement { + let colors = cx.theme().colors.clone(); + let top = headings.iter().map(|heading| heading.depth).min(); + div() + .flex() + .flex_col() + .gap_1() + .children(headings.iter().enumerate().map(|(ix, heading)| { + let entry = div() + .id(self.key("toc")) + .debug_selector(move || format!("markdown-toc-{ix}")) + .child(heading.title.clone()); + let entry = match heading.depth - top.expect("a heading is listed") { + 0 => entry, + 1 => entry.pl_4(), + 2 => entry.pl_8(), + 3 => entry.pl_12(), + 4 => entry.pl_16(), + _ => entry.pl_20(), + }; + let Some(reveal) = self.on_heading.clone() else { + return entry.text_color(colors.fg_muted).into_any_element(); + }; + let at = heading.at; + entry + .text_color(colors.link) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_click(move |_, window, cx| reveal(at, window, cx)) + .into_any_element() + })) + .into_any_element() + } + + /// Front matter as written, inside its box. + pub(super) fn front_matter(&self, text: &str, cx: &App) -> AnyElement { + let theme = cx.theme(); + div() + .p_3() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) + .font_family(theme.mono_family.clone()) + .text_size(theme.text_size(TextSize::Sm) * self.scale) + .text_color(theme.colors.fg_muted) + .child(text.to_string()) + .into_any_element() + } +} diff --git a/src/documents/render/tests.rs b/src/documents/render/tests.rs new file mode 100644 index 0000000..23246f6 --- /dev/null +++ b/src/documents/render/tests.rs @@ -0,0 +1,53 @@ +use gpui::{Context, IntoElement, Render, TestAppContext, Window, div, prelude::*, px}; + +use super::MarkdownRenderer; +use crate::theme::Theme; + +/// `source` drawn in a 400-point column. +struct Page(&'static str); + +impl Render for Page { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .w(px(400.0)) + .child(MarkdownRenderer::new("page", self.0)) + } +} + +#[gpui::test] +fn a_rule_draws_a_line_two_pixels_high(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Page("above\n\n---\n\nbelow\n")); + cx.run_until_parked(); + let rule = cx.debug_bounds("markdown-rule").expect("the rule is drawn"); + assert_eq!(rule.size.height, px(2.0), "{rule:?}"); + assert!(rule.size.width > px(300.0), "{rule:?}"); +} + +#[gpui::test] +fn bullets_turn_with_depth(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Page("- a\n - b\n 1. c\n - d\n")); + cx.run_until_parked(); + for depth in [0, 1, 3] { + let selector = Box::leak(format!("markdown-bullet-{depth}").into_boxed_str()); + assert!(cx.debug_bounds(selector).is_some(), "depth {depth}"); + } + assert!( + cx.debug_bounds("markdown-bullet-2").is_none(), + "an ordered list" + ); +} + +#[gpui::test] +fn alerts_lists_of_headings_and_front_matter_draw(cx: &mut TestAppContext) { + cx.update(Theme::init); + let source = "> [!IMPORTANT]\n> read\n\n[toc]\n\n# One\n\n## Two\n"; + let (_, cx) = cx.add_window_view(|_, _| Page(source)); + cx.run_until_parked(); + let (one, two) = ( + cx.debug_bounds("markdown-toc-0").expect("drawn"), + cx.debug_bounds("markdown-toc-1").expect("drawn"), + ); + assert!(two.top() > one.top(), "{one:?} {two:?}"); +} diff --git a/src/documents/tree/blocks.rs b/src/documents/tree/blocks.rs new file mode 100644 index 0000000..b3b290f --- /dev/null +++ b/src/documents/tree/blocks.rs @@ -0,0 +1,326 @@ +use markdown::mdast; + +use super::{Node, Reader, Run, html, text_block}; +use crate::documents::markdown::span; + +/// A heading anywhere in the file. +#[derive(Clone, Debug, PartialEq)] +pub struct DocumentHeading { + pub depth: u8, + pub title: String, + /// Where its source starts, in bytes. + pub at: usize, +} + +/// A GitHub alert's kind, named by its marker. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AlertKind { + Note, + Tip, + Important, + Warning, + Caution, +} + +impl AlertKind { + /// The title GitHub shows. + pub fn title(self) -> &'static str { + match self { + Self::Note => "Note", + Self::Tip => "Tip", + Self::Important => "Important", + Self::Warning => "Warning", + Self::Caution => "Caution", + } + } + + /// The kind `line` marks, as `[!NOTE]` in any case. + fn marked(line: &str) -> Option { + let name = line.trim().strip_prefix("[!")?.strip_suffix(']')?; + [ + Self::Note, + Self::Tip, + Self::Important, + Self::Warning, + Self::Caution, + ] + .into_iter() + .find(|kind| kind.title().eq_ignore_ascii_case(name)) + } +} + +impl Reader<'_> { + /// A root block; alerts and `[toc]` live only here. + pub(crate) fn top(&self, node: &mdast::Node, out: &mut Vec) { + if let Some(alert) = self.alert(node) { + return out.push(alert); + } + let toc = matches!(node, mdast::Node::Paragraph(_)) + && self.source[span(node)].trim().eq_ignore_ascii_case("[toc]"); + match toc { + true => out.push(Node::Toc(self.headings.clone())), + false => self.block(node, out), + } + } + + /// A quote whose first line marks it, with more after. + fn alert(&self, node: &mdast::Node) -> Option { + let mdast::Node::Blockquote(quote) = node else { + return None; + }; + let [first @ mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else { + return None; + }; + let line = self.source[span(first)].lines().next().unwrap_or_default(); + let kind = AlertKind::marked(line)?; + let after = after_first_line(¶graph.children); + if after.is_empty() && rest.is_empty() { + return None; + } + let mut body = Vec::new(); + if !after.is_empty() { + let kept = mdast::Paragraph { + children: after, + position: paragraph.position.clone(), + }; + self.block(&mdast::Node::Paragraph(kept), &mut body); + } + self.blocks(rest, &mut body); + Some(Node::Alert { kind, body }) + } + + /// An HTML block's text; comments, scripts and styles drop. + pub(super) fn html_block(&self, written: &str, out: &mut Vec) { + let mut run = Run::default(); + let mut rest = written; + while let Some(at) = rest.find('<') { + run.words(&rest[..at]); + rest = &rest[at..]; + if let Some(comment) = rest.strip_prefix("").map_or("", |(_, after)| after); + continue; + } + let opens = + rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch)); + if !opens { + run.words("<"); + rest = &rest[1..]; + continue; + } + // An unclosed tag drops the rest, as HTML5 does. + let Some(end) = html::tag_end(rest) else { + rest = ""; + break; + }; + let (written, after) = rest.split_at(end); + rest = after; + match html::tag(written) { + Some(tag) if !tag.closing && matches!(tag.name.as_str(), "script" | "style") => { + let closing = format!(" run.line(), + // A line's end drops its space. + Some(tag) if tag.closing || tag.name == "br" => { + run.owed = run.owed.filter(|_| tag.name != "br"); + run.html(written); + } + Some(_) => { + run.pay(); + run.html(written); + } + None => {} + } + } + run.words(rest); + text_block(run.cut(), out); + } +} + +impl Run { + /// HTML text: references decoded, white space collapsed. + fn words(&mut self, written: &str) { + let decoded = html::text(written); + let shown = match self.in_code() { + true => decoded, + false => html::emoji(&decoded), + }; + for ch in shown.chars() { + if !html::spacing(ch) { + self.pay(); + self.text.text.push(ch); + } else if self.owed.is_none() && !self.text.text.ends_with('\n') { + self.owed = (!self.text.text.is_empty()).then_some(' '); + } + } + } + + /// A line owed where a block element starts or ends. + fn line(&mut self) { + let text = &self.text.text; + self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some('\n'); + } + + /// Writes the white space owed, before a word or tag. + fn pay(&mut self) { + if let Some(owed) = self.owed.take() { + self.text.text.push(owed); + } + } +} + +/// Inlines past the first line ending. +fn after_first_line(children: &[mdast::Node]) -> Vec { + let mut found = None; + for (at, child) in children.iter().enumerate() { + match child { + mdast::Node::Text(text) => { + if let Some((_, rest)) = text.value.split_once('\n') { + let mut kept = text.clone(); + kept.value = rest.to_string(); + found = Some((at + 1, (!rest.is_empty()).then_some(kept))); + break; + } + } + mdast::Node::Break(_) => { + found = Some((at + 1, None)); + break; + } + _ => {} + } + } + let Some((next, head)) = found else { + return Vec::new(); + }; + let head = head.map(mdast::Node::Text); + head.into_iter() + .chain(children[next..].iter().cloned()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::documents::tree::{Inline, Style, parse}; + + fn alert(source: &str) -> Option<(AlertKind, Vec)> { + match parse(source).into_iter().next() { + Some(Node::Alert { kind, body }) => Some((kind, body)), + _ => None, + } + } + + fn text(nodes: &[Node]) -> Vec { + nodes + .iter() + .map(|node| match node { + Node::Paragraph(line) => line.text.clone(), + Node::Heading(_, line) => format!("# {}", line.text), + other => format!("{other:?}"), + }) + .collect() + } + + #[test] + fn alerts_read_as_github_reads_them() { + let note = alert("> [!NOTE]\n> one").expect("an alert"); + assert_eq!( + (note.0, text(¬e.1)), + (AlertKind::Note, vec!["one".into()]) + ); + let cases = [ + ("> [!tip]\n> two", Some(AlertKind::Tip)), + ("> [!NOTE] \n> trailing", Some(AlertKind::Note)), + ("> [!TIP]\n> leading", Some(AlertKind::Tip)), + ("> [!Warning]\n>\n> after a blank", Some(AlertKind::Warning)), + ("> [!WARNING] same line", None), + ("> [!IMPORTANT]", None), + ("> [!FOO]\n> five", None), + ("> text\n> [!NOTE]", None), + ]; + for (source, kind) in cases { + assert_eq!(alert(source).map(|found| found.0), kind, "{source:?}"); + } + let nested = parse("- > [!CAUTION]\n > in a list"); + assert!(matches!(&nested[0], Node::List { .. }), "{nested:?}"); + let blocks = alert("> [!NOTE]\n> # head\n> - item").expect("an alert"); + assert!(matches!( + &blocks.1[..], + [Node::Heading(1, _), Node::List { .. }] + )); + let styled = alert("> [!CAUTION]\n> a **b**\n> c").expect("an alert"); + let Node::Paragraph(line) = &styled.1[0] else { + panic!("a paragraph, not {:?}", styled.1) + }; + let bold = 2..3; + assert_eq!( + (line.text.as_str(), &line.styles[..]), + ("a b c", &[(bold, Style::Strong)][..]) + ); + } + + #[test] + fn a_toc_paragraph_lists_every_heading() { + let nodes = parse("[toc]\n\n# A\n\n> ## B :smile:\n"); + let Node::Toc(headings) = &nodes[0] else { + panic!("a toc, not {nodes:?}") + }; + let listed: Vec<(u8, &str, usize)> = headings + .iter() + .map(|heading| (heading.depth, heading.title.as_str(), heading.at)) + .collect(); + assert_eq!(listed, [(1, "A", 7), (2, "B 😄", 14)]); + assert!(matches!(&parse(" [TOC] ")[0], Node::Toc(_))); + assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_))); + assert!(matches!(&parse("> [toc]")[0], Node::Quote(_))); + } + + fn html(source: &str) -> Option { + match parse(source).into_iter().next() { + Some(Node::Paragraph(line)) => Some(line), + None => None, + other => panic!("a paragraph, not {other:?}"), + } + } + + #[test] + fn an_html_block_shows_its_text_as_a_browser_does() { + let centered = html( + "
\n Bold and\n :smile: & :smile:\n
", + ) + .expect("text"); + assert_eq!(centered.text, "Bold and 😄 & :smile:"); + let (bold, code) = (0..4, 16..23); + assert_eq!( + centered.styles, + [(bold, Style::Strong), (code, Style::Code)] + ); + let parts = html("

one two

\n

three <b>

").expect("text"); + assert_eq!(parts.text, "one two\nthree "); + assert_eq!( + html("
\na
b
c
").expect("text").text, + "a\nb\nc" + ); + let hidden = "
\n\n\nshown\n
"; + assert_eq!(html(hidden).expect("text").text, "shown"); + assert_eq!(html(""), None); + assert_eq!(html("
\na < b \n{source}"); + parse(&block); + } + let spent = started.elapsed(); + assert!(spent.as_secs() < 2, "took {spent:?}"); + } +} diff --git a/src/documents/tree/html.rs b/src/documents/tree/html.rs index f4d89ab..a969f29 100644 --- a/src/documents/tree/html.rs +++ b/src/documents/tree/html.rs @@ -41,6 +41,63 @@ pub(crate) fn reopens(name: &str) -> bool { ) } +/// Elements a browser lays as blocks: a line each side. +pub(crate) fn breaks(name: &str) -> bool { + matches!( + name, + "address" + | "article" + | "aside" + | "blockquote" + | "center" + | "details" + | "div" + | "dl" + | "dd" + | "dt" + | "figcaption" + | "figure" + | "footer" + | "h1" + | "h2" + | "h3" + | "h4" + | "h5" + | "h6" + | "header" + | "hr" + | "li" + | "main" + | "nav" + | "ol" + | "p" + | "pre" + | "section" + | "summary" + | "table" + | "tr" + | "ul" + ) +} + +/// Bytes through the `>` ending the tag at `html`'s start. +pub(crate) fn tag_end(html: &str) -> Option { + let (mut quote, mut last) = (None, '<'); + for (at, ch) in html.char_indices().skip(1) { + match quote { + Some(open) if ch == open => quote = None, + Some(_) => {} + None if ch == '>' => return Some(at + 1), + None if matches!(ch, '"' | '\'') && last == '=' => quote = Some(ch), + None => {} + } + if !spacing(ch) { + last = ch; + } + } + None +} + /// `html` as one tag; comments and the rest are none. pub(crate) fn tag(html: &str) -> Option { let inner = html.trim().strip_prefix('<')?.strip_suffix('>')?; @@ -94,24 +151,29 @@ fn attributes(mut rest: &str) -> Vec<(String, String)> { (&after[..end], &after[end..]) } }; - out.push((name, decoded(value))); + out.push((name, decoded(value, true))); rest = left; } } /// HTML's whitespace, which is ASCII alone. -fn spacing(ch: char) -> bool { +pub(crate) fn spacing(ch: char) -> bool { matches!(ch, ' ' | '\t' | '\n' | '\r' | '\x0C') } -/// An attribute value with references decoded as HTML does. -fn decoded(text: &str) -> String { +/// Text between tags, references decoded as HTML does. +pub(crate) fn text(written: &str) -> String { + decoded(written, false) +} + +/// References decoded as HTML does, in a value or text. +fn decoded(text: &str, attribute: bool) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(at) = rest.find('&') { out.push_str(&rest[..at]); rest = &rest[at..]; - match attribute_reference(rest) { + match reference(rest, attribute) { Some((value, len)) => { out.push_str(&value); rest = &rest[len..]; @@ -129,8 +191,8 @@ fn decoded(text: &str) -> String { /// Longest reference name HTML knows, in bytes. const LONGEST: usize = 32; -/// A reference at `text`'s start, as HTML reads values. -fn attribute_reference(text: &str) -> Option<(String, usize)> { +/// A reference at `text`'s start, as HTML reads it. +fn reference(text: &str, attribute: bool) -> Option<(String, usize)> { let body = text.strip_prefix('&')?; if let Some(number) = body.strip_prefix('#') { let (digits, radix) = match number.strip_prefix(['x', 'X']) { @@ -160,8 +222,18 @@ fn attribute_reference(text: &str) -> Option<(String, usize)> { { return Some((value, len + 2)); } - let bare = !body[len..].starts_with(['=', ';']); - (bare && legacy(name)).then(|| (decode_named(name, true).expect("a legacy name"), len + 1)) + // Text takes a legacy prefix; a value, the whole name. + let shortest = match attribute { + true if body[len..].starts_with(['=', ';']) => return None, + true => len, + false => 1, + }; + let name = (shortest..=len.min(LONGEST)) + .rev() + .map(|end| &name[..end]) + .find(|name| legacy(name))?; + let value = decode_named(name, true).expect("a legacy name"); + Some((value, name.len() + 1)) } /// A name HTML decodes without its semicolon. @@ -299,7 +371,8 @@ mod tests { fn long_runs_scan_in_linear_time() { let (amps, colons) = ("&".repeat(1 << 18), ":zq".repeat(1 << 16)); let started = web_time::Instant::now(); - assert_eq!(decoded(&s).len(), amps.len()); + assert_eq!(decoded(&s, true).len(), amps.len()); + assert_eq!(text(&s).len(), amps.len()); assert_eq!(emoji(&colons).len(), colons.len()); let spent = started.elapsed(); assert!(spent.as_secs() < 2, "256 KiB took {spent:?}"); @@ -313,6 +386,22 @@ mod tests { assert_eq!(emoji("é :smile: 中"), "é 😄 中"); } + #[test] + fn text_takes_the_longest_legacy_name() { + assert_eq!( + text("¬it; & &x <3 &nope;"), + "¬it; & &x <3 &nope;" + ); + assert_eq!(decoded("¬it;&x", true), "¬it;&x"); + } + + #[test] + fn a_tag_ends_outside_its_quotes() { + assert_eq!(tag_end("y\">z"), Some(14)); + assert_eq!(tag_end("r"), Some(8)); + assert_eq!(tag_end(", }, + /// A GitHub alert: a quote under its kind's title. + Alert { + kind: AlertKind, + body: Vec, + }, + /// `[toc]`: the file's headings. + Toc(Vec), + /// Front matter, its fences cut. + FrontMatter(String), } /// A tree read against its source and its link definitions. @@ -70,6 +82,7 @@ pub(crate) struct Reader<'a> { urls: HashMap, /// Footnotes numbered by their first reference. notes: HashMap, + pub headings: Vec, } /// An open element, as HTML names it. @@ -87,6 +100,8 @@ struct Run { open: Vec, /// Inside an autolink: its text stays as written. verbatim: bool, + /// White space an HTML block owes the next word. + owed: Option, } impl Run { @@ -101,6 +116,11 @@ impl Run { done } + /// Inside HTML ``, where shortcodes stay text. + fn in_code(&self) -> bool { + self.open.iter().any(|opened| opened.name == "code") + } + /// The innermost open link's address. fn link(&self) -> Option { self.open.iter().rev().find_map(|opened| opened.url.clone()) @@ -178,11 +198,10 @@ fn close(text: &mut Inline, opened: &Opened, end: usize) { /// `markdown` as blocks. pub fn parse(markdown: &str) -> Vec { let root = tree(markdown); - let mut out = Vec::new(); - Reader::new(markdown, &root).blocks( - root.children().expect("the root holds the blocks"), - &mut out, - ); + let (reader, mut out) = (Reader::new(markdown, &root), Vec::new()); + for node in root.children().expect("the root holds the blocks") { + reader.top(node, &mut out); + } out } @@ -191,7 +210,7 @@ impl<'a> Reader<'a> { pub(crate) fn new(source: &'a str, root: &mdast::Node) -> Self { let mut nodes = Vec::new(); walk(root, &mut nodes); - let (mut urls, mut notes) = (HashMap::new(), HashMap::new()); + let (mut urls, mut notes, mut headings) = (HashMap::new(), HashMap::new(), Vec::new()); for node in nodes { match node { mdast::Node::Definition(definition) => { @@ -202,14 +221,25 @@ impl<'a> Reader<'a> { let next = notes.len() + 1; notes.entry(note.identifier.clone()).or_insert(next); } + mdast::Node::Heading(heading) => headings.push((heading, span(node).start)), _ => {} } } - Self { + let mut reader = Self { source, urls, notes, - } + headings: Vec::new(), + }; + reader.headings = headings + .into_iter() + .map(|(heading, at)| DocumentHeading { + depth: heading.depth, + title: reader.inline(&heading.children).text, + at, + }) + .collect(); + reader } fn blocks(&self, nodes: &[mdast::Node], out: &mut Vec) { @@ -293,7 +323,10 @@ impl<'a> Reader<'a> { }, body: self.nested(¬e.children), }), - mdast::Node::Html(_) | mdast::Node::Definition(_) => {} + mdast::Node::Html(html) => self.html_block(&html.value, out), + mdast::Node::Yaml(yaml) => out.push(Node::FrontMatter(yaml.value.clone())), + mdast::Node::Toml(toml) => out.push(Node::FrontMatter(toml.value.clone())), + mdast::Node::Definition(_) => {} other => unreachable!("{other:?} is no block outside MDX"), } } @@ -325,8 +358,7 @@ impl<'a> Reader<'a> { let text = &mut run.text; let (children, (name, style), url) = match node { mdast::Node::Text(words) => { - let code = run.open.iter().any(|opened| opened.name == "code"); - let shown = match run.verbatim || code { + let shown = match run.verbatim || run.in_code() { true => words.value.clone(), false => html::emoji(&words.value), }; diff --git a/src/primitives/icon.rs b/src/primitives/icon.rs index 1eb9eeb..b6358fa 100644 --- a/src/primitives/icon.rs +++ b/src/primitives/icon.rs @@ -226,6 +226,7 @@ icons! { Merge => "merge", MessageSquare => "message-square", MessageSquareDiff => "message-square-diff", + MessageSquareWarning => "message-square-warning", MicOff => "mic-off", Mic => "mic", Minimize2 => "minimize-2",