| 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 | 134 | the page's context menu and the palette, trades the selected paragraphs' bullets |
| 135 | 135 | and numbers for the first check box tag in the user's list as one undo step; |
| 136 | 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 | 142 | An outline holding only pictures or files takes a paragraph after them where a |
| 139 | 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 | 3625 | /// Enter as OneNote 2010 does (`evidence/structural-edits`): the new paragraph takes the |
| 3626 | 3626 | /// level, lists and character formatting at the caret but no note tags. At a paragraph's |
| 3627 | 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 | 3631 | fn split(&mut self, engine: &mut TextEngine) -> Result<(), EditorError> { |
| 3630 | 3632 | if let Some(split) = self.across(engine, Self::split)? { |
| 3631 | 3633 | return Ok(split); |
| ... | ... | @@ -3673,14 +3675,22 @@ impl CanvasEditor { |
| 3673 | 3675 | if next.is_none_or(|next| next.lists.is_empty()) { |
| 3674 | 3676 | tail.lists.clear(); |
| 3675 | 3677 | } |
| 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 | } | |
| 3684 | 3694 | } |
| 3685 | 3695 | let caret = TextPosition { |
| 3686 | 3696 | paragraph: start.paragraph + 1, |
crates/canvas/src/editor/evidence.rs+97-4| ... | ... | @@ -260,13 +260,27 @@ fn replay( |
| 260 | 260 | before: &str, |
| 261 | 261 | caret: (usize, Offset), |
| 262 | 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 | 277 | ) -> CanvasEditor { |
| 264 | 278 | let mut editor = open(engine, before); |
| 265 | 279 | place(&mut editor, caret); |
| 266 | 280 | let original = editor.active_outline().document.clone(); |
| 267 | 281 | for (key, after) in steps { |
| 268 | 282 | press(&mut editor, engine, *key); |
| 269 | assert_eq!(render(&editor), summary(after).trim_end(), "{after}"); | |
| 283 | assert_eq!(&render(&editor), after); | |
| 270 | 284 | let outline = editor.active_outline(); |
| 271 | 285 | let fresh = OutlineLayout::flow( |
| 272 | 286 | outline.document.nodes().iter(), |
| ... | ... | @@ -323,9 +337,9 @@ const KINDS: [&str; 9] = [ |
| 323 | 337 | ]; |
| 324 | 338 | |
| 325 | 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 | 341 | let mut engine = TextEngine::default(); |
| 328 | for kind in KINDS { | |
| 342 | for kind in KINDS.into_iter().filter(|kind| !CHECKED.contains(kind)) { | |
| 329 | 343 | let prefix = first_run(kind); |
| 330 | 344 | replay( |
| 331 | 345 | &mut engine, |
| ... | ... | @@ -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 | 486 | #[test] |
| 398 | 487 | fn backspace_removes_the_list_then_outdents_then_joins_and_the_upper_paragraph_wins() { |
| 399 | 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 | 799 | }; |
| 711 | 800 | editor.select([at(4); 2].into()).unwrap(); |
| 712 | 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 | 807 | editor.delete(&mut engine, true).unwrap(); |
| 715 | 808 | assert_eq!(render(&editor), summary("c9-multitag-2").trim_end()); |
| 716 | 809 | editor.select([at(0); 2].into()).unwrap(); |
crates/canvas/src/editor/format.rs+39-9| ... | ... | @@ -1329,26 +1329,56 @@ impl CanvasEditor { |
| 1329 | 1329 | /// Checks the check boxes among `tags`, or clears them once all are checked; OneNote keeps |
| 1330 | 1330 | /// a cleared box's completion time as zero. |
| 1331 | 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 | 1332 | let checked = tags |
| 1340 | 1333 | .iter() |
| 1341 | .filter(|tag| checkable(tag)) | |
| 1334 | .filter(|tag| self.check_box(tag).is_some()) | |
| 1342 | 1335 | .all(|tag| tag.status & 1 != 0); |
| 1343 | 1336 | let completed = if checked { Some(0) } else { time32() }; |
| 1344 | 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 | 1339 | tag.status ^= 1; |
| 1347 | 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 | 1382 | /// Checks the check boxes of the `covered` paragraphs, or clears them once all are |
| 1353 | 1383 | /// checked. |
| 1354 | 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 | 67 | .into() |
| 68 | 68 | }; |
| 69 | 69 | let list = list(); |
| 70 | for (paragraph, (text, places)) in TAGGED.iter().enumerate() { | |
| 70 | for (paragraph, (text, _)) in TAGGED.iter().enumerate() { | |
| 71 | 71 | if paragraph > 0 { |
| 72 | 72 | editor.enter(&mut engine, false).unwrap(); |
| 73 | 73 | } |
| 74 | 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 | 78 | editor.select(at(paragraph)).unwrap(); |
| 76 | 79 | for &place in *places { |
| 77 | 80 | let tag = list[usize::from(place)].clone(); |
| ... | ... | @@ -82,10 +85,6 @@ fn custom_tags_store_what_customize_tags_stores() { |
| 82 | 85 | if paragraph == 2 { |
| 83 | 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 | 89 | let arena = onestore::Arena::default(); |
| 91 | 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 | 123 | } |
| 124 | 124 | |
| 125 | 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 | 163 | fn paragraphs(page: &Page) -> BTreeMap<ExGuid, PageParagraph> { |
| 127 | 164 | fn walk(list: &[PageParagraph], out: &mut BTreeMap<ExGuid, PageParagraph>) { |
| 128 | 165 | for paragraph in list { |
| ... | ... | @@ -346,10 +383,10 @@ fn sweep( |
| 346 | 383 | } |
| 347 | 384 | // An untitled page's title follows its first line as the writer stores it. |
| 348 | 385 | let rereads = |reread: &Page| { |
| 349 | Page { | |
| 386 | settled(Page { | |
| 350 | 387 | title: after.title.clone(), |
| 351 | 388 | ..reread.clone() |
| 352 | } == after | |
| 389 | }) == settled(after.clone()) | |
| 353 | 390 | }; |
| 354 | 391 | let arena = Arena::default(); |
| 355 | 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 | 539 | .find(|(id, _)| *id == space) |
| 503 | 540 | .unwrap(); |
| 504 | 541 | assert_eq!( |
| 505 | Page { | |
| 542 | settled(Page { | |
| 506 | 543 | title: after.title.clone(), |
| 507 | 544 | ..reread.clone() |
| 508 | }, | |
| 509 | after, | |
| 545 | }), | |
| 546 | settled(after), | |
| 510 | 547 | "{title}" |
| 511 | 548 | ); |
| 512 | 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 | 104 | let written = section.image(); |
| 105 | 105 | let reread = Section::open(&arena, written.clone()).unwrap(); |
| 106 | 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") { | |
| 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. | |
| 111 | fn 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. | |
| 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 | 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 | 1155 | editor.enter(&mut engine, false).unwrap(); |
| 1156 | 1156 | } |
| 1157 | 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 | 1161 | editor |
| 1159 | 1162 | .select( |
| 1160 | 1163 | [TextPosition { |
| ... | ... | @@ -1164,15 +1167,11 @@ mod tests { |
| 1164 | 1167 | .into(), |
| 1165 | 1168 | ) |
| 1166 | 1169 | .unwrap(); |
| 1167 | let formatting = Formatting::Tag(list[paragraph].clone(), paragraph as u16); | |
| 1170 | let formatting = Formatting::Tag(tag.clone(), paragraph as u16); | |
| 1168 | 1171 | editor.format(&mut engine, formatting).unwrap(); |
| 1169 | 1172 | if paragraph == 2 { |
| 1170 | 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 | 1176 | let arena = onestore::Arena::default(); |
| 1178 | 1177 | let mut section = onestore::Section::open(&arena, source.to_vec()).unwrap(); |