documents: block checks read as drawn; alert, fence and box edges kept
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
892e095025
commit
0558f0c890
7 files changed
+229
-83
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 its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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 listLine 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 its nodes, not its bytes: in release, 128 KiB of one-letter paragraphs takes 1.7 s, 16 KiB 42 ms, while 128 KiB of prose takes 20 ms; a block-heavy text under the cutoff can still hold the UI thread past 100 ms, a known limit). 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 listLine 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.
|
||||
|
||||
@@ -5,18 +5,24 @@ use std::ops::Range;
|
||||
use markdown::mdast::Node;
|
||||
|
||||
use crate::documents::{
|
||||
MarkdownReader, Style,
|
||||
document::format::EditRefusal,
|
||||
markdown::{file_tree, span, walk},
|
||||
};
|
||||
|
||||
const KEPT: u16 = bit(Style::Code) | bit(Style::Math);
|
||||
const CODE: u16 = 1 << 9;
|
||||
const MATH: u16 = 1 << 10;
|
||||
const HTML: u16 = 1 << 11;
|
||||
|
||||
/// Refuses a result that reads as something else.
|
||||
pub(super) fn misread(why: &str) -> EditRefusal {
|
||||
log::warn!("block command: {why}");
|
||||
EditRefusal::Misread
|
||||
}
|
||||
|
||||
/// `text` with sorted `edits` written, parsed.
|
||||
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> Node {
|
||||
/// `text` with sorted `edits` written, and its parse.
|
||||
pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> (String, Node) {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut at = 0;
|
||||
for (range, with) in edits {
|
||||
@@ -25,59 +31,79 @@ pub(super) fn reread(text: &str, edits: &[(Range<usize>, String)]) -> Node {
|
||||
at = range.end;
|
||||
}
|
||||
out.push_str(&text[at..]);
|
||||
file_tree(&out)
|
||||
let root = file_tree(&out);
|
||||
(out, root)
|
||||
}
|
||||
|
||||
/// Each shown character under `node`, with its formats.
|
||||
pub(super) fn styled(node: &Node) -> Vec<(char, u16)> {
|
||||
let mut out = Vec::new();
|
||||
read(node, 0, &mut out);
|
||||
out
|
||||
const fn bit(style: Style) -> u16 {
|
||||
1 << style as u16
|
||||
}
|
||||
|
||||
fn read(node: &Node, formats: u16, out: &mut Vec<(char, u16)>) {
|
||||
let (own, value) = match node {
|
||||
Node::Text(text) => (0, Some(text.value.as_str())),
|
||||
Node::Strong(_) => (1, None),
|
||||
Node::Emphasis(_) => (2, None),
|
||||
Node::Delete(_) => (4, None),
|
||||
Node::Link(_) | Node::LinkReference(_) => (8, None),
|
||||
Node::InlineCode(code) => (16, Some(code.value.as_str())),
|
||||
Node::InlineMath(math) => (32, Some(math.value.as_str())),
|
||||
Node::Html(html) => (64, Some(html.value.as_str())),
|
||||
Node::Image(image) => (128, Some(image.alt.as_str())),
|
||||
Node::ImageReference(image) => (128, Some(image.alt.as_str())),
|
||||
Node::FootnoteReference(note) => (256, Some(note.identifier.as_str())),
|
||||
Node::Code(code) => (512, Some(code.value.as_str())),
|
||||
Node::Math(math) => (1024, Some(math.value.as_str())),
|
||||
_ => (0, None),
|
||||
};
|
||||
let formats = formats | own;
|
||||
match value {
|
||||
Some(value) => out.extend(
|
||||
(value.chars())
|
||||
.filter(|ch| !ch.is_whitespace())
|
||||
.map(|ch| (ch, formats)),
|
||||
),
|
||||
None => {
|
||||
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
read(kid, formats, out);
|
||||
/// A parsed document read as the drawing reads it.
|
||||
pub(super) struct Shown<'a> {
|
||||
root: &'a Node,
|
||||
reader: MarkdownReader<'a>,
|
||||
}
|
||||
|
||||
impl<'a> Shown<'a> {
|
||||
pub(super) fn new(source: &'a str, root: &'a Node) -> Self {
|
||||
let reader = MarkdownReader::new(source, root);
|
||||
Self { root, reader }
|
||||
}
|
||||
|
||||
/// Each shown character, with its formats.
|
||||
pub(super) fn all(&self) -> Vec<(char, u16)> {
|
||||
let mut out = Vec::new();
|
||||
self.read(self.root, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn read(&self, node: &Node, out: &mut Vec<(char, u16)>) {
|
||||
let (value, format) = match node {
|
||||
Node::Paragraph(_) | Node::Heading(_) | Node::TableCell(_) => {
|
||||
return out.extend(self.inline(node));
|
||||
}
|
||||
Node::Code(code) => (code.value.as_str(), CODE),
|
||||
Node::Math(math) => (math.value.as_str(), MATH),
|
||||
Node::Html(html) => (html.value.as_str(), HTML),
|
||||
_ => {
|
||||
for kid in node.children().map(Vec::as_slice).unwrap_or_default() {
|
||||
self.read(kid, out);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
out.extend(value.chars().map(|ch| (ch, format)));
|
||||
}
|
||||
|
||||
/// `node`'s drawn text; white space counts only in code.
|
||||
pub(super) fn inline(&self, node: &Node) -> Vec<(char, u16)> {
|
||||
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
|
||||
let inline = self.reader.inline(kids);
|
||||
let mut formats = vec![0u16; inline.text.len()];
|
||||
for (range, style) in &inline.styles {
|
||||
for format in &mut formats[range.clone()] {
|
||||
*format |= bit(*style);
|
||||
}
|
||||
}
|
||||
(inline.text.char_indices())
|
||||
.map(|(at, ch)| (ch, formats[at]))
|
||||
.filter(|(ch, format)| !ch.is_whitespace() || format & KEPT != 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The level (0 a paragraph) and characters at `at`.
|
||||
pub(super) fn at(&self, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
|
||||
let mut nodes = Vec::new();
|
||||
walk(self.root, &mut nodes);
|
||||
nodes.into_iter().find_map(|node| {
|
||||
let level = match node {
|
||||
Node::Paragraph(_) => 0,
|
||||
Node::Heading(heading) => heading.depth,
|
||||
_ => return None,
|
||||
};
|
||||
let whole = span(node);
|
||||
(whole.start <= at && at <= whole.end).then(|| (level, self.inline(node)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The level (0 a paragraph) and characters at `at`.
|
||||
pub(super) fn block_at(root: &Node, at: usize) -> Option<(u8, Vec<(char, u16)>)> {
|
||||
let mut nodes = Vec::new();
|
||||
walk(root, &mut nodes);
|
||||
nodes.into_iter().find_map(|node| {
|
||||
let level = match node {
|
||||
Node::Paragraph(_) => 0,
|
||||
Node::Heading(heading) => heading.depth,
|
||||
_ => return None,
|
||||
};
|
||||
let whole = span(node);
|
||||
(whole.start <= at && at <= whole.end).then(|| (level, styled(node)))
|
||||
})
|
||||
}
|
||||
@@ -66,9 +66,15 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
};
|
||||
let change = change(edits, caret);
|
||||
// Every shown character stays; the caret's block is `goal`.
|
||||
let after = check::reread(text, &change.0);
|
||||
let kept = check::styled(&after) == check::styled(here.root)
|
||||
&& check::block_at(&after, change.1.start).is_some_and(|(level, _)| level == goal);
|
||||
let (written, after) = check::reread(text, &change.0);
|
||||
let before = check::Shown::new(text, here.root);
|
||||
let now = check::Shown::new(&written, &after);
|
||||
let placed = match now.at(change.1.start) {
|
||||
Some((level, _)) => level == goal,
|
||||
// An empty paragraph has no node.
|
||||
None => goal == 0 && before.inline(node).is_empty(),
|
||||
};
|
||||
let kept = placed && now.all() == before.all();
|
||||
match kept {
|
||||
true => Ok(change),
|
||||
false => Err(check::misread("a heading change would reread otherwise")),
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use markdown::mdast::{List, Node};
|
||||
use markdown::mdast::{Code, List, Node};
|
||||
|
||||
use super::{
|
||||
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
|
||||
opener, prefix, refusal,
|
||||
opener, prefix, refusal, unblocked,
|
||||
};
|
||||
use crate::documents::{
|
||||
document::format::{Change, EditRefusal},
|
||||
@@ -60,7 +60,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
|
||||
&& (here.path_at(caret.end).iter()).any(|node| std::ptr::eq(*node, path[ix - 1]))
|
||||
{
|
||||
let edits = match kind(list) == command {
|
||||
true => unwrap(here, ix - 1, list),
|
||||
true => unwrap(here, ix - 1, list)?,
|
||||
false => {
|
||||
let start = if list.ordered {
|
||||
list.start.unwrap_or(1)
|
||||
@@ -125,7 +125,7 @@ fn continued<'a>(
|
||||
}
|
||||
|
||||
/// Every item's marker off, its lines out one level.
|
||||
fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
|
||||
fn unwrap(here: &Here, ix: usize, list: &List) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
|
||||
let text = here.text;
|
||||
let outer = here.containers(ix);
|
||||
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
|
||||
@@ -142,16 +142,23 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
|
||||
edits.push((line..line, blank.clone()));
|
||||
}
|
||||
let first = item.children().and_then(|kids| kids.first()).map(span);
|
||||
match first {
|
||||
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
|
||||
match &first {
|
||||
Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => {
|
||||
edits.push((start..kid.start, String::new()));
|
||||
}
|
||||
Some(_) => {
|
||||
Some(kid) => {
|
||||
let next = next_line(text, start).expect("an item's text lies below");
|
||||
edits.push((line..next, String::new()));
|
||||
if boxed {
|
||||
edits.push((unbox(text, kid.start), String::new()));
|
||||
}
|
||||
}
|
||||
None => edits.push((start..line_end(text, start), String::new())),
|
||||
}
|
||||
if boxed && let Some(kid) = first {
|
||||
edits.extend(escaped(text, kid.start)?);
|
||||
}
|
||||
for (at, cut, past) in continued(text, item, &outer) {
|
||||
edits.push((at..cut, " ".repeat(past)));
|
||||
}
|
||||
@@ -160,7 +167,26 @@ fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
|
||||
let next = next_line(text, whole.end).expect("a joining line follows");
|
||||
edits.push((next..next, blank.clone()));
|
||||
}
|
||||
edits
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// The task box before the text at `kid`.
|
||||
fn unbox(text: &str, kid: usize) -> Range<usize> {
|
||||
let line = line_start(text, kid);
|
||||
line + text[line..kid].rfind('[').expect("a task's box")..kid
|
||||
}
|
||||
|
||||
/// A backslash where text at `at`, unboxed, opens a block.
|
||||
fn escaped(text: &str, at: usize) -> Result<Option<(Range<usize>, String)>, EditRefusal> {
|
||||
let mark = unblocked(&text[at..line_end(text, at)])?;
|
||||
Ok(mark.map(|mark| (at + mark..at + mark, "\\".to_string())))
|
||||
}
|
||||
|
||||
/// Whether `node`, holding `code`, is fenced in the source.
|
||||
fn fenced(text: &str, node: &Node, code: &Code) -> bool {
|
||||
code.lang.is_some()
|
||||
|| code.value.is_empty()
|
||||
|| text[span(node)].lines().count() > code.value.lines().count()
|
||||
}
|
||||
|
||||
/// Whether the line before `line` holds text inside `outer`.
|
||||
@@ -200,9 +226,8 @@ fn retype(
|
||||
edits.push((at..end, marker(kind, number, bullet)));
|
||||
if !task {
|
||||
let kid = span(first.expect("a boxed item has text")).start;
|
||||
let line = line_start(text, kid);
|
||||
let mark = line + text[line..kid].rfind('[').expect("a task's box");
|
||||
edits.push((mark..kid, String::new()));
|
||||
edits.push((unbox(text, kid), String::new()));
|
||||
edits.extend(escaped(text, kid)?);
|
||||
}
|
||||
} else if task && let Some(kid) = first.filter(|kid| !matches!(kid, Node::Paragraph(_))) {
|
||||
// GFM boxes only a paragraph.
|
||||
@@ -211,13 +236,10 @@ fn retype(
|
||||
edits.push((at..opener(text, item), marker(kind, number, bullet)));
|
||||
} else {
|
||||
// Indented code is written again from its value.
|
||||
let fence = ["```", "~~~"]
|
||||
.iter()
|
||||
.any(|fence| text[body..].starts_with(fence));
|
||||
let pad = match first {
|
||||
Some(Node::Code(code)) if !fence => {
|
||||
Some(kid @ Node::Code(code)) if !fenced(text, kid, code) => {
|
||||
let line = code.value.lines().next().unwrap_or_default();
|
||||
4 + line.len() - line.trim_start().len()
|
||||
4 + line.len() - line.trim_start_matches(' ').len()
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::{
|
||||
use crate::documents::{
|
||||
alert,
|
||||
document::format::{Change, EditRefusal},
|
||||
markdown::span,
|
||||
markdown::{file_tree, span},
|
||||
};
|
||||
|
||||
pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
|
||||
@@ -72,27 +72,27 @@ fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRef
|
||||
if let Node::Blockquote(inner) = quote
|
||||
&& alert(inner).is_some()
|
||||
{
|
||||
let head = text[whole.start + 1..line_end(text, line)].trim();
|
||||
let name = head
|
||||
.strip_prefix("[!")
|
||||
.and_then(|head| head.strip_suffix(']'));
|
||||
if !name
|
||||
.is_some_and(|name| !name.is_empty() && name.chars().all(|ch| ch.is_ascii_alphabetic()))
|
||||
{
|
||||
if !marker_only(&text[whole.start..line_end(text, line)]) {
|
||||
return Err(check::misread("an alert marker shares its line"));
|
||||
}
|
||||
// A body block keeps its indent; text sheds it.
|
||||
let continues = inner
|
||||
.children
|
||||
.first()
|
||||
.is_some_and(|head| span(head).end > line_end(text, line));
|
||||
// The marker goes with blank lines; an outer opener stays.
|
||||
let (mut cut, mut rest) = (whole.end, whole.end);
|
||||
let mut next = next_line(text, line);
|
||||
while let Some(at) = next.filter(|at| *at < whole.end) {
|
||||
let body = match mark(text, at, &outer) {
|
||||
let lead = match mark(text, at, &outer) {
|
||||
Some(mark) => mark.end,
|
||||
None => content(text, at, &outer),
|
||||
};
|
||||
let body =
|
||||
body + text[body..].len() - text[body..].trim_start_matches([' ', '\t']).len();
|
||||
lead + text[lead..].len() - text[lead..].trim_start_matches([' ', '\t']).len();
|
||||
if body < line_end(text, at) {
|
||||
(cut, rest) = (body, next_line(text, at).unwrap_or(text.len()));
|
||||
cut = if continues { body } else { lead };
|
||||
rest = next_line(text, at).unwrap_or(text.len());
|
||||
break;
|
||||
}
|
||||
next = next_line(text, at);
|
||||
@@ -116,6 +116,17 @@ fn unwrap(here: &Here, ix: usize) -> Result<Vec<(Range<usize>, String)>, EditRef
|
||||
Ok(edits)
|
||||
}
|
||||
|
||||
/// Whether `line`, a quote's first, holds only an alert marker.
|
||||
fn marker_only(line: &str) -> bool {
|
||||
let root = file_tree(&format!("{line}\n> x"));
|
||||
let Some(Node::Blockquote(quote)) = root.children().and_then(|kids| kids.first()) else {
|
||||
return false;
|
||||
};
|
||||
matches!(alert(quote), Some((_, body)) if matches!(&body[..],
|
||||
[Node::Paragraph(head)] if matches!(&head.children[..],
|
||||
[Node::Text(text)] if text.value == "x")))
|
||||
}
|
||||
|
||||
/// `line`'s `>` and one space after, inside `outer`.
|
||||
fn mark(text: &str, line: usize, outer: &[&Node]) -> Option<Range<usize>> {
|
||||
let at = inside(text, line, outer)?;
|
||||
|
||||
@@ -195,13 +195,14 @@ pub(super) fn split(
|
||||
}
|
||||
let change = placed(edits, from + shift + caret);
|
||||
let edits = &change.0;
|
||||
let after_root = check::reread(text, edits);
|
||||
let (written, after_root) = check::reread(text, edits);
|
||||
let now = check::Shown::new(&written, &after_root);
|
||||
let level = match node {
|
||||
Node::Heading(heading) => heading.depth,
|
||||
_ => 0,
|
||||
};
|
||||
let piece = |at: usize| {
|
||||
check::block_at(&after_root, shifted(at, edits))
|
||||
now.at(shifted(at, edits))
|
||||
.and_then(|(got, chars)| (got == level).then_some(chars))
|
||||
};
|
||||
let head = if before {
|
||||
@@ -210,7 +211,7 @@ pub(super) fn split(
|
||||
Some(Vec::new())
|
||||
};
|
||||
let tail = if follows { piece(to) } else { Some(Vec::new()) };
|
||||
let want = check::styled(node);
|
||||
let want = check::Shown::new(text, here.root).inline(node);
|
||||
let kept = head
|
||||
.zip(tail)
|
||||
.is_some_and(|(head, tail)| match here.selection.is_empty() {
|
||||
|
||||
@@ -141,3 +141,83 @@ fn delete_keeps_neighbors_apart_past_gone_openers() {
|
||||
assert_eq!(out, "p\n\nq");
|
||||
assert_eq!(kinds(&out), ["p", "p"]);
|
||||
}
|
||||
|
||||
fn inline_codes(text: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
nodes(text, &mut |node| {
|
||||
if let Node::InlineCode(code) = node {
|
||||
out.push(code.value.clone());
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heading_join_keeps_white_space_in_code() {
|
||||
let source = "a `x\n y`‸\n===";
|
||||
match ran(source, BlockCommand::Heading(3)) {
|
||||
Err(EditRefusal::Misread) => {}
|
||||
Ok(out) => assert_eq!(inline_codes(&plain(&out)), inline_codes(&plain(source))),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_that_draws_otherwise_refuses() {
|
||||
for (source, command) in [
|
||||
("<u>ab‸cd</u>", BlockCommand::Rule),
|
||||
("<u>a‸\nb</u>", BlockCommand::Heading(1)),
|
||||
("x:smile: ‸:smile:", BlockCommand::Rule),
|
||||
] {
|
||||
assert_eq!(ran(source, command), Err(EditRefusal::Misread), "{source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_body_block_keeps_its_indent() {
|
||||
let out = plain(&ok("> [!NOTE]\n>\n> co‸de", BlockCommand::Quote));
|
||||
assert_eq!(
|
||||
(kinds(&out), codes(&out)),
|
||||
(vec!["code"], vec!["code".to_string()])
|
||||
);
|
||||
let out = plain(&ok(
|
||||
"> [!NOTE]\n>\n> ```\n> co‸de\n> ```",
|
||||
BlockCommand::Quote,
|
||||
));
|
||||
assert_eq!(
|
||||
(kinds(&out), codes(&out)),
|
||||
(vec!["code"], vec!["code".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyping_reads_a_fence_from_its_source() {
|
||||
let out = plain(&ok("- ```lang‸", BlockCommand::OrderedList));
|
||||
assert_eq!(codes(&out), vec!["```lang".to_string()]);
|
||||
let out = plain(&ok("- \u{a0}code‸", BlockCommand::OrderedList));
|
||||
assert_eq!(codes(&out), vec!["\u{a0}code".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_box_leaves_text() {
|
||||
assert_eq!(
|
||||
plain(&ok("- [x] ---‸", BlockCommand::BulletList)),
|
||||
"- \\---"
|
||||
);
|
||||
assert_eq!(
|
||||
plain(&ok("- [x] # h‸", BlockCommand::BulletList)),
|
||||
"- \\# h"
|
||||
);
|
||||
assert_eq!(plain(&ok("-\n [x] a‸", BlockCommand::TaskList)), "a");
|
||||
assert_eq!(plain(&ok("- [x] # h‸", BlockCommand::TaskList)), "\\# h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_heading_becomes_an_empty_paragraph() {
|
||||
assert!(ran("# ‸", BlockCommand::Paragraph).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_escaped_alert_marker_on_its_own_line_unwraps() {
|
||||
assert_eq!(plain(&ok("> \\[!NOTE]\n> a‸", BlockCommand::Quote)), "a");
|
||||
}
|
||||
Reference in new issue
Block a user