Merge pull request #2248 from TrevorBrown-dev/feat/typed-number-starts-list

M4: a typed number and full stop start a numbered list at that number, as measured in Figma
This commit is contained in:
BFlat
2026-11-12 09:27:46 +07:00
committed by GitHub
5 changed files with 194 additions and 14 deletions
+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)]
+30 -5
View File
@@ -284,32 +284,57 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
let text = normalized;
edit(s, |t, te| {
let (a, b) = range(te);
// "- ", "* ", "1. " or "1) " typed at the start of a plain paragraph starts a list (the
// creation characters of Create bulleted and numbered lists).
// "- ", "* ", "<number>. " or "1) " typed at the start of a plain paragraph starts a list
// (the creation characters of Create bulleted and numbered lists). As measured in Figma
// (2026-11-11), a typed "5. " starts the numbered list at 5 (there is no start-number
// field). "0. " and a bracket after a number other than 1 are not measured: plain text.
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
let chars: Vec<char> = t.characters.chars().collect();
let start = line_start(&chars, a);
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
let mut first = 1_u32;
let list = match typed.as_str() {
"-" | "*" => ListStyle::Bulleted,
"1." | "1)" => ListStyle::Numbered,
_ => ListStyle::None,
"1)" => ListStyle::Numbered,
_ => match typed.strip_suffix('.').filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) {
Some(d) => match d.parse::<u32>() {
Ok(n) if (1..=MAX_TYPED_LIST_START).contains(&n) => {
first = n;
ListStyle::Numbered
}
_ => ListStyle::None,
},
None => ListStyle::None,
},
};
if list != ListStyle::None {
t.replace_range(start, a, "");
t.set_para_lists(start, start, |pl| pl.list = list);
t.set_para_lists(start, start, |pl| {
pl.list = list;
pl.start_offset = first - 1;
});
(te.caret, te.anchor) = (start, start);
outcome = ImeInsertion::Transformed;
return;
}
}
t.replace_range(a, b, &text);
// 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.
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;
})?;
Ok(outcome)
}
/// The largest number a typed `N. ` can start a numbered list at.
const MAX_TYPED_LIST_START: u32 = 9999;
/// What a list key does to the selected items.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ListMove {
+111 -1
View File
@@ -84,7 +84,11 @@ fn typed_list_starts() {
("- ", "", B, "hyphen and space: bullets"),
("* ", "", B, "asterisk and space: bullets"),
("1. ", "", N, "one, full stop, space: numbers"),
("2. ", "2. ", P, "silent: only 1. starts a numbered list"),
("2. ", "", N, "as measured in Figma: a typed number starts the list there"),
("0. ", "0. ", P, "zero is not measured: text"),
("12a. ", "12a. ", P, "digits only: text"),
("1) ", "", N, "one and a closing bracket: numbers"),
("2) ", "2) ", P, "silent: only 1) starts with a bracket"),
("a- ", "a- ", P, "not at the paragraph's start: text"),
("-x ", "-x ", P, "the space must follow the marker"),
];
@@ -108,6 +112,76 @@ fn typed_list_starts() {
assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("Intro\n- ", vec![(P, 0), (B, 0)]), "an item's own text stays text");
}
#[test]
fn typed_number_starts_the_list_there() {
// As measured in Figma (2026-11-11): typing "5. x", Enter, "y" reads 5. then 6.; the start
// is the typed number less one, and the next item goes on counting from it.
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();
}
assert_eq!(data(&s, t).para_list(0).start_offset, 4, "5 starts at offset 4");
key(&mut s, "Enter", false, false, false).unwrap();
insert(&mut s, "y").unwrap();
crate::textedit::end(&mut s).unwrap();
let d = data(&s, t);
assert_eq!(d.characters, "x\ny");
assert_eq!(lists(&s, t), [(N, 0), (N, 0)]);
// An item made by Enter has no start of its own and goes on counting: 5. then 6.
assert_eq!((d.para_list(0).start_offset, d.para_list(1).start_offset), (4, 0), "the new item has no start");
s.undo().unwrap();
assert_eq!(data(&s, t).characters, "", "one undo step for the session");
assert_eq!(data(&s, t).para_list(0).start_offset, 0);
}
/// Type `first` ("5. a" style), then Enter, "b", Enter, "c", leaving the caret in "c".
fn three_typed_items(s: &mut Session, first: &str) -> NodeId {
let t = text(s, "x");
s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap();
begin(s, t, Some(0), false).unwrap();
for c in first.chars() {
insert(s, &c.to_string()).unwrap();
}
for step in ["Enter", "b", "Enter", "c"] {
if step == "Enter" {
key(s, step, false, false, false).unwrap();
} else {
insert(s, step).unwrap();
}
}
t
}
#[test]
fn a_list_split_by_unlisting_a_middle_item_restarts_at_one() {
// As measured in Figma (2026-11-11): typed "5. a", Enter, "b", Enter, "c" reads 5. 6. 7.;
// Backspace at the start of "b" takes its marker and "c" then reads 1., for "1. " too.
// `list_markers` counts on after a numbered item and restarts a numbered item that follows a
// plain paragraph at its own start, so the starts and the neighbours give the markers.
for (first, start) in [("5. a", 4), ("1. a", 0)] {
let mut s = Session::new();
let t = three_typed_items(&mut s, first);
assert_eq!(
data(&s, t).characters,
"a
b
c"
);
// The guard: unsplit, "b" and "c" go on counting (5. 6. 7.).
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0); 3], "{first}: no split");
assert_eq!((d.para_list(0).start_offset, d.para_list(1).start_offset, d.para_list(2).start_offset), (start, 0, 0), "{first}");
set_caret(&mut s, 2, false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
let d = data(&s, t);
assert_eq!(lists(&s, t), [(N, 0), (P, 0), (N, 0)], "{first}: split");
assert_eq!(d.para_list(2).start_offset, 0, "{first}: c restarts at 1 (the item made by Enter has no start)");
}
}
#[test]
fn list_keys_at_list_items() {
// (chars, lists before, caret, key, shift, chars after, lists after, derivation)
@@ -372,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));
}
+10 -2
View File
@@ -93,8 +93,16 @@ fn list_numbering_by_level() {
vec!["\u{2022}", "\u{2022}", "\u{2022}", "\u{2022}"],
"bullets keep the same styling at all levels of indentation",
),
(vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"),
(vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"),
(
vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)],
vec!["1.", "2.", "", "1."],
"as measured in Figma (2026-11-11): a plain paragraph ends a list, the next starts at 1",
),
(
vec![pl(N, 0), pl(B, 0), pl(N, 0)],
vec!["1.", "\u{2022}", "1."],
"as measured in Figma (2026-11-11): a bullet item between numbered ones starts the count again",
),
(vec![pl(N, 0), pl(N, 1), pl(N, 0), pl(N, 1)], vec!["1.", "a.", "2.", "a."], "a shallower item starts the deeper count again"),
(vec![pl(N, 2), pl(N, 2), pl(N, 2), pl(N, 2)], vec!["i.", "ii.", "iii.", "iv."], "roman numerals at the third level"),
];
File diff suppressed because one or more lines are too long