Control: list and answer the unsaved-changes prompt (#4920) (#4924)

When a document with unsaved changes was closed, reverted, or the app was
asked to quit, the Save / Don't Save / Cancel prompt showed in the window
but not in ui.inspect, and a control client had no way to answer it: the
app stayed open until someone clicked.

ui.inspect now lists the prompt among its dialogs with the id "discard":
the waiting command, the document it asks about, how many are left and
the choices. ui.dialog.confirm {"dialog": "discard", "choice":
"save"|"dontSave"|"cancel"} answers it through the same code as the
buttons (one document at a time when quitting), and ui.dialog.cancel
cancels it. The automation policy sees the parked command before a save
or don't-save answer runs it.

Fixes #4920

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dan Hiorns 2026-10-13 10:24:35 -06:00 committed by GitHub
parent b21956aef7
commit ef2905ea8e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 121 additions and 5 deletions

View File

@ -872,6 +872,23 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest)
wrap(crate::workspace_ui::confirm(app, ctx))
}
"ui.dialog.apply" if s("dialog") == Some(SHELL_DIALOG) => err("`ui.dialog.apply` is for Preferences; use `ui.dialog.confirm`"),
// The unsaved-changes prompt (closing, Revert, quitting), listed by ui.inspect with the
// id "discard" (#4920): confirm takes a `choice` (save, dontSave, cancel; default save).
"ui.dialog.confirm" if s("dialog") == Some(crate::discard_ui::CONTROL_ID) => {
let choice = s("choice").unwrap_or("save");
if choice != "cancel"
&& let Some((command, params)) = crate::discard_ui::pending_command(app)
&& let Some(authorize) = app.services.automation_command.as_ref()
&& let Err(error) = authorize(&command, &params)
{
return err(error);
}
wrap(crate::discard_ui::answer_control(app, ctx, choice))
}
"ui.dialog.cancel" if s("dialog") == Some(crate::discard_ui::CONTROL_ID) => wrap(crate::discard_ui::answer_control(app, ctx, "cancel")),
"ui.dialog.set" | "ui.dialog.apply" if s("dialog") == Some(crate::discard_ui::CONTROL_ID) => {
err("the unsaved-changes prompt has no fields; use `ui.dialog.confirm` with a `choice`")
}
"ui.dialog.cancel" if s("dialog") == Some(SHELL_DIALOG) => match app.ui.shell.dialog.take() {
Some(_) => ok(Value::Null),
None => err("no such dialog"),
@ -1242,6 +1259,7 @@ pub fn inspect(app: &PhotocraftApp, ctx: &egui::Context) -> Value {
if let Some((kind, fields)) = &app.ui.shell.dialog {
dialogs.push(json!({"id": SHELL_DIALOG, "kind": kind, "title": crate::workspace_ui::title(kind), "fields": fields}));
}
dialogs.extend(crate::discard_ui::inspect(app));
json!({
"window": {"width": screen.width(), "height": screen.height(), "pixelsPerPoint": ctx.pixels_per_point()},
"tool": app.ui.tool,
@ -1554,6 +1572,47 @@ mod tests {
assert_eq!(app.ui.tool_options.stroke_width, 0.0);
}
#[test]
fn the_unsaved_changes_prompt_is_listed_and_answered_through_ui_dialog() {
// #4920: the prompt shown on quit or close with unsaved changes wasn't in ui.inspect, and a
// control client couldn't answer it.
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default());
let ctx = egui::Context::default();
for _ in 0..2 {
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
}
let prompt = |app: &mut PhotocraftApp| {
let v = call(app, &ctx, "ui.inspect", json!({}));
v["result"]["dialogs"].as_array().unwrap().iter().find(|d| d["id"] == "discard").cloned()
};
assert!(prompt(&mut app).is_none());
// Closing a dirty document: listed with its choices; a bad choice and Cancel keep the document.
crate::menus::invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
let d = prompt(&mut app).expect("the prompt is listed");
assert_eq!(d["fields"]["command"], "file.close");
assert_eq!(d["fields"]["choices"], json!(["save", "dontSave", "cancel"]));
assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": "discard", "choice": "maybe"}))["ok"], false);
assert_eq!(call(&mut app, &ctx, "ui.dialog.set", json!({"dialog": "discard", "field": "x", "value": 1}))["ok"], false);
assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": "discard"}))["ok"], true);
assert!(prompt(&mut app).is_none() && app.session.documents().len() == 2);
assert_eq!(call(&mut app, &ctx, "ui.dialog.cancel", json!({"dialog": "discard"}))["ok"], false, "nothing left to answer");
// Don't Save closes it.
crate::menus::invoke(&mut app, &ctx, "file.close", json!({})).unwrap();
assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": "discard", "choice": "dontSave"}))["ok"], true);
assert_eq!(app.session.documents().len(), 1);
// Quitting asks about each dirty document, then lets the window close.
app.run("file.new", json!({"width": 8, "height": 8})).unwrap();
app.run("layer.new.layer", json!({})).unwrap();
assert!(crate::discard_ui::intercept(&mut app, "file.exit", &Value::Null));
let d = prompt(&mut app).expect("the quit prompt is listed");
assert_eq!((d["fields"]["command"].as_str(), d["fields"]["remaining"].as_u64()), (Some("file.exit"), Some(2)));
assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": "discard", "choice": "dontSave"}))["result"]["open"], true);
assert_eq!(prompt(&mut app).unwrap()["fields"]["remaining"], 1);
assert_eq!(call(&mut app, &ctx, "ui.dialog.confirm", json!({"dialog": "discard", "choice": "dontSave"}))["result"]["open"], false);
assert!(app.allow_close && app.session.documents().is_empty());
}
#[test]
fn shell_dialogs_are_listed_and_driven_through_ui_dialog() {
// #1004: Window › Workspace and View dialogs live in `ui.shell.dialog`, and ui.inspect and

View File

@ -288,14 +288,70 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
if answer.is_none() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, Key::Enter)) {
answer = buttons.iter().find(|b| b.0 == ButtonRole::Default).map(|b| b.4);
}
match answer {
Some(Answer::Cancel) => app.discard = None,
Some(Answer::Discard) => advance(app, ctx),
Some(Answer::Save) if save(app, ctx, doc) => advance(app, ctx),
_ => {}
if let Some(answer) = answer {
apply(app, ctx, doc, answer);
}
}
/// Acts on the answer about `doc`, the document the prompt is asking about.
fn apply(app: &mut PhotocraftApp, ctx: &egui::Context, doc: DocId, answer: Answer) {
match answer {
Answer::Cancel => app.discard = None,
Answer::Discard => advance(app, ctx),
Answer::Save if save(app, ctx, doc) => advance(app, ctx),
Answer::Save => {}
}
}
/// The id `ui.inspect` lists the prompt under; `ui.dialog.confirm` and `ui.dialog.cancel` answer
/// it by this id, so a control client can see and answer it like the window's buttons (#4920).
pub const CONTROL_ID: &str = "discard";
/// The prompt as `ui.inspect` lists it among the dialogs: the command waiting on it, the document
/// it asks about, how many are still to answer, and the choices `ui.dialog.confirm` takes.
pub fn inspect(app: &PhotocraftApp) -> Option<Value> {
let p = app.discard.as_ref()?;
let doc = *p.docs.first()?;
let name = index_of(app, doc).and_then(|i| app.session.documents().get(i)).map(|d| d.doc.name.clone())?;
let reverts = p.id == "file.revert";
let (choices, default): (&[&str], _) = if reverts { (&["dontSave", "cancel"], "dontSave") } else { (&["save", "dontSave", "cancel"], "save") };
Some(json!({
"id": CONTROL_ID,
"kind": "unsavedChanges",
"title": if reverts { "Revert" } else { "Unsaved changes" },
"fields": {"command": p.id, "document": name, "remaining": p.docs.len(), "choices": choices, "default": default},
}))
}
/// The command the prompt holds back and its params, for the automation policy to see before a
/// control client answers it.
pub fn pending_command(app: &PhotocraftApp) -> Option<(String, Value)> {
app.discard.as_ref().map(|p| (p.id.clone(), p.params.clone()))
}
/// Answers the prompt for a control client: `save`, `dontSave` or `cancel` (Revert takes
/// `dontSave` or `cancel`). Like the buttons, Save may first ask for a path in the save dialog.
pub fn answer_control(app: &mut PhotocraftApp, ctx: &egui::Context, choice: &str) -> Result<Value, String> {
let Some(doc) = app.discard.as_ref().and_then(|p| p.docs.first().copied()) else {
return Err("no unsaved-changes prompt is open".into());
};
if app.file_dialog_open() || app.save_options_open() || app.saving() {
return Err("the unsaved-changes prompt is waiting for a save to finish".into());
}
let reverts = app.discard.as_ref().is_some_and(|p| p.id == "file.revert");
let answer = match choice {
"save" if !reverts => Answer::Save,
"dontSave" => Answer::Discard,
"cancel" => Answer::Cancel,
other => {
let choices = if reverts { "dontSave or cancel" } else { "save, dontSave or cancel" };
return Err(format!("unknown choice `{other}`: {choices}"));
}
};
apply(app, ctx, doc, answer);
Ok(json!({"open": app.discard.is_some(), "fileDialog": app.file_dialog_open()}))
}
#[derive(Clone, Copy)]
enum Answer {
Save,

View File

@ -62,6 +62,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i
- A Layer Style color field opens the shared picker with `ui.dialog.open {"kind":"colorPicker","parent":12,"effect":"fx2","field":"color"}`. `parent` is the Layer Style dialog id; `effect` is its effect instance id. `field` must be one of that instance's color parameters (`color`, or `from`/`to` for Gradient Overlay). The picker previews over the pending style without changing the document. Picker OK changes only the parent parameter; Cancel discards the preview. Confirm the parent afterwards to apply the style. Parent confirmation is refused while its picker is open; parent cancellation closes both. The binding is exposed in `ui.inspect` as `__layerStyleColor`; changed/closed targets or a document switch invalidate it.
- `ui.dialog.open {kind:"colorPicker", textStyle:{kind:"character"|"paragraph", id:u32}}` opens the shared Color Picker for an explicitly enabled Color attribute in Style Options. It opens the matching style panel/editor after validation; inherited colors must first be enabled through the existing style command. The picker reports its bound document/revision/style under `__textStyleColor` in `ui.inspect`. Changes preview in an isolated document; OK runs the existing style `set` command once, Cancel changes nothing. Document edits/switches or closed/changed Style Options discard the picker. `textStyle` cannot be combined with `parent`, `effect`, `field` or `target`.
- The shell's own dialogs (Window › Workspace › New Workspace / Delete Workspace, View › Pixel Aspect Ratio › Custom Pixel Aspect Ratio…, View › Show › Show Extras Options…, View › 32-bit Preview Options…) are listed in `ui.inspect`'s `dialogs` with the id `"shell"` (at most one is open). `ui.dialog.set`, `ui.dialog.confirm` and `ui.dialog.cancel` take `{"dialog": "shell"}`; OK runs the dialog's command and keeps the dialog open if it fails
- The unsaved-changes prompt (closing a document, Revert, quitting with unsaved work) is listed in `ui.inspect`'s `dialogs` with the id `"discard"` while it is up: `fields` gives the waiting `command` (`file.close`, `file.exit`…), the `document` it asks about, how many are `remaining`, and its `choices`. `ui.dialog.confirm` takes `{"dialog": "discard", "choice": "save" | "dontSave" | "cancel"}` (default `save`; Revert takes `dontSave` or `cancel`) and answers it as the window's buttons do, one document at a time; `ui.dialog.cancel` with `{"dialog": "discard"}` cancels it. Save of an untitled document opens the save dialog first, so the reply's `fileDialog` is true and the prompt stays open until that is answered.
- Layer Style (`layerStyle {effect?}` opens on that effect kind): the fields hold the dialog's whole state, so agents read and drive it like a user. `effects` lists every effect instance in the layer's order as `{id, kind, on, params, fx}` (`fx` is the effect snapshot the dialog loaded; OK edits that snapshot with `params`, so anything the dialog doesn't model survives). `selected` picks the page (`blendingOptions` or an instance id), `preview` (default on) gates the live canvas preview, `p:blendingOptions` edits blend mode/opacity/fill opacity, and `patternList` names the usable patterns. OK replaces the layer's effects in one step (`layer.layerStyle.replace`); Cancel discards. "New Style…" saves the pending state via `style.presets.new {effects}`, a swatch click applies a style preset, and Make/Reset Default use `layer.layerStyle.makeDefault` / `layer.layerStyle.defaultFor`
- Preferences (`ui.menu.invoke {id: "edit.preferences.interface"}`) exposes the sections in its `values` field. Apply (`ui.dialog.apply`) and OK (`ui.dialog.confirm`) on Preferences are refused over control because automation denies whole-preferences updates and filesystem-bearing preference sections. A refused `ui.dialog.apply` returns an error and keeps the dialog open; a refused `ui.dialog.confirm` returns an error but closes and removes the dialog. `ui.dialog.cancel` closes the dialog without saving. Individual safe preferences can instead be changed with `engine.execute` using `prefs.set` (e.g. `{"command": "prefs.set", "params": {"path": "interface.showTooltips", "value": false}}`). Settings marked for the next launch still require a restart.
- Edit › Fill… (`ui.menu.invoke {id: "edit.fill"}`, Shift+F5, Shift+Backspace) opens the Fill dialog; its fields are `edit.fill`'s params (`contents`, `color`, `pattern`, `colorAdaptation`, `mode`, `opacity`, `preserveTransparency`), and OK remembers them in the preferences (`dialogs["edit.fill"]`).