authorclover caruso <git@paperclover.net> 2026-10-12 16:32:52-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 02:58:22-07:00
log147a2af4881d7bbc1d857cf57d191ba94c0522ca
treeec2f2ddd5b69a58112dfb39c3cea85855635d765
parentc21fb63b8d789060e2ff38951cd910a734696ca7
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(desktop): route shortcut input before the next keystroke

Refs #123, #129 Assisted-by: gpt-6.1-sol

5 files changed, 534 insertions(+), 44 deletions(-)

crates/snowbound/src/commands.rs+47-8
......@@ -1079,6 +1079,43 @@ impl State {
10791079 self.window.request_redraw();
10801080 }
10811081
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
10821119 /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord,
10831120 /// ⌘K, to the open palette's actions.
10841121 pub(crate) fn statuses(&self) -> Vec<Status> {
......@@ -1508,14 +1545,16 @@ impl State {
15081545 },
15091546 )
15101547 }
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 }
15191558 Id::PageVersions => {
15201559 let session = self.session.as_ref().ok_or("No notebook is open")?;
15211560 Work::History {
crates/snowbound/src/main.rs+51-26
......@@ -390,6 +390,7 @@ impl std::task::Wake for Redraw {
390390#[derive(Debug)]
391391enum Replay {
392392 Input(ui::Event),
393 Inputs(Vec<ui::Event>),
393394 Access(accesskit::Action, String),
394395 /// A trackpad pinch scaling the page by the factor.
395396 Pinch(f32),
......@@ -695,8 +696,7 @@ enum Laid {
695696 Library(Arc<Library>, Option<String>),
696697}
697698
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.
700700struct Opening {
701701 loaded: Shown,
702702 scene: (PageScene, [f32; 2]),
......@@ -934,8 +934,6 @@ struct State {
934934 drag: Option<menus::Drag>,
935935 /// Pages whose template strip was dismissed this run.
936936 dismissed: HashSet<ExGuid>,
937 /// A page just created, whose title takes the caret once it opens.
938 title_focus: Option<ExGuid>,
939937 /// The page colour a menu previews over the open page's own this frame.
940938 page_color_preview: Option<Option<u32>>,
941939 media: recording::Media,
......@@ -1347,7 +1345,6 @@ impl State {
13471345 sidebar: stored.sidebar,
13481346 folded: HashSet::new(),
13491347 dismissed: HashSet::new(),
1350 title_focus: None,
13511348 menu: None,
13521349 sync_notebook: None,
13531350 actions: None,
......@@ -3847,6 +3844,10 @@ impl State {
38473844 return Ok(());
38483845 }
38493846 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>> {
38503851 let requested = opening.requested;
38513852 if let Shown::Section(session) | Shown::Created(session) = &mut opening.loaded
38523853 && let Some(library) = self
......@@ -3931,6 +3932,7 @@ impl State {
39313932 }
39323933 Shown::Page(space) | Shown::Version(space, _) => {
39333934 let session = self.session.as_mut().ok_or("No section is open")?;
3935 let pages = session.section.pages()?;
39343936 session.space = space;
39353937 session.version = match opening.loaded {
39363938 Shown::Version(_, version) => Some(version),
......@@ -3938,7 +3940,7 @@ impl State {
39383940 };
39393941 session.change = None;
39403942 // Pages created, moved or deleted since the list was read.
3941 session.pages = session.section.pages()?;
3943 session.pages = pages;
39423944 leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left));
39433945 }
39443946 }
......@@ -3978,21 +3980,6 @@ impl State {
39783980 self.opened()?;
39793981 self.prefetch_around();
39803982 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 }
39963983 if created && let Some(session) = &self.session {
39973984 let target = rename::Target::Entry {
39983985 library: Arc::clone(&session.library),
......@@ -4913,7 +4900,7 @@ impl State {
49134900 commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers()))
49144901 && !(id == commands::Id::Link && self.ui.popup_open(palette::id()))
49154902 {
4916 self.choose(commands::Choice::Command(id));
4903 self.choose_input(commands::Choice::Command(id));
49174904 } else {
49184905 self.ui.event(event);
49194906 }
......@@ -6385,7 +6372,7 @@ impl State {
63856372 eprintln!("{error}");
63866373 }
63876374 }
6388 UserEvent::Choose(choice) => self.choose(choice),
6375 UserEvent::Choose(choice) => self.choose_input(choice),
63896376 UserEvent::Appearance => {
63906377 self.follow_color_scheme();
63916378 self.window.request_redraw();
......@@ -6402,6 +6389,14 @@ impl State {
64026389 };
64036390 match replay {
64046391 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 }
64056400 Replay::Access(action, label) => {
64066401 let tree = self.ui.accessibility_tree("", self.window.scale_factor());
64076402 let mut nodes = tree
......@@ -6813,12 +6808,26 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E
68136808/// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift]
68146809/// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and
68156810/// `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.
68176813#[cfg(not(target_arch = "wasm32"))]
68186814fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> {
68196815 let (settle, settled) = std::sync::mpsc::channel();
68206816 let mut steps = Vec::new();
6817 let mut batch = None;
68216818 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 }
68226831 let (command, rest) = line.split_once(' ').unwrap_or((line, ""));
68236832 let numbers = || -> Result<Vec<f32>, std::num::ParseFloatError> {
68246833 rest.split_whitespace().map(str::parse).collect()
......@@ -6832,7 +6841,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
68326841 pressed,
68336842 at: Instant::now(),
68346843 };
6835 steps.push(match command {
6844 let step = match command {
68366845 "move" => Ok(Replay::Input(ui::Event::PointerMoved(
68376846 numbers()?.try_into().map_err(|_| "move takes X Y")?,
68386847 ))),
......@@ -6865,11 +6874,16 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
68656874 text: None,
68666875 })),
68676876 "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 )))),
68686881 "modifiers" => Ok(Replay::Input(ui::Event::Modifiers(
68696882 rest.split_whitespace()
68706883 .map(|name| match name {
68716884 "shift" => Ok(ModifiersState::SHIFT),
68726885 "control" => Ok(ModifiersState::CONTROL),
6886 "option" => Ok(ModifiersState::ALT),
68736887 // The shortcut modifier, as ui::edit_modifiers reads it.
68746888 "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER),
68756889 "command" => Ok(ModifiersState::CONTROL),
......@@ -6903,7 +6917,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
69036917 _ => return Err(format!("Unknown appearance {rest}").into()),
69046918 })),
69056919 _ => 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());
69076932 }
69086933 std::thread::spawn(move || {
69096934 for step in steps {
crates/snowbound/src/manage.rs+40-9
......@@ -219,21 +219,52 @@ impl State {
219219 )?])));
220220 }
221221 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)?;
229230 let shown = session.space;
230231 self.made(Change::Delete {
231232 pages: vec![space],
232233 show: Some(shown),
233234 created: true,
234235 });
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 }
237268 Ok(())
238269 }
239270
crates/snowbound/src/search.rs+1-1
......@@ -670,7 +670,7 @@ impl State {
670670 self.search.open = false;
671671 self.ui.close_popup(results());
672672 self.search.finding = true;
673 self.search.claim = true;
673 self.ui.set_focus(Some(find_field()));
674674 self.search.current = None;
675675 self.refind(true)
676676 }
crates/snowbound/tests/replay.rs+395
......@@ -589,6 +589,401 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() {
589589 assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}");
590590}
591591
592fn 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
611fn 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
624fn 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]
646fn 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]
695fn 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]
792fn 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]
884fn 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]
922fn 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]
953fn 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
592987/// Finding on the page leaves the query room beside the match count: a press just past
593988/// where the count ends lands in the field.
594989#[test]