| author | |
| committer | |
| log | 147a2af4881d7bbc1d857cf57d191ba94c0522ca |
| tree | ec2f2ddd5b69a58112dfb39c3cea85855635d765 |
| parent | c21fb63b8d789060e2ff38951cd910a734696ca7 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover) |
Refs #123, #129
Assisted-by: gpt-6.1-sol5 files changed, 534 insertions(+), 44 deletions(-)
crates/snowbound/src/commands.rs+47-8| ... | ... | @@ -1079,6 +1079,43 @@ impl State { |
| 1079 | 1079 | self.window.request_redraw(); |
| 1080 | 1080 | } |
| 1081 | 1081 | |
| 1082 | pub(crate) fn choose_input(&mut self, choice: Choice) { | |
| 1083 | #[cfg(target_os = "macos")] | |
| 1084 | if !platform::commands_allowed(&choice) { | |
| 1085 | return; | |
| 1086 | } | |
| 1087 | let id = match choice { | |
| 1088 | Choice::Command(id @ (Id::Find | Id::NewPage | Id::NewSubpage)) => id, | |
| 1089 | choice => { | |
| 1090 | self.choose(choice); | |
| 1091 | return; | |
| 1092 | } | |
| 1093 | }; | |
| 1094 | let result = (|| -> Result<(), Box<dyn Error>> { | |
| 1095 | let size = self.window.inner_size(); | |
| 1096 | let size = [size.width, size.height] | |
| 1097 | .map(|side| side as f32 / self.window.scale_factor() as f32); | |
| 1098 | let scale = self.scale(); | |
| 1099 | self.layout(size, scale)?; | |
| 1100 | while !self.commands.is_empty() { | |
| 1101 | let mut commands = std::mem::take(&mut self.commands).into_iter(); | |
| 1102 | while let Some(command) = commands.next() { | |
| 1103 | if let Err(error) = self.apply(command) { | |
| 1104 | self.commands = | |
| 1105 | commands.chain(std::mem::take(&mut self.commands)).collect(); | |
| 1106 | return Err(error); | |
| 1107 | } | |
| 1108 | } | |
| 1109 | } | |
| 1110 | self.run(Choice::Command(id))?; | |
| 1111 | self.layout(size, scale) | |
| 1112 | })(); | |
| 1113 | if let Err(error) = result { | |
| 1114 | platform::alert(command(id).title, &crate::plain(&*error, "page")); | |
| 1115 | } | |
| 1116 | self.window.request_redraw(); | |
| 1117 | } | |
| 1118 | ||
| 1082 | 1119 | /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord, |
| 1083 | 1120 | /// ⌘K, to the open palette's actions. |
| 1084 | 1121 | pub(crate) fn statuses(&self) -> Vec<Status> { |
| ... | ... | @@ -1508,14 +1545,16 @@ impl State { |
| 1508 | 1545 | }, |
| 1509 | 1546 | ) |
| 1510 | 1547 | } |
| 1511 | Id::NewPage => Work::NewPage { | |
| 1512 | under: None, | |
| 1513 | title: String::new(), | |
| 1514 | }, | |
| 1515 | Id::NewSubpage => Work::NewPage { | |
| 1516 | under: self.session.as_ref().map(|session| session.space), | |
| 1517 | title: String::new(), | |
| 1518 | }, | |
| 1548 | Id::NewPage | Id::NewSubpage => { | |
| 1549 | let under = self | |
| 1550 | .session | |
| 1551 | .as_ref() | |
| 1552 | .filter(|_| id == Id::NewSubpage) | |
| 1553 | .map(|session| session.space); | |
| 1554 | self.new_page(under, "")?; | |
| 1555 | self.edited(Vec::new()); | |
| 1556 | return Ok(()); | |
| 1557 | } | |
| 1519 | 1558 | Id::PageVersions => { |
| 1520 | 1559 | let session = self.session.as_ref().ok_or("No notebook is open")?; |
| 1521 | 1560 | Work::History { |
crates/snowbound/src/main.rs+51-26| ... | ... | @@ -390,6 +390,7 @@ impl std::task::Wake for Redraw { |
| 390 | 390 | #[derive(Debug)] |
| 391 | 391 | enum Replay { |
| 392 | 392 | Input(ui::Event), |
| 393 | Inputs(Vec<ui::Event>), | |
| 393 | 394 | Access(accesskit::Action, String), |
| 394 | 395 | /// A trackpad pinch scaling the page by the factor. |
| 395 | 396 | Pinch(f32), |
| ... | ... | @@ -695,8 +696,7 @@ enum Laid { |
| 695 | 696 | Library(Arc<Library>, Option<String>), |
| 696 | 697 | } |
| 697 | 698 | |
| 698 | /// The newest page read, laid out and waiting for the pictures it shows first, so it | |
| 699 | /// never appears without them. | |
| 699 | /// A page ready for adoption, with the scene and editor laid out together. | |
| 700 | 700 | struct Opening { |
| 701 | 701 | loaded: Shown, |
| 702 | 702 | scene: (PageScene, [f32; 2]), |
| ... | ... | @@ -934,8 +934,6 @@ struct State { |
| 934 | 934 | drag: Option<menus::Drag>, |
| 935 | 935 | /// Pages whose template strip was dismissed this run. |
| 936 | 936 | dismissed: HashSet<ExGuid>, |
| 937 | /// A page just created, whose title takes the caret once it opens. | |
| 938 | title_focus: Option<ExGuid>, | |
| 939 | 937 | /// The page colour a menu previews over the open page's own this frame. |
| 940 | 938 | page_color_preview: Option<Option<u32>>, |
| 941 | 939 | media: recording::Media, |
| ... | ... | @@ -1347,7 +1345,6 @@ impl State { |
| 1347 | 1345 | sidebar: stored.sidebar, |
| 1348 | 1346 | folded: HashSet::new(), |
| 1349 | 1347 | dismissed: HashSet::new(), |
| 1350 | title_focus: None, | |
| 1351 | 1348 | menu: None, |
| 1352 | 1349 | sync_notebook: None, |
| 1353 | 1350 | actions: None, |
| ... | ... | @@ -3847,6 +3844,10 @@ impl State { |
| 3847 | 3844 | return Ok(()); |
| 3848 | 3845 | } |
| 3849 | 3846 | lap("switch pictures", opening.since); |
| 3847 | self.show_page(opening) | |
| 3848 | } | |
| 3849 | ||
| 3850 | fn show_page(&mut self, mut opening: Opening) -> Result<(), Box<dyn Error>> { | |
| 3850 | 3851 | let requested = opening.requested; |
| 3851 | 3852 | if let Shown::Section(session) | Shown::Created(session) = &mut opening.loaded |
| 3852 | 3853 | && let Some(library) = self |
| ... | ... | @@ -3931,6 +3932,7 @@ impl State { |
| 3931 | 3932 | } |
| 3932 | 3933 | Shown::Page(space) | Shown::Version(space, _) => { |
| 3933 | 3934 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 3935 | let pages = session.section.pages()?; | |
| 3934 | 3936 | session.space = space; |
| 3935 | 3937 | session.version = match opening.loaded { |
| 3936 | 3938 | Shown::Version(_, version) => Some(version), |
| ... | ... | @@ -3938,7 +3940,7 @@ impl State { |
| 3938 | 3940 | }; |
| 3939 | 3941 | session.change = None; |
| 3940 | 3942 | // Pages created, moved or deleted since the list was read. |
| 3941 | session.pages = session.section.pages()?; | |
| 3943 | session.pages = pages; | |
| 3942 | 3944 | leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left)); |
| 3943 | 3945 | } |
| 3944 | 3946 | } |
| ... | ... | @@ -3978,21 +3980,6 @@ impl State { |
| 3978 | 3980 | self.opened()?; |
| 3979 | 3981 | self.prefetch_around(); |
| 3980 | 3982 | self.refind(true)?; |
| 3981 | if self.title_focus.take().is_some_and(|space| { | |
| 3982 | self.session | |
| 3983 | .as_ref() | |
| 3984 | .is_some_and(|session| session.space == space) | |
| 3985 | }) && let Some(title) = self | |
| 3986 | .view | |
| 3987 | .editor | |
| 3988 | .outlines() | |
| 3989 | .iter() | |
| 3990 | .find(|outline| outline.title) | |
| 3991 | { | |
| 3992 | self.view.editor.focus_outline(title.id)?; | |
| 3993 | let response = self.view.focus_text()?; | |
| 3994 | self.respond(response); | |
| 3995 | } | |
| 3996 | 3983 | if created && let Some(session) = &self.session { |
| 3997 | 3984 | let target = rename::Target::Entry { |
| 3998 | 3985 | library: Arc::clone(&session.library), |
| ... | ... | @@ -4913,7 +4900,7 @@ impl State { |
| 4913 | 4900 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) |
| 4914 | 4901 | && !(id == commands::Id::Link && self.ui.popup_open(palette::id())) |
| 4915 | 4902 | { |
| 4916 | self.choose(commands::Choice::Command(id)); | |
| 4903 | self.choose_input(commands::Choice::Command(id)); | |
| 4917 | 4904 | } else { |
| 4918 | 4905 | self.ui.event(event); |
| 4919 | 4906 | } |
| ... | ... | @@ -6385,7 +6372,7 @@ impl State { |
| 6385 | 6372 | eprintln!("{error}"); |
| 6386 | 6373 | } |
| 6387 | 6374 | } |
| 6388 | UserEvent::Choose(choice) => self.choose(choice), | |
| 6375 | UserEvent::Choose(choice) => self.choose_input(choice), | |
| 6389 | 6376 | UserEvent::Appearance => { |
| 6390 | 6377 | self.follow_color_scheme(); |
| 6391 | 6378 | self.window.request_redraw(); |
| ... | ... | @@ -6402,6 +6389,14 @@ impl State { |
| 6402 | 6389 | }; |
| 6403 | 6390 | match replay { |
| 6404 | 6391 | Replay::Input(event) => self.input(event), |
| 6392 | Replay::Inputs(events) => { | |
| 6393 | for mut event in events { | |
| 6394 | if let ui::Event::Button { at, .. } = &mut event { | |
| 6395 | *at = Instant::now(); | |
| 6396 | } | |
| 6397 | self.input(event); | |
| 6398 | } | |
| 6399 | } | |
| 6405 | 6400 | Replay::Access(action, label) => { |
| 6406 | 6401 | let tree = self.ui.accessibility_tree("", self.window.scale_factor()); |
| 6407 | 6402 | let mut nodes = tree |
| ... | ... | @@ -6813,12 +6808,26 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 6813 | 6808 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 6814 | 6809 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and |
| 6815 | 6810 | /// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`, |
| 6816 | /// `access click|focus LABEL`, `resize WIDTH HEIGHT` and `quit`. | |
| 6811 | /// `access click|focus LABEL`, `resize WIDTH HEIGHT` and `quit`. `batch` through `end` | |
| 6812 | /// delivers input without intervening frames; `preedit TEXT` sets marked text. | |
| 6817 | 6813 | #[cfg(not(target_arch = "wasm32"))] |
| 6818 | 6814 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 6819 | 6815 | let (settle, settled) = std::sync::mpsc::channel(); |
| 6820 | 6816 | let mut steps = Vec::new(); |
| 6817 | let mut batch = None; | |
| 6821 | 6818 | for line in script.lines().filter(|line| !line.trim().is_empty()) { |
| 6819 | if line == "batch" { | |
| 6820 | if batch.replace(Vec::new()).is_some() { | |
| 6821 | return Err("Replay input batches cannot nest".into()); | |
| 6822 | } | |
| 6823 | continue; | |
| 6824 | } | |
| 6825 | if line == "end" { | |
| 6826 | steps.push(Ok(Replay::Inputs( | |
| 6827 | batch.take().ok_or("No input batch is open")?, | |
| 6828 | ))); | |
| 6829 | continue; | |
| 6830 | } | |
| 6822 | 6831 | let (command, rest) = line.split_once(' ').unwrap_or((line, "")); |
| 6823 | 6832 | let numbers = || -> Result<Vec<f32>, std::num::ParseFloatError> { |
| 6824 | 6833 | rest.split_whitespace().map(str::parse).collect() |
| ... | ... | @@ -6832,7 +6841,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6832 | 6841 | pressed, |
| 6833 | 6842 | at: Instant::now(), |
| 6834 | 6843 | }; |
| 6835 | steps.push(match command { | |
| 6844 | let step = match command { | |
| 6836 | 6845 | "move" => Ok(Replay::Input(ui::Event::PointerMoved( |
| 6837 | 6846 | numbers()?.try_into().map_err(|_| "move takes X Y")?, |
| 6838 | 6847 | ))), |
| ... | ... | @@ -6865,11 +6874,16 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6865 | 6874 | text: None, |
| 6866 | 6875 | })), |
| 6867 | 6876 | "type" => Ok(Replay::Input(ui::Event::Ime(Ime::Commit(rest.into())))), |
| 6877 | "preedit" => Ok(Replay::Input(ui::Event::Ime(Ime::Preedit( | |
| 6878 | rest.into(), | |
| 6879 | Some((rest.len(), rest.len())), | |
| 6880 | )))), | |
| 6868 | 6881 | "modifiers" => Ok(Replay::Input(ui::Event::Modifiers( |
| 6869 | 6882 | rest.split_whitespace() |
| 6870 | 6883 | .map(|name| match name { |
| 6871 | 6884 | "shift" => Ok(ModifiersState::SHIFT), |
| 6872 | 6885 | "control" => Ok(ModifiersState::CONTROL), |
| 6886 | "option" => Ok(ModifiersState::ALT), | |
| 6873 | 6887 | // The shortcut modifier, as ui::edit_modifiers reads it. |
| 6874 | 6888 | "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER), |
| 6875 | 6889 | "command" => Ok(ModifiersState::CONTROL), |
| ... | ... | @@ -6903,7 +6917,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6903 | 6917 | _ => return Err(format!("Unknown appearance {rest}").into()), |
| 6904 | 6918 | })), |
| 6905 | 6919 | _ => return Err(format!("Unknown replay command: {line}").into()), |
| 6906 | }); | |
| 6920 | }; | |
| 6921 | if let Some(batch) = &mut batch { | |
| 6922 | match step { | |
| 6923 | Ok(Replay::Input(event)) => batch.push(event), | |
| 6924 | _ => return Err("A replay input batch accepts only input events".into()), | |
| 6925 | } | |
| 6926 | } else { | |
| 6927 | steps.push(step); | |
| 6928 | } | |
| 6929 | } | |
| 6930 | if batch.is_some() { | |
| 6931 | return Err("Replay input batch is missing end".into()); | |
| 6907 | 6932 | } |
| 6908 | 6933 | std::thread::spawn(move || { |
| 6909 | 6934 | for step in steps { |
crates/snowbound/src/manage.rs+40-9| ... | ... | @@ -219,21 +219,52 @@ impl State { |
| 219 | 219 | )?]))); |
| 220 | 220 | } |
| 221 | 221 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 222 | session.section.apply( | |
| 223 | &self.author, | |
| 224 | Edit { | |
| 225 | at: crate::filetime(), | |
| 226 | ops, | |
| 227 | }, | |
| 228 | )?; | |
| 222 | let edit = Edit { | |
| 223 | at: crate::filetime(), | |
| 224 | ops, | |
| 225 | }; | |
| 226 | #[cfg(not(target_arch = "wasm32"))] | |
| 227 | session.section.replica().apply(&self.author, edit)?; | |
| 228 | #[cfg(target_arch = "wasm32")] | |
| 229 | session.section.apply(&self.author, edit)?; | |
| 229 | 230 | let shown = session.space; |
| 230 | 231 | self.made(Change::Delete { |
| 231 | 232 | pages: vec![space], |
| 232 | 233 | show: Some(shown), |
| 233 | 234 | created: true, |
| 234 | 235 | }); |
| 235 | self.title_focus = Some(space); | |
| 236 | self.commands.push(Command::OpenPage(space)); | |
| 236 | let session = self.session.as_mut().ok_or("No section is open")?; | |
| 237 | session.pages = session.section.pages()?; | |
| 238 | let page = session.section.replica().page(space)?; | |
| 239 | let (scene, editor) = canvas::gpu::page::PageScene::from_page(page, &mut self.view.engine)?; | |
| 240 | let now = crate::Instant::now(); | |
| 241 | let shown = self.show_page(crate::Opening { | |
| 242 | loaded: crate::Shown::Page(space), | |
| 243 | scene: (scene, [0.0; 2]), | |
| 244 | editor, | |
| 245 | since: now, | |
| 246 | requested: now, | |
| 247 | }); | |
| 248 | if self | |
| 249 | .session | |
| 250 | .as_ref() | |
| 251 | .is_some_and(|session| session.space == space) | |
| 252 | { | |
| 253 | self.stop_loading(); | |
| 254 | } | |
| 255 | shown?; | |
| 256 | self.ui.set_focus(Some(crate::page())); | |
| 257 | if let Some(title) = self | |
| 258 | .view | |
| 259 | .editor | |
| 260 | .outlines() | |
| 261 | .iter() | |
| 262 | .find(|outline| outline.title) | |
| 263 | { | |
| 264 | self.view.editor.focus_outline(title.id)?; | |
| 265 | let response = self.view.focus_text()?; | |
| 266 | self.respond(response); | |
| 267 | } | |
| 237 | 268 | Ok(()) |
| 238 | 269 | } |
| 239 | 270 |
crates/snowbound/src/search.rs+1-1| ... | ... | @@ -670,7 +670,7 @@ impl State { |
| 670 | 670 | self.search.open = false; |
| 671 | 671 | self.ui.close_popup(results()); |
| 672 | 672 | self.search.finding = true; |
| 673 | self.search.claim = true; | |
| 673 | self.ui.set_focus(Some(find_field())); | |
| 674 | 674 | self.search.current = None; |
| 675 | 675 | self.refind(true) |
| 676 | 676 | } |
crates/snowbound/tests/replay.rs+395| ... | ... | @@ -589,6 +589,401 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() { |
| 589 | 589 | assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}"); |
| 590 | 590 | } |
| 591 | 591 | |
| 592 | fn stored_pages(path: &Path) -> Vec<(onestore::ExGuid, onestore::page::Page, u32)> { | |
| 593 | let bytes = std::fs::read(path).unwrap(); | |
| 594 | let store = onestore::Store::parse(&bytes).unwrap(); | |
| 595 | let index = onestore::RevisionIndex::parse(&store).unwrap(); | |
| 596 | let document = onestore::document::Document::parse(&index).unwrap(); | |
| 597 | document | |
| 598 | .pages() | |
| 599 | .unwrap() | |
| 600 | .into_iter() | |
| 601 | .map(|(space, page)| { | |
| 602 | ( | |
| 603 | space, | |
| 604 | onestore::page::Page::from_space(&document, space).unwrap(), | |
| 605 | onestore::page::Page::heading(document.active(space).unwrap(), page).1, | |
| 606 | ) | |
| 607 | }) | |
| 608 | .collect() | |
| 609 | } | |
| 610 | ||
| 611 | fn body_text(page: &onestore::page::Page) -> Vec<String> { | |
| 612 | page.objects | |
| 613 | .iter() | |
| 614 | .filter_map(|object| match object { | |
| 615 | onestore::page::PageObject::Outline(outline) if !outline.title => Some(outline), | |
| 616 | _ => None, | |
| 617 | }) | |
| 618 | .flat_map(|outline| &outline.paragraphs) | |
| 619 | .filter_map(|paragraph| paragraph.text()) | |
| 620 | .map(|text| text.text.text().to_owned()) | |
| 621 | .collect() | |
| 622 | } | |
| 623 | ||
| 624 | fn assert_replica_matches( | |
| 625 | scratch: &Scratch, | |
| 626 | pages: &[(onestore::ExGuid, onestore::page::Page, u32)], | |
| 627 | ) { | |
| 628 | let notebook = | |
| 629 | notebook::session::Notebook::open(scratch.0.join("notebook"), scratch.0.join("cache")) | |
| 630 | .unwrap(); | |
| 631 | let replica = notebook::Replica::open(notebook.replica_path("Cross.one").unwrap()).unwrap(); | |
| 632 | assert!(replica.pending().unwrap().is_empty()); | |
| 633 | assert_eq!( | |
| 634 | replica.pages().unwrap(), | |
| 635 | pages | |
| 636 | .iter() | |
| 637 | .map(|(space, page, level)| (*space, page.title.clone(), *level)) | |
| 638 | .collect::<Vec<_>>() | |
| 639 | ); | |
| 640 | for (space, page, _) in pages { | |
| 641 | assert_eq!(&replica.page(*space).unwrap(), page); | |
| 642 | } | |
| 643 | } | |
| 644 | ||
| 645 | #[test] | |
| 646 | fn find_takes_the_next_input_without_a_display_frame() { | |
| 647 | let scratch = Scratch::new("find-burst"); | |
| 648 | let notebook = | |
| 649 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 650 | let original = stored_pages(&notebook.join("Cross.one")); | |
| 651 | let [found] = replay( | |
| 652 | &scratch, | |
| 653 | Some(&notebook), | |
| 654 | &[ | |
| 655 | "batch", | |
| 656 | "move 900 600", | |
| 657 | "press", | |
| 658 | "release", | |
| 659 | "preedit 雪", | |
| 660 | "type BEFORE_FIND_QA", | |
| 661 | "modifiers command", | |
| 662 | "key f", | |
| 663 | "modifiers", | |
| 664 | "type FIND_QUERY_QAX", | |
| 665 | "key Backspace", | |
| 666 | "end", | |
| 667 | "accessibility found", | |
| 668 | ], | |
| 669 | ) | |
| 670 | .try_into() | |
| 671 | .unwrap(); | |
| 672 | assert!( | |
| 673 | found.contains(r#"SearchInput "Find on Page" = "FIND_QUERY_QA" [focused]"#), | |
| 674 | "{found}" | |
| 675 | ); | |
| 676 | let pages = stored_pages(&scratch.0.join("notebook/Cross.one")); | |
| 677 | assert_eq!(pages.len(), original.len()); | |
| 678 | assert_eq!(pages[0].0, original[0].0); | |
| 679 | assert_eq!(pages[0].1.title, original[0].1.title); | |
| 680 | assert!( | |
| 681 | body_text(&pages[0].1) | |
| 682 | .iter() | |
| 683 | .any(|text| text == "BEFORE_FIND_QA") | |
| 684 | ); | |
| 685 | assert!( | |
| 686 | body_text(&pages[0].1) | |
| 687 | .iter() | |
| 688 | .all(|text| !text.contains("FIND_QUERY_QA")) | |
| 689 | ); | |
| 690 | assert_eq!(&pages[1..], &original[1..]); | |
| 691 | assert_replica_matches(&scratch, &pages); | |
| 692 | } | |
| 693 | ||
| 694 | #[test] | |
| 695 | fn new_pages_take_burst_titles_and_bodies_before_a_display_frame() { | |
| 696 | let scratch = Scratch::new("new-page-burst"); | |
| 697 | let notebook = | |
| 698 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 699 | let original = stored_pages(&notebook.join("Cross.one")); | |
| 700 | let [burst, back, forward, undone, redone] = replay( | |
| 701 | &scratch, | |
| 702 | Some(&notebook), | |
| 703 | &[ | |
| 704 | "batch", | |
| 705 | "move 900 600", | |
| 706 | "press", | |
| 707 | "release", | |
| 708 | "type BEFORE_NEW_QA", | |
| 709 | "modifiers command", | |
| 710 | "key n", | |
| 711 | "modifiers", | |
| 712 | "type FIRST_TITLE_QAX", | |
| 713 | "key Backspace", | |
| 714 | "key Tab", | |
| 715 | "type FIRST_BODY_QA", | |
| 716 | "modifiers command", | |
| 717 | "key n", | |
| 718 | "modifiers", | |
| 719 | "type SECOND_TITLE_QA", | |
| 720 | "key Tab", | |
| 721 | "type SECOND_BODY_QA", | |
| 722 | "end", | |
| 723 | "accessibility burst", | |
| 724 | "modifiers command control", | |
| 725 | "key Left", | |
| 726 | "modifiers", | |
| 727 | "accessibility back", | |
| 728 | "modifiers command control", | |
| 729 | "key Right", | |
| 730 | "modifiers", | |
| 731 | "accessibility forward", | |
| 732 | "modifiers command", | |
| 733 | "key z", | |
| 734 | "modifiers", | |
| 735 | "accessibility undone", | |
| 736 | "modifiers command shift", | |
| 737 | "key z", | |
| 738 | "modifiers", | |
| 739 | "accessibility redone", | |
| 740 | ], | |
| 741 | ) | |
| 742 | .try_into() | |
| 743 | .unwrap(); | |
| 744 | for tree in [&burst, &forward, &redone] { | |
| 745 | assert!( | |
| 746 | page_tabs(tree).contains(&"*SECOND_TITLE_QA".to_owned()), | |
| 747 | "{tree}" | |
| 748 | ); | |
| 749 | assert!(tree.contains("SECOND_BODY_QA"), "{tree}"); | |
| 750 | assert!( | |
| 751 | !tree.contains("FIRST_BODY_QA") && !tree.contains("BEFORE_NEW_QA"), | |
| 752 | "{tree}" | |
| 753 | ); | |
| 754 | } | |
| 755 | assert!( | |
| 756 | page_tabs(&back).contains(&"*FIRST_TITLE_QA".to_owned()), | |
| 757 | "{back}" | |
| 758 | ); | |
| 759 | assert!(back.contains("FIRST_BODY_QA"), "{back}"); | |
| 760 | assert!(!undone.contains("SECOND_BODY_QA"), "{undone}"); | |
| 761 | let pages = stored_pages(&scratch.0.join("notebook/Cross.one")); | |
| 762 | assert_eq!(pages.len(), original.len() + 2); | |
| 763 | assert_eq!(pages[0].0, original[0].0); | |
| 764 | assert_eq!(pages[0].1.title, original[0].1.title); | |
| 765 | assert!( | |
| 766 | body_text(&pages[0].1) | |
| 767 | .iter() | |
| 768 | .any(|text| text == "BEFORE_NEW_QA") | |
| 769 | ); | |
| 770 | assert!( | |
| 771 | body_text(&pages[0].1) | |
| 772 | .iter() | |
| 773 | .all(|text| !text.contains("TITLE_QA") && !text.contains("BODY_QA")) | |
| 774 | ); | |
| 775 | assert_eq!(&pages[1..original.len()], &original[1..]); | |
| 776 | for (page, title, body) in [ | |
| 777 | (&pages[original.len()].1, "FIRST_TITLE_QA", "FIRST_BODY_QA"), | |
| 778 | ( | |
| 779 | &pages[original.len() + 1].1, | |
| 780 | "SECOND_TITLE_QA", | |
| 781 | "SECOND_BODY_QA", | |
| 782 | ), | |
| 783 | ] { | |
| 784 | assert_eq!(page.title, title); | |
| 785 | assert_eq!(body_text(page), [body]); | |
| 786 | } | |
| 787 | assert_ne!(pages[original.len()].0, pages[original.len() + 1].0); | |
| 788 | assert_replica_matches(&scratch, &pages); | |
| 789 | } | |
| 790 | ||
| 791 | #[test] | |
| 792 | fn back_followed_by_new_page_cannot_replace_the_burst_target() { | |
| 793 | let scratch = Scratch::new("back-new-page-burst"); | |
| 794 | let notebook = | |
| 795 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 796 | let original = stored_pages(&notebook.join("Cross.one")); | |
| 797 | let [before, created, later] = replay( | |
| 798 | &scratch, | |
| 799 | Some(&notebook), | |
| 800 | &[ | |
| 801 | "batch", | |
| 802 | "modifiers command", | |
| 803 | "key n", | |
| 804 | "modifiers", | |
| 805 | "type BACK_FIRST_QA", | |
| 806 | "key Tab", | |
| 807 | "type BACK_FIRST_BODY_QA", | |
| 808 | "modifiers command", | |
| 809 | "key n", | |
| 810 | "modifiers", | |
| 811 | "type BACK_SECOND_QA", | |
| 812 | "key Tab", | |
| 813 | "type BACK_SECOND_BODY_QA", | |
| 814 | "end", | |
| 815 | "accessibility before", | |
| 816 | "batch", | |
| 817 | "modifiers command control", | |
| 818 | "key Left", | |
| 819 | "modifiers command", | |
| 820 | "key n", | |
| 821 | "modifiers", | |
| 822 | "type BACK_NEW_QA", | |
| 823 | "key Tab", | |
| 824 | "type BACK_NEW_BODY_QA", | |
| 825 | "end", | |
| 826 | "accessibility created", | |
| 827 | "type _AFTER_SETTLE_QA", | |
| 828 | "accessibility later", | |
| 829 | ], | |
| 830 | ) | |
| 831 | .try_into() | |
| 832 | .unwrap(); | |
| 833 | assert!( | |
| 834 | page_tabs(&before).contains(&"*BACK_SECOND_QA".to_owned()), | |
| 835 | "{before}" | |
| 836 | ); | |
| 837 | for tree in [&created, &later] { | |
| 838 | assert!( | |
| 839 | page_tabs(tree).contains(&"*BACK_NEW_QA".to_owned()), | |
| 840 | "{tree}" | |
| 841 | ); | |
| 842 | assert!(tree.contains("BACK_NEW_BODY_QA"), "{tree}"); | |
| 843 | assert!( | |
| 844 | !tree.contains("BACK_FIRST_BODY_QA") && !tree.contains("BACK_SECOND_BODY_QA"), | |
| 845 | "{tree}" | |
| 846 | ); | |
| 847 | } | |
| 848 | assert!( | |
| 849 | later.contains("BACK_NEW_BODY_QA_AFTER_SETTLE_QA"), | |
| 850 | "{later}" | |
| 851 | ); | |
| 852 | let pages = stored_pages(&scratch.0.join("notebook/Cross.one")); | |
| 853 | assert_eq!(pages.len(), original.len() + 3); | |
| 854 | assert_eq!(&pages[..original.len()], &original); | |
| 855 | for (page, title, body) in [ | |
| 856 | ( | |
| 857 | &pages[original.len()].1, | |
| 858 | "BACK_FIRST_QA", | |
| 859 | "BACK_FIRST_BODY_QA", | |
| 860 | ), | |
| 861 | ( | |
| 862 | &pages[original.len() + 1].1, | |
| 863 | "BACK_SECOND_QA", | |
| 864 | "BACK_SECOND_BODY_QA", | |
| 865 | ), | |
| 866 | ( | |
| 867 | &pages[original.len() + 2].1, | |
| 868 | "BACK_NEW_QA", | |
| 869 | "BACK_NEW_BODY_QA_AFTER_SETTLE_QA", | |
| 870 | ), | |
| 871 | ] { | |
| 872 | assert_eq!(page.title, title); | |
| 873 | assert_eq!(body_text(page), [body]); | |
| 874 | } | |
| 875 | let identities = pages | |
| 876 | .iter() | |
| 877 | .map(|(space, ..)| *space) | |
| 878 | .collect::<std::collections::HashSet<_>>(); | |
| 879 | assert_eq!(identities.len(), pages.len()); | |
| 880 | assert_replica_matches(&scratch, &pages); | |
| 881 | } | |
| 882 | ||
| 883 | #[test] | |
| 884 | fn a_new_subpage_takes_the_next_input_and_keeps_its_parent() { | |
| 885 | let scratch = Scratch::new("new-subpage-burst"); | |
| 886 | let notebook = | |
| 887 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 888 | let original = stored_pages(&notebook.join("Cross.one")); | |
| 889 | let [tree] = replay( | |
| 890 | &scratch, | |
| 891 | Some(&notebook), | |
| 892 | &[ | |
| 893 | "batch", | |
| 894 | "modifiers command shift option", | |
| 895 | "key n", | |
| 896 | "modifiers", | |
| 897 | "type SUBPAGE_TITLE_QA", | |
| 898 | "key Tab", | |
| 899 | "type SUBPAGE_BODY_QA", | |
| 900 | "end", | |
| 901 | "accessibility subpage", | |
| 902 | ], | |
| 903 | ) | |
| 904 | .try_into() | |
| 905 | .unwrap(); | |
| 906 | assert!( | |
| 907 | page_tabs(&tree).contains(&"*SUBPAGE_TITLE_QA".to_owned()), | |
| 908 | "{tree}" | |
| 909 | ); | |
| 910 | assert!(tree.contains("SUBPAGE_BODY_QA"), "{tree}"); | |
| 911 | let pages = stored_pages(&scratch.0.join("notebook/Cross.one")); | |
| 912 | assert_eq!(pages.len(), original.len() + 1); | |
| 913 | assert_eq!(pages[0], original[0]); | |
| 914 | assert_eq!(&pages[2..], &original[1..]); | |
| 915 | assert_eq!(pages[1].1.title, "SUBPAGE_TITLE_QA"); | |
| 916 | assert_eq!(body_text(&pages[1].1), ["SUBPAGE_BODY_QA"]); | |
| 917 | assert_eq!(pages[1].2, (original[0].2 + 1).min(3)); | |
| 918 | assert_replica_matches(&scratch, &pages); | |
| 919 | } | |
| 920 | ||
| 921 | #[test] | |
| 922 | fn an_open_dialog_keeps_burst_shortcuts_out_of_the_page() { | |
| 923 | let scratch = Scratch::new("modal-shortcut-burst"); | |
| 924 | let notebook = | |
| 925 | Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate"); | |
| 926 | let original = stored_pages(&notebook.join("Cross.one")); | |
| 927 | let [tree] = replay( | |
| 928 | &scratch, | |
| 929 | Some(&notebook), | |
| 930 | &[ | |
| 931 | "batch", | |
| 932 | "modifiers command", | |
| 933 | "key ,", | |
| 934 | "key f", | |
| 935 | "key n", | |
| 936 | "modifiers", | |
| 937 | "type MODAL_INPUT_QA", | |
| 938 | "end", | |
| 939 | "accessibility modal", | |
| 940 | "key Escape", | |
| 941 | ], | |
| 942 | ) | |
| 943 | .try_into() | |
| 944 | .unwrap(); | |
| 945 | assert!(tree.contains("Dialog"), "{tree}"); | |
| 946 | assert!(!tree.contains("Find on Page"), "{tree}"); | |
| 947 | let pages = stored_pages(&scratch.0.join("notebook/Cross.one")); | |
| 948 | assert_eq!(pages, original); | |
| 949 | assert_replica_matches(&scratch, &pages); | |
| 950 | } | |
| 951 | ||
| 952 | #[test] | |
| 953 | fn disabled_shortcuts_leave_an_empty_notebook_empty() { | |
| 954 | let scratch = Scratch::new("disabled-shortcut-burst"); | |
| 955 | let [tree] = replay( | |
| 956 | &scratch, | |
| 957 | None, | |
| 958 | &[ | |
| 959 | "batch", | |
| 960 | "modifiers command", | |
| 961 | "key f", | |
| 962 | "modifiers", | |
| 963 | "type QUERY_QA", | |
| 964 | "modifiers command", | |
| 965 | "key n", | |
| 966 | "modifiers", | |
| 967 | "type TITLE_QA", | |
| 968 | "end", | |
| 969 | "accessibility empty", | |
| 970 | ], | |
| 971 | ) | |
| 972 | .try_into() | |
| 973 | .unwrap(); | |
| 974 | assert!(tree.contains("No sections in this notebook"), "{tree}"); | |
| 975 | assert!( | |
| 976 | !tree.contains("Find on Page") && !tree.contains("TITLE_QA"), | |
| 977 | "{tree}" | |
| 978 | ); | |
| 979 | assert!( | |
| 980 | std::fs::read_dir(scratch.0.join("notebook")) | |
| 981 | .unwrap() | |
| 982 | .next() | |
| 983 | .is_none() | |
| 984 | ); | |
| 985 | } | |
| 986 | ||
| 592 | 987 | /// Finding on the page leaves the query room beside the match count: a press just past |
| 593 | 988 | /// where the count ends lands in the field. |
| 594 | 989 | #[test] |