diff --git a/AGENTS.md b/AGENTS.md index 335e2c4..4cbf985 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. An open block stays open while a menu or dialog borrows focus, so the overlay's command reaches it with its selection; it writes back once the overlay lets focus go anywhere else (`primitives::lent`). A block dropped while it holds focus, as an edit past it drops it, hands focus to the document, so its keys still reach the document. A secondary press on a drawn block 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. One in the open block keeps it open with its selection and emits the same target for the pressed byte, not the selection, read apart as drawing reads it (an inline anchor's address, a definition elsewhere for a reference); it is dropped if the text changed meanwhile or a later press came first. A text field and the code editor keep a selection a right press lands in, else put the caret there, as browsers do. `MarkdownDocument::delete_image(at)` takes the picture at a target's byte away in one step, refused elsewhere as `EditRefusal::NoImage` (Typora's Delete Image; `InlineFormat::Image` keeps the alt text). In visual mode with no block open, a paste that brings no picture puts the clipboard's text at the caret. `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`), tidies, and deletes the whole table as `BlockCommand::Delete` does, outside a table refusing as `TableRefusal::Outside`; every table it writes pads each column to its display width, 4 to 60, through `unicode-width` 0.2 (MIT OR Apache-2.0), a tab counting 4 and a control character 0 as Typora's `wcswidth` counts them, in the table's own line ending, 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. `CodeEditor` splits lines on LF alone, so End can leave the caret between CR and LF; edits and a new table read it before the CR. 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; a press in a table opens that cell alone, in place, as its source in a one-line field (a press on its text at the pressed character, past it at its end; a table block opened by its edge, the keyboard or a step from a neighbor opens its first or last cell), Tab and Shift-Tab going to the next and previous cell, Up and Down to the cell above and below or out of the table, Enter and Escape writing it back, a bare `|` typed in it escaped as the cell keeps it, and a table edit writing it back before it runs; 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. An open block stays open while a menu or dialog borrows focus, so the overlay's command reaches it with its selection; it writes back once the overlay lets focus go anywhere else (`primitives::lent`). A block dropped while it holds focus, as an edit past it drops it, hands focus to the document, so its keys still reach the document. A secondary press on a drawn block 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. One in the open block keeps it open with its selection and emits the same target for the pressed byte, not the selection, read apart as drawing reads it (an inline anchor's address, a definition elsewhere for a reference); it is dropped if the text changed meanwhile or a later press came first. A text field and the code editor keep a selection a right press lands in, else put the caret there, as browsers do. `MarkdownDocument::delete_image(at)` takes the picture at a target's byte away in one step, refused elsewhere as `EditRefusal::NoImage` (Typora's Delete Image; `InlineFormat::Image` keeps the alt text). In visual mode with no block open, a paste that brings no picture puts the clipboard's text at the caret. `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`), tidies, and deletes the whole table as `BlockCommand::Delete` does, outside a table refusing as `TableRefusal::Outside`; every table it writes pads each column to its display width, 4 to 60, through `unicode-width` 0.2 (MIT OR Apache-2.0), a tab counting 4 and a control character 0 as Typora's `wcswidth` counts them, in the table's own line ending, 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. `CodeEditor` splits lines on LF alone, so End can leave the caret between CR and LF; edits and a new table read it before the CR. 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 index fa2d0a2..7f9f8ae 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -7,6 +7,7 @@ use gpui::{AppContext as _, Context}; use super::{ DocumentEvent, LARGEST_READ, MarkdownDocument, block::{self, BlockCommand, settled}, + cells, format::{Change, EditRefusal, InlineFormat, drop_image, toggle}, tables::{self, TableEdit}, }; @@ -127,9 +128,18 @@ impl MarkdownDocument { &text[open.range.end..], ), }; - let picked = input.selection(); + let (typed, mut picked) = (input.text(), input.selection()); + // A cell reads as it writes back. + let typed = match open.cell { + Some(_) => { + let shift = |at: usize| cells::guarded(&typed[..at]).len(); + picked = shift(picked.start)..shift(picked.end); + cells::guarded(typed) + } + None => typed.to_string(), + }; let offset = before.len(); - let full = format!("{before}{}{after}", input.text()); + let full = format!("{before}{typed}{after}"); settle((full, offset, offset + picked.start..offset + picked.end)) } @@ -144,12 +154,16 @@ impl MarkdownDocument { ) -> Result<(), EditRefusal> { log::info!("markdown document: {name}"); if let Some(open) = &self.open { - let end = offset + open.input.read(cx).text().len(); + let typed = open.input.read(cx).text(); + // Offsets read the escaped cell; its field holds it raw. + let raw = open.cell.is_some() && cells::guarded(typed) != typed; + let end = offset + typed.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) + let inside = !raw + && 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)) = ( diff --git a/src/documents/document/cells.rs b/src/documents/document/cells.rs new file mode 100644 index 0000000..2201c00 --- /dev/null +++ b/src/documents/document/cells.rs @@ -0,0 +1,221 @@ +//! A table cell opens alone, as Typora edits tables. + +use std::ops::Range; + +use markdown::mdast::Node; + +use crate::documents::{ + markdown::{file_tree, span, walk}, + render::CellAt, +}; + +/// A cell of the text: which, its source, the caret in it. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct Cell { + pub at: CellAt, + pub range: Range, + pub caret: usize, +} + +/// What names a cell in a block. +#[derive(Clone, Copy, Debug)] +pub(super) enum Aim { + /// The cell holding this byte of the text. + Byte(usize), + /// This cell, its caret at the end. + At(CellAt), + First, + Last, +} + +/// A way out of a cell. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Move { + Next, + Previous, + Up, + Down, +} + +/// The rows of each table in `root`, in drawing order. +fn tables(root: &Node) -> Vec> { + let mut nodes = Vec::new(); + walk(root, &mut nodes); + nodes + .into_iter() + .filter(|node| matches!(node, Node::Table(_))) + .map(|table| { + table + .children() + .expect("a table holds rows") + .iter() + .collect() + }) + .collect() +} + +fn cells(row: &Node) -> &[Node] { + row.children().expect("a row holds cells") +} + +/// The cell `aim` names in `block` of `text`. +pub(super) fn find(text: &str, block: Range, aim: Aim) -> Option { + let root = file_tree(&text[block.clone()]); + let tables = tables(&root); + let at = match aim { + Aim::At(at) => at, + Aim::First if !tables.is_empty() => CellAt { + table: 0, + row: 0, + column: 0, + }, + Aim::Last => { + let table = tables.len().checked_sub(1)?; + let row = tables[table].len() - 1; + let column = cells(tables[table][row]).len().checked_sub(1)?; + CellAt { table, row, column } + } + Aim::First => return None, + Aim::Byte(byte) => { + let byte = byte.checked_sub(block.start)?; + let table = (tables.iter()).position(|rows| { + let (first, last) = (rows[0], rows[rows.len() - 1]); + (span(first).start..=span(last).end).contains(&byte) + })?; + let rows = &tables[table]; + let row = rows.iter().rposition(|row| span(row).start <= byte)?; + let column = (cells(rows[row]).iter()).rposition(|cell| span(cell).start <= byte)?; + CellAt { table, row, column } + } + }; + let cell = cells(tables.get(at.table)?.get(at.row)?).get(at.column)?; + let range = written(&text[block.clone()], cell); + let caret = match aim { + Aim::Byte(byte) => (byte - block.start).clamp(range.start, range.end) - range.start, + _ => range.len(), + }; + Some(Cell { + at, + range: block.start + range.start..block.start + range.end, + caret, + }) +} + +/// The cell `by` reaches from `at`; none past the table's edge. +pub(super) fn beside(text: &str, block: Range, at: CellAt, by: Move) -> Option { + let root = file_tree(&text[block]); + let tables = tables(&root); + let rows = tables.get(at.table)?; + let width = |row: usize| rows.get(row).map_or(0, |row| cells(row).len()); + let (row, column) = match by { + Move::Next if at.column + 1 < width(at.row) => (at.row, at.column + 1), + Move::Next => (at.row + 1, 0), + Move::Previous if at.column > 0 => (at.row, at.column - 1), + Move::Previous => { + let row = at.row.checked_sub(1)?; + (row, width(row).checked_sub(1)?) + } + Move::Up => (at.row.checked_sub(1)?, at.column), + Move::Down => (at.row + 1, at.column), + }; + let column = column.min(width(row).checked_sub(1)?); + Some(CellAt { + table: at.table, + row, + column, + }) +} + +/// A cell's source between its pipes; empty, a place after the opening one. +fn written(text: &str, cell: &Node) -> Range { + let inside = cell.children().expect("a cell holds inlines"); + if let (Some(first), Some(last)) = (inside.first(), inside.last()) { + return span(first).start..span(last).end; + } + let span = span(cell); + let mut at = span.start; + if text[at..span.end].starts_with('|') { + at += 1; + } + if text[at..span.end].starts_with([' ', '\t']) { + at += 1; + } + at..at +} + +/// `typed` with each bare `|` escaped, as a cell keeps it. +pub(super) fn guarded(typed: &str) -> String { + let mut out = String::with_capacity(typed.len()); + let mut slashes = 0; + for ch in typed.chars() { + if ch == '|' && slashes % 2 == 0 { + out.push('\\'); + } + slashes = if ch == '\\' { slashes + 1 } else { 0 }; + out.push(ch); + } + out +} + +#[cfg(test)] +mod tests { + use super::{Aim, CellAt, Move, beside, find, guarded}; + + const TABLE: &str = "intro\n\n| a | **b** |\n| --- | --- |\n| c | |\n| d | e |\n"; + + fn block() -> std::ops::Range { + 7..TABLE.len() - 1 + } + + fn at(row: usize, column: usize) -> CellAt { + CellAt { + table: 0, + row, + column, + } + } + + #[test] + fn a_byte_opens_its_cell_with_the_caret_there() { + let byte = TABLE.find("**b**").expect("in the table") + 2; + let cell = find(TABLE, block(), Aim::Byte(byte)).expect("a cell"); + assert_eq!(cell.at, at(0, 1)); + assert_eq!(&TABLE[cell.range.clone()], "**b**"); + assert_eq!(cell.caret, 2); + } + + #[test] + fn an_empty_cell_opens_between_its_pipes() { + let cell = find(TABLE, block(), Aim::At(at(1, 1))).expect("a cell"); + assert!(cell.range.is_empty()); + let written = [&TABLE[..cell.range.start], "x", &TABLE[cell.range.end..]].concat(); + assert!(written.contains("| c | x |"), "{written:?}"); + } + + #[test] + fn ends_open_the_first_and_last_cells() { + let first = find(TABLE, block(), Aim::First).expect("a cell"); + assert_eq!((first.at, &TABLE[first.range]), (at(0, 0), "a")); + let last = find(TABLE, block(), Aim::Last).expect("a cell"); + assert_eq!((last.at, &TABLE[last.range]), (at(2, 1), "e")); + assert_eq!(find("plain\n", 0..5, Aim::First), None); + } + + #[test] + fn moves_wrap_rows_and_stop_at_edges() { + let to = |from, by| beside(TABLE, block(), from, by); + assert_eq!(to(at(0, 1), Move::Next), Some(at(1, 0))); + assert_eq!(to(at(1, 0), Move::Previous), Some(at(0, 1))); + assert_eq!(to(at(0, 0), Move::Previous), None); + assert_eq!(to(at(2, 1), Move::Next), None); + assert_eq!(to(at(1, 1), Move::Up), Some(at(0, 1))); + assert_eq!(to(at(2, 0), Move::Down), None); + } + + #[test] + fn bare_pipes_escape_and_escaped_stay() { + assert_eq!(guarded("a|b"), "a\\|b"); + assert_eq!(guarded("a\\|b"), "a\\|b"); + assert_eq!(guarded("a\\\\|b"), "a\\\\\\|b"); + } +} diff --git a/src/documents/document/edit.rs b/src/documents/document/edit.rs index 5a6b4ac..84c0135 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -2,7 +2,10 @@ use std::ops::Range; use gpui::{AppContext as _, Context, Focusable as _, Window}; -use super::{DocumentEvent, MarkdownDocument, Open}; +use super::{ + DocumentEvent, MarkdownDocument, Open, TargetBlock, + cells::{self, Aim, Move}, +}; use crate::{ documents::{ MarkdownMode, @@ -20,6 +23,8 @@ pub(super) enum Landing { End, /// A source byte of the drawn read. At(usize), + /// A table cell, by byte or place. + Cell(Aim), } /// Where byte `at` lies after `edit`, if it survives. @@ -76,8 +81,8 @@ impl MarkdownDocument { return log::warn!("markdown document: pressed while the text is read again"); } let text = self.editor.read(cx).text(); - let (range, append) = match pressed { - None => (text.len()..text.len(), true), + let (range, append, cell) = match pressed { + None => (text.len()..text.len(), true, None), Some(start) => { let found = moved(start, &written).and_then(|start| { self.parsed @@ -88,12 +93,36 @@ impl MarkdownDocument { let Some(block) = found else { return log::warn!("markdown document: the pressed block changed as one wrote"); }; - (block.range.clone(), false) + let table = block.kind == TargetBlock::Table; + let aim = match landing { + Landing::Cell(Aim::Byte(at)) => match moved(at, &written) { + Some(at) => Some(Aim::Byte(at)), + None => { + return log::warn!( + "markdown document: the pressed cell was written over" + ); + } + }, + Landing::Cell(aim) => Some(aim), + Landing::Start if table => Some(Aim::First), + Landing::End if table => Some(Aim::Last), + _ => None, + }; + match aim { + None => (block.range.clone(), false, None), + Some(aim) => match cells::find(text, block.range.clone(), aim) { + Some(cell) => (cell.range, false, Some((cell.at, cell.caret))), + None => { + return log::warn!("markdown document: no cell {aim:?} in the block"); + } + }, + } } }; let caret = match landing { + _ if cell.is_some() => cell.map(|(_, caret)| caret), Landing::Start => Some(0), - Landing::End => None, + Landing::End | Landing::Cell(_) => None, Landing::At(at) => match moved(at, &written) { Some(at) if range.contains(&at) || at == range.end => Some(at - range.start), _ => { @@ -104,9 +133,11 @@ impl MarkdownDocument { }; let source = text[range.clone()].to_string(); let input = cx.new(|cx| { - TextInput::new(window, cx) - .multi_line(1, usize::MAX) - .highlighter(markdown_highlights) + let input = TextInput::new(window, cx).highlighter(markdown_highlights); + match cell { + Some(_) => input, + None => input.multi_line(1, usize::MAX), + } }); input.update(cx, |input, cx| { input.set_text(source, cx); @@ -118,23 +149,39 @@ impl MarkdownDocument { let subscriptions = vec![cx.subscribe_in( &input, window, - |document, _, event: &InputEvent, window, cx| match event { - InputEvent::Blur if lent(window, cx) => document.lend(window, cx), - InputEvent::Blur => { - document.commit(cx); + |document, _, event: &InputEvent, window, cx| { + match event { + InputEvent::Blur if lent(window, cx) => document.lend(window, cx), + InputEvent::Blur => { + document.commit(cx); + } + InputEvent::Changed => { + document.typed += 1; + cx.emit(DocumentEvent::Changed) + } + // Enter in a cell writes it back. + InputEvent::Submit + if document + .open + .as_ref() + .is_some_and(|open| open.cell.is_some()) => + { + document.commit(cx); + window.focus(&document.focus, cx); + } + _ => {} } - InputEvent::Changed => { - document.typed += 1; - cx.emit(DocumentEvent::Changed) - } - _ => {} }, )]; - log::info!("markdown document: block {range:?} opens"); + match cell { + Some((at, _)) => log::info!("markdown document: cell {at:?} at {range:?} opens"), + None => log::info!("markdown document: block {range:?} opens"), + } self.open = Some(Open { range, input, append, + cell: cell.map(|(at, _)| at), lent: None, _subscriptions: subscriptions, }); @@ -187,7 +234,12 @@ impl MarkdownDocument { let (lead, written) = match open.append { true if typed.is_empty() => return None, true => (joint(text).len(), format!("{}{typed}", joint(text))), - false => (0, typed), + false if open.cell.is_some() => (0, cells::guarded(&typed)), + false => (0, typed.clone()), + }; + let caret = match open.cell { + Some(_) => cells::guarded(&typed[..caret]).len(), + None => caret, }; let at = open.range.start + lead + caret; let edit = (text[open.range.clone()] != written).then(|| { @@ -222,6 +274,15 @@ impl MarkdownDocument { return false; } let here = self.block_at(open.range.start); + if let Some(at) = open.cell { + let block = self.parsed.blocks[here].range.clone(); + let by = if down { Move::Down } else { Move::Up }; + let text = self.editor.read(cx).text(); + if let Some(to) = cells::beside(text, block, at, by) { + self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx); + return true; + } + } let next = match down { true => Some(here + 1).filter(|next| *next < self.parsed.blocks.len()), false => here.checked_sub(1), diff --git a/src/documents/document/lists.rs b/src/documents/document/lists.rs index 1dc1e09..b9ab5b5 100644 --- a/src/documents/document/lists.rs +++ b/src/documents/document/lists.rs @@ -5,7 +5,11 @@ use std::ops::Range; use gpui::{Context, Window}; use markdown::mdast; -use super::MarkdownDocument; +use super::{ + MarkdownDocument, + cells::{Aim, Move, beside}, + edit::Landing, +}; use crate::documents::markdown::{span, tree, walk}; /// Spaces one indent step adds. @@ -196,7 +200,7 @@ impl MarkdownDocument { /// Enter on a block list line; false leaves it. pub(super) fn block_newline(&mut self, cx: &mut Context) -> bool { - let Some(open) = &self.open else { + let Some(open) = self.open.as_ref().filter(|open| open.cell.is_none()) else { return false; }; let input = open.input.clone(); @@ -218,11 +222,27 @@ impl MarkdownDocument { true } - /// Tab or Shift-Tab in the open block. - pub(super) fn block_shift(&mut self, deeper: bool, _: &mut Window, cx: &mut Context) { + /// Tab or Shift-Tab in the open block; in a cell, the next or previous. + pub(super) fn block_shift( + &mut self, + deeper: bool, + window: &mut Window, + cx: &mut Context, + ) { let Some(open) = &self.open else { return; }; + if let Some(at) = open.cell { + let here = self.block_at(open.range.start); + let block = self.parsed.blocks[here].range.clone(); + let by = if deeper { Move::Next } else { Move::Previous }; + let text = self.editor.read(cx).text(); + match beside(text, block, at, by) { + Some(to) => self.open(Some(here), Landing::Cell(Aim::At(to)), window, cx), + None => log::info!("markdown document: no cell {by:?} of {at:?}"), + } + return; + } let input = open.input.clone(); let (text, selection) = { let input = input.read(cx); diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 5cb4ec6..11b2077 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -1,6 +1,7 @@ mod apart; mod attach; mod block; +mod cells; mod edit; mod finding; mod format; @@ -36,7 +37,7 @@ use super::{ MarkdownMode, markdown::markdown_highlights, modes::joint, - render::{OnHeading, OnLink}, + render::{CellAt, OnHeading, OnLink}, }; use crate::{ editor::{CodeEditor, EditorEvent, LineNumbers}, @@ -80,6 +81,8 @@ struct Open { input: Entity, /// Opened past the end, to add a block. append: bool, + /// A table cell alone; its bare pipes escape. + cell: Option, /// Watches an overlay that borrowed focus. lent: Option, _subscriptions: Vec, @@ -243,6 +246,7 @@ impl MarkdownDocument { let written = match open.append { true if typed.is_empty() => String::new(), true => format!("{}{typed}", joint(text)), + false if open.cell.is_some() => cells::guarded(typed), false => typed.to_string(), }; [&text[..open.range.start], &written, &text[open.range.end..]].concat() diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 567e6c9..df93eaa 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -3,7 +3,9 @@ use std::rc::Rc; use gpui::{AppContext as _, Context, MouseButton, Pixels, Point, Window}; use markdown::mdast; -use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block}; +use super::{ + DocumentEvent, MarkdownDocument, cells::Aim, edit::Landing, edit::moved, parse::Block, +}; use crate::documents::{ markdown::{file_tree, span, walk}, render::{OnPoint, Place, Pointed}, @@ -80,15 +82,20 @@ impl MarkdownDocument { Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from)) } Place::At(at) => Some(*at), - Place::Block => None, + Place::Cell(_) | Place::Block => None, } .map(|at| start + at); + // A press in a table opens only its cell. + let landing = match (&pointed.place, at) { + (Place::Cell(cell), _) => Landing::Cell(Aim::At(*cell)), + (_, Some(at)) if pointed.table => Landing::Cell(Aim::Byte(at)), + (_, Some(at)) => Landing::At(at), + (_, None) => Landing::End, + }; let ix = Some(ix); document .update(cx, |document, cx| match button { - MouseButton::Left => { - document.open(ix, at.map_or(Landing::End, Landing::At), window, cx) - } + MouseButton::Left => document.open(ix, landing, window, cx), MouseButton::Right => document.point(ix, at, pointed, window, cx), _ => {} }) diff --git a/src/documents/document/tests/cells.rs b/src/documents/document/tests/cells.rs new file mode 100644 index 0000000..3d45268 --- /dev/null +++ b/src/documents/document/tests/cells.rs @@ -0,0 +1,58 @@ +//! A press in a table opens only its cell (R6.1). + +use gpui::{Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; + +use super::{open, opened, text}; +use crate::documents::MarkdownMode; + +const TABLE: &str = "| a | **bold** |\n| --- | --- |\n| c | |\n"; + +/// Presses cell `row`, `column` of the first table near its left. +fn press_cell(row: usize, column: usize, cx: &mut VisualTestContext) { + let name = format!("markdown-cell-0-{row}-{column}"); + let bounds = cx + .debug_bounds(Box::leak(name.into_boxed_str())) + .expect("the cell is drawn"); + let at = point(bounds.left() + px(2.0), bounds.top() + px(2.0)); + cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none()); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_press_opens_its_cell_and_writes_one_undo_step(cx: &mut TestAppContext) { + let (document, cx) = open(TABLE, MarkdownMode::Visual, cx); + press_cell(0, 1, cx); + assert_eq!(opened(&document, cx), "**bold**"); + assert!( + cx.debug_bounds("markdown-cell-0-0-0").is_some(), + "the rest stays drawn" + ); + press_cell(1, 1, cx); + assert_eq!(opened(&document, cx), ""); + cx.simulate_input("x|y"); + assert_eq!( + text(&document, cx), + "| a | **bold** |\n| --- | --- |\n| c | x\\|y |\n" + ); + cx.simulate_keystrokes("escape"); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + editor.update(cx, |editor, cx| editor.undo(cx)); + assert_eq!(text(&document, cx), TABLE); +} + +#[gpui::test] +fn tab_and_arrows_walk_the_cells(cx: &mut TestAppContext) { + let (document, cx) = open(TABLE, MarkdownMode::Visual, cx); + press_cell(0, 0, cx); + assert_eq!(opened(&document, cx), "a"); + cx.simulate_keystrokes("tab"); + assert_eq!(opened(&document, cx), "**bold**"); + cx.simulate_keystrokes("shift-tab"); + assert_eq!(opened(&document, cx), "a"); + cx.simulate_keystrokes("down"); + assert_eq!(opened(&document, cx), "c"); + cx.simulate_keystrokes("enter"); + assert!(document.read_with(cx, |document, _| document.open.is_none())); + assert_eq!(text(&document, cx), TABLE); +} diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index ff93c31..531ffe9 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -368,6 +368,7 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) { mod attach; mod block; mod block_edges; +mod cells; mod format; mod history; mod lend; diff --git a/src/documents/document/tests/tools.rs b/src/documents/document/tests/tools.rs index 855e03c..92354ee 100644 --- a/src/documents/document/tests/tools.rs +++ b/src/documents/document/tests/tools.rs @@ -341,9 +341,10 @@ fn a_new_table_takes_the_texts_line_ending(cx: &mut TestAppContext) { } #[gpui::test] -fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) { +fn tidy_in_an_open_cell_writes_it_and_tidies(cx: &mut TestAppContext) { let (document, cx) = open(SMALL, MarkdownMode::Visual, cx); press(0, Modifiers::none(), cx); + assert_eq!(opened(&document, cx), "2"); document.update(cx, |document, cx| { document .edit_table(crate::documents::TableEdit::Tidy, cx) @@ -351,8 +352,8 @@ fn tidy_in_an_open_table_keeps_it_open(cx: &mut TestAppContext) { }); cx.run_until_parked(); assert_eq!( - opened(&document, cx), - "| A | B |\n| ---- | ---- |\n| 1 | 2 |" + text(&document, cx), + "| A | B |\n| ---- | ---- |\n| 1 | 2 |\n" ); } diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index ccd3ffd..c555816 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -9,7 +9,7 @@ use super::{MarkdownDocument, edit::Landing}; use crate::{ documents::{ MarkdownMode, - render::{Hooks, Pointed, draw_nodes, point_lines}, + render::{Hooks, OpenCell, Pointed, draw_nodes, point_lines}, }, forms::{self, Down, Up}, primitives::FocusRing as _, @@ -72,11 +72,7 @@ impl MarkdownDocument { // Focus fades all but the reader's block. let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx); let open = self.open.as_ref(); - let point = cx.listener(|document, event: &MouseDownEvent, window, cx| { - window.prevent_default(); - cx.stop_propagation(); - document.point_open(event.position, cx); - }); + let point = Self::point_in_open(cx); let field = |open: &super::Open| { div() .debug_selector(|| "markdown-open".to_string()) @@ -98,9 +94,25 @@ impl MarkdownDocument { (_, false) => div().into_any_element(), }; }; - if let Some(open) = open.filter(|open| open.range == block.range) { + if let Some(open) = open.filter(|open| open.cell.is_none() && open.range == block.range) { return field(open); } + // A cell open in this block, typed in place. + let cell = open.and_then(|open| { + let inside = block.range.start <= open.range.start && open.range.end <= block.range.end; + Some(OpenCell { + at: open.cell.filter(|_| inside)?, + field: std::cell::RefCell::new(Some( + div() + .debug_selector(|| "markdown-open-cell".to_string()) + .on_mouse_down(MouseButton::Right, Self::point_in_open(cx)) + .rounded(radius) + .bg(colors.sunken) + .child(open.input.clone()) + .into_any_element(), + )), + }) + }); let shown = match &block.nodes { Some(nodes) => div() .flex() @@ -117,6 +129,7 @@ impl MarkdownDocument { on_link: &self.on_link, on_heading: &self.on_heading, on_point: &on_point, + cell: cell.as_ref(), }, self.look.scale, cx, @@ -155,6 +168,17 @@ impl MarkdownDocument { } } + /// A right press in the open field points there. + fn point_in_open( + cx: &mut Context, + ) -> impl Fn(&MouseDownEvent, &mut Window, &mut App) + 'static { + cx.listener(|document, event: &MouseDownEvent, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + document.point_open(event.position, cx); + }) + } + /// `element` opens block `ix`; a right press points. fn pressable( &self, diff --git a/src/documents/render.rs b/src/documents/render.rs index cd2f6ab..fc731ce 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -5,7 +5,6 @@ use gpui::{ IntoElement, ParentElement, RenderOnce, SharedString, StrikethroughStyle, Styled, StyledText, UnderlineStyle, Window, div, prelude::*, }; -use markdown::mdast::AlignKind; use super::{ contents::anchor, @@ -23,8 +22,10 @@ use crate::{ mod blocks; mod point; +mod table; pub(crate) use point::{OnPoint, Place, Pointed, point_lines}; +pub(crate) use table::{CellAt, OpenCell}; pub(crate) type OnLink = Rc; /// Reveals the heading whose source starts at a byte. @@ -96,6 +97,8 @@ pub(crate) struct Hooks<'a> { pub on_link: &'a Option, pub on_heading: &'a Option, pub on_point: &'a Option, + /// The cell typed in, drawn in its place. + pub cell: Option<&'a OpenCell>, } /// Draws nodes under one owner's ids and hooks. @@ -112,6 +115,8 @@ struct Draw<'a> { lists: std::cell::Cell, /// Drawing a table's cells. table: std::cell::Cell, + /// Tables drawn so far. + tables: std::cell::Cell, } impl Draw<'_> { @@ -302,60 +307,7 @@ impl Draw<'_> { self.point_code(block, Some(layout), text, *from) } Node::List { start, items } => self.list(start, items, cx), - Node::Table { aligns, head, rows } => { - let cell = |blocks: &[Node], column: usize, header: bool, cx: &App| { - div() - .flex_1() - .min_w_0() - .flex() - .px_3() - .py_1p5() - .when(aligns.get(column) == Some(&AlignKind::Right), |cell| { - cell.justify_end() - }) - .when(aligns.get(column) == Some(&AlignKind::Center), |cell| { - cell.justify_center() - }) - .when(header, |cell| { - cell.font_weight(FontWeight::MEDIUM) - .text_color(colors.fg_muted) - }) - .child( - div() - .max_w_full() - .flex() - .flex_col() - .gap_1() - .children(self.nodes(blocks, cx)), - ) - }; - div() - .flex() - .flex_col() - .rounded(theme.radius(Radius::Md)) - .border_1() - .border_color(colors.border) - .overflow_hidden() - .child( - div().flex().bg(colors.hover).children( - head.iter() - .enumerate() - .map(|(column, blocks)| cell(blocks, column, true, cx)), - ), - ) - .children(rows.iter().map(|row| { - div() - .flex() - .border_t_1() - .border_color(colors.border) - .children( - row.iter() - .enumerate() - .map(|(column, blocks)| cell(blocks, column, false, cx)), - ) - })) - .into_any_element() - } + Node::Table { aligns, head, rows } => self.table(aligns, head, rows, cx), Node::Rule => div() .debug_selector(|| "markdown-rule".into()) .my_2() @@ -443,6 +395,7 @@ pub(crate) fn draw_nodes( scale, lists: std::cell::Cell::new(0), table: std::cell::Cell::new(false), + tables: std::cell::Cell::new(0), }; draw.nodes(nodes, cx) } @@ -458,6 +411,7 @@ impl RenderOnce for MarkdownRenderer { on_link: &self.on_link, on_heading: &None, on_point: &None, + cell: None, }, next: std::cell::Cell::new(0), sections: &self.sections, @@ -466,6 +420,7 @@ impl RenderOnce for MarkdownRenderer { scale: 1.0, lists: std::cell::Cell::new(0), table: std::cell::Cell::new(false), + tables: std::cell::Cell::new(0), }; div() .flex() diff --git a/src/documents/render/point.rs b/src/documents/render/point.rs index 233159e..27a256e 100644 --- a/src/documents/render/point.rs +++ b/src/documents/render/point.rs @@ -1,11 +1,11 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent, + AnyElement, App, Div, InteractiveElement, IntoElement as _, MouseButton, MouseDownEvent, ParentElement as _, TextLayout, Window, div, }; -use super::Draw; +use super::{Draw, table::CellAt}; use crate::documents::tree::Inline; /// Where a press fell, as the drawing knows it. @@ -20,6 +20,8 @@ pub(crate) enum Place { from: usize, at: usize, }, + /// A table cell, pressed past its text. + Cell(CellAt), /// Somewhere the block holds no byte for. Block, } @@ -61,6 +63,19 @@ fn pointable( table: bool, point: impl Fn(&MouseDownEvent) -> Pointed + 'static, ) -> AnyElement { + match on_point { + Some(_) => pressed(div().child(element), on_point, table, point).into_any_element(), + None => element, + } +} + +/// `element` itself takes the presses `pointable` hands on. +fn pressed( + element: E, + on_point: &Option, + table: bool, + point: impl Fn(&MouseDownEvent) -> Pointed + 'static, +) -> E { let Some(on_point) = on_point.clone() else { return element; }; @@ -79,11 +94,9 @@ fn pointable( on_point(pointed, button, window, cx); } }; - div() + element .on_mouse_down(MouseButton::Left, press(MouseButton::Left)) .on_mouse_down(MouseButton::Right, press(MouseButton::Right)) - .child(element) - .into_any_element() } /// Drawn `text`, its lines written from source byte `from`. @@ -120,6 +133,13 @@ impl Draw<'_> { pointable(element, self.hooks.on_point, self.table.get(), point) } + /// A table cell taking its presses, past its text too. + pub(super) fn pressed_cell(&self, cell: Div, at: CellAt) -> Div { + pressed(cell, self.hooks.on_point, true, move |_| { + Pointed::at(Place::Cell(at)) + }) + } + /// Drawn `inline`; a press finds its byte through `layout`. pub(super) fn point_text( &self, diff --git a/src/documents/render/table.rs b/src/documents/render/table.rs new file mode 100644 index 0000000..a27eb36 --- /dev/null +++ b/src/documents/render/table.rs @@ -0,0 +1,99 @@ +use std::cell::RefCell; + +use gpui::{AnyElement, App, FontWeight, IntoElement, ParentElement, Styled, div, prelude::*}; +use markdown::mdast::AlignKind; + +use super::Draw; +use crate::{ + documents::tree::Node, + theme::{ActiveTheme, Radius}, +}; + +/// A cell: its table in drawing order, row from the head, column. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CellAt { + pub table: usize, + pub row: usize, + pub column: usize, +} + +/// The cell open for typing and its field, drawn once. +pub(crate) struct OpenCell { + pub at: CellAt, + pub field: RefCell>, +} + +impl Draw<'_> { + pub(super) fn table( + &self, + aligns: &[AlignKind], + head: &[Vec], + rows: &[Vec>], + cx: &App, + ) -> AnyElement { + let theme = cx.theme(); + let colors = theme.colors.clone(); + let table = self.tables.get(); + self.tables.set(table + 1); + let cell = |blocks: &[Node], row: usize, column: usize, cx: &App| { + let at = CellAt { table, row, column }; + let open = (self.hooks.cell) + .filter(|open| open.at == at) + .and_then(|open| open.field.borrow_mut().take()); + let drawn = div() + .debug_selector(move || format!("markdown-cell-{table}-{row}-{column}")) + .flex_1() + .min_w_0() + .flex() + .px_3() + .py_1p5() + .when(aligns.get(column) == Some(&AlignKind::Right), |cell| { + cell.justify_end() + }) + .when(aligns.get(column) == Some(&AlignKind::Center), |cell| { + cell.justify_center() + }) + .when(row == 0, |cell| { + cell.font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + }) + .child(match open { + Some(field) => div().w_full().child(field), + None => div() + .max_w_full() + .flex() + .flex_col() + .gap_1() + .children(self.nodes(blocks, cx)), + }); + self.pressed_cell(drawn, at) + }; + div() + .flex() + .flex_col() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child( + div().flex().bg(colors.hover).children( + head.iter() + .enumerate() + .map(|(column, blocks)| cell(blocks, 0, column, cx)), + ), + ) + .children(rows.iter().enumerate().map(|(row, cells)| { + div() + .flex() + .border_t_1() + .border_color(colors.border) + .children( + cells + .iter() + .enumerate() + .map(|(column, blocks)| cell(blocks, row + 1, column, cx)), + ) + })) + .into_any_element() + } +}