| 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,6 +1079,43 @@ impl State { |
| 1079 | self.window.request_redraw(); | 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 | /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord, | 1119 | /// Every choice's status, in `choices` order, for the menu bar. Link gives up its chord, |
| 1083 | /// ⌘K, to the open palette's actions. | 1120 | /// ⌘K, to the open palette's actions. |
| 1084 | pub(crate) fn statuses(&self) -> Vec<Status> { | 1121 | pub(crate) fn statuses(&self) -> Vec<Status> { |
| ... | @@ -1508,14 +1545,16 @@ impl State { | ... | @@ -1508,14 +1545,16 @@ impl State { |
| 1508 | }, | 1545 | }, |
| 1509 | ) | 1546 | ) |
| 1510 | } | 1547 | } |
| 1511 | Id::NewPage => Work::NewPage { | 1548 | Id::NewPage | Id::NewSubpage => { |
| 1512 | under: None, | 1549 | let under = self |
| 1513 | title: String::new(), | 1550 | .session |
| 1514 | }, | 1551 | .as_ref() |
| 1515 | Id::NewSubpage => Work::NewPage { | 1552 | .filter(|_| id == Id::NewSubpage) |
| 1516 | under: self.session.as_ref().map(|session| session.space), | 1553 | .map(|session| session.space); |
| 1517 | title: String::new(), | 1554 | self.new_page(under, "")?; |
| 1518 | }, | 1555 | self.edited(Vec::new()); |
| 1556 | return Ok(()); | ||
| 1557 | } | ||
| 1519 | Id::PageVersions => { | 1558 | Id::PageVersions => { |
| 1520 | let session = self.session.as_ref().ok_or("No notebook is open")?; | 1559 | let session = self.session.as_ref().ok_or("No notebook is open")?; |
| 1521 | Work::History { | 1560 | Work::History { |
crates/snowbound/src/main.rs+51-26| ... | @@ -390,6 +390,7 @@ impl std::task::Wake for Redraw { | ... | @@ -390,6 +390,7 @@ impl std::task::Wake for Redraw { |
| 390 | #[derive(Debug)] | 390 | #[derive(Debug)] |
| 391 | enum Replay { | 391 | enum Replay { |
| 392 | Input(ui::Event), | 392 | Input(ui::Event), |
| 393 | Inputs(Vec<ui::Event>), | ||
| 393 | Access(accesskit::Action, String), | 394 | Access(accesskit::Action, String), |
| 394 | /// A trackpad pinch scaling the page by the factor. | 395 | /// A trackpad pinch scaling the page by the factor. |
| 395 | Pinch(f32), | 396 | Pinch(f32), |
| ... | @@ -695,8 +696,7 @@ enum Laid { | ... | @@ -695,8 +696,7 @@ enum Laid { |
| 695 | Library(Arc<Library>, Option<String>), | 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 | /// A page ready for adoption, with the scene and editor laid out together. |
| 699 | /// never appears without them. | ||
| 700 | struct Opening { | 700 | struct Opening { |
| 701 | loaded: Shown, | 701 | loaded: Shown, |
| 702 | scene: (PageScene, [f32; 2]), | 702 | scene: (PageScene, [f32; 2]), |
| ... | @@ -934,8 +934,6 @@ struct State { | ... | @@ -934,8 +934,6 @@ struct State { |
| 934 | drag: Option<menus::Drag>, | 934 | drag: Option<menus::Drag>, |
| 935 | /// Pages whose template strip was dismissed this run. | 935 | /// Pages whose template strip was dismissed this run. |
| 936 | dismissed: HashSet<ExGuid>, | 936 | dismissed: HashSet<ExGuid>, |
| 937 | /// A page just created, whose title takes the caret once it opens. | ||
| 938 | title_focus: Option<ExGuid>, | ||
| 939 | /// The page colour a menu previews over the open page's own this frame. | 937 | /// The page colour a menu previews over the open page's own this frame. |
| 940 | page_color_preview: Option<Option<u32>>, | 938 | page_color_preview: Option<Option<u32>>, |
| 941 | media: recording::Media, | 939 | media: recording::Media, |
| ... | @@ -1347,7 +1345,6 @@ impl State { | ... | @@ -1347,7 +1345,6 @@ impl State { |
| 1347 | sidebar: stored.sidebar, | 1345 | sidebar: stored.sidebar, |
| 1348 | folded: HashSet::new(), | 1346 | folded: HashSet::new(), |
| 1349 | dismissed: HashSet::new(), | 1347 | dismissed: HashSet::new(), |
| 1350 | title_focus: None, | ||
| 1351 | menu: None, | 1348 | menu: None, |
| 1352 | sync_notebook: None, | 1349 | sync_notebook: None, |
| 1353 | actions: None, | 1350 | actions: None, |
| ... | @@ -3847,6 +3844,10 @@ impl State { | ... | @@ -3847,6 +3844,10 @@ impl State { |
| 3847 | return Ok(()); | 3844 | return Ok(()); |
| 3848 | } | 3845 | } |
| 3849 | lap("switch pictures", opening.since); | 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 | let requested = opening.requested; | 3851 | let requested = opening.requested; |
| 3851 | if let Shown::Section(session) | Shown::Created(session) = &mut opening.loaded | 3852 | if let Shown::Section(session) | Shown::Created(session) = &mut opening.loaded |
| 3852 | && let Some(library) = self | 3853 | && let Some(library) = self |
| ... | @@ -3931,6 +3932,7 @@ impl State { | ... | @@ -3931,6 +3932,7 @@ impl State { |
| 3931 | } | 3932 | } |
| 3932 | Shown::Page(space) | Shown::Version(space, _) => { | 3933 | Shown::Page(space) | Shown::Version(space, _) => { |
| 3933 | let session = self.session.as_mut().ok_or("No section is open")?; | 3934 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 3935 | let pages = session.section.pages()?; | ||
| 3934 | session.space = space; | 3936 | session.space = space; |
| 3935 | session.version = match opening.loaded { | 3937 | session.version = match opening.loaded { |
| 3936 | Shown::Version(_, version) => Some(version), | 3938 | Shown::Version(_, version) => Some(version), |
| ... | @@ -3938,7 +3940,7 @@ impl State { | ... | @@ -3938,7 +3940,7 @@ impl State { |
| 3938 | }; | 3940 | }; |
| 3939 | session.change = None; | 3941 | session.change = None; |
| 3940 | // Pages created, moved or deleted since the list was read. | 3942 | // Pages created, moved or deleted since the list was read. |
| 3941 | session.pages = session.section.pages()?; | 3943 | session.pages = pages; |
| 3942 | leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left)); | 3944 | leaving = leaving.filter(|left| session.pages.iter().any(|(s, ..)| s == left)); |
| 3943 | } | 3945 | } |
| 3944 | } | 3946 | } |
| ... | @@ -3978,21 +3980,6 @@ impl State { | ... | @@ -3978,21 +3980,6 @@ impl State { |
| 3978 | self.opened()?; | 3980 | self.opened()?; |
| 3979 | self.prefetch_around(); | 3981 | self.prefetch_around(); |
| 3980 | self.refind(true)?; | 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 | if created && let Some(session) = &self.session { | 3983 | if created && let Some(session) = &self.session { |
| 3997 | let target = rename::Target::Entry { | 3984 | let target = rename::Target::Entry { |
| 3998 | library: Arc::clone(&session.library), | 3985 | library: Arc::clone(&session.library), |
| ... | @@ -4913,7 +4900,7 @@ impl State { | ... | @@ -4913,7 +4900,7 @@ impl State { |
| 4913 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) | 4900 | commands::find(&ui::edit_key(key), ui::edit_modifiers(self.ui.modifiers())) |
| 4914 | && !(id == commands::Id::Link && self.ui.popup_open(palette::id())) | 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 | } else { | 4904 | } else { |
| 4918 | self.ui.event(event); | 4905 | self.ui.event(event); |
| 4919 | } | 4906 | } |
| ... | @@ -6385,7 +6372,7 @@ impl State { | ... | @@ -6385,7 +6372,7 @@ impl State { |
| 6385 | eprintln!("{error}"); | 6372 | eprintln!("{error}"); |
| 6386 | } | 6373 | } |
| 6387 | } | 6374 | } |
| 6388 | UserEvent::Choose(choice) => self.choose(choice), | 6375 | UserEvent::Choose(choice) => self.choose_input(choice), |
| 6389 | UserEvent::Appearance => { | 6376 | UserEvent::Appearance => { |
| 6390 | self.follow_color_scheme(); | 6377 | self.follow_color_scheme(); |
| 6391 | self.window.request_redraw(); | 6378 | self.window.request_redraw(); |
| ... | @@ -6402,6 +6389,14 @@ impl State { | ... | @@ -6402,6 +6389,14 @@ impl State { |
| 6402 | }; | 6389 | }; |
| 6403 | match replay { | 6390 | match replay { |
| 6404 | Replay::Input(event) => self.input(event), | 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 | Replay::Access(action, label) => { | 6400 | Replay::Access(action, label) => { |
| 6406 | let tree = self.ui.accessibility_tree("", self.window.scale_factor()); | 6401 | let tree = self.ui.accessibility_tree("", self.window.scale_factor()); |
| 6407 | let mut nodes = tree | 6402 | let mut nodes = tree |
| ... | @@ -6813,12 +6808,26 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E | ... | @@ -6813,12 +6808,26 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 6813 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] | 6808 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 6814 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and | 6809 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and |
| 6815 | /// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`, | 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 | #[cfg(not(target_arch = "wasm32"))] | 6813 | #[cfg(not(target_arch = "wasm32"))] |
| 6818 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { | 6814 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 6819 | let (settle, settled) = std::sync::mpsc::channel(); | 6815 | let (settle, settled) = std::sync::mpsc::channel(); |
| 6820 | let mut steps = Vec::new(); | 6816 | let mut steps = Vec::new(); |
| 6817 | let mut batch = None; | ||
| 6821 | for line in script.lines().filter(|line| !line.trim().is_empty()) { | 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 | let (command, rest) = line.split_once(' ').unwrap_or((line, "")); | 6831 | let (command, rest) = line.split_once(' ').unwrap_or((line, "")); |
| 6823 | let numbers = || -> Result<Vec<f32>, std::num::ParseFloatError> { | 6832 | let numbers = || -> Result<Vec<f32>, std::num::ParseFloatError> { |
| 6824 | rest.split_whitespace().map(str::parse).collect() | 6833 | rest.split_whitespace().map(str::parse).collect() |
| ... | @@ -6832,7 +6841,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy | ... | @@ -6832,7 +6841,7 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6832 | pressed, | 6841 | pressed, |
| 6833 | at: Instant::now(), | 6842 | at: Instant::now(), |
| 6834 | }; | 6843 | }; |
| 6835 | steps.push(match command { | 6844 | let step = match command { |
| 6836 | "move" => Ok(Replay::Input(ui::Event::PointerMoved( | 6845 | "move" => Ok(Replay::Input(ui::Event::PointerMoved( |
| 6837 | numbers()?.try_into().map_err(|_| "move takes X Y")?, | 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,11 +6874,16 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6865 | text: None, | 6874 | text: None, |
| 6866 | })), | 6875 | })), |
| 6867 | "type" => Ok(Replay::Input(ui::Event::Ime(Ime::Commit(rest.into())))), | 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 | "modifiers" => Ok(Replay::Input(ui::Event::Modifiers( | 6881 | "modifiers" => Ok(Replay::Input(ui::Event::Modifiers( |
| 6869 | rest.split_whitespace() | 6882 | rest.split_whitespace() |
| 6870 | .map(|name| match name { | 6883 | .map(|name| match name { |
| 6871 | "shift" => Ok(ModifiersState::SHIFT), | 6884 | "shift" => Ok(ModifiersState::SHIFT), |
| 6872 | "control" => Ok(ModifiersState::CONTROL), | 6885 | "control" => Ok(ModifiersState::CONTROL), |
| 6886 | "option" => Ok(ModifiersState::ALT), | ||
| 6873 | // The shortcut modifier, as ui::edit_modifiers reads it. | 6887 | // The shortcut modifier, as ui::edit_modifiers reads it. |
| 6874 | "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER), | 6888 | "command" if cfg!(target_os = "macos") => Ok(ModifiersState::SUPER), |
| 6875 | "command" => Ok(ModifiersState::CONTROL), | 6889 | "command" => Ok(ModifiersState::CONTROL), |
| ... | @@ -6903,7 +6917,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy | ... | @@ -6903,7 +6917,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6903 | _ => return Err(format!("Unknown appearance {rest}").into()), | 6917 | _ => return Err(format!("Unknown appearance {rest}").into()), |
| 6904 | })), | 6918 | })), |
| 6905 | _ => return Err(format!("Unknown replay command: {line}").into()), | 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 | std::thread::spawn(move || { | 6933 | std::thread::spawn(move || { |
| 6909 | for step in steps { | 6934 | for step in steps { |
crates/snowbound/src/manage.rs+40-9| ... | @@ -219,21 +219,52 @@ impl State { | ... | @@ -219,21 +219,52 @@ impl State { |
| 219 | )?]))); | 219 | )?]))); |
| 220 | } | 220 | } |
| 221 | let session = self.session.as_mut().ok_or("No section is open")?; | 221 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 222 | session.section.apply( | 222 | let edit = Edit { |
| 223 | &self.author, | 223 | at: crate::filetime(), |
| 224 | Edit { | 224 | ops, |
| 225 | at: crate::filetime(), | 225 | }; |
| 226 | ops, | 226 | #[cfg(not(target_arch = "wasm32"))] |
| 227 | }, | 227 | session.section.replica().apply(&self.author, edit)?; |
| 228 | )?; | 228 | #[cfg(target_arch = "wasm32")] |
| 229 | session.section.apply(&self.author, edit)?; | ||
| 229 | let shown = session.space; | 230 | let shown = session.space; |
| 230 | self.made(Change::Delete { | 231 | self.made(Change::Delete { |
| 231 | pages: vec![space], | 232 | pages: vec![space], |
| 232 | show: Some(shown), | 233 | show: Some(shown), |
| 233 | created: true, | 234 | created: true, |
| 234 | }); | 235 | }); |
| 235 | self.title_focus = Some(space); | 236 | let session = self.session.as_mut().ok_or("No section is open")?; |
| 236 | self.commands.push(Command::OpenPage(space)); | 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 | Ok(()) | 268 | Ok(()) |
| 238 | } | 269 | } |
| 239 | 270 |
crates/snowbound/src/search.rs+1-1| ... | @@ -670,7 +670,7 @@ impl State { | ... | @@ -670,7 +670,7 @@ impl State { |
| 670 | self.search.open = false; | 670 | self.search.open = false; |
| 671 | self.ui.close_popup(results()); | 671 | self.ui.close_popup(results()); |
| 672 | self.search.finding = true; | 672 | self.search.finding = true; |
| 673 | self.search.claim = true; | 673 | self.ui.set_focus(Some(find_field())); |
| 674 | self.search.current = None; | 674 | self.search.current = None; |
| 675 | self.refind(true) | 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,6 +589,401 @@ fn renaming_a_section_from_its_tab_types_into_the_tab_at_once() { |
| 589 | assert!(renamed.contains(r#"Tab "Renamed""#), "{renamed}"); | 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 | /// Finding on the page leaves the query room beside the match count: a press just past | 987 | /// Finding on the page leaves the query room beside the match count: a press just past |
| 593 | /// where the count ends lands in the field. | 988 | /// where the count ends lands in the field. |
| 594 | #[test] | 989 | #[test] |