From b37031b00b5e68a7bbb14ce9f83abfbead6eb02f 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 14:08:40 -0400 Subject: [PATCH] documents: block commands keep text, tasks and list items whole Round-1 fixes for R4.7: a caret in heading marks splits at the text; a split closes and reopens bold, italic and strike, refusing inside links, code, math and HTML; a task's first text takes no block; retype keeps task boxes and code indent and takes siblings past the list; an alert in an item keeps the item's opener; paragraph-before moves a holder's first text below its opener; delete keeps neighbors from joining; a heading's text keeps closing hashes and block openers escaped; a heading level is idempotent, as Typora's menu calls it. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/documents/document/block.rs | 8 ++ src/documents/document/block/heading.rs | 57 ++++++-- src/documents/document/block/insert.rs | 127 ++++------------- src/documents/document/block/list.rs | 66 +++++---- src/documents/document/block/quote.rs | 43 ++++-- src/documents/document/block/split.rs | 176 ++++++++++++++++++++++++ src/documents/document/format.rs | 2 +- src/documents/document/tests/block.rs | 110 ++++++++++++++- 9 files changed, 438 insertions(+), 153 deletions(-) create mode 100644 src/documents/document/block/split.rs diff --git a/AGENTS.md b/AGENTS.md index 0f807a5..e550d37 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 again makes a paragraph), 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; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder, retype a list of another kind with its lines re-indented, or make each paragraph line an item; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret; front matter goes on top once; empty paragraphs come before or after; delete takes the block, or the holder of a lone paragraph. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. 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. 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.rs b/src/documents/document/block.rs index dde09bf..ab4ab40 100644 --- a/src/documents/document/block.rs +++ b/src/documents/document/block.rs @@ -4,6 +4,7 @@ mod heading; mod insert; mod list; mod quote; +mod split; use std::ops::Range; @@ -189,6 +190,13 @@ pub(super) fn container(node: &Node) -> bool { ) } +/// Whether `node` is task item `holder`'s first text. +pub(super) fn task_head(holder: &Node, node: &Node) -> bool { + matches!(holder, Node::ListItem(item) + if item.checked.is_some() + && item.children.first().is_some_and(|kid| std::ptr::eq(kid, node))) +} + /// Whether the path goes on below `node`. fn holds(node: &Node) -> bool { container(node) || matches!(node, Node::Root(_) | Node::List(_)) diff --git a/src/documents/document/block/heading.rs b/src/documents/document/block/heading.rs index 237783f..fd7e215 100644 --- a/src/documents/document/block/heading.rs +++ b/src/documents/document/block/heading.rs @@ -10,7 +10,7 @@ use super::{ }; use crate::documents::{ document::format::{Change, EditRefusal}, - markdown::span, + markdown::{file_tree, span}, }; /// The level `command` asks at `level`; 0 is a paragraph. @@ -18,7 +18,7 @@ fn target(level: u8, command: BlockCommand) -> u8 { match command { BlockCommand::Heading(n) => { assert!((1..=6).contains(&n), "a heading level is 1 to 6"); - if n == level { 0 } else { n } + n } BlockCommand::Promote if level == 0 => 6, BlockCommand::Promote => (level - 1).max(1), @@ -61,7 +61,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result return Err(EditRefusal::Outside), (0, _) => to_heading(here, &containers, &whole, hashes), - (_, 0) => to_paragraph(here, node, &containers), + (_, 0) => to_paragraph(here, node, &containers)?, _ => relevel(here, node, &containers, goal), }; Ok(change(edits, caret)) @@ -75,8 +75,38 @@ fn atx(text: &str, node: &Node) -> bool { /// Whether the caret is on a task item's first line. fn task_head(here: &Here, whole: &Range) -> bool { let first = line_start(here.text, whole.start) == line_start(here.text, here.selection.start); - matches!(here.path.iter().rev().nth(1), Some(Node::ListItem(item)) if item.checked.is_some()) - && first + let leaf = here.path[here.path.len() - 1]; + first && super::task_head(here.path[here.path.len() - 2], leaf) +} + +/// Where a `#` run that would close the heading starts. +fn closing(text: &str, at: usize) -> Option { + let line = text[at..line_end(text, at)].trim_end_matches([' ', '\t']); + let kept = line.trim_end_matches('#'); + (kept.len() < line.len() && kept.ends_with([' ', '\t'])).then(|| at + kept.len()) +} + +/// The byte to escape if `at`'s line would open a block. +fn opener(text: &str, at: usize) -> Result, EditRefusal> { + let line = &text[at..line_end(text, at)]; + let prose = |line: &str| { + let root = file_tree(line); + matches!( + root.children().and_then(|kids| kids.first()), + Some(Node::Paragraph(_)) + ) + }; + if prose(line) { + return Ok(None); + } + let mark = line.find(|ch: char| ch.is_ascii_punctuation()); + match mark.filter(|mark| prose(&format!("{}\\{}", &line[..*mark], &line[*mark..]))) { + Some(mark) => Ok(Some(at + mark)), + None => { + log::warn!("block command: a heading's text stays a block"); + Err(EditRefusal::Outside) + } + } } /// The caret's line led by `hashes`; lazy lines kept in. @@ -105,6 +135,9 @@ fn to_heading( }; if line == first { edits.push((at..at, format!("{lazy}{hashes}"))); + if let Some(run) = closing(text, at) { + edits.push((run..run, "\\".to_string())); + } } else if !lazy.is_empty() { edits.push((at..at, lazy.to_string())); } @@ -117,7 +150,11 @@ fn to_heading( } /// A heading's marks off; a blank line where text joins. -fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range, String)> { +fn to_paragraph( + here: &Here, + node: &Node, + containers: &[&Node], +) -> Result, String)>, EditRefusal> { let text = here.text; let whole = span(node); let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text)); @@ -130,7 +167,11 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range match (kids.first(), kids.last()) { (Some(first), Some(last)) => { - edits.push((whole.start..span(first).start, String::new())); + let start = span(first).start; + edits.push((whole.start..start, String::new())); + if let Some(mark) = opener(text, start)? { + edits.push((mark..mark, "\\".to_string())); + } edits.push((span(last).end..whole.end, String::new())); } _ => edits.push((whole.clone(), String::new())), @@ -146,7 +187,7 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range usize { - let at = inside(text, line, containers).unwrap_or(line); - at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len() -} - /// Whether `line` holds text past `containers`' prefix. fn text_on(text: &str, line: usize, containers: &[&Node]) -> bool { let at = content(text, line, containers); at < line_end(text, line) } -/// Back from `at` over blanks and prefixes, to `floor`. -fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize { - loop { - let line = line_start(text, at).max(floor); - at = line + text[line..at].trim_end_matches([' ', '\t']).len(); - if line == floor || at > content(text, line, containers) { - return at; - } - at = line_end(text, prev_line(text, line).expect("a line above the floor")); - } -} - -/// On from `at` over blanks and prefixes, to `ceil`. -fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize { - loop { - let end = line_end(text, at).min(ceil); - at = end - text[at..end].trim_start_matches([' ', '\t']).len(); - if at < end { - return at; - } - match next_line(text, at) { - Some(next) if next < ceil => at = content(text, next, containers), - _ => return ceil, - } - } -} - /// The selection's text, line by line, prefixes cut. fn picked(here: &Here, containers: &[&Node]) -> Vec { let text = here.text; @@ -165,21 +134,6 @@ fn splits(text: &str, node: &Node) -> bool { } } -/// Where the new block's text goes, ending the edit. -struct Room<'a> { - nl: &'static str, - lead: String, - blank: String, - containers: Vec<&'a Node>, -} - -impl Room<'_> { - /// A blank line, then a new line's prefix. - fn gap(&self) -> String { - format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead) - } -} - pub(super) fn run(here: &Here, command: BlockCommand) -> Result { let text = here.text; let leaf = here.leaf(); @@ -199,7 +153,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result span(node).end { return Err(EditRefusal::Across); } - Ok(split(here, node, &room, &block, inner)) + split(here, node, &room, &block, inner) } Some(node) if !here.selection.is_empty() => Err(refusal(node)), Some(node) => { @@ -251,60 +205,6 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result) -> Change { - let text = here.text; - let whole = span(node); - let kids = node.children().map(Vec::as_slice).unwrap_or_default(); - let (marks, body) = match node { - Node::Heading(heading) => ( - "#".repeat(usize::from(heading.depth)) + " ", - kids.first().map_or(whole.end, |kid| span(kid).start) - ..kids.last().map_or(whole.end, |kid| span(kid).end), - ), - _ => (String::new(), whole.clone()), - }; - let containers = &room.containers; - let mut from = back(text, here.selection.start, body.start, containers); - let after = forth(text, here.selection.end, body.end, containers); - let before = from > body.start; - let follows = after < body.end; - if !before { - from = whole.start; - } - let to = if follows { after } else { whole.end }; - let gap = room.gap(); - let mut written = String::new(); - if before { - written.push_str(&gap); - } - let start = written.len(); - written.push_str(block); - let caret = match inner { - Some(inner) => start + inner, - None => { - written.push_str(&gap); - written.len() + if follows { marks.len() } else { 0 } - } - }; - if follows { - if inner.is_some() { - written.push_str(&gap); - } - written.push_str(&marks); - } - let mut edits = Vec::new(); - let mut shift = 0; - if !before && here.joins_before(&whole, None) { - let line = line_start(text, whole.start); - let blank = format!("{}{}", room.blank, room.nl); - shift = blank.len(); - edits.push((line..line, blank)); - } - edits.push((from..to, written)); - placed(edits, from + shift + caret) -} - /// An empty line before or after the caret's block. pub(super) fn paragraph(here: &Here, before: bool) -> Result { let text = here.text; @@ -319,6 +219,19 @@ pub(super) fn paragraph(here: &Here, before: bool) -> Result Result { if blank_above && end < text.len() && !text_on(text, end, &outer) { end = next_line(text, end).unwrap_or(text.len()); } - Ok(placed(vec![(line..end, String::new())], line)) + // Neighbors that would join keep a blank line between. + let joins = here.joins_before(&whole, None) && here.joins_after(&whole); + let with = match joins { + true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)), + false => String::new(), + }; + Ok(placed(vec![(line..end, with)], line)) } diff --git a/src/documents/document/block/list.rs b/src/documents/document/block/list.rs index be6a930..b7ff718 100644 --- a/src/documents/document/block/list.rs +++ b/src/documents/document/block/list.rs @@ -6,7 +6,7 @@ use markdown::mdast::{List, Node}; use super::{ BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line, - prefix, + opener, prefix, }; use crate::documents::{ document::format::{Change, EditRefusal}, @@ -54,12 +54,22 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))), }; + // A selection past the list takes every touched sibling. if let Some(ix) = item && let Node::List(list) = path[ix - 1] + && (here.path_at(caret.end).iter()).any(|node| std::ptr::eq(*node, path[ix - 1])) { let edits = match kind(list) == command { true => unwrap(here, ix - 1, list), - false => retype(here, ix - 1, list, command), + false => { + let start = if list.ordered { + list.start.unwrap_or(1) + } else { + 1 + }; + let outer = here.containers(ix - 1); + retype(here.text, path[ix - 1], list, &outer, command, start) + } }; return Ok(change(edits, caret)); } @@ -160,21 +170,18 @@ fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool { }) } -/// Every item's marker as `kind`'s, its lines moved to match. -fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range, String)> { - let text = here.text; - let outer = here.containers(ix); - let start = if list.ordered { - list.start.unwrap_or(1) - } else { - 1 - }; +/// Every item's marker as `kind`'s from `start`, lines moved to match. +fn retype( + text: &str, + node: &Node, + list: &List, + outer: &[&Node], + kind: BlockCommand, + start: u32, +) -> Vec<(Range, String)> { let bullet = match list.ordered { true => '-', - false => text[span(here.path[ix]).start..] - .chars() - .next() - .unwrap_or('-'), + false => text[span(node).start..].chars().next().unwrap_or('-'), }; let mut edits = Vec::new(); for (n, item) in list.children.iter().enumerate() { @@ -183,13 +190,19 @@ fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range let (mark, spaces) = super::marker(text, item); let first = item.children().and_then(|kids| kids.first()).map(span); let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some()); - let end = match first { - Some(kid) if boxed && !text[at..kid.start].contains(['\n', '\r']) => kid.start, - _ => at + mark + spaces, + let inline = first.filter(|kid| !text[at..kid.start].contains(['\n', '\r'])); + // A box stays when the kind keeps boxes. + let (end, written) = match inline { + Some(_) if boxed && kind == BlockCommand::TaskList => { + (at + mark + spaces, format!("{bullet} ")) + } + Some(kid) if boxed => (kid.start, marker(kind, number, bullet)), + // Spaces past the marker's own are the text's. + _ => (opener(text, item), marker(kind, number, bullet)), }; - edits.push((at..end, marker(kind, number, bullet))); + edits.push((at..end, written)); let want = column(text, at) + width(kind, number); - for (from, cut, past) in continued(text, item, &outer) { + for (from, cut, past) in continued(text, item, outer) { let pad = want.saturating_sub(column(text, from)) + past; edits.push((from..cut, " ".repeat(pad))); } @@ -202,7 +215,7 @@ fn wrap( here: &Here, k: usize, blocks: &[&Node], - kind: BlockCommand, + command: BlockCommand, ) -> Vec<(Range, String)> { let text = here.text; let outer = here.containers(k + 1); @@ -210,6 +223,13 @@ fn wrap( let mut edits = Vec::new(); let mut n = 1; for block in blocks { + if let Node::List(list) = block { + if kind(list) != command { + edits.extend(retype(text, block, list, &outer, command, n)); + } + n += list.children.len() as u32; + continue; + } let whole = span(block); let mut line = line_start(text, whole.start); while line <= whole.end { @@ -227,9 +247,9 @@ fn wrap( let mark = match first || matches!(block, Node::Paragraph(_)) { true => { n += 1; - marker(kind, n - 1, '-') + marker(command, n - 1, '-') } - false => " ".repeat(width(kind, n - 1)), + false => " ".repeat(width(command, n - 1)), }; let at = if matches!(block, Node::Paragraph(_)) { content diff --git a/src/documents/document/block/quote.rs b/src/documents/document/block/quote.rs index 423743c..ff6f883 100644 --- a/src/documents/document/block/quote.rs +++ b/src/documents/document/block/quote.rs @@ -4,7 +4,7 @@ use std::ops::Range; use markdown::mdast::Node; -use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix}; +use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix, task_head}; use crate::documents::{ alert, document::format::{Change, EditRefusal}, @@ -32,6 +32,10 @@ pub(super) fn run(here: &Here) -> Result { return Ok(change(vec![(at..at, "> ".to_string())], caret)); } let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?; + // GFM reads a task's first line as text. + if task_head(here.path[k], blocks[0]) { + return Err(EditRefusal::Outside); + } Ok(change(wrap(here, k, &blocks), caret)) } @@ -65,20 +69,21 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range, String)> { if let Node::Blockquote(inner) = quote && alert(inner).is_some() { - // Typora drops an alert's marker with its quote. - let next = next_line(text, line).unwrap_or(text.len()); - edits.push((line..next, String::new())); - line = next; + // The marker goes; an outer opener stays. + let next = next_line(text, line).filter(|next| *next < whole.end); + let (cut, rest) = match next { + Some(next) => match mark(text, next, &outer) { + Some(mark) => (mark.end, next_line(text, next).unwrap_or(text.len())), + None => (next, next), + }, + None => (line_end(text, line), whole.end), + }; + edits.push((whole.start..cut, String::new())); + line = rest; } while line < whole.end { - if let Some(at) = inside(text, line, &outer) { - let end = line_end(text, line); - let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len(); - let mark = at + lead; - if lead <= 3 && text[mark..end].starts_with('>') { - let space = usize::from(text[mark + 1..end].starts_with([' ', '\t'])); - edits.push((mark..mark + 1 + space, String::new())); - } + if let Some(mark) = mark(text, line, &outer) { + edits.push((mark, String::new())); } match next_line(text, line) { Some(next) => line = next, @@ -92,6 +97,18 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range, String)> { edits } +/// `line`'s `>` and one space after, inside `outer`. +fn mark(text: &str, line: usize, outer: &[&Node]) -> Option> { + let at = inside(text, line, outer)?; + let end = line_end(text, line); + let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len(); + let mark = at + lead; + (lead <= 3 && text[mark..end].starts_with('>')).then(|| { + let space = usize::from(text[mark + 1..end].starts_with([' ', '\t'])); + mark..mark + 1 + space + }) +} + /// `> ` before each line of `blocks`, held by `path[k]`. fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range, String)> { let text = here.text; diff --git a/src/documents/document/block/split.rs b/src/documents/document/block/split.rs new file mode 100644 index 0000000..0c769c3 --- /dev/null +++ b/src/documents/document/block/split.rs @@ -0,0 +1,176 @@ +//! A paragraph or heading split at the caret, as Typora's `splitAt`. + +use std::ops::Range; + +use markdown::mdast::Node; + +use super::{Here, inside, line_end, line_start, next_line, placed, prev_line, task_head}; +use crate::documents::{ + document::format::{Change, EditRefusal, marks}, + markdown::span, +}; + +/// Where the new block's text goes, ending the edit. +pub(super) struct Room<'a> { + pub(super) nl: &'static str, + pub(super) lead: String, + pub(super) blank: String, + pub(super) containers: Vec<&'a Node>, +} + +impl Room<'_> { + /// A blank line, then a new line's prefix. + pub(super) fn gap(&self) -> String { + format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead) + } +} + +/// Where `line`'s text starts inside `containers`; a lazy line's own. +pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize { + let at = inside(text, line, containers).unwrap_or(line); + at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len() +} + +/// Back from `at` over blanks and prefixes, to `floor`. +fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize { + loop { + let line = line_start(text, at).max(floor); + at = line + text[line..at].trim_end_matches([' ', '\t']).len(); + if line == floor || at > content(text, line, containers) { + return at; + } + at = line_end(text, prev_line(text, line).expect("a line above the floor")); + } +} + +/// On from `at` over blanks and prefixes, to `ceil`. +fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize { + loop { + let end = line_end(text, at).min(ceil); + at = end - text[at..end].trim_start_matches([' ', '\t']).len(); + if at < end { + return at; + } + match next_line(text, at) { + Some(next) if next < ceil => at = content(text, next, containers), + _ => return ceil, + } + } +} + +/// Inline nodes with no text to cut. +fn sealed(node: &Node) -> bool { + matches!( + node, + Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_) + ) +} + +/// `at` off inline edges; the marks to close and to reopen. +fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> { + let text = here.text; + let marked: Vec<(&Node, [Range; 2])> = (here.nodes.iter()) + .filter_map(|node| Some((*node, marks(node, text)?))) + .collect(); + while let Some([open, _]) = (marked.iter()) + .map(|(_, holes)| holes) + .find(|[open, _]| open.start < at && open.end == at) + { + at = open.start; + } + while let Some([_, close]) = (marked.iter()) + .map(|(_, holes)| holes) + .find(|[_, close]| close.start == at && at < close.end) + { + at = close.end; + } + let strictly = |range: Range| range.start < at && at < range.end; + let mut cut: Vec<&(&Node, [Range; 2])> = (marked.iter()) + .filter(|(node, _)| strictly(span(node))) + .collect(); + let paired = + |node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_)); + let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node))) + || (cut.iter()).any(|(node, [open, close])| { + !paired(node) || strictly(open.clone()) || strictly(close.clone()) + }); + if unsafe_cut { + return Err(EditRefusal::Across); + } + cut.sort_by_key(|(node, _)| span(node).start); + let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]); + let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]); + Ok((at, close.collect(), open.collect())) +} + +/// `block` put where the selection splits `node`. +pub(super) fn split( + here: &Here, + node: &Node, + room: &Room, + block: &str, + inner: Option, +) -> Result { + let text = here.text; + let whole = span(node); + let kids = node.children().map(Vec::as_slice).unwrap_or_default(); + let (marks, body) = match node { + Node::Heading(heading) => ( + "#".repeat(usize::from(heading.depth)) + " ", + kids.first().map_or(whole.end, |kid| span(kid).start) + ..kids.last().map_or(whole.end, |kid| span(kid).end), + ), + _ => (String::new(), whole.clone()), + }; + // A caret in heading marks takes the text's edge. + let s = here.selection.start.clamp(body.start, body.end); + let e = here.selection.end.clamp(body.start, body.end); + let (s, close, _) = seam(here, s)?; + let (e, _, open) = seam(here, e)?; + let containers = &room.containers; + let mut from = back(text, s, body.start, containers); + let after = forth(text, e, body.end, containers); + let before = from > body.start; + let follows = after < body.end; + // GFM reads a task's first line as text. + if !before && task_head(here.path[here.path.len() - 2], node) { + return Err(EditRefusal::Outside); + } + if !before { + from = whole.start; + } + let to = if follows { after } else { whole.end }; + let gap = room.gap(); + let mut written = String::new(); + if before { + written.push_str(&close); + written.push_str(&gap); + } + let start = written.len(); + written.push_str(block); + let lead = if follows { marks.len() + open.len() } else { 0 }; + let caret = match inner { + Some(inner) => start + inner, + None => { + written.push_str(&gap); + written.len() + lead + } + }; + if follows { + if inner.is_some() { + written.push_str(&gap); + } + written.push_str(&marks); + written.push_str(&open); + } + let mut edits = Vec::new(); + let mut shift = 0; + if !before && here.joins_before(&whole, None) { + let line = line_start(text, whole.start); + let blank = format!("{}{}", room.blank, room.nl); + shift = blank.len(); + edits.push((line..line, blank)); + } + edits.push((from..to, written)); + Ok(placed(edits, from + shift + caret)) +} diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index 8add954..932ff2c 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -97,7 +97,7 @@ fn block(node: &Node) -> bool { } /// A styled node's two marks, kept text between. -fn marks(node: &Node, text: &str) -> Option<[Range; 2]> { +pub(super) fn marks(node: &Node, text: &str) -> Option<[Range; 2]> { let whole = span(node); let inner = match node { Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node), diff --git a/src/documents/document/tests/block.rs b/src/documents/document/tests/block.rs index 82b2bd9..952b24c 100644 --- a/src/documents/document/tests/block.rs +++ b/src/documents/document/tests/block.rs @@ -62,10 +62,10 @@ fn kinds(text: &str) -> Vec<&'static str> { } #[test] -fn headings_set_and_toggle_as_typora() { +fn headings_set_as_typoras_menu() { use BlockCommand::*; assert_eq!(ok("a‸b", Heading(2)), "## a‸b"); - assert_eq!(ok("## a‸b", Heading(2)), "a‸b"); + assert_eq!(ok("## a‸b", Heading(2)), "## a‸b"); assert_eq!(ok("## a‸b", Heading(3)), "### a‸b"); assert_eq!(ok("a‸", Promote), "###### a‸"); assert_eq!(ok("# a‸", Promote), "# a‸"); @@ -189,7 +189,12 @@ fn empty_paragraphs_come_before_and_after() { assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb"); assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb"); assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h"); - assert_eq!(ok("- a‸", ParagraphBefore), "- ‸\n\n a"); + let out = ok("- a‸", ParagraphBefore); + assert_eq!(out, "- ‸\n a"); + assert_eq!(kinds(&out), ["list"]); + let out = ok("x[^1]\n\n[^1]: a‸", ParagraphBefore); + assert_eq!(out, "x[^1]\n\n[^1]: ‸\n a"); + assert_eq!(kinds(&out), ["p", "note"]); } #[test] @@ -268,3 +273,102 @@ fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) { ); document.read_with(cx, |document, _| assert!(document.open.is_none())); } + +/// The one list's items, each as its blocks' kinds. +fn items(text: &str) -> Vec> { + let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect(); + let root = file_tree(&text); + let Some(Node::List(list)) = root.children().and_then(|kids| kids.first()) else { + panic!("a list first"); + }; + (list.children.iter()) + .map(|item| { + (item + .children() + .map(Vec::as_slice) + .unwrap_or_default() + .iter()) + .map(|kid| match kid { + Node::Paragraph(_) => "p", + Node::Code(_) => "code", + _ => "other", + }) + .collect() + }) + .collect() +} + +#[test] +fn a_caret_in_heading_marks_splits_at_the_text() { + use BlockCommand::Rule; + assert_eq!(ok("‸# alpha", Rule), "---\n\n# ‸alpha"); + let out = ok("# alpha #‸##", Rule); + assert_eq!(kinds(&out), ["h", "rule"]); +} + +#[test] +fn a_task_list_keeps_checked_boxes() { + assert_eq!( + ok("- a\n- [x] b‸", BlockCommand::TaskList), + "- [ ] a\n- [x] b‸" + ); +} + +#[test] +fn an_alert_in_an_item_keeps_the_item() { + let out = ok("- > [!NOTE]\n > a‸\n- b", BlockCommand::Quote); + assert_eq!(out, "- a‸\n- b"); + assert_eq!(items(&out), [vec!["p"], vec!["p"]]); +} + +#[test] +fn a_split_closes_and_reopens_inline_marks() { + use BlockCommand::Rule; + let out = ok("**ab‸cd**", Rule); + assert_eq!(out, "**ab**\n\n---\n\n**‸cd**"); + assert_eq!(kinds(&out), ["p", "rule", "p"]); + assert_eq!(ran("[a‸b](u)", Rule), Err(EditRefusal::Across)); + assert_eq!(ran("`a‸b`", Rule), Err(EditRefusal::Across)); +} + +#[test] +fn a_retyped_item_keeps_its_code_indent() { + let out = ok("- code‸", BlockCommand::OrderedList); + assert_eq!(out, "1. code‸"); + assert_eq!(items(&out), [vec!["code"]]); +} + +#[test] +fn deleting_keeps_neighbors_apart() { + let out = ok("p\n# h‸\nq", BlockCommand::Delete); + assert_eq!(out, "p\n‸\nq"); + assert_eq!(kinds(&out), ["p", "p"]); +} + +#[test] +fn heading_marks_keep_their_text() { + use BlockCommand::*; + let out = ok("alpha #‸", Heading(1)); + assert_eq!(out, "# alpha \\#‸"); + assert_eq!(kinds(&out), ["h"]); + let out = ok("# - alpha‸", Paragraph); + assert_eq!(out, "\\- alpha‸"); + assert_eq!(kinds(&out), ["p"]); + assert_eq!(kinds(&ok("# 1. a‸", Paragraph)), ["p"]); +} + +#[test] +fn a_tasks_first_text_takes_no_block() { + use BlockCommand::*; + assert_eq!(ran("- [ ] a‸", Quote), Err(EditRefusal::Outside)); + assert_eq!(ran("- [ ] ‸ab", Rule), Err(EditRefusal::Outside)); + assert_eq!(ran("- [ ] ‸ab", CodeBlock), Err(EditRefusal::Outside)); + assert_eq!(ran("- [ ] a‸", ParagraphBefore), Err(EditRefusal::Outside)); +} + +#[test] +fn a_selection_past_a_list_takes_its_siblings() { + let out = ok("- ⟨a\n\nb⟩", BlockCommand::OrderedList); + assert_eq!(out, "1. ⟨a\n\n2. b⟩"); + assert_eq!(kinds(&out), ["list"]); +}