| author | |
| committer | |
| log | 577710e1d47638bbd086a22366de263d858f4475 |
| tree | 2c03317dcef272b0ef7c986aab8f918f9c13ff4f |
| parent | 30964a9cc80080214547f04dcb744f23406dffd6 |
| signature | Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU |
Presses during an in-flight page action or page switch were dropped
(the command was disabled while busy), so quick Undo presses were lost
and the replay test undo_walks_back_through_new_pages_and_their_titles
flaked under load. They now queue and are taken in order once nothing
is on its way. An action that shows a page counts as switching until
it lands.
Replay gains `settle`, and `accessibility` waits for the same settled
state before writing the tree, so replay tests stop racing background
work.
Assisted-by: claude-opus-5.53 files changed, 87 insertions(+), 35 deletions(-)
crates/snowbound/src/main.rs+38-10| ... | @@ -363,8 +363,9 @@ enum Replay { | ... | @@ -363,8 +363,9 @@ enum Replay { |
| 363 | Pinch(f32), | 363 | Pinch(f32), |
| 364 | /// Paints the next frame into a PNG as well as the window. | 364 | /// Paints the next frame into a PNG as well as the window. |
| 365 | Snapshot(PathBuf), | 365 | Snapshot(PathBuf), |
| 366 | /// Writes the window's accessibility tree as text. | 366 | /// Waits for nothing to be on its way, then writes the window's accessibility tree as |
| 367 | Accessibility(PathBuf), | 367 | /// text where given, and answers. |
| 368 | Settle(Option<PathBuf>, std::sync::mpsc::Sender<()>), | ||
| 368 | /// A frame during a wait, as a visible window's display would ask for. | 369 | /// A frame during a wait, as a visible window's display would ask for. |
| 369 | Tick, | 370 | Tick, |
| 370 | Appearance(winit::window::Theme), | 371 | Appearance(winit::window::Theme), |
| ... | @@ -883,6 +884,9 @@ struct State { | ... | @@ -883,6 +884,9 @@ struct State { |
| 883 | strip_held: bool, | 884 | strip_held: bool, |
| 884 | /// Where a replay asked the next frame to be written. | 885 | /// Where a replay asked the next frame to be written. |
| 885 | snapshot: Option<PathBuf>, | 886 | snapshot: Option<PathBuf>, |
| 887 | /// A replay waiting for nothing to be on its way, and where it wants the accessibility | ||
| 888 | /// tree written then. | ||
| 889 | replay_settle: Option<(Option<PathBuf>, std::sync::mpsc::Sender<()>)>, | ||
| 886 | /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by | 890 | /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by |
| 887 | /// milliseconds since the window opened. | 891 | /// milliseconds since the window opened. |
| 888 | frames: Option<(PathBuf, Instant)>, | 892 | frames: Option<(PathBuf, Instant)>, |
| ... | @@ -1226,6 +1230,7 @@ impl State { | ... | @@ -1226,6 +1230,7 @@ impl State { |
| 1226 | strip_press: None, | 1230 | strip_press: None, |
| 1227 | strip_held: false, | 1231 | strip_held: false, |
| 1228 | snapshot: None, | 1232 | snapshot: None, |
| 1233 | replay_settle: None, | ||
| 1229 | frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())), | 1234 | frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())), |
| 1230 | initial, | 1235 | initial, |
| 1231 | initial_date, | 1236 | initial_date, |
| ... | @@ -1286,6 +1291,9 @@ impl State { | ... | @@ -1286,6 +1291,9 @@ impl State { |
| 1286 | if let Err(error) = self.open_loaded() { | 1291 | if let Err(error) = self.open_loaded() { |
| 1287 | eprintln!("{error}"); | 1292 | eprintln!("{error}"); |
| 1288 | } | 1293 | } |
| 1294 | if let Err(error) = self.take_waiting() { | ||
| 1295 | eprintln!("{error}"); | ||
| 1296 | } | ||
| 1289 | lap("open", start); | 1297 | lap("open", start); |
| 1290 | self.follow_reading(); | 1298 | self.follow_reading(); |
| 1291 | let size = self.window.inner_size(); | 1299 | let size = self.window.inner_size(); |
| ... | @@ -5706,11 +5714,7 @@ impl State { | ... | @@ -5706,11 +5714,7 @@ impl State { |
| 5706 | } | 5714 | } |
| 5707 | } | 5715 | } |
| 5708 | Replay::Snapshot(path) => self.snapshot = Some(path), | 5716 | Replay::Snapshot(path) => self.snapshot = Some(path), |
| 5709 | Replay::Accessibility(path) => { | 5717 | Replay::Settle(path, settled) => self.replay_settle = Some((path, settled)), |
| 5710 | if let Err(error) = self.write_accessibility(&path) { | ||
| 5711 | eprintln!("{error}"); | ||
| 5712 | } | ||
| 5713 | } | ||
| 5714 | Replay::Tick | Replay::Quit => {} | 5718 | Replay::Tick | Replay::Quit => {} |
| 5715 | Replay::Appearance(appearance) => { | 5719 | Replay::Appearance(appearance) => { |
| 5716 | self.window.set_theme(Some(appearance)); | 5720 | self.window.set_theme(Some(appearance)); |
| ... | @@ -5726,6 +5730,16 @@ impl State { | ... | @@ -5726,6 +5730,16 @@ impl State { |
| 5726 | if let Err(error) = self.frame() { | 5730 | if let Err(error) = self.frame() { |
| 5727 | eprintln!("{error}"); | 5731 | eprintln!("{error}"); |
| 5728 | } | 5732 | } |
| 5733 | if self.settled() | ||
| 5734 | && let Some((path, settled)) = self.replay_settle.take() | ||
| 5735 | { | ||
| 5736 | if let Some(path) = path | ||
| 5737 | && let Err(error) = self.write_accessibility(&path) | ||
| 5738 | { | ||
| 5739 | eprintln!("{error}"); | ||
| 5740 | } | ||
| 5741 | let _ = settled.send(()); | ||
| 5742 | } | ||
| 5729 | } | 5743 | } |
| 5730 | #[cfg(not(target_arch = "wasm32"))] | 5744 | #[cfg(not(target_arch = "wasm32"))] |
| 5731 | UserEvent::Accessibility(event) => self.access_event(event), | 5745 | UserEvent::Accessibility(event) => self.access_event(event), |
| ... | @@ -6041,10 +6055,12 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E | ... | @@ -6041,10 +6055,12 @@ fn write_png(path: &Path, size: [u32; 2], pixels: &[u8]) -> Result<(), Box<dyn E |
| 6041 | /// Feeds a development script to the window from another thread, one command per line | 6055 | /// Feeds a development script to the window from another thread, one command per line |
| 6042 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, | 6056 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, |
| 6043 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] | 6057 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 6044 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`, | 6058 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and |
| 6045 | /// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | 6059 | /// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`, |
| 6060 | /// `resize WIDTH HEIGHT` and `quit`. | ||
| 6046 | #[cfg(not(target_arch = "wasm32"))] | 6061 | #[cfg(not(target_arch = "wasm32"))] |
| 6047 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { | 6062 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 6063 | let (settle, settled) = std::sync::mpsc::channel(); | ||
| 6048 | let mut steps = Vec::new(); | 6064 | let mut steps = Vec::new(); |
| 6049 | for line in script.lines().filter(|line| !line.trim().is_empty()) { | 6065 | for line in script.lines().filter(|line| !line.trim().is_empty()) { |
| 6050 | let (command, rest) = line.split_once(' ').unwrap_or((line, "")); | 6066 | let (command, rest) = line.split_once(' ').unwrap_or((line, "")); |
| ... | @@ -6113,7 +6129,8 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy | ... | @@ -6113,7 +6129,8 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6113 | .map_err(|_| "resize takes WIDTH HEIGHT")?, | 6129 | .map_err(|_| "resize takes WIDTH HEIGHT")?, |
| 6114 | )), | 6130 | )), |
| 6115 | "snapshot" => Ok(Replay::Snapshot(rest.into())), | 6131 | "snapshot" => Ok(Replay::Snapshot(rest.into())), |
| 6116 | "accessibility" => Ok(Replay::Accessibility(rest.into())), | 6132 | "settle" => Ok(Replay::Settle(None, settle.clone())), |
| 6133 | "accessibility" => Ok(Replay::Settle(Some(rest.into()), settle.clone())), | ||
| 6117 | "appearance" => Ok(Replay::Appearance(match rest { | 6134 | "appearance" => Ok(Replay::Appearance(match rest { |
| 6118 | "light" => winit::window::Theme::Light, | 6135 | "light" => winit::window::Theme::Light, |
| 6119 | "dark" => winit::window::Theme::Dark, | 6136 | "dark" => winit::window::Theme::Dark, |
| ... | @@ -6129,7 +6146,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy | ... | @@ -6129,7 +6146,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6129 | if let Replay::Input(ui::Event::Button { at, .. }) = &mut replay { | 6146 | if let Replay::Input(ui::Event::Button { at, .. }) = &mut replay { |
| 6130 | *at = Instant::now(); | 6147 | *at = Instant::now(); |
| 6131 | } | 6148 | } |
| 6149 | let settling = matches!(replay, Replay::Settle(..)); | ||
| 6132 | let _ = proxy.send_event(UserEvent::Replay(replay)); | 6150 | let _ = proxy.send_event(UserEvent::Replay(replay)); |
| 6151 | // Frames tick until settled; a minute bounds a page that never lands. | ||
| 6152 | let start = Instant::now(); | ||
| 6153 | while settling | ||
| 6154 | && start.elapsed().as_secs() < 60 | ||
| 6155 | && settled | ||
| 6156 | .recv_timeout(std::time::Duration::from_micros(16_667)) | ||
| 6157 | .is_err() | ||
| 6158 | { | ||
| 6159 | let _ = proxy.send_event(UserEvent::Replay(Replay::Tick)); | ||
| 6160 | } | ||
| 6133 | } | 6161 | } |
| 6134 | Err(duration) => { | 6162 | Err(duration) => { |
| 6135 | // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed. | 6163 | // Ticks keep a 60 Hz display's pace, dropping those a slow frame missed. |
crates/snowbound/src/undo.rs+48-24| ... | @@ -23,8 +23,10 @@ pub struct Timeline { | ... | @@ -23,8 +23,10 @@ pub struct Timeline { |
| 23 | redo: Vec<Step>, | 23 | redo: Vec<Step>, |
| 24 | /// Editors of pages left holding history, the longest left first. | 24 | /// Editors of pages left holding history, the longest left first. |
| 25 | parked: Vec<(ExGuid, CanvasEditor)>, | 25 | parked: Vec<(ExGuid, CanvasEditor)>, |
| 26 | /// Actions on their way, while which Undo and Redo wait. | 26 | /// Actions on their way. |
| 27 | busy: usize, | 27 | busy: usize, |
| 28 | /// Presses of Undo, or Redo when true, that wait for an action or page on its way. | ||
| 29 | waiting: std::collections::VecDeque<bool>, | ||
| 28 | } | 30 | } |
| 29 | 31 | ||
| 30 | enum Step { | 32 | enum Step { |
| ... | @@ -185,9 +187,6 @@ impl Timeline { | ... | @@ -185,9 +187,6 @@ impl Timeline { |
| 185 | depth: [usize; 2], | 187 | depth: [usize; 2], |
| 186 | here: &Here, | 188 | here: &Here, |
| 187 | ) -> Option<Next> { | 189 | ) -> Option<Next> { |
| 188 | if self.busy > 0 { | ||
| 189 | return None; | ||
| 190 | } | ||
| 191 | self.edited(page, depth, false); | 190 | self.edited(page, depth, false); |
| 192 | let steps = if redo { &mut self.redo } else { &mut self.undo }; | 191 | let steps = if redo { &mut self.redo } else { &mut self.undo }; |
| 193 | let at = steps.iter().rposition(|step| match step { | 192 | let at = steps.iter().rposition(|step| match step { |
| ... | @@ -235,10 +234,9 @@ impl Timeline { | ... | @@ -235,10 +234,9 @@ impl Timeline { |
| 235 | /// Whether Undo, or `redo` Redo, has an action to take `here`. | 234 | /// Whether Undo, or `redo` Redo, has an action to take `here`. |
| 236 | pub fn reaches(&self, redo: bool, here: &Here) -> bool { | 235 | pub fn reaches(&self, redo: bool, here: &Here) -> bool { |
| 237 | let steps = if redo { &self.redo } else { &self.undo }; | 236 | let steps = if redo { &self.redo } else { &self.undo }; |
| 238 | self.busy == 0 | 237 | steps |
| 239 | && steps | 238 | .iter() |
| 240 | .iter() | 239 | .any(|step| matches!(step, Step::Action(action) if action.applies(here))) |
| 241 | .any(|step| matches!(step, Step::Action(action) if action.applies(here))) | ||
| 242 | } | 240 | } |
| 243 | 241 | ||
| 244 | /// Keeps the editor of page `page`, just left, for its history. | 242 | /// Keeps the editor of page `page`, just left, for its history. |
| ... | @@ -815,21 +813,47 @@ impl State { | ... | @@ -815,21 +813,47 @@ impl State { |
| 815 | }) | 813 | }) |
| 816 | } | 814 | } |
| 817 | 815 | ||
| 818 | /// Whether Undo, or `redo` Redo, has something to take. | 816 | /// Whether no action, page or command is on its way, which Undo and Redo wait for. |
| 817 | pub(crate) fn settled(&self) -> bool { | ||
| 818 | self.undo.busy == 0 && self.switching.is_none() && self.commands.is_empty() | ||
| 819 | } | ||
| 820 | |||
| 821 | /// Whether Undo, or `redo` Redo, has something to take, or may once what is on its way | ||
| 822 | /// lands. | ||
| 819 | pub(crate) fn can_step(&self, redo: bool) -> bool { | 823 | pub(crate) fn can_step(&self, redo: bool) -> bool { |
| 820 | let editor = &self.view.editor; | 824 | let editor = &self.view.editor; |
| 821 | (if redo { | 825 | !self.settled() |
| 822 | editor.can_redo() | 826 | || (if redo { |
| 823 | } else { | 827 | editor.can_redo() |
| 824 | editor.can_undo() | 828 | } else { |
| 825 | }) || self | 829 | editor.can_undo() |
| 826 | .here() | 830 | }) |
| 827 | .is_some_and(|here| self.undo.reaches(redo, &here)) | 831 | || self |
| 832 | .here() | ||
| 833 | .is_some_and(|here| self.undo.reaches(redo, &here)) | ||
| 834 | } | ||
| 835 | |||
| 836 | /// Undo, or `redo` Redo, once what is on its way lands. | ||
| 837 | pub(crate) fn step(&mut self, redo: bool) -> Result<(), Box<dyn Error>> { | ||
| 838 | self.undo.waiting.push_back(redo); | ||
| 839 | self.take_waiting() | ||
| 840 | } | ||
| 841 | |||
| 842 | /// Takes the Undo and Redo presses waiting, while nothing is on its way. | ||
| 843 | pub(crate) fn take_waiting(&mut self) -> Result<(), Box<dyn Error>> { | ||
| 844 | while self.settled() | ||
| 845 | && let Some(redo) = self.undo.waiting.pop_front() | ||
| 846 | { | ||
| 847 | if self.can_step(redo) { | ||
| 848 | self.take(redo)?; | ||
| 849 | } | ||
| 850 | } | ||
| 851 | Ok(()) | ||
| 828 | } | 852 | } |
| 829 | 853 | ||
| 830 | /// Undo, or `redo` Redo: the open page's last edit or the last action made where the | 854 | /// Undo, or `redo` Redo: the open page's last edit or the last action made where the |
| 831 | /// user is, whichever came last. | 855 | /// user is, whichever came last. |
| 832 | pub(crate) fn step(&mut self, redo: bool) -> Result<(), Box<dyn Error>> { | 856 | fn take(&mut self, redo: bool) -> Result<(), Box<dyn Error>> { |
| 833 | self.persist()?; | 857 | self.persist()?; |
| 834 | let depth = self.view.editor.history_depth(); | 858 | let depth = self.view.editor.history_depth(); |
| 835 | // A page kept in no notebook has only its own history. | 859 | // A page kept in no notebook has only its own history. |
| ... | @@ -876,7 +900,7 @@ impl State { | ... | @@ -876,7 +900,7 @@ impl State { |
| 876 | .ok_or("That notebook is closed")?; | 900 | .ok_or("That notebook is closed")?; |
| 877 | let Some((structure, undo)) = restructuring(&library, &change) else { | 901 | let Some((structure, undo)) = restructuring(&library, &change) else { |
| 878 | return match taken { | 902 | return match taken { |
| 879 | Some(redo) => self.step(redo), | 903 | Some(redo) => self.take(redo), |
| 880 | None => Ok(()), | 904 | None => Ok(()), |
| 881 | }; | 905 | }; |
| 882 | }; | 906 | }; |
| ... | @@ -895,6 +919,7 @@ impl State { | ... | @@ -895,6 +919,7 @@ impl State { |
| 895 | let shown = session.space; | 919 | let shown = session.space; |
| 896 | let proxy = self.proxy.clone(); | 920 | let proxy = self.proxy.clone(); |
| 897 | self.undo.busy += 1; | 921 | self.undo.busy += 1; |
| 922 | self.switching = Some((None, web_time::Instant::now())); | ||
| 898 | self.load(move || { | 923 | self.load(move || { |
| 899 | let applied = site.change(change, shown); | 924 | let applied = site.change(change, shown); |
| 900 | let show = match &applied { | 925 | let show = match &applied { |
| ... | @@ -908,7 +933,10 @@ impl State { | ... | @@ -908,7 +933,10 @@ impl State { |
| 908 | let Some(applied) = applied else { | 933 | let Some(applied) = applied else { |
| 909 | // What it would change is gone: Undo goes on to the step before. | 934 | // What it would change is gone: Undo goes on to the step before. |
| 910 | return match taken { | 935 | return match taken { |
| 911 | Some(redo) if stale => state.step(redo), | 936 | Some(redo) if stale => { |
| 937 | state.undo.waiting.push_front(redo); | ||
| 938 | Ok(()) | ||
| 939 | } | ||
| 912 | _ => Ok(()), | 940 | _ => Ok(()), |
| 913 | }; | 941 | }; |
| 914 | }; | 942 | }; |
| ... | @@ -1079,8 +1107,7 @@ mod tests { | ... | @@ -1079,8 +1107,7 @@ mod tests { |
| 1079 | assert!(!timeline.reaches(false, &here())); | 1107 | assert!(!timeline.reaches(false, &here())); |
| 1080 | } | 1108 | } |
| 1081 | 1109 | ||
| 1082 | /// Redo takes back the last undone first; a new edit ends what actions it held, and | 1110 | /// Redo takes back the last undone first; a new edit ends what actions it held. |
| 1083 | /// waits while an action is on its way. | ||
| 1084 | #[test] | 1111 | #[test] |
| 1085 | fn redo_mirrors_undo_and_new_edits_end_it() { | 1112 | fn redo_mirrors_undo_and_new_edits_end_it() { |
| 1086 | let mut timeline = Timeline::default(); | 1113 | let mut timeline = Timeline::default(); |
| ... | @@ -1098,9 +1125,6 @@ mod tests { | ... | @@ -1098,9 +1125,6 @@ mod tests { |
| 1098 | timeline.stepped(shown, true); | 1125 | timeline.stepped(shown, true); |
| 1099 | timeline.stepped(shown, false); | 1126 | timeline.stepped(shown, false); |
| 1100 | assert!(!timeline.reaches(true, &here())); | 1127 | assert!(!timeline.reaches(true, &here())); |
| 1101 | timeline.record(created(page(3))); | ||
| 1102 | timeline.busy = 1; | ||
| 1103 | assert!(timeline.next(false, shown, [0, 1], &here()).is_none()); | ||
| 1104 | } | 1128 | } |
| 1105 | 1129 | ||
| 1106 | const AUTHOR: &str = "Rust Author"; | 1130 | const AUTHOR: &str = "Rust Author"; |
crates/snowbound/tests/replay.rs+1-1| ... | @@ -205,7 +205,7 @@ fn undo_walks_back_through_new_pages_and_their_titles() { | ... | @@ -205,7 +205,7 @@ fn undo_walks_back_through_new_pages_and_their_titles() { |
| 205 | let mut steps = Vec::new(); | 205 | let mut steps = Vec::new(); |
| 206 | for title in ["type One", "type Two"] { | 206 | for title in ["type One", "type Two"] { |
| 207 | steps.extend(chord("modifiers command", "key n")); | 207 | steps.extend(chord("modifiers command", "key n")); |
| 208 | steps.extend([title, "wait 300"].map(String::from)); | 208 | steps.extend(["settle", title, "wait 300"].map(String::from)); |
| 209 | } | 209 | } |
| 210 | steps.extend(chord("modifiers command control", "key Left")); | 210 | steps.extend(chord("modifiers command control", "key Left")); |
| 211 | steps.push("accessibility back".into()); | 211 | steps.push("accessibility back".into()); |