From 83d747d87edea83a4bb9a28da7c6960728d6ce35 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 10:11:00 -0400 Subject: [PATCH] documents: presses read back through quotes, code, HTML and front matter Each drawn character finds its written one, skipping only white space, escapes and quote marks, so a press lands on the character itself. Code, front matter and link definitions read back line for line. An HTML block keeps each text run's source byte, and an alert keeps its text past the marker; alert() now takes the source. A secondary press refuses a target its write-back changed, and its focus no longer opens an empty document. Run's element stack moves to tree/stack.rs and the HTML-block reader to tree/html_block.rs. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- src/documents/document/edit.rs | 3 + src/documents/document/mod.rs | 3 + src/documents/document/parse.rs | 3 +- src/documents/document/point.rs | 17 +- src/documents/document/tests/point.rs | 169 +++++++++++++- src/documents/document/visual.rs | 29 ++- src/documents/render.rs | 8 +- src/documents/render/blocks.rs | 23 +- src/documents/render/point.rs | 121 ++++++---- src/documents/tree/blocks.rs | 311 +++----------------------- src/documents/tree/html_block.rs | 300 +++++++++++++++++++++++++ src/documents/tree/mod.rs | 115 ++-------- src/documents/tree/sources.rs | 154 +++++++++---- src/documents/tree/stack.rs | 88 ++++++++ src/documents/tree/tests.rs | 2 +- 16 files changed, 868 insertions(+), 480 deletions(-) create mode 100644 src/documents/tree/html_block.rs create mode 100644 src/documents/tree/stack.rs diff --git a/AGENTS.md b/AGENTS.md index 534fa5c..9b578bd 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, 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. +- 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, escapes, indents and quote marks (an HTML block's text runs and an alert's text past its marker too), and text that does not read back (references, emoji) lands at its piece's start; code, front matter and a link definition read back line for line, each drawn line ending its written one; a block's drawn positions count from its own start, so an edit before it keeps it alike. A secondary press opens nothing, an empty document's open on focus included: it writes back an open block, refuses the press if that changed the pressed block, 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 9a28a20..5347c5b 100644 --- a/src/documents/document/edit.rs +++ b/src/documents/document/edit.rs @@ -34,6 +34,9 @@ pub(super) fn moved(at: usize, edit: &Option<(Range, usize)>) -> Option) { + if std::mem::take(&mut self.pointing) { + return; + } 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, Landing::End, window, cx); diff --git a/src/documents/document/mod.rs b/src/documents/document/mod.rs index fb6e79e..5ae89c7 100644 --- a/src/documents/document/mod.rs +++ b/src/documents/document/mod.rs @@ -102,6 +102,8 @@ pub struct MarkdownDocument { /// The block, text, typing and heights last centered. centered: Option<(usize, u64, u64, [u64; 2])>, focus: FocusHandle, + /// A secondary press took focus. + pointing: bool, on_link: Option, on_heading: Option, _subscriptions: Vec, @@ -165,6 +167,7 @@ impl MarkdownDocument { centered: None, room: Pixels::ZERO, focus, + pointing: false, on_link: None, on_heading: Some(on_heading), _subscriptions: subscriptions, diff --git a/src/documents/document/parse.rs b/src/documents/document/parse.rs index 09a34a9..5712caa 100644 --- a/src/documents/document/parse.rs +++ b/src/documents/document/parse.rs @@ -107,7 +107,8 @@ mod tests { ] ); let drawn = |ix: usize| found.blocks[ix].nodes.clone().expect("drawn"); - assert_eq!(drawn(0), [Node::FrontMatter("title: a".into())]); + let text = "title: a".into(); + assert_eq!(drawn(0), [Node::FrontMatter { text, from: 4 }]); assert!(matches!(&drawn(3)[..], [Node::Paragraph(line)] if line.text == "x")); let headings: Vec<(u8, &str, usize)> = found .headings diff --git a/src/documents/document/point.rs b/src/documents/document/point.rs index 47aaaa5..f8f3d06 100644 --- a/src/documents/document/point.rs +++ b/src/documents/document/point.rs @@ -6,6 +6,7 @@ use markdown::mdast; use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved}; use crate::documents::{ render::{OnPoint, Place, Pointed}, + tree::lines_at, tree::{alert, is_toc}, }; @@ -35,7 +36,7 @@ impl TargetBlock { 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(quote) if alert(quote, source).is_some() => Self::Alert, mdast::Node::Blockquote(_) => Self::Quote, mdast::Node::List(_) => Self::List, mdast::Node::Code(_) => Self::Code, @@ -73,6 +74,10 @@ impl MarkdownDocument { // The drawing counts from its block's start. let at = match &pointed.place { Place::Text(inline, drawn) => inline.source_at(&parsed.text[start..], *drawn), + // Unreadable lines land at their start. + Place::Lines { text, from, at } => { + Some(lines_at(&parsed.text[start..], *from, text, *at).unwrap_or(*from)) + } Place::At(at) => Some(*at), Place::Block => None, } @@ -104,6 +109,7 @@ impl MarkdownDocument { block => block.flatten(), }; let kind = block.map(|block| block.kind); + let pressed = block.map(|block| self.parsed.text[block.range.clone()].to_string()); let at = at.or(block.map(|block| block.range.start)); let written = self.commit(cx); if written.is_some() { @@ -119,8 +125,17 @@ impl MarkdownDocument { None => return log::warn!("markdown document: the pressed text was written over"), }, }; + // A write-back may change the pressed block. + let now = (self.parsed.blocks.iter()) + .find(|block| block.range.start <= at && at <= block.range.end) + .map(|block| self.parsed.text[block.range.clone()].to_string()); + if written.is_some() && pressed.is_some() && now != pressed { + return log::warn!("markdown document: the write-back changed the pressed block"); + } self.editor .update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx)); + // Its focus opens no empty block. + self.pointing = !self.focus.is_focused(window); window.focus(&self.focus, cx); log::info!("markdown document: a secondary press puts the caret at byte {at}"); cx.emit(DocumentEvent::Pointed(DocumentTarget { diff --git a/src/documents/document/tests/point.rs b/src/documents/document/tests/point.rs index ca49cb9..a9d3db1 100644 --- a/src/documents/document/tests/point.rs +++ b/src/documents/document/tests/point.rs @@ -2,11 +2,15 @@ use std::{cell::RefCell, rc::Rc}; -use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px}; +use gpui::{Entity, Modifiers, MouseButton, Pixels, TestAppContext, VisualTestContext, point, px}; use super::{open, type_in}; use crate::{ - documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock}, + documents::{ + DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock, + document::parse::parsed, + tree::{Inline, Node}, + }, theme::{ActiveTheme, TextSize}, }; @@ -163,3 +167,164 @@ fn a_secondary_press_past_the_end_goes_to_the_end(cx: &mut TestAppContext) { assert_eq!(*heard.borrow(), [expected]); assert!(document.read_with(cx, |document, _| document.open.is_none())); } + +/// Base and small text sizes, in pixels. +fn sizes(cx: &mut VisualTestContext) -> (Pixels, Pixels) { + cx.update(|window, cx| { + let size = |size| cx.theme().text_size(size).to_pixels(window.rem_size()); + (size(TextSize::Base), size(TextSize::Sm)) + }) +} + +fn press_at(x: Pixels, y: Pixels, button: MouseButton, cx: &mut VisualTestContext) { + cx.simulate_mouse_down(point(x, y), button, Modifiers::none()); + cx.simulate_mouse_up(point(x, y), button, Modifiers::none()); + cx.run_until_parked(); +} + +/// Presses block 0's code glyph `k` on `line`. +fn press_lines(k: usize, line: usize, button: MouseButton, cx: &mut VisualTestContext) { + let bounds = cx.debug_bounds("markdown-block-0").expect("drawn"); + let (body, small) = sizes(cx); + let x = bounds.left() + px(25.0) + small * 0.6 * (k as f32 + 0.25); + let y = bounds.top() + px(17.0) + body * 1.6 * (line as f32 + 0.25); + press_at(x, y, button, cx); +} + +/// The source byte under drawn `shown` in block 0. +fn read_back(source: &str, shown: &str) -> Option { + fn find<'a>(nodes: &'a [Node], shown: &str) -> Option<&'a Inline> { + nodes.iter().find_map(|node| match node { + Node::Paragraph(inline) | Node::Heading(_, inline) if inline.text.contains(shown) => { + Some(inline) + } + Node::Quote(body) | Node::Alert { body, .. } | Node::Footnote { body, .. } => { + find(body, shown) + } + _ => None, + }) + } + let parsed = parsed(source); + let block = &parsed.blocks[0]; + let inline = find(block.nodes.as_ref().expect("drawn"), shown).expect("shown"); + let at = inline.text.find(shown).expect("shown"); + let written = inline.source_at(&source[block.range.start..], at)?; + Some(block.range.start + written) +} + +#[test] +fn quote_marks_escapes_trims_and_alert_markers_read_back() { + let cases = [ + ("> alpha\n> beta", "eta"), + ("> [!NOTE]\n> alpha beta", "beta"), + ("a \\*star\\* z", "*star"), + ("a ` xy ` z", "xy"), + ("
alpha beta
\n", "eta"), + (">
\n> alpha beta
\n", "beta"), + ]; + for (source, shown) in cases { + assert_eq!(read_back(source, shown), source.find(shown), "{source:?}"); + } +} + +#[gpui::test] +fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) { + let source = "> alpha\n> beta"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + let bounds = cx.debug_bounds("markdown-block-0").expect("drawn"); + let (body, _) = sizes(cx); + let x = bounds.left() + px(31.0) + body * 0.6 * 7.25; + press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx); + let at = source.find("eta").unwrap(); + assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]); +} + +#[gpui::test] +fn a_secondary_press_in_an_alert_lands_past_its_marker(cx: &mut TestAppContext) { + let source = "> [!NOTE]\n> alpha beta"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + let bounds = cx.debug_bounds("markdown-block-0").expect("drawn"); + let (body, _) = sizes(cx); + let x = bounds.left() + px(31.0) + body * 0.6 * 6.25; + let y = bounds.top() + px(15.0) + body * 1.6; + press_at(x, y, MouseButton::Right, cx); + let at = source.find("beta").unwrap(); + assert_eq!(*events.borrow(), [target(at, TargetBlock::Alert)]); +} + +#[gpui::test] +fn an_html_block_press_lands_on_its_character(cx: &mut TestAppContext) { + let source = "
alpha beta
\n"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + press_on(0, 7, MouseButton::Right, cx); + let at = source.find("eta").unwrap(); + assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]); + press_on(0, 7, MouseButton::Left, cx); + assert_eq!(opened_caret(&document, cx), at); +} + +#[gpui::test] +fn a_definition_press_lands_on_its_character(cx: &mut TestAppContext) { + let source = "[ref]: https://ely.dev\n"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + press_on(0, 10, MouseButton::Right, cx); + assert_eq!(*events.borrow(), [target(10, TargetBlock::Definition)]); +} + +#[gpui::test] +fn code_presses_land_line_for_line(cx: &mut TestAppContext) { + for source in [" alpha\n beta\n", "```\r\nalpha\r\nbeta\r\n```\r\n"] { + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + press_lines(1, 1, MouseButton::Right, cx); + let at = source.find("eta").unwrap(); + let expected = DocumentTarget { + in_code: true, + ..target(at, TargetBlock::Code) + }; + assert_eq!(*events.borrow(), [expected], "{source:?}"); + press_lines(1, 1, MouseButton::Left, cx); + assert_eq!(opened_caret(&document, cx), at, "{source:?}"); + } +} + +#[gpui::test] +fn a_front_matter_press_lands_past_its_fence(cx: &mut TestAppContext) { + let source = "---\ntitle: value\n---\n"; + let (document, cx) = open(source, MarkdownMode::Visual, cx); + let events = heard(&document, cx); + press_lines(7, 0, MouseButton::Right, cx); + let at = source.find("value").unwrap(); + assert_eq!(*events.borrow(), [target(at, TargetBlock::FrontMatter)]); + press_lines(7, 0, MouseButton::Left, cx); + assert_eq!(opened_caret(&document, cx), at); +} + +#[gpui::test] +fn a_secondary_press_on_an_empty_document_opens_nothing(cx: &mut TestAppContext) { + let (document, cx) = open("", MarkdownMode::Visual, cx); + assert!(document.read_with(cx, |document, _| document.open.is_none())); + let events = heard(&document, cx); + press_at(px(25.0), px(25.0), MouseButton::Right, cx); + assert_eq!(events.borrow().len(), 1); + assert!(document.read_with(cx, |document, _| document.open.is_none())); +} + +#[gpui::test] +fn a_write_back_that_changes_the_pressed_block_points_nowhere(cx: &mut TestAppContext) { + let (document, cx) = open("First.\n\nalpha beta\n", MarkdownMode::Visual, cx); + let events = heard(&document, cx); + type_in(&document, Some(0), Some("```"), cx); + press_on(1, 7, MouseButton::Right, cx); + let written = "```\n\nalpha beta\n"; + assert_eq!(super::text(&document, cx), written); + assert!( + events.borrow().is_empty(), + "the pressed paragraph became code" + ); + assert_ne!(caret(&document, cx), written.find("eta").unwrap()); +} diff --git a/src/documents/document/visual.rs b/src/documents/document/visual.rs index 79a3931..83589af 100644 --- a/src/documents/document/visual.rs +++ b/src/documents/document/visual.rs @@ -1,14 +1,15 @@ use gpui::{ AnyElement, App, ClickEvent, Context, ExternalPaths, FontWeight, InteractiveElement, IntoElement, KeyBinding, MouseButton, MouseDownEvent, ParentElement, Render, - StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _, + StatefulInteractiveElement, Styled, StyledText, Window, actions, div, list, + prelude::FluentBuilder as _, }; use super::{MarkdownDocument, edit::Landing}; use crate::{ documents::{ MarkdownMode, - render::{Hooks, Pointed, draw_nodes}, + render::{Hooks, Pointed, draw_nodes, point_lines}, }, forms::{self, Down, Up}, primitives::FocusRing as _, @@ -113,11 +114,25 @@ impl MarkdownDocument { self.look.scale, cx, )), - None => div() - .text_size(body) - .font_family(mono) - .text_color(colors.fg_muted) - .child(self.parsed.text[block.range.clone()].to_string()), + None => { + let raw = &self.parsed.text[block.range.clone()]; + let drawn = StyledText::new(raw.to_string()); + let layout = drawn.layout().clone(); + let shown = div() + .text_size(body) + .font_family(mono) + .text_color(colors.fg_muted) + .child(drawn) + .into_any_element(); + div().child(point_lines( + shown, + layout, + raw, + 0, + &on_point, + (false, false), + )) + } }; let block = div() .id(("block", ix)) diff --git a/src/documents/render.rs b/src/documents/render.rs index 5d79a83..66f52b8 100644 --- a/src/documents/render.rs +++ b/src/documents/render.rs @@ -24,7 +24,7 @@ use crate::{ mod blocks; mod point; -pub(crate) use point::{OnPoint, Place, Pointed}; +pub(crate) use point::{OnPoint, Place, Pointed, point_lines}; pub(crate) type OnLink = Rc; /// Reveals the heading whose source starts at a byte. @@ -264,7 +264,7 @@ impl Draw<'_> { } => { if language.as_deref() == Some("mermaid") { let diagram = Diagram::new(self.key("diagram"), text.clone()); - return self.point_code(diagram.into_any_element(), None, None); + return self.point_code(diagram.into_any_element(), None, *from); } if let Some(draw) = self.code { return draw( @@ -299,7 +299,7 @@ impl Draw<'_> { })) .child(code) .into_any_element(); - self.point_code(block, Some(layout), *from) + self.point_code(block, Some((layout, text)), *from) } Node::List { start, items } => self.list(start, items, cx), Node::Table { aligns, head, rows } => { @@ -410,7 +410,7 @@ impl Draw<'_> { .into_any_element(), Node::Alert { kind, body } => self.alert(*kind, body, cx), Node::Toc(headings) => self.toc(headings, cx), - Node::FrontMatter(text) => self.front_matter(text, cx), + Node::FrontMatter { text, from } => self.front_matter(text, *from, cx), } } } diff --git a/src/documents/render/blocks.rs b/src/documents/render/blocks.rs index 225877d..e66977d 100644 --- a/src/documents/render/blocks.rs +++ b/src/documents/render/blocks.rs @@ -1,8 +1,9 @@ use gpui::{ - AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, div, prelude::*, + AnyElement, App, FontWeight, IntoElement, MouseButton, ParentElement, Styled, StyledText, div, + prelude::*, }; -use super::Draw; +use super::{Draw, point::point_lines}; use crate::{ documents::tree::{AlertKind, DocumentHeading, Node}, primitives::{FocusRing as _, Icon, IconName}, @@ -171,16 +172,26 @@ impl Draw<'_> { } /// Front matter as written, inside its box. - pub(super) fn front_matter(&self, text: &str, cx: &App) -> AnyElement { + pub(super) fn front_matter(&self, text: &str, from: usize, cx: &App) -> AnyElement { let theme = cx.theme(); - div() + let drawn = StyledText::new(text.to_string()); + let layout = drawn.layout().clone(); + let block = div() .p_3() .rounded(theme.radius(Radius::Md)) .bg(theme.colors.sunken) .font_family(theme.mono_family.clone()) .text_size(theme.text_size(TextSize::Sm) * self.scale) .text_color(theme.colors.fg_muted) - .child(text.to_string()) - .into_any_element() + .child(drawn) + .into_any_element(); + point_lines( + block, + layout, + text, + from, + self.hooks.on_point, + (false, false), + ) } } diff --git a/src/documents/render/point.rs b/src/documents/render/point.rs index 2286cd7..ff3c72b 100644 --- a/src/documents/render/point.rs +++ b/src/documents/render/point.rs @@ -14,6 +14,12 @@ pub(crate) enum Place { Text(Rc, usize), /// A source byte. At(usize), + /// Drawn byte `at` of `text`, its lines written from `from`. + Lines { + text: Rc, + from: usize, + at: usize, + }, /// Somewhere the block holds no byte for. Block, } @@ -47,36 +53,70 @@ impl Pointed { /// Takes a press's button and what it fell on. pub(crate) type OnPoint = Rc; +/// `element` hands its left and right presses to `on_point`. +fn pointable( + element: AnyElement, + on_point: &Option, + table: bool, + point: impl Fn(&MouseDownEvent) -> Pointed + 'static, +) -> AnyElement { + let Some(on_point) = on_point.clone() else { + return element; + }; + let point = Rc::new(point); + 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 `text`, its lines written from source byte `from`. +pub(crate) fn point_lines( + element: AnyElement, + layout: TextLayout, + text: &str, + from: usize, + on_point: &Option, + (table, code): (bool, bool), +) -> AnyElement { + let text: Rc = Rc::from(text); + pointable(element, on_point, table, move |event| { + let at = layout + .index_for_position(event.position) + .unwrap_or_else(|near| near); + Pointed { + code, + ..Pointed::at(Place::Lines { + text: text.clone(), + from, + at, + }) + } + }) +} + 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() + pointable(element, self.hooks.on_point, self.table.get(), point) } /// Drawn `inline`; a press finds its byte through `layout`. @@ -102,27 +142,28 @@ impl Draw<'_> { }) } - /// A code block; its text starts at source byte `from`. + /// A code block; its lines start at source byte `from`. pub(super) fn point_code( &self, element: AnyElement, - layout: Option, - from: Option, + drawn: Option<(TextLayout, &str)>, + from: usize, ) -> 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 { + let table = self.table.get(); + match drawn { + Some((layout, text)) => point_lines( + element, + layout, + text, + from, + self.hooks.on_point, + (table, true), + ), + None => self.pointable(element, |_| Pointed { code: true, - ..Pointed::at(place) - } - }) + ..Pointed::block() + }), + } } /// A picture written at source byte `at`. diff --git a/src/documents/tree/blocks.rs b/src/documents/tree/blocks.rs index c33df80..a2ec29e 100644 --- a/src/documents/tree/blocks.rs +++ b/src/documents/tree/blocks.rs @@ -1,6 +1,6 @@ use markdown::mdast; -use super::{Inline, Node, Reader, Run, html}; +use super::{Node, Reader}; use crate::documents::markdown::span; /// A heading anywhere in the file. @@ -69,151 +69,19 @@ impl Reader<'_> { let mdast::Node::Blockquote(quote) = node else { return None; }; - let (kind, parts) = alert(quote)?; + let (kind, parts) = alert(quote, self.source)?; let mut body = Vec::new(); self.blocks(&parts, &mut body); Some(Node::Alert { kind, body }) } } -/// An HTML block's text, as a browser shows it. -pub(super) fn html_block_text(written: &str) -> Inline { - let mut run = Run::default(); - let mut rest = written; - while let Some(at) = rest.find('<') { - run.words(&rest[..at]); - rest = &rest[at..]; - if let Some(comment) = rest.strip_prefix("").map_or("", |(_, after)| after); - run.shortcodes.split(); - continue; - } - let opens = - rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch)); - if !opens { - run.words("<"); - rest = &rest[1..]; - continue; - } - // An unclosed tag drops the rest, as HTML5 does. - let Some(end) = html::tag_end(rest) else { - rest = ""; - break; - }; - let (written, after) = rest.split_at(end); - rest = after; - match html::tag(written) { - Some(tag) if html::filtered(&tag.name) => run.words(written), - // A block closes what opened inside it. - Some(tag) if html::breaks(&tag.name) => { - run.line(); - match tag.closing { - true => run.end(&tag.name), - false => { - run.implied(&tag.name); - match tag.name == "hr" { - true => run.shortcodes.split(), - false => run.start(&tag.name, None, None), - } - } - } - } - // It ends a `p` but lays no block. - Some(tag) if !tag.closing && html::closes_p(&tag.name) => { - run.implied(&tag.name); - run.html(written); - } - // A line's end drops its space. - Some(tag) if tag.closing || tag.name == "br" => { - run.owed = run.owed.filter(|_| tag.name != "br"); - run.html(written); - } - Some(_) => { - run.pay(); - run.html(written); - } - None => run.shortcodes.split(), - } - } - run.words(rest); - run.cut() -} - -impl Run { - /// HTML text: references decoded, white space collapsed. - fn words(&mut self, written: &str) { - let shown = self.shown(&html::text(written)); - self.spaced(&shown); - } - - /// Text with its white space collapsed. - fn spaced(&mut self, shown: &str) { - for ch in shown.chars() { - if !html::spacing(ch) { - self.pay(); - self.text.text.push(ch); - } else if self.owed.is_none() && !self.text.text.ends_with('\n') { - let open = self.open.len(); - self.owed = (!self.text.text.is_empty()).then_some((' ', open)); - } - } - } - - /// A line owed where a block element starts or ends. - fn line(&mut self) { - let from = self.owed.map_or(self.open.len(), |(_, from)| from); - let text = &self.text.text; - self.owed = (!(text.is_empty() || text.ends_with('\n'))).then_some(('\n', from)); - } - - /// Writes the white space owed, before a word or tag. - fn pay(&mut self) { - let Some((owed, from)) = self.owed.take() else { - return; - }; - self.text.text.push(owed); - let start = self.text.text.len(); - // What opened after it starts after it. - for opened in &mut self.open[from..] { - opened.start = start; - } - } - - /// Ends what a block's start tag closes, as HTML5 does. - fn implied(&mut self, name: &str) { - let items: &[&str] = match name { - "li" => &["li"], - "dd" | "dt" => &["dd", "dt"], - _ => &[], - }; - // The search stops at a special element. - let stops = |name: &str| html::breaks(name) && !matches!(name, "address" | "div" | "p"); - let found = items - .iter() - .any(|item| self.count(item) > 0) - .then(|| { - let mut names = self.open.iter().rev().map(|opened| opened.name.as_str()); - names.find(|name| items.contains(name) || stops(name)) - }) - .flatten() - .filter(|name| items.contains(name)) - .map(str::to_string); - if let Some(item) = found { - self.end(&item); - } - if html::closes_p(name) && self.count("p") > 0 { - self.line(); - self.end("p"); - } - } -} - /// A quote's alert kind and its blocks after the marker. -pub fn alert(quote: &mdast::Blockquote) -> Option<(AlertKind, Vec)> { +pub fn alert(quote: &mdast::Blockquote, source: &str) -> Option<(AlertKind, Vec)> { let [mdast::Node::Paragraph(paragraph), rest @ ..] = "e.children[..] else { return None; }; - let (kind, after) = marker(¶graph.children)?; + let (kind, after) = marker(¶graph.children, source)?; if after.is_empty() && rest.is_empty() { return None; } @@ -233,7 +101,7 @@ pub fn is_toc(node: &mdast::Node, source: &str) -> bool { } /// A plain first line naming a kind, and what follows. -fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec)> { +fn marker(children: &[mdast::Node], source: &str) -> Option<(AlertKind, Vec)> { let mut line = String::new(); for (at, child) in children.iter().enumerate() { let head = match child { @@ -248,6 +116,7 @@ fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec)> { let after = after.strip_prefix("\r\n").unwrap_or(&after[1..]); let mut kept = text.clone(); kept.value = after.to_string(); + past_line(&mut kept, source); (!after.is_empty()).then_some(mdast::Node::Text(kept)) } }, @@ -260,10 +129,29 @@ fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec)> { Some((AlertKind::marked(&line)?, Vec::new())) } +/// `text` starts past its first line, as its value does. +fn past_line(text: &mut mdast::Text, source: &str) { + let start = &mut text + .position + .as_mut() + .expect("parsed text has a place") + .start; + let written = &source[start.offset..]; + let end = written.find(['\n', '\r']).expect("its line end is written"); + let width = if written[end..].starts_with("\r\n") { + 2 + } else { + 1 + }; + start.offset += end + width; + start.line += 1; + start.column = 1; +} + #[cfg(test)] mod tests { use super::*; - use crate::documents::tree::{Inline, Style, parse}; + use crate::documents::tree::{Style, parse}; fn alert(source: &str) -> Option<(AlertKind, Vec)> { match parse(source).into_iter().next() { @@ -345,151 +233,4 @@ mod tests { assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_))); assert!(matches!(&parse("> [toc]")[0], Node::Quote(_))); } - - fn html(source: &str) -> Option { - match parse(source).into_iter().next() { - Some(Node::Paragraph(line)) => Some(line), - None => None, - other => panic!("a paragraph, not {other:?}"), - } - } - - #[test] - fn an_html_block_shows_its_text_as_a_browser_does() { - let centered = html( - "
\n Bold and\n :smile: & :smile:\n
", - ) - .expect("text"); - assert_eq!(centered.text, "Bold and 😄 & :smile:"); - let (bold, code) = (0..4, 16..23); - assert_eq!( - centered.styles, - [(bold, Style::Strong), (code, Style::Code)] - ); - let parts = html("

one two

\n

three <b>

").expect("text"); - assert_eq!(parts.text, "one two\nthree "); - assert_eq!( - html("
\na
b
c
").expect("text").text, - "a\nb\nc" - ); - let filtered = "
\n\n\nshown\n
"; - assert_eq!( - html(filtered).expect("text").text, - " shown" - ); - let crossed = "
beforeafter
"; - assert_eq!( - html(crossed).expect("text").text, - "beforeafter" - ); - let split = parse("
\n\n
"); - assert_eq!(text(&split), [""]); - let inline = html("a c").expect("text"); - assert_eq!(inline.text, "a c"); - assert_eq!(html("
\na < b x
y").expect("text"); - assert_eq!( - (marked.text.as_str(), &marked.styles[..]), - ("x\ny", &[(0..1, Style::Mark)][..]) - ); - let bold = html("
x
y").expect("text"); - assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]); - let kept = html("

x

y
").expect("text"); - assert_eq!(kept.styles, [(0..3, Style::Mark)]); - } - - #[test] - fn a_block_start_ends_what_html5_ends() { - let mark = [(0..1, Style::Mark)]; - for source in [ - "
  • x
  • y
z", - "

x

y
z
", - "
x
y
z", - ] { - let line = html(source).expect("text"); - assert_eq!( - (line.text.as_str(), &line.styles[..]), - ("x\ny\nz", &mark[..]) - ); - } - let kbd = html("

x

y

z").expect("text"); - assert_eq!(kbd.styles, [(0..1, Style::Code)]); - let nested = html("
  • a
    • b
").expect("text"); - assert_eq!( - nested.styles, - [(0..3, Style::Mark)], - "a list stops the search" - ); - } - - #[test] - fn filtered_tags_are_text_and_names_end_as_html5_ends_them() { - // GitHub's answers, `.work/R4.3-fix3/github-*.log`. - for (source, shown) in [ - ( - "
:smile:
", - " :smile:", - ), - ( - "
:smile:
", - " 😄", - ), - ( - "
:smile:
", - " 😄", - ), - ( - "
\n\nshown\n
"; + assert_eq!( + html(filtered).expect("text").text, + " shown" + ); + let crossed = "
beforeafter
"; + assert_eq!( + html(crossed).expect("text").text, + "beforeafter" + ); + let split = parse("
\n\n
"); + let lines: Vec<_> = (split.into_iter()) + .map(|node| match node { + Node::Paragraph(line) => line.text, + other => panic!("a paragraph, not {other:?}"), + }) + .collect(); + assert_eq!(lines, [""]); + let inline = html("a c").expect("text"); + assert_eq!(inline.text, "a c"); + assert_eq!(html("
\na < b x
y").expect("text"); + assert_eq!( + (marked.text.as_str(), &marked.styles[..]), + ("x\ny", &[(0..1, Style::Mark)][..]) + ); + let bold = html("
x
y").expect("text"); + assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]); + let kept = html("

x

y
").expect("text"); + assert_eq!(kept.styles, [(0..3, Style::Mark)]); + } + + #[test] + fn a_block_start_ends_what_html5_ends() { + let mark = [(0..1, Style::Mark)]; + for source in [ + "
  • x
  • y
z", + "

x

y
z
", + "
x
y
z", + ] { + let line = html(source).expect("text"); + assert_eq!( + (line.text.as_str(), &line.styles[..]), + ("x\ny\nz", &mark[..]) + ); + } + let kbd = html("

x

y

z").expect("text"); + assert_eq!(kbd.styles, [(0..1, Style::Code)]); + let nested = html("
  • a
    • b
").expect("text"); + assert_eq!( + nested.styles, + [(0..3, Style::Mark)], + "a list stops the search" + ); + } + + #[test] + fn filtered_tags_are_text_and_names_end_as_html5_ends_them() { + // GitHub's answers, `.work/R4.3-fix3/github-*.log`. + for (source, shown) in [ + ( + "
:smile:
", + " :smile:", + ), + ( + "
:smile:
", + " 😄", + ), + ( + "
:smile:
", + " 😄", + ), + ( + "