Files
Ely-GPUI-Components/src/documents/document/block/list.rs
T
ZacharyZhang-NYandClaude Opus 5.5 b37031b00b 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>
2026-10-14 14:08:40 -04:00

269 lines
9.4 KiB
Rust

//! Lists on, off and retyped, as Typora's `toggleIndent`.
use std::ops::Range;
use markdown::mdast::{List, Node};
use super::{
BlockCommand, Here, change, column, eol, indent, inside, line_end, line_start, next_line,
opener, prefix,
};
use crate::documents::{
document::format::{Change, EditRefusal},
markdown::span,
};
fn kind(list: &List) -> BlockCommand {
match (list.ordered, list.children.first()) {
(true, _) => BlockCommand::OrderedList,
(false, Some(Node::ListItem(item))) if item.checked.is_some() => BlockCommand::TaskList,
_ => BlockCommand::BulletList,
}
}
/// The marker an item takes: number `n` or the bullet.
fn marker(kind: BlockCommand, n: u32, bullet: char) -> String {
match kind {
BlockCommand::OrderedList => format!("{n}. "),
BlockCommand::TaskList => format!("{bullet} [ ] "),
_ => format!("{bullet} "),
}
}
/// A marker's width for continuation lines; boxes add none.
fn width(kind: BlockCommand, n: u32) -> usize {
match kind {
BlockCommand::OrderedList => format!("{n}. ").len(),
_ => 2,
}
}
pub(super) fn run(here: &Here, command: BlockCommand) -> Result<Change, EditRefusal> {
let caret = here.selection.clone();
let path = &here.path;
let item = match here.leaf() {
Some(leaf) => path.len().checked_sub(2).filter(|ix| match path[*ix] {
Node::ListItem(item) => {
!caret.is_empty()
|| item
.children
.first()
.is_some_and(|kid| std::ptr::eq(kid, leaf))
}
_ => false,
}),
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 => {
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));
}
if here.leaf().is_none() && caret.is_empty() {
if !here.blank() {
return Err(EditRefusal::Outside);
}
let line = line_start(here.text, caret.start);
let at = inside(here.text, line, &here.containers(path.len()))
.expect("a blank line holds its prefix");
return Ok(change(vec![(at..at, marker(command, 1, '-'))], caret));
}
let (k, blocks) = here.blocks().ok_or(EditRefusal::Outside)?;
Ok(change(wrap(here, k, &blocks, command), caret))
}
/// Indent from `at` to column `want`: its end, columns past.
fn indented(text: &str, at: usize, want: usize) -> Option<(usize, usize)> {
let end = line_end(text, at);
let mut cut = at;
let mut col = column(text, at);
while col < want {
let ch = text[cut..end]
.chars()
.next()
.filter(|ch| matches!(ch, ' ' | '\t'))?;
col = if ch == '\t' { col / 4 * 4 + 4 } else { col + 1 };
cut += 1;
}
Some((cut, col - want))
}
/// Each non-lazy line of `item` after its first, with indent.
fn continued<'a>(
text: &'a str,
item: &Node,
outer: &'a [&Node],
) -> impl Iterator<Item = (usize, usize, usize)> + 'a {
let whole = span(item);
let want = indent(text, item);
std::iter::successors(next_line(text, whole.start), move |line| {
next_line(text, *line)
})
.take_while(move |line| *line < whole.end)
.filter_map(move |line| {
let at = inside(text, line, outer)?;
if text[at..line_end(text, line)].trim().is_empty() {
return None;
}
let (cut, past) = indented(text, at, want)?;
Some((at, cut, past))
})
}
/// Every item's marker off, its lines out one level.
fn unwrap(here: &Here, ix: usize, list: &List) -> Vec<(Range<usize>, String)> {
let text = here.text;
let outer = here.containers(ix);
let blank = format!("{}{}", prefix(text, &outer).trim_end(), eol(text));
let whole = span(here.path[ix]);
let mut edits = Vec::new();
for (n, item) in list.children.iter().enumerate() {
let start = span(item).start;
let line = line_start(text, start);
let joins = match n {
0 => here.joins_before(&whole, Some(here.path[ix])),
_ => prev_text(text, line, &outer),
};
if joins {
edits.push((line..line, blank.clone()));
}
let first = item.children().and_then(|kids| kids.first()).map(span);
match first {
Some(kid) if !text[start..kid.start].contains(['\n', '\r']) => {
edits.push((start..kid.start, String::new()));
}
Some(_) => {
let next = next_line(text, start).expect("an item's text lies below");
edits.push((line..next, String::new()));
}
None => edits.push((start..line_end(text, start), String::new())),
}
for (at, cut, past) in continued(text, item, &outer) {
edits.push((at..cut, " ".repeat(past)));
}
}
if here.joins_after(&whole) {
let next = next_line(text, whole.end).expect("a joining line follows");
edits.push((next..next, blank.clone()));
}
edits
}
/// Whether the line before `line` holds text inside `outer`.
fn prev_text(text: &str, line: usize, outer: &[&Node]) -> bool {
super::prev_line(text, line).is_some_and(|prev| {
inside(text, prev, outer).is_none_or(|at| !text[at..line_end(text, prev)].trim().is_empty())
})
}
/// 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(node).start..].chars().next().unwrap_or('-'),
};
let mut edits = Vec::new();
for (n, item) in list.children.iter().enumerate() {
let number = start + n as u32;
let at = span(item).start;
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 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, written));
let want = column(text, at) + width(kind, number);
for (from, cut, past) in continued(text, item, outer) {
let pad = want.saturating_sub(column(text, from)) + past;
edits.push((from..cut, " ".repeat(pad)));
}
}
edits
}
/// Each paragraph line, and each other block, made an item.
fn wrap(
here: &Here,
k: usize,
blocks: &[&Node],
command: BlockCommand,
) -> Vec<(Range<usize>, String)> {
let text = here.text;
let outer = here.containers(k + 1);
let lead = prefix(text, &outer);
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 {
let first = line == line_start(text, whole.start);
let (at, lazy) = match first {
true => (Some(whole.start), ""),
false => match inside(text, line, &outer) {
Some(at) => (Some(at), ""),
None => (None, lead.as_str()),
},
};
let at = at.unwrap_or(line);
let content = at + text[at..].len() - text[at..].trim_start_matches([' ', '\t']).len();
if !text[at..line_end(text, line)].trim().is_empty() {
let mark = match first || matches!(block, Node::Paragraph(_)) {
true => {
n += 1;
marker(command, n - 1, '-')
}
false => " ".repeat(width(command, n - 1)),
};
let at = if matches!(block, Node::Paragraph(_)) {
content
} else {
at
};
edits.push((at..at, format!("{lazy}{mark}")));
}
match next_line(text, line) {
Some(next) => line = next,
None => break,
}
}
}
edits
}