diff --git a/AGENTS.md b/AGENTS.md index fc2a92b..534fa5c 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 a paragraph's lines: 40,000 lines take 0.9 s). 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. 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 a paragraph's lines: 40,000 lines take 0.9 s). 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, read back past line joins and escapes, and text that does not read back (references, emoji) lands at its piece's start; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing: it writes back an open block, moves the caret there and emits `DocumentEvent::Pointed`, whose `DocumentTarget` names the block's kind, a link's or picture's address, and whether it lies in a table or code. 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/edit.rs b/src/documents/document/edit.rs index e5ebab9..9a28a20 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -12,8 +12,17 @@ use crate::{ forms::{InputEvent, TextInput}, }; +/// Where an opened block's caret lands. +#[derive(Clone, Copy, Debug)] +pub(super) enum Landing { + Start, + End, + /// A source byte of the drawn read. + At(usize), +} + /// Where byte `at` lies after `edit`, if it survives. -fn moved(at: usize, edit: &Option<(Range, usize)>) -> Option { +pub(super) fn moved(at: usize, edit: &Option<(Range, usize)>) -> Option { match edit { None => Some(at), Some((replaced, _)) if at < replaced.start => Some(at), @@ -27,7 +36,7 @@ impl MarkdownDocument { pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context) { let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx); if self.mode == MarkdownMode::Visual && self.open.is_none() && empty { - self.open(None, false, window, cx); + self.open(None, Landing::End, window, cx); } } @@ -47,7 +56,7 @@ impl MarkdownDocument { pub(super) fn open( &mut self, ix: Option, - at_start: bool, + landing: Landing, window: &mut Window, cx: &mut Context, ) { @@ -78,6 +87,17 @@ impl MarkdownDocument { (block.range.clone(), false) } }; + let caret = match landing { + Landing::Start => Some(0), + Landing::End => None, + Landing::At(at) => match moved(at, &written) { + Some(at) if range.contains(&at) || at == range.end => Some(at - range.start), + _ => { + log::warn!("markdown document: the pressed byte left its block"); + None + } + }, + }; let source = text[range.clone()].to_string(); let input = cx.new(|cx| { TextInput::new(window, cx) @@ -86,8 +106,8 @@ impl MarkdownDocument { }); input.update(cx, |input, cx| { input.set_text(source, cx); - if at_start { - input.select(0..0, cx); + if let Some(caret) = caret { + input.select(caret..caret, cx); } }); window.focus(&input.focus_handle(cx), cx); @@ -166,7 +186,8 @@ impl MarkdownDocument { let Some(next) = next else { return false; }; - self.open(Some(next), down, window, cx); + let landing = if down { Landing::Start } else { Landing::End }; + self.open(Some(next), landing, window, cx); true } } diff --git a/src/documents/document/finding.rs b/src/documents/document/finding.rs index 47932de..feafb7b 100644 --- a/src/documents/document/finding.rs +++ b/src/documents/document/finding.rs @@ -4,7 +4,7 @@ use std::ops::Range; use gpui::{Context, Window}; -use super::MarkdownDocument; +use super::{MarkdownDocument, edit::Landing}; use crate::documents::MarkdownMode; impl MarkdownDocument { @@ -42,7 +42,7 @@ impl MarkdownDocument { return self.reveal(line(self, cx), window, cx); }; let start = block.start; - self.open(Some(ix), false, window, cx); + self.open(Some(ix), Landing::End, window, cx); self.list.scroll_to_reveal_item(ix); if let Some(open) = &self.open { let input = open.input.clone(); diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index 0b524e5..fb6e79e 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -5,6 +5,7 @@ mod lists; mod look; mod parse; mod place; +mod point; mod tables; #[cfg(all(test, feature = "test-support"))] mod tests; @@ -23,6 +24,7 @@ pub use crate::documents::tree::DocumentHeading; pub use attach::Attachment; pub use look::DocumentLook; use parse::{Parsed, alike, parsed}; +pub use point::{DocumentTarget, TargetBlock}; pub use tables::{TableEdit, TableRefusal}; use super::{ @@ -46,12 +48,14 @@ pub const LARGEST_READ: usize = 2 << 20; const PAUSE: Duration = Duration::from_millis(300); /// What the document tells its owner. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] pub enum DocumentEvent { /// The text changed, typed in any mode or replaced. Changed, /// A new read of the text: blocks and headings. Parsed, + /// A secondary press moved the caret onto this. + Pointed(DocumentTarget), } /// A top line asked, numbered so late callbacks know theirs. diff --git a/src/documents/document/parse.rs b/src/documents/document/parse.rs index 4061b12..09a34a9 100644 --- a/src/documents/document/parse.rs +++ b/src/documents/document/parse.rs @@ -7,10 +7,13 @@ use crate::documents::{ tree::{DocumentHeading, Node, Reader}, }; +use super::TargetBlock; + /// A top-level block: its source, and what it draws. #[derive(Clone, Debug, PartialEq)] pub(crate) struct Block { pub range: Range, + pub kind: TargetBlock, /// None shows a link definition as written. pub nodes: Option>, } @@ -39,10 +42,12 @@ pub(crate) fn parsed(text: &str) -> Parsed { let nodes = (!raw).then(|| { let mut out = Vec::new(); reader.top(node, &mut out); + out.iter_mut().for_each(|drawn| drawn.rebase(range.start)); out }); Block { range: range.start..range.start + kept, + kind: TargetBlock::of(node, text), nodes, } }) @@ -149,6 +154,31 @@ mod tests { assert_eq!(alike(&old, &shorter), (1, 0)); } + #[test] + fn pictures_and_code_count_from_their_block() { + let old = parsed( + "a + +![p](p.png) + +``` +x +``` +", + ); + let new = parsed( + "a and more + +![p](p.png) + +``` +x +``` +", + ); + assert_eq!(alike(&old, &new), (0, 2)); + } + #[test] fn a_reference_resolves_across_blocks() { let found = parsed("[site]\n\n[site]: https://ely.dev\n"); diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs new file mode 100644 index 0000000..47aaaa5 --- /dev/null +++ b/src/documents/document/point.rs @@ -0,0 +1,136 @@ +use std::rc::Rc; + +use gpui::{Context, MouseButton, Window}; +use markdown::mdast; + +use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved}; +use crate::documents::{ + render::{OnPoint, Place, Pointed}, + tree::{alert, is_toc}, +}; + +/// A top-level block's kind. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TargetBlock { + Paragraph, + Heading(u8), + Quote, + Alert, + List, + Code, + Math, + Table, + Rule, + Html, + Footnote, + Definition, + FrontMatter, + Toc, +} + +impl TargetBlock { + /// `node`'s kind, read from `source`. + pub(super) fn of(node: &mdast::Node, source: &str) -> Self { + match node { + mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc, + mdast::Node::Paragraph(_) => Self::Paragraph, + mdast::Node::Heading(heading) => Self::Heading(heading.depth), + mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert, + mdast::Node::Blockquote(_) => Self::Quote, + mdast::Node::List(_) => Self::List, + mdast::Node::Code(_) => Self::Code, + mdast::Node::Math(_) => Self::Math, + mdast::Node::Table(_) => Self::Table, + mdast::Node::ThematicBreak(_) => Self::Rule, + mdast::Node::Html(_) => Self::Html, + mdast::Node::FootnoteDefinition(_) => Self::Footnote, + mdast::Node::Definition(_) => Self::Definition, + mdast::Node::Yaml(_) | mdast::Node::Toml(_) => Self::FrontMatter, + other => unreachable!("{other:?} is no block outside MDX"), + } + } +} + +/// What a secondary press landed on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DocumentTarget { + /// The source byte the caret went to. + pub at: usize, + /// None past the last block. + pub block: Option, + pub link: Option, + pub image: Option, + pub in_table: bool, + pub in_code: bool, +} + +impl MarkdownDocument { + /// Presses on block `ix`'s drawing, read as it was drawn. + pub(super) fn on_point(&self, ix: usize, cx: &mut Context) -> OnPoint { + let (document, parsed) = (cx.entity().downgrade(), self.parsed.clone()); + let start = parsed.blocks[ix].range.start; + Rc::new(move |pointed, button, window, cx| { + // The drawing counts from its block's start. + let at = match &pointed.place { + Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn), + Place::At(at) => Some(*at), + Place::Block => None, + } + .map(|at| start + at); + 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::Right => document.point(ix, at, pointed, window, cx), + _ => {} + }) + .expect("a block's drawing lives in its document"); + }) + } + + /// Caret to `at`, else block `ix`; the owner hears. + pub(super) fn point( + &mut self, + ix: Option, + at: Option, + pointed: Pointed, + window: &mut Window, + cx: &mut Context, + ) { + let block = match ix.map(|ix| self.parsed.blocks.get(ix)) { + Some(None) => return log::warn!("markdown document: a press on a block read away"), + block => block.flatten(), + }; + let kind = block.map(|block| block.kind); + let at = at.or(block.map(|block| block.range.start)); + let written = self.commit(cx); + if written.is_some() { + self.reparse(true, cx); + } + if !self.read_now(cx) { + return log::warn!("markdown document: pressed while the text is read again"); + } + let at = match at { + None => self.editor.read(cx).text().len(), + Some(at) => match moved(at, &written) { + Some(at) => at, + None => return log::warn!("markdown document: the pressed text was written over"), + }, + }; + self.editor + .update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx)); + window.focus(&self.focus, cx); + log::info!("markdown document: a secondary press puts the caret at byte {at}"); + cx.emit(DocumentEvent::Pointed(DocumentTarget { + at, + block: kind, + link: pointed.link, + image: pointed.image, + in_table: pointed.table || kind == Some(TargetBlock::Table), + in_code: pointed.code || kind == Some(TargetBlock::Code), + })); + cx.notify(); + } +} diff --git a/src/documents/document/tests/look.rs b/src/documents/document/tests/look.rs index 9658dcc..700490a 100644 --- a/src/documents/document/tests/look.rs +++ b/src/documents/document/tests/look.rs @@ -2,6 +2,7 @@ use gpui::{TestAppContext, px, rems}; use super::{MarkdownMode, frames, open}; use crate::documents::DocumentLook; +use crate::documents::document::edit::Landing; /// Sixty short paragraphs, each its own block. fn long() -> String { @@ -30,7 +31,7 @@ fn typewriter_keeps_the_open_block_mid_view(cx: &mut TestAppContext) { }; document.update_in(cx, |document, window, cx| { document.set_look(look, cx); - document.open(Some(40), false, window, cx); + document.open(Some(40), Landing::End, window, cx); document.list.scroll_to_reveal_item(40); }); frames(cx); @@ -99,7 +100,7 @@ fn the_scale_sizes_source_lines(cx: &mut TestAppContext) { fn focus_counts_the_open_block_as_the_readers(cx: &mut TestAppContext) { let (document, cx) = open(&long(), MarkdownMode::Visual, cx); document.update_in(cx, |document, window, cx| { - document.open(Some(3), false, window, cx) + document.open(Some(3), Landing::End, window, cx) }); assert_eq!( document.read_with(cx, |document, cx| document.current_block(cx)), @@ -137,7 +138,7 @@ fn off_middle( fn an_open_block_takes_the_scale(cx: &mut TestAppContext) { let (document, cx) = open("A paragraph.\n", MarkdownMode::Visual, cx); document.update_in(cx, |document, window, cx| { - document.open(Some(0), false, window, cx) + document.open(Some(0), Landing::End, window, cx) }); frames(cx); let plain = document @@ -163,7 +164,7 @@ fn a_resize_centers_the_block_again(cx: &mut TestAppContext) { let (document, cx) = open(&long(), MarkdownMode::Visual, cx); document.update_in(cx, |document, window, cx| { document.set_look(typewriter(), cx); - document.open(Some(40), false, window, cx); + document.open(Some(40), Landing::End, window, cx); document.list.scroll_to_reveal_item(40); }); frames(cx); @@ -177,13 +178,13 @@ fn the_first_and_last_blocks_center(cx: &mut TestAppContext) { let (document, cx) = open(&long(), MarkdownMode::Visual, cx); document.update_in(cx, |document, window, cx| { document.set_look(typewriter(), cx); - document.open(Some(0), false, window, cx); + document.open(Some(0), Landing::End, window, cx); }); frames(cx); let first = off_middle(&document, 0, cx); assert!(first < px(4.0), "the first is {first:?} off"); document.update_in(cx, |document, window, cx| { - document.open(Some(59), false, window, cx); + document.open(Some(59), Landing::End, window, cx); document.list.scroll_to_reveal_item(59); }); frames(cx); diff --git a/src/documents/document/tests/mod.rs b/src/documents/document/tests/mod.rs index 410036e..200161f 100644 --- a/src/documents/document/tests/mod.rs +++ b/src/documents/document/tests/mod.rs @@ -2,7 +2,7 @@ use std::{cell::RefCell, rc::Rc}; use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; -use super::{AT_ONCE, DocumentEvent, MarkdownDocument, visual}; +use super::{AT_ONCE, DocumentEvent, MarkdownDocument, edit::Landing, visual}; use crate::documents::MarkdownMode; const TEXT: &str = "# Lift\n\nBlend toward white.\n\n- one\n- two\n"; @@ -236,7 +236,7 @@ pub(super) fn type_in( cx: &mut VisualTestContext, ) { document.update_in(cx, |document, window, cx| { - document.open(ix, false, window, cx); + document.open(ix, Landing::End, window, cx); if let (Some(typed), Some(open)) = (typed, &document.open) { open.input.update(cx, |input, cx| input.set_text(typed, cx)); } @@ -353,11 +353,11 @@ fn a_press_after_a_write_back_waits_for_the_long_read(cx: &mut TestAppContext) { let long = format!("A\n\nB\n\nCCCC\n\n{}", "Prose.\n\n".repeat(AT_ONCE / 8 + 1)); let (document, cx) = open(&long, MarkdownMode::Visual, cx); document.update_in(cx, |document, window, cx| { - document.open(Some(0), false, window, cx); + document.open(Some(0), Landing::End, window, cx); let input = document.open.as_ref().expect("open").input.clone(); input.update(cx, |input, cx| input.set_text("AAAA", cx)); // B is pressed before the new read lands. - document.open(Some(1), false, window, cx); + document.open(Some(1), Landing::End, window, cx); assert!(document.open.is_none(), "a stale range opens nothing"); }); cx.run_until_parked(); @@ -370,6 +370,7 @@ mod history; mod links; mod look; mod place; +mod point; mod toc; mod tools; diff --git a/src/documents/document/tests/point.rs b/src/documents/document/tests/point.rs new file mode 100644 index 0000000..ca49cb9 --- /dev/null +++ b/src/documents/document/tests/point.rs @@ -0,0 +1,165 @@ +//! A press lands on the pressed character (R4.5). + +use std::{cell::RefCell, rc::Rc}; + +use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; + +use super::{open, type_in}; +use crate::{ + documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock}, + theme::{ActiveTheme, TextSize}, +}; + +/// Presses block `ix` on its first line's character `k`. +fn press_on(ix: usize, k: usize, button: MouseButton, cx: &mut VisualTestContext) { + let bounds = cx + .debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str())) + .expect("the block is drawn"); + // The test text system sets glyphs 0.6 em apart. + let size = cx.update(|window, cx| { + cx.theme() + .text_size(TextSize::Base) + .to_pixels(window.rem_size()) + }); + let em = size * 0.6; + let x = bounds.left() + px(13.0) + em * (k as f32 + 0.25); + let at = point(x, bounds.top() + px(10.0)); + cx.simulate_mouse_down(at, button, Modifiers::none()); + cx.simulate_mouse_up(at, button, Modifiers::none()); + cx.run_until_parked(); +} + +fn heard( + document: &Entity, + cx: &mut VisualTestContext, +) -> Rc>> { + let heard = Rc::new(RefCell::new(Vec::new())); + let kept = heard.clone(); + cx.update(|_, cx| { + cx.subscribe(document, move |_, event: &DocumentEvent, _| { + if let DocumentEvent::Pointed(target) = event { + kept.borrow_mut().push(target.clone()); + } + }) + .detach() + }); + heard +} + +fn caret(document: &Entity, cx: &mut VisualTestContext) -> usize { + document.read_with(cx, |document, cx| document.editor.read(cx).primary().head) +} + +fn opened_caret(document: &Entity, cx: &mut VisualTestContext) -> usize { + document.read_with(cx, |document, cx| { + document + .open + .as_ref() + .expect("open") + .input + .read(cx) + .cursor() + }) +} + +fn target(at: usize, block: TargetBlock) -> DocumentTarget { + DocumentTarget { + at, + block: Some(block), + link: None, + image: None, + in_table: false, + in_code: false, + } +} + +const TEXT: &str = "First.\n\nSay **bold** and more.\n"; + +#[gpui::test] +fn a_press_opens_the_block_at_the_pressed_character(cx: &mut TestAppContext) { + let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); + press_on(1, 4, MouseButton::Left, cx); + assert_eq!( + opened_caret(&document, cx), + "Say **".len(), + "on the b of bold" + ); +} + +#[gpui::test] +fn a_secondary_press_moves_the_caret_and_opens_nothing(cx: &mut TestAppContext) { + let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(1, 4, MouseButton::Right, cx); + assert!(document.read_with(cx, |document, _| document.open.is_none())); + let at = TEXT.find("bold").unwrap(); + assert_eq!(caret(&document, cx), at); + assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]); +} + +#[gpui::test] +fn a_secondary_press_on_a_link_names_its_address(cx: &mut TestAppContext) { + let text = "Go [there](https://ely.dev) now\n"; + let (document, cx) = open(text, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 4, MouseButton::Right, cx); + let expected = DocumentTarget { + link: Some("https://ely.dev".into()), + ..target(text.find("here").unwrap(), TargetBlock::Paragraph) + }; + assert_eq!(*heard.borrow(), [expected]); +} + +#[gpui::test] +fn a_code_press_lands_in_its_code(cx: &mut TestAppContext) { + let text = "```rust\nlet x = 1;\n```\n"; + let (document, cx) = open(text, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(0, 0, MouseButton::Right, cx); + let expected = DocumentTarget { + in_code: true, + ..target("```rust\n".len(), TargetBlock::Code) + }; + assert_eq!(*heard.borrow(), [expected]); +} + +#[gpui::test] +fn a_secondary_press_on_a_picture_names_it(cx: &mut TestAppContext) { + let text = "Lead.\n\n![pic](missing.png)\n"; + let (document, cx) = open(text, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + press_on(1, 1, MouseButton::Right, cx); + let expected = DocumentTarget { + image: Some("missing.png".into()), + ..target(text.find("![").unwrap(), TargetBlock::Paragraph) + }; + assert_eq!(*heard.borrow(), [expected]); +} + +#[gpui::test] +fn a_secondary_press_writes_the_open_block_first(cx: &mut TestAppContext) { + let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + type_in(&document, Some(0), Some("First, longer."), cx); + press_on(1, 4, MouseButton::Right, cx); + let written = "First, longer.\n\nSay **bold** and more.\n"; + assert_eq!(super::text(&document, cx), written); + let at = written.find("bold").unwrap(); + assert_eq!(caret(&document, cx), at, "the byte moved with the write"); + assert_eq!(*heard.borrow(), [target(at, TargetBlock::Paragraph)]); +} + +#[gpui::test] +fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) { + let (document, cx) = open(TEXT, MarkdownMode::Visual, cx); + let heard = heard(&document, cx); + let end = cx.debug_bounds("markdown-block-1").expect("drawn").bottom() + px(12.0); + cx.simulate_mouse_down(point(px(40.0), end), MouseButton::Right, Modifiers::none()); + cx.run_until_parked(); + let expected = DocumentTarget { + block: None, + ..target(TEXT.len(), TargetBlock::Paragraph) + }; + assert_eq!(*heard.borrow(), [expected]); + assert!(document.read_with(cx, |document, _| document.open.is_none())); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 0dfa918..79a3931 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -4,9 +4,12 @@ use gpui::{ StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _, }; -use super::MarkdownDocument; +use super::{MarkdownDocument, edit::Landing}; use crate::{ - documents::{MarkdownMode, render::draw_nodes}, + documents::{ + MarkdownMode, + render::{Hooks, Pointed, draw_nodes}, + }, forms::{self, Down, Up}, primitives::FocusRing as _, theme::{ActiveTheme, Radius, TextSize}, @@ -55,6 +58,9 @@ impl MarkdownDocument { /// A block, or the end. fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context) -> AnyElement { + let editing = self.mode == MarkdownMode::Visual; + let drawn = editing && ix < self.parsed.blocks.len(); + let on_point = drawn.then(|| self.on_point(ix, cx)); let theme = cx.theme(); let colors = theme.colors.clone(); let (radius, body, mono) = ( @@ -64,7 +70,6 @@ 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 editing = self.mode == MarkdownMode::Visual; let open = self.open.as_ref(); let field = |open: &super::Open| { div() @@ -99,9 +104,12 @@ impl MarkdownDocument { .children(draw_nodes( &("render", ix).into(), nodes, - &self.base, - &self.on_link, - &self.on_heading, + Hooks { + base: &self.base, + on_link: &self.on_link, + on_heading: &self.on_heading, + on_point: &on_point, + }, self.look.scale, cx, )), @@ -125,7 +133,7 @@ impl MarkdownDocument { } } - /// `element` opens block `ix` on a press, Enter or Space. + /// `element` opens block `ix`; a right press points. fn pressable( &self, element: gpui::Stateful, @@ -149,13 +157,21 @@ impl MarkdownDocument { } window.prevent_default(); cx.stop_propagation(); - document.open(ix, false, window, cx); + document.open(ix, Landing::End, window, cx); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |document, _: &MouseDownEvent, window, cx| { + window.prevent_default(); + cx.stop_propagation(); + document.point(ix, None, Pointed::block(), window, cx); }), ) .on_click( cx.listener(move |document, event: &ClickEvent, window, cx| { if event.is_keyboard() { - document.open(ix, false, window, cx); + document.open(ix, Landing::End, window, cx); } }), ) diff --git a/src/documents/mod.rs b/src/documents/mod.rs index 26d9eb8..a0046cb 100644 --- a/src/documents/mod.rs +++ b/src/documents/mod.rs @@ -36,8 +36,8 @@ pub use counts::{ReadingTime, WordCount}; pub use diagram::{Diagram, diagram_svg}; pub(crate) use diagram::{flatten, options as svg_options}; pub use document::{ - Attachment, DocumentEvent, DocumentHeading, DocumentLook, LARGEST_READ, MarkdownDocument, - TableEdit, TableRefusal, + Attachment, DocumentEvent, DocumentHeading, DocumentLook, DocumentTarget, LARGEST_READ, + MarkdownDocument, TableEdit, TableRefusal, TargetBlock, }; pub use epub::{Chapter, EpubReader}; pub use format::{Format, format}; diff --git a/src/documents/render.rs b/src/documents/render.rs index 349f850..5d79a83 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -22,6 +22,9 @@ use crate::{ }; mod blocks; +mod point; + +pub(crate) use point::{OnPoint, Place, Pointed}; pub(crate) type OnLink = Rc; /// Reveals the heading whose source starts at a byte. @@ -87,20 +90,28 @@ impl MarkdownRenderer { } } -/// Draws nodes under one owner's ids and link handler. +/// What an owner hands its drawing: pictures' folder and presses. +pub(crate) struct Hooks<'a> { + pub base: &'a Option>, + pub on_link: &'a Option, + pub on_heading: &'a Option, + pub on_point: &'a Option, +} + +/// Draws nodes under one owner's ids and hooks. struct Draw<'a> { id: &'a ElementId, - on_link: &'a Option, + hooks: Hooks<'a>, next: std::cell::Cell, sections: &'a Option>, headings: std::cell::Cell, code: &'a Option, - base: &'a Option>, /// Text size against the theme's. scale: f32, /// Lists open around the node drawn. lists: std::cell::Cell, - on_heading: &'a Option, + /// Drawing a table's cells. + table: std::cell::Cell, } impl Draw<'_> { @@ -174,16 +185,20 @@ impl Draw<'_> { .collect(); let styled = StyledText::new(inline.text.clone()).with_highlights(crate::editor::stack(styles)); - if inline.links.is_empty() { - return styled.into_any_element(); - } - let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip(); - let on_link = self.on_link.clone(); - InteractiveText::new(self.key("links"), styled) - .on_click(ranges, move |ix, window, cx| { - open_link(&on_link, &urls[ix], window, cx) - }) - .into_any_element() + let layout = styled.layout().clone(); + let drawn = match inline.links.is_empty() { + true => styled.into_any_element(), + false => { + let (ranges, urls): (Vec<_>, Vec<_>) = inline.links.iter().cloned().unzip(); + let on_link = self.hooks.on_link.clone(); + InteractiveText::new(self.key("links"), styled) + .on_click(ranges, move |ix, window, cx| { + open_link(&on_link, &urls[ix], window, cx) + }) + .into_any_element() + } + }; + self.point_text(drawn, layout, inline) } fn nodes(&self, nodes: &[Node], cx: &App) -> Vec { @@ -191,6 +206,14 @@ impl Draw<'_> { } fn node(&self, node: &Node, cx: &App) -> AnyElement { + let table = self.table.get(); + self.table.set(table || matches!(node, Node::Table { .. })); + let drawn = self.drawn(node, cx); + self.table.set(table); + drawn + } + + fn drawn(&self, node: &Node, cx: &App) -> AnyElement { let theme = cx.theme(); let colors = theme.colors.clone(); match node { @@ -234,9 +257,14 @@ impl Draw<'_> { .text_color(colors.fg_muted) .children(self.nodes(inner, cx)) .into_any_element(), - Node::Code { language, text } => { + Node::Code { + language, + text, + from, + } => { if language.as_deref() == Some("mermaid") { - return Diagram::new(self.key("diagram"), text.clone()).into_any_element(); + let diagram = Diagram::new(self.key("diagram"), text.clone()); + return self.point_code(diagram.into_any_element(), None, None); } if let Some(draw) = self.code { return draw( @@ -251,7 +279,9 @@ impl Draw<'_> { .map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)), None => code_colors(text, cx), }; - div() + let code = StyledText::new(text.clone()).with_highlights(styles); + let layout = code.layout().clone(); + let block = div() .flex() .flex_col() .gap_1() @@ -267,8 +297,9 @@ impl Draw<'_> { .text_color(colors.fg_subtle) .child(name) })) - .child(StyledText::new(text.clone()).with_highlights(styles)) - .into_any_element() + .child(code) + .into_any_element(); + self.point_code(block, Some(layout), *from) } Node::List { start, items } => self.list(start, items, cx), Node::Table { aligns, head, rows } => { @@ -340,23 +371,28 @@ impl Draw<'_> { Err(error) => failed(&error, tex, cx), }) .into_any_element(), - Node::Image { alt, url, link } => { + Node::Image { alt, url, link, at } => { let picture = div().id(self.key("image")).child(Picture { id: self.key("picture"), alt: alt.clone(), url: url.clone(), - base: self.base.clone(), + base: self.hooks.base.clone(), }); - let Some(link) = link.clone() else { - return picture.into_any_element(); + let drawn = match link.clone() { + None => picture.into_any_element(), + Some(address) => { + let on_link = self.hooks.on_link.clone(); + picture + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_click(move |_, window, cx| { + open_link(&on_link, &address, window, cx) + }) + .into_any_element() + } }; - let on_link = self.on_link.clone(); - picture - .tab_index(0) - .focus_ring(cx) - .cursor_pointer() - .on_click(move |_, window, cx| open_link(&on_link, &link, window, cx)) - .into_any_element() + self.point_image(drawn, *at, url, link) } Node::Footnote { label, body } => div() .flex() @@ -389,27 +425,24 @@ fn open_link(on_link: &Option, url: &str, window: &mut Window, cx: &mut } } -/// Parsed `nodes` drawn under `id`; pictures read from `base`. +/// Parsed `nodes` drawn under `id`, with the owner's hooks. pub(crate) fn draw_nodes( id: &ElementId, nodes: &[Node], - base: &Option>, - on_link: &Option, - on_heading: &Option, + hooks: Hooks, scale: f32, cx: &App, ) -> Vec { let draw = Draw { id, - on_link, + hooks, next: std::cell::Cell::new(0), sections: &None, headings: std::cell::Cell::new(0), code: &None, - base, scale, lists: std::cell::Cell::new(0), - on_heading, + table: std::cell::Cell::new(false), }; draw.nodes(nodes, cx) } @@ -420,15 +453,19 @@ impl RenderOnce for MarkdownRenderer { let nodes = parse(&self.source); let draw = Draw { id: &self.id, - on_link: &self.on_link, + hooks: Hooks { + base: &self.base, + on_link: &self.on_link, + on_heading: &None, + on_point: &None, + }, next: std::cell::Cell::new(0), sections: &self.sections, headings: std::cell::Cell::new(0), code: &self.code, - base: &self.base, scale: 1.0, lists: std::cell::Cell::new(0), - on_heading: &None, + table: std::cell::Cell::new(false), }; div() .flex() diff --git a/src/documents/render/blocks.rs b/src/documents/render/blocks.rs index 08cea91..225877d 100644 --- a/src/documents/render/blocks.rs +++ b/src/documents/render/blocks.rs @@ -143,7 +143,7 @@ impl Draw<'_> { 4 => entry.pl_16(), _ => entry.pl_20(), }; - let Some(reveal) = self.on_heading.clone() else { + let Some(reveal) = self.hooks.on_heading.clone() else { return entry.text_color(colors.fg_muted).into_any_element(); }; let (at, pressed) = (heading.at, reveal.clone()); diff --git a/src/documents/render/point.rs b/src/documents/render/point.rs new file mode 100644 index 0000000..2286cd7 --- /dev/null +++ b/src/documents/render/point.rs @@ -0,0 +1,143 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent, + ParentElement as _, TextLayout, Window, div, +}; + +use super::Draw; +use crate::documents::tree::Inline; + +/// Where a press fell, as the drawing knows it. +pub(crate) enum Place { + /// A drawn byte, read back through its text's sources. + Text(Rc, usize), + /// A source byte. + At(usize), + /// Somewhere the block holds no byte for. + Block, +} + +/// A press on drawn content. +pub(crate) struct Pointed { + pub place: Place, + pub link: Option, + pub image: Option, + pub code: bool, + pub table: bool, +} + +impl Pointed { + /// A press past any drawn text. + pub(crate) fn block() -> Self { + Self::at(Place::Block) + } + + fn at(place: Place) -> Self { + Self { + place, + link: None, + image: None, + code: false, + table: false, + } + } +} + +/// Takes a press's button and what it fell on. +pub(crate) type OnPoint = Rc; + +impl Draw<'_> { + /// `element` hands its left and right presses to the owner. + fn pointable( + &self, + element: AnyElement, + point: impl Fn(&MouseDownEvent) -> Pointed + 'static, + ) -> AnyElement { + let Some(on_point) = self.hooks.on_point.clone() else { + return element; + }; + let (point, table) = (Rc::new(point), self.table.get()); + let press = move |button: MouseButton| { + let (on_point, point) = (on_point.clone(), point.clone()); + move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| { + // Cmd or Ctrl leaves the press to a link. + if button == MouseButton::Left && event.modifiers.secondary() { + return; + } + window.prevent_default(); + cx.stop_propagation(); + let mut pointed = point(event); + pointed.table |= table; + on_point(pointed, button, window, cx); + } + }; + div() + .on_mouse_down(MouseButton::Left, press(MouseButton::Left)) + .on_mouse_down(MouseButton::Right, press(MouseButton::Right)) + .child(element) + .into_any_element() + } + + /// Drawn `inline`; a press finds its byte through `layout`. + pub(super) fn point_text( + &self, + element: AnyElement, + layout: TextLayout, + inline: &Inline, + ) -> AnyElement { + if self.hooks.on_point.is_none() { + return element; + } + let inline = Rc::new(inline.clone()); + self.pointable(element, move |event| { + let at = layout + .index_for_position(event.position) + .unwrap_or_else(|near| near); + let link = inline.links.iter().find(|(range, _)| range.contains(&at)); + Pointed { + link: link.map(|(_, url)| url.clone()), + ..Pointed::at(Place::Text(inline.clone(), at)) + } + }) + } + + /// A code block; its text starts at source byte `from`. + pub(super) fn point_code( + &self, + element: AnyElement, + layout: Option, + from: Option, + ) -> AnyElement { + self.pointable(element, move |event| { + let place = match (&layout, from) { + (Some(layout), Some(from)) => Place::At( + from + layout + .index_for_position(event.position) + .unwrap_or_else(|near| near), + ), + _ => Place::Block, + }; + Pointed { + code: true, + ..Pointed::at(place) + } + }) + } + + /// A picture written at source byte `at`. + pub(super) fn point_image( + &self, + element: AnyElement, + at: usize, + url: &str, + link: &Option, + ) -> AnyElement { + let (url, link) = (url.to_string(), link.clone()); + self.pointable(element, move |_| Pointed { + image: Some(url.clone()), + link: link.clone(), + ..Pointed::at(Place::At(at)) + }) + } +} diff --git a/src/documents/tree/mod.rs b/src/documents/tree/mod.rs index 4d3e49d..687a26b 100644 --- a/src/documents/tree/mod.rs +++ b/src/documents/tree/mod.rs @@ -7,6 +7,7 @@ use super::markdown::{span, tree, walk}; mod blocks; mod emoji; mod html; +mod sources; use blocks::html_block_text; pub use blocks::{AlertKind, DocumentHeading, alert, is_toc}; @@ -33,6 +34,8 @@ pub struct Inline { pub text: String, pub styles: Vec<(Range, Style)>, pub links: Vec<(Range, String)>, + /// Where each piece was written: drawn start, source span. + pub sources: Vec<(usize, Range)>, } /// A block of a markdown document. @@ -44,6 +47,8 @@ pub enum Node { Code { language: Option, text: String, + /// Where `text` starts in the source, if written as drawn. + from: Option, }, /// Items and blocks; ordered from `start`; tasks carry checkboxes. List { @@ -63,6 +68,8 @@ pub enum Node { alt: String, url: String, link: Option, + /// Its source start. + at: usize, }, Footnote { label: String, @@ -315,6 +322,7 @@ impl<'a> Reader<'a> { mdast::Node::Code(code) => out.push(Node::Code { language: code.lang.clone(), text: code.value.clone(), + from: sources::code_from(self.source, node, &code.value), }), mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())), mdast::Node::List(list) => { @@ -386,7 +394,10 @@ impl<'a> Reader<'a> { pub fn label(&self, note: &mdast::FootnoteDefinition) -> String { match self.note(¬e.identifier) { Some(number) => number.to_string(), - None => note.label.clone().unwrap_or_else(|| note.identifier.clone()), + None => note + .label + .clone() + .unwrap_or_else(|| note.identifier.clone()), } } @@ -404,6 +415,9 @@ impl<'a> Reader<'a> { if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) { run.shortcodes.split(); } + if let Some(written) = sources::written(node, self.source) { + run.text.sources.push((run.text.text.len(), written)); + } let text = &mut run.text; let (children, (name, style), url) = match node { mdast::Node::Text(words) => return run.push_text(&words.value), @@ -422,14 +436,14 @@ impl<'a> Reader<'a> { } _ => unreachable!("matched as an image"), }; - let link = run.link(); + let (link, at) = (run.link(), span(node).start); return match images { Images::Alt => run.text.text.push_str(&alt), Images::Split(out) => { text_block(run.cut(), out); - out.push(Node::Image { alt, url, link }) + out.push(Node::Image { alt, url, link, at }) } - Images::After(out) => out.push(Node::Image { alt, url, link }), + Images::After(out) => out.push(Node::Image { alt, url, link, at }), }; } mdast::Node::Html(written) => return run.html(&written.value), diff --git a/src/documents/tree/sources.rs b/src/documents/tree/sources.rs new file mode 100644 index 0000000..2dd56e8 --- /dev/null +++ b/src/documents/tree/sources.rs @@ -0,0 +1,157 @@ +use std::ops::Range; + +use markdown::mdast::Node; + +use super::{Inline, Node as Drawn, span}; + +/// A leaf's written span; code and math inside their marks. +pub(super) fn written(node: &Node, source: &str) -> Option> { + let whole = match node { + Node::Text(_) + | Node::Break(_) + | Node::Html(_) + | Node::FootnoteReference(_) + | Node::Image(_) + | Node::ImageReference(_) + | Node::InlineCode(_) + | Node::InlineMath(_) => span(node), + _ => return None, + }; + let marks = match node { + Node::InlineCode(_) | Node::InlineMath(_) => { + let text = &source[whole.clone()]; + text.len() - text.trim_start_matches(['`', '$']).len() + } + _ => 0, + }; + Some(whole.start + marks..whole.end) +} + +/// Where a fenced block's `text` starts, if written as drawn. +pub(super) fn code_from(source: &str, node: &Node, text: &str) -> Option { + let whole = span(node); + let written = &source[whole.clone()]; + let body = written.find('\n')? + 1; + written[body..] + .starts_with(text) + .then_some(whole.start + body) +} + +impl Drawn { + /// Its source bytes counted from `start`, its block's. + pub(crate) fn rebase(&mut self, start: usize) { + match self { + Drawn::Paragraph(inline) | Drawn::Heading(_, inline) => { + for (_, span) in &mut inline.sources { + *span = span.start - start..span.end - start; + } + } + Drawn::Quote(body) | Drawn::Alert { body, .. } | Drawn::Footnote { body, .. } => { + body.iter_mut().for_each(|node| node.rebase(start)) + } + Drawn::List { items, .. } => items + .iter_mut() + .flat_map(|(_, body)| body) + .for_each(|node| node.rebase(start)), + Drawn::Table { head, rows, .. } => head + .iter_mut() + .chain(rows.iter_mut().flatten()) + .flatten() + .for_each(|node| node.rebase(start)), + Drawn::Code { from, .. } => { + if let Some(from) = from { + *from -= start; + } + } + Drawn::Image { at, .. } => *at -= start, + Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) | Drawn::FrontMatter(_) => {} + } + } +} + +impl Inline { + /// Source byte under drawn byte `at`, if a piece holds it. + pub(crate) fn source_at(&self, source: &str, at: usize) -> Option { + let piece = self.sources.partition_point(|(start, _)| *start <= at); + let (start, span) = self.sources.get(piece.checked_sub(1)?)?; + let end = self + .sources + .get(piece) + .map_or(self.text.len(), |(next, _)| *next); + let drawn = self.text.get(*start..end.max(*start))?; + let written = source.get(span.clone())?; + // Text that does not read back lands at its start. + Some(span.start + align(drawn, written, at - start).unwrap_or(0)) + } +} + +/// Where drawn byte `at` was written, past joins and escapes. +fn align(drawn: &str, written: &str, at: usize) -> Option { + let (mut shown, mut source) = (drawn.char_indices().peekable(), written.char_indices()); + let mut next = source.next(); + loop { + let here = next.map_or(written.len(), |(byte, _)| byte); + let Some(&(byte, shown_char)) = shown.peek() else { + return Some(here); + }; + if byte >= at { + return Some(here); + } + let (_, written_char) = next?; + match (shown_char, written_char) { + (a, b) if a == b => { + shown.next(); + } + (' ', '\r' | '\n') => { + shown.next(); + next = source.next(); + while let Some((_, ' ' | '\t' | '\r' | '\n')) = next { + next = source.next(); + } + continue; + } + (_, ' ' | '\t' | '\r' | '\n' | '\\') => {} + _ => return None, + } + next = source.next(); + } +} + +#[cfg(test)] +mod tests { + use super::super::{Reader, tree}; + + /// The source byte under drawn text in the first paragraph. + fn under(source: &str, drawn: &str) -> usize { + let root = tree(source); + let reader = Reader::new(source, &root); + let paragraph = &root.children().expect("blocks")[0]; + let inline = reader.inline(paragraph.children().expect("inlines")); + let at = inline.text.find(drawn).expect("drawn"); + inline.source_at(source, at).expect("a piece") + } + + #[test] + fn a_word_lands_where_it_was_written() { + let source = "Say **bold** and `code` here\nnext line"; + assert_eq!(under(source, "bold"), source.find("bold").unwrap()); + assert_eq!(under(source, "code"), source.find("code").unwrap()); + assert_eq!(under(source, "here"), source.find("here").unwrap()); + assert_eq!(under(source, "next"), source.find("next").unwrap()); + } + + #[test] + fn joins_escapes_and_indents_read_back() { + let source = "a \\*star\\* x\n y"; + assert_eq!(under(source, "x"), source.find('x').unwrap()); + assert_eq!(under(source, "y"), source.find('y').unwrap()); + } + + #[test] + fn text_that_does_not_read_back_lands_at_its_start() { + let source = "a & b"; + assert_eq!(under(source, "b"), 0, "the reference ends the reading"); + let source = "x :smile: y"; + assert_eq!(under(source, "y"), 0); + } +} diff --git a/src/documents/tree/tests.rs b/src/documents/tree/tests.rs index d895f4a..eec36c5 100644 --- a/src/documents/tree/tests.rs +++ b/src/documents/tree/tests.rs @@ -25,7 +25,8 @@ fn blocks_nest_as_written() { nodes[4], Node::Code { language: Some("rust".into()), - text: "fn main() {}".into() + text: "fn main() {}".into(), + from: Some(95) } ); assert_eq!(nodes[5], Node::Rule); @@ -39,7 +40,8 @@ fn an_image_inside_an_image_is_the_outer_one() { [Node::Image { alt: "outer ".into(), url: "outer.png".into(), - link: None + link: None, + at: 0 }] ); } @@ -53,7 +55,8 @@ fn an_image_splits_its_paragraph_and_references_resolve() { Node::Image { alt: "a".into(), url: "a.png".into(), - link: None + link: None, + at: 7 } ); let Node::Paragraph(after) = &nodes[2] else { @@ -95,7 +98,8 @@ fn definitions_nested_in_quotes_and_lists_resolve() { Node::Image { alt: "pic".into(), url: "a.png".into(), - link: None + link: None, + at: 7 } ); } @@ -116,7 +120,8 @@ fn an_image_inside_styles_and_links_stays_an_image() { Node::Image { alt: "x".into(), url: "x.png".into(), - link: None + link: None, + at: 3 } ); let Node::Paragraph(after) = &nodes[2] else { @@ -131,7 +136,8 @@ fn an_image_inside_styles_and_links_stays_an_image() { Node::Image { alt: "y".into(), url: "y.png".into(), - link: Some("https://ely.dev".into()) + link: Some("https://ely.dev".into()), + at: 19 } ); assert_eq!(nodes.len(), 4, "{nodes:?}");