From bcee87c08fa410290574570b906f065103689a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 15:16:42 -0400 Subject: [PATCH] documents: table moves and tidy read apart; a code press names its text Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- Cargo.lock | 1 + Cargo.toml | 1 + src/documents/document/apart.rs | 16 ++ src/documents/document/format.rs | 4 + src/documents/document/point.rs | 11 +- src/documents/document/tables.rs | 270 +++++++++++++++++-------- src/documents/document/tests/format.rs | 2 +- src/documents/document/tests/point.rs | 28 ++- src/documents/document/tests/tools.rs | 63 +++++- src/documents/render.rs | 4 +- src/documents/render/point.rs | 25 ++- 12 files changed, 321 insertions(+), 106 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba5909b..e218484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window. - Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32. - Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation. -- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `
` breaks as `
` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). Texts past `LARGEST_READ` (2 MiB) are never read: no blocks, no outline. A new read keeps the list's blocks alike at both ends. One caret serves every mode: the editor's selection, which a written-back block moves; `set_caret` and `set_top_line` set caret and view apart; a top line asked (numbered, so a late frame callback finishes only its own ask; new text places it again, clamped to the text as it then is) waits for the read, lands on its block, then a frame later at its share of the block's height; `reveal` is such an ask with the caret at its line; a switch between source and the list keeps the top line, one between visual and preview keeps the list's scroll as it is. A block opens only when the read is of the text as it is now. A pressed block keeps its identity across the write-back of the one before it, and opens with the caret on the pressed character: each drawn piece keeps its written span and how it decodes (Markdown's escapes and references for text, as markdown-rs decodes them, autolink text as written, HTML's references for an HTML block's runs, none for code spans but a table cell's `\|`, cut as CommonMark cuts their end spaces), and is matched from its end, an equal character first, a drawn space next against a line end, decoded or not, so quote marks and indents at a line's start are never taken for text and an alert's text reads past its marker; text before an emoji lands at its piece's start; code, front matter, a link definition and an HTML block's runs read back line for line, LF, CRLF and CR alike, each drawn line ending its written one, a tab cut to spaces landing on the tab; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block's kind, text or drawing (a definition's new address too), moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written and read through `MarkdownReader`, as the drawing reads it, white space counting only in code and math: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker, read as the alert reader reads it, shares its line as `Misread`; an alert's body block keeps its indent; a fence is told by its source, not its text; a task box taken off escapes the block opener it bares, on the marker's line or below. Formats and block commands share one runner and its `EditRefusal`, `DocumentEvent::EditRefused` and one-at-a-time rule: an edit inside the open block edits its field; one past it writes the block back first, a second undo step. Blocks are Tab stops that Enter or Space opens; preview takes no presses, undo or redo. `CodeEditor` keeps at most 64 MiB of undo and redo text, dropping the oldest undo and then the farthest redo after every record, composition, undo and redo. `BlockData::from_markdown` and its export still read with `pulldown-cmark` 0.13 (MIT, default features off); it adds GitHub's alerts, a callout's form, and reads each field's inline Markdown from the source by offset, as written. +- 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, and the text of the code block it lies in. `MarkdownDocument::format` toggles an `InlineFormat` at the selection as Typora's toggle does: inside that style it comes off; a selection is wrapped, its end spaces left out and the same style it partly covers taken in; a caret takes its word, or an empty pair between stop characters or on a blank line; `Clear` takes every inline mark off the blocks it touches, inline math's `$`, footnote brackets and escape backslashes too, an autolink's kept as written; code, math, HTML and front matter refuse, as does an end inside a node's marks or a link address, or a selection crossing a block, a link or another mark (`EditRefusal`); the toggle always reads off the UI thread, one at a time (another refuses as `Busy`), and lands only if text and selection are unchanged and preview is not shown, a refusal met there coming as `DocumentEvent::EditRefused`; past the largest read it refuses; in an open block it edits the field, which stays open, and joins its one write-back step. `MarkdownDocument::block` runs a `BlockCommand` at the caret as Typora's Paragraph menu: a heading level (the same level keeps it, as Typora's menu calls it), paragraph, promote and demote (a paragraph promotes to 6, 6 demotes to a paragraph) take the caret's line alone, a setext heading past level 2 going ATX, a closing `#` run or a block opener left bare escaped so the text stays; math, HTML or a table take an empty paragraph after instead, code refuses; quote and list toggles unwrap the holder (an alert's marker goes, an outer opener stays), retype a list of another kind with its lines re-indented, task boxes and code indent kept, or make each paragraph line an item, a selection past a list taking each touched sibling; rule, code, math, `[toc]`, footnote and link definition split the paragraph at the caret, bold, italic and strike closed before and reopened after, a link, code, math or HTML refusing; front matter goes on top once; an empty line comes before (a list item's or footnote's text moving below its opener) or after; delete takes the block, or the holder of a lone paragraph, a blank line kept where neighbors would join; a task's first text, which GFM reads as text, takes no block. Edits keep each line's container prefix, put one on lazy lines that must stay, and add a blank line where text would join. A heading change or split is parsed again before it is written and read through `MarkdownReader`, as the drawing reads it, white space counting only in code and math: if any shown character or inline format would change, or the block would read as another kind, it refuses as `EditRefusal::Misread`; a task list over code refuses as `Code`, and an alert whose marker, read as the alert reader reads it, shares its line as `Misread`; an alert's body block keeps its indent; a fence is told by its source, not its text; a task box taken off escapes the block opener it bares, on the marker's line or below. `MarkdownDocument::edit_table` adds, deletes and moves rows and columns as Typora's table menu (the head row trades places with the first, a row with cells past the head's columns never becomes the head, an edge refuses as `TableRefusal::Edge`) and tidies; every table it writes pads each column to its display width, 4 to 60, through `unicode-width` 0.2 (MIT OR Apache-2.0), as Typora's `reformatTable`, its refusals coming as `EditRefusal::Cells`. Formats, block commands and table edits 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/Cargo.lock b/Cargo.lock index 13194e3..c89bc49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1685,6 +1685,7 @@ dependencies = [ "similar", "smallvec", "unicode-segmentation", + "unicode-width", "usvg", "vte", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index e9cbf6e..2dbff68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ serde_json = { version = "1", features = ["preserve_order"] } similar = { version = "3.2", default-features = false, features = ["std", "text", "inline", "unicode"] } smallvec = "1" unicode-segmentation = "1" +unicode-width = "0.2" usvg = "0.46" vte = { version = "0.15", default-features = false, features = ["std", "ansi"] } web-time = "1.1" diff --git a/src/documents/document/apart.rs b/src/documents/document/apart.rs index 4a02cf4..a44164f 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -8,6 +8,7 @@ use super::{ DocumentEvent, LARGEST_READ, MarkdownDocument, block::{self, BlockCommand}, format::{Change, EditRefusal, InlineFormat, toggle}, + tables::{self, TableEdit}, }; use crate::documents::{MarkdownMode, modes::joint}; @@ -41,6 +42,21 @@ impl MarkdownDocument { ) } + /// `edit` on the table at the caret, read apart. + pub fn edit_table( + &mut self, + edit: TableEdit, + cx: &mut Context, + ) -> Result<(), EditRefusal> { + self.apart( + format!("table edit {edit:?}"), + move |full, selection| { + tables::edit_table(full, selection.start, edit).map_err(EditRefusal::Cells) + }, + cx, + ) + } + /// Reads `read` off the UI thread, then writes its change. fn apart( &mut self, diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index 71a1782..b9a2539 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -6,6 +6,8 @@ use std::ops::Range; use markdown::mdast::Node; +use super::TableRefusal; + use crate::documents::{ markdown::{file_tree, span, walk}, tree::inside, @@ -50,6 +52,8 @@ pub enum EditRefusal { Changed, /// The result would read as other text or blocks. Misread, + /// A table edit refused, and why. + Cells(TableRefusal), } /// The edits and the selection after them. diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 1563ffc..667a980 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -61,7 +61,8 @@ pub struct DocumentTarget { pub link: Option, pub image: Option, pub in_table: bool, - pub in_code: bool, + /// The code block's text, pressed in one. + pub code: Option, } impl MarkdownDocument { @@ -115,6 +116,12 @@ impl MarkdownDocument { }; let kind = block.map(|block| block.kind); let pressed = block.map(|block| self.drawn(block)); + let code = (pointed.code.as_deref().map(str::to_string)).or_else(|| { + match block.and_then(|block| block.nodes.as_deref()) { + Some([Node::Code { text, .. }]) => Some(text.clone()), + _ => None, + } + }); let at = at.or(block.map(|block| block.range.start)); let written = self.commit(cx); if written.is_some() { @@ -149,7 +156,7 @@ impl MarkdownDocument { link: pointed.link, image: pointed.image, in_table: pointed.table || kind == Some(TargetBlock::Table), - in_code: pointed.code || kind == Some(TargetBlock::Code), + code, })); cx.notify(); } diff --git a/src/documents/document/tables.rs b/src/documents/document/tables.rs index d523047..263bde4 100644 --- a/src/documents/document/tables.rs +++ b/src/documents/document/tables.rs @@ -1,14 +1,16 @@ //! Tables change by rows and columns (MARK-06). -use std::ops::Range; - use gpui::Context; use markdown::mdast::{self, AlignKind}; +use unicode_width::UnicodeWidthStr; -use super::MarkdownDocument; +use super::{ + MarkdownDocument, + format::{Change, EditRefusal}, +}; use crate::documents::{ MarkdownMode, - markdown::{span, tree, walk}, + markdown::{file_tree, span, walk}, }; /// What a table edit does at the caret. @@ -20,11 +22,14 @@ pub enum TableEdit { ColumnRight, DeleteRow, DeleteColumn, + MoveRowUp, + MoveRowDown, + MoveColumnLeft, + MoveColumnRight, + /// Every cell padded to its column, as Typora tidies. + Tidy, } -/// A new table: three columns, a head and two rows. -const NEW: &str = "| | | |\n| --- | --- | --- |\n| | | |\n| | | |"; - /// Why a table edit did not happen. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TableRefusal { @@ -36,17 +41,13 @@ pub enum TableRefusal { Beyond, /// Its lines carry different quote or list marks. Mixed, - /// Preview takes no edits. - Preview, + /// Nothing lies past the caret's row or column that way. + Edge, } -/// The table around `at` after `edit`: its range, text, caret. -pub(super) fn edit_table( - text: &str, - at: usize, - edit: TableEdit, -) -> Result<(Range, String, usize), TableRefusal> { - let root = tree(text); +/// The table around `at` after `edit`, and the caret. +pub(super) fn edit_table(text: &str, at: usize, edit: TableEdit) -> Result { + let root = file_tree(text); let mut nodes = Vec::new(); walk(&root, &mut nodes); let (range, table) = nodes @@ -72,10 +73,12 @@ pub(super) fn edit_table( .rposition(|row| span(row).start <= at) .unwrap_or(0); let cells_of = |row: &mdast::Node| row.children().expect("a row holds cells").clone(); - let column = cells_of(rows[row]) + let cells = cells_of(rows[row]); + let column = cells .iter() .rposition(|cell| span(cell).start <= at) .unwrap_or(0); + let offset = cells.get(column).map_or(0, |cell| within(cell, at)); let width = table.align.len(); // Cells past the head stay as written. let mut grid: Vec> = rows @@ -94,40 +97,69 @@ pub(super) fn edit_table( let mut aligns = table.align.clone(); let column_edit = matches!( edit, - TableEdit::ColumnLeft | TableEdit::ColumnRight | TableEdit::DeleteColumn + TableEdit::ColumnLeft + | TableEdit::ColumnRight + | TableEdit::DeleteColumn + | TableEdit::MoveColumnLeft + | TableEdit::MoveColumnRight ); if column_edit && column >= width { return Err(TableRefusal::Beyond); } - let (row, column) = match edit { + let caret = match edit { TableEdit::RowAbove | TableEdit::RowBelow => { let below = edit == TableEdit::RowBelow || row == 0; let at = if below { row + 1 } else { row }; grid.insert(at, vec![String::new(); width]); - (at, column.min(width - 1)) + (at, column.min(width - 1), 0) } TableEdit::DeleteRow if row > 0 => { grid.remove(row); - (row.min(grid.len() - 1), column.min(width - 1)) + (row.min(grid.len() - 1), column.min(width - 1), 0) } TableEdit::ColumnLeft | TableEdit::ColumnRight => { let at = column + usize::from(edit == TableEdit::ColumnRight); grid.iter_mut() .for_each(|cells| cells.insert(at, String::new())); aligns.insert(at, AlignKind::None); - (row, at) + (row, at, 0) } TableEdit::DeleteColumn if width > 1 => { grid.iter_mut().for_each(|cells| { cells.remove(column); }); aligns.remove(column); - (row, column.min(width - 2)) + (row, column.min(width - 2), 0) } + TableEdit::MoveRowUp | TableEdit::MoveRowDown => { + let to = match edit { + TableEdit::MoveRowUp => row.checked_sub(1), + _ => Some(row + 1).filter(|to| *to < grid.len()), + } + .ok_or(TableRefusal::Edge)?; + // GFM reads a head of other width as no table. + if row.min(to) == 0 && grid[row.max(to)].len() > width { + return Err(TableRefusal::Beyond); + } + grid.swap(row, to); + (to, column, offset) + } + TableEdit::MoveColumnLeft | TableEdit::MoveColumnRight => { + let to = match edit { + TableEdit::MoveColumnLeft => column.checked_sub(1), + _ => Some(column + 1).filter(|to| *to < width), + } + .ok_or(TableRefusal::Edge)?; + grid.iter_mut().for_each(|cells| cells.swap(column, to)); + aligns.swap(column, to); + (row, to, offset) + } + TableEdit::Tidy => (row, column, offset), _ => return Err(TableRefusal::Kept), }; - let (out, caret) = table_text(&grid, &aligns, (row, column), prefix); - Ok((range, out, caret)) + let (out, caret) = table_text(&grid, &aligns, caret, prefix); + let caret = range.start + caret; + Ok((vec![(range, out)], caret..caret)) } /// A cell's source between its pipes. @@ -139,37 +171,69 @@ fn written(text: &str, cell: &mdast::Node) -> String { } } -/// The table's text, each line after the first under `prefix`. +/// `at`'s byte in `cell`'s written text. +fn within(cell: &mdast::Node, at: usize) -> usize { + let inside = cell.children().expect("a cell holds inlines"); + match (inside.first(), inside.last()) { + (Some(first), Some(last)) => { + let start = span(first).start; + at.clamp(start, span(last).end) - start + } + _ => 0, + } +} + +/// The table's text and the caret at row, column, byte. fn table_text( grid: &[Vec], aligns: &[AlignKind], - at: (usize, usize), + at: (usize, usize, usize), prefix: &str, ) -> (String, usize) { + // Typora pads columns 4 to 60 display columns wide. + let widths: Vec = (0..aligns.len()) + .map(|column| { + (grid.iter()) + .map(|cells| cells[column].width()) + .fold(4, usize::max) + .min(60) + }) + .collect(); let mut out = String::new(); let mut caret = 0; for (row, cells) in grid.iter().enumerate() { + out.push('|'); for (column, cell) in cells.iter().enumerate() { - out.push_str("| "); - if (row, column) == at { - caret = out.len(); + let room = widths + .get(column) + .map_or(0, |width| width.saturating_sub(cell.width())); + let before = match aligns.get(column) { + Some(AlignKind::Right) => room, + Some(AlignKind::Center) => room / 2, + _ => 0, + }; + out.push(' '); + out.push_str(&" ".repeat(before)); + if (row, column) == (at.0, at.1) { + caret = out.len() + at.2; } out.push_str(cell); - out.push(' '); + out.push_str(&" ".repeat(room - before)); + out.push_str(" |"); } - out.push('|'); if row == 0 { out.push('\n'); out.push_str(prefix); - for align in aligns { - out.push_str(match align { - AlignKind::Left => "| :-- ", - AlignKind::Right => "| --: ", - AlignKind::Center => "| :-: ", - AlignKind::None => "| --- ", - }); - } out.push('|'); + for (align, width) in aligns.iter().zip(&widths) { + let dashes = match align { + AlignKind::Left => format!(":{}", "-".repeat(width - 1)), + AlignKind::Right => format!("{}:", "-".repeat(width - 1)), + AlignKind::Center => format!(":{}:", "-".repeat(width - 2)), + AlignKind::None => "-".repeat(*width), + }; + out.push_str(&format!(" {dashes} |")); + } } if row + 1 < grid.len() { out.push('\n'); @@ -180,34 +244,10 @@ fn table_text( } impl MarkdownDocument { - /// `edit` on the table at the caret, or why not. - pub fn edit_table( - &mut self, - edit: TableEdit, - cx: &mut Context, - ) -> Result<(), TableRefusal> { - if self.mode == MarkdownMode::Preview { - return Err(TableRefusal::Preview); - } - self.commit(cx); - let at = self.editor.read(cx).primary().range().start; - let text = self.editor.read(cx).text().to_string(); - let (range, table, caret) = edit_table(&text, at, edit).inspect_err(|why| { - log::info!("markdown document: table edit {edit:?} refused: {why:?}"); - })?; - log::info!("markdown document: table edit {edit:?}"); - let caret = range.start + caret; - self.editor.update(cx, |editor, cx| { - editor.edit([(range, table)], cx); - editor.select(std::iter::once(caret..caret), cx); - }); - Ok(()) - } - /// A new table after the caret's line, unless in preview. - pub fn insert_table(&mut self, cx: &mut Context) -> Result<(), TableRefusal> { + pub fn insert_table(&mut self, cx: &mut Context) -> Result<(), EditRefusal> { if self.mode == MarkdownMode::Preview { - return Err(TableRefusal::Preview); + return Err(EditRefusal::Preview); } self.commit(cx); let text = self.editor.read(cx).text().to_string(); @@ -230,11 +270,12 @@ impl MarkdownDocument { } else { "\n" }; - let written = format!("{before}{NEW}{after}"); - let caret = from + before.len() + 2; + let grid = vec![vec![String::new(); 3]; 3]; + let (table, caret) = table_text(&grid, &[AlignKind::None; 3], (0, 0, 0), ""); + let caret = from + before.len() + caret; log::info!("markdown document: a table goes in"); self.editor.update(cx, |editor, cx| { - editor.edit([(from..end, written)], cx); + editor.edit([(from..end, format!("{before}{table}{after}"))], cx); editor.select(std::iter::once(caret..caret), cx); }); Ok(()) @@ -247,42 +288,93 @@ mod tests { const TABLE: &str = "Intro\n\n| A | B |\n|:--|--:|\n| 1 | 2 |\n| 3 | 4 |\n\nAfter\n"; + /// `text` after `edit` at `at`, the caret as `‸`. fn edited_in(text: &str, at: &str, edit: TableEdit) -> Result { let at = text.find(at).expect("in the table"); - let (range, table, _) = edit_table(text, at, edit)?; - Ok(format!( - "{}{table}{}", - &text[..range.start], - &text[range.end..] - )) + let (edits, caret) = edit_table(text, at, edit)?; + let (range, table) = &edits[0]; + let out = format!("{}{table}{}", &text[..range.start], &text[range.end..]); + Ok(format!("{}‸{}", &out[..caret.start], &out[caret.start..])) } fn edited(at: &str, edit: TableEdit) -> Result { edited_in(TABLE, at, edit) } + fn around(table: &str) -> Result { + Ok(format!("Intro\n\n{table}\n\nAfter\n")) + } + #[test] fn rows_and_columns_come_and_go() { - let around = |table: &str| Ok(format!("Intro\n\n{table}\n\nAfter\n")); assert_eq!( edited("3", TableEdit::RowAbove), - around("| A | B |\n| :-- | --: |\n| 1 | 2 |\n| | |\n| 3 | 4 |") + around( + "| A | B |\n| :--- | ---: |\n| 1 | 2 |\n| ‸ | |\n| 3 | 4 |" + ) ); assert_eq!( edited("A", TableEdit::RowBelow), - around("| A | B |\n| :-- | --: |\n| | |\n| 1 | 2 |\n| 3 | 4 |") + around( + "| A | B |\n| :--- | ---: |\n| ‸ | |\n| 1 | 2 |\n| 3 | 4 |" + ) ); assert_eq!( edited("2", TableEdit::ColumnLeft), - around("| A | | B |\n| :-- | --- | --: |\n| 1 | | 2 |\n| 3 | | 4 |") + around( + "| A | | B |\n| :--- | ---- | ---: |\n| 1 | ‸ | 2 |\n| 3 | | 4 |" + ) ); assert_eq!( edited("1", TableEdit::DeleteRow), - around("| A | B |\n| :-- | --: |\n| 3 | 4 |") + around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |") ); assert_eq!( edited("B", TableEdit::DeleteColumn), - around("| A |\n| :-- |\n| 1 |\n| 3 |") + around("| ‸A |\n| :--- |\n| 1 |\n| 3 |") + ); + } + + #[test] + fn rows_and_columns_move_with_the_caret() { + assert_eq!( + edited("3", TableEdit::MoveRowUp), + around("| A | B |\n| :--- | ---: |\n| ‸3 | 4 |\n| 1 | 2 |") + ); + assert_eq!( + edited("A", TableEdit::MoveRowDown), + around("| 1 | 2 |\n| :--- | ---: |\n| ‸A | B |\n| 3 | 4 |") + ); + assert_eq!( + edited("2", TableEdit::MoveColumnLeft), + around("| B | A |\n| ---: | :--- |\n| ‸2 | 1 |\n| 4 | 3 |") + ); + assert_eq!(edited("A", TableEdit::MoveRowUp), Err(TableRefusal::Edge)); + assert_eq!(edited("3", TableEdit::MoveRowDown), Err(TableRefusal::Edge)); + assert_eq!( + edited("1", TableEdit::MoveColumnLeft), + Err(TableRefusal::Edge) + ); + assert_eq!( + edited("4", TableEdit::MoveColumnRight), + Err(TableRefusal::Edge) + ); + } + + #[test] + fn tidy_pads_by_display_width_and_keeps_the_caret() { + let text = "| 名前 | x |\n|:-:|-|\n| 長い名前です | yy‸z |\n"; + let at = text.find('‸').unwrap(); + let text = text.replace('‸', ""); + let (edits, caret) = edit_table(&text, at, TableEdit::Tidy).unwrap(); + let out = format!( + "{}‸{}", + &edits[0].1[..caret.start], + &edits[0].1[caret.start..] + ); + assert_eq!( + out, + "| 名前 | x |\n| :----------: | ---- |\n| 長い名前です | yy‸z |" ); } @@ -307,9 +399,18 @@ mod tests { edited_in(text, "3", TableEdit::DeleteColumn), Err(TableRefusal::Beyond) ); + assert_eq!( + edited_in(text, "3", TableEdit::MoveColumnLeft), + Err(TableRefusal::Beyond) + ); + assert_eq!( + edited_in(text, "1", TableEdit::MoveRowUp), + Err(TableRefusal::Beyond), + "a head wider than its delimiter is no table" + ); assert_eq!( edited_in(text, "1", TableEdit::RowBelow), - Ok("| A | B |\n| --- | --- |\n| 1 | 2 | 3 |\n| | |\n".into()) + Ok("| A | B |\n| ---- | ---- |\n| 1 | 2 | 3 |\n| ‸ | |\n".into()) ); } @@ -318,7 +419,10 @@ mod tests { let text = "> | A | B |\n> | - | - |\n> | 1 | 2 |\n"; assert_eq!( edited_in(text, "1", TableEdit::RowBelow), - Ok("> | A | B |\n> | --- | --- |\n> | 1 | 2 |\n> | | |\n".into()) + Ok( + "> | A | B |\n> | ---- | ---- |\n> | 1 | 2 |\n> | ‸ | |\n" + .into() + ) ); let mixed = "- | A |\n | - |\n | 1 |\n"; assert_eq!( diff --git a/src/documents/document/tests/format.rs b/src/documents/document/tests/format.rs index 5d3e9a1..cf9c360 100644 --- a/src/documents/document/tests/format.rs +++ b/src/documents/document/tests/format.rs @@ -277,7 +277,7 @@ fn a_format_lapses_when_the_text_moves(cx: &mut TestAppContext) { } /// The refusals a document sends by event. -fn refusals( +pub(super) fn refusals( document: &Entity, cx: &mut VisualTestContext, ) -> Rc>> { diff --git a/src/documents/document/tests/point.rs b/src/documents/document/tests/point.rs index dd209c8..79ecf8a 100644 --- a/src/documents/document/tests/point.rs +++ b/src/documents/document/tests/point.rs @@ -72,7 +72,7 @@ pub(super) fn target(at: usize, block: TargetBlock) -> DocumentTarget { link: None, image: None, in_table: false, - in_code: false, + code: None, } } @@ -120,7 +120,7 @@ fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) { let heard = heard(&document, cx); press_on(0, 0, MouseButton::Right, cx); let expected = DocumentTarget { - in_code: true, + code: Some("let x = 1;".into()), ..target("```rust\n".len(), TargetBlock::Code) }; assert_eq!(*heard.borrow(), [expected]); @@ -245,8 +245,9 @@ fn code_presses_land_line_for_line(cx: &mut TestAppContext) { let events = heard(&document, cx); press_lines(1, 1, MouseButton::Right, cx); let at = source.find("eta").unwrap(); + let eol = if source.contains('\r') { "\r\n" } else { "\n" }; let expected = DocumentTarget { - in_code: true, + code: Some(format!("alpha{eol}beta")), ..target(at, TargetBlock::Code) }; assert_eq!(*events.borrow(), [expected], "{source:?}"); @@ -304,7 +305,7 @@ beta press_lines(2, 0, MouseButton::Right, cx); let at = source.find("pha").unwrap(); let expected = DocumentTarget { - in_code: true, + code: Some("alpha\nbeta".into()), ..target(at, TargetBlock::Code) }; assert_eq!(*events.borrow(), [expected]); @@ -392,3 +393,22 @@ fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) { let at = source.find("eta").unwrap(); assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]); } + +#[gpui::test] +fn a_press_beside_the_code_still_names_it(cx: &mut TestAppContext) { + let source = "```\nlet x;\n```\n"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + let bounds = cx.debug_bounds("markdown-block-0").expect("drawn"); + press_at( + bounds.left() + px(4.0), + bounds.top() + px(4.0), + MouseButton::Right, + cx, + ); + let code = events + .borrow() + .first() + .and_then(|target| target.code.clone()); + assert_eq!(code.as_deref(), Some("let x;")); +} diff --git a/src/documents/document/tests/tools.rs b/src/documents/document/tests/tools.rs index a429a7e..4738289 100644 --- a/src/documents/document/tests/tools.rs +++ b/src/documents/document/tests/tools.rs @@ -47,7 +47,11 @@ fn a_table_edit_is_one_undo_step_at_the_caret(cx: &mut TestAppContext) { document.edit_table(crate::documents::TableEdit::RowBelow, cx) }); assert_eq!(edited, Ok(())); - assert_eq!(text(&document, cx), "| A |\n| --- |\n| 1 |\n| |\n"); + cx.run_until_parked(); + assert_eq!( + text(&document, cx), + "| A |\n| ---- |\n| 1 |\n| |\n" + ); let editor = document.read_with(cx, |document, _| document.editor().clone()); editor.update(cx, |editor, cx| editor.undo(cx)); assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n"); @@ -61,7 +65,7 @@ fn a_new_table_goes_after_the_caret_line(cx: &mut TestAppContext) { }); assert_eq!( text(&document, cx), - "Intro\n\n| | | |\n| --- | --- | --- |\n| | | |\n| | | |\n\nNext\n" + "Intro\n\n| | | |\n| ---- | ---- | ---- |\n| | | |\n| | | |\n\nNext\n" ); } @@ -143,7 +147,7 @@ fn preview_takes_no_edits(cx: &mut TestAppContext) { document.insert_table(cx), ) }); - let preview = Err(crate::documents::TableRefusal::Preview); + let preview = Err(crate::documents::EditRefusal::Preview); assert_eq!(refused, (false, preview, preview)); assert_eq!(text(&document, cx), "| A |\n| - |\n| 1 |\n"); } @@ -247,5 +251,58 @@ fn a_table_on_an_indented_blank_line_is_a_table(cx: &mut TestAppContext) { document.edit_table(crate::documents::TableEdit::ColumnRight, cx) }); assert_eq!(column, Ok(()), "{}", text(&document, cx)); + cx.run_until_parked(); assert!(text(&document, cx).starts_with("| ")); } + +const SMALL: &str = "| A | B |\n| - | - |\n| 1 | 2 |\n"; + +#[gpui::test] +fn a_table_move_reads_apart_as_one_undo_step(cx: &mut TestAppContext) { + let (document, cx) = open(SMALL, MarkdownMode::Source, cx); + document.update(cx, |document, cx| document.set_caret(2, 2, cx)); + let moved = document.update(cx, |document, cx| { + document.edit_table(crate::documents::TableEdit::MoveRowUp, cx) + }); + assert_eq!(moved, Ok(())); + cx.run_until_parked(); + assert_eq!( + text(&document, cx), + "| 1 | 2 |\n| ---- | ---- |\n| A | B |\n" + ); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + editor.update(cx, |editor, cx| editor.undo(cx)); + assert_eq!(text(&document, cx), SMALL); +} + +#[gpui::test] +fn a_table_edit_at_an_edge_refuses_by_event(cx: &mut TestAppContext) { + let (document, cx) = open(SMALL, MarkdownMode::Source, cx); + let heard = super::format::refusals(&document, cx); + document.update(cx, |document, cx| document.set_caret(2, 2, cx)); + document.update(cx, |document, cx| { + document + .edit_table(crate::documents::TableEdit::MoveRowDown, cx) + .expect("taken") + }); + cx.run_until_parked(); + let edge = crate::documents::EditRefusal::Cells(crate::documents::TableRefusal::Edge); + assert_eq!(*heard.borrow(), [edge]); + assert_eq!(text(&document, cx), SMALL); +} + +#[gpui::test] +fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) { + let (document, cx) = open(SMALL, MarkdownMode::Visual, cx); + press(0, Modifiers::none(), cx); + document.update(cx, |document, cx| { + document + .edit_table(crate::documents::TableEdit::Tidy, cx) + .expect("taken") + }); + cx.run_until_parked(); + assert_eq!( + opened(&document, cx), + "| A | B |\n| ---- | ---- |\n| 1 | 2 |" + ); +} diff --git a/src/documents/render.rs b/src/documents/render.rs index 66f52b8..cd2f6ab 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -264,7 +264,7 @@ impl Draw<'_> { } => { if language.as_deref() == Some("mermaid") { let diagram = Diagram::new(self.key("diagram"), text.clone()); - return self.point_code(diagram.into_any_element(), None, *from); + return self.point_code(diagram.into_any_element(), None, text, *from); } if let Some(draw) = self.code { return draw( @@ -299,7 +299,7 @@ impl Draw<'_> { })) .child(code) .into_any_element(); - self.point_code(block, Some((layout, text)), *from) + self.point_code(block, Some(layout), text, *from) } Node::List { start, items } => self.list(start, items, cx), Node::Table { aligns, head, rows } => { diff --git a/src/documents/render/point.rs b/src/documents/render/point.rs index ff3c72b..233159e 100644 --- a/src/documents/render/point.rs +++ b/src/documents/render/point.rs @@ -29,7 +29,8 @@ pub(crate) struct Pointed { pub place: Place, pub link: Option, pub image: Option, - pub code: bool, + /// The code's text, pressed in a code block. + pub code: Option>, pub table: bool, } @@ -44,7 +45,7 @@ impl Pointed { place, link: None, image: None, - code: false, + code: None, table: false, } } @@ -100,7 +101,7 @@ pub(crate) fn point_lines( .index_for_position(event.position) .unwrap_or_else(|near| near); Pointed { - code, + code: code.then(|| text.clone()), ..Pointed::at(Place::Lines { text: text.clone(), from, @@ -146,12 +147,13 @@ impl Draw<'_> { pub(super) fn point_code( &self, element: AnyElement, - drawn: Option<(TextLayout, &str)>, + layout: Option, + text: &str, from: usize, ) -> AnyElement { let table = self.table.get(); - match drawn { - Some((layout, text)) => point_lines( + match layout { + Some(layout) => point_lines( element, layout, text, @@ -159,10 +161,13 @@ impl Draw<'_> { self.hooks.on_point, (table, true), ), - None => self.pointable(element, |_| Pointed { - code: true, - ..Pointed::block() - }), + None => { + let text: Rc = Rc::from(text); + self.pointable(element, move |_| Pointed { + code: Some(text.clone()), + ..Pointed::block() + }) + } } }