| author | |
| committer | |
| log | bb9f2fba980bc24c51881194b08afaa2ef2f0bc3 |
| tree | f50935948fc0fac37287f24a616035f86fc43f04 |
| parent | 3dade15eef573afab71c86c6ae88e452c7da8176 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
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.58 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 |
| 134 | the page's context menu and the palette, trades the selected paragraphs' bullets | 134 | the page's context menu and the palette, trades the selected paragraphs' bullets |
| 135 | and numbers for the first check box tag in the user's list as one undo step; | 135 | and numbers for the first check box tag in the user's list as one undo step; |
| 136 | Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`). | 136 | Make Bulleted List trades the tag back for a bullet (`corpus/to-do-list`). |
| 137 | OneNote's Enter never carries a tag, and on an empty tagged paragraph it opens a | ||
| 138 | plain one above (`corpus/structural-probe`). Snowbound's Enter continues a to-do | ||
| 139 | list as a list continues: the new paragraph takes an unchecked copy of each check | ||
| 140 | box, and Enter on an empty item drops them. | ||
| 137 | 141 | ||
| 138 | An outline holding only pictures or files takes a paragraph after them where a | 142 | An outline holding only pictures or files takes a paragraph after them where a |
| 139 | click beside them lands, as OneNote 2010 adds one when typing there. It is | 143 | click 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 the | 3625 | /// 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's | 3626 | /// 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 and | 3627 | /// 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) = following | 3679 | } 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. | ||
| 272 | fn 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 | ]; |
| 324 | 338 | ||
| 325 | #[test] | 339 | #[test] |
| 326 | fn enter_carries_level_lists_and_children_but_never_tags() { | 340 | fn 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 | } |
| 396 | 410 | ||
| 411 | /// The kinds with a To Do box, whose Enter departs from OneNote 2010's. | ||
| 412 | const 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] | ||
| 417 | fn 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] |
| 398 | fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() { | 487 | fn 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 keeps | 1329 | /// 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 = tags | 1332 | 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 | } |
| 1351 | 1344 | ||
| 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 are | 1382 | /// 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 | } |
| 124 | 124 | ||
| 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. | ||
| 128 | fn 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 | |||
| 126 | fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> { | 163 | fn 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 | } == after | 389 | }) == 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; | 111 | fn 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. | ||
| 128 | fn 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] | ||
| 150 | fn 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 | editor | 1161 | 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(); |