From 79e94f7e24dafb139b4624f4fc45f333f9dee3a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 14 Oct 2026 20:18:22 -0400 Subject: [PATCH] documents: a right press in an open block targets the pressed byte The open block's target was read at the selection's start and from the syntax tree only, so a press on the second of two selected links named the first, and an inline anchor read as its outer link. point_open now reads the byte under the press and resolves links as drawing does; delete_image takes that byte. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/documents/document/apart.rs | 7 +- src/documents/document/format.rs | 3 +- src/documents/document/point.rs | 111 +++++++++++++++++----- src/documents/document/tests/secondary.rs | 74 +++++++++++++-- src/documents/document/visual.rs | 4 +- 6 files changed, 165 insertions(+), 36 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50cb9dc..b8faa2f 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, 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). 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; 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. 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 fcb6d81..fa2d0a2 100644 --- a/src/documents/document/apart.rs +++ b/src/documents/document/apart.rs @@ -29,9 +29,10 @@ 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) + /// Removes the picture at source byte `at`, read apart. + pub fn delete_image(&mut self, at: usize, cx: &mut Context) -> Result<(), EditRefusal> { + let drop = move |full: &str, _| drop_image(full, at); + self.apart("delete image".to_string(), drop, cx) } /// `command` at the caret, read apart; refusals by event. diff --git a/src/documents/document/format.rs b/src/documents/document/format.rs index 9ab951d..7343dcc 100644 --- a/src/documents/document/format.rs +++ b/src/documents/document/format.rs @@ -62,11 +62,10 @@ pub enum EditRefusal { 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 { +pub(super) fn drop_image(text: &str, at: usize) -> 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)) diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 766ca38..74db591 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -1,13 +1,13 @@ use std::rc::Rc; -use gpui::{AppContext as _, Context, MouseButton, Window}; +use gpui::{AppContext as _, Context, MouseButton, Pixels, Point, 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}, + tree::{Node, Reader, alert, is_toc, lines_at}, }; /// A top-level block's kind. @@ -55,7 +55,7 @@ impl TargetBlock { /// What a secondary press landed on. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DocumentTarget { - /// The caret's source byte; an open block's as unwritten. + /// The pressed source byte; an open block's as unwritten. pub at: usize, /// None past the last block. pub block: Option, @@ -162,16 +162,17 @@ 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() { + /// A right press at `position`; the open block stays open. + pub(super) fn point_open(&mut self, position: Point, cx: &mut Context) { + let Some(open) = &self.open else { return log::warn!("markdown document: a press on a block closed since"); - } + }; + let pressed = open.input.read(cx).offset_for_point(position); let taken = self.snapshot(cx); - let (full, _, selection) = taken.clone(); + let (full, offset, _) = taken.clone(); cx.spawn(async move |document, cx| { let target = cx - .background_spawn(async move { target_at(&full, selection.start) }) + .background_spawn(async move { target_at(&full, offset + pressed) }) .await; document .update(cx, |document, cx| { @@ -187,18 +188,24 @@ impl MarkdownDocument { } } -/// What byte `at` of `text` holds. +/// What byte `at` of `text` holds, read as drawn. 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 + let top = (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)); + .find(|node| span(node).start <= at && at <= span(node).end); + let block = top.map(|node| TargetBlock::of(node, text)); + let mut drawn = Vec::new(); + if let Some(node) = top.filter(|node| !matches!(node, mdast::Node::Definition(_))) { + Reader::new(text, &root).top(node, &mut drawn); + } + let mut flat = Vec::new(); + flatten(&drawn, &mut flat); let around: Vec<&mdast::Node> = (nodes.iter().copied()) .filter(|node| span(node).contains(&at)) .collect(); @@ -210,16 +217,37 @@ fn target_at(text: &str, at: usize) -> DocumentTarget { _ => 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 written = || { + 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 picture = around + .iter() + .rev() + .find(|node| matches!(node, mdast::Node::Image(_) | mdast::Node::ImageReference(_))); + let shown = picture.and_then(|node| { + flat.iter().find_map(|kid| match kid { + Node::Image { at, url, link, .. } if *at == span(node).start => { + Some((url.clone(), link.clone())) + } + _ => None, + }) }); + let image = match &shown { + Some((url, _)) => Some(url.clone()), + None => picture.and_then(|node| match node { + mdast::Node::Image(picture) => Some(picture.url.clone()), + mdast::Node::ImageReference(picture) => defined(&picture.identifier), + _ => None, + }), + }; + let link = match shown { + Some((_, link)) => link, + None => drawn_link(&flat, text, at).unwrap_or_else(written), + }; let code = around.iter().find_map(|node| match node { mdast::Node::Code(code) => Some(code.value.clone()), _ => None, @@ -235,3 +263,42 @@ fn target_at(text: &str, at: usize) -> DocumentTarget { code, } } + +/// `nodes` and all they hold, depth first. +fn flatten<'a>(nodes: &'a [Node], out: &mut Vec<&'a Node>) { + for node in nodes { + out.push(node); + match node { + Node::Quote(body) | Node::Footnote { body, .. } | Node::Alert { body, .. } => { + flatten(body, out) + } + Node::List { items, .. } => items.iter().for_each(|(_, body)| flatten(body, out)), + Node::Table { head, rows, .. } => { + (head.iter().chain(rows.iter().flatten())).for_each(|cell| flatten(cell, out)) + } + _ => {} + } + } +} + +/// The link drawn at source byte `at`; none if undrawn. +fn drawn_link(nodes: &[&Node], text: &str, at: usize) -> Option> { + let inline = nodes.iter().find_map(|node| match node { + Node::Paragraph(inline) | Node::Heading(_, inline) + if (inline.sources.iter()).any(|(_, span, _)| span.contains(&at)) => + { + Some(inline) + } + _ => None, + })?; + let drawn = (0..inline.text.len()) + .filter(|ix| inline.text.is_char_boundary(*ix)) + .rev() + .find(|ix| { + inline + .source_at(text, *ix) + .is_some_and(|source| source <= at) + })?; + let link = (inline.links.iter()).find(|(range, _)| range.contains(&drawn)); + Some(link.map(|(_, url)| url.clone())) +} diff --git a/src/documents/document/tests/secondary.rs b/src/documents/document/tests/secondary.rs index 08dee8e..57f9fab 100644 --- a/src/documents/document/tests/secondary.rs +++ b/src/documents/document/tests/secondary.rs @@ -5,7 +5,7 @@ use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, po 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. +/// A right press near the open block's left edge. 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)); @@ -48,9 +48,11 @@ fn a_right_press_in_a_selection_keeps_the_block_and_the_selection(cx: &mut TestA Some(0..22), "kept open and selected" ); + let at = heard.borrow().first().expect("heard").at; + assert!(at < 6, "the pressed byte, not the selection's: {at}"); let target = DocumentTarget { link: Some("https://x.test".into()), - ..super::point::target(0, TargetBlock::Paragraph) + ..super::point::target(at, TargetBlock::Paragraph) }; assert_eq!(*heard.borrow(), [target]); assert_eq!(text(&document, cx), LINKED, "nothing written"); @@ -109,14 +111,12 @@ fn delete_image_takes_the_picture_away_in_one_step(cx: &mut TestAppContext) { 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"); + document.delete_image(0, 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"); + document.delete_image(4, cx).expect("taken"); }); cx.run_until_parked(); assert_eq!(text(&document, cx), "A B\n"); @@ -133,3 +133,65 @@ fn text_pastes_at_the_caret_with_no_block_open(cx: &mut TestAppContext) { cx.simulate_keystrokes("secondary-v"); assert_eq!(text(&document, cx), "New First.\n"); } + +/// A right press on byte `at` of the open block. +fn press_open_at(document: &Entity, at: usize, cx: &mut VisualTestContext) { + let spot = document.read_with(cx, |document, cx| { + let input = document.open.as_ref().expect("open").input.read(cx); + input.bounds_for_range(at..at + 1)[0].center() + }); + cx.simulate_mouse_down(spot, MouseButton::Right, Modifiers::none()); + cx.simulate_mouse_up(spot, MouseButton::Right, Modifiers::none()); + cx.run_until_parked(); +} + +const TWO: &str = "[one](https://a.test) [two](https://b.test)\n"; + +#[gpui::test] +fn a_press_in_a_selection_reads_the_pressed_link(cx: &mut TestAppContext) { + let (document, cx) = open(TWO, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 1, MouseButton::Left, cx); + select_open(&document, 0..43, cx); + press_open_at(&document, TWO.find("two").expect("two") + 1, cx); + assert_eq!(open_selection(&document, cx), Some(0..43), "kept"); + let link = heard + .borrow() + .first() + .and_then(|target| target.link.clone()); + assert_eq!(link.as_deref(), Some("https://b.test")); +} + +const PICTURES: &str = "x ![a](a.png) ![b](b.png)\n"; + +#[gpui::test] +fn delete_image_takes_the_pressed_picture(cx: &mut TestAppContext) { + let (document, cx) = open(PICTURES, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 0, MouseButton::Left, cx); + select_open(&document, 0..25, cx); + press_open_at(&document, PICTURES.find("b.png").expect("b"), cx); + let target = heard.borrow().first().cloned().expect("heard"); + assert_eq!(target.image.as_deref(), Some("b.png")); + document.update(cx, |document, cx| { + document.delete_image(target.at, cx).expect("taken"); + }); + cx.run_until_parked(); + assert_eq!(opened(&document, cx), "x ![a](a.png) "); +} + +const ANCHORED: &str = "[inside](https://outer.test)\n"; + +#[gpui::test] +fn an_open_block_reads_an_anchor_as_drawn(cx: &mut TestAppContext) { + let (document, cx) = open(ANCHORED, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 1, MouseButton::Left, cx); + select_open(&document, 0..0, cx); + press_open_at(&document, ANCHORED.find("inside").expect("word") + 2, cx); + let link = heard + .borrow() + .first() + .and_then(|target| target.link.clone()); + assert_eq!(link.as_deref(), Some("https://inner.test")); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 7cc46ae..ccd3ffd 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -72,10 +72,10 @@ 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| { + let point = cx.listener(|document, event: &MouseDownEvent, window, cx| { window.prevent_default(); cx.stop_propagation(); - document.point_open(cx); + document.point_open(event.position, cx); }); let field = |open: &super::Open| { div()