Files
photocraft/crates/ui-egui/src/actions.rs
T
59c8bd3b26 Fix/7381 action worker failure 20261018t2254jkt (#7465)
* fix(actions): stop resumed playback when a dialog's worker fails

* test(actions): reject suffix after failing background dialog filter

* fix(actions): propagate the failed worker result through dialog confirmation

* fix(dialogs): return action worker failures to callers

* Collapse the nested if in actions::resume (clippy::collapsible_if)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 16:56:44 -04:00

1663 lines
83 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Actions panel: record and replay command sequences.
//!
//! The list and the recording flag live on the engine session (`actions.record` / `stop` /
//! `play` / `list` / `get` / `delete` / `move` / `rename`), so the panel, the CLI and MCP share them. This module
//! keeps only which row is selected and which rows are expanded.
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use photocraft_engine::actions_cmds::{self, Action};
use crate::PhotocraftApp;
use crate::theme::Tokens;
/// Which row is selected and which rows are expanded. The action list itself is
/// [`photocraft_engine::Session::actions`].
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ActionsUi {
pub selected: Option<usize>,
pub selected_step: Option<usize>,
pub expanded: Vec<bool>,
/// Set folders shown collapsed (Photoshop's set disclosure triangles).
pub collapsed_sets: Vec<String>,
/// Photoshop's Button Mode: each action is a coloured button, and a click plays it.
#[serde(default)]
pub button_mode: bool,
#[serde(skip)]
reveal_recording: Option<(usize, usize)>,
/// The action whose name is being edited in place (double-click on its name).
#[serde(skip)]
renaming: Option<RenameAction>,
}
#[derive(Clone, Debug, PartialEq)]
struct RenameAction {
index: usize,
text: String,
focused: bool,
}
pub(crate) fn label_of(id: &str) -> String {
photocraft_engine::commands::find(id)
.map(|c| c.label)
.or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| c.1))
// Shell menu items recorded as steps, like Save a Copy (#4373).
.or_else(|| crate::menu_catalog::CATALOG.iter().find(|c| c.3 == id).map(|c| c.1))
.map(|label| label.trim_end_matches('…').to_string())
.unwrap_or_else(|| id.to_string())
}
/// The selected action, or the first one when nothing is selected.
pub fn selected_action(app: &PhotocraftApp) -> Option<&Action> {
let list = &app.session.actions.list;
let idx = app.ui.actions.selected.filter(|i| *i < list.len());
idx.and_then(|i| list.get(i)).or_else(|| list.first())
}
/// Index of the playback target. Photoshop's Play button uses the first action when
/// the panel has actions but none of its rows is selected.
fn playback_selection(app: &PhotocraftApp) -> Option<usize> {
let n = app.session.actions.list.len();
if n == 0 { None } else { Some(app.ui.actions.selected.filter(|i| *i < n).unwrap_or(0)) }
}
/// Steps in the `[[id, params], …]` shape batch and droplets already accept, without the
/// steps switched off in the panel.
pub fn action_steps(action: &Action) -> Vec<Value> {
action.enabled_steps().map(|(_, (id, p))| json!([id, p])).collect()
}
/// Named action bindings use the existing persisted shortcut overrides. Keeping the
/// name in the key means selecting or deleting another row cannot retarget F6.
pub(crate) const SHORTCUT_PREFIX: &str = actions_cmds::PLAY_SHORTCUT_PREFIX;
pub(crate) fn shortcut_id(name: &str) -> String {
format!("{SHORTCUT_PREFIX}{name}")
}
pub(crate) fn assign_shortcut(app: &mut PhotocraftApp, name: &str, shortcut: &str) -> Result<Value, String> {
let id = shortcut_id(name);
let mut set = serde_json::Map::new();
// A function key belongs to only one action. Ordinary menu bindings remain
// intact and become available again when the action binding is removed.
for (other, assigned) in &app.session.prefs().shortcuts {
if other.starts_with(SHORTCUT_PREFIX) && other != &id && assigned == shortcut && !shortcut.is_empty() {
set.insert(other.clone(), json!(""));
}
}
set.insert(id, json!(shortcut));
app.run("edit.keyboardShortcuts", json!({"set": set, "allowUnknown": true, "removeConflicts": false}))
}
/// 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())?;
let mut ran = 0u64;
let mut failed = None;
let mut paused = None;
for (i, (id, p)) in action.enabled_steps().skip_while(|(i, _)| *i < from) {
// The dialog toggle: show the step's dialog with its recorded values and continue
// from the next step when it is confirmed (`resume`). Agents play headlessly.
if action.dialogs.contains(&i) && !app.automation_input && (crate::filter_dialog::has_dialog(id) || crate::adjust_dialog::has_dialog(id)) {
let label = label_of(id);
let dialog = crate::dialogs::open_command_dialog(app, id, &label);
if let Some(d) = app.ui.dialog_mut(dialog) {
if let Some(o) = p.as_object() {
d.fields.extend(o.iter().map(|(k, v)| (k.clone(), v.clone())));
}
d.fields.insert(RESUME.into(), json!({"action": action.name, "from": i + 1}));
}
paused = Some(json!({"step": i, "id": id, "dialog": dialog}));
break;
}
let result = (|| {
if let Some(auth) = app.session.authorize {
auth(id, p).map_err(|e| e.to_string())?;
}
if id == "actions.play" {
play(app, p)
} else if actions_cmds::shell_save_command(id) {
// Write synchronously, so the next step doesn't meet a document locked by a
// background save.
let background = std::mem::replace(&mut app.background_jobs, false);
let r = app.replay_save(id, p);
app.background_jobs = background;
r
} else if actions_cmds::shell_view_command(id) {
app.sync_views();
app.run(id, p.clone())
} else {
app.session.execute(id, p.clone()).map_err(|e| e.to_string())
}
})();
match result {
Ok(value) => {
if let Some(error) = actions_cmds::nested_failure(id, &value) {
failed = Some(json!({"step": i, "id": id, "error": error}));
break;
}
if let Some(inner) = value.get("paused").filter(|p| p.is_object()) {
// A called action paused for a dialog; it resumes alone when confirmed.
paused = Some(inner.clone());
break;
}
ran += 1;
}
Err(error) => {
failed = Some(json!({"step": i, "id": id, "error": error}));
break;
}
}
}
actions_cmds::finish_playback(&mut app.session, recording, &action, from);
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}),
(None, None) => json!({"action": action.name, "ran": ran}),
})
}
/// Execute the actual menu command, not a synthetic keystroke. Fit is deferred
/// until canvas layout, so it uses the dimensions after all preceding action steps.
pub(crate) fn run_view(app: &mut PhotocraftApp, id: &str, params: Value) -> Result<Value, String> {
app.sync_views();
let i = app.session.active_index().ok_or("no document")?;
let v = app.ui.views.get_mut(i).ok_or("no document view")?;
v.fill_pending = false;
match id {
"view.fitOnScreen" => v.fit_pending = true,
"view.zoomIn" => {
v.fit_pending = false;
v.zoom = crate::zoom_levels::step(v.zoom, 1, v.doc_size);
}
"view.zoomOut" => {
v.fit_pending = false;
v.zoom = crate::zoom_levels::step(v.zoom, -1, v.doc_size);
}
"view.actualPixels" => {
v.fit_pending = false;
v.zoom = 1.0;
}
_ => return Err(format!("unsupported view command: {id}")),
}
app.session.journal.push((id.into(), params));
Ok(Value::Null)
}
fn begin_recording(app: &mut PhotocraftApp, append: bool) {
if app.session.actions.recording.is_some() {
return;
}
let params = if append { app.ui.actions.selected.map_or(json!({}), |i| json!({"action": i})) } else { json!({}) };
let Ok(v) = app.run("actions.record", params) else { return };
let Some(i) = v.get("index").and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()) else { return };
app.ui.actions.selected = Some(i);
app.ui.actions.selected_step = None;
if app.ui.actions.expanded.len() <= i {
app.ui.actions.expanded.resize(i + 1, false);
}
app.ui.actions.expanded[i] = true;
}
pub(crate) fn report_play(app: &mut PhotocraftApp, v: &Value) {
if let Some(paused) = v.get("paused").filter(|p| p.is_object()) {
let step = paused.get("step").and_then(Value::as_u64).unwrap_or(0);
let template = "Step {step} is waiting for its dialog";
app.ui.status = crate::i18n::fmt(tl!(template), &[("step", &(step + 1).to_string())]);
app.ui.status_error = false;
return;
}
let Some(failed) = v.get("failed").filter(|f| f.is_object()) else {
let ran = v.get("ran").and_then(Value::as_u64).unwrap_or(0);
app.ui.status = crate::i18n::trn(crate::i18n::current(), ran, "Played {n} step", "Played {n} steps");
app.ui.status_error = false;
return;
};
let step = failed.get("step").and_then(Value::as_u64).unwrap_or(0);
let id = failed.get("id").and_then(Value::as_str).unwrap_or("");
let error = failed.get("error").and_then(Value::as_str).unwrap_or("");
let template = "Step {step} ({id}) failed: {error}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("step", &(step + 1).to_string()), ("id", id), ("error", error)]);
app.ui.status_error = true;
}
fn visible_steps(app: &PhotocraftApp, action: usize) -> Vec<(String, Value)> {
app.session.actions.list.get(action).into_iter().flat_map(|a| a.steps.iter()).chain(actions_cmds::pending_steps(&app.session, action)).cloned().collect()
}
/// "3 steps", or "2/3 steps" when some are switched off (Photoshop shows a red partial check).
fn step_count(steps: usize, disabled: usize) -> String {
if disabled == 0 {
crate::i18n::trn(crate::i18n::current(), steps as u64, "{n} step", "{n} steps")
} else {
let count = crate::i18n::trn(crate::i18n::current(), steps as u64, "{n} step", "{n} steps");
format!("{}/{count}", steps.saturating_sub(disabled))
}
}
fn button_fill(color: &str, t: &Tokens) -> Color32 {
// Photoshop's Actions button colours. Empty uses the panel card so an uncoloured action stays readable.
match color {
"red" => Color32::from_rgb(196, 64, 64),
"orange" => Color32::from_rgb(204, 120, 40),
"yellow" => Color32::from_rgb(196, 176, 48),
"green" => Color32::from_rgb(56, 140, 72),
"blue" => Color32::from_rgb(48, 104, 176),
"violet" => Color32::from_rgb(120, 72, 168),
"gray" => t.text_dim,
_ => t.card,
}
}
fn button_label_color(color: &str, t: &Tokens) -> Color32 {
if color.is_empty() { t.text } else { Color32::from_rgb(20, 20, 20) }
}
fn paint_check(ui: &egui::Ui, rect: Rect, on: bool, t: &Tokens) {
let b = Rect::from_center_size(rect.center(), vec2(11.0, 11.0));
ui.painter().rect_stroke(b, 2.0, Stroke::new(1.0, t.text_faint), egui::StrokeKind::Inside);
if on {
let pts = vec![pos2(b.left() + 2.5, b.center().y), pos2(b.left() + 4.5, b.bottom() - 3.0), pos2(b.right() - 2.5, b.top() + 2.5)];
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.5, t.text)));
}
}
fn step_label(id: &str, params: &Value) -> String {
if id == "actions.play" {
let target = params.get("action").map(|v| v.as_str().map(str::to_owned).unwrap_or_else(|| v.to_string())).unwrap_or_default();
format!("{}: {target}", label_of(id))
} else if id == "layer.select" {
let target = params
.get("name")
.and_then(Value::as_str)
.or_else(|| params.get("layer").and_then(Value::as_str))
.map(str::to_owned)
.or_else(|| params.get("layer").and_then(Value::as_u64).map(|id| format!("ID {id}")));
target.map_or_else(|| label_of(id), |target| format!("{} ({target})", label_of(id)))
} else if id == "actions.conditional" {
// "If hasLayers: FS8-Layers / FS8-No-Layers"
let cond = params.get("if").and_then(Value::as_str).unwrap_or("?");
let branch = |k: &str| params.get(k).and_then(Value::as_str).unwrap_or("–").to_string();
format!("{}: {cond} → {} / {}", label_of(id), branch("then"), branch("else"))
} else if id == photocraft_engine::atn_cmds::STOP_MESSAGE {
format!("{}: {}", label_of(id), params.get("message").and_then(Value::as_str).unwrap_or_default())
} else if id == photocraft_engine::atn_cmds::PHOTOSHOP_STEP {
// A step from a .atn file PhotoCraft can't play yet: its Photoshop name, marked.
let name = params.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or("?");
crate::i18n::fmt(tl!("{name} (Photoshop)"), &[("name", name)])
} else {
label_of(id)
}
}
/// Command params for a picked file: a real path keeps the journal small; browser files arrive
/// as bytes.
fn source(name: &str, bytes: &[u8]) -> Value {
#[cfg(not(target_arch = "wasm32"))]
if std::path::Path::new(name).is_absolute() && std::path::Path::new(name).is_file() {
return json!({"path": name});
}
let _ = name;
json!({"data": photocraft_engine::paint::tile::b64_encode(bytes)})
}
/// Load a `.atn` file's bytes into the Actions panel (the panel menu's Load Actions…).
pub fn load_bytes(app: &mut PhotocraftApp, name: &str, bytes: &[u8]) -> Result<Value, String> {
let r = app.run("actions.load", source(name, bytes))?;
let n = r["actions"].as_array().map_or(0, Vec::len) as u64;
let unsupported = r["unsupported"].as_array().map_or(0, Vec::len);
let shown = crate::file_open::display_name(name);
let loaded =
crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Loaded {n} action from {file}", "Loaded {n} actions from {file}"), &[("file", &shown)]);
app.ui.status_error = false;
app.ui.status = if unsupported > 0 {
let note = crate::i18n::trn(
crate::i18n::current(),
unsupported as u64,
"{n} step has no PhotoCraft equivalent yet",
"{n} steps have no PhotoCraft equivalent yet",
);
let template = "{message} ({note})";
crate::i18n::fmt(tl!(template), &[("message", &loaded), ("note", &note)])
} else {
loaded
};
// Show what arrived: select the first loaded action, expanded.
let list = &app.session.actions.list;
if let Some(first) = r["actions"][0].as_str()
&& let Some(i) = list.iter().position(|a| a.name == first)
{
app.ui.actions.selected = Some(i);
app.ui.actions.selected_step = None;
app.ui.actions.expanded.resize(list.len(), false);
if let Some(e) = app.ui.actions.expanded.get_mut(i) {
*e = true;
}
}
app.ui.panels.history = true;
Ok(r)
}
fn load_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
app.pick_file_bytes_filtered(&["atn"], |app, name, bytes| load_bytes(app, &name, &bytes))
}
/// Save Actions…: the selected action's set (every action of it), or every action when none
/// came from a set, as a `.atn` file Photoshop loads.
fn save_dialog(app: &mut PhotocraftApp, set: Option<String>) -> Result<Value, String> {
let set = set.or_else(|| selected_action(app).and_then(|a| a.set.clone()));
let params = match &set {
Some(set) => json!({"set": set, "skipUnsupported": true}),
None => json!({"skipUnsupported": true}),
};
let r = app.run("actions.save", params)?;
let bytes = r["data"].as_str().and_then(photocraft_engine::paint::tile::b64_decode).ok_or("the export returned no data")?;
let n = r["actions"].as_array().map_or(0, Vec::len) as u64;
let skipped = r["skipped"].as_array().map_or(0, Vec::len);
let file = format!("{}.atn", r["set"].as_str().unwrap_or("Actions"));
app.pick_save(&file, move |app, path| {
let write = app.services.write.as_mut().ok_or("no writer configured")?;
write(&path, &bytes)?;
let shown = crate::file_open::display_name(&path);
let saved =
crate::i18n::fmt(&crate::i18n::trn(crate::i18n::current(), n, "Saved {n} action to {file}", "Saved {n} actions to {file}"), &[("file", &shown)]);
app.ui.status_error = false;
app.ui.status = if skipped > 0 {
let note = crate::i18n::trn(
crate::i18n::current(),
skipped as u64,
"{n} step with no Photoshop equivalent left out",
"{n} steps with no Photoshop equivalent left out",
);
let template = "{message} ({note})";
crate::i18n::fmt(tl!(template), &[("message", &saved), ("note", &note)])
} else {
saved
};
Ok(json!({"path": path, "actions": n, "skipped": skipped}))
})
}
const INSERT_STOP: &str = "__insertStop";
pub fn owns_insert_stop(fields: &serde_json::Map<String, Value>) -> bool {
fields.contains_key(INSERT_STOP)
}
/// Insert Stop…: a message step after the selected step (or at the end of the selected action).
fn insert_stop_dialog(app: &mut PhotocraftApp) -> Result<Value, String> {
let action = app.ui.actions.selected.filter(|i| *i < app.session.actions.list.len()).ok_or_else(|| tl!("Select an action first").to_string())?;
let len = app.session.actions.list[action].steps.len() + actions_cmds::pending_steps(&app.session, action).count();
let at = app.ui.actions.selected_step.map_or(len, |s| (s + 1).min(len));
let fields =
json!({INSERT_STOP: true, "__command": "actions.insert", "__label": "Insert Stop", "action": action, "at": at, "message": "", "allowContinue": false});
let id = app.ui.open_dialog(crate::state::DialogKind::Command, fields.as_object().cloned().unwrap_or_default());
Ok(json!({"dialog": id}))
}
pub fn insert_stop_body(ui: &mut egui::Ui, fields: &mut serde_json::Map<String, Value>) {
ui.label(tl!("Message:"));
let mut message = fields.get("message").and_then(Value::as_str).unwrap_or_default().to_string();
ui.add(egui::TextEdit::multiline(&mut message).desired_rows(4).desired_width(320.0));
fields.insert("message".into(), json!(message));
ui.add_space(6.0);
let mut allow = fields.get("allowContinue").and_then(Value::as_bool).unwrap_or(false);
ui.checkbox(&mut allow, tl!("Allow Continue"));
fields.insert("allowContinue".into(), json!(allow));
}
pub fn insert_stop_confirm(app: &mut PhotocraftApp, fields: &serde_json::Map<String, Value>) -> Result<Value, String> {
let action = fields.get("action").and_then(Value::as_u64).ok_or("the dialog names no action")?;
let params = json!({
"action": action,
"at": fields.get("at").cloned().unwrap_or(Value::Null),
"id": photocraft_engine::atn_cmds::STOP_MESSAGE,
"params": {"message": fields.get("message").cloned().unwrap_or(json!("")), "allowContinue": fields.get("allowContinue").cloned().unwrap_or(json!(false))},
});
let r = app.run("actions.insert", params)?;
if let (Some(a), Some(step)) = (usize::try_from(action).ok(), r["step"].as_u64().and_then(|s| usize::try_from(s).ok())) {
app.ui.actions.selected = Some(a);
app.ui.actions.selected_step = Some(step);
app.ui.actions.expanded.resize(app.session.actions.list.len(), false);
if let Some(e) = app.ui.actions.expanded.get_mut(a) {
*e = true;
}
}
Ok(r)
}
/// The key a paused playback leaves in its dialog's fields: `{action, from}`.
pub const RESUME: &str = "__resumeAction";
/// 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(()) };
// 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)
{
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) {
Ok(v) => {
report_play(app, &v);
Ok(())
}
Err(e) => {
let template = "Action playback failed: {error}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]);
app.ui.status_error = true;
Err(e)
}
}
}
/// 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) {
app.ui.status = tl!("Action playback stopped").to_string();
app.ui.status_error = false;
}
}
/// The Actions entries of the panel group's menu ("…").
pub fn panel_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let mut act: Option<&str> = None;
if ui.button(tl!("Load Actions…")).clicked() {
act = Some("load");
}
let any = !app.session.actions.list.is_empty();
if ui.add_enabled(any, egui::Button::new(tl!("Save Actions…"))).clicked() {
act = Some("save");
}
ui.separator();
let selected = app.ui.actions.selected.is_some_and(|i| i < app.session.actions.list.len());
if ui.add_enabled(selected, egui::Button::new(tl!("Insert Stop…"))).clicked() {
act = Some("stop");
}
let Some(a) = act else { return };
ui.close();
let r = match a {
"load" => load_dialog(app),
"stop" => insert_stop_dialog(app),
_ => save_dialog(app, None),
};
if let Err(e) = r
&& e != "cancelled"
{
app.ui.status = e;
app.ui.status_error = true;
}
}
#[derive(Clone, Copy)]
struct DragRow {
action: usize,
step: Option<usize>,
}
/// Drops use an insertion line, converted to the final index expected by actions.move.
fn drag_row(ui: &egui::Ui, response: &egui::Response, row: DragRow, t: &Tokens) -> Option<(DragRow, usize)> {
response.dnd_set_drag_payload(row);
let source = response.dnd_hover_payload::<DragRow>()?;
if source.step.is_some() != row.step.is_some() || (source.step.is_some() && source.action != row.action) {
return None;
}
let after = ui.input(|i| i.pointer.hover_pos().is_some_and(|p| p.y >= response.rect.center().y));
let index = row.step.unwrap_or(row.action);
let from = source.step.unwrap_or(source.action);
let slot = index.saturating_add(usize::from(after));
let to = if from < slot { slot.saturating_sub(1) } else { slot };
let y = if after { response.rect.bottom() } else { response.rect.top() };
ui.painter().line_segment([pos2(response.rect.left() + 22.0, y), pos2(response.rect.right(), y)], Stroke::new(2.0, t.accent));
response.dnd_release_payload::<DragRow>().map(|source| (*source, to))
}
fn move_row(app: &mut PhotocraftApp, source: DragRow, to: usize) {
let mut params = json!({"action": source.action, "to": to});
if let Some(step) = source.step {
params["step"] = json!(step);
}
if app.run("actions.move", params).is_err() {
return;
}
if source.step.is_some() {
app.ui.actions.selected = Some(source.action);
app.ui.actions.selected_step = Some(to);
} else {
app.ui.actions.expanded.resize(app.session.actions.list.len(), false);
if source.action < app.ui.actions.expanded.len() && to < app.ui.actions.expanded.len() {
let expanded = app.ui.actions.expanded.remove(source.action);
app.ui.actions.expanded.insert(to, expanded);
}
app.ui.actions.selected = Some(to);
app.ui.actions.selected_step = None;
// Dropped among another set's actions: the action joins that set (the row above wins).
let list = &app.session.actions.list;
let neighbour = to.checked_sub(1).and_then(|j| list.get(j)).or_else(|| list.get(to + 1)).and_then(|a| a.set.clone());
if list.get(to).is_some_and(|a| a.set != neighbour) {
let _ = app.run("actions.assignSet", json!({"action": to, "set": neighbour}));
}
}
}
fn delete_selection(app: &mut PhotocraftApp) {
let Some(action) = app.ui.actions.selected else { return };
let step = app.ui.actions.selected_step;
let mut params = json!({"action": action});
if let Some(step) = step {
params["step"] = json!(step);
}
if app.run("actions.delete", params).is_err() {
return;
}
// Clear selection after deletion so a second click cannot delete a different
// step or unexpectedly fall back to deleting the entire action.
app.ui.actions.selected = None;
app.ui.actions.selected_step = None;
if step.is_none() && action < app.ui.actions.expanded.len() {
app.ui.actions.expanded.remove(action);
}
}
/// The in-place name field of the action being renamed. Enter, Tab or clicking elsewhere
/// returns the new name to commit; Escape cancels and keeps the old name.
fn rename_field(app: &mut PhotocraftApp, ui: &mut egui::Ui, index: usize, rect: Rect) -> Option<String> {
let mut r = app.ui.actions.renaming.take().filter(|r| r.index == index)?;
let id = egui::Id::new(("action-rename-field", index));
let te = ui.put(rect, egui::TextEdit::singleline(&mut r.text).id(id).font(egui::FontId::proportional(12.0)));
if !r.focused {
te.request_focus();
// Like Photoshop, the whole name starts selected so typing replaces it.
if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) {
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(r.text.chars().count()));
state.cursor.set_char_range(Some(all));
state.store(ui.ctx(), id);
}
r.focused = true;
app.ui.actions.renaming = Some(r);
return None;
}
let (enter, tab, esc) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Tab), i.key_pressed(egui::Key::Escape)));
if esc {
None
} else if enter || tab || te.lost_focus() || !te.has_focus() {
Some(r.text)
} else {
app.ui.actions.renaming = Some(r);
None
}
}
fn rename_action(app: &mut PhotocraftApp, index: usize, name: String) {
if let Err(e) = app.run("actions.rename", json!({"action": index, "name": name})) {
let template = "Could not rename the action: {error}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]);
app.ui.status_error = true;
}
}
/// One action's row data for a frame.
struct Row {
name: String,
steps: Vec<(String, Value)>,
disabled: std::collections::BTreeSet<usize>,
dialogs: std::collections::BTreeSet<usize>,
set: Option<String>,
color: String,
}
/// The panel's order: actions outside any set first, then each set folder (in order of first
/// appearance) with its actions. Indices are into the engine's flat list.
fn display_order(rows: &[Row]) -> Vec<usize> {
let mut order: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| r.set.is_none()).map(|(i, _)| i).collect();
let mut sets: Vec<&str> = Vec::new();
for r in rows {
if let Some(set) = r.set.as_deref()
&& !sets.contains(&set)
{
sets.push(set);
}
}
for set in sets {
order.extend(rows.iter().enumerate().filter(|(_, r)| r.set.as_deref() == Some(set)).map(|(i, _)| i));
}
order
}
pub fn panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let recording = app.session.actions.recording;
let rows: Vec<Row> = app
.session
.actions
.list
.iter()
.enumerate()
.map(|(i, a)| Row {
name: a.name.clone(),
steps: visible_steps(app, i),
disabled: a.disabled.clone(),
dialogs: a.dialogs.clone(),
set: a.set.clone(),
color: a.color.clone(),
})
.collect();
if app
.ui
.actions
.selected
.is_some_and(|i| i >= rows.len() || app.ui.actions.selected_step.is_some_and(|step| rows.get(i).is_none_or(|row| step >= row.steps.len())))
{
app.ui.actions.selected = None;
app.ui.actions.selected_step = None;
}
if app.ui.actions.expanded.len() > rows.len() {
app.ui.actions.expanded.truncate(rows.len());
}
if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index >= rows.len()) {
app.ui.actions.renaming = None;
}
let mut renamed = None;
let mut toggled = None;
let mut dialog_toggled = None;
let mut set_toggled: Option<String> = None;
let mut save_set: Option<String> = None;
let max_h = (ui.available_height() - 38.0).max(0.0);
let mut play_idx = None;
let mut moved = None;
let recording_row = recording.and_then(|(_, i)| rows.get(i).map(|r| (i, r.steps.len())));
let reveal = recording_row.filter(|row| app.ui.actions.reveal_recording != Some(*row));
app.ui.actions.reveal_recording = recording_row;
if let Some((i, _)) = reveal {
app.ui.actions.expanded.resize(rows.len(), false);
if let Some(expanded) = app.ui.actions.expanded.get_mut(i) {
*expanded = true;
}
}
egui::ScrollArea::vertical().id_salt("actions-rows").max_height(max_h).min_scrolled_height(max_h).auto_shrink([false, false]).show(ui, |ui| {
if rows.is_empty() {
ui.label(egui::RichText::new(tl!("Record ● a sequence of edits, then play ▶ it on any document.")).color(t.text_faint).size(11.5));
}
if egui::DragAndDrop::has_payload_of_type::<DragRow>(ui.ctx()) {
let clip = ui.clip_rect();
if let Some(p) = ui.input(|i| i.pointer.hover_pos()).filter(|p| clip.contains(*p)) {
let delta = if p.y < clip.top() + 22.0 {
8.0
} else if p.y > clip.bottom() - 22.0 {
-8.0
} else {
0.0
};
if delta != 0.0 {
ui.scroll_with_delta(vec2(0.0, delta));
ui.ctx().request_repaint();
}
}
}
let mut shown_set: Option<&str> = None;
for i in display_order(&rows) {
let Row { name, steps, disabled, dialogs, set, color: swatch } = &rows[i];
// A set folder row before the first of its actions; collapsed folders hide them.
if let Some(set) = set.as_deref()
&& shown_set != Some(set)
{
shown_set = Some(set);
let collapsed = app.ui.actions.collapsed_sets.iter().any(|c| c == set);
let (hr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
hresp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, !collapsed, set));
if hresp.hovered() {
ui.painter().rect_filled(hr, 0.0, t.hover.gamma_multiply(0.5));
}
let tri = Rect::from_center_size(pos2(hr.left() + 12.0, hr.center().y), vec2(8.0, 8.0));
let pts = if collapsed {
vec![tri.left_top(), tri.right_center(), tri.left_bottom()]
} else {
vec![tri.left_top(), tri.right_top(), tri.center_bottom()]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
crate::icons::paint(
ui,
Rect::from_center_size(pos2(hr.left() + 30.0, hr.center().y), vec2(16.0, 16.0)),
if collapsed { "folder" } else { "folder-open" },
12.0,
t.icon,
);
ui.painter().text(pos2(hr.left() + 44.0, hr.center().y), Align2::LEFT_CENTER, set, egui::FontId::proportional(12.0), t.text);
let count = rows.iter().filter(|r| r.set.as_deref() == Some(set)).count() as u64;
let summary = crate::i18n::trn(crate::i18n::current(), count, "{n} action", "{n} actions");
ui.painter().text(pos2(hr.right() - 8.0, hr.center().y), Align2::RIGHT_CENTER, summary, egui::FontId::proportional(11.0), t.text_faint);
if hresp.clicked() {
set_toggled = Some(set.to_string());
}
hresp.context_menu(|ui| {
if ui.button(tl!("Save Set…")).clicked() {
save_set = Some(set.to_string());
ui.close();
}
});
}
if set.as_deref().is_some_and(|set| app.ui.actions.collapsed_sets.iter().any(|c| c == set)) {
continue;
}
let indent = if set.is_some() { 14.0 } else { 0.0 };
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click_and_drag());
let x0 = rect.left() + indent;
if reveal == Some((i, 0)) {
ui.scroll_to_rect(rect, Some(egui::Align::BOTTOM));
}
let button_mode = app.ui.actions.button_mode;
let sel = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step.is_none();
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, name));
if button_mode {
ui.painter().rect_filled(rect, 3.0, button_fill(swatch, &t));
} else if sel {
ui.painter().rect_filled(rect, 0.0, t.row_selected);
} else if resp.hovered() {
ui.painter().rect_filled(rect, 0.0, t.hover.gamma_multiply(0.5));
}
if let Some(change) = drag_row(ui, &resp, DragRow { action: i, step: None }, &t) {
moved = Some(change);
}
let expanded = app.ui.actions.expanded.get(i).copied().unwrap_or(false) && !button_mode;
if !button_mode {
let tri = Rect::from_center_size(pos2(x0 + 12.0, rect.center().y), vec2(8.0, 8.0));
let pts = if expanded {
vec![tri.left_top(), tri.right_top(), tri.center_bottom()]
} else {
vec![tri.left_top(), tri.right_center(), tri.left_bottom()]
};
ui.painter().add(egui::Shape::convex_polygon(pts, t.text_dim, Stroke::NONE));
crate::icons::paint(ui, Rect::from_center_size(pos2(x0 + 30.0, rect.center().y), vec2(16.0, 16.0)), "play", 11.0, t.icon);
}
let recording_this = recording.is_some_and(|(_, r)| r == i);
let nsteps = steps.len();
let name_x = if button_mode { x0 + 8.0 } else { x0 + 44.0 };
let name_color = if button_mode { button_label_color(swatch, &t) } else { t.text };
let name_rect = if app.ui.actions.renaming.as_ref().is_some_and(|r| r.index == i) {
let field = Rect::from_min_max(pos2(x0 + 40.0, rect.top() + 3.0), pos2((rect.right() - 80.0).max(x0 + 120.0), rect.bottom() - 3.0));
if let Some(text) = rename_field(app, ui, i, field) {
renamed = Some((i, text));
}
field
} else {
ui.painter().text(pos2(name_x, rect.center().y), Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), name_color)
};
if !button_mode {
ui.painter().text(
pos2(rect.right() - 8.0, rect.center().y),
Align2::RIGHT_CENTER,
app.session
.prefs()
.shortcuts
.get(&shortcut_id(name))
.filter(|s| !s.is_empty())
.map_or_else(|| step_count(nsteps, disabled.len()), |key| format!("{key} · {}", step_count(nsteps, disabled.len()))),
egui::FontId::proportional(11.0),
t.text_faint,
);
}
if recording_this {
ui.painter().circle_filled(pos2(rect.right() - 64.0, rect.center().y), 4.0, Color32::from_rgb(230, 60, 60));
}
if resp.clicked() {
app.ui.actions.selected = Some(i);
app.ui.actions.selected_step = None;
if button_mode {
if !resp.double_clicked() {
play_idx = Some(i);
}
} else if resp.interact_pointer_pos().is_some_and(|p| p.x < x0 + 20.0) {
if app.ui.actions.expanded.len() <= i {
app.ui.actions.expanded.resize(i + 1, false);
}
app.ui.actions.expanded[i] = !expanded;
}
}
resp.context_menu(|ui| {
ui.label(tl!("Keyboard Shortcuts"));
let current = app.session.prefs().shortcuts.get(&shortcut_id(name)).cloned().unwrap_or_default();
for key in std::iter::once(String::new()).chain((1..=12).map(|n| format!("F{n}"))) {
let label = if key.is_empty() { tl!("None").to_string() } else { key.clone() };
if ui.selectable_label(current == key, label).clicked() {
let _ = assign_shortcut(app, name, &key);
ui.close();
}
}
ui.separator();
ui.menu_button(tl!("Color"), |ui| {
for (id, label) in [
("", tl!("None")),
("red", tl!("Red")),
("orange", tl!("Orange")),
("yellow", tl!("Yellow")),
("green", tl!("Green")),
("blue", tl!("Blue")),
("violet", tl!("Violet")),
("gray", tl!("Gray")),
] {
if ui.selectable_label(swatch == id, label).clicked() {
let _ = app.run("actions.setColor", json!({"action": i, "color": id}));
ui.close();
}
}
});
});
if resp.double_clicked() && !button_mode {
// Photoshop renames in place when the name itself is double-clicked.
if resp.interact_pointer_pos().is_some_and(|p| name_rect.expand2(vec2(4.0, 13.0)).contains(p)) {
app.ui.actions.renaming = Some(RenameAction { index: i, text: name.clone(), focused: false });
} else {
play_idx = Some(i);
}
}
if expanded {
for (step, (id, params)) in steps.iter().enumerate() {
let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::click_and_drag());
if reveal == Some((i, step + 1)) {
ui.scroll_to_rect(r, Some(egui::Align::BOTTOM));
}
let label = step_label(id, params);
let selected = app.ui.actions.selected == Some(i) && app.ui.actions.selected_step == Some(step);
response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, selected, &label));
if selected {
ui.painter().rect_filled(r, 0.0, t.row_selected);
} else if response.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5));
}
if let Some(change) = drag_row(ui, &response, DragRow { action: i, step: Some(step) }, &t) {
moved = Some(change);
}
let on = !disabled.contains(&step);
let color = if on { t.text_dim } else { t.text_faint };
ui.painter().text(pos2(x0 + 62.0, r.center().y), Align2::LEFT_CENTER, &label, egui::FontId::proportional(11.5), color);
// Photoshop's check column: unchecked steps are skipped by playback, Batch and droplets.
let check = ui.interact(
Rect::from_center_size(pos2(x0 + 30.0, r.center().y), vec2(16.0, 16.0)),
ui.id().with(("action-step-check", i, step)),
Sense::click(),
);
check.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, on, format!("{}: {label}", tl!("Include in playback"))));
paint_check(ui, check.rect, on, &t);
// Photoshop's dialog column: playback shows the step's dialog, seeded with its
// recorded values, and continues when it is confirmed.
let with_dialog = dialogs.contains(&step);
let dialog = ui.interact(
Rect::from_center_size(pos2(x0 + 46.0, r.center().y), vec2(16.0, 16.0)),
ui.id().with(("action-step-dialog", i, step)),
Sense::click(),
);
// A toggle button, not a check box, so the check column stays the only CheckBox role.
dialog.widget_info(|| {
egui::WidgetInfo::selected(egui::WidgetType::Button, true, with_dialog, format!("{}: {label}", tl!("Show dialog during playback")))
});
crate::icons::paint(ui, dialog.rect, "message-square", 11.0, if with_dialog { t.icon } else { t.text_faint.gamma_multiply(0.45) });
let dialog = dialog.on_hover_text(tl!("Show dialog during playback"));
if check.on_hover_text(tl!("Include in playback")).clicked() {
toggled = Some((i, step));
} else if dialog.clicked() {
dialog_toggled = Some((i, step));
} else if response.clicked() {
app.ui.actions.selected = Some(i);
app.ui.actions.selected_step = Some(step);
}
}
}
}
});
if let Some((action, step)) = toggled {
let _ = app.run("actions.toggle", json!({"action": action, "step": step}));
}
if let Some((action, step)) = dialog_toggled {
let _ = app.run("actions.dialog", json!({"action": action, "step": step}));
}
if let Some(set) = set_toggled {
match app.ui.actions.collapsed_sets.iter().position(|c| *c == set) {
Some(i) => {
app.ui.actions.collapsed_sets.remove(i);
}
None => app.ui.actions.collapsed_sets.push(set),
}
}
if let Some(set) = save_set
&& let Err(e) = save_dialog(app, Some(set))
&& e != "cancelled"
{
app.ui.status = e;
app.ui.status_error = true;
}
if let Some((index, name)) = renamed {
rename_action(app, index, name);
}
if let Some((source, to)) = moved {
move_row(app, source, to);
}
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let recording_now = app.session.actions.recording.is_some();
if crate::icons::button(ui, "square", 24.0, false, tl!("Stop playing/recording")).clicked() && recording_now {
let _ = app.run("actions.stop", json!({}));
}
let (r, rec) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click());
if rec.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
ui.painter().circle_filled(r.center(), 5.5, if recording_now { Color32::from_rgb(230, 60, 60) } else { t.icon });
if rec.on_hover_text(tl!("Begin recording")).clicked() && !recording_now {
begin_recording(app, true);
}
let play = crate::icons::button(ui, "play", 24.0, false, tl!("Play selection"));
play.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Play selection")));
if play.clicked() {
play_idx = playback_selection(app);
}
if crate::icons::button(ui, "plus", 24.0, false, tl!("Create new action")).clicked() && !recording_now {
begin_recording(app, false);
}
let can_delete = app.ui.actions.selected.is_some() && (!recording_now || app.ui.actions.selected_step.is_some());
ui.add_enabled_ui(can_delete, |ui| {
let response = crate::icons::button(ui, "trash", 24.0, false, tl!("Delete"));
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, can_delete, tl!("Delete")));
if response.clicked() {
delete_selection(app);
}
});
let mode_label = if app.ui.actions.button_mode { tl!("Button Mode On") } else { tl!("Button Mode") };
let mode_button = ui.add_enabled(!recording_now, egui::Button::new(mode_label).small()).on_hover_text(tl!("Button Mode"));
mode_button.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, !recording_now, mode_label));
if mode_button.clicked() {
app.ui.actions.button_mode = !app.ui.actions.button_mode;
}
});
if let Some(i) = play_idx {
match app.run("actions.play", json!({"action": i})) {
Ok(v) => report_play(app, &v),
Err(e) => {
let template = "Action playback failed: {error}";
app.ui.status = crate::i18n::fmt(tl!(template), &[("error", &e)]);
app.ui.status_error = true;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selected_layer_name_is_visible_during_recording_and_after_stop() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width":4,"height":4})).unwrap();
app.run("layer.setProps", json!({"name":"Background"})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
app.run("actions.record", json!({"name":"Select photo"})).unwrap();
app.ui.actions.expanded = vec![true];
app.run("layer.select", json!({"layer":layer.0})).unwrap();
let mut h = Harness::builder().with_size(vec2(400.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(4);
h.get_by_label("Select Layer (Background)");
h.state_mut().run("actions.stop", json!({})).unwrap();
h.run_steps(4);
h.get_by_label("Select Layer (Background)");
assert_eq!(step_label("layer.select", &json!({"layer":26})), "Select Layer (ID 26)");
assert_eq!(step_label("layer.select", &json!({"layer":"Background"})), "Select Layer (Background)");
}
#[test]
fn actions_list_grows_with_panel_and_keeps_footer_visible() {
use egui_kittest::{Harness, kittest::Queryable};
let bottom = |height: f32| {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
for i in 0..50 {
app.session.actions.list.push(Action { name: format!("Action {i}"), steps: vec![], disabled: Default::default(), ..Default::default() });
}
let mut h = Harness::builder().with_size(vec2(320.0, height)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
let rect = h.get_by_label("Play selection").rect();
assert!(rect.bottom() <= height, "footer must fit: {rect:?}");
rect.top()
};
assert!(bottom(700.0) - bottom(300.0) > 350.0, "list uses the additional panel height instead of stopping at 320 px");
assert!(crate::dock::Group::History.scrolls_itself(1));
}
#[test]
fn double_clicking_an_action_name_renames_it_in_place() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
app.session.actions.list.push(Action {
name: "Action 1".into(),
steps: vec![("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let layers = app.session.active().unwrap().doc.layers.len();
// 60 fps steps, so two clicks a frame apart count as a double-click.
let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).with_step_dt(1.0 / 60.0).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
let rename = |h: &mut Harness<'_, PhotocraftApp>, text: &str| {
let name = h.get_by_label("Action 1").rect().left_center() + vec2(52.0, 0.0);
h.event(egui::Event::PointerMoved(name));
// Wait out the double-click window of the previous attempt.
h.run_steps(30);
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos: name, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
h.step();
}
h.run_steps(3);
h.event(egui::Event::Text(text.into()));
h.run_steps(1);
h.key_press(egui::Key::Enter);
h.run_steps(3);
};
// Whitespace keeps the old name and reports why.
rename(&mut h, " ");
assert_eq!(h.state().session.actions.list[0].name, "Action 1");
assert!(h.state().ui.status_error);
rename(&mut h, "Sepia");
assert_eq!(h.state().session.actions.list[0].name, "Sepia");
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), layers, "renaming must not play the action");
}
#[test]
fn play_button_targets_first_action_if_nothing_is_selected() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
assert_eq!(playback_selection(&app), None);
app.session.actions.list.push(Action {
name: "Make Layer".into(),
steps: vec![("layer.new.layer".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
app.session.actions.list.push(Action {
name: "Fit".into(),
steps: vec![("view.fitOnScreen".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
assert_eq!(playback_selection(&app), Some(0));
app.ui.actions.selected = Some(1);
assert_eq!(playback_selection(&app), Some(1));
app.ui.actions.selected = Some(999);
assert_eq!(playback_selection(&app), Some(0));
app.ui.actions.selected = None;
use egui_kittest::{Harness, kittest::Queryable};
let mut h = Harness::builder().with_size(vec2(400.0, 320.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
let before = h.state().session.active().unwrap().doc.layers.len();
h.get_by_label("Play selection").click();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1);
assert!(!h.state().ui.status_error);
}
#[test]
fn step_check_box_excludes_the_step_from_playback_and_batch() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
let steps = ["A", "B", "C"].map(|n| ("layer.new.layer".to_string(), json!({"name": n})));
app.session.actions.list.push(Action::new("Three", steps.to_vec()));
app.ui.actions.expanded = vec![true];
let mut h = Harness::builder().with_size(vec2(320.0, 300.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
let checks = h.query_all_by_role(egui::accesskit::Role::CheckBox).count();
assert_eq!(checks, 3);
h.query_all_by_role(egui::accesskit::Role::CheckBox).nth(1).unwrap().click();
h.run_steps(3);
let action = &h.state().session.actions.list[0];
assert_eq!(action.disabled.iter().copied().collect::<Vec<_>>(), [1]);
assert_eq!(action_steps(action).len(), 2, "Batch and droplets get only the checked steps");
assert_eq!(step_count(3, action.disabled.len()), "2/3 steps");
h.get_by_label("Play selection").click();
h.run_steps(3);
let names: Vec<_> = h.state().session.active().unwrap().doc.layers.iter().skip(1).map(|l| l.name.clone()).collect();
assert_eq!(names, ["A", "C"]);
}
#[test]
fn playback_errors_are_visible_in_status_bar() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 32, "height": 32})).unwrap();
app.session.actions.list.push(Action {
name: "Bad action".into(),
steps: vec![("not.a.command".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let mut h = Harness::builder().with_size(vec2(400.0, 320.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
h.get_by_label("Play selection").click();
h.run_steps(3);
assert!(h.state().ui.status_error);
assert!(h.state().ui.status.contains("not.a.command") || h.state().ui.status.contains("unknown command"));
}
#[test]
fn recording_reveals_new_action_and_latest_step_in_a_long_panel() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width":4,"height":4})).unwrap();
for i in 0..30 {
app.session.actions.list.push(Action { name: format!("Existing {i}"), steps: vec![], disabled: Default::default(), ..Default::default() });
}
let mut h = Harness::builder().with_size(vec2(320.0, 260.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(4);
begin_recording(h.state_mut(), false);
h.run_steps(20);
let rect = h.get_by_label("Action 31").rect();
assert!(rect.top() >= 0.0 && rect.bottom() <= h.get_by_label("Play selection").rect().top(), "new action visible: {rect:?}");
for _ in 0..20 {
h.state_mut().run("view.zoomOut", json!({})).unwrap();
}
h.state_mut().run("view.fitOnScreen", json!({})).unwrap();
h.run_steps(20);
let rect = h.get_by_label("Fit on Screen").rect();
assert!(rect.top() >= 0.0 && rect.bottom() <= h.get_by_label("Play selection").rect().top(), "latest step visible: {rect:?}");
}
#[test]
fn drag_steps_and_action_headers_reorders_and_keeps_selection() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.actions.list.push(Action {
name: "First".into(),
steps: vec![("view.zoomOut".into(), json!({})), ("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
app.session.actions.list.push(Action { name: "Second".into(), steps: vec![], disabled: Default::default(), ..Default::default() });
app.ui.actions.expanded = vec![true, false];
let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(4);
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: egui::Pos2, to: egui::Pos2) {
h.event(egui::Event::PointerMoved(from));
h.run_steps(1);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(1);
h.event(egui::Event::PointerMoved(from + vec2(10.0, 0.0)));
h.run_steps(2);
h.event(egui::Event::PointerMoved(to));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
}
let from = h.get_by_label("Zoom Out").rect().center();
let target = h.get_by_label("100%").rect();
drag(&mut h, from, pos2(target.center().x, target.bottom() - 2.0));
assert_eq!(
h.state().session.actions.list[0].steps.iter().map(|st| st.0.as_str()).collect::<Vec<_>>(),
["view.fitOnScreen", "view.actualPixels", "view.zoomOut"]
);
assert_eq!(h.state().ui.actions.selected_step, Some(2));
let from = h.get_by_label("Second").rect().center();
let target = h.get_by_label("First").rect();
drag(&mut h, from, pos2(target.center().x, target.top() + 2.0));
assert_eq!(h.state().session.actions.list[0].name, "Second");
assert_eq!(h.state().ui.actions.expanded, [false, true]);
assert_eq!(h.state().ui.actions.selected, Some(0));
}
#[test]
fn nested_shell_actions_include_view_steps_and_propagate_failures() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width":40,"height":40})).unwrap();
app.session.actions.list.push(Action {
name: "Child".into(),
steps: vec![("layer.new.layer".into(), json!({})), ("view.fitOnScreen".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
app.run("actions.record", json!({"name":"Parent"})).unwrap();
app.run("actions.play", json!({"action":"Child"})).unwrap();
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list[1].steps, [("actions.play".into(), json!({"action":"Child"}))]);
assert_eq!(step_label("actions.play", &json!({"action":"Child"})), "Play Action: Child");
app.run("view.actualPixels", json!({})).unwrap();
let r = app.run("actions.play", json!({"action":"Parent"})).unwrap();
assert!(r.get("failed").is_none(), "{r}");
assert!(app.ui.views[0].fit_pending);
app.session.authorize =
Some(|id, _| if id == "view.fitOnScreen" { Err(photocraft_engine::EngineError::Other("denied nested view".into())) } else { Ok(()) });
let r = app.run("actions.play", json!({"action":"Parent"})).unwrap();
assert!(r["failed"]["error"].as_str().unwrap().contains("denied nested view"));
assert!(app.session.actions.playback_stack.is_empty());
assert_eq!(app.session.actions.playing, 0);
}
#[test]
fn panel_shows_pending_steps_and_trash_deletes_only_the_clicked_step() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 40, "height": 40})).unwrap();
begin_recording(&mut app, false);
app.run("view.fitOnScreen", json!({})).unwrap();
app.run("view.zoomOut", json!({})).unwrap();
let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(2);
assert!(h.state().session.actions.recording.is_some());
h.get_by_label("Fit on Screen");
h.get_by_label("Zoom Out").click();
h.run_steps(2);
assert_eq!(h.state().ui.actions.selected_step, Some(1));
h.get_by_label("Delete").click();
h.run_steps(2);
assert_eq!(visible_steps(h.state(), 0).iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["view.fitOnScreen"]);
assert_eq!(h.state().session.actions.list.len(), 1);
assert!(h.state().session.actions.recording.is_some());
assert!(h.state().ui.actions.selected.is_none());
delete_selection(h.state_mut());
assert_eq!(h.state().session.actions.list.len(), 1, "repeated trash cannot delete the action");
h.state_mut().run("view.zoomIn", json!({})).unwrap();
h.run_steps(2);
h.get_by_label("Zoom In");
h.state_mut().run("actions.stop", json!({})).unwrap();
h.run_steps(2);
assert_eq!(visible_steps(h.state(), 0).iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["view.fitOnScreen", "view.zoomIn"]);
h.get_by_label("Zoom In").click();
h.run_steps(2);
h.get_by_label("Delete").click();
h.run_steps(2);
assert_eq!(visible_steps(h.state(), 0).iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["view.fitOnScreen"]);
}
#[test]
fn view_menu_commands_record_append_and_replay_after_resize() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 160, "height": 100})).unwrap();
begin_recording(&mut app, false);
app.run("image.canvasSize", json!({"width": 80, "height": 60})).unwrap();
app.run("actions.stop", json!({})).unwrap();
begin_recording(&mut app, true);
crate::menus::invoke(&mut app, &ctx, "view.fitOnScreen", json!({})).unwrap();
app.run("actions.stop", json!({})).unwrap();
assert_eq!(app.session.actions.list.len(), 1);
assert_eq!(app.session.actions.list[0].steps.iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["image.canvasSize", "view.fitOnScreen"]);
assert_eq!(label_of("view.fitOnScreen"), "Fit on Screen");
// Persisted actions retain the menu step, and playback fits the new document.
let saved = serde_json::to_string(&app.session.actions.list).unwrap();
app.session.actions.list = serde_json::from_str(&saved).unwrap();
app.run("file.new", json!({"width": 200, "height": 140})).unwrap();
app.run("view.actualPixels", json!({})).unwrap();
let result = app.run("actions.play", json!({"action": 0})).unwrap();
assert_eq!(result["ran"], 2);
assert!(result.get("failed").is_none(), "{result}");
let idx = app.session.active_index().unwrap();
assert!(app.ui.views[idx].fit_pending);
let doc = &app.session.active().unwrap().doc;
crate::canvas::fit_view(&mut app.ui.views[idx], doc, vec2(100.0, 100.0), 1.0, 1.0);
assert_eq!(app.ui.views[idx].zoom, 0.75);
assert_eq!(app.ui.views[idx].center, [40.0, 30.0]);
assert!(!app.ui.views[idx].fit_pending);
}
#[test]
fn view_action_failure_resume_and_authorization() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.session.actions.list.push(Action {
name: "Fit".into(),
steps: vec![("view.fitOnScreen".into(), json!({})), ("view.actualPixels".into(), json!({}))],
disabled: Default::default(),
..Default::default()
});
let result = app.run("actions.play", json!({"action": "Fit"})).unwrap();
assert_eq!(result["ran"], 0);
assert_eq!(result["failed"]["step"], 0);
assert_eq!(app.session.actions.playing, 0);
app.run("file.new", json!({"width": 40, "height": 40})).unwrap();
app.session.authorize =
Some(
|id, _| {
if id == "view.fitOnScreen" { Err(photocraft_engine::EngineError::BadParams { cmd: id.into(), msg: "denied view".into() }) } else { Ok(()) }
},
);
let result = app.run("actions.play", json!({"action": 0})).unwrap();
assert_eq!(result["ran"], 0);
assert!(result["failed"]["error"].as_str().unwrap().contains("denied view"));
let result = app.run("actions.play", json!({"action": 0, "from": 1})).unwrap();
assert_eq!(result["ran"], 1);
assert!(!app.ui.views[0].fit_pending);
assert!(app.run("actions.play", json!({"action": 0, "from": -1})).is_err());
app.session.actions.playing = 1;
assert!(app.run("actions.play", json!({"action": 0})).is_err());
}
#[test]
fn record_and_replay_on_another_document() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 40, "height": 40})).unwrap();
app.sync_views();
app.run("actions.record", json!({})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
app.run("select.rect", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.run("edit.fill", json!({"color": "#ff0000"})).unwrap();
let _ = app.run("document.pixel", json!({"x": 1, "y": 1}));
app.run("actions.stop", json!({})).unwrap();
let a = &app.session.actions.list[0];
assert_eq!(a.steps.iter().map(|s| s.0.as_str()).collect::<Vec<_>>(), ["layer.new.layer", "select.rect", "edit.fill"]);
app.run("file.new", json!({"width": 40, "height": 40})).unwrap();
app.sync_views();
let played = app.run("actions.play", json!({"action": 0})).unwrap();
assert_eq!(played["ran"], 3);
assert!(played.get("failed").is_none(), "{played}");
let d = &app.session.active().unwrap().doc;
assert_eq!(d.layers.len(), 2);
assert_eq!(d.layers[1].surface().unwrap().pixel(5, 5), vec![1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn recorded_save_as_and_save_replay_and_write_the_file() {
let written = std::rc::Rc::new(std::cell::RefCell::new(Vec::<String>::new()));
let log = written.clone();
let services = crate::Services {
export: Some(Box::new(|doc, path, _| {
photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
write: Some(Box::new(move |path, _| {
log.borrow_mut().push(path.to_string());
Ok(())
})),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
let ctx = egui::Context::default();
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
app.run("actions.record", json!({})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
crate::menus::invoke(&mut app, &ctx, "file.saveAs", json!({"path": "out/result.psd"})).unwrap();
crate::menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();
app.run("actions.stop", json!({})).unwrap();
let steps = &app.session.actions.list[0].steps;
assert_eq!(steps, &[("layer.new.layer".into(), json!({})), ("file.saveAs".into(), json!({"path": "out/result.psd"})), ("file.save".into(), json!({}))]);
written.borrow_mut().clear();
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
let played = app.run("actions.play", json!({"action": 0})).unwrap();
assert!(played.get("failed").is_none(), "{played}");
assert_eq!(*written.borrow(), ["out/result.psd", "out/result.psd"]);
assert_eq!(app.session.active().unwrap().path.as_deref(), Some("out/result.psd"));
}
/// An app with the real importer/exporter and writer whose Save dialogs answer `answers`.
fn disk_app(answers: Vec<Option<crate::file_dialog::FileDialogAnswer>>) -> PhotocraftApp {
let services = crate::Services {
import: Some(Box::new(|name, bytes, depth| {
photocraft_io::import_with_svg_group_depth(name, bytes, depth).map(|r| (r.document, r.warnings)).map_err(|e| e.to_string())
})),
export: Some(Box::new(|doc, path, _| {
photocraft_io::export(doc, path, &Default::default()).map(|r| (r.bytes, r.warnings)).map_err(|e| e.to_string())
})),
write: Some(Box::new(|path, bytes| std::fs::write(path, bytes).map_err(|e| e.to_string()))),
file_dialog: Some(crate::file_dialog::fake(answers).0),
..Default::default()
};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services);
app.background_jobs = false;
app
}
/// Records Invert + File › Save on the file `a`, then plays it on the file `b` (given a
/// new layer first when `layered`). Returns the played result and whether `b` / `a`
/// changed on disk.
fn record_save_on_a_play_on_b(ext: &str, layered: bool) -> (Value, bool, bool) {
use crate::file_dialog::FileDialogAnswer::SaveTo;
let dir = std::env::temp_dir().join(format!("photocraft-rec-save-{ext}-{layered}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = |n: &str| dir.join(format!("{n}.{ext}")).to_string_lossy().into_owned();
let (a, b) = (path("a"), path("b"));
// The dialog confirms the suggested name, which is the document's own file.
let mut app = disk_app(vec![Some(SaveTo(a.clone())), Some(SaveTo(a.clone()))]);
let ctx = egui::Context::default();
for p in [&a, &b] {
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
app.save_as(Some(p.clone())).unwrap();
}
let (before_a, before_b) = (std::fs::read(&a).unwrap(), std::fs::read(&b).unwrap());
app.open_path(&a).unwrap();
app.run("actions.record", json!({})).unwrap();
app.run("image.adjustments.invert", json!({})).unwrap();
crate::menus::invoke(&mut app, &ctx, "file.save", json!({})).unwrap();
app.poll_file_dialog(&ctx, None);
app.run("actions.stop", json!({})).unwrap();
std::fs::write(&a, &before_a).unwrap();
app.open_path(&b).unwrap();
if layered {
app.run("layer.new.layer", json!({})).unwrap();
}
let played = app.run("actions.play", json!({"action": 0})).unwrap();
let r = (played, std::fs::read(&b).unwrap() != before_b, std::fs::read(&a).unwrap() != before_a);
let _ = std::fs::remove_dir_all(&dir);
r
}
#[test]
fn recorded_save_overwrites_the_layered_file_the_action_is_played_on() {
let (played, b_changed, a_changed) = record_save_on_a_play_on_b("psd", false);
assert!(played.get("failed").is_none(), "{played}");
assert!(b_changed && !a_changed);
}
#[test]
fn recorded_save_overwrites_the_flat_file_the_action_is_played_on() {
let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", false);
assert!(played.get("failed").is_none(), "{played}");
assert!(b_changed, "Save did not overwrite the file the action was played on");
assert!(!a_changed, "Save wrote the file the action was recorded on");
}
#[test]
fn recorded_save_never_flattens_layers_over_a_flat_file() {
// The played-on document has layers its PNG can't keep: the step fails, nothing is written (#3806).
let (played, b_changed, a_changed) = record_save_on_a_play_on_b("png", true);
assert!(played.get("failed").is_some(), "{played}");
assert!(!b_changed && !a_changed);
}
#[test]
fn set_folders_group_actions_and_collapse() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut sharpen = Action::new("Sharpen", vec![("filter.sharpen.sharpen".into(), json!({}))]);
sharpen.set = Some("Retouch".into());
let mut soften = Action::new("Soften", vec![("filter.blur.blur".into(), json!({}))]);
soften.set = Some("Retouch".into());
app.session.actions.list.push(Action::new("Loose", vec![]));
app.session.actions.list.push(sharpen);
app.session.actions.list.push(Action::new("Also loose", vec![]));
app.session.actions.list.push(soften);
let mut h = Harness::builder().with_size(vec2(360.0, 400.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
// The folder row shows the set with its count; its actions sit under it, loose ones first.
let folder = h.get_by_label("Retouch").rect();
assert!(h.get_by_label("Loose").rect().top() < folder.top());
assert!(h.get_by_label("Also loose").rect().top() < folder.top());
assert!(h.get_by_label("Sharpen").rect().top() > folder.top());
assert!(h.get_by_label("Soften").rect().top() > h.get_by_label("Sharpen").rect().top());
assert_eq!(display_order(&[]), Vec::<usize>::new());
// Clicking the folder collapses it; its actions disappear and the state is remembered.
h.get_by_label("Retouch").click();
h.run_steps(3);
assert_eq!(h.state().ui.actions.collapsed_sets, ["Retouch"]);
assert!(h.query_by_label("Sharpen").is_none());
assert!(h.query_by_label("Loose").is_some());
h.get_by_label("Retouch").click();
h.run_steps(3);
assert!(h.state().ui.actions.collapsed_sets.is_empty());
assert!(h.query_by_label("Sharpen").is_some());
}
#[test]
fn moving_an_action_next_to_another_set_joins_it() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let mut a = Action::new("A", vec![]);
a.set = Some("One".into());
let mut b = Action::new("B", vec![]);
b.set = Some("Two".into());
app.session.actions.list.push(a);
app.session.actions.list.push(b);
app.session.actions.list.push(Action::new("C", vec![]));
// C dropped below B: it joins B's set, "Two".
move_row(&mut app, DragRow { action: 2, step: None }, 2);
assert_eq!(app.session.actions.list[2].set.as_deref(), Some("Two"));
// B dropped to the top, above A: the row below decides when there is none above.
move_row(&mut app, DragRow { action: 1, step: None }, 0);
assert_eq!((app.session.actions.list[0].name.as_str(), app.session.actions.list[0].set.as_deref()), ("B", Some("One")));
// C dropped between A (One) and nothing: the row above wins.
move_row(&mut app, DragRow { action: 2, step: None }, 2);
assert_eq!((app.session.actions.list[2].name.as_str(), app.session.actions.list[2].set.as_deref()), ("C", Some("One")));
}
#[test]
fn insert_stop_dialog_adds_a_stop_after_the_selected_step() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
app.session.actions.list.push(Action::new("A", vec![("select.all".into(), json!({})), ("select.inverse".into(), json!({}))]));
assert!(insert_stop_dialog(&mut app).is_err(), "needs a selected action");
app.ui.actions.selected = Some(0);
app.ui.actions.selected_step = Some(0);
let id = insert_stop_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap();
let d = app.ui.dialog_mut(id).unwrap();
assert!(owns_insert_stop(&d.fields));
assert_eq!(d.fields["at"], 1);
d.fields.insert("message".into(), json!("Check the mask"));
d.fields.insert("allowContinue".into(), json!(true));
crate::dialogs::confirm(&mut app, id).unwrap();
let steps = &app.session.actions.list[0].steps;
assert_eq!(steps[1], (photocraft_engine::atn_cmds::STOP_MESSAGE.to_string(), json!({"message": "Check the mask", "allowContinue": true})));
assert_eq!((app.ui.actions.selected, app.ui.actions.selected_step), (Some(0), Some(1)));
assert_eq!(step_label(&steps[1].0, &steps[1].1), "Stop: Check the mask");
// Without a selected step the stop goes last; the panel's menu wiring is exercised by name.
app.ui.actions.selected_step = None;
let id = insert_stop_dialog(&mut app).unwrap()["dialog"].as_u64().unwrap();
assert_eq!(app.ui.dialog_mut(id).unwrap().fields["at"], 3);
crate::dialogs::cancel(&mut app, id).unwrap();
assert_eq!(app.session.actions.list[0].steps.len(), 3);
}
#[test]
fn dialog_worker_failure_stops_action_before_following_steps() {
for (background, locked) in [(true, true), (false, true), (true, false)] {
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 action = Action::new(
"Worker Result",
vec![
("layer.setProps".into(), json!({"name": "Prefix", "locks": {"pixels": locked}})),
("filter.blur.gaussianBlur".into(), json!({"radius": 1.5})),
("layer.new.layer".into(), json!({"name": "Suffix"})),
],
);
action.dialogs.insert(1);
app.session.actions.list.push(action);
app.background_jobs = background;
let paused = play(&mut app, &json!({"action": "Worker Result"})).unwrap();
let dialog = paused["paused"]["dialog"].as_u64().unwrap();
let result = crate::dialogs::confirm(&mut app, dialog);
let suffix = app.session.active().unwrap().doc.walk().iter().any(|(_, _, layer)| layer.name == "Suffix");
assert_eq!(suffix, !locked, "background={background}, locked={locked}: {result:?}");
if locked && background {
assert!(result.as_ref().is_err_and(|e| e.contains("locked")), "failed worker must fail the dialog confirmation: {result:?}");
assert!(app.ui.status_error, "the action must report the worker failure");
assert!(app.ui.status.contains("locked"), "{}", app.ui.status);
assert_eq!(app.session.jobs_with_recent()[0].state, "failed");
} else if locked {
assert!(result.is_err(), "the foreground command rejects the locked layer");
} else {
assert!(result.is_ok(), "the successful worker lets the action continue: {result:?}");
}
assert_eq!(app.session.actions.playing, 0);
assert!(app.session.actions.playback_stack.is_empty());
}
}
#[test]
fn a_dialog_toggled_step_pauses_playback_and_resumes_on_ok() {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
let mut a = Action::new(
"Blur",
vec![("layer.new.layer".into(), json!({})), ("filter.blur.gaussianBlur".into(), json!({"radius": 3})), ("select.all".into(), json!({}))],
);
a.dialogs.insert(1);
app.session.actions.list.push(a);
let v = play(&mut app, &json!({"action": "Blur"})).unwrap();
assert_eq!(v["ran"], 1, "{v}");
assert_eq!(v["paused"]["step"], 1);
let id = v["paused"]["dialog"].as_u64().unwrap();
let d = app.ui.dialog_mut(id).unwrap();
assert_eq!(d.fields["radius"], 3, "the dialog opens on the recorded values");
assert_eq!(d.fields[RESUME], json!({"action": "Blur", "from": 2}));
assert!(app.session.active().unwrap().doc.selection.is_none(), "nothing after the dialog ran yet");
report_play(&mut app, &v);
assert!(app.ui.status.contains("dialog"), "{}", app.ui.status);
// OK runs the blur with the dialog's values and plays the rest.
app.ui.dialog_mut(id).unwrap().fields.insert("radius".into(), json!(5));
crate::dialogs::confirm(&mut app, id).unwrap();
assert!(app.ui.dialogs.is_empty());
assert!(app.session.active().unwrap().doc.selection.is_some(), "the step after the dialog ran");
assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status);
// Cancel stops the action there.
app.run("select.deselect", json!({})).unwrap();
let v = play(&mut app, &json!({"action": "Blur"})).unwrap();
let id = v["paused"]["dialog"].as_u64().unwrap();
crate::dialogs::cancel(&mut app, id).unwrap();
assert!(app.session.active().unwrap().doc.selection.is_none());
assert!(app.ui.status.contains("stopped"), "{}", app.ui.status);
// With background jobs (the desktop default) the resumed steps wait for the dialog's
// filter to land instead of meeting a locked document.
app.background_jobs = true;
let v = play(&mut app, &json!({"action": "Blur"})).unwrap();
let id = v["paused"]["dialog"].as_u64().unwrap();
crate::dialogs::confirm(&mut app, id).unwrap();
assert!(app.session.active().unwrap().doc.selection.is_some(), "the step after the background blur ran");
assert!(app.ui.status.starts_with("Played"), "{}", app.ui.status);
app.background_jobs = false;
// Agents play headlessly: no pause, the recorded values run.
app.automation_input = true;
let v = play(&mut app, &json!({"action": "Blur"})).unwrap();
assert_eq!(v["ran"], 3, "{v}");
assert!(v.get("paused").is_none());
}
#[test]
fn button_mode_plays_the_clicked_action_and_hides_its_steps() {
use egui_kittest::{Harness, kittest::Queryable};
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({"width": 16, "height": 16})).unwrap();
app.session.actions.list.push(Action::new("Print", vec![("layer.new.layer".into(), json!({}))]));
app.session.actions.list[0].color = "blue".into();
app.ui.actions.expanded = vec![true];
app.ui.actions.button_mode = true;
let mut h = Harness::builder().with_size(vec2(420.0, 280.0)).build_ui_state(|ui, app| panel(app, ui), app);
h.run_steps(3);
assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 0, "button mode hides the step list");
let before = h.state().session.active().unwrap().doc.layers.len();
h.get_by_label("Print").click();
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.layers.len(), before + 1, "a click plays the action");
h.get_by_label("Button Mode On").click();
h.run_steps(2);
assert!(!h.state().ui.actions.button_mode);
assert!(h.query_by_label("Button Mode").is_some());
assert_eq!(h.query_all_by_role(egui::accesskit::Role::CheckBox).count(), 1, "the step list returns when Button Mode is turned off");
}
}