From 0558f0c890a27e7c904f2391f010977cfb5e0c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 15:03:36 -0400 Subject: [PATCH] documents: block checks read as drawn; alert, fence and box edges kept Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/documents/document/block/check.rs | 126 ++++++++++++-------- src/documents/document/block/heading.rs | 12 +- src/documents/document/block/list.rs | 52 +++++--- src/documents/document/block/quote.rs | 33 +++-- src/documents/document/block/split.rs | 7 +- src/documents/document/tests/block_edges.rs | 80 +++++++++++++ 7 files changed, 229 insertions(+), 83 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45b63b5..ba5909b 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; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; 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 plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs 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`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; 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 and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `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 its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker shares its line as `Misread`. Formats and block commands share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. 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; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; 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 plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs 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`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; 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 and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `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 its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written and read through `MarkdownReader`, as the drawing reads it, white space counting only in code and math: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker, read as the alert reader reads it, shares its line as `Misread`; an alert's body block keeps its indent; a fence is told by its source, not its text; a task box taken off escapes the block opener it bares, on the marker's line or below. Formats and block commands share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. 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/src/documents/document/block/check.rs b/src/documents/document/block/check.rs index 2922b08..c4ee43f 100644 --- a/src/documents/document/block/check.rs +++ b/src/documents/document/block/check.rs @@ -5,18 +5,24 @@ use std::ops::Range; use markdown::mdast::Node; use crate::documents::{ + MarkdownReader, Style, document::format::EditRefusal, markdown::{file_tree, span, walk}, }; +const KEPT: u16 = bit(Style::Code) | bit(Style::Math); +const CODE: u16 = 1 << 9; +const MATH: u16 = 1 << 10; +const HTML: u16 = 1 << 11; + /// Refuses a result that reads as something else. pub(super) fn misread(why: &str) -> EditRefusal { log::warn!("block command: {why}"); EditRefusal::Misread } -/// `text` with sorted `edits` written, parsed. -pub(super) fn reread(text: &str, edits: &[(Range, String)]) -> Node { +/// `text` with sorted `edits` written, and its parse. +pub(super) fn reread(text: &str, edits: &[(Range, String)]) -> (String, Node) { let mut out = String::with_capacity(text.len()); let mut at = 0; for (range, with) in edits { @@ -25,59 +31,79 @@ pub(super) fn reread(text: &str, edits: &[(Range, String)]) -> Node { at = range.end; } out.push_str(&text[at..]); - file_tree(&out) + let root = file_tree(&out); + (out, root) } -/// Each shown character under `node`, with its formats. -pub(super) fn styled(node: &Node) -> Vec<(char, u16)> { - let mut out = Vec::new(); - read(node, 0, &mut out); - out +const fn bit(style: Style) -> u16 { + 1 << style as u16 } -fn read(node: &Node, formats: u16, out: &mut Vec<(char, u16)>) { - let (own, value) = match node { - Node::Text(text) => (0, Some(text.value.as_str())), - Node::Strong(_) => (1, None), - Node::Emphasis(_) => (2, None), - Node::Delete(_) => (4, None), - Node::Link(_) | Node::LinkReference(_) => (8, None), - Node::InlineCode(code) => (16, Some(code.value.as_str())), - Node::InlineMath(math) => (32, Some(math.value.as_str())), - Node::Html(html) => (64, Some(html.value.as_str())), - Node::Image(image) => (128, Some(image.alt.as_str())), - Node::ImageReference(image) => (128, Some(image.alt.as_str())), - Node::FootnoteReference(note) => (256, Some(note.identifier.as_str())), - Node::Code(code) => (512, Some(code.value.as_str())), - Node::Math(math) => (1024, Some(math.value.as_str())), - _ => (0, None), - }; - let formats = formats | own; - match value { - Some(value) => out.extend( - (value.chars()) - .filter(|ch| !ch.is_whitespace()) - .map(|ch| (ch, formats)), - ), - None => { - for kid in node.children().map(Vec::as_slice).unwrap_or_default() { - read(kid, formats, out); +/// A parsed document read as the drawing reads it. +pub(super) struct Shown<'a> { + root: &'a Node, + reader: MarkdownReader<'a>, +} + +impl<'a> Shown<'a> { + pub(super) fn new(source: &'a str, root: &'a Node) -> Self { + let reader = MarkdownReader::new(source, root); + Self { root, reader } + } + + /// Each shown character, with its formats. + pub(super) fn all(&self) -> Vec<(char, u16)> { + let mut out = Vec::new(); + self.read(self.root, &mut out); + out + } + + fn read(&self, node: &Node, out: &mut Vec<(char, u16)>) { + let (value, format) = match node { + Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_) => { + return out.extend(self.inline(node)); + } + Node::Code(code) => (code.value.as_str(), CODE), + Node::Math(math) => (math.value.as_str(), MATH), + Node::Html(html) => (html.value.as_str(), HTML), + _ => { + for kid in node.children().map(Vec::as_slice).unwrap_or_default() { + self.read(kid, out); + } + return; + } + }; + out.extend(value.chars().map(|ch| (ch, format))); + } + + /// `node`'s drawn text; white space counts only in code. + pub(super) fn inline(&self, node: &Node) -> Vec<(char, u16)> { + let kids = node.children().map(Vec::as_slice).unwrap_or_default(); + let inline = self.reader.inline(kids); + let mut formats = vec![0u16; inline.text.len()]; + for (range, style) in &inline.styles { + for format in &mut formats[range.clone()] { + *format |= bit(*style); } } + (inline.text.char_indices()) + .map(|(at, ch)| (ch, formats[at])) + .filter(|(ch, format)| !ch.is_whitespace() || format & KEPT != 0) + .collect() + } + + /// The level (0 a paragraph) and characters at `at`. + pub(super) fn at(&self, at: usize) -> Option<(u8, Vec<(char, u16)>)> { + let mut nodes = Vec::new(); + walk(self.root, &mut nodes); + nodes.into_iter().find_map(|node| { + let level = match node { + Node::Paragraph(_) => 0, + Node::Heading(heading) => heading.depth, + _ => return None, + }; + let whole = span(node); + (whole.start <= at && at <= whole.end).then(|| (level, self.inline(node))) + }) } } - -/// The level (0 a paragraph) and characters at `at`. -pub(super) fn block_at(root: &Node, at: usize) -> Option<(u8, Vec<(char, u16)>)> { - let mut nodes = Vec::new(); - walk(root, &mut nodes); - nodes.into_iter().find_map(|node| { - let level = match node { - Node::Paragraph(_) => 0, - Node::Heading(heading) => heading.depth, - _ => return None, - }; - let whole = span(node); - (whole.start <= at && at <= whole.end).then(|| (level, styled(node))) - }) -} diff --git a/src/documents/document/block/heading.rs b/src/documents/document/block/heading.rs index 1e04064..50a6a7e 100644 --- a/src/documents/document/block/heading.rs +++ b/src/documents/document/block/heading.rs @@ -66,9 +66,15 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result level == goal, + // An empty paragraph has no node. + None => goal == 0 && before.inline(node).is_empty(), + }; + let kept = placed && now.all() == before.all(); match kept { true => Ok(change), false => Err(check::misread("a heading change would reread otherwise")), diff --git a/src/documents/document/block/list.rs b/src/documents/document/block/list.rs index 4af31f1..2ac4ae5 100644 --- a/src/documents/document/block/list.rs +++ b/src/documents/document/block/list.rs @@ -2,11 +2,11 @@ use std::ops::Range; -use markdown::mdast::{List, Node}; +use markdown::mdast::{Code, List, Node}; use super::{ BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line, - opener, prefix, refusal, + opener, prefix, refusal, unblocked, }; use crate::documents::{ document::format::{Change, EditRefusal}, @@ -60,7 +60,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result unwrap(here, ix - 1, list), + true => unwrap(here, ix - 1, list)?, false => { let start = if list.ordered { list.start.unwrap_or(1) @@ -125,7 +125,7 @@ fn continued<'a>( } /// Every item's marker off, its lines out one level. -fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range, String)> { +fn unwrap(here: &Here, ix: usize, list: &List) -> Result, String)>, EditRefusal> { let text = here.text; let outer = here.containers(ix); let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text)); @@ -142,16 +142,23 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range, String)> { edits.push((line..line, blank.clone())); } let first = item.children().and_then(|kids| kids.first()).map(span); - match first { + let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some()); + match &first { Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => { edits.push((start..kid.start, String::new())); } - Some(_) => { + Some(kid) => { let next = next_line(text, start).expect("an item's text lies below"); edits.push((line..next, String::new())); + if boxed { + edits.push((unbox(text, kid.start), String::new())); + } } None => edits.push((start..line_end(text, start), String::new())), } + if boxed && let Some(kid) = first { + edits.extend(escaped(text, kid.start)?); + } for (at, cut, past) in continued(text, item, &outer) { edits.push((at..cut, " ".repeat(past))); } @@ -160,7 +167,26 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range, String)> { let next = next_line(text, whole.end).expect("a joining line follows"); edits.push((next..next, blank.clone())); } - edits + Ok(edits) +} + +/// The task box before the text at `kid`. +fn unbox(text: &str, kid: usize) -> Range { + let line = line_start(text, kid); + line + text[line..kid].rfind('[').expect("a task's box")..kid +} + +/// A backslash where text at `at`, unboxed, opens a block. +fn escaped(text: &str, at: usize) -> Result, String)>, EditRefusal> { + let mark = unblocked(&text[at..line_end(text, at)])?; + Ok(mark.map(|mark| (at + mark..at + mark, "\\".to_string()))) +} + +/// Whether `node`, holding `code`, is fenced in the source. +fn fenced(text: &str, node: &Node, code: &Code) -> bool { + code.lang.is_some() + || code.value.is_empty() + || text[span(node)].lines().count() > code.value.lines().count() } /// Whether the line before `line` holds text inside `outer`. @@ -200,9 +226,8 @@ fn retype( edits.push((at..end, marker(kind, number, bullet))); if !task { let kid = span(first.expect("a boxed item has text")).start; - let line = line_start(text, kid); - let mark = line + text[line..kid].rfind('[').expect("a task's box"); - edits.push((mark..kid, String::new())); + edits.push((unbox(text, kid), String::new())); + edits.extend(escaped(text, kid)?); } } else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) { // GFM boxes only a paragraph. @@ -211,13 +236,10 @@ fn retype( edits.push((at..opener(text, item), marker(kind, number, bullet))); } else { // Indented code is written again from its value. - let fence = ["```", "~~~"] - .iter() - .any(|fence| text[body..].starts_with(fence)); let pad = match first { - Some(Node::Code(code)) if !fence => { + Some(kid @ Node::Code(code)) if !fenced(text, kid, code) => { let line = code.value.lines().next().unwrap_or_default(); - 4 + line.len() - line.trim_start().len() + 4 + line.len() - line.trim_start_matches(' ').len() } _ => 0, }; diff --git a/src/documents/document/block/quote.rs b/src/documents/document/block/quote.rs index 6c9741f..1795f00 100644 --- a/src/documents/document/block/quote.rs +++ b/src/documents/document/block/quote.rs @@ -11,7 +11,7 @@ use super::{ use crate::documents::{ alert, document::format::{Change, EditRefusal}, - markdown::span, + markdown::{file_tree, span}, }; pub(super) fn run(here: &Here) -> Result { @@ -72,27 +72,27 @@ fn unwrap(here: &Here, ix: usize) -> Result, String)>, EditRef if let Node::Blockquote(inner) = quote && alert(inner).is_some() { - let head = text[whole.start + 1..line_end(text, line)].trim(); - let name = head - .strip_prefix("[!") - .and_then(|head| head.strip_suffix(']')); - if !name - .is_some_and(|name| !name.is_empty() && name.chars().all(|ch| ch.is_ascii_alphabetic())) - { + if !marker_only(&text[whole.start..line_end(text, line)]) { return Err(check::misread("an alert marker shares its line")); } + // A body block keeps its indent; text sheds it. + let continues = inner + .children + .first() + .is_some_and(|head| span(head).end > line_end(text, line)); // The marker goes with blank lines; an outer opener stays. let (mut cut, mut rest) = (whole.end, whole.end); let mut next = next_line(text, line); while let Some(at) = next.filter(|at| *at < whole.end) { - let body = match mark(text, at, &outer) { + let lead = match mark(text, at, &outer) { Some(mark) => mark.end, None => content(text, at, &outer), }; let body = - body + text[body..].len() - text[body..].trim_start_matches([' ', '\t']).len(); + lead + text[lead..].len() - text[lead..].trim_start_matches([' ', '\t']).len(); if body < line_end(text, at) { - (cut, rest) = (body, next_line(text, at).unwrap_or(text.len())); + cut = if continues { body } else { lead }; + rest = next_line(text, at).unwrap_or(text.len()); break; } next = next_line(text, at); @@ -116,6 +116,17 @@ fn unwrap(here: &Here, ix: usize) -> Result, String)>, EditRef Ok(edits) } +/// Whether `line`, a quote's first, holds only an alert marker. +fn marker_only(line: &str) -> bool { + let root = file_tree(&format!("{line}\n> x")); + let Some(Node::Blockquote(quote)) = root.children().and_then(|kids| kids.first()) else { + return false; + }; + matches!(alert(quote), Some((_, body)) if matches!(&body[..], + [Node::Paragraph(head)] if matches!(&head.children[..], + [Node::Text(text)] if text.value == "x"))) +} + /// `line`'s `>` and one space after, inside `outer`. fn mark(text: &str, line: usize, outer: &[&Node]) -> Option> { let at = inside(text, line, outer)?; diff --git a/src/documents/document/block/split.rs b/src/documents/document/block/split.rs index 235d70d..4b16962 100644 --- a/src/documents/document/block/split.rs +++ b/src/documents/document/block/split.rs @@ -195,13 +195,14 @@ pub(super) fn split( } let change = placed(edits, from + shift + caret); let edits = &change.0; - let after_root = check::reread(text, edits); + let (written, after_root) = check::reread(text, edits); + let now = check::Shown::new(&written, &after_root); let level = match node { Node::Heading(heading) => heading.depth, _ => 0, }; let piece = |at: usize| { - check::block_at(&after_root, shifted(at, edits)) + now.at(shifted(at, edits)) .and_then(|(got, chars)| (got == level).then_some(chars)) }; let head = if before { @@ -210,7 +211,7 @@ pub(super) fn split( Some(Vec::new()) }; let tail = if follows { piece(to) } else { Some(Vec::new()) }; - let want = check::styled(node); + let want = check::Shown::new(text, here.root).inline(node); let kept = head .zip(tail) .is_some_and(|(head, tail)| match here.selection.is_empty() { diff --git a/src/documents/document/tests/block_edges.rs b/src/documents/document/tests/block_edges.rs index a76162f..96f13be 100644 --- a/src/documents/document/tests/block_edges.rs +++ b/src/documents/document/tests/block_edges.rs @@ -141,3 +141,83 @@ fn delete_keeps_neighbors_apart_past_gone_openers() { assert_eq!(out, "p\n\nq"); assert_eq!(kinds(&out), ["p", "p"]); } + +fn inline_codes(text: &str) -> Vec { + let mut out = Vec::new(); + nodes(text, &mut |node| { + if let Node::InlineCode(code) = node { + out.push(code.value.clone()); + } + }); + out +} + +#[test] +fn a_heading_join_keeps_white_space_in_code() { + let source = "a `x\n y`‸\n==="; + match ran(source, BlockCommand::Heading(3)) { + Err(EditRefusal::Misread) => {} + Ok(out) => assert_eq!(inline_codes(&plain(&out)), inline_codes(&plain(source))), + other => panic!("{other:?}"), + } +} + +#[test] +fn a_split_that_draws_otherwise_refuses() { + for (source, command) in [ + ("ab‸cd", BlockCommand::Rule), + ("a‸\nb", BlockCommand::Heading(1)), + ("x:smile: ‸:smile:", BlockCommand::Rule), + ] { + assert_eq!(ran(source, command), Err(EditRefusal::Misread), "{source}"); + } +} + +#[test] +fn an_alert_body_block_keeps_its_indent() { + let out = plain(&ok("> [!NOTE]\n>\n> co‸de", BlockCommand::Quote)); + assert_eq!( + (kinds(&out), codes(&out)), + (vec!["code"], vec!["code".to_string()]) + ); + let out = plain(&ok( + "> [!NOTE]\n>\n> ```\n> co‸de\n> ```", + BlockCommand::Quote, + )); + assert_eq!( + (kinds(&out), codes(&out)), + (vec!["code"], vec!["code".to_string()]) + ); +} + +#[test] +fn retyping_reads_a_fence_from_its_source() { + let out = plain(&ok("- ```lang‸", BlockCommand::OrderedList)); + assert_eq!(codes(&out), vec!["```lang".to_string()]); + let out = plain(&ok("- \u{a0}code‸", BlockCommand::OrderedList)); + assert_eq!(codes(&out), vec!["\u{a0}code".to_string()]); +} + +#[test] +fn a_removed_box_leaves_text() { + assert_eq!( + plain(&ok("- [x] ---‸", BlockCommand::BulletList)), + "- \\---" + ); + assert_eq!( + plain(&ok("- [x] # h‸", BlockCommand::BulletList)), + "- \\# h" + ); + assert_eq!(plain(&ok("-\n [x] a‸", BlockCommand::TaskList)), "a"); + assert_eq!(plain(&ok("- [x] # h‸", BlockCommand::TaskList)), "\\# h"); +} + +#[test] +fn an_empty_heading_becomes_an_empty_paragraph() { + assert!(ran("# ‸", BlockCommand::Paragraph).is_ok()); +} + +#[test] +fn an_escaped_alert_marker_on_its_own_line_unwraps() { + assert_eq!(plain(&ok("> \\[!NOTE]\n> a‸", BlockCommand::Quote)), "a"); +}