diff --git a/AGENTS.md b/AGENTS.md index 0af3016..e4fbd46 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 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`), 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; 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, read apart from the text with the block in place, so a definition elsewhere resolves a reference; it is dropped if the text changed meanwhile. 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` takes the picture at the caret away in one step, refused elsewhere as `EditRefusal::NoImage` (Typora's Delete Image; `InlineFormat::Image` keeps the alt text). `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 ddc3466..fcb6d81 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -7,7 +7,7 @@ use gpui::{AppContext as _, Context}; use super::{ DocumentEvent, LARGEST_READ, MarkdownDocument, block::{self, BlockCommand, settled}, - format::{Change, EditRefusal, InlineFormat, toggle}, + format::{Change, EditRefusal, InlineFormat, drop_image, toggle}, tables::{self, TableEdit}, }; use crate::documents::{MarkdownMode, modes::joint}; @@ -29,6 +29,11 @@ impl MarkdownDocument { ) } + /// Removes the picture at the caret, read apart. + pub fn delete_image(&mut self, cx: &mut Context) -> Result<(), EditRefusal> { + self.apart("delete image".to_string(), drop_image, cx) + } + /// `command` at the caret, read apart; refusals by event. pub fn block( &mut self, @@ -107,7 +112,7 @@ impl MarkdownDocument { } /// The text an edit reads: the open block's in place. - fn snapshot(&self, cx: &Context) -> Snapshot { + pub(super) fn snapshot(&self, cx: &Context) -> Snapshot { let text = self.editor.read(cx).text(); let Some(open) = &self.open else { let picked = self.editor.read(cx).primary().range(); diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index b9a2539..9ab951d 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -40,6 +40,8 @@ pub enum EditRefusal { Outside, /// A table holds the caret. Table, + /// No picture holds the caret. + NoImage, /// Front matter is there already. Present, /// The selection crosses a block or another mark. @@ -59,6 +61,23 @@ pub enum EditRefusal { /// The edits and the selection after them. pub(super) type Change = (Vec<(Range, String)>, Range); +/// Removes the picture at the caret, as Typora's Delete Image. +pub(super) fn drop_image(text: &str, selection: Range) -> Result { + let root = file_tree(text); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + let at = selection.start; + let picture = (nodes.iter()) + .filter(|node| matches!(node, Node::Image(_) | Node::ImageReference(_))) + .map(|node| span(node)) + .find(|range| range.contains(&at)) + .ok_or(EditRefusal::NoImage)?; + Ok(( + vec![(picture.clone(), String::new())], + picture.start..picture.start, + )) +} + /// Typora's word stops: punctuation and space. fn stop(ch: char) -> bool { ch.is_whitespace() || "()[]^&_-+*#@!~`\\/{}'\":;<>,.?%".contains(ch) diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 667a980..766ca38 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -1,10 +1,11 @@ use std::rc::Rc; -use gpui::{Context, MouseButton, Window}; +use gpui::{AppContext as _, Context, MouseButton, Window}; use markdown::mdast; use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block}; use crate::documents::{ + markdown::{file_tree, span, walk}, render::{OnPoint, Place, Pointed}, tree::{Node, alert, is_toc, lines_at}, }; @@ -54,7 +55,7 @@ impl TargetBlock { /// What a secondary press landed on. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DocumentTarget { - /// The source byte the caret went to. + /// The caret's source byte; an open block's as unwritten. pub at: usize, /// None past the last block. pub block: Option, @@ -160,4 +161,77 @@ impl MarkdownDocument { })); cx.notify(); } + + /// A right press in the open block; it stays open. + pub(super) fn point_open(&mut self, cx: &mut Context) { + if self.open.is_none() { + return log::warn!("markdown document: a press on a block closed since"); + } + let taken = self.snapshot(cx); + let (full, _, selection) = taken.clone(); + cx.spawn(async move |document, cx| { + let target = cx + .background_spawn(async move { target_at(&full, selection.start) }) + .await; + document + .update(cx, |document, cx| { + if document.open.is_none() || document.snapshot(cx) != taken { + return log::info!("markdown document: the pressed block changed first"); + } + log::info!("markdown document: a secondary press in the open block"); + cx.emit(DocumentEvent::Pointed(target)); + }) + .ok(); + }) + .detach(); + } +} + +/// What byte `at` of `text` holds. +fn target_at(text: &str, at: usize) -> DocumentTarget { + let root = file_tree(text); + let mut nodes = Vec::new(); + walk(&root, &mut nodes); + let block = (root + .children() + .map(Vec::as_slice) + .unwrap_or_default() + .iter()) + .find(|node| span(node).start <= at && at <= span(node).end) + .map(|node| TargetBlock::of(node, text)); + let around: Vec<&mdast::Node> = (nodes.iter().copied()) + .filter(|node| span(node).contains(&at)) + .collect(); + let defined = |identifier: &str| { + nodes.iter().find_map(|node| match node { + mdast::Node::Definition(definition) if definition.identifier == identifier => { + Some(definition.url.clone()) + } + _ => None, + }) + }; + let link = around.iter().rev().find_map(|node| match node { + mdast::Node::Link(link) => Some(link.url.clone()), + mdast::Node::LinkReference(link) => defined(&link.identifier), + _ => None, + }); + let image = around.iter().rev().find_map(|node| match node { + mdast::Node::Image(picture) => Some(picture.url.clone()), + mdast::Node::ImageReference(picture) => defined(&picture.identifier), + _ => None, + }); + let code = around.iter().find_map(|node| match node { + mdast::Node::Code(code) => Some(code.value.clone()), + _ => None, + }); + DocumentTarget { + at, + block, + link, + image, + in_table: around + .iter() + .any(|node| matches!(node, mdast::Node::Table(_))), + code, + } } diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 396edbc..ff93c31 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -377,6 +377,7 @@ mod place; mod point; mod presses; mod reading; +mod secondary; mod toc; mod tools; diff --git a/src/documents/document/tests/secondary.rs b/src/documents/document/tests/secondary.rs new file mode 100644 index 0000000..872f101 --- /dev/null +++ b/src/documents/document/tests/secondary.rs @@ -0,0 +1,126 @@ +//! Right presses that keep a selection; Delete Image (R4.10). + +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; + +use super::{MarkdownMode, open, opened, point::heard, point::press_on, text}; +use crate::documents::{DocumentTarget, EditRefusal, MarkdownDocument, TargetBlock}; + +/// A right press near the left of the open block's text. +fn press_open(cx: &mut VisualTestContext) { + let field = cx.debug_bounds("markdown-open").expect("a block is open"); + let at = point(field.left() + px(20.0), field.top() + px(14.0)); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none()); + cx.run_until_parked(); +} + +fn open_selection( + document: &Entity, + cx: &mut VisualTestContext, +) -> Option> { + document.read_with(cx, |document, cx| { + (document.open.as_ref()).map(|open| open.input.read(cx).selection()) + }) +} + +fn select_open( + document: &Entity, + range: std::ops::Range, + cx: &mut VisualTestContext, +) { + document.update(cx, |document, cx| { + let input = document.open.as_ref().expect("open").input.clone(); + input.update(cx, |input, cx| input.select(range, cx)); + }); +} + +const LINKED: &str = "[docs](https://x.test)\n\nNext.\n"; + +#[gpui::test] +fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestAppContext) { + let (document, cx) = open(LINKED, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 1, MouseButton::Left, cx); + select_open(&document, 0..22, cx); + press_open(cx); + assert_eq!( + open_selection(&document, cx), + Some(0..22), + "kept open and selected" + ); + let target = DocumentTarget { + link: Some("https://x.test".into()), + ..super::point::target(0, TargetBlock::Paragraph) + }; + assert_eq!(*heard.borrow(), [target]); + assert_eq!(text(&document, cx), LINKED, "nothing written"); +} + +#[gpui::test] +fn a_right_press_outside_the_selection_moves_the_caret(cx: &mut TestAppContext) { + let (document, cx) = open(LINKED, MarkdownMode::Visual, cx); + press_on(0, 1, MouseButton::Left, cx); + select_open(&document, 22..22, cx); + press_open(cx); + let at = open_selection(&document, cx).expect("still open"); + assert!( + at.is_empty() && at.start < 5, + "the caret went to the press: {at:?}" + ); +} + +#[gpui::test] +fn a_reference_in_the_open_block_reads_its_definition_outside(cx: &mut TestAppContext) { + let source = "[docs][d]\n\n[d]: https://x.test\n"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 1, MouseButton::Left, cx); + select_open(&document, 0..9, cx); + press_open(cx); + let link = heard + .borrow() + .first() + .and_then(|target| target.link.clone()); + assert_eq!(link.as_deref(), Some("https://x.test")); + assert_eq!(opened(&document, cx), "[docs][d]"); +} + +#[gpui::test] +fn a_right_press_in_source_keeps_the_selection(cx: &mut TestAppContext) { + let (document, cx) = open("alpha beta\n", MarkdownMode::Source, cx); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + editor.update(cx, |editor, cx| editor.select(std::iter::once(0..10), cx)); + let line = cx.debug_bounds("editor-line-0").expect("drawn"); + let at = point(line.left() + px(60.0), line.top() + px(6.0)); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none()); + cx.run_until_parked(); + let kept = editor.read_with(cx, |editor, _| editor.primary().range()); + assert_eq!(kept, 0..10); + editor.update(cx, |editor, cx| editor.select(std::iter::once(10..10), cx)); + cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none()); + cx.run_until_parked(); + let moved = editor.read_with(cx, |editor, _| editor.primary().range()); + assert!(moved.is_empty() && moved.start < 10, "{moved:?}"); +} + +#[gpui::test] +fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) { + let source = "A ![p](p.png) B\n"; + let (document, cx) = open(source, MarkdownMode::Source, cx); + let heard = super::format::refusals(&document, cx); + document.update(cx, |document, cx| { + document.set_caret(0, 0, cx); + document.delete_image(cx).expect("taken"); + }); + cx.run_until_parked(); + assert_eq!(*heard.borrow(), [EditRefusal::NoImage]); + document.update(cx, |document, cx| { + document.set_caret(0, 4, cx); + document.delete_image(cx).expect("taken"); + }); + cx.run_until_parked(); + assert_eq!(text(&document, cx), "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), source); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 7a3f5d0..8ab30a2 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -72,8 +72,15 @@ 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, _: &MouseDownEvent, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + document.point_open(cx); + }); let field = |open: &super::Open| { div() + .debug_selector(|| "markdown-open".to_string()) + .on_mouse_down(MouseButton::Right, point) .text_size(body) .px_3() .py_2() diff --git a/src/editor/line.rs b/src/editor/line.rs index af59f1c..3c00338 100644 --- a/src/editor/line.rs +++ b/src/editor/line.rs @@ -335,6 +335,12 @@ impl CodeEditor { editor.press(line, event, window, cx) }), ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |editor, event: &MouseDownEvent, window, cx| { + editor.secondary_press(line, event, window, cx) + }), + ) .on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| { editor.drag(line, event, cx) })); @@ -392,6 +398,27 @@ impl CodeEditor { self.set_selections(chosen, cx); } + /// A right press keeps a selection it lands in. + pub(crate) fn secondary_press( + &mut self, + line: usize, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + window.focus(&self.focus, cx); + let Some(at) = self.offset_in(line, event.position.x, cx) else { + return; + }; + let held = (self.selections.iter()).any(|selection| { + let range = selection.range(); + !range.is_empty() && (range.start..=range.end).contains(&at) + }); + if !held { + self.set_selections(vec![Selection::caret(at)], cx); + } + } + /// A drag over a line stretches the primary selection to where the pointer is. pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context) { if !self.dragging || !event.dragging() { diff --git a/src/forms/text/actions.rs b/src/forms/text/actions.rs index e9ca77c..9f0b037 100644 --- a/src/forms/text/actions.rs +++ b/src/forms/text/actions.rs @@ -343,6 +343,25 @@ impl TextInput { } } + /// A right press keeps a selection it lands in. + fn secondary_down( + &mut self, + event: &MouseDownEvent, + window: &mut Window, + cx: &mut Context, + ) { + if self.disabled { + return; + } + window.focus(&self.focus, cx); + let at = self.offset_for_point(event.position); + let held = + !self.selection.is_empty() && (self.selection.start..=self.selection.end).contains(&at); + if !held { + self.move_to(at, cx); + } + } + fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { if self.selecting && event.dragging() { let at = self.offset_for_point(event.position); @@ -405,6 +424,7 @@ impl Render for TextInput { .on_action(cx.listener(Self::submit)) .on_action(cx.listener(Self::show_character_palette)) .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down)) + .on_mouse_down(MouseButton::Right, cx.listener(Self::secondary_down)) .on_mouse_move(cx.listener(Self::mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))