documents: block commands keep text, tasks and list items whole

Round-1 fixes for R4.7: a caret in heading marks splits at the text; a
split closes and reopens bold, italic and strike, refusing inside links,
code, math and HTML; a task's first text takes no block; retype keeps
task boxes and code indent and takes siblings past the list; an alert
in an item keeps the item's opener; paragraph-before moves a holder's
first text below its opener; delete keeps neighbors from joining; a
heading's text keeps closing hashes and block openers escaped; a heading
level is idempotent, as Typora's menu calls it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-14 14:08:40 -04:00
1 parent 58e212d4ec
commit b37031b00b
9 files changed
+438 -153

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Web views: `wry` 0.57 (Apache-2.0 OR MIT, default features off, macOS only) lays a child WKWebView over the window.
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off, native only) for the grid, its parser and the pseudo-terminal; `futures` carries its events. `vte` 0.15 (Apache-2.0 OR MIT), the parser it re-exports, reads `AnsiText`'s codes alone, so colored output builds for wasm32.
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
- Markdown: `markdown` 1.0.0 (markdown-rs, MIT) reads GitHub's flavor and math into a syntax tree with byte positions for `MarkdownRenderer`, `MarkdownEditor`, the highlights, block ranges and outline. `MarkdownRenderer` draws display math through `typography::Latex`, a ```` ```mermaid ```` block through `documents::Diagram`, and pictures from the owner's folder (`base`) or `data:` addresses (`base64` 0.22, MIT OR Apache-2.0); web pictures stay unloaded. Inline math shows as TeX: gpui text carries no inline pictures. Inline it reads as Typora's defaults: `:shortcode:` turns to its emoji as GitHub reads it (the `emojis` crate's names, GitHub's today, so `:cricket:` is the insect; read after escapes and references, so `\:smile:` turns too; inside an autolink or `<code>` it stays text; within one DOM text node, which filtered tags do not split, the first `:name:`-shaped run decides for all: blank text or a space before it turns every shortcode, else none, so `x:smile: :smile:` stays), inline HTML's `u`, `b`/`strong`, `i`/`em`, `s`/`del`/`strike`, `code`/`kbd`, `mark`, `br` and `a href` draw as what they mean GFM's filtered tags (`script`, `style`, `title`, `textarea`, `iframe`, `xmp`, `noembed`, `noframes`, `plaintext`) show as text, and other tags, hyphenated names too, vanish, leaving their text; a tag's name runs to white space or `/`, as HTML5 reads it; attribute values split on ASCII whitespace alone and decode as HTML decodes them; each Markdown container is the element GitHub writes (`em`, `strong`, `del`, `a`), on one stack with the tags and repaired as HTML5 repairs: an end tag closes the innermost element of its name and all above it, of which only formatting elements (`a`, `b`, `code`, `em`, `i`, `s`, `strike`, `strong`, `u`) reopen, a new `a` ends the open one and an `a` without `href` links nothing; other tags are not tracked, and `</br>` breaks as `<br>` does; and a footnote reference is its number in superscript, numbered by first reference, as its definition is; one `~` still strikes. At the root, blocks read as GitHub shows them: a quote whose first line is plain text reading `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]` or `[!CAUTION]` in any case, after escapes and references, spaces and tabs around it, with more after it, is an alert under its title (Important in the palette's violet, `syntax.keyword`, read at 4.5:1); a paragraph that is `[toc]` alone, as Typora reads it, lists every heading by depth, and a press on one reveals it as a link press does (`MarkdownDocument`'s `reveal`), the heading found where `CodeEditor` anchors carried it through each edit since the read, gone if an edit or an undo took it, and a secondary press acts on press, since a block writing back moves the list; front matter shows in a box without its fences; an HTML block shows its text with the inline tags above, white space collapsed and owed as a browser lays it, block elements breaking lines and closing what opened inside them, HTML5's start tags ending an open `p`, `fieldset` and `menu` too, and an `li` start an `li`, a `dd` or `dt` start either, as HTML5 ends them, comments dropped, the filtered tags shown as text, and an unclosed tag dropping the rest; a rule is a 2px `border_strong` line; bullets go •, ◦, ▪ by list depth, ordered lists counted. Exports read a tree as the drawing does through `MarkdownReader` (`block` gives its `MarkdownBlock`s, `inline` its text, `note` and `label` its footnotes, `headings` the `[toc]` list), one fresh stack per paragraph, heading or cell. `MarkdownDocument` holds a whole file in one `CodeEditor` (text and undo, colored by `CodeEditor::highlighter` with `markdown_highlights`): visual draws the file's top-level blocks in a gpui `list`, a pressed block opening as its source in a `TextInput` whose typing is the document's text at once and whose write-back is one undo step; source is the editor; preview draws without presses. A written-back block, a mode switch or a new text is read at once up to 128 KiB, longer ones on the background executor; typing in source or split reads 300 ms after the last key, off the main thread (markdown-rs grows with the square of 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.
+8
View File
@@ -4,6 +4,7 @@ mod heading;
mod insert;
mod list;
mod quote;
mod split;
use std::ops::Range;
@@ -189,6 +190,13 @@ pub(super) fn container(node: &Node) -> bool {
)
}
/// Whether `node` is task item `holder`'s first text.
pub(super) fn task_head(holder: &Node, node: &Node) -> bool {
matches!(holder, Node::ListItem(item)
if item.checked.is_some()
&& item.children.first().is_some_and(|kid| std::ptr::eq(kid, node)))
}
/// Whether the path goes on below `node`.
fn holds(node: &Node) -> bool {
container(node) || matches!(node, Node::Root(_) | Node::List(_))
+49 -8
View File
@@ -10,7 +10,7 @@ use super::{
};
use crate::documents::{
document::format::{Change, EditRefusal},
markdown::span,
markdown::{file_tree, span},
};
/// The level `command` asks at `level`; 0 is a paragraph.
@@ -18,7 +18,7 @@ fn target(level: u8, command: BlockCommand) -> u8 {
match command {
BlockCommand::Heading(n) => {
assert!((1..=6).contains(&n), "a heading level is 1 to 6");
if n == level { 0 } else { n }
n
}
BlockCommand::Promote if level == 0 => 6,
BlockCommand::Promote => (level - 1).max(1),
@@ -61,7 +61,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
let edits = match (level, goal) {
(0, _) if task_head(here, &whole) => return Err(EditRefusal::Outside),
(0, _) => to_heading(here, &containers, &whole, hashes),
(_, 0) => to_paragraph(here, node, &containers),
(_, 0) => to_paragraph(here, node, &containers)?,
_ => relevel(here, node, &containers, goal),
};
Ok(change(edits, caret))
@@ -75,8 +75,38 @@ fn atx(text: &str, node: &Node) -> bool {
/// Whether the caret is on a task item's first line.
fn task_head(here: &Here, whole: &Range<usize>) -> bool {
let first = line_start(here.text, whole.start) == line_start(here.text, here.selection.start);
matches!(here.path.iter().rev().nth(1), Some(Node::ListItem(item)) if item.checked.is_some())
&& first
let leaf = here.path[here.path.len() - 1];
first && super::task_head(here.path[here.path.len() - 2], leaf)
}
/// Where a `#` run that would close the heading starts.
fn closing(text: &str, at: usize) -> Option<usize> {
let line = text[at..line_end(text, at)].trim_end_matches([' ', '\t']);
let kept = line.trim_end_matches('#');
(kept.len() < line.len() && kept.ends_with([' ', '\t'])).then(|| at + kept.len())
}
/// The byte to escape if `at`'s line would open a block.
fn opener(text: &str, at: usize) -> Result<Option<usize>, EditRefusal> {
let line = &text[at..line_end(text, at)];
let prose = |line: &str| {
let root = file_tree(line);
matches!(
root.children().and_then(|kids| kids.first()),
Some(Node::Paragraph(_))
)
};
if prose(line) {
return Ok(None);
}
let mark = line.find(|ch: char| ch.is_ascii_punctuation());
match mark.filter(|mark| prose(&format!("{}\\{}", &line[..*mark], &line[*mark..]))) {
Some(mark) => Ok(Some(at + mark)),
None => {
log::warn!("block command: a heading's text stays a block");
Err(EditRefusal::Outside)
}
}
}
/// The caret's line led by `hashes`; lazy lines kept in.
@@ -105,6 +135,9 @@ fn to_heading(
};
if line == first {
edits.push((at..at, format!("{lazy}{hashes}")));
if let Some(run) = closing(text, at) {
edits.push((run..run, "\\".to_string()));
}
} else if !lazy.is_empty() {
edits.push((at..at, lazy.to_string()));
}
@@ -117,7 +150,11 @@ fn to_heading(
}
/// A heading's marks off; a blank line where text joins.
fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<usize>, String)> {
fn to_paragraph(
here: &Here,
node: &Node,
containers: &[&Node],
) -> Result<Vec<(Range<usize>, String)>, EditRefusal> {
let text = here.text;
let whole = span(node);
let blank = format!("{}{}", prefix(text, containers).trim_end(), eol(text));
@@ -130,7 +167,11 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<us
match atx(text, node) {
true => match (kids.first(), kids.last()) {
(Some(first), Some(last)) => {
edits.push((whole.start..span(first).start, String::new()));
let start = span(first).start;
edits.push((whole.start..start, String::new()));
if let Some(mark) = opener(text, start)? {
edits.push((mark..mark, "\\".to_string()));
}
edits.push((span(last).end..whole.end, String::new()));
}
_ => edits.push((whole.clone(), String::new())),
@@ -146,7 +187,7 @@ fn to_paragraph(here: &Here, node: &Node, containers: &[&Node]) -> Vec<(Range<us
let next = next_line(text, whole.end).expect("a joining line follows");
edits.push((next..next, blank.clone()));
}
edits
Ok(edits)
}
/// A heading moved to level `goal`.
+23 -104
View File
@@ -7,51 +7,20 @@ use markdown::mdast::Node;
use super::{
BlockCommand, Here, container, eol, inside, line_end, line_start, next_line, placed, prefix,
prev_line, refusal,
split::{Room, content, split},
task_head,
};
use crate::documents::{
document::format::{Change, EditRefusal},
markdown::span,
};
/// Where `line`'s text starts inside `containers`; a lazy line's own.
fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
let at = inside(text, line, containers).unwrap_or(line);
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
}
/// Whether `line` holds text past `containers`' prefix.
fn text_on(text: &str, line: usize, containers: &[&Node]) -> bool {
let at = content(text, line, containers);
at < line_end(text, line)
}
/// Back from `at` over blanks and prefixes, to `floor`.
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
loop {
let line = line_start(text, at).max(floor);
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
if line == floor || at > content(text, line, containers) {
return at;
}
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
}
}
/// On from `at` over blanks and prefixes, to `ceil`.
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
loop {
let end = line_end(text, at).min(ceil);
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
if at < end {
return at;
}
match next_line(text, at) {
Some(next) if next < ceil => at = content(text, next, containers),
_ => return ceil,
}
}
}
/// The selection's text, line by line, prefixes cut.
fn picked(here: &Here, containers: &[&Node]) -> Vec<String> {
let text = here.text;
@@ -165,21 +134,6 @@ fn splits(text: &str, node: &Node) -> bool {
}
}
/// Where the new block's text goes, ending the edit.
struct Room<'a> {
nl: &'static str,
lead: String,
blank: String,
containers: Vec<&'a Node>,
}
impl Room<'_> {
/// A blank line, then a new line's prefix.
fn gap(&self) -> String {
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
}
}
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
let text = here.text;
let leaf = here.leaf();
@@ -199,7 +153,7 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
if here.selection.end > span(node).end {
return Err(EditRefusal::Across);
}
Ok(split(here, node, &room, &block, inner))
split(here, node, &room, &block, inner)
}
Some(node) if !here.selection.is_empty() => Err(refusal(node)),
Some(node) => {
@@ -251,60 +205,6 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
}
}
/// `block` put where the caret splits `node`.
fn split(here: &Here, node: &Node, room: &Room, block: &str, inner: Option<usize>) -> Change {
let text = here.text;
let whole = span(node);
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
let (marks, body) = match node {
Node::Heading(heading) => (
"#".repeat(usize::from(heading.depth)) + " ",
kids.first().map_or(whole.end, |kid| span(kid).start)
..kids.last().map_or(whole.end, |kid| span(kid).end),
),
_ => (String::new(), whole.clone()),
};
let containers = &room.containers;
let mut from = back(text, here.selection.start, body.start, containers);
let after = forth(text, here.selection.end, body.end, containers);
let before = from > body.start;
let follows = after < body.end;
if !before {
from = whole.start;
}
let to = if follows { after } else { whole.end };
let gap = room.gap();
let mut written = String::new();
if before {
written.push_str(&gap);
}
let start = written.len();
written.push_str(block);
let caret = match inner {
Some(inner) => start + inner,
None => {
written.push_str(&gap);
written.len() + if follows { marks.len() } else { 0 }
}
};
if follows {
if inner.is_some() {
written.push_str(&gap);
}
written.push_str(&marks);
}
let mut edits = Vec::new();
let mut shift = 0;
if !before && here.joins_before(&whole, None) {
let line = line_start(text, whole.start);
let blank = format!("{}{}", room.blank, room.nl);
shift = blank.len();
edits.push((line..line, blank));
}
edits.push((from..to, written));
placed(edits, from + shift + caret)
}
/// An empty line before or after the caret's block.
pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal> {
let text = here.text;
@@ -319,6 +219,19 @@ pub(super) fn paragraph(here: &Here, before: bool) -> Result<Change, EditRefusal
containers,
};
let gap = room.gap();
let holder = here.path[here.path.len() - 2];
if before && task_head(holder, leaf) {
return Err(EditRefusal::Outside);
}
// A holder's first line keeps its opener: text moves down.
if before
&& container(holder)
&& !matches!(holder, Node::Blockquote(_))
&& line_start(text, span(holder).start) == line_start(text, whole.start)
{
let down = format!("{}{}", room.nl, room.lead);
return Ok(placed(vec![(whole.start..whole.start, down)], whole.start));
}
if before {
let mut edits = Vec::new();
let mut shift = 0;
@@ -387,5 +300,11 @@ pub(super) fn delete(here: &Here) -> Result<Change, EditRefusal> {
if blank_above && end < text.len() && !text_on(text, end, &outer) {
end = next_line(text, end).unwrap_or(text.len());
}
Ok(placed(vec![(line..end, String::new())], line))
// Neighbors that would join keep a blank line between.
let joins = here.joins_before(&whole, None) && here.joins_after(&whole);
let with = match joins {
true => format!("{}{}", prefix(text, &outer).trim_end(), eol(text)),
false => String::new(),
};
Ok(placed(vec![(line..end, with)], line))
}
+43 -23
View File
@@ -6,7 +6,7 @@ use markdown::mdast::{List, Node};
use super::{
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
prefix,
opener, prefix,
};
use crate::documents::{
document::format::{Change, EditRefusal},
@@ -54,12 +54,22 @@ pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefu
}),
None => Some(path.len() - 1).filter(|ix| matches!(path[*ix], Node::ListItem(_))),
};
// A selection past the list takes every touched sibling.
if let Some(ix) = item
&& let Node::List(list) = path[ix - 1]
&& (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),
false => retype(here, ix - 1, list, command),
false => {
let start = if list.ordered {
list.start.unwrap_or(1)
} else {
1
};
let outer = here.containers(ix - 1);
retype(here.text, path[ix - 1], list, &outer, command, start)
}
};
return Ok(change(edits, caret));
}
@@ -160,21 +170,18 @@ fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
})
}
/// Every item's marker as `kind`'s, its lines moved to match.
fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range<usize>, String)> {
let text = here.text;
let outer = here.containers(ix);
let start = if list.ordered {
list.start.unwrap_or(1)
} else {
1
};
/// Every item's marker as `kind`'s from `start`, lines moved to match.
fn retype(
text: &str,
node: &Node,
list: &List,
outer: &[&Node],
kind: BlockCommand,
start: u32,
) -> Vec<(Range<usize>, String)> {
let bullet = match list.ordered {
true => '-',
false => text[span(here.path[ix]).start..]
.chars()
.next()
.unwrap_or('-'),
false => text[span(node).start..].chars().next().unwrap_or('-'),
};
let mut edits = Vec::new();
for (n, item) in list.children.iter().enumerate() {
@@ -183,13 +190,19 @@ fn retype(here: &Here, ix: usize, list: &List, kind: BlockCommand) -> Vec<(Range
let (mark, spaces) = super::marker(text, item);
let first = item.children().and_then(|kids| kids.first()).map(span);
let boxed = matches!(item, Node::ListItem(item) if item.checked.is_some());
let end = match first {
Some(kid) if boxed && !text[at..kid.start].contains(['\n', '\r']) => kid.start,
_ => at + mark + spaces,
let inline = first.filter(|kid| !text[at..kid.start].contains(['\n', '\r']));
// A box stays when the kind keeps boxes.
let (end, written) = match inline {
Some(_) if boxed && kind == BlockCommand::TaskList => {
(at + mark + spaces, format!("{bullet} "))
}
Some(kid) if boxed => (kid.start, marker(kind, number, bullet)),
// Spaces past the marker's own are the text's.
_ => (opener(text, item), marker(kind, number, bullet)),
};
edits.push((at..end, marker(kind, number, bullet)));
edits.push((at..end, written));
let want = column(text, at) + width(kind, number);
for (from, cut, past) in continued(text, item, &outer) {
for (from, cut, past) in continued(text, item, outer) {
let pad = want.saturating_sub(column(text, from)) + past;
edits.push((from..cut, " ".repeat(pad)));
}
@@ -202,7 +215,7 @@ fn wrap(
here: &Here,
k: usize,
blocks: &[&Node],
kind: BlockCommand,
command: BlockCommand,
) -> Vec<(Range<usize>, String)> {
let text = here.text;
let outer = here.containers(k + 1);
@@ -210,6 +223,13 @@ fn wrap(
let mut edits = Vec::new();
let mut n = 1;
for block in blocks {
if let Node::List(list) = block {
if kind(list) != command {
edits.extend(retype(text, block, list, &outer, command, n));
}
n += list.children.len() as u32;
continue;
}
let whole = span(block);
let mut line = line_start(text, whole.start);
while line <= whole.end {
@@ -227,9 +247,9 @@ fn wrap(
let mark = match first || matches!(block, Node::Paragraph(_)) {
true => {
n += 1;
marker(kind, n - 1, '-')
marker(command, n - 1, '-')
}
false => " ".repeat(width(kind, n - 1)),
false => " ".repeat(width(command, n - 1)),
};
let at = if matches!(block, Node::Paragraph(_)) {
content
+30 -13
View File
@@ -4,7 +4,7 @@ use std::ops::Range;
use markdown::mdast::Node;
use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix};
use super::{Here, change, eol, inside, line_end, line_start, next_line, prefix, task_head};
use crate::documents::{
alert,
document::format::{Change, EditRefusal},
@@ -32,6 +32,10 @@ pub(super) fn run(here: &Here) -> Result<Change, EditRefusal> {
return Ok(change(vec![(at..at, "> ".to_string())], caret));
}
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
// GFM reads a task's first line as text.
if task_head(here.path[k], blocks[0]) {
return Err(EditRefusal::Outside);
}
Ok(change(wrap(here, k, &blocks), caret))
}
@@ -65,20 +69,21 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
if let Node::Blockquote(inner) = quote
&& alert(inner).is_some()
{
// Typora drops an alert's marker with its quote.
let next = next_line(text, line).unwrap_or(text.len());
edits.push((line..next, String::new()));
line = next;
// The marker goes; an outer opener stays.
let next = next_line(text, line).filter(|next| *next < whole.end);
let (cut, rest) = match next {
Some(next) => match mark(text, next, &outer) {
Some(mark) => (mark.end, next_line(text, next).unwrap_or(text.len())),
None => (next, next),
},
None => (line_end(text, line), whole.end),
};
edits.push((whole.start..cut, String::new()));
line = rest;
}
while line < whole.end {
if let Some(at) = inside(text, line, &outer) {
let end = line_end(text, line);
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
let mark = at + lead;
if lead <= 3 && text[mark..end].starts_with('>') {
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
edits.push((mark..mark + 1 + space, String::new()));
}
if let Some(mark) = mark(text, line, &outer) {
edits.push((mark, String::new()));
}
match next_line(text, line) {
Some(next) => line = next,
@@ -92,6 +97,18 @@ fn unwrap(here: &Here, ix: usize) -> Vec<(Range<usize>, String)> {
edits
}
/// `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)?;
let end = line_end(text, line);
let lead = text[at..end].len() - text[at..end].trim_start_matches(' ').len();
let mark = at + lead;
(lead <= 3 && text[mark..end].starts_with('>')).then(|| {
let space = usize::from(text[mark + 1..end].starts_with([' ', '\t']));
mark..mark + 1 + space
})
}
/// `> ` before each line of `blocks`, held by `path[k]`.
fn wrap(here: &Here, k: usize, blocks: &[&Node]) -> Vec<(Range<usize>, String)> {
let text = here.text;
+176
View File
@@ -0,0 +1,176 @@
//! A paragraph or heading split at the caret, as Typora's `splitAt`.
use std::ops::Range;
use markdown::mdast::Node;
use super::{Here, inside, line_end, line_start, next_line, placed, prev_line, task_head};
use crate::documents::{
document::format::{Change, EditRefusal, marks},
markdown::span,
};
/// Where the new block's text goes, ending the edit.
pub(super) struct Room<'a> {
pub(super) nl: &'static str,
pub(super) lead: String,
pub(super) blank: String,
pub(super) containers: Vec<&'a Node>,
}
impl Room<'_> {
/// A blank line, then a new line's prefix.
pub(super) fn gap(&self) -> String {
format!("{}{}{}{}", self.nl, self.blank, self.nl, self.lead)
}
}
/// Where `line`'s text starts inside `containers`; a lazy line's own.
pub(super) fn content(text: &str, line: usize, containers: &[&Node]) -> usize {
let at = inside(text, line, containers).unwrap_or(line);
at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len()
}
/// Back from `at` over blanks and prefixes, to `floor`.
fn back(text: &str, mut at: usize, floor: usize, containers: &[&Node]) -> usize {
loop {
let line = line_start(text, at).max(floor);
at = line + text[line..at].trim_end_matches([' ', '\t']).len();
if line == floor || at > content(text, line, containers) {
return at;
}
at = line_end(text, prev_line(text, line).expect("a line above the floor"));
}
}
/// On from `at` over blanks and prefixes, to `ceil`.
fn forth(text: &str, mut at: usize, ceil: usize, containers: &[&Node]) -> usize {
loop {
let end = line_end(text, at).min(ceil);
at = end - text[at..end].trim_start_matches([' ', '\t']).len();
if at < end {
return at;
}
match next_line(text, at) {
Some(next) if next < ceil => at = content(text, next, containers),
_ => return ceil,
}
}
}
/// Inline nodes with no text to cut.
fn sealed(node: &Node) -> bool {
matches!(
node,
Node::InlineMath(_) | Node::Html(_) | Node::FootnoteReference(_)
)
}
/// `at` off inline edges; the marks to close and to reopen.
fn seam(here: &Here, mut at: usize) -> Result<(usize, String, String), EditRefusal> {
let text = here.text;
let marked: Vec<(&Node, [Range<usize>; 2])> = (here.nodes.iter())
.filter_map(|node| Some((*node, marks(node, text)?)))
.collect();
while let Some([open, _]) = (marked.iter())
.map(|(_, holes)| holes)
.find(|[open, _]| open.start < at && open.end == at)
{
at = open.start;
}
while let Some([_, close]) = (marked.iter())
.map(|(_, holes)| holes)
.find(|[_, close]| close.start == at && at < close.end)
{
at = close.end;
}
let strictly = |range: Range<usize>| range.start < at && at < range.end;
let mut cut: Vec<&(&Node, [Range<usize>; 2])> = (marked.iter())
.filter(|(node, _)| strictly(span(node)))
.collect();
let paired =
|node: &Node| matches!(node, Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_));
let unsafe_cut = (here.nodes.iter()).any(|node| sealed(node) && strictly(span(node)))
|| (cut.iter()).any(|(node, [open, close])| {
!paired(node) || strictly(open.clone()) || strictly(close.clone())
});
if unsafe_cut {
return Err(EditRefusal::Across);
}
cut.sort_by_key(|(node, _)| span(node).start);
let close = (cut.iter().rev()).map(|(_, [_, close])| &text[close.clone()]);
let open = cut.iter().map(|(_, [open, _])| &text[open.clone()]);
Ok((at, close.collect(), open.collect()))
}
/// `block` put where the selection splits `node`.
pub(super) fn split(
here: &Here,
node: &Node,
room: &Room,
block: &str,
inner: Option<usize>,
) -> Result<Change, EditRefusal> {
let text = here.text;
let whole = span(node);
let kids = node.children().map(Vec::as_slice).unwrap_or_default();
let (marks, body) = match node {
Node::Heading(heading) => (
"#".repeat(usize::from(heading.depth)) + " ",
kids.first().map_or(whole.end, |kid| span(kid).start)
..kids.last().map_or(whole.end, |kid| span(kid).end),
),
_ => (String::new(), whole.clone()),
};
// A caret in heading marks takes the text's edge.
let s = here.selection.start.clamp(body.start, body.end);
let e = here.selection.end.clamp(body.start, body.end);
let (s, close, _) = seam(here, s)?;
let (e, _, open) = seam(here, e)?;
let containers = &room.containers;
let mut from = back(text, s, body.start, containers);
let after = forth(text, e, body.end, containers);
let before = from > body.start;
let follows = after < body.end;
// GFM reads a task's first line as text.
if !before && task_head(here.path[here.path.len() - 2], node) {
return Err(EditRefusal::Outside);
}
if !before {
from = whole.start;
}
let to = if follows { after } else { whole.end };
let gap = room.gap();
let mut written = String::new();
if before {
written.push_str(&close);
written.push_str(&gap);
}
let start = written.len();
written.push_str(block);
let lead = if follows { marks.len() + open.len() } else { 0 };
let caret = match inner {
Some(inner) => start + inner,
None => {
written.push_str(&gap);
written.len() + lead
}
};
if follows {
if inner.is_some() {
written.push_str(&gap);
}
written.push_str(&marks);
written.push_str(&open);
}
let mut edits = Vec::new();
let mut shift = 0;
if !before && here.joins_before(&whole, None) {
let line = line_start(text, whole.start);
let blank = format!("{}{}", room.blank, room.nl);
shift = blank.len();
edits.push((line..line, blank));
}
edits.push((from..to, written));
Ok(placed(edits, from + shift + caret))
}
+1 -1
View File
@@ -97,7 +97,7 @@ fn block(node: &Node) -> bool {
}
/// A styled node's two marks, kept text between.
fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
pub(super) fn marks(node: &Node, text: &str) -> Option<[Range<usize>; 2]> {
let whole = span(node);
let inner = match node {
Node::Strong(_) | Node::Emphasis(_) | Node::Delete(_) => content(node),
+107 -3
View File
@@ -62,10 +62,10 @@ fn kinds(text: &str) -> Vec<&'static str> {
}
#[test]
fn headings_set_and_toggle_as_typora() {
fn headings_set_as_typoras_menu() {
use BlockCommand::*;
assert_eq!(ok("a‸b", Heading(2)), "## a‸b");
assert_eq!(ok("## a‸b", Heading(2)), "a‸b");
assert_eq!(ok("## a‸b", Heading(2)), "## a‸b");
assert_eq!(ok("## a‸b", Heading(3)), "### a‸b");
assert_eq!(ok("a‸", Promote), "###### a‸");
assert_eq!(ok("# a‸", Promote), "# a‸");
@@ -189,7 +189,12 @@ fn empty_paragraphs_come_before_and_after() {
assert_eq!(ok("# a‸\nb", ParagraphAfter), "# a\n\n‸\n\nb");
assert_eq!(ok("a\n\nb‸", ParagraphBefore), "a\n\n‸\n\nb");
assert_eq!(ok("a\n# h‸", ParagraphBefore), "a\n\n‸\n\n# h");
assert_eq!(ok("- a‸", ParagraphBefore), "- ‸\n\n a");
let out = ok("- a‸", ParagraphBefore);
assert_eq!(out, "- ‸\n a");
assert_eq!(kinds(&out), ["list"]);
let out = ok("x[^1]\n\n[^1]: a‸", ParagraphBefore);
assert_eq!(out, "x[^1]\n\n[^1]: ‸\n a");
assert_eq!(kinds(&out), ["p", "note"]);
}
#[test]
@@ -268,3 +273,102 @@ fn a_command_past_the_open_block_writes_it_back_first(cx: &mut TestAppContext) {
);
document.read_with(cx, |document, _| assert!(document.open.is_none()));
}
/// The one list's items, each as its blocks' kinds.
fn items(text: &str) -> Vec<Vec<&'static str>> {
let text: String = text.chars().filter(|ch| !"‸⟨⟩".contains(*ch)).collect();
let root = file_tree(&text);
let Some(Node::List(list)) = root.children().and_then(|kids| kids.first()) else {
panic!("a list first");
};
(list.children.iter())
.map(|item| {
(item
.children()
.map(Vec::as_slice)
.unwrap_or_default()
.iter())
.map(|kid| match kid {
Node::Paragraph(_) => "p",
Node::Code(_) => "code",
_ => "other",
})
.collect()
})
.collect()
}
#[test]
fn a_caret_in_heading_marks_splits_at_the_text() {
use BlockCommand::Rule;
assert_eq!(ok("‸# alpha", Rule), "---\n\n# ‸alpha");
let out = ok("# alpha #‸##", Rule);
assert_eq!(kinds(&out), ["h", "rule"]);
}
#[test]
fn a_task_list_keeps_checked_boxes() {
assert_eq!(
ok("- a\n- [x] b‸", BlockCommand::TaskList),
"- [ ] a\n- [x] b‸"
);
}
#[test]
fn an_alert_in_an_item_keeps_the_item() {
let out = ok("- > [!NOTE]\n > a‸\n- b", BlockCommand::Quote);
assert_eq!(out, "- a‸\n- b");
assert_eq!(items(&out), [vec!["p"], vec!["p"]]);
}
#[test]
fn a_split_closes_and_reopens_inline_marks() {
use BlockCommand::Rule;
let out = ok("**ab‸cd**", Rule);
assert_eq!(out, "**ab**\n\n---\n\n**‸cd**");
assert_eq!(kinds(&out), ["p", "rule", "p"]);
assert_eq!(ran("[a‸b](u)", Rule), Err(EditRefusal::Across));
assert_eq!(ran("`a‸b`", Rule), Err(EditRefusal::Across));
}
#[test]
fn a_retyped_item_keeps_its_code_indent() {
let out = ok("- code‸", BlockCommand::OrderedList);
assert_eq!(out, "1. code‸");
assert_eq!(items(&out), [vec!["code"]]);
}
#[test]
fn deleting_keeps_neighbors_apart() {
let out = ok("p\n# h‸\nq", BlockCommand::Delete);
assert_eq!(out, "p\n‸\nq");
assert_eq!(kinds(&out), ["p", "p"]);
}
#[test]
fn heading_marks_keep_their_text() {
use BlockCommand::*;
let out = ok("alpha #‸", Heading(1));
assert_eq!(out, "# alpha \\#‸");
assert_eq!(kinds(&out), ["h"]);
let out = ok("# - alpha‸", Paragraph);
assert_eq!(out, "\\- alpha‸");
assert_eq!(kinds(&out), ["p"]);
assert_eq!(kinds(&ok("# 1. a‸", Paragraph)), ["p"]);
}
#[test]
fn a_tasks_first_text_takes_no_block() {
use BlockCommand::*;
assert_eq!(ran("- [ ] a‸", Quote), Err(EditRefusal::Outside));
assert_eq!(ran("- [ ] ‸ab", Rule), Err(EditRefusal::Outside));
assert_eq!(ran("- [ ] ‸ab", CodeBlock), Err(EditRefusal::Outside));
assert_eq!(ran("- [ ] a‸", ParagraphBefore), Err(EditRefusal::Outside));
}
#[test]
fn a_selection_past_a_list_takes_its_siblings() {
let out = ok("- ⟨a\n\nb⟩", BlockCommand::OrderedList);
assert_eq!(out, "1. ⟨a\n\n2. b⟩");
assert_eq!(kinds(&out), ["list"]);
}