| 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 | 363 | Pinch(f32), |
| 364 | 364 | /// Paints the next frame into a PNG as well as the window. |
| 365 | 365 | Snapshot(PathBuf), |
| 366 | /// Writes the window's accessibility tree as text. | |
| 367 | Accessibility(PathBuf), | |
| 366 | /// Waits for nothing to be on its way, then writes the window's accessibility tree as | |
| 367 | /// text where given, and answers. | |
| 368 | Settle(Option<PathBuf>, std::sync::mpsc::Sender<()>), | |
| 368 | 369 | /// A frame during a wait, as a visible window's display would ask for. |
| 369 | 370 | Tick, |
| 370 | 371 | Appearance(winit::window::Theme), |
| ... | ... | @@ -883,6 +884,9 @@ struct State { |
| 883 | 884 | strip_held: bool, |
| 884 | 885 | /// Where a replay asked the next frame to be written. |
| 885 | 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 | 890 | /// `SNOWBOUND_FRAMES`: a directory every frame drawn is also written to, named by |
| 887 | 891 | /// milliseconds since the window opened. |
| 888 | 892 | frames: Option<(PathBuf, Instant)>, |
| ... | ... | @@ -1226,6 +1230,7 @@ impl State { |
| 1226 | 1230 | strip_press: None, |
| 1227 | 1231 | strip_held: false, |
| 1228 | 1232 | snapshot: None, |
| 1233 | replay_settle: None, | |
| 1229 | 1234 | frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())), |
| 1230 | 1235 | initial, |
| 1231 | 1236 | initial_date, |
| ... | ... | @@ -1286,6 +1291,9 @@ impl State { |
| 1286 | 1291 | if let Err(error) = self.open_loaded() { |
| 1287 | 1292 | eprintln!("{error}"); |
| 1288 | 1293 | } |
| 1294 | if let Err(error) = self.take_waiting() { | |
| 1295 | eprintln!("{error}"); | |
| 1296 | } | |
| 1289 | 1297 | lap("open", start); |
| 1290 | 1298 | self.follow_reading(); |
| 1291 | 1299 | let size = self.window.inner_size(); |
| ... | ... | @@ -5706,11 +5714,7 @@ impl State { |
| 5706 | 5714 | } |
| 5707 | 5715 | } |
| 5708 | 5716 | Replay::Snapshot(path) => self.snapshot = Some(path), |
| 5709 | Replay::Accessibility(path) => { | |
| 5710 | if let Err(error) = self.write_accessibility(&path) { | |
| 5711 | eprintln!("{error}"); | |
| 5712 | } | |
| 5713 | } | |
| 5717 | Replay::Settle(path, settled) => self.replay_settle = Some((path, settled)), | |
| 5714 | 5718 | Replay::Tick | Replay::Quit => {} |
| 5715 | 5719 | Replay::Appearance(appearance) => { |
| 5716 | 5720 | self.window.set_theme(Some(appearance)); |
| ... | ... | @@ -5726,6 +5730,16 @@ impl State { |
| 5726 | 5730 | if let Err(error) = self.frame() { |
| 5727 | 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 | 5744 | #[cfg(not(target_arch = "wasm32"))] |
| 5731 | 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 | 6055 | /// Feeds a development script to the window from another thread, one command per line |
| 6042 | 6056 | /// in logical pixels: `move X Y`, `press [right]`, `release [right]`, `wheel DX DY`, |
| 6043 | 6057 | /// `pressure LEVEL|none`, `pinch FACTOR`, `key NAME`, `type TEXT`, `modifiers [shift] |
| 6044 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `accessibility TEXT_PATH`, | |
| 6045 | /// `appearance light|dark`, `resize WIDTH HEIGHT` and `quit`. | |
| 6058 | /// [control] [command]`, `wait MILLISECONDS`, `snapshot PNG_PATH`, `settle` and | |
| 6059 | /// `accessibility TEXT_PATH` (which wait for what is on its way), `appearance light|dark`, | |
| 6060 | /// `resize WIDTH HEIGHT` and `quit`. | |
| 6046 | 6061 | #[cfg(not(target_arch = "wasm32"))] |
| 6047 | 6062 | fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dyn Error>> { |
| 6063 | let (settle, settled) = std::sync::mpsc::channel(); | |
| 6048 | 6064 | let mut steps = Vec::new(); |
| 6049 | 6065 | for line in script.lines().filter(|line| !line.trim().is_empty()) { |
| 6050 | 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 | 6129 | .map_err(|_| "resize takes WIDTH HEIGHT")?, |
| 6114 | 6130 | )), |
| 6115 | 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 | 6134 | "appearance" => Ok(Replay::Appearance(match rest { |
| 6118 | 6135 | "light" => winit::window::Theme::Light, |
| 6119 | 6136 | "dark" => winit::window::Theme::Dark, |
| ... | ... | @@ -6129,7 +6146,18 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy |
| 6129 | 6146 | if let Replay::Input(ui::Event::Button { at, .. }) = &mut replay { |
| 6130 | 6147 | *at = Instant::now(); |
| 6131 | 6148 | } |
| 6149 | let settling = matches!(replay, Replay::Settle(..)); | |
| 6132 | 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 | 6162 | Err(duration) => { |
| 6135 | 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 | 23 | redo: Vec<Step>, |
| 24 | 24 | /// Editors of pages left holding history, the longest left first. |
| 25 | 25 | parked: Vec<(ExGuid, CanvasEditor)>, |
| 26 | /// Actions on their way, while which Undo and Redo wait. | |
| 26 | /// Actions on their way. | |
| 27 | 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 | 32 | enum Step { |
| ... | ... | @@ -185,9 +187,6 @@ impl Timeline { |
| 185 | 187 | depth: [usize; 2], |
| 186 | 188 | here: &Here, |
| 187 | 189 | ) -> Option<Next> { |
| 188 | if self.busy > 0 { | |
| 189 | return None; | |
| 190 | } | |
| 191 | 190 | self.edited(page, depth, false); |
| 192 | 191 | let steps = if redo { &mut self.redo } else { &mut self.undo }; |
| 193 | 192 | let at = steps.iter().rposition(|step| match step { |
| ... | ... | @@ -235,10 +234,9 @@ impl Timeline { |
| 235 | 234 | /// Whether Undo, or `redo` Redo, has an action to take `here`. |
| 236 | 235 | pub fn reaches(&self, redo: bool, here: &Here) -> bool { |
| 237 | 236 | let steps = if redo { &self.redo } else { &self.undo }; |
| 238 | self.busy == 0 | |
| 239 | && steps | |
| 240 | .iter() | |
| 241 | .any(|step| matches!(step, Step::Action(action) if action.applies(here))) | |
| 237 | steps | |
| 238 | .iter() | |
| 239 | .any(|step| matches!(step, Step::Action(action) if action.applies(here))) | |
| 242 | 240 | } |
| 243 | 241 | |
| 244 | 242 | /// Keeps the editor of page `page`, just left, for its history. |
| ... | ... | @@ -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 | 823 | pub(crate) fn can_step(&self, redo: bool) -> bool { |
| 820 | 824 | let editor = &self.view.editor; |
| 821 | (if redo { | |
| 822 | editor.can_redo() | |
| 823 | } else { | |
| 824 | editor.can_undo() | |
| 825 | }) || self | |
| 826 | .here() | |
| 827 | .is_some_and(|here| self.undo.reaches(redo, &here)) | |
| 825 | !self.settled() | |
| 826 | || (if redo { | |
| 827 | editor.can_redo() | |
| 828 | } else { | |
| 829 | editor.can_undo() | |
| 830 | }) | |
| 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 | 854 | /// Undo, or `redo` Redo: the open page's last edit or the last action made where the |
| 831 | 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 | 857 | self.persist()?; |
| 834 | 858 | let depth = self.view.editor.history_depth(); |
| 835 | 859 | // A page kept in no notebook has only its own history. |
| ... | ... | @@ -876,7 +900,7 @@ impl State { |
| 876 | 900 | .ok_or("That notebook is closed")?; |
| 877 | 901 | let Some((structure, undo)) = restructuring(&library, &change) else { |
| 878 | 902 | return match taken { |
| 879 | Some(redo) => self.step(redo), | |
| 903 | Some(redo) => self.take(redo), | |
| 880 | 904 | None => Ok(()), |
| 881 | 905 | }; |
| 882 | 906 | }; |
| ... | ... | @@ -895,6 +919,7 @@ impl State { |
| 895 | 919 | let shown = session.space; |
| 896 | 920 | let proxy = self.proxy.clone(); |
| 897 | 921 | self.undo.busy += 1; |
| 922 | self.switching = Some((None, web_time::Instant::now())); | |
| 898 | 923 | self.load(move || { |
| 899 | 924 | let applied = site.change(change, shown); |
| 900 | 925 | let show = match &applied { |
| ... | ... | @@ -908,7 +933,10 @@ impl State { |
| 908 | 933 | let Some(applied) = applied else { |
| 909 | 934 | // What it would change is gone: Undo goes on to the step before. |
| 910 | 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 | 940 | _ => Ok(()), |
| 913 | 941 | }; |
| 914 | 942 | }; |
| ... | ... | @@ -1079,8 +1107,7 @@ mod tests { |
| 1079 | 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 | |
| 1083 | /// waits while an action is on its way. | |
| 1110 | /// Redo takes back the last undone first; a new edit ends what actions it held. | |
| 1084 | 1111 | #[test] |
| 1085 | 1112 | fn redo_mirrors_undo_and_new_edits_end_it() { |
| 1086 | 1113 | let mut timeline = Timeline::default(); |
| ... | ... | @@ -1098,9 +1125,6 @@ mod tests { |
| 1098 | 1125 | timeline.stepped(shown, true); |
| 1099 | 1126 | timeline.stepped(shown, false); |
| 1100 | 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 | 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 | 205 | let mut steps = Vec::new(); |
| 206 | 206 | for title in ["type One", "type Two"] { |
| 207 | 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 | 210 | steps.extend(chord("modifiers command control", "key Left")); |
| 211 | 211 | steps.push("accessibility back".into()); |