From bb9f2fba980bc24c51881194b08afaa2ef2f0bc3 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Fri, 2 Oct 2026 02:43:40 -0700 Subject: [PATCH] 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 --- arc/canvas.md | 4 + crates/canvas/src/editor.rs | 28 ++-- crates/canvas/src/editor/evidence.rs | 101 ++++++++++++++- crates/canvas/src/editor/format.rs | 48 +++++-- crates/canvas/tests/custom_tags.rs | 9 +- crates/canvas/tests/structural_roundtrip.rs | 47 ++++++- crates/canvas/tests/to_do_list.rs | 137 ++++++++++++++++++-- crates/snowbound/src/tags.rs | 9 +- 8 files changed, 337 insertions(+), 46 deletions(-) diff --git a/arc/canvas.md b/arc/canvas.md index 5862a04d174252785767351437265c51fba55de4..d86a6cdfeb76d5edbc89f44b1ad29db07e4f067e 100644 --- a/arc/canvas.md +++ b/arc/canvas.md @@ -134,6 +134,10 @@ new To Do box, and it has no command that converts a list. Make To-Do List, in the page's context menu and the palette, trades the selected paragraphs' bullets and numbers for the first check box tag in the user's list as one undo step; Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`). +OneNote's Enter never carries a tag, and on an empty tagged paragraph it opens a +plain one above (`corpus/structural-probe`). Snowbound's Enter continues a to-do +list as a list continues: the new paragraph takes an unchecked copy of each check +box, and Enter on an empty item drops them. An outline holding only pictures or files takes a paragraph after them where a click beside them lands, as OneNote 2010 adds one when typing there. It is diff --git a/crates/canvas/src/editor.rs b/crates/canvas/src/editor.rs index 9d8cba33d6c5166af3cc86d94c73d1f0fe25517b..2f2f9e815f051bd4ecb095abc50c245517944814 100644 --- a/crates/canvas/src/editor.rs +++ b/crates/canvas/src/editor.rs @@ -3625,7 +3625,9 @@ impl CanvasEditor { /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the /// level, lists and character formatting at the caret but no note tags. At a paragraph's /// start its tags stay with its text below; an empty paragraph opens a plain one above and - /// leaves the list it ends; a heading continues as body text. + /// leaves the list it ends; a heading continues as body text. Unlike OneNote, a to-do list + /// continues as lists do: the new paragraph takes unchecked copies of the check boxes, and + /// an empty item loses them. fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { if let Some(split) = self.across(engine, Self::split)? { return Ok(split); @@ -3673,14 +3675,22 @@ impl CanvasEditor { if next.is_none_or(|next| next.lists.is_empty()) { tail.lists.clear(); } - } else if empty(tail) - && let Some(following) = following - { - tail.style = self.style_named(&following)?; - // The heading's run formatting carries over under the body style (`c9-h1-*`). - let run = format.over(&self.style_format(head.style)?); - tail.text_mut().unwrap().text = - Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?)); + self.end_checks(tail); + } else { + if start.offset == 0 { + self.continue_checks(tail, head); + } else { + self.continue_checks(head, tail); + } + if empty(tail) + && let Some(following) = following + { + tail.style = self.style_named(&following)?; + // The heading's run formatting carries over under the body style (`c9-h1-*`). + let run = format.over(&self.style_format(head.style)?); + tail.text_mut().unwrap().text = + Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?)); + } } let caret = TextPosition { paragraph: start.paragraph + 1, diff --git a/crates/canvas/src/editor/evidence.rs b/crates/canvas/src/editor/evidence.rs index 20a3129a03e7d652750efd6799a57d85fffcf889..bb1f85db15c4a58ef553b96a9278ce6f8a072ddf 100644 --- a/crates/canvas/src/editor/evidence.rs +++ b/crates/canvas/src/editor/evidence.rs @@ -260,13 +260,27 @@ fn replay( before: &str, caret: (usize, Offset), steps: &[(Key, &str)], +) -> CanvasEditor { + let steps = steps + .iter() + .map(|(key, name)| (*key, summary(name).trim_end().to_owned())) + .collect::>(); + expect(engine, before, caret, &steps) +} + +/// [`replay`] against outlines given in the summaries' form. +fn expect( + engine: &mut TextEngine, + before: &str, + caret: (usize, Offset), + steps: &[(Key, String)], ) -> CanvasEditor { let mut editor = open(engine, before); place(&mut editor, caret); let original = editor.active_outline().document.clone(); for (key, after) in steps { press(&mut editor, engine, *key); - assert_eq!(render(&editor), summary(after).trim_end(), "{after}"); + assert_eq!(&render(&editor), after); let outline = editor.active_outline(); let fresh = OutlineLayout::flow( outline.document.nodes().iter(), @@ -323,9 +337,9 @@ const KINDS: [&str; 9] = [ ]; #[test] -fn enter_carries_level_lists_and_children_but_never_tags() { +fn enter_carries_level_lists_and_children_but_no_other_tags() { let mut engine = TextEngine::default(); - for kind in KINDS { + for kind in KINDS.into_iter().filter(|kind| !CHECKED.contains(kind)) { let prefix = first_run(kind); replay( &mut engine, @@ -394,6 +408,81 @@ fn enter_carries_level_lists_and_children_but_never_tags() { ); } +/// The kinds with a To Do box, whose Enter departs from OneNote 2010's. +const CHECKED: [&str; 3] = ["todo", "done", "tagbullet"]; + +/// Enter continues a to-do list with unchecked items and an empty item ends it, as Enter does +/// a bulleted list; OneNote 2010 gives the new paragraph no tag and keeps an empty item's. +#[test] +fn enter_continues_a_to_do_list_unchecked_and_an_empty_item_ends_it() { + let mut engine = TextEngine::default(); + for kind in CHECKED { + let (level, tag, list) = match kind { + "todo" => (" L1", "To Do", ""), + "done" => (" L1", "To Do[x]", ""), + _ => (" L2", "To Do", "bullet"), + }; + // Below `Above`, a paragraph per `(tag, list, text)`. + let page = |lines: &[(&str, &str, &str)]| { + std::iter::once(" L1 [] [] qs0 Above".to_owned()) + .chain( + lines + .iter() + .map(|(tag, list, text)| format!("{level} [{tag}] [{list}] qs0 {text}")), + ) + .collect::>() + .join("\n") + .trim_end() + .to_owned() + }; + let mut enter = |caret, [split, typed]: [&[(&str, &str, &str)]; 2], text| { + expect( + &mut engine, + &target(kind), + caret, + &[(Key::Enter, page(split)), (Key::Type(text), page(typed))], + ); + }; + enter( + (1, Offset::End), + [ + &[(tag, list, "Target text"), ("To Do", list, "")], + &[(tag, list, "Target text"), ("To Do", list, "New")], + ], + "New", + ); + enter( + (1, Offset::Back(4)), + [ + &[(tag, list, "Target "), ("To Do", list, "text")], + &[(tag, list, "Target "), ("To Do", list, "Xtext")], + ], + "X", + ); + enter( + (1, Offset::Start), + [ + &[("To Do", list, ""), (tag, list, "Target text")], + &[("To Do", list, ""), (tag, list, "XTarget text")], + ], + "X", + ); + let item = (tag, list, "Target text"); + expect( + &mut engine, + &summary(&format!("c3-{kind}-0")), + (2, Offset::Start), + &[ + (Key::Enter, page(&[item, ("", "", ""), ("", "", "")])), + ( + Key::Enter, + page(&[item, ("", "", ""), ("", "", ""), ("", "", "")]), + ), + ], + ); + } +} + #[test] fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() { let mut engine = TextEngine::default(); @@ -710,7 +799,11 @@ fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() { }; editor.select([at(4); 2].into()).unwrap(); editor.enter(&mut engine, false).unwrap(); - assert_eq!(render(&editor), summary("c9-multitag-1").trim_end()); + // OneNote 2010 leaves the lower half untagged (`c9-multitag-1`); its To Do continues here. + assert_eq!( + render(&editor), + " L1 [] [] qs0 Alpha\n L1 [To Do,Important[x]] [] qs0 Two \n L1 [To Do] [] qs0 tags" + ); editor.delete(&mut engine, true).unwrap(); assert_eq!(render(&editor), summary("c9-multitag-2").trim_end()); editor.select([at(0); 2].into()).unwrap(); diff --git a/crates/canvas/src/editor/format.rs b/crates/canvas/src/editor/format.rs index eb92e8b91882da9907e59dcbb965bf4766eef097..9cea5359ea813ce5ffe010e986d9b2075a244ea6 100644 --- a/crates/canvas/src/editor/format.rs +++ b/crates/canvas/src/editor/format.rs @@ -1329,26 +1329,56 @@ impl CanvasEditor { /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps /// a cleared box's completion time as zero. fn toggle_checks(&self, mut tags: Vec<&mut Tag>) { - let checkable = |tag: &Tag| { - matches!( - self.tag_kind(tag), - Some(Kind::TagDefinition { shape: Some(shape), .. }) - if crate::outline::checkable(*shape) - ) - }; let checked = tags .iter() - .filter(|tag| checkable(tag)) + .filter(|tag| self.check_box(tag).is_some()) .all(|tag| tag.status & 1 != 0); let completed = if checked { Some(0) } else { time32() }; for tag in &mut tags { - if checkable(tag) && (tag.status & 1 != 0) == checked { + if self.check_box(tag).is_some() && (tag.status & 1 != 0) == checked { tag.status ^= 1; tag.completed = completed; } } } + /// The definition and shape of `tag` when it is a check box. + fn check_box(&self, tag: &Tag) -> Option<(&Kind<'static>, u16)> { + match self.tag_kind(tag)? { + kind @ Kind::TagDefinition { + shape: Some(shape), .. + } if crate::outline::checkable(*shape) => Some((kind, *shape)), + _ => None, + } + } + + /// Enter in a to-do list: `opened` takes an unchecked copy of each check box of `from`. + pub(super) fn continue_checks(&self, from: &PageParagraph, opened: &mut PageParagraph) { + let created = time32(); + let ParagraphContent::Text(text) = &mut opened.content else { + unreachable!() + }; + for tag in tags(from).collect::>().into_iter().rev() { + if let (Some((kind, shape)), Some(id)) = (self.check_box(tag), tag.definition) { + self.retag( + [&mut opened.tags, &mut text.tags], + kind, + Some((id, shape, created)), + ); + } + } + } + + /// Enter on an empty to-do item ends the list: its check boxes go. + pub(super) fn end_checks(&self, node: &mut PageParagraph) { + let ParagraphContent::Text(text) = &mut node.content else { + unreachable!() + }; + for tags in [&mut node.tags, &mut text.tags] { + tags.retain(|tag| self.check_box(tag).is_none()); + } + } + /// Checks the check boxes of the `covered` paragraphs, or clears them once all are /// checked. fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap>) { diff --git a/crates/canvas/tests/custom_tags.rs b/crates/canvas/tests/custom_tags.rs index d8dafca0276dfd2aff8836de105a030fd7b1d98f..67a675359269180e803081270c26440653ee6393 100644 --- a/crates/canvas/tests/custom_tags.rs +++ b/crates/canvas/tests/custom_tags.rs @@ -67,11 +67,14 @@ fn custom_tags_store_what_customize_tags_stores() { .into() }; let list = list(); - for (paragraph, (text, places)) in TAGGED.iter().enumerate() { + for (paragraph, (text, _)) in TAGGED.iter().enumerate() { if paragraph > 0 { editor.enter(&mut engine, false).unwrap(); } editor.insert(&mut engine, text).unwrap(); + } + // Tagged once typed, as Enter would continue a check box. + for (paragraph, (_, places)) in TAGGED.iter().enumerate() { editor.select(at(paragraph)).unwrap(); for &place in *places { let tag = list[usize::from(place)].clone(); @@ -82,10 +85,6 @@ fn custom_tags_store_what_customize_tags_stores() { if paragraph == 2 { editor.format(&mut engine, Formatting::Check).unwrap(); } - let offset = text.len() as u32; - editor - .select([TextPosition { paragraph, offset }; 2].into()) - .unwrap(); } let arena = onestore::Arena::default(); let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); diff --git a/crates/canvas/tests/structural_roundtrip.rs b/crates/canvas/tests/structural_roundtrip.rs index d73a64fe8a7cbc22c07bf66bbf970eb4b080242f..a518ae0cdc6f769d47f17a58bb9d7e02d736248d 100644 --- a/crates/canvas/tests/structural_roundtrip.rs +++ b/crates/canvas/tests/structural_roundtrip.rs @@ -123,6 +123,43 @@ fn apply( } /// Every paragraph on the page, cells' included, by identity. +/// `page` with its tags' places in their elements' property arenas, which a read assigns, +/// cleared. +fn settled(mut page: Page) -> Page { + fn walk(list: &mut [PageParagraph]) { + for paragraph in list { + for tag in &mut paragraph.tags { + tag.extra_set = 0; + } + match &mut paragraph.content { + ParagraphContent::Text(text) => { + for tag in &mut text.tags { + tag.extra_set = 0; + } + } + ParagraphContent::Table(table) => { + for cell in table.rows.iter_mut().flat_map(|row| &mut row.cells) { + walk(&mut cell.paragraphs); + } + } + _ => {} + } + } + } + for object in &mut page.objects { + match object { + PageObject::Outline(outline) => walk(&mut outline.paragraphs), + PageObject::Title(title) => { + for outline in &mut title.outlines { + walk(&mut outline.paragraphs); + } + } + _ => {} + } + } + page +} + fn paragraphs(page: &Page) -> BTreeMap { fn walk(list: &[PageParagraph], out: &mut BTreeMap) { for paragraph in list { @@ -346,10 +383,10 @@ fn sweep( } // An untitled page's title follows its first line as the writer stores it. let rereads = |reread: &Page| { - Page { + settled(Page { title: after.title.clone(), ..reread.clone() - } == after + }) == settled(after.clone()) }; let arena = Arena::default(); let mut stored = Section::open(&arena, section.clone()).unwrap(); @@ -502,11 +539,11 @@ fn edit(section: &mut Vec, title: &str, keys: &[(Caret, Press)], engine: &mu .find(|(id, _)| *id == space) .unwrap(); assert_eq!( - Page { + settled(Page { title: after.title.clone(), ..reread.clone() - }, - after, + }), + settled(after), "{title}" ); *section = saved; diff --git a/crates/canvas/tests/to_do_list.rs b/crates/canvas/tests/to_do_list.rs index f64e5eb08eb02e2e78d2213eba745dcb6bda36ee..372e23c6774f911ef3ba4a07d6a0a5d84882a63c 100644 --- a/crates/canvas/tests/to_do_list.rs +++ b/crates/canvas/tests/to_do_list.rs @@ -104,16 +104,135 @@ fn lists_become_to_do_lists_and_back_one_revision_each() { let written = section.image(); let reread = Section::open(&arena, written.clone()).unwrap(); assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected); - if let Some(directory) = std::env::var_os("SNOWBOUND_TO_DO_LIST_EXPORT") { - let directory = std::path::PathBuf::from(directory); - std::fs::create_dir(&directory).unwrap(); - std::fs::write(directory.join("lists.one"), &written).unwrap(); - let file_id = Store::parse(&written).unwrap().header.file_id; - std::fs::write( - directory.join("Open Notebook.onetoc2"), - onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)]) - .unwrap(), + export("SNOWBOUND_TO_DO_LIST_EXPORT", &written); +} + +/// Writes section `written` and a table of contents to the new directory `variable` names. +fn export(variable: &str, written: &[u8]) { + let Some(directory) = std::env::var_os(variable) else { + return; + }; + let directory = std::path::PathBuf::from(directory); + std::fs::create_dir(&directory).unwrap(); + std::fs::write(directory.join("lists.one"), written).unwrap(); + let file_id = Store::parse(written).unwrap().header.file_id; + std::fs::write( + directory.join("Open Notebook.onetoc2"), + onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)]) + .unwrap(), + ) + .unwrap(); +} + +/// Each top-level paragraph's text and its tags' check states. +fn checks(page: &Page) -> Vec<(String, Vec)> { + page.objects + .iter() + .find_map(|object| match object { + PageObject::Outline(outline) => Some(&outline.paragraphs), + _ => None, + }) + .unwrap() + .iter() + .filter(|node| node.level == 1) + .map(|node| { + let text = node.text().unwrap(); + let checked = text.tags.iter().map(|tag| tag.status & 1 == 1).collect(); + (text.text.text().to_owned(), checked) + }) + .collect() +} + +/// Enter after a checked To Do opens an unchecked one, and Enter on that empty item ends the +/// list, each Enter one undo step and one revision. `SNOWBOUND_TO_DO_ENTER_EXPORT` names a new +/// directory receiving the candidate for a cold reopen. +#[test] +fn enter_continues_a_to_do_list_and_twice_ends_it() { + let arena = Arena::default(); + let mut section = Section::open(&arena, LISTS.to_vec()).unwrap(); + let space = section.pages().unwrap()[0].0; + let page = section.page(space).unwrap(); + let mut engine = TextEngine::default(); + let mut editor = CanvasEditor::from_page(page.clone(), &mut engine).unwrap(); + editor.focus_outline(body(&page)).unwrap(); + let mut at_time = 134_000_000_000_000_000; + let mut publish = |editor: &mut CanvasEditor| { + let ops = editor + .take_ops() + .unwrap() + .into_iter() + .map(|op| Op::Page { space, op }) + .collect::>(); + at_time += 10_000_000; + section.apply("Author", &Edit { at: at_time, ops }).unwrap(); + }; + let end = |paragraph, offset| [TextPosition { paragraph, offset }; 2].into(); + editor + .select( + [ + at(0), + TextPosition { + paragraph: 1, + offset: 14, + }, + ] + .into(), ) .unwrap(); + editor + .format( + &mut engine, + Formatting::ToDoList(NoteTag::defaults()[0].clone(), 0), + ) + .unwrap(); + editor.select(end(0, 0)).unwrap(); + editor.format(&mut engine, Formatting::Check).unwrap(); + publish(&mut editor); + + editor.select(end(0, 11)).unwrap(); + editor.enter(&mut engine, false).unwrap(); + publish(&mut editor); + editor.insert(&mut engine, "Next task").unwrap(); + publish(&mut editor); + editor.select(end(2, 14)).unwrap(); + for _ in 0..2 { + editor.enter(&mut engine, false).unwrap(); + publish(&mut editor); + } + editor.insert(&mut engine, "After the list").unwrap(); + publish(&mut editor); + + let expected = [ + ("Bullet item", vec![true]), + ("Next task", vec![false]), + ("First numbered", vec![false]), + ("", vec![]), + ("After the list", vec![]), + ("Second numbered", vec![]), + ("Restarted at three", vec![]), + ("Plain again", vec![]), + ] + .map(|(text, checked)| (text.to_owned(), checked)); + assert_eq!(checks(§ion.page(space).unwrap()), expected); + section.seal().unwrap(); + let written = section.image(); + let reread = Section::open(&arena, written.clone()).unwrap(); + assert_eq!(checks(&reread.page(space).unwrap()), expected); + for _ in 0..3 { + assert!(editor.undo(&mut engine).unwrap()); } + assert_eq!( + checks(&editor.page().unwrap()) + .iter() + .map(|(text, _)| text.as_str()) + .take(4) + .collect::>(), + [ + "Bullet item", + "Next task", + "First numbered", + "Second numbered" + ] + ); + export("SNOWBOUND_TO_DO_ENTER_EXPORT", &written); } diff --git a/crates/snowbound/src/tags.rs b/crates/snowbound/src/tags.rs index 2e76efe3e1aaca72cef87c3b02446020eaade5cf..d8e634c704b6a64a9c7d66d4495f8c13a2c88232 100644 --- a/crates/snowbound/src/tags.rs +++ b/crates/snowbound/src/tags.rs @@ -1155,6 +1155,9 @@ mod tests { editor.enter(&mut engine, false).unwrap(); } editor.insert(&mut engine, text).unwrap(); + } + // Tagged once typed, as Enter would continue a check box. + for (paragraph, tag) in list.iter().take(lines.len()).enumerate() { editor .select( [TextPosition { @@ -1164,15 +1167,11 @@ mod tests { .into(), ) .unwrap(); - let formatting = Formatting::Tag(list[paragraph].clone(), paragraph as u16); + let formatting = Formatting::Tag(tag.clone(), paragraph as u16); editor.format(&mut engine, formatting).unwrap(); if paragraph == 2 { editor.format(&mut engine, Formatting::Check).unwrap(); } - let offset = text.len() as u32; - editor - .select([TextPosition { paragraph, offset }; 2].into()) - .unwrap(); } let arena = onestore::Arena::default(); let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); -- 2.54.0