documents: a press opens its block on the pressed character; a secondary press points
Each drawn text piece keeps its written span, so a press maps back to a source byte. A secondary press opens nothing: it moves the caret there and emits DocumentEvent::Pointed with the block's kind, link, picture and whether it lies in a table or code. Block nodes count from their own start. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
12512217bd
commit
5af11fe489
17 files changed
+813
-82
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),
|
||||
@@ -27,7 +36,7 @@ impl MarkdownDocument {
|
||||
pub(super) fn focused(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let empty = self.editor.read(cx).text().trim().is_empty() && self.read_now(cx);
|
||||
if self.mode == MarkdownMode::Visual && self.open.is_none() && empty {
|
||||
self.open(None, false, window, cx);
|
||||
self.open(None, Landing::End, window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +56,7 @@ impl MarkdownDocument {
|
||||
pub(super) fn open(
|
||||
&mut self,
|
||||
ix: Option<usize>,
|
||||
at_start: bool,
|
||||
landing: Landing,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
@@ -78,6 +87,17 @@ impl MarkdownDocument {
|
||||
(block.range.clone(), false)
|
||||
}
|
||||
};
|
||||
let caret = match landing {
|
||||
Landing::Start => Some(0),
|
||||
Landing::End => None,
|
||||
Landing::At(at) => match moved(at, &written) {
|
||||
Some(at) if range.contains(&at) || at == range.end => Some(at - range.start),
|
||||
_ => {
|
||||
log::warn!("markdown document: the pressed byte left its block");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
let source = text[range.clone()].to_string();
|
||||
let input = cx.new(|cx| {
|
||||
TextInput::new(window, cx)
|
||||
@@ -86,8 +106,8 @@ impl MarkdownDocument {
|
||||
});
|
||||
input.update(cx, |input, cx| {
|
||||
input.set_text(source, cx);
|
||||
if at_start {
|
||||
input.select(0..0, cx);
|
||||
if let Some(caret) = caret {
|
||||
input.select(caret..caret, cx);
|
||||
}
|
||||
});
|
||||
window.focus(&input.focus_handle(cx), cx);
|
||||
@@ -166,7 +186,8 @@ impl MarkdownDocument {
|
||||
let Some(next) = next else {
|
||||
return false;
|
||||
};
|
||||
self.open(Some(next), down, window, cx);
|
||||
let landing = if down { Landing::Start } else { Landing::End };
|
||||
self.open(Some(next), landing, window, cx);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -149,6 +154,31 @@ mod tests {
|
||||
assert_eq!(alike(&old, &shorter), (1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_and_code_count_from_their_block() {
|
||||
let old = parsed(
|
||||
"a
|
||||
|
||||

|
||||
|
||||
```
|
||||
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,136 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{Context, MouseButton, Window};
|
||||
use markdown::mdast;
|
||||
|
||||
use super::{DocumentEvent, MarkdownDocument, edit::Landing, edit::moved};
|
||||
use crate::documents::{
|
||||
render::{OnPoint, Place, Pointed},
|
||||
tree::{alert, is_toc},
|
||||
};
|
||||
|
||||
/// A top-level block's kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TargetBlock {
|
||||
Paragraph,
|
||||
Heading(u8),
|
||||
Quote,
|
||||
Alert,
|
||||
List,
|
||||
Code,
|
||||
Math,
|
||||
Table,
|
||||
Rule,
|
||||
Html,
|
||||
Footnote,
|
||||
Definition,
|
||||
FrontMatter,
|
||||
Toc,
|
||||
}
|
||||
|
||||
impl TargetBlock {
|
||||
/// `node`'s kind, read from `source`.
|
||||
pub(super) fn of(node: &mdast::Node, source: &str) -> Self {
|
||||
match node {
|
||||
mdast::Node::Paragraph(_) if is_toc(node, source) => Self::Toc,
|
||||
mdast::Node::Paragraph(_) => Self::Paragraph,
|
||||
mdast::Node::Heading(heading) => Self::Heading(heading.depth),
|
||||
mdast::Node::Blockquote(quote) if alert(quote).is_some() => Self::Alert,
|
||||
mdast::Node::Blockquote(_) => Self::Quote,
|
||||
mdast::Node::List(_) => Self::List,
|
||||
mdast::Node::Code(_) => Self::Code,
|
||||
mdast::Node::Math(_) => Self::Math,
|
||||
mdast::Node::Table(_) => Self::Table,
|
||||
mdast::Node::ThematicBreak(_) => Self::Rule,
|
||||
mdast::Node::Html(_) => Self::Html,
|
||||
mdast::Node::FootnoteDefinition(_) => Self::Footnote,
|
||||
mdast::Node::Definition(_) => Self::Definition,
|
||||
mdast::Node::Yaml(_) | mdast::Node::Toml(_) => Self::FrontMatter,
|
||||
other => unreachable!("{other:?} is no block outside MDX"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a secondary press landed on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DocumentTarget {
|
||||
/// The source byte the caret went to.
|
||||
pub at: usize,
|
||||
/// None past the last block.
|
||||
pub block: Option<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),
|
||||
Place::At(at) => Some(*at),
|
||||
Place::Block => None,
|
||||
}
|
||||
.map(|at| start + at);
|
||||
let ix = Some(ix);
|
||||
document
|
||||
.update(cx, |document, cx| match button {
|
||||
MouseButton::Left => {
|
||||
document.open(ix, at.map_or(Landing::End, Landing::At), window, cx)
|
||||
}
|
||||
MouseButton::Right => document.point(ix, at, pointed, window, cx),
|
||||
_ => {}
|
||||
})
|
||||
.expect("a block's drawing lives in its document");
|
||||
})
|
||||
}
|
||||
|
||||
/// Caret to `at`, else block `ix`; the owner hears.
|
||||
pub(super) fn point(
|
||||
&mut self,
|
||||
ix: Option<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 at = at.or(block.map(|block| block.range.start));
|
||||
let written = self.commit(cx);
|
||||
if written.is_some() {
|
||||
self.reparse(true, cx);
|
||||
}
|
||||
if !self.read_now(cx) {
|
||||
return log::warn!("markdown document: pressed while the text is read again");
|
||||
}
|
||||
let at = match at {
|
||||
None => self.editor.read(cx).text().len(),
|
||||
Some(at) => match moved(at, &written) {
|
||||
Some(at) => at,
|
||||
None => return log::warn!("markdown document: the pressed text was written over"),
|
||||
},
|
||||
};
|
||||
self.editor
|
||||
.update(cx, |editor, cx| editor.select(std::iter::once(at..at), cx));
|
||||
window.focus(&self.focus, cx);
|
||||
log::info!("markdown document: a secondary press puts the caret at byte {at}");
|
||||
cx.emit(DocumentEvent::Pointed(DocumentTarget {
|
||||
at,
|
||||
block: kind,
|
||||
link: pointed.link,
|
||||
image: pointed.image,
|
||||
in_table: pointed.table || kind == Some(TargetBlock::Table),
|
||||
in_code: pointed.code || kind == Some(TargetBlock::Code),
|
||||
}));
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -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,7 @@ mod history;
|
||||
mod links;
|
||||
mod look;
|
||||
mod place;
|
||||
mod point;
|
||||
mod toc;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
//! A press lands on the pressed character (R4.5).
|
||||
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
|
||||
use gpui::{Entity, Modifiers, MouseButton, TestAppContext, VisualTestContext, point, px};
|
||||
|
||||
use super::{open, type_in};
|
||||
use crate::{
|
||||
documents::{DocumentEvent, DocumentTarget, MarkdownDocument, MarkdownMode, TargetBlock},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
};
|
||||
|
||||
/// Presses block `ix` on its first line's character `k`.
|
||||
fn press_on(ix: usize, k: usize, button: MouseButton, cx: &mut VisualTestContext) {
|
||||
let bounds = cx
|
||||
.debug_bounds(Box::leak(format!("markdown-block-{ix}").into_boxed_str()))
|
||||
.expect("the block is drawn");
|
||||
// The test text system sets glyphs 0.6 em apart.
|
||||
let size = cx.update(|window, cx| {
|
||||
cx.theme()
|
||||
.text_size(TextSize::Base)
|
||||
.to_pixels(window.rem_size())
|
||||
});
|
||||
let em = size * 0.6;
|
||||
let x = bounds.left() + px(13.0) + em * (k as f32 + 0.25);
|
||||
let at = point(x, bounds.top() + px(10.0));
|
||||
cx.simulate_mouse_down(at, button, Modifiers::none());
|
||||
cx.simulate_mouse_up(at, button, Modifiers::none());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn heard(
|
||||
document: &Entity<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)
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
@@ -4,9 +4,12 @@ use gpui::{
|
||||
StatefulInteractiveElement, Styled, Window, actions, div, list, prelude::FluentBuilder as _,
|
||||
};
|
||||
|
||||
use super::MarkdownDocument;
|
||||
use super::{MarkdownDocument, edit::Landing};
|
||||
use crate::{
|
||||
documents::{MarkdownMode, render::draw_nodes},
|
||||
documents::{
|
||||
MarkdownMode,
|
||||
render::{Hooks, Pointed, draw_nodes},
|
||||
},
|
||||
forms::{self, Down, Up},
|
||||
primitives::FocusRing as _,
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
@@ -55,6 +58,9 @@ impl MarkdownDocument {
|
||||
|
||||
/// A block, or the end.
|
||||
fn block_item(&mut self, ix: usize, _: &mut Window, cx: &mut Context<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 +70,6 @@ impl MarkdownDocument {
|
||||
);
|
||||
// Focus fades all but the reader's block.
|
||||
let faded = self.look.focus && editing_mode(self.mode) && ix != self.current_block(cx);
|
||||
let editing = self.mode == MarkdownMode::Visual;
|
||||
let open = self.open.as_ref();
|
||||
let field = |open: &super::Open| {
|
||||
div()
|
||||
@@ -99,9 +104,12 @@ impl MarkdownDocument {
|
||||
.children(draw_nodes(
|
||||
&("render", ix).into(),
|
||||
nodes,
|
||||
&self.base,
|
||||
&self.on_link,
|
||||
&self.on_heading,
|
||||
Hooks {
|
||||
base: &self.base,
|
||||
on_link: &self.on_link,
|
||||
on_heading: &self.on_heading,
|
||||
on_point: &on_point,
|
||||
},
|
||||
self.look.scale,
|
||||
cx,
|
||||
)),
|
||||
@@ -125,7 +133,7 @@ impl MarkdownDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// `element` opens block `ix` on a press, Enter or Space.
|
||||
/// `element` opens block `ix`; a right press points.
|
||||
fn pressable(
|
||||
&self,
|
||||
element: gpui::Stateful<gpui::Div>,
|
||||
@@ -149,13 +157,21 @@ impl MarkdownDocument {
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.open(ix, false, window, cx);
|
||||
document.open(ix, Landing::End, window, cx);
|
||||
}),
|
||||
)
|
||||
.on_mouse_down(
|
||||
MouseButton::Right,
|
||||
cx.listener(move |document, _: &MouseDownEvent, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
document.point(ix, None, Pointed::block(), window, cx);
|
||||
}),
|
||||
)
|
||||
.on_click(
|
||||
cx.listener(move |document, event: &ClickEvent, window, cx| {
|
||||
if event.is_keyboard() {
|
||||
document.open(ix, false, window, cx);
|
||||
document.open(ix, Landing::End, window, cx);
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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};
|
||||
|
||||
+77
-40
@@ -22,6 +22,9 @@ use crate::{
|
||||
};
|
||||
|
||||
mod blocks;
|
||||
mod point;
|
||||
|
||||
pub(crate) use point::{OnPoint, Place, Pointed};
|
||||
|
||||
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, None);
|
||||
}
|
||||
if let Some(draw) = self.code {
|
||||
return draw(
|
||||
@@ -251,7 +279,9 @@ impl Draw<'_> {
|
||||
.map_or_else(Vec::new, |syntax| code_colors_with(text, syntax, cx)),
|
||||
None => code_colors(text, cx),
|
||||
};
|
||||
div()
|
||||
let code = StyledText::new(text.clone()).with_highlights(styles);
|
||||
let layout = code.layout().clone();
|
||||
let block = div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
@@ -267,8 +297,9 @@ impl Draw<'_> {
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(name)
|
||||
}))
|
||||
.child(StyledText::new(text.clone()).with_highlights(styles))
|
||||
.into_any_element()
|
||||
.child(code)
|
||||
.into_any_element();
|
||||
self.point_code(block, Some(layout), *from)
|
||||
}
|
||||
Node::List { start, items } => self.list(start, items, cx),
|
||||
Node::Table { aligns, head, rows } => {
|
||||
@@ -340,23 +371,28 @@ impl Draw<'_> {
|
||||
Err(error) => failed(&error, tex, cx),
|
||||
})
|
||||
.into_any_element(),
|
||||
Node::Image { alt, url, link } => {
|
||||
Node::Image { alt, url, link, at } => {
|
||||
let picture = div().id(self.key("image")).child(Picture {
|
||||
id: self.key("picture"),
|
||||
alt: alt.clone(),
|
||||
url: url.clone(),
|
||||
base: self.base.clone(),
|
||||
base: self.hooks.base.clone(),
|
||||
});
|
||||
let Some(link) = link.clone() else {
|
||||
return picture.into_any_element();
|
||||
let drawn = match link.clone() {
|
||||
None => picture.into_any_element(),
|
||||
Some(address) => {
|
||||
let on_link = self.hooks.on_link.clone();
|
||||
picture
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.on_click(move |_, window, cx| {
|
||||
open_link(&on_link, &address, window, cx)
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
};
|
||||
let on_link = self.on_link.clone();
|
||||
picture
|
||||
.tab_index(0)
|
||||
.focus_ring(cx)
|
||||
.cursor_pointer()
|
||||
.on_click(move |_, window, cx| open_link(&on_link, &link, window, cx))
|
||||
.into_any_element()
|
||||
self.point_image(drawn, *at, url, link)
|
||||
}
|
||||
Node::Footnote { label, body } => div()
|
||||
.flex()
|
||||
@@ -389,27 +425,24 @@ fn open_link(on_link: &Option<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()
|
||||
|
||||
@@ -143,7 +143,7 @@ impl Draw<'_> {
|
||||
4 => entry.pl_16(),
|
||||
_ => entry.pl_20(),
|
||||
};
|
||||
let Some(reveal) = self.on_heading.clone() else {
|
||||
let Some(reveal) = self.hooks.on_heading.clone() else {
|
||||
return entry.text_color(colors.fg_muted).into_any_element();
|
||||
};
|
||||
let (at, pressed) = (heading.at, reveal.clone());
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, InteractiveElement as _, IntoElement as _, MouseButton, MouseDownEvent,
|
||||
ParentElement as _, TextLayout, Window, div,
|
||||
};
|
||||
|
||||
use super::Draw;
|
||||
use crate::documents::tree::Inline;
|
||||
|
||||
/// Where a press fell, as the drawing knows it.
|
||||
pub(crate) enum Place {
|
||||
/// A drawn byte, read back through its text's sources.
|
||||
Text(Rc<Inline>, usize),
|
||||
/// A source byte.
|
||||
At(usize),
|
||||
/// Somewhere the block holds no byte for.
|
||||
Block,
|
||||
}
|
||||
|
||||
/// A press on drawn content.
|
||||
pub(crate) struct Pointed {
|
||||
pub place: Place,
|
||||
pub link: Option<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)>;
|
||||
|
||||
impl Draw<'_> {
|
||||
/// `element` hands its left and right presses to the owner.
|
||||
fn pointable(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
point: impl Fn(&MouseDownEvent) -> Pointed + 'static,
|
||||
) -> AnyElement {
|
||||
let Some(on_point) = self.hooks.on_point.clone() else {
|
||||
return element;
|
||||
};
|
||||
let (point, table) = (Rc::new(point), self.table.get());
|
||||
let press = move |button: MouseButton| {
|
||||
let (on_point, point) = (on_point.clone(), point.clone());
|
||||
move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| {
|
||||
// Cmd or Ctrl leaves the press to a link.
|
||||
if button == MouseButton::Left && event.modifiers.secondary() {
|
||||
return;
|
||||
}
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
let mut pointed = point(event);
|
||||
pointed.table |= table;
|
||||
on_point(pointed, button, window, cx);
|
||||
}
|
||||
};
|
||||
div()
|
||||
.on_mouse_down(MouseButton::Left, press(MouseButton::Left))
|
||||
.on_mouse_down(MouseButton::Right, press(MouseButton::Right))
|
||||
.child(element)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Drawn `inline`; a press finds its byte through `layout`.
|
||||
pub(super) fn point_text(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
layout: TextLayout,
|
||||
inline: &Inline,
|
||||
) -> AnyElement {
|
||||
if self.hooks.on_point.is_none() {
|
||||
return element;
|
||||
}
|
||||
let inline = Rc::new(inline.clone());
|
||||
self.pointable(element, move |event| {
|
||||
let at = layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near);
|
||||
let link = inline.links.iter().find(|(range, _)| range.contains(&at));
|
||||
Pointed {
|
||||
link: link.map(|(_, url)| url.clone()),
|
||||
..Pointed::at(Place::Text(inline.clone(), at))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A code block; its text starts at source byte `from`.
|
||||
pub(super) fn point_code(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
layout: Option<TextLayout>,
|
||||
from: Option<usize>,
|
||||
) -> AnyElement {
|
||||
self.pointable(element, move |event| {
|
||||
let place = match (&layout, from) {
|
||||
(Some(layout), Some(from)) => Place::At(
|
||||
from + layout
|
||||
.index_for_position(event.position)
|
||||
.unwrap_or_else(|near| near),
|
||||
),
|
||||
_ => Place::Block,
|
||||
};
|
||||
Pointed {
|
||||
code: true,
|
||||
..Pointed::at(place)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// A picture written at source byte `at`.
|
||||
pub(super) fn point_image(
|
||||
&self,
|
||||
element: AnyElement,
|
||||
at: usize,
|
||||
url: &str,
|
||||
link: &Option<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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::markdown::{span, tree, walk};
|
||||
mod blocks;
|
||||
mod emoji;
|
||||
mod html;
|
||||
mod sources;
|
||||
|
||||
use blocks::html_block_text;
|
||||
pub use blocks::{AlertKind, DocumentHeading, alert, is_toc};
|
||||
@@ -33,6 +34,8 @@ pub struct Inline {
|
||||
pub text: String,
|
||||
pub styles: Vec<(Range<usize>, Style)>,
|
||||
pub links: Vec<(Range<usize>, String)>,
|
||||
/// Where each piece was written: drawn start, source span.
|
||||
pub sources: Vec<(usize, Range<usize>)>,
|
||||
}
|
||||
|
||||
/// A block of a markdown document.
|
||||
@@ -44,6 +47,8 @@ pub enum Node {
|
||||
Code {
|
||||
language: Option<String>,
|
||||
text: String,
|
||||
/// Where `text` starts in the source, if written as drawn.
|
||||
from: Option<usize>,
|
||||
},
|
||||
/// Items and blocks; ordered from `start`; tasks carry checkboxes.
|
||||
List {
|
||||
@@ -63,6 +68,8 @@ pub enum Node {
|
||||
alt: String,
|
||||
url: String,
|
||||
link: Option<String>,
|
||||
/// Its source start.
|
||||
at: usize,
|
||||
},
|
||||
Footnote {
|
||||
label: String,
|
||||
@@ -315,6 +322,7 @@ impl<'a> Reader<'a> {
|
||||
mdast::Node::Code(code) => out.push(Node::Code {
|
||||
language: code.lang.clone(),
|
||||
text: code.value.clone(),
|
||||
from: sources::code_from(self.source, node, &code.value),
|
||||
}),
|
||||
mdast::Node::Math(math) => out.push(Node::Math(math.value.clone())),
|
||||
mdast::Node::List(list) => {
|
||||
@@ -386,7 +394,10 @@ impl<'a> Reader<'a> {
|
||||
pub fn label(&self, note: &mdast::FootnoteDefinition) -> String {
|
||||
match self.note(¬e.identifier) {
|
||||
Some(number) => number.to_string(),
|
||||
None => note.label.clone().unwrap_or_else(|| note.identifier.clone()),
|
||||
None => note
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| note.identifier.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,6 +415,9 @@ impl<'a> Reader<'a> {
|
||||
if !matches!(node, mdast::Node::Text(_) | mdast::Node::Html(_)) {
|
||||
run.shortcodes.split();
|
||||
}
|
||||
if let Some(written) = sources::written(node, self.source) {
|
||||
run.text.sources.push((run.text.text.len(), written));
|
||||
}
|
||||
let text = &mut run.text;
|
||||
let (children, (name, style), url) = match node {
|
||||
mdast::Node::Text(words) => return run.push_text(&words.value),
|
||||
@@ -422,14 +436,14 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
_ => unreachable!("matched as an image"),
|
||||
};
|
||||
let link = run.link();
|
||||
let (link, at) = (run.link(), span(node).start);
|
||||
return match images {
|
||||
Images::Alt => run.text.text.push_str(&alt),
|
||||
Images::Split(out) => {
|
||||
text_block(run.cut(), out);
|
||||
out.push(Node::Image { alt, url, link })
|
||||
out.push(Node::Image { alt, url, link, at })
|
||||
}
|
||||
Images::After(out) => out.push(Node::Image { alt, url, link }),
|
||||
Images::After(out) => out.push(Node::Image { alt, url, link, at }),
|
||||
};
|
||||
}
|
||||
mdast::Node::Html(written) => return run.html(&written.value),
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use super::{Inline, Node as Drawn, span};
|
||||
|
||||
/// A leaf's written span; code and math inside their marks.
|
||||
pub(super) fn written(node: &Node, source: &str) -> Option<Range<usize>> {
|
||||
let whole = match node {
|
||||
Node::Text(_)
|
||||
| Node::Break(_)
|
||||
| Node::Html(_)
|
||||
| Node::FootnoteReference(_)
|
||||
| Node::Image(_)
|
||||
| Node::ImageReference(_)
|
||||
| Node::InlineCode(_)
|
||||
| Node::InlineMath(_) => span(node),
|
||||
_ => return None,
|
||||
};
|
||||
let marks = match node {
|
||||
Node::InlineCode(_) | Node::InlineMath(_) => {
|
||||
let text = &source[whole.clone()];
|
||||
text.len() - text.trim_start_matches(['`', '$']).len()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
Some(whole.start + marks..whole.end)
|
||||
}
|
||||
|
||||
/// Where a fenced block's `text` starts, if written as drawn.
|
||||
pub(super) fn code_from(source: &str, node: &Node, text: &str) -> Option<usize> {
|
||||
let whole = span(node);
|
||||
let written = &source[whole.clone()];
|
||||
let body = written.find('\n')? + 1;
|
||||
written[body..]
|
||||
.starts_with(text)
|
||||
.then_some(whole.start + body)
|
||||
}
|
||||
|
||||
impl Drawn {
|
||||
/// Its source bytes counted from `start`, its block's.
|
||||
pub(crate) fn rebase(&mut self, start: usize) {
|
||||
match self {
|
||||
Drawn::Paragraph(inline) | Drawn::Heading(_, inline) => {
|
||||
for (_, span) in &mut inline.sources {
|
||||
*span = span.start - start..span.end - start;
|
||||
}
|
||||
}
|
||||
Drawn::Quote(body) | Drawn::Alert { body, .. } | Drawn::Footnote { body, .. } => {
|
||||
body.iter_mut().for_each(|node| node.rebase(start))
|
||||
}
|
||||
Drawn::List { items, .. } => items
|
||||
.iter_mut()
|
||||
.flat_map(|(_, body)| body)
|
||||
.for_each(|node| node.rebase(start)),
|
||||
Drawn::Table { head, rows, .. } => head
|
||||
.iter_mut()
|
||||
.chain(rows.iter_mut().flatten())
|
||||
.flatten()
|
||||
.for_each(|node| node.rebase(start)),
|
||||
Drawn::Code { from, .. } => {
|
||||
if let Some(from) = from {
|
||||
*from -= start;
|
||||
}
|
||||
}
|
||||
Drawn::Image { at, .. } => *at -= start,
|
||||
Drawn::Rule | Drawn::Math(_) | Drawn::Toc(_) | Drawn::FrontMatter(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Inline {
|
||||
/// Source byte under drawn byte `at`, if a piece holds it.
|
||||
pub(crate) fn source_at(&self, source: &str, at: usize) -> Option<usize> {
|
||||
let piece = self.sources.partition_point(|(start, _)| *start <= at);
|
||||
let (start, span) = self.sources.get(piece.checked_sub(1)?)?;
|
||||
let end = self
|
||||
.sources
|
||||
.get(piece)
|
||||
.map_or(self.text.len(), |(next, _)| *next);
|
||||
let drawn = self.text.get(*start..end.max(*start))?;
|
||||
let written = source.get(span.clone())?;
|
||||
// Text that does not read back lands at its start.
|
||||
Some(span.start + align(drawn, written, at - start).unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where drawn byte `at` was written, past joins and escapes.
|
||||
fn align(drawn: &str, written: &str, at: usize) -> Option<usize> {
|
||||
let (mut shown, mut source) = (drawn.char_indices().peekable(), written.char_indices());
|
||||
let mut next = source.next();
|
||||
loop {
|
||||
let here = next.map_or(written.len(), |(byte, _)| byte);
|
||||
let Some(&(byte, shown_char)) = shown.peek() else {
|
||||
return Some(here);
|
||||
};
|
||||
if byte >= at {
|
||||
return Some(here);
|
||||
}
|
||||
let (_, written_char) = next?;
|
||||
match (shown_char, written_char) {
|
||||
(a, b) if a == b => {
|
||||
shown.next();
|
||||
}
|
||||
(' ', '\r' | '\n') => {
|
||||
shown.next();
|
||||
next = source.next();
|
||||
while let Some((_, ' ' | '\t' | '\r' | '\n')) = next {
|
||||
next = source.next();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
(_, ' ' | '\t' | '\r' | '\n' | '\\') => {}
|
||||
_ => return None,
|
||||
}
|
||||
next = source.next();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{Reader, tree};
|
||||
|
||||
/// The source byte under drawn text in the first paragraph.
|
||||
fn under(source: &str, drawn: &str) -> usize {
|
||||
let root = tree(source);
|
||||
let reader = Reader::new(source, &root);
|
||||
let paragraph = &root.children().expect("blocks")[0];
|
||||
let inline = reader.inline(paragraph.children().expect("inlines"));
|
||||
let at = inline.text.find(drawn).expect("drawn");
|
||||
inline.source_at(source, at).expect("a piece")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_lands_where_it_was_written() {
|
||||
let source = "Say **bold** and `code` here\nnext line";
|
||||
assert_eq!(under(source, "bold"), source.find("bold").unwrap());
|
||||
assert_eq!(under(source, "code"), source.find("code").unwrap());
|
||||
assert_eq!(under(source, "here"), source.find("here").unwrap());
|
||||
assert_eq!(under(source, "next"), source.find("next").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_escapes_and_indents_read_back() {
|
||||
let source = "a \\*star\\* x\n y";
|
||||
assert_eq!(under(source, "x"), source.find('x').unwrap());
|
||||
assert_eq!(under(source, "y"), source.find('y').unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_does_not_read_back_lands_at_its_start() {
|
||||
let source = "a & b";
|
||||
assert_eq!(under(source, "b"), 0, "the reference ends the reading");
|
||||
let source = "x :smile: y";
|
||||
assert_eq!(under(source, "y"), 0);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ fn blocks_nest_as_written() {
|
||||
nodes[4],
|
||||
Node::Code {
|
||||
language: Some("rust".into()),
|
||||
text: "fn main() {}".into()
|
||||
text: "fn main() {}".into(),
|
||||
from: Some(95)
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes[5], Node::Rule);
|
||||
@@ -39,7 +40,8 @@ fn an_image_inside_an_image_is_the_outer_one() {
|
||||
[Node::Image {
|
||||
alt: "outer ".into(),
|
||||
url: "outer.png".into(),
|
||||
link: None
|
||||
link: None,
|
||||
at: 0
|
||||
}]
|
||||
);
|
||||
}
|
||||
@@ -53,7 +55,8 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
|
||||
Node::Image {
|
||||
alt: "a".into(),
|
||||
url: "a.png".into(),
|
||||
link: None
|
||||
link: None,
|
||||
at: 7
|
||||
}
|
||||
);
|
||||
let Node::Paragraph(after) = &nodes[2] else {
|
||||
@@ -95,7 +98,8 @@ fn definitions_nested_in_quotes_and_lists_resolve() {
|
||||
Node::Image {
|
||||
alt: "pic".into(),
|
||||
url: "a.png".into(),
|
||||
link: None
|
||||
link: None,
|
||||
at: 7
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -116,7 +120,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
|
||||
Node::Image {
|
||||
alt: "x".into(),
|
||||
url: "x.png".into(),
|
||||
link: None
|
||||
link: None,
|
||||
at: 3
|
||||
}
|
||||
);
|
||||
let Node::Paragraph(after) = &nodes[2] else {
|
||||
@@ -131,7 +136,8 @@ fn an_image_inside_styles_and_links_stays_an_image() {
|
||||
Node::Image {
|
||||
alt: "y".into(),
|
||||
url: "y.png".into(),
|
||||
link: Some("https://ely.dev".into())
|
||||
link: Some("https://ely.dev".into()),
|
||||
at: 19
|
||||
}
|
||||
);
|
||||
assert_eq!(nodes.len(), 4, "{nodes:?}");
|
||||
|
||||
Reference in new issue
Block a user