From 58e212d4ec77adb4fea138f5294081d4dbd58091 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 13:44:42 -0400 Subject: [PATCH] documents: block commands as Typora's Paragraph menu MarkdownDocument::block runs a BlockCommand at the caret: heading levels, paragraph, promote and demote; quote and list toggles; rule, code, math, toc, footnote, link definition and front matter inserts; empty paragraphs before and after; delete. Formats and block commands share one runner off the UI thread; FormatRefusal is now EditRefusal. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/documents/document/apart.rs | 165 ++++++++++ src/documents/document/block.rs | 411 ++++++++++++++++++++++++ src/documents/document/block/heading.rs | 190 +++++++++++ src/documents/document/block/insert.rs | 391 ++++++++++++++++++++++ src/documents/document/block/list.rs | 248 ++++++++++++++ src/documents/document/block/quote.rs | 119 +++++++ src/documents/document/format.rs | 45 +-- src/documents/document/format/apply.rs | 138 -------- src/documents/document/format/clear.rs | 6 +- src/documents/document/mod.rs | 11 +- src/documents/document/tests/block.rs | 270 ++++++++++++++++ src/documents/document/tests/format.rs | 35 +- src/documents/document/tests/mod.rs | 1 + src/documents/mod.rs | 5 +- 15 files changed, 1849 insertions(+), 188 deletions(-) create mode 100644 src/documents/document/apart.rs create mode 100644 src/documents/document/block.rs create mode 100644 src/documents/document/block/heading.rs create mode 100644 src/documents/document/block/insert.rs create mode 100644 src/documents/document/block/list.rs create mode 100644 src/documents/document/block/quote.rs delete mode 100644 src/documents/document/format/apply.rs create mode 100644 src/documents/document/tests/block.rs diff --git a/AGENTS.md b/AGENTS.md index 203c2f6..0f807a5 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 (`FormatRefusal`); 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::FormatRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back 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 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. - 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/apart.rs b/src/documents/document/apart.rs new file mode 100644 index 0000000..4a02cf4 --- /dev/null +++ b/src/documents/document/apart.rs @@ -0,0 +1,165 @@ +//! Edits read off the UI thread: formats and block commands. + +use std::ops::Range; + +use gpui::{AppContext as _, Context}; + +use super::{ + DocumentEvent, LARGEST_READ, MarkdownDocument, + block::{self, BlockCommand}, + format::{Change, EditRefusal, InlineFormat, toggle}, +}; +use crate::documents::{MarkdownMode, modes::joint}; + +/// The text read, the open block's offset, the selection. +type Snapshot = (String, usize, Range); + +impl MarkdownDocument { + /// `format` at the selection, read apart; refusals by event. + pub fn format( + &mut self, + format: InlineFormat, + cx: &mut Context, + ) -> Result<(), EditRefusal> { + self.apart( + format!("format {format:?}"), + move |full, selection| toggle(full, selection, format), + cx, + ) + } + + /// `command` at the caret, read apart; refusals by event. + pub fn block( + &mut self, + command: BlockCommand, + cx: &mut Context, + ) -> Result<(), EditRefusal> { + self.apart( + format!("block {command:?}"), + move |full, selection| block::run(full, selection, command), + cx, + ) + } + + /// Reads `read` off the UI thread, then writes its change. + fn apart( + &mut self, + name: String, + read: impl FnOnce(&str, Range) -> Result + Send + 'static, + cx: &mut Context, + ) -> Result<(), EditRefusal> { + if self.mode == MarkdownMode::Preview { + return Err(EditRefusal::Preview); + } + let (full, offset, selection) = self.snapshot(cx); + if full.len() > LARGEST_READ { + log::info!("markdown document: {name} refused: {} bytes", full.len()); + return Err(EditRefusal::TooLong); + } + if self.editing { + log::info!("markdown document: {name} refused: one runs"); + return Err(EditRefusal::Busy); + } + log::info!("markdown document: {name} reads {} bytes apart", full.len()); + let taken = (full.clone(), offset, selection.clone()); + self.editing = true; + cx.spawn(async move |document, cx| { + let change = cx + .background_spawn(async move { read(&full, selection) }) + .await; + document + .update(cx, |document, cx| { + document.editing = false; + let done = if document.mode == MarkdownMode::Preview { + Err(EditRefusal::Preview) + } else if document.snapshot(cx) != taken { + Err(EditRefusal::Changed) + } else { + change + .and_then(|change| document.apply(&name, change, &taken.0, taken.1, cx)) + }; + if let Err(why) = done { + log::info!("markdown document: {name} refused: {why:?}"); + cx.emit(DocumentEvent::EditRefused(why)); + } + }) + .ok(); + }) + .detach(); + Ok(()) + } + + /// The text an edit reads: the open block's in place. + fn snapshot(&self, cx: &Context) -> Snapshot { + let text = self.editor.read(cx).text(); + let Some(open) = &self.open else { + return (text.to_string(), 0, self.editor.read(cx).primary().range()); + }; + let input = open.input.read(cx); + let (before, after) = match open.append { + true => (format!("{text}{}", joint(text)), ""), + false => ( + text[..open.range.start].to_string(), + &text[open.range.end..], + ), + }; + let picked = input.selection(); + let offset = before.len(); + let full = format!("{before}{}{after}", input.text()); + (full, offset, offset + picked.start..offset + picked.end) + } + + /// Writes `change`: into the open block while it stays inside. + fn apply( + &mut self, + name: &str, + (edits, selected): Change, + full: &str, + offset: usize, + cx: &mut Context, + ) -> Result<(), EditRefusal> { + log::info!("markdown document: {name}"); + if let Some(open) = &self.open { + let end = offset + open.input.read(cx).text().len(); + let grown = (edits.iter().map(|(_, with)| with.len()).sum::() + end) + .checked_sub(edits.iter().map(|(range, _)| range.len()).sum::()); + let inside = edits + .iter() + .all(|(range, _)| offset <= range.start && range.end <= end) + && grown.is_some_and(|grown| offset <= selected.start && selected.end <= grown); + if inside { + let (Some(from), Some(to)) = ( + edits.iter().map(|(range, _)| range.start).min(), + edits.iter().map(|(range, _)| range.end).max(), + ) else { + return Ok(()); + }; + let mut written = String::new(); + let mut at = from; + for (range, with) in &edits { + written.push_str(&full[at..range.start]); + written.push_str(with); + at = range.end; + } + written.push_str(&full[at..to]); + open.input.update(cx, |input, cx| { + input.select(from - offset..to - offset, cx); + input.insert(&written, cx); + input.select(selected.start - offset..selected.end - offset, cx); + }); + return Ok(()); + } + log::info!("markdown document: {name} reaches past the open block"); + self.commit(cx); + if self.editor.read(cx).text() != full { + log::error!("markdown document: {name}: the write-back is not the text read"); + return Err(EditRefusal::Changed); + } + } + self.editor.update(cx, |editor, cx| { + editor.edit(edits, cx); + editor.select(std::iter::once(selected), cx); + }); + Ok(()) + } +} diff --git a/src/documents/document/block.rs b/src/documents/document/block.rs new file mode 100644 index 0000000..dde09bf --- /dev/null +++ b/src/documents/document/block.rs @@ -0,0 +1,411 @@ +//! Block commands at the caret, as Typora's Paragraph menu (R4.7). + +mod heading; +mod insert; +mod list; +mod quote; + +use std::ops::Range; + +use markdown::mdast::Node; + +use super::format::{Change, EditRefusal}; +use crate::documents::markdown::{file_tree, span, walk}; + +/// A command of Typora's Paragraph menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockCommand { + /// Levels 1 to 6. + Heading(u8), + Paragraph, + /// A level up; a paragraph turns level 6. + Promote, + /// A level down; level 6 turns a paragraph. + Demote, + Quote, + OrderedList, + BulletList, + TaskList, + Rule, + CodeBlock, + MathBlock, + Toc, + FrontMatter, + Footnote, + LinkDefinition, + ParagraphBefore, + ParagraphAfter, + Delete, +} + +/// `command` at `selection` in `text`, or why not. +pub(super) fn run( + text: &str, + selection: Range, + command: BlockCommand, +) -> Result { + let root = file_tree(text); + let here = Here::new(text, &root, selection); + match command { + BlockCommand::Heading(_) + | BlockCommand::Paragraph + | BlockCommand::Promote + | BlockCommand::Demote => heading::run(&here, command), + BlockCommand::Quote => quote::run(&here), + BlockCommand::OrderedList | BlockCommand::BulletList | BlockCommand::TaskList => { + list::run(&here, command) + } + BlockCommand::FrontMatter => insert::front_matter(&here), + BlockCommand::ParagraphBefore => insert::paragraph(&here, true), + BlockCommand::ParagraphAfter => insert::paragraph(&here, false), + BlockCommand::Delete => insert::delete(&here), + _ => insert::run(&here, command), + } +} + +/// The caret's place in the parsed text. +pub(super) struct Here<'a> { + pub(super) text: &'a str, + pub(super) selection: Range, + /// Root down to the deepest block at the selection's start. + pub(super) path: Vec<&'a Node>, + pub(super) root: &'a Node, + /// Every node, in document order. + pub(super) nodes: Vec<&'a Node>, +} + +impl<'a> Here<'a> { + fn new(text: &'a str, root: &'a Node, selection: Range) -> Self { + let mut nodes = Vec::new(); + walk(root, &mut nodes); + Here { + text, + path: path(text, root, selection.start), + selection, + root, + nodes, + } + } + + /// The caret's block, unless the path ends on a container. + pub(super) fn leaf(&self) -> Option<&'a Node> { + let last = *self.path.last()?; + (!holds(last)).then_some(last) + } + + /// The containers around `path[..upto]`. + pub(super) fn containers(&self, upto: usize) -> Vec<&'a Node> { + self.path[..upto] + .iter() + .copied() + .filter(|node| container(node)) + .collect() + } + + /// Whether the caret's line holds nothing past its prefix. + pub(super) fn blank(&self) -> bool { + let line = line_start(self.text, self.selection.start); + let containers = self.containers(self.path.len()); + inside(self.text, line, &containers) + .is_some_and(|at| self.text[at..line_end(self.text, line)].trim().is_empty()) + } + + /// Root down to the deepest block at `at`. + pub(super) fn path_at(&self, at: usize) -> Vec<&'a Node> { + path(self.text, self.root, at) + } + + /// The common holder's index and the blocks the selection touches. + pub(super) fn blocks(&self) -> Option<(usize, Vec<&'a Node>)> { + let end = self.path_at(self.selection.end); + let mut k = 0; + while k + 1 < self.path.len() + && k + 1 < end.len() + && std::ptr::eq(self.path[k + 1], end[k + 1]) + { + k += 1; + } + if !holds(self.path[k]) { + return Some((k - 1, vec![self.path[k]])); + } + let (s, e) = (self.selection.start, self.selection.end); + let kids: Vec<&Node> = (self.path[k].children().map(Vec::as_slice)) + .unwrap_or_default() + .iter() + .filter(|kid| span(kid).start <= e && s <= span(kid).end) + .collect(); + (!kids.is_empty()).then_some((k, kids)) + } + + /// Whether a paragraph ending on the line before joins `block`. + pub(super) fn joins_before(&self, block: &Range, gone: Option<&Node>) -> bool { + let line = line_start(self.text, block.start); + let Some(previous) = prev_line(self.text, line) else { + return false; + }; + let ended = (self.nodes.iter()).any(|node| { + matches!(node, Node::Paragraph(_)) && line_start(self.text, span(node).end) == previous + }); + ended && !self.opens(line, gone) + } + + /// Whether the line after `block` would join it as text. + pub(super) fn joins_after(&self, block: &Range) -> bool { + let Some(next) = next_line(self.text, block.end) else { + return false; + }; + let starts = (self.nodes.iter()).any(|node| { + let at = span(node).start; + line_start(self.text, at) == next + && match node { + Node::Paragraph(_) => true, + Node::ThematicBreak(_) => self.text[at..].starts_with('-'), + _ => false, + } + }); + starts && !self.opens(next, None) + } + + /// Whether `line` opens a container that stays and interrupts. + fn opens(&self, line: usize, gone: Option<&Node>) -> bool { + let on = |node: &Node| line_start(self.text, span(node).start) == line; + let left = |node: &Node| gone.is_none_or(|gone| !std::ptr::eq(gone, node)); + (self.nodes.iter()).any(|node| match node { + Node::List(list) if left(node) => list + .children + .iter() + .any(|item| on(item) && (!list.ordered || list.start == Some(1))), + Node::Blockquote(_) => on(node) && left(node), + _ => false, + }) + } +} + +/// Blockquotes, list items and footnote definitions. +pub(super) fn container(node: &Node) -> bool { + matches!( + node, + Node::Blockquote(_) | Node::ListItem(_) | Node::FootnoteDefinition(_) + ) +} + +/// Whether the path goes on below `node`. +fn holds(node: &Node) -> bool { + container(node) || matches!(node, Node::Root(_) | Node::List(_)) +} + +/// Root down to the deepest block at `at`. +fn path<'a>(text: &str, root: &'a Node, at: usize) -> Vec<&'a Node> { + let mut path = vec![root]; + while let Some(node) = path.last().copied().filter(|node| holds(node)) { + let kids = node.children().map(Vec::as_slice).unwrap_or_default(); + let found = (kids.iter()) + .find(|kid| span(kid).contains(&at) || span(kid).end == at) + .or_else(|| { + // A caret before a block on its line is inside. + (kids.iter()).find(|kid| { + let start = span(kid).start; + at < start && !text[at..start].contains(['\n', '\r']) + }) + }); + let Some(kid) = found else { + break; + }; + path.push(kid); + } + path +} + +pub(super) fn line_start(text: &str, at: usize) -> usize { + text[..at].rfind(['\n', '\r']).map_or(0, |at| at + 1) +} + +pub(super) fn line_end(text: &str, at: usize) -> usize { + at + text[at..].find(['\n', '\r']).unwrap_or(text.len() - at) +} + +/// The start of the line after `at`'s. +pub(super) fn next_line(text: &str, at: usize) -> Option { + let end = line_end(text, at); + let rest = &text[end..]; + let ending = if rest.starts_with("\r\n") { 2 } else { 1 }; + (end < text.len()).then_some(end + ending) +} + +/// The start of the line before `at`'s. +pub(super) fn prev_line(text: &str, at: usize) -> Option { + let start = line_start(text, at); + let ending = if text[..start].ends_with("\r\n") { + 2 + } else { + 1 + }; + (start > 0).then(|| line_start(text, start - ending)) +} + +/// The text's own line ending. +pub(super) fn eol(text: &str) -> &'static str { + match (text.contains("\r\n"), text.contains('\r')) { + (true, _) => "\r\n", + (false, true) => "\r", + _ => "\n", + } +} + +/// Columns before `at` on its line, tabs stopping at four. +pub(super) fn column(text: &str, at: usize) -> usize { + advance(0, &text[line_start(text, at)..at]) +} + +fn advance(col: usize, run: &str) -> usize { + run.chars().fold(col, |col, ch| match ch { + '\t' => col / 4 * 4 + 4, + _ => col + 1, + }) +} + +/// A list marker's or footnote label's bytes, then its spaces. +pub(super) fn marker(text: &str, node: &Node) -> (usize, usize) { + let start = span(node).start; + let line = &text[start..line_end(text, start)]; + let mark = match node { + Node::FootnoteDefinition(_) => line.find("]:").map_or(line.len(), |at| at + 2), + _ if line.starts_with(['-', '*', '+']) => 1, + _ => line.find(['.', ')']).map_or(line.len(), |at| at + 1), + }; + let after = &line[mark..]; + let spaces = after.len() - after.trim_start_matches([' ', '\t']).len(); + (mark, spaces) +} + +/// The column a container's continuation lines start at. +pub(super) fn indent(text: &str, node: &Node) -> usize { + let start = span(node).start; + let base = column(text, start); + let (mark, spaces) = marker(text, node); + if matches!(node, Node::FootnoteDefinition(_)) { + return base + 4; + } + let after = advance(base + mark, &text[start + mark..start + mark + spaces]) - base - mark; + let blank = text[start + mark..line_end(text, start)].trim().is_empty(); + match blank || !(1..=4).contains(&after) { + true => base + mark + 1, + false => base + mark + after, + } +} + +/// Where a container's text starts on its first line. +pub(super) fn opener(text: &str, node: &Node) -> usize { + let start = span(node).start; + let (mark, spaces) = marker(text, node); + let width = advance( + column(text, start + mark), + &text[start + mark..start + mark + spaces], + ) - column(text, start + mark); + match (1..=4).contains(&width) { + true => start + mark + spaces, + false => start + mark + spaces.min(1), + } +} + +/// Where `line`'s text starts inside `containers`; none when lazy. +pub(super) fn inside(text: &str, line: usize, containers: &[&Node]) -> Option { + let end = line_end(text, line); + let mut at = line; + for node in containers { + if text[at..end].trim().is_empty() { + return Some(end); + } + if matches!(node, Node::Blockquote(_)) { + let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len(); + if lead > 3 || !text[at + lead..end].starts_with('>') { + return None; + } + at += lead + 1; + at += usize::from(text[at..end].starts_with([' ', '\t'])); + continue; + } + let start = span(node).start; + if line <= start && start < end { + at = opener(text, node); + continue; + } + let want = indent(text, node); + let mut col = column(text, at); + while col < want { + let ch = text[at..end] + .chars() + .next() + .filter(|ch| matches!(ch, ' ' | '\t'))?; + col = advance(col, &text[at..at + 1]); + at += ch.len_utf8(); + } + } + Some(at) +} + +/// The prefix a new line takes inside `containers`. +pub(super) fn prefix(text: &str, containers: &[&Node]) -> String { + let mut out = String::new(); + for (ix, node) in containers.iter().enumerate() { + if matches!(node, Node::Blockquote(_)) { + out.push_str("> "); + continue; + } + let start = span(node).start; + let outer = inside(text, line_start(text, start), &containers[..ix]).unwrap_or(start); + let width = indent(text, node).saturating_sub(column(text, outer.min(start))); + out.push_str(&" ".repeat(width)); + } + out +} + +/// Where byte `at` lies once `edits`, sorted, are written. +pub(super) fn shifted(at: usize, edits: &[(Range, String)]) -> usize { + let mut moved = at; + for (range, with) in edits { + if range.end <= at { + moved = moved + with.len() - range.len(); + } else if range.start < at { + moved -= at - range.start; + } + } + moved +} + +/// `edits`, sorted, and the caret mapped through them. +pub(super) fn change(mut edits: Vec<(Range, String)>, caret: Range) -> Change { + edits.sort_by_key(|(range, _)| range.start); + assert!( + edits + .windows(2) + .all(|pair| pair[0].0.end <= pair[1].0.start), + "block edits overlap" + ); + let caret = shifted(caret.start, &edits)..shifted(caret.end, &edits); + (edits, caret) +} + +/// `edits`, sorted, with the caret already where it lands. +pub(super) fn placed(mut edits: Vec<(Range, String)>, caret: usize) -> Change { + edits.sort_by_key(|(range, _)| range.start); + assert!( + edits + .windows(2) + .all(|pair| pair[0].0.end <= pair[1].0.start), + "block edits overlap" + ); + (edits, caret..caret) +} + +/// Why `node` takes no block command at the caret. +pub(super) fn refusal(node: &Node) -> EditRefusal { + match node { + Node::Code(_) => EditRefusal::Code, + Node::Math(_) => EditRefusal::Math, + Node::Html(_) => EditRefusal::Html, + Node::Table(_) => EditRefusal::Table, + Node::Yaml(_) | Node::Toml(_) => EditRefusal::FrontMatter, + _ => EditRefusal::Outside, + } +} diff --git a/src/documents/document/block/heading.rs b/src/documents/document/block/heading.rs new file mode 100644 index 0000000..237783f --- /dev/null +++ b/src/documents/document/block/heading.rs @@ -0,0 +1,190 @@ +//! Heading levels and paragraphs, as Typora's `changeBlock`. + +use std::ops::Range; + +use markdown::mdast::Node; + +use super::{ + BlockCommand, Here, change, eol, insert, inside, line_end, line_start, next_line, prefix, + refusal, +}; +use crate::documents::{ + document::format::{Change, EditRefusal}, + markdown::span, +}; + +/// The level `command` asks at `level`; 0 is a paragraph. +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 } + } + BlockCommand::Promote if level == 0 => 6, + BlockCommand::Promote => (level - 1).max(1), + BlockCommand::Demote if level == 0 || level == 6 => 0, + BlockCommand::Demote => level + 1, + _ => 0, + } +} + +pub(super) fn run(here: &Here, command: BlockCommand) -> Result { + let text = here.text; + let caret = here.selection.clone(); + let leaf = here.leaf(); + let level = match leaf { + Some(Node::Paragraph(_)) => 0, + Some(Node::Heading(heading)) => heading.depth, + Some(node @ (Node::Math(_) | Node::Html(_) | Node::Table(_))) + if !matches!(command, BlockCommand::Promote | BlockCommand::Demote) => + { + // Typora gives these an empty paragraph after. + log::info!("block command: {:?} takes a paragraph after", refusal(node)); + return insert::paragraph(here, false); + } + Some(node) => return Err(refusal(node)), + None if here.blank() => 0, + None => return Err(EditRefusal::Outside), + }; + let goal = target(level, command); + if goal == level { + return Ok((Vec::new(), caret)); + } + let containers = here.containers(here.path.len()); + let hashes = "#".repeat(usize::from(goal)) + " "; + let Some(node) = leaf else { + let line = line_start(text, caret.start); + let at = inside(text, line, &containers).expect("a blank line holds its prefix"); + return Ok(change(vec![(at..at, hashes)], caret)); + }; + let whole = span(node); + let edits = match (level, goal) { + (0, _) if task_head(here, &whole) => return Err(EditRefusal::Outside), + (0, _) => to_heading(here, &containers, &whole, hashes), + (_, 0) => to_paragraph(here, node, &containers), + _ => relevel(here, node, &containers, goal), + }; + Ok(change(edits, caret)) +} + +/// Whether `node` is an ATX heading, `#` led. +fn atx(text: &str, node: &Node) -> bool { + text[span(node)].trim_start().starts_with('#') +} + +/// 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 +} + +/// The caret's line led by `hashes`; lazy lines kept in. +fn to_heading( + here: &Here, + containers: &[&Node], + whole: &Range, + hashes: String, +) -> Vec<(Range, String)> { + let text = here.text; + let lead = prefix(text, containers); + let first = line_start(text, here.selection.start); + let mut edits = Vec::new(); + let mut line = first; + loop { + let at = match line_start(text, whole.start) == line { + true => Some(whole.start), + false => inside(text, line, containers), + }; + let content = |from: usize| { + from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len() + }; + let (at, lazy) = match at { + Some(at) => (content(at), ""), + None => (content(line), lead.as_str()), + }; + if line == first { + edits.push((at..at, format!("{lazy}{hashes}"))); + } else if !lazy.is_empty() { + edits.push((at..at, lazy.to_string())); + } + match next_line(text, line) { + Some(next) if next < whole.end => line = next, + _ => break, + } + } + edits +} + +/// A heading's marks off; a blank line where text joins. +fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range, String)> { + let text = here.text; + let whole = span(node); + let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text)); + let mut edits = Vec::new(); + if here.joins_before(&whole, None) { + let line = line_start(text, whole.start); + edits.push((line..line, blank.clone())); + } + let kids = node.children().map(Vec::as_slice).unwrap_or_default(); + match atx(text, node) { + true => match (kids.first(), kids.last()) { + (Some(first), Some(last)) => { + edits.push((whole.start..span(first).start, String::new())); + edits.push((span(last).end..whole.end, String::new())); + } + _ => edits.push((whole.clone(), String::new())), + }, + false => { + // The underline line goes with the break before it. + let under = line_start(text, whole.end); + let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len(); + edits.push((under - ending.min(2)..whole.end, String::new())); + } + } + if here.joins_after(&whole) { + let next = next_line(text, whole.end).expect("a joining line follows"); + edits.push((next..next, blank.clone())); + } + edits +} + +/// A heading moved to level `goal`. +fn relevel( + here: &Here, + node: &Node, + containers: &[&Node], + goal: u8, +) -> Vec<(Range, String)> { + let text = here.text; + let whole = span(node); + if atx(text, node) { + let at = whole.start + text[whole.clone()].find('#').expect("an ATX heading has #"); + let run = text[at..].len() - text[at..].trim_start_matches('#').len(); + return vec![(at..at + run, "#".repeat(usize::from(goal)))]; + } + let under = line_start(text, whole.end); + let marks = &text[under..whole.end]; + let at = under + marks.find(['=', '-']).expect("a setext underline"); + let run = marks.trim_end().len() - (at - under); + if goal <= 2 { + let mark = if goal == 1 { "=" } else { "-" }; + return vec![(at..at + run, mark.repeat(run))]; + } + // Levels past 2 are ATX: one line, the underline gone. + let mut edits = vec![( + whole.start..whole.start, + "#".repeat(usize::from(goal)) + " ", + )]; + let mut line = line_start(text, whole.start); + while let Some(next) = next_line(text, line).filter(|next| *next < under) { + let end = line_end(text, line); + let from = inside(text, next, containers).unwrap_or(next); + let from = from + text[from..].len() - text[from..].trim_start_matches([' ', '\t']).len(); + edits.push((end..from, " ".to_string())); + line = next; + } + let ending = text[..under].len() - text[..under].trim_end_matches(['\r', '\n']).len(); + edits.push((under - ending.min(2)..whole.end, String::new())); + edits +} diff --git a/src/documents/document/block/insert.rs b/src/documents/document/block/insert.rs new file mode 100644 index 0000000..b59aece --- /dev/null +++ b/src/documents/document/block/insert.rs @@ -0,0 +1,391 @@ +//! Blocks put in and taken out, as Typora's `insertBlock`. + +use std::collections::HashSet; + +use markdown::mdast::Node; + +use super::{ + BlockCommand, Here, container, eol, inside, line_end, line_start, next_line, placed, prefix, + prev_line, refusal, +}; +use crate::documents::{ + document::format::{Change, EditRefusal}, + markdown::span, +}; + +/// Where `line`'s text starts inside `containers`; a lazy line's own. +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() +} + +/// 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; + let (s, e) = (here.selection.start, here.selection.end); + let mut out = Vec::new(); + let mut at = s; + while at < e { + out.push(text[at..line_end(text, at).min(e)].to_string()); + match next_line(text, at).filter(|next| *next < e) { + Some(next) => at = inside(text, next, containers).unwrap_or(next).min(e), + None => break, + } + } + out +} + +/// The smallest footnote number not taken. +fn free_note(here: &Here) -> usize { + let used: HashSet<&str> = (here.nodes.iter()) + .filter_map(|node| match node { + Node::FootnoteDefinition(note) => Some(note.identifier.as_str()), + Node::FootnoteReference(note) => Some(note.identifier.as_str()), + _ => None, + }) + .collect(); + (1..) + .find(|n: &usize| !used.contains(n.to_string().as_str())) + .expect("a free number") +} + +/// `command`'s lines and the caret's line and byte. +fn lines( + here: &Here, + command: BlockCommand, + body: Vec, +) -> (Vec, Option<(usize, usize)>) { + let fenced = |fence: String, body: Vec| { + let mut lines = vec![fence.clone()]; + lines.extend(if body.is_empty() { + vec![String::new()] + } else { + body + }); + lines.push(fence); + (lines, Some((1, 0))) + }; + match command { + BlockCommand::CodeBlock => { + let longest = (body.iter()) + .flat_map(|line| line.split(|ch| ch != '`')) + .map(str::len) + .max() + .unwrap_or(0); + fenced("`".repeat(longest.max(2) + 1), body) + } + BlockCommand::MathBlock => fenced("$$".to_string(), body), + BlockCommand::Toc => (vec!["[toc]".to_string()], Some((0, 5))), + BlockCommand::Footnote => { + let line = format!("[^{}]: ", free_note(here)); + let end = line.len(); + (vec![line], Some((0, end))) + } + BlockCommand::LinkDefinition => { + let first = body.first().map_or("", |line| line.trim()); + if first.contains(['/', '\\']) { + let address: String = first.chars().take(160).collect(); + return (vec![format!("[]: {address}")], Some((0, 1))); + } + let label: String = (first.split_whitespace().next().unwrap_or_default()) + .chars() + .take(20) + .collect(); + let line = format!("[{label}]: "); + let caret = if label.is_empty() { 1 } else { line.len() }; + (vec![line], Some((0, caret))) + } + _ => (vec!["---".to_string()], None), + } +} + +/// `lines` joined inside `lead`, and the caret's byte in them. +fn joined( + lines: &[String], + caret: Option<(usize, usize)>, + nl: &str, + lead: &str, +) -> (String, Option) { + let mut out = String::new(); + let mut at = None; + for (ix, line) in lines.iter().enumerate() { + if ix > 0 { + out.push_str(nl); + out.push_str(lead); + } + if let Some((row, byte)) = caret + && row == ix + { + at = Some(out.len() + byte); + } + out.push_str(line); + } + (out, at) +} + +/// Whether a paragraph or ATX heading splits at the caret. +fn splits(text: &str, node: &Node) -> bool { + match node { + Node::Paragraph(_) => true, + Node::Heading(_) => text[span(node)].trim_start().starts_with('#'), + _ => false, + } +} + +/// 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(); + let containers = here.containers(here.path.len() - usize::from(leaf.is_some())); + let lead = prefix(text, &containers); + let room = Room { + nl: eol(text), + blank: lead.trim_end().to_string(), + lead, + containers, + }; + let (lines, caret) = lines(here, command, picked(here, &room.containers)); + let (block, inner) = joined(&lines, caret, room.nl, &room.lead); + let rule = inner.is_none(); + match leaf { + Some(node) if splits(text, node) => { + if here.selection.end > span(node).end { + return Err(EditRefusal::Across); + } + Ok(split(here, node, &room, &block, inner)) + } + Some(node) if !here.selection.is_empty() => Err(refusal(node)), + Some(node) => { + let at = line_end(text, span(node).end); + let next = + next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers)); + let gap = room.gap(); + let tail = match rule { + true => gap.clone(), + false => String::new(), + }; + let sep = if next { + format!("{}{}", room.nl, room.blank) + } else { + String::new() + }; + let caret = at + gap.len() + inner.unwrap_or(block.len() + tail.len()); + Ok(placed( + vec![(at..at, format!("{gap}{block}{tail}{sep}"))], + caret, + )) + } + None if here.blank() && here.selection.is_empty() => { + let line = line_start(text, here.selection.start); + let at = inside(text, line, &room.containers).expect("a blank line holds its prefix"); + let end = line_end(text, line); + let prev = + prev_line(text, line).is_some_and(|prev| text_on(text, prev, &room.containers)); + let next = + next_line(text, line).is_some_and(|next| text_on(text, next, &room.containers)); + let head = if prev { + format!("{}{}", room.nl, room.lead) + } else { + String::new() + }; + let tail = if rule { room.gap() } else { String::new() }; + let sep = if next { + format!("{}{}", room.nl, room.blank) + } else { + String::new() + }; + let caret = at + head.len() + inner.unwrap_or(block.len() + tail.len()); + Ok(placed( + vec![(at..end, format!("{head}{block}{tail}{sep}"))], + caret, + )) + } + None => Err(EditRefusal::Outside), + } +} + +/// `block` put where the caret splits `node`. +fn split(here: &Here, node: &Node, room: &Room, block: &str, inner: Option) -> 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; + let leaf = here.leaf().ok_or(EditRefusal::Outside)?; + let whole = span(leaf); + let containers = here.containers(here.path.len() - 1); + let lead = prefix(text, &containers); + let room = Room { + nl: eol(text), + blank: lead.trim_end().to_string(), + lead, + containers, + }; + let gap = room.gap(); + if before { + let mut edits = Vec::new(); + let mut shift = 0; + if 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((whole.start..whole.start, gap)); + return Ok(placed(edits, whole.start + shift)); + } + let at = line_end(text, whole.end); + let next = next_line(text, at).is_some_and(|next| text_on(text, next, &room.containers)); + let sep = if next { + format!("{}{}", room.nl, room.blank) + } else { + String::new() + }; + Ok(placed( + vec![(at..at, format!("{gap}{sep}"))], + at + gap.len(), + )) +} + +/// Front matter at the top, unless there is some. +pub(super) fn front_matter(here: &Here) -> Result { + let first = here.root.children().and_then(|kids| kids.first()); + if matches!(first, Some(Node::Yaml(_) | Node::Toml(_))) { + return Err(EditRefusal::Present); + } + let nl = eol(here.text); + let tail = match here.text.is_empty() || here.text.starts_with(['\n', '\r']) { + true => "", + false => nl, + }; + Ok(placed( + vec![(0..0, format!("---{nl}{nl}---{nl}{tail}"))], + 3 + nl.len(), + )) +} + +/// The caret's block gone; a lone paragraph takes its holder. +pub(super) fn delete(here: &Here) -> Result { + let text = here.text; + let leaf = here.leaf().ok_or(EditRefusal::Outside)?; + let mut ix = here.path.len() - 1; + if matches!(leaf, Node::Paragraph(_)) + && let Some(parent) = here.path.get(ix - 1) + && container(parent) + && parent.children().is_some_and(|kids| kids.len() == 1) + { + ix -= 1; + } + let whole = span(here.path[ix]); + let outer = here.containers(ix); + let line = line_start(text, whole.start); + if !text[line..whole.start] + .chars() + .all(|ch| matches!(ch, ' ' | '\t' | '>')) + { + return Ok(placed(vec![(whole.clone(), String::new())], whole.start)); + } + let mut end = next_line(text, whole.end).unwrap_or(text.len()); + let blank_above = prev_line(text, line).is_none_or(|prev| !text_on(text, prev, &outer)); + 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)) +} diff --git a/src/documents/document/block/list.rs b/src/documents/document/block/list.rs new file mode 100644 index 0000000..be6a930 --- /dev/null +++ b/src/documents/document/block/list.rs @@ -0,0 +1,248 @@ +//! Lists on, off and retyped, as Typora's `toggleIndent`. + +use std::ops::Range; + +use markdown::mdast::{List, Node}; + +use super::{ + BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line, + prefix, +}; +use crate::documents::{ + document::format::{Change, EditRefusal}, + markdown::span, +}; + +fn kind(list: &List) -> BlockCommand { + match (list.ordered, list.children.first()) { + (true, _) => BlockCommand::OrderedList, + (false, Some(Node::ListItem(item))) if item.checked.is_some() => BlockCommand::TaskList, + _ => BlockCommand::BulletList, + } +} + +/// The marker an item takes: number `n` or the bullet. +fn marker(kind: BlockCommand, n: u32, bullet: char) -> String { + match kind { + BlockCommand::OrderedList => format!("{n}. "), + BlockCommand::TaskList => format!("{bullet} [ ] "), + _ => format!("{bullet} "), + } +} + +/// A marker's width for continuation lines; boxes add none. +fn width(kind: BlockCommand, n: u32) -> usize { + match kind { + BlockCommand::OrderedList => format!("{n}. ").len(), + _ => 2, + } +} + +pub(super) fn run(here: &Here, command: BlockCommand) -> Result { + let caret = here.selection.clone(); + let path = &here.path; + let item = match here.leaf() { + Some(leaf) => path.len().checked_sub(2).filter(|ix| match path[*ix] { + Node::ListItem(item) => { + !caret.is_empty() + || item + .children + .first() + .is_some_and(|kid| std::ptr::eq(kid, leaf)) + } + _ => false, + }), + None => Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))), + }; + if let Some(ix) = item + && let Node::List(list) = path[ix - 1] + { + let edits = match kind(list) == command { + true => unwrap(here, ix - 1, list), + false => retype(here, ix - 1, list, command), + }; + return Ok(change(edits, caret)); + } + if here.leaf().is_none() && caret.is_empty() { + if !here.blank() { + return Err(EditRefusal::Outside); + } + let line = line_start(here.text, caret.start); + let at = inside(here.text, line, &here.containers(path.len())) + .expect("a blank line holds its prefix"); + return Ok(change(vec![(at..at, marker(command, 1, '-'))], caret)); + } + let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?; + Ok(change(wrap(here, k, &blocks, command), caret)) +} + +/// Indent from `at` to column `want`: its end, columns past. +fn indented(text: &str, at: usize, want: usize) -> Option<(usize, usize)> { + let end = line_end(text, at); + let mut cut = at; + let mut col = column(text, at); + while col < want { + let ch = text[cut..end] + .chars() + .next() + .filter(|ch| matches!(ch, ' ' | '\t'))?; + col = if ch == '\t' { col / 4 * 4 + 4 } else { col + 1 }; + cut += 1; + } + Some((cut, col - want)) +} + +/// Each non-lazy line of `item` after its first, with indent. +fn continued<'a>( + text: &'a str, + item: &Node, + outer: &'a [&Node], +) -> impl Iterator + 'a { + let whole = span(item); + let want = indent(text, item); + std::iter::successors(next_line(text, whole.start), move |line| { + next_line(text, *line) + }) + .take_while(move |line| *line < whole.end) + .filter_map(move |line| { + let at = inside(text, line, outer)?; + if text[at..line_end(text, line)].trim().is_empty() { + return None; + } + let (cut, past) = indented(text, at, want)?; + Some((at, cut, past)) + }) +} + +/// Every item's marker off, its lines out one level. +fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range, String)> { + let text = here.text; + let outer = here.containers(ix); + let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text)); + let whole = span(here.path[ix]); + let mut edits = Vec::new(); + for (n, item) in list.children.iter().enumerate() { + let start = span(item).start; + let line = line_start(text, start); + let joins = match n { + 0 => here.joins_before(&whole, Some(here.path[ix])), + _ => prev_text(text, line, &outer), + }; + if joins { + edits.push((line..line, blank.clone())); + } + let first = item.children().and_then(|kids| kids.first()).map(span); + match first { + Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => { + edits.push((start..kid.start, String::new())); + } + Some(_) => { + let next = next_line(text, start).expect("an item's text lies below"); + edits.push((line..next, String::new())); + } + None => edits.push((start..line_end(text, start), String::new())), + } + for (at, cut, past) in continued(text, item, &outer) { + edits.push((at..cut, " ".repeat(past))); + } + } + if here.joins_after(&whole) { + let next = next_line(text, whole.end).expect("a joining line follows"); + edits.push((next..next, blank.clone())); + } + edits +} + +/// Whether the line before `line` holds text inside `outer`. +fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool { + super::prev_line(text, line).is_some_and(|prev| { + inside(text, prev, outer).is_none_or(|at| !text[at..line_end(text, prev)].trim().is_empty()) + }) +} + +/// 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 + }; + let bullet = match list.ordered { + true => '-', + false => text[span(here.path[ix]).start..] + .chars() + .next() + .unwrap_or('-'), + }; + let mut edits = Vec::new(); + for (n, item) in list.children.iter().enumerate() { + let number = start + n as u32; + let at = span(item).start; + 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, + }; + edits.push((at..end, marker(kind, number, bullet))); + let want = column(text, at) + width(kind, number); + for (from, cut, past) in continued(text, item, &outer) { + let pad = want.saturating_sub(column(text, from)) + past; + edits.push((from..cut, " ".repeat(pad))); + } + } + edits +} + +/// Each paragraph line, and each other block, made an item. +fn wrap( + here: &Here, + k: usize, + blocks: &[&Node], + kind: BlockCommand, +) -> Vec<(Range, String)> { + let text = here.text; + let outer = here.containers(k + 1); + let lead = prefix(text, &outer); + let mut edits = Vec::new(); + let mut n = 1; + for block in blocks { + let whole = span(block); + let mut line = line_start(text, whole.start); + while line <= whole.end { + let first = line == line_start(text, whole.start); + let (at, lazy) = match first { + true => (Some(whole.start), ""), + false => match inside(text, line, &outer) { + Some(at) => (Some(at), ""), + None => (None, lead.as_str()), + }, + }; + let at = at.unwrap_or(line); + let content = at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len(); + if !text[at..line_end(text, line)].trim().is_empty() { + let mark = match first || matches!(block, Node::Paragraph(_)) { + true => { + n += 1; + marker(kind, n - 1, '-') + } + false => " ".repeat(width(kind, n - 1)), + }; + let at = if matches!(block, Node::Paragraph(_)) { + content + } else { + at + }; + edits.push((at..at, format!("{lazy}{mark}"))); + } + match next_line(text, line) { + Some(next) => line = next, + None => break, + } + } + } + edits +} diff --git a/src/documents/document/block/quote.rs b/src/documents/document/block/quote.rs new file mode 100644 index 0000000..423743c --- /dev/null +++ b/src/documents/document/block/quote.rs @@ -0,0 +1,119 @@ +//! Quotes on and off, as Typora's `toggleIndent`. + +use std::ops::Range; + +use markdown::mdast::Node; + +use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix}; +use crate::documents::{ + alert, + document::format::{Change, EditRefusal}, + markdown::span, +}; + +pub(super) fn run(here: &Here) -> Result { + let caret = here.selection.clone(); + let ix = match here.leaf() { + Some(_) => here.path.len() - 2, + None => here.path.len() - 1, + }; + if let Node::Blockquote(_) = here.path[ix] + && whole(here, ix) + { + return Ok(change(unwrap(here, ix), caret)); + } + if here.leaf().is_none() && here.selection.is_empty() { + if !here.blank() { + return Err(EditRefusal::Outside); + } + let line = line_start(here.text, caret.start); + let at = inside(here.text, line, &here.containers(here.path.len())) + .expect("a blank line holds its prefix"); + return Ok(change(vec![(at..at, "> ".to_string())], caret)); + } + let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?; + Ok(change(wrap(here, k, &blocks), caret)) +} + +/// Whether the selection takes in the whole quote at `ix`. +fn whole(here: &Here, ix: usize) -> bool { + if here.selection.is_empty() { + return true; + } + let quote = here.path[ix]; + let end = here.path_at(here.selection.end); + let kids = quote.children().map(Vec::as_slice).unwrap_or_default(); + let first = here.path.get(ix + 1).copied(); + let last = end.get(ix + 1).copied(); + end.get(ix).is_some_and(|node| std::ptr::eq(*node, quote)) + && first.is_some_and(|first| kids.first().is_some_and(|kid| std::ptr::eq(kid, first))) + && last.is_some_and(|last| kids.last().is_some_and(|kid| std::ptr::eq(kid, last))) +} + +/// One `>` off each line of the quote at `ix`. +fn unwrap(here: &Here, ix: usize) -> Vec<(Range, String)> { + let text = here.text; + let quote = here.path[ix]; + let whole = span(quote); + let outer = here.containers(ix); + let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text)); + let mut edits = Vec::new(); + let mut line = line_start(text, whole.start); + if here.joins_before(&whole, Some(quote)) { + edits.push((line..line, blank.clone())); + } + 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; + } + 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())); + } + } + match next_line(text, line) { + Some(next) => line = next, + None => break, + } + } + if here.joins_after(&whole) { + let next = next_line(text, whole.end).expect("a joining line follows"); + edits.push((next..next, blank.clone())); + } + edits +} + +/// `> ` before each line of `blocks`, held by `path[k]`. +fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range, String)> { + let text = here.text; + let outer = here.containers(k + 1); + let first = span(blocks[0]).start; + let last = span(blocks[blocks.len() - 1]).end; + let mut edits = Vec::new(); + let mut line = line_start(text, first); + while line <= last { + let at = match line == line_start(text, first) { + true => Some(first), + false => inside(text, line, &outer), + }; + // A lazy line stays in its paragraph. + if let Some(at) = at { + let blank = text[at..line_end(text, line)].trim().is_empty(); + edits.push((at..at, if blank { ">" } else { "> " }.to_string())); + } + match next_line(text, line) { + Some(next) => line = next, + None => break, + } + } + edits +} diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index d1d2da6..8add954 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -1,6 +1,5 @@ //! Inline formats toggle at the selection, as Typora's (R4.6). -mod apply; mod clear; use std::ops::Range; @@ -26,9 +25,9 @@ pub enum InlineFormat { Clear, } -/// Why a format did not happen. +/// Why a format or block command did not happen. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum FormatRefusal { +pub enum EditRefusal { /// Preview takes no edits. Preview, Code, @@ -37,13 +36,17 @@ pub enum FormatRefusal { FrontMatter, /// No paragraph, heading or cell at the caret. Outside, + /// A table holds the caret. + Table, + /// Front matter is there already. + Present, /// The selection crosses a block or another mark. Across, /// Past the largest read: source alone. TooLong, - /// A long format still runs. + /// Another edit is still read. Busy, - /// The text changed before a long format finished. + /// The text changed before the edit was read. Changed, } @@ -160,7 +163,7 @@ fn verbatim( nodes: &[&Node], selection: &Range, format: InlineFormat, -) -> Result<(), FormatRefusal> { +) -> Result<(), EditRefusal> { let mut blocks: Vec> = nodes .iter() .filter(|node| block(node)) @@ -180,14 +183,14 @@ fn verbatim( // Clear takes a block's inline code and tags too. let held = format != InlineFormat::Clear && touches(false); let why = match node { - Node::Code(_) if touches(true) => FormatRefusal::Code, - Node::Math(_) if touches(true) => FormatRefusal::Math, - Node::Yaml(_) | Node::Toml(_) if touches(true) => FormatRefusal::FrontMatter, - Node::InlineCode(_) if format != InlineFormat::Code && held => FormatRefusal::Code, - Node::InlineMath(_) if held => FormatRefusal::Math, + Node::Code(_) if touches(true) => EditRefusal::Code, + Node::Math(_) if touches(true) => EditRefusal::Math, + Node::Yaml(_) | Node::Toml(_) if touches(true) => EditRefusal::FrontMatter, + Node::InlineCode(_) if format != InlineFormat::Code && held => EditRefusal::Code, + Node::InlineMath(_) if held => EditRefusal::Math, Node::Html(_) => match holder(&blocks, &whole).is_some() { - true if held => FormatRefusal::Html, - false if touches(true) => FormatRefusal::Html, + true if held => EditRefusal::Html, + false if touches(true) => EditRefusal::Html, _ => continue, }, _ => continue, @@ -233,7 +236,7 @@ pub(super) fn toggle( text: &str, selection: Range, format: InlineFormat, -) -> Result { +) -> Result { toggle_tree(text, &file_tree(text), selection, format) } @@ -243,7 +246,7 @@ pub(super) fn toggle_tree( root: &Node, selection: Range, format: InlineFormat, -) -> Result { +) -> Result { let mut nodes = Vec::new(); walk(root, &mut nodes); verbatim(&nodes, &selection, format)?; @@ -287,8 +290,8 @@ pub(super) fn toggle_tree( if s != e { let touched = (rooms.iter()).any(|range| range.start <= e && s <= range.end); return Err(match touched { - true => FormatRefusal::Across, - false => FormatRefusal::Outside, + true => EditRefusal::Across, + false => EditRefusal::Outside, }); } let line = text[..s] @@ -299,7 +302,7 @@ pub(super) fn toggle_tree( + text[s..].split(['\r', '\n']).next().unwrap_or_default(); return match line.trim().is_empty() { true => Ok(pair(s, format)), - false => Err(FormatRefusal::Outside), + false => Err(EditRefusal::Outside), }; }; if format == InlineFormat::Underline @@ -377,7 +380,7 @@ fn crossed( s: usize, e: usize, format: InlineFormat, -) -> Result<(), FormatRefusal> { +) -> Result<(), EditRefusal> { for node in nodes { let whole = span(node); if matches!(node, Node::Text(_)) || whole.start < within.start || whole.end > within.end { @@ -398,7 +401,7 @@ fn crossed( // A same style cut is taken in whole. let same = merges(format) && styled(format, node); if strictly(s) || strictly(e) || (overlaps && !holds_all && !same) { - return Err(FormatRefusal::Across); + return Err(EditRefusal::Across); } } Ok(()) @@ -412,7 +415,7 @@ fn wrap( mut s: usize, mut e: usize, format: InlineFormat, -) -> Result { +) -> Result { crossed(text, nodes, within, s, e, format)?; if merges(format) { for node in nodes.iter().filter(|node| styled(format, node)) { diff --git a/src/documents/document/format/apply.rs b/src/documents/document/format/apply.rs deleted file mode 100644 index dcc1613..0000000 --- a/src/documents/document/format/apply.rs +++ /dev/null @@ -1,138 +0,0 @@ -//! The document's format command. - -use std::ops::Range; - -use gpui::{AppContext as _, Context}; - -use super::{Change, FormatRefusal, InlineFormat, toggle}; -use crate::documents::{ - MarkdownMode, - document::{DocumentEvent, LARGEST_READ, MarkdownDocument}, - modes::joint, -}; - -/// The text formatted, the open block's offset, the selection. -type Snapshot = (String, usize, Range); - -impl MarkdownDocument { - /// `format` at the selection, read apart; refusals by event. - pub fn format( - &mut self, - format: InlineFormat, - cx: &mut Context, - ) -> Result<(), FormatRefusal> { - if self.mode == MarkdownMode::Preview { - return Err(FormatRefusal::Preview); - } - let (full, offset, selection) = self.snapshot(cx); - if full.len() > LARGEST_READ { - log::info!( - "markdown document: format {format:?} refused: {} bytes", - full.len() - ); - return Err(FormatRefusal::TooLong); - } - if self.formatting { - log::info!("markdown document: format {format:?} refused: one runs"); - return Err(FormatRefusal::Busy); - } - log::info!( - "markdown document: format {format:?} reads {} bytes apart", - full.len() - ); - let taken = (full.clone(), offset, selection.clone()); - self.formatting = true; - cx.spawn(async move |document, cx| { - let change = cx - .background_spawn(async move { toggle(&full, selection, format) }) - .await; - document - .update(cx, |document, cx| { - document.formatting = false; - let done = if document.mode == MarkdownMode::Preview { - Err(FormatRefusal::Preview) - } else if document.snapshot(cx) != taken { - Err(FormatRefusal::Changed) - } else { - change.and_then(|change| { - document.apply(format, change, &taken.0, taken.1, cx) - }) - }; - if let Err(why) = done { - log::info!("markdown document: format {format:?} refused: {why:?}"); - cx.emit(DocumentEvent::FormatRefused(why)); - } - }) - .ok(); - }) - .detach(); - Ok(()) - } - - /// The text a format reads: the open block's in place. - fn snapshot(&self, cx: &Context) -> Snapshot { - let text = self.editor.read(cx).text(); - let Some(open) = &self.open else { - return (text.to_string(), 0, self.editor.read(cx).primary().range()); - }; - let input = open.input.read(cx); - let (before, after) = match open.append { - true => (format!("{text}{}", joint(text)), ""), - false => ( - text[..open.range.start].to_string(), - &text[open.range.end..], - ), - }; - let picked = input.selection(); - let offset = before.len(); - let full = format!("{before}{}{after}", input.text()); - (full, offset, offset + picked.start..offset + picked.end) - } - - /// Writes `change`, into the open block if one is. - fn apply( - &mut self, - format: InlineFormat, - (edits, selected): Change, - full: &str, - offset: usize, - cx: &mut Context, - ) -> Result<(), FormatRefusal> { - log::info!("markdown document: format {format:?}"); - let Some(open) = &self.open else { - self.editor.update(cx, |editor, cx| { - editor.edit(edits, cx); - editor.select(std::iter::once(selected), cx); - }); - return Ok(()); - }; - let end = offset + open.input.read(cx).text().len(); - if !edits - .iter() - .all(|(range, _)| offset <= range.start && range.end <= end) - { - log::warn!("markdown document: format {format:?} reaches past the open block"); - return Err(FormatRefusal::Across); - } - let (Some(from), Some(to)) = ( - edits.iter().map(|(range, _)| range.start).min(), - edits.iter().map(|(range, _)| range.end).max(), - ) else { - return Ok(()); - }; - let mut written = String::new(); - let mut at = from; - for (range, with) in &edits { - written.push_str(&full[at..range.start]); - written.push_str(with); - at = range.end; - } - written.push_str(&full[at..to]); - open.input.update(cx, |input, cx| { - input.select(from - offset..to - offset, cx); - input.insert(&written, cx); - input.select(selected.start - offset..selected.end - offset, cx); - }); - Ok(()) - } -} diff --git a/src/documents/document/format/clear.rs b/src/documents/document/format/clear.rs index 73510ce..02d5e3b 100644 --- a/src/documents/document/format/clear.rs +++ b/src/documents/document/format/clear.rs @@ -4,7 +4,7 @@ use std::ops::Range; use markdown::mdast::Node; -use super::{Change, FormatRefusal, after, block, cut, holder, marks, room}; +use super::{Change, EditRefusal, after, block, cut, holder, marks, room}; use crate::documents::{markdown::span, tree::inside}; /// The backslashes of escapes in a text node's span. @@ -46,14 +46,14 @@ pub(super) fn clear( text: &str, nodes: &[&Node], selection: Range, -) -> Result { +) -> Result { let mut blocks: Vec> = (nodes.iter()) .filter(|node| block(node)) .map(|node| room(node, text)) .filter(|range| range.start <= selection.end && selection.start <= range.end) .collect(); if blocks.is_empty() { - return Err(FormatRefusal::Outside); + return Err(EditRefusal::Outside); } blocks.sort_by_key(|block| block.start); let mut autolinks: Vec> = (nodes.iter()) diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 96b836c..7f5635d 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -1,4 +1,6 @@ +mod apart; mod attach; +mod block; mod edit; mod finding; mod format; @@ -23,7 +25,8 @@ use gpui::{ pub use crate::documents::tree::DocumentHeading; pub use attach::Attachment; -pub use format::{FormatRefusal, InlineFormat}; +pub use block::BlockCommand; +pub use format::{EditRefusal, InlineFormat}; pub use look::DocumentLook; use parse::{Parsed, alike, parsed}; pub use point::{DocumentTarget, TargetBlock}; @@ -59,7 +62,7 @@ pub enum DocumentEvent { /// A secondary press moved the caret onto this. Pointed(DocumentTarget), /// A long format, accepted, did not happen. - FormatRefused(FormatRefusal), + EditRefused(EditRefusal), } /// A top line asked, numbered so late callbacks know theirs. @@ -89,7 +92,7 @@ pub struct MarkdownDocument { /// Text changes counted; a read of an older one lapses. edits: u64, _parsing: Option>, - formatting: bool, + editing: bool, list: ListState, open: Option, /// A top line asked for, until the view holds it. @@ -161,7 +164,7 @@ impl MarkdownDocument { parsed: Arc::default(), edits: 0, _parsing: None, - formatting: false, + editing: false, list: ListState::new(1, ListAlignment::Top, overdraw), open: None, top: None, diff --git a/src/documents/document/tests/block.rs b/src/documents/document/tests/block.rs new file mode 100644 index 0000000..82b2bd9 --- /dev/null +++ b/src/documents/document/tests/block.rs @@ -0,0 +1,270 @@ +//! Block commands as Typora's Paragraph menu (R4.7). + +use gpui::{Modifiers, TestAppContext}; +use markdown::mdast::Node; + +use super::{open, opened, press, text}; +use crate::documents::{ + BlockCommand, EditRefusal, MarkdownMode, document::block::run, markdown::file_tree, +}; + +/// `marked` after `command`: `‸` is the caret, `⟨…⟩` the selection. +fn ran(marked: &str, command: BlockCommand) -> Result { + let caret = marked.find('‸'); + let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect(); + let selection = match caret { + Some(at) => at..at, + None => { + let start = marked.find('⟨').expect("a selection"); + start..marked.find('⟩').expect("its end") - '⟨'.len_utf8() + } + }; + let (edits, selected) = run(&text, selection, command)?; + let mut out = text.clone(); + for (range, with) in edits.into_iter().rev() { + out.replace_range(range, &with); + } + Ok(match selected.is_empty() { + true => format!("{}‸{}", &out[..selected.start], &out[selected.start..]), + false => format!( + "{}⟨{}⟩{}", + &out[..selected.start], + &out[selected.clone()], + &out[selected.end..] + ), + }) +} + +fn ok(marked: &str, command: BlockCommand) -> String { + ran(marked, command).expect("ran") +} + +/// The top-level blocks `text` reads as. +fn kinds(text: &str) -> Vec<&'static str> { + let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect(); + let root = file_tree(&text); + let kids = root.children().map(Vec::as_slice).unwrap_or_default(); + (kids.iter()) + .map(|kid| match kid { + Node::Paragraph(_) => "p", + Node::Heading(_) => "h", + Node::Blockquote(_) => "quote", + Node::List(_) => "list", + Node::Code(_) => "code", + Node::Math(_) => "math", + Node::ThematicBreak(_) => "rule", + Node::Yaml(_) => "yaml", + Node::Definition(_) => "definition", + Node::FootnoteDefinition(_) => "note", + _ => "other", + }) + .collect() +} + +#[test] +fn headings_set_and_toggle_as_typora() { + 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(3)), "### a‸b"); + assert_eq!(ok("a‸", Promote), "###### a‸"); + assert_eq!(ok("# a‸", Promote), "# a‸"); + assert_eq!(ok("## a‸", Promote), "# a‸"); + assert_eq!(ok("###### a‸", Demote), "a‸"); + assert_eq!(ok("a‸", Demote), "a‸"); + assert_eq!(ok("a\n\n‸\n\nb", Heading(2)), "a\n\n## ‸\n\nb"); +} + +#[test] +fn a_paragraph_line_becomes_the_heading_alone() { + let out = ok("a\nb‸\nc", BlockCommand::Heading(1)); + assert_eq!(out, "a\n# b‸\nc"); + assert_eq!(kinds(&out), ["p", "h", "p"]); + assert_eq!(ok("> a\nb‸", BlockCommand::Heading(1)), "> a\n> # b‸"); + assert_eq!(ok("> a‸\nb", BlockCommand::Heading(1)), "> # a‸\n> b"); +} + +#[test] +fn setext_headings_relevel_and_go_atx_past_two() { + use BlockCommand::*; + assert_eq!(ok("a‸\n===", Heading(2)), "a‸\n---"); + assert_eq!(ok("a‸\n===", Heading(3)), "### a‸"); + assert_eq!(ok("a\nb‸\n---", Heading(4)), "#### a b‸"); + assert_eq!(ok("a‸\n===", Paragraph), "a‸"); +} + +#[test] +fn a_heading_made_text_keeps_apart_from_its_neighbors() { + use BlockCommand::Paragraph; + assert_eq!(ok("a\n# h‸", Paragraph), "a\n\nh‸"); + assert_eq!(ok("# h‸\nb", Paragraph), "h‸\n\nb"); + let out = ok("# h‸\n---", Paragraph); + assert_eq!(kinds(&out), ["p", "rule"]); + assert_eq!(ok("- a\n- # h‸", Paragraph), "- a\n- h‸"); + let out = ok("> # h‸\nb", Paragraph); + assert_eq!(kinds(&out), ["quote", "p"]); +} + +#[test] +fn heading_commands_refuse_or_add_a_paragraph_as_typora() { + use BlockCommand::*; + assert_eq!(ran("```\na‸\n```", Heading(1)), Err(EditRefusal::Code)); + assert_eq!(ran("$$\na‸\n$$", Promote), Err(EditRefusal::Math)); + assert_eq!(ok("$$\na‸\n$$", Heading(1)), "$$\na\n$$\n\n‸"); + assert_eq!( + ran("---\nt: a‸\n---\n", Heading(1)), + Err(EditRefusal::FrontMatter) + ); + assert_eq!(ran("- [ ] a‸", Heading(1)), Err(EditRefusal::Outside)); +} + +#[test] +fn quotes_wrap_and_unwrap() { + use BlockCommand::Quote; + assert_eq!(ok("a‸", Quote), "> a‸"); + assert_eq!(ok("> a‸", Quote), "a‸"); + assert_eq!(ok("> a\n> b‸", Quote), "a\nb‸"); + assert_eq!(ok("> a\nb‸", Quote), "a\nb‸"); + assert_eq!(ok("⟨a\n\nb⟩", Quote), "> ⟨a\n>\n> b⟩"); + assert_eq!(ok("x\n> a‸", Quote), "x\n\na‸"); + assert_eq!(ok("> [!NOTE]\n> a‸", Quote), "a‸"); + assert_eq!(ok("- a‸", Quote), "- > a‸"); + assert_eq!(ok("> a\n>\n> ⟨b⟩", Quote), "> a\n>\n> > ⟨b⟩"); +} + +#[test] +fn lists_wrap_each_line() { + use BlockCommand::*; + assert_eq!(ok("a‸", BulletList), "- a‸"); + assert_eq!(ok("a‸", OrderedList), "1. a‸"); + assert_eq!(ok("a‸", TaskList), "- [ ] a‸"); + assert_eq!(ok("⟨a\nb⟩", OrderedList), "1. ⟨a\n2. b⟩"); + assert_eq!(ok("> a‸", BulletList), "> - a‸"); + assert_eq!(ok("- a\n\n b‸", BulletList), "- a\n\n - b‸"); +} + +#[test] +fn a_list_of_the_kind_unwraps_and_another_retypes() { + use BlockCommand::*; + let out = ok("- a\n- b‸", BulletList); + assert_eq!(out, "a\n\nb‸"); + assert_eq!(kinds(&out), ["p", "p"]); + assert_eq!(ok("- a\n- b‸", OrderedList), "1. a\n2. b‸"); + assert_eq!(ok("- a\n b‸", OrderedList), "1. a\n b‸"); + assert_eq!(ok("- [ ] a‸", BulletList), "- a‸"); + assert_eq!(ok("* a‸", TaskList), "* [ ] a‸"); + assert_eq!(ok("3. a\n4. b‸", BulletList), "- a\n- b‸"); +} + +#[test] +fn inserts_split_the_paragraph_at_the_caret() { + use BlockCommand::*; + assert_eq!(ok("ab‸cd", Rule), "ab\n\n---\n\n‸cd"); + assert_eq!(ok("ab‸", CodeBlock), "ab\n\n```\n‸\n```"); + assert_eq!(ok("a ⟨b⟩", CodeBlock), "a\n\n```\n‸b\n```"); + assert_eq!(ok("‸ab", Toc), "[toc]‸\n\nab"); + assert_eq!(ok("# ab‸cd", Rule), "# ab\n\n---\n\n# ‸cd"); + assert_eq!(ok("- ab‸", Rule), "- ab\n\n ---\n\n ‸"); + assert_eq!(ok("```\na‸\n```", Rule), "```\na\n```\n\n---\n\n‸"); + assert_eq!(ok("a\n\n‸\n\nb", Rule), "a\n\n---\n\n‸\n\nb"); + assert_eq!(ran("⟨a\n\nb⟩", Rule), Err(EditRefusal::Across)); +} + +#[test] +fn notes_and_definitions_take_free_names() { + use BlockCommand::*; + let out = ok("x[^1]\n\n[^1]: y\n\nab‸", Footnote); + assert_eq!(out, "x[^1]\n\n[^1]: y\n\nab\n\n[^2]: ‸"); + assert_eq!(ok("‸", LinkDefinition), "[‸]: "); + assert_eq!(ok("⟨see⟩", LinkDefinition), "[see]: ‸"); + assert_eq!(ok("⟨a/b.md⟩", LinkDefinition), "[‸]: a/b.md"); + let out = ok("ab‸", MathBlock); + assert_eq!(kinds(&out), ["p", "math"]); +} + +#[test] +fn empty_paragraphs_come_before_and_after() { + use BlockCommand::*; + assert_eq!(ok("a‸", ParagraphAfter), "a\n\n‸"); + 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"); +} + +#[test] +fn front_matter_goes_on_top_once() { + let out = ok("a‸", BlockCommand::FrontMatter); + assert_eq!(out, "---\n‸\n---\n\na"); + assert_eq!(kinds(&out), ["yaml", "p"]); + assert_eq!( + ran("---\nt: 1\n---\n\na‸", BlockCommand::FrontMatter), + Err(EditRefusal::Present) + ); +} + +#[test] +fn delete_takes_the_block_or_its_lone_holder() { + use BlockCommand::Delete; + assert_eq!(ok("a\n\nb‸\n\nc", Delete), "a\n\n‸c"); + assert_eq!(ok("x\n\n> b‸\n\nc", Delete), "x\n\n‸c"); + assert_eq!(ok("- a\n- b‸\n- c", Delete), "- a\n‸- c"); + assert_eq!(ok("- a‸\n\n b", Delete), "- ‸\n\n b"); +} + +#[test] +fn line_endings_follow_the_text() { + assert_eq!( + ok("ab‸\r\n", BlockCommand::ParagraphAfter), + "ab\r\n\r\n‸\r\n" + ); +} + +#[gpui::test] +fn a_source_command_is_one_undo_step(cx: &mut TestAppContext) { + let (document, cx) = open("alpha\n", MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document + .editor + .update(cx, |editor, cx| editor.select(std::iter::once(2..2), cx)); + document.block(BlockCommand::Heading(2), cx).expect("taken"); + }); + cx.run_until_parked(); + assert_eq!(text(&document, cx), "## alpha\n"); + document.update(cx, |document, cx| { + document.editor.update(cx, |editor, cx| editor.undo(cx)); + }); + assert_eq!(text(&document, cx), "alpha\n"); +} + +#[gpui::test] +fn an_open_block_takes_a_command_and_stays_open(cx: &mut TestAppContext) { + let (document, cx) = open("alpha\n", MarkdownMode::Visual, cx); + press(0, Modifiers::none(), cx); + document.update(cx, |document, cx| { + document.block(BlockCommand::Quote, cx).expect("taken"); + }); + cx.run_until_parked(); + assert_eq!(opened(&document, cx), "> alpha", "still open to type in"); +} + +#[gpui::test] +fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) { + let (document, cx) = open("first\n\nalpha\n", MarkdownMode::Visual, cx); + press(1, Modifiers::none(), cx); + document.update(cx, |document, cx| { + let open = document.open.as_ref().expect("opened"); + open.input.update(cx, |input, cx| input.insert("x", cx)); + document + .block(BlockCommand::FrontMatter, cx) + .expect("taken"); + }); + cx.run_until_parked(); + let written = text(&document, cx); + assert!(written.starts_with("---\n\n---\n"), "{written:?}"); + assert!( + written.contains("alpha") && written.contains('x'), + "{written:?}" + ); + document.read_with(cx, |document, _| assert!(document.open.is_none())); +} diff --git a/src/documents/document/tests/format.rs b/src/documents/document/tests/format.rs index ad9903c..5d3e9a1 100644 --- a/src/documents/document/tests/format.rs +++ b/src/documents/document/tests/format.rs @@ -8,7 +8,7 @@ use web_time::Instant; use super::{open, opened, press, text}; use crate::documents::{ - DocumentEvent, FormatRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode, + DocumentEvent, EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, MarkdownMode, document::{ format::{toggle, toggle_tree}, visual, @@ -17,7 +17,7 @@ use crate::documents::{ }; /// `marked` toggled: `‸` is the caret, `⟨…⟩` the selection. -fn toggled(marked: &str, format: InlineFormat) -> Result { +fn toggled(marked: &str, format: InlineFormat) -> Result { let caret = marked.find('‸'); let text: String = marked.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect(); let selection = match caret { @@ -109,7 +109,7 @@ fn clear_takes_every_mark_off_the_touched_blocks() { #[test] fn code_math_html_and_front_matter_refuse() { - use FormatRefusal as Why; + use EditRefusal as Why; use InlineFormat::*; assert_eq!(toggled("```\nco‸de\n```", Strong), Err(Why::Code)); assert_eq!(toggled("x `co‸de` y", Strong), Err(Why::Code)); @@ -126,12 +126,9 @@ fn code_math_html_and_front_matter_refuse() { #[test] fn a_selection_across_a_link_or_into_its_address_refuses() { use InlineFormat::*; - assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(FormatRefusal::Across)); - assert_eq!( - toggled("[a](ht‸tp://x)", Strong), - Err(FormatRefusal::Across) - ); - assert_eq!(toggled("a\n\n> ‸", Strong), Err(FormatRefusal::Outside)); + assert_eq!(toggled("[a ⟨b](u) c⟩", Strong), Err(EditRefusal::Across)); + assert_eq!(toggled("[a](ht‸tp://x)", Strong), Err(EditRefusal::Across)); + assert_eq!(toggled("a\n\n> ‸", Strong), Err(EditRefusal::Outside)); } #[gpui::test] @@ -185,14 +182,14 @@ fn the_source_takes_a_format_as_one_step(cx: &mut TestAppContext) { fn preview_refuses_a_format(cx: &mut TestAppContext) { let (document, cx) = open("alpha\n", MarkdownMode::Preview, cx); let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx)); - assert_eq!(refused, Err(FormatRefusal::Preview)); + assert_eq!(refused, Err(EditRefusal::Preview)); assert_eq!(text(&document, cx), "alpha\n"); } #[test] fn an_end_in_marks_or_an_address_refuses() { use InlineFormat::*; - let across = Err(FormatRefusal::Across); + let across = Err(EditRefusal::Across); assert_eq!(toggled("a *‸*bold** z", Strong), across); assert_eq!(toggled("[a](ht‸tps://example.com)", Link), across); assert_eq!(toggled("a *⟨*b** c⟩", Strong), across); @@ -283,12 +280,12 @@ fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) { fn refusals( document: &Entity, cx: &mut VisualTestContext, -) -> Rc>> { +) -> Rc>> { let heard = Rc::new(RefCell::new(Vec::new())); let kept = heard.clone(); cx.update(|_, cx| { cx.subscribe(document, move |_, event: &DocumentEvent, _| { - if let DocumentEvent::FormatRefused(why) = event { + if let DocumentEvent::EditRefused(why) = event { kept.borrow_mut().push(*why); } }) @@ -308,13 +305,13 @@ fn a_lapse_comes_by_event(cx: &mut TestAppContext) { .update(cx, |editor, cx| editor.edit([(0..0, "y".to_string())], cx)); }); cx.run_until_parked(); - assert_eq!(*heard.borrow(), [FormatRefusal::Changed]); + assert_eq!(*heard.borrow(), [EditRefusal::Changed]); } #[test] fn an_unwrap_refuses_an_end_in_nested_marks() { use InlineFormat::*; - let across = Err(FormatRefusal::Across); + let across = Err(EditRefusal::Across); assert_eq!(toggled("**[a](ht‸tps://x)**", Strong), across); assert_eq!(toggled("**~~a~‸~**", Strong), across); assert_eq!(toggled("[a](ht‸tps://x)", Underline), across); @@ -359,7 +356,7 @@ fn a_second_format_refuses_while_one_runs(cx: &mut TestAppContext) { document.format(InlineFormat::Strong, cx).expect("taken"); document.format(InlineFormat::Emphasis, cx) }); - assert_eq!(second, Err(FormatRefusal::Busy)); + assert_eq!(second, Err(EditRefusal::Busy)); cx.run_until_parked(); document.update(cx, |document, cx| { document.format(InlineFormat::Emphasis, cx).expect("taken") @@ -386,7 +383,7 @@ fn a_refusal_comes_by_event(cx: &mut TestAppContext) { }); assert_eq!(taken, Ok(())); cx.run_until_parked(); - assert_eq!(*heard.borrow(), [FormatRefusal::Code]); + assert_eq!(*heard.borrow(), [EditRefusal::Code]); assert_eq!(text(&document, cx), source); } @@ -400,14 +397,14 @@ fn a_format_drops_once_preview_shows(cx: &mut TestAppContext) { }); cx.run_until_parked(); assert!(text(&document, cx).starts_with("xx")); - assert_eq!(*heard.borrow(), [FormatRefusal::Preview]); + assert_eq!(*heard.borrow(), [EditRefusal::Preview]); } #[gpui::test] fn a_text_past_the_largest_read_refuses(cx: &mut TestAppContext) { let (document, cx) = word(cx, LARGEST_READ + 1); let refused = document.update(cx, |document, cx| document.format(InlineFormat::Strong, cx)); - assert_eq!(refused, Err(FormatRefusal::TooLong)); + assert_eq!(refused, Err(EditRefusal::TooLong)); } #[gpui::test] diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 2c753d0..b5a4d8c 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -366,6 +366,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) { } mod attach; +mod block; mod format; mod history; mod links; diff --git a/src/documents/mod.rs b/src/documents/mod.rs index f6cb424..1a0c525 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -36,8 +36,9 @@ pub use counts::{ReadingTime, WordCount}; pub use diagram::{Diagram, diagram_svg}; pub(crate) use diagram::{flatten, options as svg_options}; pub use document::{ - Attachment, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget, FormatRefusal, - InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal, TargetBlock, + Attachment, BlockCommand, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget, + EditRefusal, InlineFormat, LARGEST_READ, MarkdownDocument, TableEdit, TableRefusal, + TargetBlock, }; pub use epub::{Chapter, EpubReader}; pub use format::{Format, format};