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...@@ -134,6 +134,10 @@ new To Do box, and it has no command that converts a list. Make To-Do List, in
134the page's context menu and the palette, trades the selected paragraphs' bullets134the page's context menu and the palette, trades the selected paragraphs' bullets
135and numbers for the first check box tag in the user's list as one undo step;135and numbers for the first check box tag in the user's list as one undo step;
136Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`).136Make 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
138An outline holding only pictures or files takes a paragraph after them where a142An outline holding only pictures or files takes a paragraph after them where a
139click beside them lands, as OneNote 2010 adds one when typing there. It is143click 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 {...@@ -3625,7 +3625,9 @@ impl CanvasEditor {
3625 /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the3625 /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the
3626 /// level, lists and character formatting at the caret but no note tags. At a paragraph's3626 /// level, lists and character formatting at the caret but no note tags. At a paragraph's
3627 /// start its tags stay with its text below; an empty paragraph opens a plain one above and3627 /// 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.
3629 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {3631 fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> {
3630 if let Some(split) = self.across(engine, Self::split)? {3632 if let Some(split) = self.across(engine, Self::split)? {
3631 return Ok(split);3633 return Ok(split);
...@@ -3673,14 +3675,22 @@ impl CanvasEditor {...@@ -3673,14 +3675,22 @@ impl CanvasEditor {
3673 if next.is_none_or(|next| next.lists.is_empty()) {3675 if next.is_none_or(|next| next.lists.is_empty()) {
3674 tail.lists.clear();3676 tail.lists.clear();
3675 }3677 }
3676 } else if empty(tail)3678 self.end_checks(tail);
3677 && let Some(following) = following3679 } else {
3678 {3680 if start.offset == 0 {
3679 tail.style = self.style_named(&following)?;3681 self.continue_checks(tail, head);
3680 // The heading's run formatting carries over under the body style (`c9-h1-*`).3682 } else {
3681 let run = format.over(&self.style_format(head.style)?);3683 self.continue_checks(head, tail);
3682 tail.text_mut().unwrap().text =3684 }
3683 Paragraph::new(String::new(), run.inherit(&self.style_format(tail.style)?));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 }
3684 }3694 }
3685 let caret = TextPosition {3695 let caret = TextPosition {
3686 paragraph: start.paragraph + 1,3696 paragraph: start.paragraph + 1,
crates/canvas/src/editor/evidence.rs+97-4
...@@ -260,13 +260,27 @@ fn replay(...@@ -260,13 +260,27 @@ fn replay(
260 before: &str,260 before: &str,
261 caret: (usize, Offset),261 caret: (usize, Offset),
262 steps: &[(Key, &str)],262 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)],
263) -> CanvasEditor {277) -> CanvasEditor {
264 let mut editor = open(engine, before);278 let mut editor = open(engine, before);
265 place(&mut editor, caret);279 place(&mut editor, caret);
266 let original = editor.active_outline().document.clone();280 let original = editor.active_outline().document.clone();
267 for (key, after) in steps {281 for (key, after) in steps {
268 press(&mut editor, engine, *key);282 press(&mut editor, engine, *key);
269 assert_eq!(render(&editor), summary(after).trim_end(), "{after}");283 assert_eq!(&render(&editor), after);
270 let outline = editor.active_outline();284 let outline = editor.active_outline();
271 let fresh = OutlineLayout::flow(285 let fresh = OutlineLayout::flow(
272 outline.document.nodes().iter(),286 outline.document.nodes().iter(),
...@@ -323,9 +337,9 @@ const KINDS: [&str; 9] = [...@@ -323,9 +337,9 @@ const KINDS: [&str; 9] = [
323];337];
324338
325#[test]339#[test]
326fn enter_carries_level_lists_and_children_but_never_tags() {340fn enter_carries_level_lists_and_children_but_no_other_tags() {
327 let mut engine = TextEngine::default();341 let mut engine = TextEngine::default();
328 for kind in KINDS {342 for kind in KINDS.into_iter().filter(|kind| !CHECKED.contains(kind)) {
329 let prefix = first_run(kind);343 let prefix = first_run(kind);
330 replay(344 replay(
331 &mut engine,345 &mut engine,
...@@ -394,6 +408,81 @@ fn enter_carries_level_lists_and_children_but_never_tags() {...@@ -394,6 +408,81 @@ fn enter_carries_level_lists_and_children_but_never_tags() {
394 );408 );
395}409}
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
397#[test]486#[test]
398fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {487fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() {
399 let mut engine = TextEngine::default();488 let mut engine = TextEngine::default();
...@@ -710,7 +799,11 @@ fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {...@@ -710,7 +799,11 @@ fn a_split_keeps_every_tag_above_and_a_join_takes_the_upper_tags() {
710 };799 };
711 editor.select([at(4); 2].into()).unwrap();800 editor.select([at(4); 2].into()).unwrap();
712 editor.enter(&mut engine, false).unwrap();801 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 );
714 editor.delete(&mut engine, true).unwrap();807 editor.delete(&mut engine, true).unwrap();
715 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());808 assert_eq!(render(&editor), summary("c9-multitag-2").trim_end());
716 editor.select([at(0); 2].into()).unwrap();809 editor.select([at(0); 2].into()).unwrap();
crates/canvas/src/editor/format.rs+39-9
...@@ -1329,26 +1329,56 @@ impl CanvasEditor {...@@ -1329,26 +1329,56 @@ impl CanvasEditor {
1329 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps1329 /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps
1330 /// a cleared box's completion time as zero.1330 /// a cleared box's completion time as zero.
1331 fn toggle_checks(&self, mut tags: Vec<&mut Tag>) {1331 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 };
1339 let checked = tags1332 let checked = tags
1340 .iter()1333 .iter()
1341 .filter(|tag| checkable(tag))1334 .filter(|tag| self.check_box(tag).is_some())
1342 .all(|tag| tag.status & 1 != 0);1335 .all(|tag| tag.status & 1 != 0);
1343 let completed = if checked { Some(0) } else { time32() };1336 let completed = if checked { Some(0) } else { time32() };
1344 for tag in &mut tags {1337 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 {
1346 tag.status ^= 1;1339 tag.status ^= 1;
1347 tag.completed = completed;1340 tag.completed = completed;
1348 }1341 }
1349 }1342 }
1350 }1343 }
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
1352 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are1382 /// Checks the check boxes of the `covered` paragraphs, or clears them once all are
1353 /// checked.1383 /// checked.
1354 fn check(&self, replacement: &mut [PageParagraph], covered: &BTreeMap<ExGuid, Range<usize>>) {1384 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() {...@@ -67,11 +67,14 @@ fn custom_tags_store_what_customize_tags_stores() {
67 .into()67 .into()
68 };68 };
69 let list = list();69 let list = list();
70 for (paragraph, (text, places)) in TAGGED.iter().enumerate() {70 for (paragraph, (text, _)) in TAGGED.iter().enumerate() {
71 if paragraph > 0 {71 if paragraph > 0 {
72 editor.enter(&mut engine, false).unwrap();72 editor.enter(&mut engine, false).unwrap();
73 }73 }
74 editor.insert(&mut engine, text).unwrap();74 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() {
75 editor.select(at(paragraph)).unwrap();78 editor.select(at(paragraph)).unwrap();
76 for &place in *places {79 for &place in *places {
77 let tag = list[usize::from(place)].clone();80 let tag = list[usize::from(place)].clone();
...@@ -82,10 +85,6 @@ fn custom_tags_store_what_customize_tags_stores() {...@@ -82,10 +85,6 @@ fn custom_tags_store_what_customize_tags_stores() {
82 if paragraph == 2 {85 if paragraph == 2 {
83 editor.format(&mut engine, Formatting::Check).unwrap();86 editor.format(&mut engine, Formatting::Check).unwrap();
84 }87 }
85 let offset = text.len() as u32;
86 editor
87 .select([TextPosition { paragraph, offset }; 2].into())
88 .unwrap();
89 }88 }
90 let arena = onestore::Arena::default();89 let arena = onestore::Arena::default();
91 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();90 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(...@@ -123,6 +123,43 @@ fn apply(
123}123}
124124
125/// Every paragraph on the page, cells' included, by identity.125/// 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
126fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> {163fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> {
127 fn walk(list: &[PageParagraph], out: &mut BTreeMap<ExGuid, PageParagraph>) {164 fn walk(list: &[PageParagraph], out: &mut BTreeMap<ExGuid, PageParagraph>) {
128 for paragraph in list {165 for paragraph in list {
...@@ -346,10 +383,10 @@ fn sweep(...@@ -346,10 +383,10 @@ fn sweep(
346 }383 }
347 // An untitled page's title follows its first line as the writer stores it.384 // An untitled page's title follows its first line as the writer stores it.
348 let rereads = |reread: &Page| {385 let rereads = |reread: &Page| {
349 Page {386 settled(Page {
350 title: after.title.clone(),387 title: after.title.clone(),
351 ..reread.clone()388 ..reread.clone()
352 } == after389 }) == settled(after.clone())
353 };390 };
354 let arena = Arena::default();391 let arena = Arena::default();
355 let mut stored = Section::open(&arena, section.clone()).unwrap();392 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...@@ -502,11 +539,11 @@ fn edit(section: &mut Vec<u8>, title: &str, keys: &[(Caret, Press)], engine: &mu
502 .find(|(id, _)| *id == space)539 .find(|(id, _)| *id == space)
503 .unwrap();540 .unwrap();
504 assert_eq!(541 assert_eq!(
505 Page {542 settled(Page {
506 title: after.title.clone(),543 title: after.title.clone(),
507 ..reread.clone()544 ..reread.clone()
508 },545 }),
509 after,546 settled(after),
510 "{title}"547 "{title}"
511 );548 );
512 *section = saved;549 *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() {...@@ -104,16 +104,135 @@ fn lists_become_to_do_lists_and_back_one_revision_each() {
104 let written = section.image();104 let written = section.image();
105 let reread = Section::open(&arena, written.clone()).unwrap();105 let reread = Section::open(&arena, written.clone()).unwrap();
106 assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected);106 assert_eq!(items(&reread.page(space).unwrap(), &mut engine), expected);
107 if let Some(directory) = std::env::var_os("SNOWBOUND_TO_DO_LIST_EXPORT") {107 export("SNOWBOUND_TO_DO_LIST_EXPORT", &written);
108 let directory = std::path::PathBuf::from(directory);108}
109 std::fs::create_dir(&directory).unwrap();109
110 std::fs::write(directory.join("lists.one"), &written).unwrap();110/// Writes section `written` and a table of contents to the new directory `variable` names.
111 let file_id = Store::parse(&written).unwrap().header.file_id;111fn export(variable: &str, written: &[u8]) {
112 std::fs::write(112 let Some(directory) = std::env::var_os(variable) else {
113 directory.join("Open Notebook.onetoc2"),113 return;
114 onestore::create_table_of_contents("Open Notebook.onetoc2", &[("lists.one", file_id)])114 };
115 .unwrap(),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(),
116 )180 )
117 .unwrap();181 .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());
118 }223 }
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);
119}238}
crates/snowbound/src/tags.rs+4-5
...@@ -1155,6 +1155,9 @@ mod tests {...@@ -1155,6 +1155,9 @@ mod tests {
1155 editor.enter(&mut engine, false).unwrap();1155 editor.enter(&mut engine, false).unwrap();
1156 }1156 }
1157 editor.insert(&mut engine, text).unwrap();1157 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() {
1158 editor1161 editor
1159 .select(1162 .select(
1160 [TextPosition {1163 [TextPosition {
...@@ -1164,15 +1167,11 @@ mod tests {...@@ -1164,15 +1167,11 @@ mod tests {
1164 .into(),1167 .into(),
1165 )1168 )
1166 .unwrap();1169 .unwrap();
1167 let formatting = Formatting::Tag(list[paragraph].clone(), paragraph as u16);1170 let formatting = Formatting::Tag(tag.clone(), paragraph as u16);
1168 editor.format(&mut engine, formatting).unwrap();1171 editor.format(&mut engine, formatting).unwrap();
1169 if paragraph == 2 {1172 if paragraph == 2 {
1170 editor.format(&mut engine, Formatting::Check).unwrap();1173 editor.format(&mut engine, Formatting::Check).unwrap();
1171 }1174 }
1172 let offset = text.len() as u32;
1173 editor
1174 .select([TextPosition { paragraph, offset }; 2].into())
1175 .unwrap();
1176 }1175 }
1177 let arena = onestore::Arena::default();1176 let arena = onestore::Arena::default();
1178 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();1177 let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap();