M4: the list start reset runs once per edit and leaves plain paragraphs' levels alone

insert_model cleared the copied start with one set_para_lists call per newline, each scanning the whole text, and set_para_lists also zeroed the level of plain paragraphs. Add TextData::clear_list_starts (one pass, touches only start_offset, no-op when no paragraph has a start) and call it once over the created paragraphs, only when the text has a newline.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-11 16:03:31 -05:00
1 parent b190e675b9
commit 7c3f9ce7e3
3 files changed
+77 -4

No files matched your search

+38
View File
@@ -886,6 +886,25 @@ impl TextData {
}
pl.indent = pl.indent.min(MAX_LIST_INDENT);
}
self.fold_paragraph_lists();
}
/// Give paragraphs `a..=b` (by index) no start number of their own, as an item made by Enter
/// has none. Touches nothing else: a plain paragraph keeps its level, a bullet its settings.
/// One pass over the range, and nothing at all when no paragraph carries a start.
pub fn clear_list_starts(&mut self, a: usize, b: usize) {
if self.paragraphs.iter().all(|p| p.start_offset == 0) {
return;
}
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
pl.start_offset = 0;
}
self.fold_paragraph_lists();
}
/// One style for all paragraphs with no nesting or start folds back into `list`.
fn fold_paragraph_lists(&mut self) {
if let Some(first) = self.paragraphs.first().copied()
&& first == (ParaList { list: first.list, ..ParaList::default() })
&& self.paragraphs.iter().all(|p| *p == first)
@@ -1104,6 +1123,25 @@ mod case_tests {
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
#[test]
fn clear_list_starts_touches_only_starts() {
use ListStyle::{None as P, Numbered as N};
let mut t = TextData { characters: "a\nb\nc".into(), ..Default::default() };
t.paragraphs = vec![
ParaList { list: N, start_offset: 4, ..ParaList::default() },
ParaList { list: P, indent: 1, ..ParaList::default() },
ParaList { list: N, indent: 2, start_offset: 7, ..ParaList::default() },
];
t.clear_list_starts(1, 2);
assert_eq!(t.para_list(0).start_offset, 4, "outside the range");
assert_eq!(t.para_list(1), ParaList { list: P, indent: 1, ..ParaList::default() }, "a plain paragraph keeps its level");
assert_eq!(t.para_list(2), ParaList { list: N, indent: 2, start_offset: 0, ..ParaList::default() });
// Out of range and no-start cases leave it alone.
t.clear_list_starts(5, 9);
t.clear_list_starts(0, 0);
assert_eq!(t.para_list(0).start_offset, 0);
}
}
#[cfg(test)]
+3 -4
View File
@@ -322,10 +322,9 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
// As measured in Figma (2026-11-11): an item made by Enter has no start of its own, so
// unlisting the item before it makes it restart at 1 rather than at the copied start.
// Every paragraph after the first that the insertion made loses the start it was copied.
for (i, c) in text.chars().enumerate() {
if c == '\n' {
t.set_para_lists(a + i + 1, a + i + 1, |pl| pl.start_offset = 0);
}
if text.contains('\n') {
let end = a + text.chars().count();
t.clear_list_starts(t.para_of(a) + 1, t.para_of(end));
}
te.caret = a + text.chars().count();
te.anchor = te.caret;
+36
View File
@@ -446,3 +446,39 @@ fn typing_a_creation_character_and_a_space_starts_a_list() {
}
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("2) ", vec![(P, 0)]));
}
#[test]
fn splitting_a_plain_paragraph_that_kept_its_level_keeps_the_level() {
// Backspace at a nested item's start unlists it but keeps indent 1; Enter inside it then
// splits it, and neither half loses the level (the start reset changes only starts).
let mut s = Session::new();
let t = text(&mut s, "ab\ncd");
let ps = vec![ParaList { list: N, start_offset: 4, ..ParaList::default() }, ParaList { list: N, indent: 1, ..ParaList::default() }];
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
begin(&mut s, t, Some(3), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(lists(&s, t), [(N, 0), (P, 1)], "unlisted, level kept");
set_caret(&mut s, 4, false).unwrap();
key(&mut s, "Enter", false, false, false).unwrap();
assert_eq!(data(&s, t).characters, "ab\nc\nd");
assert_eq!(lists(&s, t), [(N, 0), (P, 1), (P, 1)], "both halves keep indent 1");
}
#[test]
fn a_many_line_paste_clears_every_created_start() {
let mut s = Session::new();
let t = text(&mut s, "x");
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
begin(&mut s, t, Some(0), false).unwrap();
for c in "5. x".chars() {
insert(&mut s, &c.to_string()).unwrap();
}
let lines: Vec<String> = (0..2001).map(|i| format!("l{i}")).collect();
insert(&mut s, &lines.join("\n")).unwrap();
let d = data(&s, t);
assert_eq!(d.paragraph_count(), 2001);
assert_eq!(d.para_list(0).start_offset, 4, "the first item keeps its start");
assert!((1..d.paragraph_count()).all(|p| d.para_list(p).start_offset == 0 && d.para_list(p).list == N));
}