Compare commits

..
4 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 c35fdac2e3 documents: read presses as the parser decoded them
CI / platforms (windows-latest) (push) Failing after 8s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m39s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Round 3 of R4.5 found five ways the reverse reading drifted from
markdown-rs: kept indents and marks were eaten as joins, autolink text
was decoded, table code kept its escaped pipes, decoded white space
could not join or drop, and C0/C1 numeric references kept their
controls. Matching now tries an equal character first, a drawn space
against any line end next, and drops white space decoded or not;
autolinks read as written, table cell code reads \| as |, and numeric
references follow markdown-rs. The reviewer's probes are regressions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:34:50 -04:00
ZacharyZhang-NYandClaude Opus 5.5 d4c614cb7f documents: drawn text reads back from its end, decoded as written
Each piece records how its source decodes: Markdown's escapes and
references for text, HTML's for an HTML block's runs, none for code
spans, cut as CommonMark cuts their end spaces. Matching runs from the
piece's end, so quote marks and indents at a line's start are never
taken for text, and an alert's text needs no moved position: alert()
takes no source again.

Lines split on LF, CRLF and CR alike; LineMap matches each drawn line
to the written line it ends once, so an HTML block maps in linear time
and a tab cut to spaces lands on the tab. A write-back refuses the
press when the pressed block's kind, text or drawing changed.

The hostile HTML test measures growth: a fixed 2 s budget failed under
the full suite's load on the old code too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:07:25 -04:00
ZacharyZhang-NYandClaude Opus 5.5 83d747d87e 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) <noreply@anthropic.com>
2026-10-14 10:11:00 -04:00
ZacharyZhang-NYandClaude Opus 5.5 5af11fe489 documents: a press opens its block on the pressed character; a secondary press points
Each drawn text piece keeps its written span, so a press maps back to a
source byte. A secondary press opens nothing: it moves the caret there and
emits DocumentEvent::Pointed with the block's kind, link, picture and
whether it lies in a table or code. Block nodes count from their own start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:38:08 -04:00
23 changed files with 2095 additions and 470 deletions

No files matched your search

+1 -1
View File
@@ -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 `<code>` 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 `</br>` breaks as `<br>` 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 identitLine truncated
- 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 `<code>` 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 `</br>` breaks as `<br>` 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 identitLine truncated
- 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.
+30 -6
View File
@@ -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>, usize)>) -> Option<usize> {
pub(super) fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
match edit {
None => Some(at),
Some((replaced, _)) if at < replaced.start => Some(at),
@@ -25,9 +34,12 @@ fn moved(at: usize, edit: &Option<(Range<usize>, usize)>) -> Option<usize> {
impl MarkdownDocument {
/// An empty, read visual document takes typing at once.
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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, false, window, cx);
self.open(None, Landing::End, window, cx);
}
}
@@ -47,7 +59,7 @@ impl MarkdownDocument {
pub(super) fn open(
&mut self,
ix: Option<usize>,
at_start: bool,
landing: Landing,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -78,6 +90,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 +109,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 +189,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
}
}
+2 -2
View File
@@ -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();
+8 -1
View File
@@ -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.
@@ -98,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<OnLink>,
on_heading: Option<OnHeading>,
_subscriptions: Vec<Subscription>,
@@ -161,6 +167,7 @@ impl MarkdownDocument {
centered: None,
room: Pixels::ZERO,
focus,
pointing: false,
on_link: None,
on_heading: Some(on_heading),
_subscriptions: subscriptions,
+32 -1
View File
@@ -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<usize>,
pub kind: TargetBlock,
/// None shows a link definition as written.
pub nodes: Option<Vec<Node>>,
}
@@ -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,
}
})
@@ -102,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
@@ -149,6 +155,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");
+156
View File
@@ -0,0 +1,156 @@
use std::rc::Rc;
use gpui::{Context, MouseButton, Window};
use markdown::mdast;
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved, parse::Block};
use crate::documents::{
render::{OnPoint, Place, Pointed},
tree::{Node, alert, is_toc, lines_at},
};
/// 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<TargetBlock>,
pub link: Option<String>,
pub image: Option<String>,
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<Self>) -> 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),
// 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,
}
.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");
})
}
/// A block's kind, text and what it draws.
fn drawn(&self, block: &Block) -> (TargetBlock, String, Option<Vec<Node>>) {
let text = self.parsed.text[block.range.clone()].to_string();
(block.kind, text, block.nodes.clone())
}
/// Caret to `at`, else block `ix`; the owner hears.
pub(super) fn point(
&mut self,
ix: Option<usize>,
at: Option<usize>,
pointed: Pointed,
window: &mut Window,
cx: &mut Context<Self>,
) {
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 pressed = block.map(|block| self.drawn(block));
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"),
},
};
// 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.drawn(block));
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 {
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();
}
}
+7 -6
View File
@@ -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);
+7 -4
View File
@@ -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,9 @@ mod history;
mod links;
mod look;
mod place;
mod point;
mod presses;
mod reading;
mod toc;
mod tools;
+394
View File
@@ -0,0 +1,394 @@
//! A press lands on the pressed character (R4.5).
use std::{cell::RefCell, rc::Rc};
use gpui::{Entity, Modifiers, MouseButton, Pixels, 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`.
pub(super) 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();
}
pub(super) fn heard(
document: &Entity<MarkdownDocument>,
cx: &mut VisualTestContext,
) -> Rc<RefCell<Vec<DocumentTarget>>> {
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<MarkdownDocument>, cx: &mut VisualTestContext) -> usize {
document.read_with(cx, |document, cx| document.editor.read(cx).primary().head)
}
pub(super) fn opened_caret(
document: &Entity<MarkdownDocument>,
cx: &mut VisualTestContext,
) -> usize {
document.read_with(cx, |document, cx| {
document
.open
.as_ref()
.expect("open")
.input
.read(cx)
.cursor()
})
}
pub(super) 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()));
}
/// Base and small text sizes, in pixels.
pub(super) 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))
})
}
pub(super) 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`.
pub(super) 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);
}
#[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 = "<div>alpha beta</div>\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());
}
#[gpui::test]
fn crlf_lines_press_where_they_were_written(cx: &mut TestAppContext) {
let source = "```
alpha
beta
```
";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_lines(2, 0, MouseButton::Right, cx);
let at = source.find("pha").unwrap();
let expected = DocumentTarget {
in_code: true,
..target(at, TargetBlock::Code)
};
assert_eq!(*events.borrow(), [expected]);
press_lines(2, 0, MouseButton::Left, cx);
assert_eq!(opened_caret(&document, cx), at);
}
#[gpui::test]
fn crlf_front_matter_presses_where_written(cx: &mut TestAppContext) {
let source = "---
title: value
author: person
---
";
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_crlf_definition_presses_where_written(cx: &mut TestAppContext) {
let source = "[ref]: https://ely.dev
\"title\"
";
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 a_changed_definition_points_nowhere(cx: &mut TestAppContext) {
let source = "[ref]: https://old.dev
Go [there][ref]
";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
type_in(&document, Some(0), Some("[ref]: https://new.dev"), cx);
press_on(1, 4, MouseButton::Right, cx);
assert_eq!(
super::text(&document, cx),
source.replace("old.dev", "new.dev")
);
assert!(
events.borrow().is_empty(),
"the link changed under the press"
);
}
#[gpui::test]
fn a_quoted_greater_than_presses_on_itself(cx: &mut TestAppContext) {
let source = "> <div>
> >alpha beta</div>";
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 * 0.25;
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
let at = source.find(">alpha").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
}
#[gpui::test]
fn an_escaped_backslash_presses_on_itself(cx: &mut TestAppContext) {
let source = r"a \\ z";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 2, MouseButton::Right, cx);
let at = source.find(r"\ z").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Paragraph)]);
}
#[gpui::test]
fn an_html_space_moved_by_a_tag_presses_on_its_word(cx: &mut TestAppContext) {
let source = "<div>alpha <b> beta</b></div>";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 8, MouseButton::Right, cx);
let at = source.find("eta").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Html)]);
}
+122
View File
@@ -0,0 +1,122 @@
//! Presses on text the parser rewrote (R4.5).
use gpui::{MouseButton, TestAppContext, px};
use super::{
open,
point::{heard, opened_caret, press_at, press_lines, press_on, sizes, target},
};
use crate::documents::{MarkdownMode, TargetBlock};
#[gpui::test]
fn inline_code_kept_indent_press(cx: &mut TestAppContext) {
let source = "a `fox\n y` z";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 3, MouseButton::Right, cx);
assert_eq!(*events.borrow(), [target(4, TargetBlock::Paragraph)]);
press_on(0, 3, MouseButton::Left, cx);
assert_eq!(opened_caret(&document, cx), 4);
}
#[gpui::test]
fn quoted_html_kept_mark_press(cx: &mut TestAppContext) {
let source = "> <div>\n> > alpha beta</div>";
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 * 0.25;
press_at(x, bounds.top() + px(10.0), MouseButton::Right, cx);
assert_eq!(*events.borrow(), [target(10, TargetBlock::Quote)]);
}
#[gpui::test]
fn decoded_line_end_press(cx: &mut TestAppContext) {
let source = "ab &#10; y";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 1, MouseButton::Right, cx);
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
}
#[gpui::test]
fn numeric_control_press(cx: &mut TestAppContext) {
let source = "ab &#128; y";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 1, MouseButton::Right, cx);
assert_eq!(*events.borrow(), [target(1, TargetBlock::Paragraph)]);
}
#[gpui::test]
fn html_decoded_space_press(cx: &mut TestAppContext) {
let source = "<div>ab&#32;</div>";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 1, MouseButton::Right, cx);
assert_eq!(*events.borrow(), [target(6, TargetBlock::Html)]);
}
#[gpui::test]
fn inline_code_kept_indent_left_press(cx: &mut TestAppContext) {
let source = "a `fox\n y` z";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
press_on(0, 3, MouseButton::Left, cx);
assert_eq!(opened_caret(&document, cx), 4);
}
#[gpui::test]
fn autolink_literal_reference_press(cx: &mut TestAppContext) {
let source = "<https://example.com/?a=&amp;b=2>";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_on(0, 9, MouseButton::Right, cx);
assert_eq!(events.borrow().len(), 1);
assert_eq!(events.borrow()[0].at, source.find('x').unwrap());
}
#[gpui::test]
fn table_escaped_pipe_press(cx: &mut TestAppContext) {
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
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(26.0) + body * 0.6 * 1.25;
press_at(x, bounds.top() + px(17.0), MouseButton::Right, cx);
assert_eq!(events.borrow().len(), 1);
assert!(events.borrow()[0].in_table);
assert_eq!(events.borrow()[0].at, source.find('b').unwrap());
}
#[gpui::test]
fn prior_crlf_code_press(cx: &mut TestAppContext) {
let source = "```\r\nalpha\r\nbeta\r\n```\r\n";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_lines(2, 0, MouseButton::Right, cx);
assert_eq!(events.borrow()[0].at, source.find("pha").unwrap());
press_lines(2, 0, MouseButton::Left, cx);
assert_eq!(opened_caret(&document, cx), source.find("pha").unwrap());
}
#[gpui::test]
fn prior_crlf_front_matter_press(cx: &mut TestAppContext) {
let source = "---\r\ntitle: value\r\nauthor: person\r\n---\r\n";
let (document, cx) = open(source, MarkdownMode::Visual, cx);
let events = heard(&document, cx);
press_lines(7, 0, MouseButton::Right, cx);
assert_eq!(events.borrow()[0].at, source.find("value").unwrap());
press_lines(7, 0, MouseButton::Left, cx);
assert_eq!(opened_caret(&document, cx), source.find("value").unwrap());
}
#[gpui::test]
fn prior_crlf_definition_press(cx: &mut TestAppContext) {
let source = "[ref]: https://ely.dev\r\n \"title\"\r\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)]);
}
+181
View File
@@ -0,0 +1,181 @@
//! Drawn text reads back to its source byte (R4.5).
use crate::documents::{
TargetBlock,
document::parse::parsed,
tree::{Inline, Node, lines_at},
};
/// The source byte under drawn `shown` in block 0.
fn read_back(source: &str, shown: &str) -> Option<usize> {
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)
}
/// The source byte under drawn `word` of block 0's code.
fn code_back(source: &str, word: &str) -> Option<usize> {
fn code(nodes: &[Node]) -> (&str, usize) {
match &nodes[0] {
Node::Code { text, from, .. } => (text, *from),
Node::Quote(body) => code(body),
other => panic!("not code: {other:?}"),
}
}
let parsed = parsed(source);
let block = &parsed.blocks[0];
let (drawn, from) = code(block.nodes.as_ref().expect("drawn"));
let at = drawn.find(word).expect("drawn");
let written = lines_at(&source[block.range.start..], from, drawn, at)?;
Some(block.range.start + written)
}
#[test]
fn text_reads_back_past_marks_escapes_trims_and_markers() {
let cases = [
("> alpha\n> beta", "eta"),
("> [!NOTE]\n> alpha beta", "beta"),
("> [!NOTE] \n> alpha beta", "beta"),
("> [!NOTE]\\\n> alpha beta", "beta"),
("> [!NOTE]\n> \\*beta\\*", "*beta"),
("> [!NOTE]&#10;alpha beta", "beta"),
("a \\*star\\* z", "*star"),
("a \\\\ z", "\\ z"),
("a ` xy ` z", "xy"),
("a ` xy ` z", " xy "),
("a ` >xy ` z", ">xy"),
("a `\\\\xy` z", "xy"),
("<div>alpha beta</div>\n", "eta"),
("> <div>\n> alpha beta</div>\n", "beta"),
("> <div>\n> >alpha beta</div>", ">alpha"),
("> <div>\n> >alpha beta</div>", "beta"),
("<div>alpha <b>beta</b> gamma</div>", "gamma"),
("<div>alpha <b>beta</b> gamma</div>", "beta"),
("<div>alpha <b> beta</b></div>", "eta"),
("<div>one</div>\n<div>alpha beta</div>", "beta"),
];
for (source, shown) in cases {
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
}
}
#[test]
fn a_reference_reads_back_to_its_ampersand() {
let source = "a &amp; b";
assert_eq!(read_back(source, "& b"), source.find('&'));
assert_eq!(read_back(source, "b"), source.find('b'));
}
#[test]
fn text_after_an_emoji_reads_back_and_before_it_lands_at_its_start() {
let source = "ab :smile: y";
assert_eq!(read_back(source, "y"), source.find('y'));
assert_eq!(read_back(source, "b"), Some(0));
}
#[test]
fn code_reads_back_line_for_line_whatever_its_line_ends() {
let cases = [
"```\nalpha\nbeta\n```\n",
"```\r\nalpha\r\nbeta\r\n```\r\n",
"```\ralpha\rbeta\r```\r",
" alpha\n beta\n",
"> ```\n> alpha\n> beta\n> ```\n",
];
for source in cases {
for word in ["pha", "eta"] {
assert_eq!(code_back(source, word), source.find(word), "{source:?}");
}
}
}
#[test]
fn code_cut_from_a_tab_reads_back_past_it() {
for spaces in 0..=3 {
for prefix in ["\t", " \t", " \t", " \t", "\t\t"] {
let indent = " ".repeat(spaces);
let source = format!("{indent}```\n{indent}alpha\n{prefix}beta\n{indent}```\n");
assert_eq!(code_back(&source, "eta"), source.find("eta"), "{source:?}");
}
}
}
#[test]
fn a_decoded_line_end_reads_without_a_written_one() {
let quote = parsed("> alpha&#10;beta");
assert_eq!(quote.blocks[0].kind, TargetBlock::Quote);
let alert = parsed("> [!NOTE]&#10;alpha beta");
assert_eq!(alert.blocks[0].kind, TargetBlock::Alert);
}
#[test]
fn kept_leading_marks_still_map() {
let source = "a `fox\n y` z";
assert_eq!(read_back(source, "ox"), source.find("ox"));
}
#[test]
fn decoded_line_end_maps_nearby_text() {
for (source, shown) in [
("ab &#10; y", "b"),
("ab &#13; y", "b"),
("ab &NewLine; y", "b"),
("<div>ab &#10; y</div>", "b"),
("<div>ab &#9; y</div>", "b"),
] {
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
}
}
#[test]
fn numeric_references_match_the_parser() {
for source in ["ab &#128; y", "ab &#1; y", "ab &#xFDD0; y"] {
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
}
}
#[test]
fn html_trailing_encoded_space_maps_previous_word() {
for source in [
"<div>ab&#32;</div>",
"<div>ab&#32;<b>word</b></div>",
"<div>ab &#32; word</div>",
] {
assert_eq!(read_back(source, "b"), source.find('b'), "{source:?}");
}
}
#[test]
fn code_pipe_in_a_table_maps_previous_text() {
let source = "| `ab\\|cd` |\n| --- |\n| cell |\n";
let parsed = parsed(source);
let nodes = parsed.blocks[0].nodes.as_ref().unwrap();
let Node::Table { head, .. } = &nodes[0] else {
panic!("table")
};
let Node::Paragraph(inline) = &head[0][0] else {
panic!("text")
};
assert_eq!(inline.text, "ab|cd");
assert_eq!(inline.source_at(source, 1), source.find('b'));
}
#[test]
fn autolink_does_not_decode_literal_reference() {
let source = "<https://example.com/?a=&amp;b=2>";
assert_eq!(read_back(source, "xample"), source.find("xample"));
}
+46 -15
View File
@@ -1,12 +1,16 @@
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;
use super::{MarkdownDocument, edit::Landing};
use crate::{
documents::{MarkdownMode, render::draw_nodes},
documents::{
MarkdownMode,
render::{Hooks, Pointed, draw_nodes, point_lines},
},
forms::{self, Down, Up},
primitives::FocusRing as _,
theme::{ActiveTheme, Radius, TextSize},
@@ -55,6 +59,9 @@ impl MarkdownDocument {
/// A block, or the end.
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Self>) -> 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 +71,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,17 +105,34 @@ 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,
)),
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))
@@ -125,7 +148,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<gpui::Div>,
@@ -149,13 +172,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);
}
}),
)
+2 -2
View File
@@ -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};
+78 -41
View File
@@ -22,6 +22,9 @@ use crate::{
};
mod blocks;
mod point;
pub(crate) use point::{OnPoint, Place, Pointed, point_lines};
pub(crate) type OnLink = Rc<dyn Fn(&str, &mut Window, &mut App)>;
/// 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<Arc<Path>>,
pub on_link: &'a Option<OnLink>,
pub on_heading: &'a Option<OnHeading>,
pub on_point: &'a Option<OnPoint>,
}
/// Draws nodes under one owner's ids and hooks.
struct Draw<'a> {
id: &'a ElementId,
on_link: &'a Option<OnLink>,
hooks: Hooks<'a>,
next: std::cell::Cell<usize>,
sections: &'a Option<Entity<Sections>>,
headings: std::cell::Cell<usize>,
code: &'a Option<DrawCode>,
base: &'a Option<Arc<Path>>,
/// Text size against the theme's.
scale: f32,
/// Lists open around the node drawn.
lists: std::cell::Cell<usize>,
on_heading: &'a Option<OnHeading>,
/// Drawing a table's cells.
table: std::cell::Cell<bool>,
}
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<AnyElement> {
@@ -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, *from);
}
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, text)), *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()
@@ -374,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),
}
}
}
@@ -389,27 +425,24 @@ fn open_link(on_link: &Option<OnLink>, 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<Arc<Path>>,
on_link: &Option<OnLink>,
on_heading: &Option<OnHeading>,
hooks: Hooks,
scale: f32,
cx: &App,
) -> Vec<AnyElement> {
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()
+18 -7
View File
@@ -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},
@@ -143,7 +144,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());
@@ -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),
)
}
}
+184
View File
@@ -0,0 +1,184 @@
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<Inline>, usize),
/// A source byte.
At(usize),
/// Drawn byte `at` of `text`, its lines written from `from`.
Lines {
text: Rc<str>,
from: usize,
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<String>,
pub image: Option<String>,
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<dyn Fn(Pointed, MouseButton, &mut Window, &mut App)>;
/// `element` hands its left and right presses to `on_point`.
fn pointable(
element: AnyElement,
on_point: &Option<OnPoint>,
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<OnPoint>,
(table, code): (bool, bool),
) -> AnyElement {
let text: Rc<str> = 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<'_> {
fn pointable(
&self,
element: AnyElement,
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
) -> AnyElement {
pointable(element, self.hooks.on_point, self.table.get(), point)
}
/// 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 lines start at source byte `from`.
pub(super) fn point_code(
&self,
element: AnyElement,
drawn: Option<(TextLayout, &str)>,
from: usize,
) -> AnyElement {
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::block()
}),
}
}
/// A picture written at source byte `at`.
pub(super) fn point_image(
&self,
element: AnyElement,
at: usize,
url: &str,
link: &Option<String>,
) -> 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))
})
}
}
+2 -281
View File
@@ -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.
@@ -76,138 +76,6 @@ impl Reader<'_> {
}
}
/// 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("<!--") {
rest = comment.split_once("-->").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<mdast::Node>)> {
let [mdast::Node::Paragraph(paragraph), rest @ ..] = &quote.children[..] else {
@@ -263,7 +131,7 @@ fn marker(children: &[mdast::Node]) -> Option<(AlertKind, Vec<mdast::Node>)> {
#[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<Node>)> {
match parse(source).into_iter().next() {
@@ -345,151 +213,4 @@ mod tests {
assert!(matches!(&parse("[toc] now")[0], Node::Paragraph(_)));
assert!(matches!(&parse("> [toc]")[0], Node::Quote(_)));
}
fn html(source: &str) -> Option<Inline> {
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(
"<div align=\"center\">\n <b>Bold</b> and\n :smile: &amp; <code>:smile:</code>\n</div>",
)
.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("<p>one <b>two </b></p>\n<p>three &lt;b&gt;</p>").expect("text");
assert_eq!(parts.text, "one two\nthree <b>");
assert_eq!(
html("<div>\na <br> b</br>c</div>").expect("text").text,
"a\nb\nc"
);
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
assert_eq!(
html(filtered).expect("text").text,
"<script>x</script> shown<style>p{}</style>"
);
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
assert_eq!(
html(crossed).expect("text").text,
"before<script>ab</script>after"
);
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
assert_eq!(text(&split), ["<script> first", "second </script>"]);
let inline = html("a <script>b</script> c").expect("text");
assert_eq!(inline.text, "a <script>b</script> c");
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
}
#[test]
fn a_closing_block_ends_what_opened_inside_it() {
let marked = html("<div><mark>x</div>y").expect("text");
assert_eq!(
(marked.text.as_str(), &marked.styles[..]),
("x\ny", &[(0..1, Style::Mark)][..])
);
let bold = html("<div><b>x</div>y").expect("text");
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
let kept = html("<div><mark><p>x</p>y</mark></div>").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 [
"<ul><li><mark>x<li>y</ul>z",
"<div><p><mark>x<div>y</div>z</div>",
"<dl><dt><mark>x<dd>y</dl>z",
] {
let line = html(source).expect("text");
assert_eq!(
(line.text.as_str(), &line.styles[..]),
("x\ny\nz", &mark[..])
);
}
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").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 [
(
"<div><script>:smile:</script> :smile:</div>",
"<script>:smile:</script> :smile:",
),
(
"<div><script><b>:smile:</b></script> :smile:</div>",
"<script>😄</script> 😄",
),
(
"<div><script> :smile:</script> :smile:</div>",
"<script> 😄</script> 😄",
),
(
"<div><script title=\" :smile:\"> x:smile:</div>",
"<script title=\" 😄\"> x😄",
),
(
"<div><iframe title=\":smile:\"> :smile:</div>",
"<iframe title=\":smile:\"> :smile:",
),
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
("<div>:smile:</div>", "😄"),
] {
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
}
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
assert_eq!(html(named).expect("text").text, "axbyc");
}
#[test]
fn every_html5_p_closer_ends_a_p() {
for name in ["fieldset", "menu"] {
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
let mark = [(0..1, Style::Mark)];
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
}
}
#[test]
fn hostile_html_reads_in_linear_time() {
let started = web_time::Instant::now();
for source in [
"<".repeat(1 << 17),
"<a".repeat(1 << 16),
"<!--".repeat(1 << 16),
"<b>".repeat(1 << 16),
"<b>".repeat(1 << 15) + &"</i>".repeat(1 << 15),
"<script></script>".repeat(1 << 13),
"<div>a ".repeat(1 << 16),
"<b> ".repeat(1 << 16),
"<li><b>x ".repeat(1 << 15),
"<p><i>x ".repeat(1 << 15),
] {
let block = format!("<div>\n{source}");
parse(&block);
}
let spent = started.elapsed();
assert!(spent.as_secs() < 2, "took {spent:?}");
}
}
+1 -1
View File
@@ -300,7 +300,7 @@ fn decoded(text: &str, attribute: bool) -> String {
const LONGEST: usize = 32;
/// A reference at `text`'s start, as HTML reads it.
fn reference(text: &str, attribute: bool) -> Option<(String, usize)> {
pub(super) fn reference(text: &str, attribute: bool) -> Option<(String, usize)> {
let body = text.strip_prefix('&')?;
if let Some(number) = body.strip_prefix('#') {
let (digits, radix) = match number.strip_prefix(['x', 'X']) {
+318
View File
@@ -0,0 +1,318 @@
use std::ops::Range;
use super::{Escapes, Inline, Run, html, sources::LineMap};
/// An HTML block's text, as a browser shows it.
pub(super) fn html_block_text(written: &str, source: &str, span: Range<usize>) -> Inline {
let (mut run, map) = (Run::default(), LineMap::new(source, span.start, written));
let mut rest = written;
// Each text run keeps where it was written.
let words = |run: &mut Run, rest: &str, text: &str| {
let from = written.len() - rest.len();
let at = |value| map.as_ref()?.at(value);
// Blank text draws nothing where it stands.
if text.chars().all(html::spacing) {
return run.words(text);
}
match at(from).zip(at(from + text.len())) {
Some((start, end)) => {
(run.text.sources).push((run.text.text.len(), start..end, Escapes::Html))
}
None => log::warn!("markdown: an HTML block's text lost its source"),
}
run.words(text);
};
while let Some(at) = rest.find('<') {
words(&mut run, rest, &rest[..at]);
rest = &rest[at..];
if let Some(comment) = rest.strip_prefix("<!--") {
rest = comment.split_once("-->").map_or("", |(_, after)| after);
run.shortcodes.split();
continue;
}
let opens =
rest[1..].starts_with(|ch: char| ch.is_ascii_alphabetic() || "/!?".contains(ch));
if !opens {
words(&mut run, rest, "<");
rest = &rest[1..];
continue;
}
// An unclosed tag drops the rest, as HTML5 does.
let Some(end) = html::tag_end(rest) else {
rest = "";
break;
};
let (tag_text, after) = rest.split_at(end);
match html::tag(tag_text) {
Some(tag) if html::filtered(&tag.name) => words(&mut run, rest, tag_text),
// 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(tag_text);
}
// 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(tag_text);
}
Some(_) => {
run.pay();
run.html(tag_text);
}
None => run.shortcodes.split(),
}
rest = after;
}
words(&mut run, rest, 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");
}
}
}
#[cfg(test)]
mod tests {
use crate::documents::tree::{Inline, Node, Style, parse};
fn html(source: &str) -> Option<Inline> {
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(
"<div align=\"center\">\n <b>Bold</b> and\n :smile: &amp; <code>:smile:</code>\n</div>",
)
.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("<p>one <b>two </b></p>\n<p>three &lt;b&gt;</p>").expect("text");
assert_eq!(parts.text, "one two\nthree <b>");
assert_eq!(
html("<div>\na <br> b</br>c</div>").expect("text").text,
"a\nb\nc"
);
let filtered = "<div>\n<script>x</script>\n<!-- note -->\nshown<style>p{}</style>\n</div>";
assert_eq!(
html(filtered).expect("text").text,
"<script>x</script> shown<style>p{}</style>"
);
let crossed = "<div>before<script>a</scriptx>b</script>after</div>";
assert_eq!(
html(crossed).expect("text").text,
"before<script>ab</script>after"
);
let split = parse("<div>\n<script>\nfirst\n\nsecond\n</script>\n</div>");
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, ["<script> first", "second </script>"]);
let inline = html("a <script>b</script> c").expect("text");
assert_eq!(inline.text, "a <script>b</script> c");
assert_eq!(html("<div>\na < b <i").expect("text").text, "a < b");
}
#[test]
fn a_closing_block_ends_what_opened_inside_it() {
let marked = html("<div><mark>x</div>y").expect("text");
assert_eq!(
(marked.text.as_str(), &marked.styles[..]),
("x\ny", &[(0..1, Style::Mark)][..])
);
let bold = html("<div><b>x</div>y").expect("text");
assert_eq!(bold.styles, [(0..1, Style::Strong), (2..3, Style::Strong)]);
let kept = html("<div><mark><p>x</p>y</mark></div>").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 [
"<ul><li><mark>x<li>y</ul>z",
"<div><p><mark>x<div>y</div>z</div>",
"<dl><dt><mark>x<dd>y</dl>z",
] {
let line = html(source).expect("text");
assert_eq!(
(line.text.as_str(), &line.styles[..]),
("x\ny\nz", &mark[..])
);
}
let kbd = html("<div><p><kbd>x<p>y</div>z").expect("text");
assert_eq!(kbd.styles, [(0..1, Style::Code)]);
let nested = html("<ul><li><mark>a<ul><li>b</li></ul></li></ul>").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 [
(
"<div><script>:smile:</script> :smile:</div>",
"<script>:smile:</script> :smile:",
),
(
"<div><script><b>:smile:</b></script> :smile:</div>",
"<script>😄</script> 😄",
),
(
"<div><script> :smile:</script> :smile:</div>",
"<script> 😄</script> 😄",
),
(
"<div><script title=\" :smile:\"> x:smile:</div>",
"<script title=\" 😄\"> x😄",
),
(
"<div><iframe title=\":smile:\"> :smile:</div>",
"<iframe title=\":smile:\"> :smile:",
),
("<div>\n:smile: x:smile:\n</div>", "😄 x😄"),
("<div>\t:smile: x:smile:</div>", "😄 x😄"),
("<div>a\n :smile: x:smile:</div>", "a 😄 x😄"),
("<div>y\n:smile: x:smile:</div>", "y :smile: x:smile:"),
("<div>:smile:</div>", "😄"),
] {
assert_eq!(html(source).expect("text").text, shown, "{source:?}");
}
let named = "<div>a<script:custom>x</script:custom>b<script!>y</script!>c</div>";
assert_eq!(html(named).expect("text").text, "axbyc");
}
#[test]
fn every_html5_p_closer_ends_a_p() {
for name in ["fieldset", "menu"] {
let line = html(&format!("<div><p><mark>x<{name}>y</{name}>z</div>")).expect("text");
let mark = [(0..1, Style::Mark)];
assert_eq!((line.text.as_str(), &line.styles[..]), ("x\nyz", &mark[..]));
}
}
#[test]
fn hostile_html_reads_in_linear_time() {
let sources = |n: usize| {
[
"<".repeat(2 * n),
"<a".repeat(n),
"<!--".repeat(n),
"<b>".repeat(n),
"<b>".repeat(n / 2) + &"</i>".repeat(n / 2),
"<script></script>".repeat(n / 8),
"<div>a ".repeat(n),
"<b> ".repeat(n),
"<li><b>x ".repeat(n / 2),
"<p><i>x ".repeat(n / 2),
]
};
let time = |n| {
let started = web_time::Instant::now();
for source in sources(n) {
parse(&format!(
"<div>
{source}"
));
}
started.elapsed()
};
let (small, large) = (time(1 << 14), time(1 << 16));
// Four times the input, at most eight the time.
assert!(large < small * 8, "{small:?} then {large:?}");
}
}
+49 -96
View File
@@ -7,10 +7,15 @@ use super::markdown::{span, tree, walk};
mod blocks;
mod emoji;
mod html;
mod html_block;
mod sources;
mod stack;
use blocks::html_block_text;
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
use html::InlineTag;
use html_block::html_block_text;
pub use sources::Escapes;
pub(crate) use sources::lines_at;
use stack::close;
/// How a stretch of inline text prints.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -33,6 +38,8 @@ pub struct Inline {
pub text: String,
pub styles: Vec<(Range<usize>, Style)>,
pub links: Vec<(Range<usize>, String)>,
/// Where each piece was written: drawn start, source span.
pub sources: Vec<(usize, Range<usize>, Escapes)>,
}
/// A block of a markdown document.
@@ -44,6 +51,8 @@ pub enum Node {
Code {
language: Option<String>,
text: String,
/// Where its lines start in the source.
from: usize,
},
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
List {
@@ -63,6 +72,8 @@ pub enum Node {
alt: String,
url: String,
link: Option<String>,
/// Its source start.
at: usize,
},
Footnote {
label: String,
@@ -75,8 +86,11 @@ pub enum Node {
},
/// `[toc]`: the file's headings.
Toc(Vec<DocumentHeading>),
/// Front matter, its fences cut.
FrontMatter(String),
/// Front matter, its fences cut; lines start at `from`.
FrontMatter {
text: String,
from: usize,
},
}
/// A tree read against its source and its link definitions.
@@ -106,6 +120,8 @@ struct Run {
named: HashMap<String, usize>,
/// Inside an autolink: its text stays as written.
verbatim: bool,
/// In a table cell, whose code reads `\|` as `|`.
table: bool,
/// The current DOM text node's shortcodes.
shortcodes: emoji::Shortcodes,
/// White space owed, and the open elements before it.
@@ -146,90 +162,6 @@ impl Run {
let soft = shown.replace("\r\n", " ").replace(['\r', '\n'], " ");
self.text.text.push_str(&soft);
}
fn push(&mut self, opened: Opened) {
*self.named.entry(opened.name.clone()).or_default() += 1;
self.open.push(opened);
}
/// The innermost open link's address.
fn link(&self) -> Option<String> {
self.open.iter().rev().find_map(|opened| opened.url.clone())
}
/// Opens or closes an element for an inline HTML tag.
fn html(&mut self, written: &str) {
match html::inline_tag(written) {
InlineTag::Text(shown) => self.push_text(&shown),
InlineTag::Open { name, style, href } => self.start(&name, style, href),
InlineTag::Close(name) => {
self.shortcodes.split();
self.end(&name);
}
InlineTag::Break => {
self.shortcodes.split();
self.text.text.push('\n');
}
InlineTag::Gone => self.shortcodes.split(),
}
}
/// Opens `name`; a new `a` ends the open one.
fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
if name == "a" {
self.end("a");
}
self.shortcodes.split();
self.push(Opened {
name: name.to_string(),
start: self.text.text.len(),
style,
url,
});
}
/// Ends the innermost `name`, as HTML5 repairs nesting.
fn end(&mut self, name: &str) {
if self.count(name) == 0 {
return;
}
self.shortcodes.split();
let at = self
.open
.iter()
.rposition(|opened| opened.name == name)
.expect("a counted name is open");
let end = self.text.text.len();
if let Some((_, from)) = &mut self.owed {
*from = (*from).min(at);
}
let closed = self.open.split_off(at);
for opened in &closed {
close(&mut self.text, opened, end);
*self.named.get_mut(&opened.name).expect("counted") -= 1;
}
for opened in closed.into_iter().skip(1) {
if html::reopens(&opened.name) {
self.push(Opened {
start: end,
..opened
});
}
}
}
}
/// Records `opened` as styled up to `end`.
fn close(text: &mut Inline, opened: &Opened, end: usize) {
if opened.start >= end {
return;
}
if let Some(style) = opened.style {
text.styles.push((opened.start..end, style));
}
if let Some(url) = &opened.url {
text.links.push((opened.start..end, url.clone()));
}
}
/// `markdown` as blocks.
@@ -315,6 +247,7 @@ impl<'a> Reader<'a> {
mdast::Node::Code(code) => out.push(Node::Code {
language: code.lang.clone(),
text: code.value.clone(),
from: sources::body(self.source, node),
}),
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
mdast::Node::List(list) => {
@@ -355,9 +288,14 @@ impl<'a> Reader<'a> {
label: self.label(note),
body: self.nested(&note.children),
}),
mdast::Node::Html(html) => text_block(html_block_text(&html.value), out),
mdast::Node::Yaml(yaml) => out.push(Node::FrontMatter(yaml.value.clone())),
mdast::Node::Toml(toml) => out.push(Node::FrontMatter(toml.value.clone())),
mdast::Node::Html(html) => {
text_block(html_block_text(&html.value, self.source, span(node)), out)
}
mdast::Node::Yaml(mdast::Yaml { value, .. })
| mdast::Node::Toml(mdast::Toml { value, .. }) => out.push(Node::FrontMatter {
text: value.clone(),
from: sources::body(self.source, node),
}),
mdast::Node::Definition(_) => {}
other => unreachable!("{other:?} is no block outside MDX"),
}
@@ -370,7 +308,10 @@ impl<'a> Reader<'a> {
{
return out.push(Node::Math(math.value.clone()));
}
let mut run = Run::default();
let mut run = Run {
table: matches!(node, mdast::Node::TableCell(_)),
..Run::default()
};
for child in children {
self.inline_into(child, &mut run, &mut Images::Split(out));
}
@@ -386,7 +327,10 @@ impl<'a> Reader<'a> {
pub fn label(&self, note: &mdast::FootnoteDefinition) -> String {
match self.note(&note.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 +348,15 @@ impl<'a> Reader<'a> {
if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) {
run.shortcodes.split();
}
if let Some((written, escapes)) = sources::written(node, self.source) {
let escapes = match (escapes, node) {
(Escapes::Markdown, _) if run.verbatim => Escapes::Plain,
(_, mdast::Node::InlineCode(_)) if run.table => Escapes::Pipes,
_ => escapes,
};
let at = run.text.text.len();
run.text.sources.push((at, written, escapes));
}
let text = &mut run.text;
let (children, (name, style), url) = match node {
mdast::Node::Text(words) => return run.push_text(&words.value),
@@ -422,14 +375,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),
+357
View File
@@ -0,0 +1,357 @@
use std::ops::Range;
use markdown::{decode_named, mdast::Node};
use super::{Inline, Node as Drawn, html, span};
/// How a piece's source turns into its text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Escapes {
Plain,
/// Backslash escapes and references, as Markdown reads them.
Markdown,
/// References, as HTML reads them.
Html,
/// Escaped pipes, as a table cell's code reads them.
Pipes,
}
/// A leaf's written span and how it reads.
pub(super) fn written(node: &Node, source: &str) -> Option<(Range<usize>, Escapes)> {
let whole = match node {
Node::Text(_)
| Node::Break(_)
| Node::Html(_)
| Node::FootnoteReference(_)
| Node::Image(_)
| Node::ImageReference(_)
| Node::InlineCode(_)
| Node::InlineMath(_) => span(node),
_ => return None,
};
Some(match node {
Node::Text(_) => (whole, Escapes::Markdown),
Node::InlineCode(_) => (inside(source, whole, '`'), Escapes::Plain),
Node::InlineMath(_) => (inside(source, whole, '$'), Escapes::Plain),
_ => (whole, Escapes::Plain),
})
}
/// A code span's text, one end space cut each side.
fn inside(source: &str, whole: Range<usize>, mark: char) -> Range<usize> {
let written = &source[whole.clone()];
let open = written.len() - written.trim_start_matches(mark).len();
let close = written.len() - written.trim_end_matches(mark).len();
let text = &written[open..written.len().saturating_sub(close).max(open)];
let blank = |ch: char| matches!(ch, ' ' | '\n' | '\r');
let cut = text.starts_with(blank) && text.ends_with(blank) && !text.chars().all(blank);
let width = |crlf: bool| if crlf { 2 } else { 1 };
let (head, tail) = match cut {
true => (
width(text.starts_with("\r\n")),
width(text.ends_with("\r\n")),
),
false => (0, 0),
};
whole.start + open + head..whole.start + open + text.len() - tail
}
/// Where a block's text lines start: past a fence line.
pub(super) fn body(source: &str, node: &Node) -> usize {
let whole = span(node);
let written = &source[whole.clone()];
let fenced = matches!(node, Node::Yaml(_) | Node::Toml(_))
|| ["```", "~~~"]
.iter()
.any(|fence| written.starts_with(fence));
match fenced {
true => lines(written)
.nth(1)
.map_or(whole.end, |line| whole.start + line.start),
false => whole.start,
}
}
/// `text`'s lines without their endings, split as CommonMark splits.
fn lines(text: &str) -> impl Iterator<Item = Range<usize>> + '_ {
let mut next = Some(0);
std::iter::from_fn(move || {
let start = next?;
let rest = &text[start..];
let Some(end) = rest.find(['\n', '\r']) else {
next = None;
return Some(start..text.len());
};
let width = if rest[end..].starts_with("\r\n") {
2
} else {
1
};
next = Some(start + end + width);
Some(start..start + end)
})
}
/// Source byte under drawn byte `at`; lines start at `from`.
pub(crate) fn lines_at(source: &str, from: usize, drawn: &str, at: usize) -> Option<usize> {
LineMap::new(source, from, drawn)?.at(at)
}
/// Each drawn line set against the written line it ends.
pub(super) struct LineMap(Vec<Line>);
struct Line {
drawn: Range<usize>,
/// Where its written line starts, and its shared end.
written: usize,
common: usize,
kept: usize,
/// The char before the shared end, as a cut tab.
before: usize,
}
impl LineMap {
pub(super) fn new(source: &str, from: usize, drawn: &str) -> Option<Self> {
let written = source.get(from..)?;
let lines = lines(drawn).zip(lines(written)).map(|(line, place)| {
let (shown, text) = (&drawn[line.clone()], &written[place.clone()]);
let kept: usize = match text.ends_with(shown) {
true => shown.len(),
false => (shown.chars().rev())
.zip(text.chars().rev())
.take_while(|(a, b)| a == b)
.map(|(a, _)| a.len_utf8())
.sum(),
};
let common = text.len() - kept;
let before =
(text[..common].chars().next_back()).map_or(0, |ch| common - ch.len_utf8());
Line {
drawn: line,
written: from + place.start,
common,
kept,
before,
}
});
Some(Self(lines.collect()))
}
/// Source byte under drawn byte `at`.
pub(super) fn at(&self, at: usize) -> Option<usize> {
let line = self
.0
.get(self.0.partition_point(|line| line.drawn.end < at))?;
let column = at.saturating_sub(line.drawn.start);
let shared = line.drawn.len() - line.kept;
Some(
line.written
+ match column.checked_sub(shared) {
Some(into) => line.common + into,
None => line.before,
},
)
}
}
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, .. } | Drawn::FrontMatter { from, .. } => *from -= start,
Drawn::Image { at, .. } => *at -= start,
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) => {}
}
}
}
impl Inline {
/// Source byte under drawn byte `at`, in some piece.
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
let piece = self.sources.partition_point(|(start, ..)| *start <= at);
let (start, span, escapes) = 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())?;
// Unreadable text lands at its start.
Some(span.start + align(drawn, written, *escapes, at - start).unwrap_or(0))
}
}
/// A source char as read, and where it was written.
struct Read {
ch: char,
at: usize,
/// Written as itself, not escaped or referenced.
raw: bool,
/// A container mark or indent at a line's start.
lead: bool,
}
impl Read {
fn ends_line(&self) -> bool {
matches!(self.ch, '\n' | '\r')
}
}
/// `written`'s chars, escapes and references read.
fn read(written: &str, escapes: Escapes) -> Vec<Read> {
let mut out = Vec::with_capacity(written.len());
let (mut at, mut line_start) = (0, false);
while let Some(ch) = written[at..].chars().next() {
let rest = &written[at..];
let decoded = match (escapes, ch) {
(Escapes::Markdown, '\\') => (rest[1..].chars().next())
.filter(char::is_ascii_punctuation)
.map(|punct| (punct.to_string(), 2, at + 1)),
(Escapes::Markdown, '&') => reference(rest).map(|(value, used)| (value, used, at)),
(Escapes::Html, '&') => {
html::reference(rest, false).map(|(value, used)| (value, used, at))
}
(Escapes::Pipes, '\\') => {
(rest[1..].starts_with('|')).then(|| ("|".to_string(), 2, at + 1))
}
_ => None,
};
if let Some((value, used, place)) = decoded {
out.extend(value.chars().map(|ch| Read {
ch,
at: place,
raw: false,
lead: false,
}));
(at, line_start) = (at + used, false);
continue;
}
let ends = matches!(ch, '\n' | '\r');
let lead = line_start && !ends && matches!(ch, ' ' | '\t' | '>');
out.push(Read {
ch,
at,
raw: true,
lead,
});
(at, line_start) = (at + ch.len_utf8(), ends || lead);
}
out
}
/// A reference at `text`'s start, as Markdown reads it.
fn reference(text: &str) -> Option<(String, usize)> {
let body = text.strip_prefix('&')?;
let (end, _) = body.char_indices().take(34).find(|(_, ch)| *ch == ';')?;
let name = &body[..end];
let code = |digits: &str, radix: u32, most: usize| {
let fits =
(1..=most).contains(&digits.len()) && digits.chars().all(|ch| ch.is_digit(radix));
let code = u32::from_str_radix(digits, radix).ok().filter(|_| fits)?;
// Controls become U+FFFD, as markdown-rs reads them.
let ch = char::from_u32(code).filter(|ch| {
!matches!(ch, '\0'..='\u{08}' | '\u{0B}' | '\u{0E}'..='\u{1F}' | '\u{7F}'..='\u{9F}')
});
Some(ch.unwrap_or(char::REPLACEMENT_CHARACTER).to_string())
};
let value = match name.strip_prefix('#') {
Some(hex) if hex.starts_with(['x', 'X']) => code(&hex[1..], 16, 6)?,
Some(digits) => code(digits, 10, 7)?,
None if name.chars().all(|ch| ch.is_ascii_alphanumeric()) => decode_named(name, true)?,
None => return None,
};
Some((value, end + 2))
}
/// Where drawn byte `at` was written, matched from the end.
fn align(drawn: &str, written: &str, escapes: Escapes, at: usize) -> Option<usize> {
if at >= drawn.len() {
return Some(written.len());
}
let source = read(written, escapes);
let mut j = source.len();
for (byte, d) in drawn.char_indices().rev() {
let found = loop {
let s = source[..j].last()?;
if d == s.ch {
j -= 1;
break s.at;
}
// A join: a line's end drawn as white space.
if d.is_whitespace() && s.ends_line() {
j -= 1;
if s.ch == '\n' && j > 0 && source[j - 1].ch == '\r' && source[j - 1].raw == s.raw {
j -= 1;
}
break source[j].at;
}
// White space, line ends and marks the text dropped.
if s.lead || matches!(s.ch, ' ' | '\t') || s.ends_line() {
j -= 1;
continue;
}
return None;
};
if byte == at {
return Some(found);
}
}
None
}
#[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 references_read_back_and_emoji_end_the_reading() {
let source = "a &amp; b";
assert_eq!(under(source, "b"), source.find('b').unwrap());
let source = "ab :smile: y";
assert_eq!(under(source, "y"), source.find('y').unwrap());
assert_eq!(under(source, "b"), 0, "before the emoji: its start");
}
}
+88
View File
@@ -0,0 +1,88 @@
use super::{Inline, Opened, Run, Style, html, html::InlineTag};
/// The elements open around inline text.
impl Run {
pub(super) fn push(&mut self, opened: Opened) {
*self.named.entry(opened.name.clone()).or_default() += 1;
self.open.push(opened);
}
/// The innermost open link's address.
pub(super) fn link(&self) -> Option<String> {
self.open.iter().rev().find_map(|opened| opened.url.clone())
}
/// Opens or closes an element for an inline HTML tag.
pub(super) fn html(&mut self, written: &str) {
match html::inline_tag(written) {
InlineTag::Text(shown) => self.push_text(&shown),
InlineTag::Open { name, style, href } => self.start(&name, style, href),
InlineTag::Close(name) => {
self.shortcodes.split();
self.end(&name);
}
InlineTag::Break => {
self.shortcodes.split();
self.text.text.push('\n');
}
InlineTag::Gone => self.shortcodes.split(),
}
}
/// Opens `name`; a new `a` ends the open one.
pub(super) fn start(&mut self, name: &str, style: Option<Style>, url: Option<String>) {
if name == "a" {
self.end("a");
}
self.shortcodes.split();
self.push(Opened {
name: name.to_string(),
start: self.text.text.len(),
style,
url,
});
}
/// Ends the innermost `name`, as HTML5 repairs nesting.
pub(super) fn end(&mut self, name: &str) {
if self.count(name) == 0 {
return;
}
self.shortcodes.split();
let at = self
.open
.iter()
.rposition(|opened| opened.name == name)
.expect("a counted name is open");
let end = self.text.text.len();
if let Some((_, from)) = &mut self.owed {
*from = (*from).min(at);
}
let closed = self.open.split_off(at);
for opened in &closed {
close(&mut self.text, opened, end);
*self.named.get_mut(&opened.name).expect("counted") -= 1;
}
for opened in closed.into_iter().skip(1) {
if html::reopens(&opened.name) {
self.push(Opened {
start: end,
..opened
});
}
}
}
}
/// Records `opened` as styled up to `end`.
pub(super) fn close(text: &mut Inline, opened: &Opened, end: usize) {
if opened.start >= end {
return;
}
if let Some(style) = opened.style {
text.styles.push((opened.start..end, style));
}
if let Some(url) = &opened.url {
text.links.push((opened.start..end, url.clone()));
}
}
+12 -6
View File
@@ -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: 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:?}");