authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 02:43:40-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 03:29:16-07:00
logbb9f2fba980bc24c51881194b08afaa2ef2f0bc3
treef50935948fc0fac37287f24a616035f86fc43f04
parent3dade15eef573afab71c86c6ae88e452c7da8176
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: Enter continues a to-do list, and Enter on an empty item ends it

Enter on a paragraph with a check box tag gives the new paragraph an unchecked copy of each check box; Enter on an empty item drops its check boxes, as Enter on an empty bullet ends a list. OneNote 2010 carries no tag on Enter and keeps an empty item's tag below a plain paragraph (corpus/structural-probe); a page edited this way cold-opens in OneNote 2010 with the new items unchecked. Each Enter is one undo step and one revision. Assisted-by: claude-opus-5.5

8 files changed, 337 insertions(+), 46 deletions(-)

arc/canvas.md+4
......@@ -134,6 +134,10 @@ new To Do box, and it has no command that converts a list. Make To-Do List, in
134134the page's context menu and the palette, trades the selected paragraphs' bullets
135135and numbers for the first check box tag in the user's list as one undo step;
136136Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`).
137OneNote's Enter never carries a tag, and on an empty tagged paragraph it opens a
138plain one above (`corpus/structural-probe`). Snowbound's Enter continues a to-do
139list as a list continues: the new paragraph takes an unchecked copy of each check
140box, and Enter on an empty item drops them.
137141
138142An outline holding only pictures or files takes a paragraph after them where a
139143click beside them lands, as OneNote 2010 adds one when typing there. It is
crates/canvas/src/editor.rs+19-9
......@@ -3625,7 +3625,9 @@ impl CanvasEditor {
36253625 /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the
36263626 /// level, lists and character formatting at the caret but no note tags. At a paragraph's
36273627 /// start its tags stay with its text below; an empty paragraph opens a plain one above and
3628 /// leaves the list it ends; a heading continues as body text.
3628 /// leaves the list it ends; a heading continues as body text. Unlike OneNote, a to-do list
3629 /// continues as lists do: the new paragraph takes unchecked copies of the check boxes, and
3630 /// an empty item loses them.
36293631 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
36303632 if let Some(split) = self.across(engine, Self::split)? {
36313633 return Ok(split);
......@@ -3673,14 +3675,22 @@ impl CanvasEditor {
36733675 if next.is_none_or(|next| next.lists.is_empty()) {
36743676 tail.lists.clear();
36753677 }
3676 } else if empty(tail)
3677 && let Some(following) = following
3678 {
3679 tail.style = self.style_named(&following)?;
3680 // The heading's run formatting carries over under the body style (`c9-h1-*`).
3681 let run = format.over(&self.style_format(head.style)?);
3682 tail.text_mut().unwrap().text =
3683 Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?));
3678 self.end_checks(tail);
3679 } else {
3680 if start.offset == 0 {
3681 self.continue_checks(tail, head);
3682 } else {
3683 self.continue_checks(head, tail);
3684 }
3685 if empty(tail)
3686 && let Some(following) = following
3687 {
3688 tail.style = self.style_named(&following)?;
3689 // The heading's run formatting carries over under the body style (`c9-h1-*`).
3690 let run = format.over(&self.style_format(head.style)?);
3691 tail.text_mut().unwrap().text =
3692 Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?));
3693 }
36843694 }
36853695 let caret = TextPosition {
36863696 paragraph: start.paragraph + 1,
crates/canvas/src/editor/evidence.rs+97-4
......@@ -260,13 +260,27 @@ fn replay(
260260 before: &str,
261261 caret: (usize, Offset),
262262 steps: &[(Key, &str)],
263) -> CanvasEditor {
264 let steps = steps
265 .iter()
266 .map(|(key, name)| (*key, summary(name).trim_end().to_owned()))
267 .collect::<Vec<_>>();
268 expect(engine, before, caret, &steps)
269}
270
271/// [`replay`] against outlines given in the summaries' form.
272fn expect(
273 engine: &mut TextEngine,
274 before: &str,
275 caret: (usize, Offset),
276 steps: &[(Key, String)],
263277) -> CanvasEditor {
264278 let mut editor = open(engine, before);
265279 place(&mut editor, caret);
266280 let original = editor.active_outline().document.clone();
267281 for (key, after) in steps {
268282 press(&mut editor, engine, *key);
269 assert_eq!(render(&editor), summary(after).trim_end(), "{after}");
283 assert_eq!(&render(&editor), after);
270284 let outline = editor.active_outline();
271285 let fresh = OutlineLayout::flow(
272286 outline.document.nodes().iter(),
......@@ -323,9 +337,9 @@ const KINDS: [&str; 9] = [
323337];
324338
325339#[test]
326fn enter_carries_level_lists_and_children_but_never_tags() {
340fn enter_carries_level_lists_and_children_but_no_other_tags() {
327341 let mut engine = TextEngine::default();
328 for kind in KINDS {
342 for kind in KINDS.into_iter().filter(|kind| !CHECKED.contains(kind)) {
329343 let prefix = first_run(kind);
330344 replay(
331345 &mut engine,
......@@ -394,6 +408,81 @@ fn enter_carries_level_lists_and_children_but_never_tags() {
394408 );
395409}
396410
411/// The kinds with a To Do box, whose Enter departs from OneNote 2010's.
412const CHECKED: [&str; 3] = ["todo", "done", "tagbullet"];
413
414/// Enter continues a to-do list with unchecked items and an empty item ends it, as Enter does
415/// a bulleted list; OneNote 2010 gives the new paragraph no tag and keeps an empty item's.
416#[test]
417fn enter_continues_a_to_do_list_unchecked_and_an_empty_item_ends_it() {
418 let mut engine = TextEngine::default();
419 for kind in CHECKED {
420 let (level, tag, list) = match kind {
421 "todo" => (" L1", "To Do", ""),
422 "done" => (" L1", "To Do[x]", ""),
423 _ => (" L2", "To Do", "bullet"),
424 };
425 // Below `Above`, a paragraph per `(tag, list, text)`.
426 let page = |lines: &[(&str, &str, &str)]| {
427 std::iter::once(" L1 [] [] qs0 Above".to_owned())
428 .chain(
429 lines
430 .iter()
431 .map(|(tag, list, text)| format!("{level} [{tag}] [{list}] qs0 {text}")),
432 )
433 .collect::<Vec<_>>()
434 .join("\n")
435 .trim_end()
436 .to_owned()
437 };
438 let mut enter = |caret, [split, typed]: [&[(&str, &str, &str)]; 2], text| {
439 expect(
440 &mut engine,
441 &target(kind),
442 caret,
443 &[(Key::Enter, page(split)), (Key::Type(text), page(typed))],
444 );
445 };
446 enter(
447 (1, Offset::End),
448 [
449 &[(tag, list, "Target text"), ("To Do", list, "")],
450 &[(tag, list, "Target text"), ("To Do", list, "New")],
451 ],
452 "New",
453 );
454 enter(
455 (1, Offset::Back(4)),
456 [
457 &[(tag, list, "Target "), ("To Do", list, "text")],
458 &[(tag, list, "Target "), ("To Do", list, "Xtext")],
459 ],
460 "X",
461 );
462 enter(
463 (1, Offset::Start),
464 [
465 &[("To Do", list, ""), (tag, list, "Target text")],
466 &[("To Do", list, ""), (tag, list, "XTarget text")],
467 ],
468 "X",
469 );
470 let item = (tag, list, "Target text");
471 expect(
472 &mut engine,
473 &summary(&format!("c3-{kind}-0")),
474 (2, Offset::Start),
475 &[
476 (Key::Enter, page(&[item, ("", "", ""), ("", "", "")])),
477 (
478 Key::Enter,
479 page(&[item, ("", "", ""), ("", "", ""), ("", "", "")]),
480 ),
481 ],
482 );
483 }
484}
485
397486#[test]
398487fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {
399488 let mut engine = TextEngine::default();
......@@ -710,7 +799,11 @@ fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {
710799 };
711800 editor.select([at(4); 2].into()).unwrap();
712801 editor.enter(&mut engine, false).unwrap();
713 assert_eq!(render(&editor), summary("c9-multitag-1").trim_end());
802 // OneNote 2010 leaves the lower half untagged (`c9-multitag-1`); its To Do continues here.
803 assert_eq!(
804 render(&editor),
805 " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two \n L1 [To Do] [] qs0 tags"
806 );
714807 editor.delete(&mut engine, true).unwrap();
715808 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());
716809 editor.select([at(0); 2].into()).unwrap();
crates/canvas/src/editor/format.rs+39-9
......@@ -1329,26 +1329,56 @@ impl CanvasEditor {
13291329 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps
13301330 /// a cleared box's completion time as zero.
13311331 fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {
1332 let checkable = |tag: &Tag| {
1333 matches!(
1334 self.tag_kind(tag),
1335 Some(Kind::TagDefinition { shape: Some(shape), .. })
1336 if crate::outline::checkable(*shape)
1337 )
1338 };
13391332 let checked = tags
13401333 .iter()
1341 .filter(|tag| checkable(tag))
1334 .filter(|tag| self.check_box(tag).is_some())
13421335 .all(|tag| tag.status & 1 != 0);
13431336 let completed = if checked { Some(0) } else { time32() };
13441337 for tag in &mut tags {
1345 if checkable(tag) && (tag.status & 1 != 0) == checked {
1338 if self.check_box(tag).is_some() && (tag.status & 1 != 0) == checked {
13461339 tag.status ^= 1;
13471340 tag.completed = completed;
13481341 }
13491342 }
13501343 }
13511344
1345 /// The definition and shape of `tag` when it is a check box.
1346 fn check_box(&self, tag: &Tag) -> Option<(&Kind<'static>, u16)> {
1347 match self.tag_kind(tag)? {
1348 kind @ Kind::TagDefinition {
1349 shape: Some(shape), ..
1350 } if crate::outline::checkable(*shape) => Some((kind, *shape)),
1351 _ => None,
1352 }
1353 }
1354
1355 /// Enter in a to-do list: `opened` takes an unchecked copy of each check box of `from`.
1356 pub(super) fn continue_checks(&self, from: &PageParagraph, opened: &mut PageParagraph) {
1357 let created = time32();
1358 let ParagraphContent::Text(text) = &mut opened.content else {
1359 unreachable!()
1360 };
1361 for tag in tags(from).collect::<Vec<_>>().into_iter().rev() {
1362 if let (Some((kind, shape)), Some(id)) = (self.check_box(tag), tag.definition) {
1363 self.retag(
1364 [&mut opened.tags, &mut text.tags],
1365 kind,
1366 Some((id, shape, created)),
1367 );
1368 }
1369 }
1370 }
1371
1372 /// Enter on an empty to-do item ends the list: its check boxes go.
1373 pub(super) fn end_checks(&self, node: &mut PageParagraph) {
1374 let ParagraphContent::Text(text) = &mut node.content else {
1375 unreachable!()
1376 };
1377 for tags in [&mut node.tags, &mut text.tags] {
1378 tags.retain(|tag| self.check_box(tag).is_none());
1379 }
1380 }
1381
13521382 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
13531383 /// checked.
13541384 fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap<ExGuid, Range<usize>>) {
crates/canvas/tests/custom_tags.rs+4-5
......@@ -67,11 +67,14 @@ fn custom_tags_store_what_customize_tags_stores() {
6767 .into()
6868 };
6969 let list = list();
70 for (paragraph, (text, places)) in TAGGED.iter().enumerate() {
70 for (paragraph, (text, _)) in TAGGED.iter().enumerate() {
7171 if paragraph > 0 {
7272 editor.enter(&mut engine, false).unwrap();
7373 }
7474 editor.insert(&mut engine, text).unwrap();
75 }
76 // Tagged once typed, as Enter would continue a check box.
77 for (paragraph, (_, places)) in TAGGED.iter().enumerate() {
7578 editor.select(at(paragraph)).unwrap();
7679 for &place in *places {
7780 let tag = list[usize::from(place)].clone();
......@@ -82,10 +85,6 @@ fn custom_tags_store_what_customize_tags_stores() {
8285 if paragraph == 2 {
8386 editor.format(&mut engine, Formatting::Check).unwrap();
8487 }
85 let offset = text.len() as u32;
86 editor
87 .select([TextPosition { paragraph, offset }; 2].into())
88 .unwrap();
8988 }
9089 let arena = onestore::Arena::default();
9190 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();
crates/canvas/tests/structural_roundtrip.rs+42-5
......@@ -123,6 +123,43 @@ fn apply(
123123}
124124
125125/// Every paragraph on the page, cells' included, by identity.
126/// `page` with its tags' places in their elements' property arenas, which a read assigns,
127/// cleared.
128fn settled(mut page: Page) -> Page {
129 fn walk(list: &mut [PageParagraph]) {
130 for paragraph in list {
131 for tag in &mut paragraph.tags {
132 tag.extra_set = 0;
133 }
134 match &mut paragraph.content {
135 ParagraphContent::Text(text) => {
136 for tag in &mut text.tags {
137 tag.extra_set = 0;
138 }
139 }
140 ParagraphContent::Table(table) => {
141 for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) {
142 walk(&mut cell.paragraphs);
143 }
144 }
145 _ => {}
146 }
147 }
148 }
149 for object in &mut page.objects {
150 match object {
151 PageObject::Outline(outline) => walk(&mut outline.paragraphs),
152 PageObject::Title(title) => {
153 for outline in &mut title.outlines {
154 walk(&mut outline.paragraphs);
155 }
156 }
157 _ => {}
158 }
159 }
160 page
161}
162
126163fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> {
127164 fn walk(list: &[PageParagraph], out: &mut BTreeMap<ExGuid, PageParagraph>) {
128165 for paragraph in list {
......@@ -346,10 +383,10 @@ fn sweep(
346383 }
347384 // An untitled page's title follows its first line as the writer stores it.
348385 let rereads = |reread: &Page| {
349 Page {
386 settled(Page {
350387 title: after.title.clone(),
351388 ..reread.clone()
352 } == after
389 }) == settled(after.clone())
353390 };
354391 let arena = Arena::default();
355392 let mut stored = Section::open(&arena, section.clone()).unwrap();
......@@ -502,11 +539,11 @@ fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mu
502539 .find(|(id, _)| *id == space)
503540 .unwrap();
504541 assert_eq!(
505 Page {
542 settled(Page {
506543 title: after.title.clone(),
507544 ..reread.clone()
508 },
509 after,
545 }),
546 settled(after),
510547 "{title}"
511548 );
512549 *section = saved;
crates/canvas/tests/to_do_list.rs+128-9
......@@ -104,16 +104,135 @@ fn lists_become_to_do_lists_and_back_one_revision_each() {
104104 let written = section.image();
105105 let reread = Section::open(&arena, written.clone()).unwrap();
106106 assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected);
107 if let Some(directory) = std::env::var_os("SNOWBOUND_TO_DO_LIST_EXPORT") {
108 let directory = std::path::PathBuf::from(directory);
109 std::fs::create_dir(&directory).unwrap();
110 std::fs::write(directory.join("lists.one"), &written).unwrap();
111 let file_id = Store::parse(&written).unwrap().header.file_id;
112 std::fs::write(
113 directory.join("Open Notebook.onetoc2"),
114 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)])
115 .unwrap(),
107 export("SNOWBOUND_TO_DO_LIST_EXPORT", &written);
108}
109
110/// Writes section `written` and a table of contents to the new directory `variable` names.
111fn export(variable: &str, written: &[u8]) {
112 let Some(directory) = std::env::var_os(variable) else {
113 return;
114 };
115 let directory = std::path::PathBuf::from(directory);
116 std::fs::create_dir(&directory).unwrap();
117 std::fs::write(directory.join("lists.one"), written).unwrap();
118 let file_id = Store::parse(written).unwrap().header.file_id;
119 std::fs::write(
120 directory.join("Open Notebook.onetoc2"),
121 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)])
122 .unwrap(),
123 )
124 .unwrap();
125}
126
127/// Each top-level paragraph's text and its tags' check states.
128fn checks(page: &Page) -> Vec<(String, Vec<bool>)> {
129 page.objects
130 .iter()
131 .find_map(|object| match object {
132 PageObject::Outline(outline) => Some(&outline.paragraphs),
133 _ => None,
134 })
135 .unwrap()
136 .iter()
137 .filter(|node| node.level == 1)
138 .map(|node| {
139 let text = node.text().unwrap();
140 let checked = text.tags.iter().map(|tag| tag.status & 1 == 1).collect();
141 (text.text.text().to_owned(), checked)
142 })
143 .collect()
144}
145
146/// Enter after a checked To Do opens an unchecked one, and Enter on that empty item ends the
147/// list, each Enter one undo step and one revision. `SNOWBOUND_TO_DO_ENTER_EXPORT` names a new
148/// directory receiving the candidate for a cold reopen.
149#[test]
150fn enter_continues_a_to_do_list_and_twice_ends_it() {
151 let arena = Arena::default();
152 let mut section = Section::open(&arena, LISTS.to_vec()).unwrap();
153 let space = section.pages().unwrap()[0].0;
154 let page = section.page(space).unwrap();
155 let mut engine = TextEngine::default();
156 let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap();
157 editor.focus_outline(body(&page)).unwrap();
158 let mut at_time = 134_000_000_000_000_000;
159 let mut publish = |editor: &mut CanvasEditor| {
160 let ops = editor
161 .take_ops()
162 .unwrap()
163 .into_iter()
164 .map(|op| Op::Page { space, op })
165 .collect::<Vec<_>>();
166 at_time += 10_000_000;
167 section.apply("Author", &Edit { at: at_time, ops }).unwrap();
168 };
169 let end = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into();
170 editor
171 .select(
172 [
173 at(0),
174 TextPosition {
175 paragraph: 1,
176 offset: 14,
177 },
178 ]
179 .into(),
116180 )
117181 .unwrap();
182 editor
183 .format(
184 &mut engine,
185 Formatting::ToDoList(NoteTag::defaults()[0].clone(), 0),
186 )
187 .unwrap();
188 editor.select(end(0, 0)).unwrap();
189 editor.format(&mut engine, Formatting::Check).unwrap();
190 publish(&mut editor);
191
192 editor.select(end(0, 11)).unwrap();
193 editor.enter(&mut engine, false).unwrap();
194 publish(&mut editor);
195 editor.insert(&mut engine, "Next task").unwrap();
196 publish(&mut editor);
197 editor.select(end(2, 14)).unwrap();
198 for _ in 0..2 {
199 editor.enter(&mut engine, false).unwrap();
200 publish(&mut editor);
201 }
202 editor.insert(&mut engine, "After the list").unwrap();
203 publish(&mut editor);
204
205 let expected = [
206 ("Bullet item", vec![true]),
207 ("Next task", vec![false]),
208 ("First numbered", vec![false]),
209 ("", vec![]),
210 ("After the list", vec![]),
211 ("Second numbered", vec![]),
212 ("Restarted at three", vec![]),
213 ("Plain again", vec![]),
214 ]
215 .map(|(text, checked)| (text.to_owned(), checked));
216 assert_eq!(checks(&section.page(space).unwrap()), expected);
217 section.seal().unwrap();
218 let written = section.image();
219 let reread = Section::open(&arena, written.clone()).unwrap();
220 assert_eq!(checks(&reread.page(space).unwrap()), expected);
221 for _ in 0..3 {
222 assert!(editor.undo(&mut engine).unwrap());
118223 }
224 assert_eq!(
225 checks(&editor.page().unwrap())
226 .iter()
227 .map(|(text, _)| text.as_str())
228 .take(4)
229 .collect::<Vec<_>>(),
230 [
231 "Bullet item",
232 "Next task",
233 "First numbered",
234 "Second numbered"
235 ]
236 );
237 export("SNOWBOUND_TO_DO_ENTER_EXPORT", &written);
119238}
crates/snowbound/src/tags.rs+4-5
......@@ -1155,6 +1155,9 @@ mod tests {
11551155 editor.enter(&mut engine, false).unwrap();
11561156 }
11571157 editor.insert(&mut engine, text).unwrap();
1158 }
1159 // Tagged once typed, as Enter would continue a check box.
1160 for (paragraph, tag) in list.iter().take(lines.len()).enumerate() {
11581161 editor
11591162 .select(
11601163 [TextPosition {
......@@ -1164,15 +1167,11 @@ mod tests {
11641167 .into(),
11651168 )
11661169 .unwrap();
1167 let formatting = Formatting::Tag(list[paragraph].clone(), paragraph as u16);
1170 let formatting = Formatting::Tag(tag.clone(), paragraph as u16);
11681171 editor.format(&mut engine, formatting).unwrap();
11691172 if paragraph == 2 {
11701173 editor.format(&mut engine, Formatting::Check).unwrap();
11711174 }
1172 let offset = text.len() as u32;
1173 editor
1174 .select([TextPosition { paragraph, offset }; 2].into())
1175 .unwrap();
11761175 }
11771176 let arena = onestore::Arena::default();
11781177 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();