mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 02:34:33 +00:00
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:
@@ -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)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user