From 88f1e4e3378202d75912856115b193ed429dfa37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 4 Nov 2026 04:34:57 -0800 Subject: [PATCH] Actions: control requests run before a paced playback's next step A Stop or a document switch sent through the control channel in the frame a step comes due now takes effect before that step runs. An action played through the control channel at Pause For starts its first pause in the next frame, as the panel's Play does. --- crates/ui-egui/src/app.rs | 10 ++-- crates/ui-egui/src/panels/actions.rs | 69 +++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 5e2bbcefe..7826cdfe3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -348,10 +348,6 @@ impl VectorcraftApp { // The timer and heartbeat run in an idle window too. ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } - // An action playing at Step by Step or Pause For runs its next step when it is due. - if let Some(wait) = crate::panels::actions::tick_playback(self, now) { - ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); - } crate::keymap::note_os(ctx); shortcut_editor::sync(&self.ui); crate::panels::actions::sync_function_keys(&self.ui); @@ -374,6 +370,12 @@ impl VectorcraftApp { } self.flush_clipboard_out(ctx); self.drain_inbox(); + // An action playing at Step by Step or Pause For runs its next step when it is due, after + // the control requests and opened files above: a Stop or a document switch they bring + // comes first. + if let Some(wait) = crate::panels::actions::tick_playback(self, now) { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } if self.viewport_hidden(ctx) && (self.needs_hidden_paint() || (!dialog_was_open && self.ui.dialog.is_some())) { // A plain repaint runs only logic in a hidden window: the ui that applies injected // input, shows a queued dialog or issues a capture needs this command. egui-winit diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 209327519..3c9138f8d 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -234,7 +234,8 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result 0 { let hold = options.speed == PlaybackSpeed::Step; let doc = active_doc(app); - app.ui.action_playing = Some(Playing { label: a.name, steps, options, next_at: None, hold, doc }); + let played_in = app.tick_iteration.filter(|&t| t == app.host_iteration); + app.ui.action_playing = Some(Playing { label: a.name, steps, options, next_at: None, hold, doc, played_in }); if let Some(ctx) = &app.ui_ctx { ctx.request_repaint(); } @@ -304,6 +305,12 @@ pub struct Playing { /// The [`uid`](vectorcraft_engine::DocState::uid) of the document that was active after the /// last step played, if any. doc: Option, + /// The host iteration in which Play ran the first step, when that iteration's [`tick`] had + /// already begun (a control request, or the UI pass after it): the pause before the second step + /// starts in a later iteration. + /// + /// [`tick`]: VectorcraftApp::tick + played_in: Option, } fn active_doc(app: &VectorcraftApp) -> Option { @@ -327,6 +334,9 @@ pub fn tick_playback(app: &mut VectorcraftApp, now: f64) -> Option { } let pause = f64::from(playing.options.seconds); if playing.options.speed == PlaybackSpeed::Pause { + if playing.next_at.is_none() && playing.played_in == Some(app.host_iteration) { + return Some(0.0); + } let due = *playing.next_at.get_or_insert(now + pause); if now < due { return Some(due - now); @@ -3093,6 +3103,63 @@ mod tests { assert_eq!(x(&app), 2.0, "an action plays again after Stop"); } + #[test] + fn control_requests_come_before_the_step_that_is_due() { + let ctx = egui::Context::default(); + // One host iteration's `tick` at `now`. + let tick = |app: &mut VectorcraftApp, now: f64| { + app.now_override = Some(now); + app.begin_host_iteration(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| app.tick(ui.ctx())); + out.textures_delta.clear(); + }; + // The same, with `method` and `params` queued on the control channel → its reply. + let iteration = + |app: &mut VectorcraftApp, tx: &std::sync::mpsc::Sender, now: f64, method: &str, params: Value| { + let (req, reply) = crate::control::ControlRequest::new(method, params); + tx.send(req).unwrap(); + tick(app, now); + reply.try_recv().unwrap() + }; + // Stop: the step that is due does not run. + let (app, x) = app_with_three_moves(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = app.with_control(rx); + app.run(PLAYBACK_OPTIONS, json!({"speed": "pause", "seconds": 1})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert_eq!(tick_playback(&mut app, 0.0), Some(1.0)); + let r = iteration(&mut app, &tx, 5.0, "engine.execute", json!({"command": STOP_RECORDING})); + assert_eq!(r["result"], json!({"stopped": true}), "{r}"); + assert!(app.ui.action_playing.is_none()); + assert_eq!(x(&app), 1.0, "the step that was due did not run"); + // A switch to another document: the playback ends, and the step does not run in the first one. + let (app, x) = app_with_three_moves(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = app.with_control(rx); + app.run(PLAYBACK_OPTIONS, json!({"speed": "pause", "seconds": 1})).unwrap(); + play(&mut app, 0, 0).unwrap(); + assert_eq!(tick_playback(&mut app, 0.0), Some(1.0)); + let r = iteration(&mut app, &tx, 5.0, "engine.execute", json!({"command": "file.new", "params": {"width": 50, "height": 50}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(app.ui.action_playing.is_none(), "the playback ends in the same iteration"); + assert_eq!(app.ui.status, "the action stopped playing: its document is no longer active"); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert_eq!(x(&app), 1.0, "the step that was due did not run in the first document"); + // Play: the pause after the first step starts in the next iteration. + let (app, x) = app_with_three_moves(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = app.with_control(rx); + app.run(PLAYBACK_OPTIONS, json!({"speed": "pause", "seconds": 1})).unwrap(); + let r = iteration(&mut app, &tx, 0.0, "engine.execute", json!({"command": PLAY_ACTION, "params": {"action": "Three Moves"}})); + assert_eq!(r["result"]["pending"], json!(2), "{r}"); + assert_eq!(x(&app), 1.0); + tick(&mut app, 0.5); + tick(&mut app, 1.0); + assert_eq!(x(&app), 1.0, "the pause started at 0.5"); + tick(&mut app, 1.5); + assert_eq!(x(&app), 3.0); + } + #[test] fn an_action_recorded_while_another_plays_plays_its_steps() { let (mut app, x) = app_with_three_moves();