Record one action call when playback pauses for a step dialog

Fixes #7365

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-19 22:40:02 +02:00
co-authored by Claude Haiku 5.5
parent 3a896a5efc
commit 48009ce53d
24 changed files with 276 additions and 10 deletions
+11 -6
View File
@@ -348,15 +348,20 @@ pub fn finish_playback(s: &mut Session, recording: Option<(usize, usize)>, actio
s.actions.playback_stack.pop();
s.actions.playing = s.actions.playing.saturating_sub(1);
if let Some((_, idx)) = recording {
s.actions.recording = Some((s.journal.len(), idx));
let mut params = json!({"action": action.name});
if from > 0 {
params["from"] = json!(from);
}
s.journal.push(("actions.play".into(), params));
restore_recording(s, idx, &action.name, from);
}
}
/// Resume recording into action `idx` and journal the named call that suspended it.
pub fn restore_recording(s: &mut Session, idx: usize, action: &str, from: usize) {
s.actions.recording = Some((s.journal.len(), idx));
let mut params = json!({"action": action});
if from > 0 {
params["from"] = json!(from);
}
s.journal.push(("actions.play".into(), params));
}
/// Nested playback returns a structured failure even though the command itself succeeded.
/// Promote it to the caller's failed step so no later parent steps run after a child fails.
pub fn nested_failure(id: &str, value: &Value) -> Option<String> {
+221 -4
View File
@@ -95,8 +95,32 @@ pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &st
/// Replay edits synchronously, with view steps handled by the shell in the same order.
/// Engine steps keep the headless action semantics (no dialogs or background jobs).
pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result<Value, String> {
let (action, from) = actions_cmds::playback_plan(&app.session, params).map_err(|e| e.to_string())?;
let recording = actions_cmds::begin_playback(&mut app.session, &action).map_err(|e| e.to_string())?;
play_owned(app, params, None)
}
/// `suspended` is the recording a paused part of the same call took (`RECORD`); it comes
/// back, with that named call journaled, only when the whole call ends.
fn play_owned(app: &mut PhotocraftApp, params: &Value, suspended: Option<Value>) -> Result<Value, String> {
let (action, from) = match actions_cmds::playback_plan(&app.session, params) {
Ok(plan) => plan,
Err(e) => {
restore_recording(app, suspended.as_ref());
return Err(e.to_string());
}
};
let recording = match actions_cmds::begin_playback(&mut app.session, &action) {
Ok(r) => r,
Err(e) => {
restore_recording(app, suspended.as_ref());
return Err(e.to_string());
}
};
let owner = recording
.map(|(_, idx)| {
let into = app.session.actions.list.get(idx).map(|a| a.name.clone());
json!({"index": idx, "into": into, "action": action.name, "from": from})
})
.or(suspended);
let mut ran = 0u64;
let mut failed = None;
let mut paused = None;
@@ -154,7 +178,18 @@ pub(crate) fn play(app: &mut PhotocraftApp, params: &Value) -> Result<Value, Str
}
}
}
actions_cmds::finish_playback(&mut app.session, recording, &action, from);
actions_cmds::finish_playback(&mut app.session, None, &action, from);
match paused.as_ref().and_then(|p| p.get("dialog")).and_then(Value::as_u64).and_then(|d| app.ui.dialog_mut(d)) {
// Paused for a dialog: the call goes on after it, so recording stays suspended.
Some(d) if failed.is_none() => match (owner, d.fields.get_mut(RESUME)) {
(Some(owner), Some(Value::Object(r))) => {
r.insert(RECORD.into(), owner);
}
// No continuation to carry the recording: the call ends here.
(owner, _) => restore_recording(app, owner.as_ref()),
},
_ => restore_recording(app, owner.as_ref()),
}
Ok(match (failed, paused) {
(Some(f), _) => json!({"action": action.name, "ran": ran, "failed": f}),
(None, Some(p)) => json!({"action": action.name, "ran": ran, "paused": p}),
@@ -434,21 +469,74 @@ pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map<Str
/// The key a paused playback leaves in its dialog's fields: `{action, from}`.
pub const RESUME: &str = "__resumeAction";
/// The recording a paused call suspended, kept in its `RESUME` entry:
/// `{index, into, action, from}` (`into` is the name of the action being recorded).
/// A step dialog only closes through `dialogs::confirm` (OK, `↩`, automation) or
/// `dialogs::cancel` (Cancel, Esc, the close box, automation), which end the call through
/// `resume`, `abandoned` or `stopped`, so the recording comes back exactly once.
const RECORD: &str = "record";
/// Resume the recording a paused call suspended and journal that call, once.
fn restore_recording(app: &mut PhotocraftApp, owner: Option<&Value>) {
let Some(owner) = owner else { return };
let (Some(idx), Some(into), Some(name)) = (
owner.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()),
owner.get("into").and_then(Value::as_str),
owner.get("action").and_then(Value::as_str),
) else {
return;
};
let from = owner.get("from").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).unwrap_or(0);
// `suspended_guard` refuses what would move the recorded action, but another command may
// still replace the list: the call goes to the action of that name, never to whatever now
// sits at the old index, and is dropped if that action is gone.
let list = &app.session.actions.list;
let target = if list.get(idx).is_some_and(|a| a.name == into) { Some(idx) } else { list.iter().position(|a| a.name == into) };
if let Some(idx) = target
&& app.session.actions.recording.is_none()
{
actions_cmds::restore_recording(&mut app.session, idx, name, from);
}
}
/// Whether a step dialog holds a suspended recording.
fn suspended(app: &PhotocraftApp) -> bool {
app.ui.dialogs.iter().any(|d| d.fields.get(RESUME).and_then(|r| r.get(RECORD)).is_some())
}
/// While a step dialog holds a suspended recording, refuse what would lose its call: a new
/// recording (the restore would find recording taken) or deleting/moving actions (the recorded
/// action could go away or change place). The engine checks these against `recording`, which
/// is `None` then.
pub(crate) fn suspended_guard(app: &PhotocraftApp, id: &str) -> Result<(), String> {
if matches!(id, "actions.record" | "actions.delete" | "actions.move") && suspended(app) {
return Err(tl!("Confirm or cancel the open action step dialog first").into());
}
Ok(())
}
/// A step dialog's command failed: the action ends there.
pub fn abandoned(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) {
restore_recording(app, fields.get(RESUME).and_then(|r| r.get(RECORD)));
}
/// A step dialog confirmed: continue the action after it (`dialogs::confirm` calls this).
pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) -> Result<(), String> {
let Some(r) = fields.get(RESUME) else { return Ok(()) };
let owner = r.get(RECORD).cloned();
// The dialog's command may run as a background job; the next step needs its result.
if let Some(doc) = app.session.active().map(|d| d.doc.id)
&& let Some(job) = app.session.job_on(doc)
&& let Err(e) = app.session.wait_job(job.id)
{
restore_recording(app, owner.as_ref());
let template = "Action playback failed: {error}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e.to_string())]);
app.ui.status_error = true;
return Err(e.to_string());
}
let params = json!({"action": r.get("action").cloned().unwrap_or(Value::Null), "from": r.get("from").cloned().unwrap_or(Value::Null)});
match play(app, &params) {
match play_owned(app, &params, owner) {
Ok(v) => {
report_play(app, &v);
Ok(())
@@ -465,6 +553,7 @@ pub fn resume(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>)
/// A step dialog cancelled: the action stops there, as in Photoshop.
pub fn stopped(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) {
if fields.contains_key(RESUME) {
abandoned(app, fields);
app.ui.status = tl!("Action playback stopped").to_string();
app.ui.status_error = false;
}
@@ -1687,6 +1776,134 @@ mod tests {
assert!(v.get("paused").is_none());
}
/// An 8×6 document, a Child action whose first step (a blur) shows its dialog and whose
/// second step adds a "Suffix" layer, and recording into Owner started.
fn recording_with_dialog_child() -> PhotocraftApp {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 8, "height": 6, "background": "#203040"})).unwrap();
let mut child =
Action::new("Child", vec![("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})), ("layer.new.layer".into(), json!({"name": "Suffix"}))]);
child.dialogs.insert(0);
app.session.actions.list.push(child);
app.run("actions.record", json!({"name": "Owner"})).unwrap();
app
}
fn suffixes(app: &PhotocraftApp) -> usize {
app.session.active().unwrap().doc.walk().iter().filter(|(_, _, l)| l.name == "Suffix").count()
}
fn child_call() -> Vec<(String, Value)> {
vec![("actions.play".to_string(), json!({"action": "Child"}))]
}
/// Play Child and return the id of its step dialog.
fn pause_child(app: &mut PhotocraftApp) -> u64 {
let v = play(app, &json!({"action": "Child"})).unwrap();
v["paused"]["dialog"].as_u64().unwrap()
}
#[test]
fn a_recorded_call_paused_by_a_dialog_records_one_call_and_replays_once() {
let mut app = recording_with_dialog_child();
let refusal = crate::i18n::t("Confirm or cancel the open action step dialog first").to_string();
// Confirm: the dialog command and the resumed rest stay inside the one named call.
let live = app.session.journal.len();
let id = pause_child(&mut app);
assert!(app.session.actions.recording.is_none(), "recording stays suspended while the step dialog is open");
let names = |app: &PhotocraftApp| app.session.actions.list.iter().map(|a| a.name.clone()).collect::<Vec<_>>();
let before = names(&app);
assert_eq!(app.run("actions.record", json!({"name": "Intruder"})), Err(refusal.clone()), "no second recording while the call is suspended");
assert_eq!(app.run("actions.delete", json!({"action": "Owner"})), Err(refusal.clone()), "the suspended owner can't be deleted");
assert_eq!(app.run("actions.move", json!({"action": "Owner", "to": 0})), Err(refusal.clone()), "the suspended owner can't be moved");
assert_eq!(names(&app), before, "the refused commands leave the list alone");
crate::dialogs::confirm(&mut app, id).unwrap();
assert!(app.session.actions.recording.is_some(), "recording comes back when the call completes");
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[1].steps, child_call());
assert_eq!(suffixes(&app), 1);
// Replaying the recording repeats the call once.
app.run("file.new", json!({"width": 8, "height": 6})).unwrap();
let start = app.session.journal.len();
app.automation_input = true;
let v = play(&mut app, &json!({"action": "Owner"})).unwrap();
app.automation_input = false;
assert!(v.get("failed").is_none(), "{v}");
assert_eq!(suffixes(&app), 1, "replay creates the suffix once");
let edits = |from: usize| -> Vec<(String, Value)> {
app.session.journal[from..].iter().filter(|(id, _)| !id.starts_with("actions.") && id != "file.new").cloned().collect()
};
let expected = vec![("filter.blur.gaussianBlur".to_string(), json!({"radius": 1.5})), ("layer.new.layer".to_string(), json!({"name": "Suffix"}))];
assert_eq!(edits(start), expected, "replay journals the blur and the suffix once each, in order");
assert_eq!(edits(live), expected[..].iter().chain(&expected).cloned().collect::<Vec<_>>(), "live then replay");
// Cancel: the call ends at the dialog and is recorded once.
app.run("actions.record", json!({"name": "Cancelled"})).unwrap();
let id = pause_child(&mut app);
crate::dialogs::cancel(&mut app, id).unwrap();
assert!(app.session.actions.recording.is_some(), "cancel restores recording");
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[2].steps, child_call());
}
#[test]
fn a_failing_step_dialog_command_ends_the_recorded_call_once() {
let mut app = recording_with_dialog_child();
let id = pause_child(&mut app);
assert!(app.session.actions.recording.is_none(), "recording stays suspended while the step dialog is open");
app.ui.dialog_mut(id).unwrap().fields.insert("radius".into(), json!("not a radius"));
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "the dialog command fails");
assert!(app.session.actions.recording.is_some(), "recording comes back when the dialog command fails");
assert_eq!(suffixes(&app), 0, "the action stops at the failed step");
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[1].steps, child_call());
}
#[test]
fn a_failing_resumed_playback_ends_the_recorded_call_once() {
let mut app = recording_with_dialog_child();
let id = pause_child(&mut app);
// The rest of the call can't be found any more: resuming it fails.
app.session.actions.list[0].name = "Renamed".into();
assert!(crate::dialogs::confirm(&mut app, id).is_err(), "the resumed playback fails");
assert!(app.session.actions.recording.is_some(), "recording comes back when the resumed playback fails");
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[1].steps, child_call());
}
#[test]
fn a_suspended_call_is_recorded_into_its_action_after_the_list_changes() {
let mut app = recording_with_dialog_child();
let id = pause_child(&mut app);
// Something else puts an action where Owner was.
app.session.actions.list.insert(1, Action::new("Bystander", vec![]));
crate::dialogs::confirm(&mut app, id).unwrap();
app.run("actions.stop", json!({})).unwrap();
assert!(app.session.actions.list[1].steps.is_empty(), "the action now at the old index gets nothing");
assert_eq!(app.session.actions.list[2].name, "Owner");
assert_eq!(app.session.actions.list[2].steps, child_call());
}
#[test]
fn escape_on_a_step_dialog_ends_the_recorded_call_once() {
let app = recording_with_dialog_child();
let mut h = egui_kittest::Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app);
PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]);
h.run_steps(2);
pause_child(h.state_mut());
h.run_steps(4);
assert!(h.state().session.actions.recording.is_none(), "recording stays suspended while the step dialog is open");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().ui.dialogs.is_empty(), "Esc closes the step dialog");
assert!(h.state().session.actions.recording.is_some(), "Esc restores recording");
let app = h.state_mut();
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[1].steps, child_call());
}
#[test]
fn button_mode_plays_the_clicked_action_and_hides_its_steps() {
use egui_kittest::{Harness, kittest::Queryable};
+3
View File
@@ -644,6 +644,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
DialogKind::Command => {
let Some(cmd) = d.fields.get("__command").and_then(Value::as_str).map(str::to_string) else {
app.filter_preview = None;
crate::actions::abandoned(app, &d.fields);
return Err("dialog has no command".into());
};
let (cmd, params) = crate::smart_ui::confirm_command(&d.fields, cmd, crate::filter_dialog::params_of(&d.fields));
@@ -665,6 +666,8 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
crate::filter_dialog::remember(app, &cmd, &d.fields);
// A step dialog of a playing action: the action continues after it.
crate::actions::resume(app, &d.fields)?;
} else {
crate::actions::abandoned(app, &d.fields);
}
result
}
+2
View File
@@ -3852,3 +3852,5 @@ navigator Zoom Zvětšení
Use Použít
In Front Vpředu
Behind Vzadu
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Nejprve potvrďte nebo zrušte otevřený dialog kroku akce
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3245,3 +3245,5 @@ curves Smooth Glätten
In Front Davor
Behind Dahinter
Blend Mischen
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Bestätige oder brich zuerst den offenen Dialog des Aktionsschritts ab
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3944,3 +3944,5 @@ curves Smooth Εξομάλυνση
Use Χρήση
In Front Μπροστά
Behind Πίσω
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Επιβεβαιώστε ή ακυρώστε πρώτα το ανοιχτό παράθυρο βήματος της ενέργειας
Can't render this file because it contains an unexpected character in line 1030 and column 18.
+2
View File
@@ -4206,3 +4206,5 @@ navigator Zoom Zoom
Use Usar
In Front Delante
Behind Detrás
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Confirme o cancele primero el cuadro de diálogo abierto del paso de la acción
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -3252,3 +3252,5 @@ curves Smooth Lisser
Use Utiliser
In Front Devant
Behind Derrière
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Validez ou annulez d’abord la boîte de dialogue ouverte de l’étape de l’action
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4163,3 +4163,5 @@ cameraRaw Yellow Sárga
Use Használat
In Front Elöl
Behind Mögött
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Előbb hagyd jóvá vagy vond vissza a művelet lépésének nyitott párbeszédablakát
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3239,3 +3239,5 @@ curves Smooth Haluskan
Use Gunakan
In Front Di Depan
Behind Di Belakang
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Konfirmasi atau batalkan dulu dialog langkah tindakan yang terbuka
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -4361,3 +4361,5 @@ shape Appearance Aspetto
Use Usa
In Front Davanti
Behind Dietro
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Conferma o annulla prima la finestra di dialogo aperta del passo dell’azione
Can't render this file because it contains an unexpected character in line 2653 and column 53.
+2
View File
@@ -3921,3 +3921,5 @@ curves Smooth スムーズ
Use 使用
In Front 前面
Behind 背面
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 先に開いているアクションのステップのダイアログを確定またはキャンセルしてください
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3426,3 +3426,5 @@ curves Smooth 매끄럽게
In Front 앞
Behind 뒤
Blend 혼합
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 먼저 열려 있는 액션 단계 대화 상자를 확인하거나 취소하세요
Can't render this file because it contains an unexpected character in line 3167 and column 53.
+2
View File
@@ -4004,3 +4004,5 @@ curves Smooth Vloeiend maken
Use Gebruiken
In Front Voor
Behind Achter
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Bevestig of annuleer eerst het geopende dialoogvenster van de handelingsstap
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3843,3 +3843,5 @@ curves Smooth Wygładź
Use Użyj
In Front Z przodu
Behind Z tyłu
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Najpierw zatwierdź lub anuluj otwarte okno kroku operacji
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4034,3 +4034,5 @@ layerLabel No Color Sem cor
Use Usar
In Front Na frente
Behind Atrás
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Confirme ou cancele primeiro a caixa de diálogo aberta da etapa da ação
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3218,3 +3218,5 @@ curves Smooth Сгладить
Use Использовать
In Front Спереди
Behind Сзади
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Сначала подтвердите или отмените открытый диалог шага операции
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -4317,3 +4317,5 @@ shape Appearance Izgled
Use Koristi
In Front Ispred
Behind Iza
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Prvo potvrdite ili otkažite otvoreni dijalog koraka radnje
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3974,3 +3974,5 @@ cameraData Lens Lens
Use Kullan
In Front Önde
Behind Arkada
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Önce açık eylem adımı iletişim kutusunu onaylayın veya iptal edin
Can't render this file because it contains an unexpected character in line 8 and column 34.
+2
View File
@@ -3952,3 +3952,5 @@ curves Smooth Згладити
Use Використати
In Front Спереду
Behind Ззаду
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Спочатку підтвердьте або скасуйте відкритий діалог кроку операції
Can't render this file because it contains an unexpected character in line 1388 and column 18.
+2
View File
@@ -3731,3 +3731,5 @@ curves Smooth Làm mượt
Use Dùng
In Front Phía trước
Behind Phía sau
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first Hãy xác nhận hoặc hủy hộp thoại bước hành động đang mở trước
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2868,3 +2868,5 @@ curves Smooth 平滑
In Front 前面
Behind 后面
Blend 混合
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 请先确认或取消打开的动作步骤对话框
Can't render this file because it contains an unexpected character in line 2601 and column 53.
+2
View File
@@ -2853,3 +2853,5 @@ curves Smooth 平滑
In Front 前面
Behind 後面
Blend 混合
# Actions: a step dialog holds a suspended recording (#7365)
Confirm or cancel the open action step dialog first 請先確認或取消開啟中的動作步驟對話框
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -1121,6 +1121,7 @@ impl PhotocraftApp {
return Err(if self.ui.status.is_empty() { tl!("The transform couldn't be applied.").into() } else { self.ui.status.clone() });
}
}
actions::suspended_guard(self, id)?;
if let Some(result) = panel_docking::command(self, id, &params) {
return result;
}