Compare commits
4
Commits
12512217bd
...
c35fdac2e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c35fdac2e3
|
||
|
|
d4c614cb7f
|
||
|
|
83d747d87e
|
||
|
|
5af11fe489
|
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
```
|
||||
x
|
||||
```
|
||||
",
|
||||
);
|
||||
let new = parsed(
|
||||
"a and more
|
||||
|
||||

|
||||
|
||||
```
|
||||
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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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\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)]);
|
||||
}
|
||||
@@ -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 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 € 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 </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=&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)]);
|
||||
}
|
||||
@@ -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] 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 & 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 beta");
|
||||
assert_eq!(quote.blocks[0].kind, TargetBlock::Quote);
|
||||
let alert = parsed("> [!NOTE] 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 y", "b"),
|
||||
("ab y", "b"),
|
||||
("ab 
 y", "b"),
|
||||
("<div>ab y</div>", "b"),
|
||||
("<div>ab 	 y</div>", "b"),
|
||||
] {
|
||||
assert_eq!(read_back(source, shown), source.find(shown), "{source:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_references_match_the_parser() {
|
||||
for source in ["ab € y", "ab  y", "ab  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 </div>",
|
||||
"<div>ab <b>word</b></div>",
|
||||
"<div>ab   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=&b=2>";
|
||||
assert_eq!(read_back(source, "xample"), source.find("xample"));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 @ ..] = "e.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: & <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 <b></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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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: & <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 <b></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
@@ -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(¬e.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(¬e.identifier) {
|
||||
Some(number) => number.to_string(),
|
||||
None => note.label.clone().unwrap_or_else(|| note.identifier.clone()),
|
||||
None => note
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| note.identifier.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +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),
|
||||
|
||||
@@ -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 & 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");
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
|
||||
Reference in new issue
Block a user