From a78e9f05fca98315732ef18eccd5cd0e6581e496 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 02:33:33 -0700 Subject: [PATCH] Save: ask before replacing a file that an added or lowercased extension names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The save dialog asks before replacing only the file it answers with. After it closes, pick_save can change that path: it gives a name without an extension the suggested file's extension (#5039), and Lowercase Extension changes CAT.PSD to CAT.psd. The save then wrote to the changed path at once, and a file there was replaced without a question. On Linux this happens when the Swatches panel exports to a folder that holds Swatches.aco (the one-type portal dialog shows and asks about "Swatches"), when CAT.PSD is typed where CAT.psd exists, and in rfd's zenity fallback dialog. When the path written differs from the dialog's answer and a file exists there, the save now asks "“{name}” already exists. Replace it?". Replace writes the file, and ui.dialog.confirm returns the save's result. Cancel writes nothing and ends the save like the dialog's Cancel, and Save in the close prompt of an untitled document returns to that prompt. PhotoCraft counts the file dialog as open until the question is answered. A name that reaches the same file, as CAT.PSD and CAT.psd do on Windows and macOS by default, is not asked about again. The new same_file service decides this: the desktop app compares device and inode on Unix and the canonical paths on Windows. The web has no file stamps and does not ask. The two new strings have rows in all 20 catalogs. --- apps/photocraft/src/services.rs | 37 +++++++++ crates/ui-egui/src/dialogs.rs | 8 +- crates/ui-egui/src/discard_ui.rs | 47 +++++++++++ crates/ui-egui/src/file_dialog.rs | 94 +++++++++++++++++++--- crates/ui-egui/src/file_dialog/tests.rs | 102 ++++++++++++++++++++++++ crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/el.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/id.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/nl.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/sr.tsv | 2 + crates/ui-egui/src/i18n/tr.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/lib.rs | 7 ++ crates/ui-egui/src/replace_prompt.rs | 46 +++++++++++ crates/ui-egui/src/swatches_ui.rs | 26 ++++++ docs/control-protocol.md | 1 + docs/ui-dialogs.md | 2 + 30 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/replace_prompt.rs diff --git a/apps/photocraft/src/services.rs b/apps/photocraft/src/services.rs index d4e7a01553..92ce4840f5 100644 --- a/apps/photocraft/src/services.rs +++ b/apps/photocraft/src/services.rs @@ -298,6 +298,26 @@ fn write_atomic_progress(path: &Path, bytes: &[u8], progress: &dyn Fn(f32)) -> R photocraft_format::atomic_write_progress(path, bytes, progress).map_err(|e| e.to_string()) } +/// Whether `a` and `b` reach the same file: the same device and inode on Unix, the same final path +/// on Windows (where it holds the name's letter case as stored). False when either can't be read. +fn same_file(a: &Path, b: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + match (std::fs::metadata(a), std::fs::metadata(b)) { + (Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(), + _ => false, + } + } + #[cfg(not(unix))] + { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } + } +} + fn export_options(settings: &photocraft_ui_egui::ExportSettings) -> photocraft_io::ExportOptions { settings.io_options() } @@ -511,6 +531,7 @@ pub fn native(automation: Option) -> let meta = std::fs::metadata(path).ok()?; Some(photocraft_ui_egui::FileStamp { len: meta.len(), modified: meta.modified().ok() }) })), + same_file: Some(Box::new(|a: &str, b: &str| same_file(Path::new(a), Path::new(b)))), automation_read, automation_write, automation_open_pcraft, @@ -888,6 +909,22 @@ mod tests { assert_eq!(dialog_file_name("", true), None); } + /// A name in another letter case reaches the same file where the file system ignores case + /// (Windows, and macOS by default), and only there. Two files with the same contents are two. + #[test] + fn same_file_follows_the_file_system() { + let dir = std::env::temp_dir().join(format!("photocraft-same-file-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (cat, dog, upper) = (dir.join("CAT.psd"), dir.join("dog.psd"), dir.join("CAT.PSD")); + std::fs::write(&cat, b"same").unwrap(); + std::fs::write(&dog, b"same").unwrap(); + assert!(same_file(&cat, &cat)); + assert!(!same_file(&cat, &dog)); + assert_eq!(same_file(&upper, &cat), upper.exists(), "the same file only where letter case is ignored"); + assert!(!same_file(&dir.join("missing.psd"), &cat)); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn block_on_waits_for_a_wake_from_another_thread() { let (tx, rx) = std::sync::mpsc::channel::(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 43b4f3d3f9..0d76025828 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -322,6 +322,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { DialogKind::Command if crate::shape_dialog::owns(&fields) => crate::shape_dialog::body(app, ui, &mut fields), DialogKind::Command if crate::delete_layer_prompt::owns(&fields) => crate::delete_layer_prompt::body(ui, &mut fields), DialogKind::Command if crate::disk_change_prompt::owns(&fields) => crate::disk_change_prompt::body(ui, &fields), + DialogKind::Command if crate::replace_prompt::owns(&fields) => crate::replace_prompt::body(ui, &fields), DialogKind::Command if crate::actions::owns_insert_stop(&fields) => crate::actions::insert_stop_body(ui, &mut fields), DialogKind::Command if crate::new_layer_ui::owns(&fields) => crate::new_layer_ui::body(ui, &mut fields), DialogKind::Command if crate::mask_trash_prompt::owns(&fields) => crate::mask_trash_prompt::body(ui, &fields), @@ -405,7 +406,7 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) { tl!("Create") } else if crate::delete_layer_prompt::owns(&d.fields) { tl!("Delete") - } else if crate::disk_change_prompt::owns(&d.fields) { + } else if crate::disk_change_prompt::owns(&d.fields) || crate::replace_prompt::owns(&d.fields) { tl!("Replace") } else if d.fields.contains_key("__export") { tl!("Export") @@ -621,6 +622,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result { DialogKind::Command if crate::shape_dialog::owns(&d.fields) => crate::shape_dialog::confirm(app, &d.fields), DialogKind::Command if crate::delete_layer_prompt::owns(&d.fields) => crate::delete_layer_prompt::confirm(app, &d.fields), DialogKind::Command if crate::disk_change_prompt::owns(&d.fields) => crate::disk_change_prompt::confirm(app, &d.fields), + DialogKind::Command if crate::replace_prompt::owns(&d.fields) => crate::replace_prompt::confirm(app), DialogKind::Command if crate::actions::owns_insert_stop(&d.fields) => crate::actions::insert_stop_confirm(app, &d.fields), DialogKind::Command if crate::new_layer_ui::owns(&d.fields) => crate::new_layer_ui::confirm(app, &d.fields), DialogKind::Command if crate::mask_trash_prompt::owns(&d.fields) => crate::mask_trash_prompt::confirm(app, &d.fields), @@ -679,6 +681,10 @@ pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result { if crate::open_prompt::owns(&closed.fields) { crate::open_prompt::answer(app, None)?; } + // Cancel on the question whether to replace a file writes nothing. + if crate::replace_prompt::owns(&closed.fields) { + crate::replace_prompt::cancel(app)?; + } app.ui.dialogs.retain(|d| !crate::layer_style::color_picker::child_of(&d.fields, id)); app.filter_preview = None; app.color_range = None; diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index 3dbf33c905..ac9d3b030e 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -878,6 +878,53 @@ mod tests { assert!(h.state().session.documents().is_empty()); } + /// Save on closing an untitled document, answered with `kept` while `kept.psd` exists: the + /// prompt waits for the question whether to replace it. Cancel brings the prompt back with + /// nothing written; Replace saves and closes the document. + #[test] + fn saving_from_the_prompt_waits_for_the_question_whether_to_replace() { + use crate::{FileDialogAnswer, FileDialogReply}; + use egui_kittest::kittest::Queryable; + use std::{cell::RefCell, rc::Rc}; + let written: Rc>> = Rc::default(); + let (w, waiting): (_, Rc>>) = (written.clone(), Rc::default()); + let held = waiting.clone(); + let mut app = app_with_docs(1); + make_dirty(&mut app, 0); + app.services.export = Some(Box::new(|_: &photocraft_doc::Document, _: &str, _: &crate::ExportSettings| Ok((b"out".to_vec(), Vec::new())))); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })); + app.services.file_stamp = Some(Box::new(|p: &str| (p == "/pics/kept.psd").then_some(crate::FileStamp { len: 1, modified: None }))); + app.services.file_dialog = Some(Box::new(move |_, _, reply| *held.borrow_mut() = Some(reply))); + let mut h = prompt_for(app, "file.close", egui::os::OperatingSystem::Windows); + let save = |h: &mut Prompted| { + h.key_press(Key::Y); + h.run_steps(3); + waiting.borrow_mut().take().expect("the save dialog is open").send(Some(FileDialogAnswer::SaveTo("/pics/kept".into()))); + h.run_steps(2); + h.state().ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the save asks first") + }; + let id = save(&mut h); + assert!(h.state().file_dialog_open()); + assert!(h.query_by_label("(Y)es").is_none(), "the prompt waits behind the question"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert_eq!(docs_left(&h), Some(1)); + assert_eq!(focused(&h, &["(Y)es", "(N)o", "(C)ancel"]), Some("(Y)es"), "the prompt comes back"); + + let id = save(&mut h); + crate::dialogs::confirm(h.state_mut(), id).unwrap(); + h.run_steps(2); + assert_eq!(*written.borrow(), ["/pics/kept.psd"]); + assert!(h.state().discard.is_none()); + assert!(h.state().session.documents().is_empty()); + } + #[test] fn saving_from_the_prompt_waits_for_a_background_save_then_closes() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/file_dialog.rs b/crates/ui-egui/src/file_dialog.rs index 7cdff50af9..7c9e83a0bb 100644 --- a/crates/ui-egui/src/file_dialog.rs +++ b/crates/ui-egui/src/file_dialog.rs @@ -73,12 +73,20 @@ pub type FileDialogFn = Box, /// What to do with the answer (`None`: cancelled). type Then = Box) -> Result>; +/// The path a save writes to for the path the save dialog answered (see [`saved_path`]). +type SavedPath = Box String>; + /// The file dialog in progress. pub(crate) struct Pending { /// Not shown yet: it is shown at the end of the frame, which has the window to parent it to. request: Option, answer: Option>>, then: Then, + /// For a save dialog, the path the save writes to for the dialog's answer. + saved_path: Option, + /// The save dialog's answer while the app asks whether to replace the file it writes to + /// (`replace_prompt`). + held: Option, } /// The error of a cancelled dialog: not reported, since backing out is the user's choice. @@ -115,7 +123,7 @@ impl PhotocraftApp { Some(FileDialogAnswer::Unavailable(why)) => Err(why), Some(a) => then(app, a), }); - self.file_dialog = Some(Pending { request: Some(request), answer: None, then }); + self.file_dialog = Some(Pending { request: Some(request), answer: None, then, saved_path: None, held: None }); Ok(json!({ "fileDialog": kind })) } @@ -130,7 +138,9 @@ impl PhotocraftApp { } /// Ask where to save: `then` gets the chosen path, with a lowercase extension when - /// Preferences ▸ File Handling › Lowercase Extension is on (its default). + /// Preferences ▸ File Handling › Lowercase Extension is on (its default). When that, or the + /// extension added to a bare name, changes the path to one where a file exists, the app asks + /// whether to replace it before `then` runs (`replace_prompt`). /// /// A bare file name (export dialogs, an untitled document) starts in the folder the active /// document was last saved or exported to through a dialog, else beside the document @@ -153,13 +163,10 @@ impl PhotocraftApp { // extensions are hidden, #5039) gets the suggested file's extension, as Photoshop's // Save As appends the extension of the chosen file type. let suggested_ext = suggested.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()); - self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { + let ext = suggested_ext.clone(); + let asked = self.ask_file(FileDialogRequest::Save { suggested: suggested.to_string_lossy().into_owned() }, move |app, answer| match answer { FileDialogAnswer::SaveTo(path) => { - let path = Self::lowercased_extension(app, path); - let path = match &suggested_ext { - Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), - _ => path, - }; + let path = saved_path(app, &path, suggested_ext.as_deref()); // The web answers a download name, which has no folder to remember. if let Some(doc) = doc && let Some(dir) = Path::new(&path).parent().filter(|p| !p.as_os_str().is_empty()) @@ -169,7 +176,32 @@ impl PhotocraftApp { then(app, path) } _ => Err(UNEXPECTED.into()), - }) + })?; + if let Some(p) = self.file_dialog.as_mut() { + p.saved_path = Some(Box::new(move |app, path| saved_path(app, path, ext.as_deref()))); + } + Ok(asked) + } + + /// Whether a save to `path` replaces a file the save dialog didn't ask about: the dialog + /// answered `answered`, and [`saved_path`] changed it. A file at `path` that + /// [`Services::same_file`](crate::Services::same_file) finds is also the file at `answered` (the + /// same name in another letter case on a file system that ignores case, as Windows and macOS do + /// by default) counts as asked about. Never on the web, which has no file stamps. + fn replaces_unasked(&self, answered: &str, path: &str) -> bool { + let Some(stamp) = self.services.file_stamp.as_ref() else { return false }; + answered != path && stamp(path).is_some() && !self.services.same_file.as_ref().is_some_and(|same| same(answered, path)) + } + + /// The question whether to replace a file is answered. Replace writes the save dialog's answer + /// as if the question had not been asked and returns what the save returns, its failure + /// included. Cancel ends the save like the dialog's Cancel. + pub(crate) fn answer_replace(&mut self, replace: bool) -> Result { + let held = self.file_dialog.as_mut().and_then(|p| p.held.take()).ok_or("no save is waiting to replace a file")?; + match self.finish_file_dialog(replace.then_some(held)) { + Err(e) if e == CANCELLED => Ok(json!({ "replaced": false })), + r => r, + } } /// `X.PSD` → `X.psd`, only the extension: the stem and the folders keep the user's spelling. @@ -275,6 +307,14 @@ impl PhotocraftApp { /// continues the action that asked once the answer is in. Its errors go to the status bar. pub fn poll_file_dialog(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) { let Some(p) = self.file_dialog.as_mut() else { return }; + if p.held.is_some() { + // The question whether to replace answers through `answer_replace`. Closed without an + // answer, it counts as Cancel. + if !self.ui.dialogs.iter().any(|d| crate::replace_prompt::owns(&d.fields)) { + let _ = self.answer_replace(false); + } + return; + } if let Some(request) = p.request.take() { let (tx, rx) = mpsc::channel(); p.answer = Some(rx); @@ -288,13 +328,43 @@ impl PhotocraftApp { // The reply was dropped unanswered: the dialog couldn't be shown. Some(Err(TryRecvError::Disconnected)) | None => None, }; - let Some(Pending { then, .. }) = self.file_dialog.take() else { return }; - if let Err(e) = then(self, answer) + if let Some(FileDialogAnswer::SaveTo(answered)) = &answer + && let Some(path) = self.file_dialog.as_ref().and_then(|p| p.saved_path.as_ref()).map(|written| written(self, answered)) + && self.replaces_unasked(answered, &path) + { + crate::replace_prompt::open(self, answered, &path); + if let Some(p) = self.file_dialog.as_mut() { + p.answer = None; + p.held = answer; + } + return; + } + let _ = self.finish_file_dialog(answer); + } + + /// Ends the file dialog in progress: its action continues with `answer`, and its errors go to + /// the status bar. Returns the action's result. + fn finish_file_dialog(&mut self, answer: Option) -> Result { + let Some(Pending { then, .. }) = self.file_dialog.take() else { return Err(CANCELLED.into()) }; + let r = then(self, answer); + if let Err(e) = &r && e != CANCELLED { - self.ui.status = e; + self.ui.status = e.clone(); self.ui.status_error = true; } + r + } +} + +/// The path a save writes to for the path the save dialog answered: its extension lowercased +/// when Preferences ▸ File Handling › Lowercase Extension is on, and a name without one given +/// `suggested_ext`, the suggested file's. +fn saved_path(app: &PhotocraftApp, answered: &str, suggested_ext: Option<&str>) -> String { + let path = PhotocraftApp::lowercased_extension(app, answered.to_string()); + match suggested_ext { + Some(ext) if Path::new(&path).extension().is_none() => Path::new(&path).with_extension(ext).to_string_lossy().into_owned(), + _ => path, } } diff --git a/crates/ui-egui/src/file_dialog/tests.rs b/crates/ui-egui/src/file_dialog/tests.rs index 6a3ccfcf9e..e08d576c01 100644 --- a/crates/ui-egui/src/file_dialog/tests.rs +++ b/crates/ui-egui/src/file_dialog/tests.rs @@ -433,6 +433,108 @@ fn save_paths_without_an_extension_get_the_suggested_one() { assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); } +/// The question whether to replace a file (`replace_prompt`), when one is open. +fn replace_prompt(app: &PhotocraftApp) -> Option { + app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id) +} + +/// File › Save As, answered with `path`. +fn save_as(app: &mut PhotocraftApp, open: &Open, path: &str) { + let ctx = egui::Context::default(); + menus::invoke(app, &ctx, "file.saveAs", json!({})).unwrap(); + app.poll_file_dialog(&ctx, None); + answer(open, Some(FileDialogAnswer::SaveTo(path.into()))); + app.poll_file_dialog(&ctx, None); +} + +/// The save dialog asked only about `cat`. The extension added to it names a file that exists, +/// so the save asks before replacing that file: Cancel writes nothing, Replace writes it. +#[test] +fn a_save_asks_before_replacing_the_file_its_added_extension_names() { + let ctx = egui::Context::default(); + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/cat.psd"); + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("the save asks first"); + let fields = &app.ui.dialogs.iter().find(|d| d.id == id).unwrap().fields; + assert!(fields["message"].as_str().unwrap().contains("“cat.psd”"), "{fields:?}"); + assert!(fields["detail"].as_str().unwrap().contains("“cat”"), "{fields:?}"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + app.poll_file_dialog(&ctx, None); + assert!(app.file_dialog_open(), "the save waits for the answer"); + assert!(written.borrow().is_empty()); + + crate::dialogs::cancel(&mut app, id).unwrap(); + assert!(written.borrow().is_empty(), "Cancel writes nothing"); + assert!(!app.file_dialog_open() && !app.ui.status_error); + assert_eq!(app.session.active().unwrap().path, None); + + // A question closed without an answer counts as Cancel. + save_as(&mut app, &open, "/pics/cat"); + app.ui.close_dialog(replace_prompt(&app).expect("asked again")); + app.poll_file_dialog(&ctx, None); + assert!(written.borrow().is_empty() && !app.file_dialog_open()); + + // Replace answers with the save's failure. + save_as(&mut app, &open, "/pics/cat"); + let write = app.services.write.replace(Box::new(|_: &str, _: &[u8]| Err("disk full".into()))); + let id = replace_prompt(&app).expect("asked again"); + let e = crate::dialogs::confirm(&mut app, id).unwrap_err(); + assert!(e.contains("disk full") && app.ui.status_error, "{e}"); + app.services.write = write; + + save_as(&mut app, &open, "/pics/cat"); + let id = replace_prompt(&app).expect("asked again"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/cat.psd"]); + assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/pics/cat.psd")); + assert!(!app.file_dialog_open() && replace_prompt(&app).is_none()); +} + +/// Lowercase Extension changes `CAT.PSD` to `CAT.psd`. Where letter case tells two files apart +/// (Linux), the dialog asked only about `CAT.PSD`; where it doesn't (Windows and macOS by +/// default), both names reach the same file, which the dialog asked about. +#[test] +fn a_save_asks_before_replacing_the_file_its_lowercased_extension_names() { + { + // Two files, even with the same size and modification time. + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a == b)); + save_as(&mut app, &open, "/pics/CAT.PSD"); + let id = replace_prompt(&app).expect("the save asks first"); + assert!(written.borrow().is_empty()); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); + } + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + for name in ["/pics/CAT.psd", "/pics/CAT.PSD"] { + changed_by_another_app(&disk, name); + } + app.services.same_file = Some(Box::new(|a: &str, b: &str| a.eq_ignore_ascii_case(b))); + save_as(&mut app, &open, "/pics/CAT.PSD"); + assert!(replace_prompt(&app).is_none(), "the dialog asked about this file"); + assert_eq!(*written.borrow(), ["/pics/CAT.psd"]); +} + +/// The save dialog asked about the file it answered: a bare name with no file under the added +/// extension, and a full name, are written at once. +#[test] +fn a_save_writes_at_once_when_the_dialog_asked_about_the_file() { + let (mut app, open, written) = app(); + let disk = on_disk(&mut app); + changed_by_another_app(&disk, "/pics/dog.png"); + save_as(&mut app, &open, "/pics/cat"); + save_as(&mut app, &open, "/pics/dog.png"); + assert!(replace_prompt(&app).is_none()); + assert_eq!(*written.borrow(), ["/pics/cat.psd", "/pics/dog.png"]); +} + #[test] fn save_and_export_dialogs_start_beside_the_document() { let (mut app, open, _) = app(); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f50c0249b7..d12c09fb5c 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3818,3 +3818,5 @@ navigator Zoom Zvětšení GPU Memory (MB, 0 = Automatic) Paměť GPU (MB, 0 = Automaticky) {name} was changed by another app after it was opened or saved here. Soubor {name} změnila jiná aplikace poté, co byl zde otevřen nebo uložen. Replace it with this document, or cancel and use File › Save As to keep both. Nahraďte ho tímto dokumentem, nebo zrušte a použijte Soubor › Uložit jako, abyste zachovali obojí. + “{name}” already exists. Replace it? „{name}“ již existuje. Nahradit? + The name in the save dialog was “{name}”. Název v dialogu ukládání byl „{name}“. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b1ea8977e2..05c2a8cb7e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3212,3 +3212,5 @@ curves Smooth Glätten GPU Memory (MB, 0 = Automatic) GPU-Speicher (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} wurde von einer anderen App geändert, nachdem es hier geöffnet oder gespeichert wurde. Replace it with this document, or cancel and use File › Save As to keep both. Ersetze die Datei durch dieses Dokument oder brich ab und nutze Datei › Speichern unter, um beide zu behalten. + “{name}” already exists. Replace it? „{name}“ ist bereits vorhanden. Ersetzen? + The name in the save dialog was “{name}”. Der Name im Speichern-Dialog war „{name}“. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8229806307..03d45ed037 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3910,3 +3910,5 @@ curves Smooth Εξομάλυνση GPU Memory (MB, 0 = Automatic) Μνήμη GPU (MB, 0 = Αυτόματο) {name} was changed by another app after it was opened or saved here. Το {name} άλλαξε από άλλη εφαρμογή αφού ανοίχτηκε ή αποθηκεύτηκε εδώ. Replace it with this document, or cancel and use File › Save As to keep both. Αντικαταστήστε το με αυτό το έγγραφο ή ακυρώστε και χρησιμοποιήστε Αρχείο › Αποθήκευση ως για να κρατήσετε και τα δύο. + “{name}” already exists. Replace it? Το «{name}» υπάρχει ήδη. Να αντικατασταθεί; + The name in the save dialog was “{name}”. Το όνομα στο παράθυρο αποθήκευσης ήταν «{name}». diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6f3d85002b..9e5158bf67 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -4172,3 +4172,5 @@ navigator Zoom Zoom GPU Memory (MB, 0 = Automatic) Memoria de GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. Otra aplicación cambió {name} después de abrirlo o guardarlo aquí. Replace it with this document, or cancel and use File › Save As to keep both. Reemplázalo con este documento, o cancela y usa Archivo › Guardar como para conservar ambos. + “{name}” already exists. Replace it? «{name}» ya existe. ¿Reemplazarlo? + The name in the save dialog was “{name}”. El nombre en el cuadro de diálogo Guardar era «{name}». diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 4a819b0e1b..139db79602 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3216,3 +3216,5 @@ curves Smooth Lisser Replace Remplacer {name} was changed by another app after it was opened or saved here. {name} a été modifié par une autre application après avoir été ouvert ou enregistré ici. Replace it with this document, or cancel and use File › Save As to keep both. Remplacez-le par ce document, ou annulez et utilisez Fichier › Enregistrer sous pour garder les deux. + “{name}” already exists. Replace it? « {name} » existe déjà. Le remplacer ? + The name in the save dialog was “{name}”. Le nom dans la boîte de dialogue d'enregistrement était « {name} ». diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 86a6b30acb..7705a01e67 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -4129,3 +4129,5 @@ cameraRaw Yellow Sárga GPU Memory (MB, 0 = Automatic) GPU-memória (MB, 0 = Automatikus) {name} was changed by another app after it was opened or saved here. A(z) {name} fájlt egy másik alkalmazás módosította, miután itt megnyitották vagy mentették. Replace it with this document, or cancel and use File › Save As to keep both. Cserélje le erre a dokumentumra, vagy válassza a Mégse gombot, és a Fájl › Mentés másként paranccsal tartsa meg mindkettőt. + “{name}” already exists. Replace it? „{name}” már létezik. Lecseréli? + The name in the save dialog was “{name}”. A mentési párbeszédpanelen megadott név: „{name}”. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 49777913c1..1ce1b3ab20 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3203,3 +3203,5 @@ curves Smooth Haluskan Replace Ganti {name} was changed by another app after it was opened or saved here. {name} diubah oleh aplikasi lain setelah dibuka atau disimpan di sini. Replace it with this document, or cancel and use File › Save As to keep both. Ganti dengan dokumen ini, atau batalkan dan gunakan Berkas › Simpan Sebagai untuk menyimpan keduanya. + “{name}” already exists. Replace it? “{name}” sudah ada. Ganti? + The name in the save dialog was “{name}”. Nama di dialog simpan adalah “{name}”. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f1144c0fdd..3e4d805f3d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4331,3 +4331,5 @@ shape Appearance Aspetto GPU Memory (MB, 0 = Automatic) Memoria GPU (MB, 0 = Automatico) {name} was changed by another app after it was opened or saved here. {name} è stato modificato da un'altra app dopo essere stato aperto o salvato qui. Replace it with this document, or cancel and use File › Save As to keep both. Sostituiscilo con questo documento, oppure annulla e usa File › Salva con nome per tenerli entrambi. + “{name}” already exists. Replace it? “{name}” esiste già. Sostituirlo? + The name in the save dialog was “{name}”. Il nome nella finestra di salvataggio era “{name}”. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 351699ea89..11532fe9e7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3887,3 +3887,5 @@ curves Smooth スムーズ Replace 置き換え {name} was changed by another app after it was opened or saved here. {name} は、ここで開くか保存した後に別のアプリで変更されました。 Replace it with this document, or cancel and use File › Save As to keep both. この書類で置き換えるか、キャンセルしてファイル › 名前を付けて保存で両方を残してください。 + “{name}” already exists. Replace it? 「{name}」は既に存在します。置き換えますか? + The name in the save dialog was “{name}”. 保存ダイアログで指定した名前は「{name}」でした。 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 3eaf143979..ad6ba85342 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3389,3 +3389,5 @@ curves Smooth 매끄럽게 Replace 대치 {name} was changed by another app after it was opened or saved here. {name}이(가) 여기서 열거나 저장한 뒤 다른 앱에서 변경되었습니다. Replace it with this document, or cancel and use File › Save As to keep both. 이 문서로 대치하거나, 취소하고 파일 › 다른 이름으로 저장을 사용해 둘 다 보관하세요. + “{name}” already exists. Replace it? “{name}”이(가) 이미 있습니다. 대치하시겠습니까? + The name in the save dialog was “{name}”. 저장 대화상자에 입력한 이름: “{name}” diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 09dd568009..4bb345d1e6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3970,3 +3970,5 @@ curves Smooth Vloeiend maken GPU Memory (MB, 0 = Automatic) GPU-geheugen (MB, 0 = Automatisch) {name} was changed by another app after it was opened or saved here. {name} is door een andere app gewijzigd nadat het hier is geopend of bewaard. Replace it with this document, or cancel and use File › Save As to keep both. Vervang het door dit document, of annuleer en gebruik Bestand › Opslaan als om beide te houden. + “{name}” already exists. Replace it? “{name}” bestaat al. Vervangen? + The name in the save dialog was “{name}”. De naam in het venster Opslaan was “{name}”. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c4827aa829..d2446750ee 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3809,3 +3809,5 @@ curves Smooth Wygładź Replace Zastąp {name} was changed by another app after it was opened or saved here. Plik {name} został zmieniony przez inną aplikację po otwarciu lub zapisaniu go tutaj. Replace it with this document, or cancel and use File › Save As to keep both. Zastąp go tym dokumentem albo anuluj i użyj Plik › Zapisz jako, aby zachować oba. + “{name}” already exists. Replace it? „{name}” już istnieje. Zastąpić? + The name in the save dialog was “{name}”. Nazwa w oknie zapisywania: „{name}”. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index f96542d642..75a9640695 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -4000,3 +4000,5 @@ layerLabel No Color Sem cor GPU Memory (MB, 0 = Automatic) Memória da GPU (MB, 0 = Automático) {name} was changed by another app after it was opened or saved here. {name} foi alterado por outro app depois de ser aberto ou salvo aqui. Replace it with this document, or cancel and use File › Save As to keep both. Substitua-o por este documento, ou cancele e use Arquivo › Salvar como para manter os dois. + “{name}” already exists. Replace it? “{name}” já existe. Substituir? + The name in the save dialog was “{name}”. O nome na caixa de diálogo Salvar era “{name}”. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index f49200b973..f95a6a6cee 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3181,3 +3181,5 @@ curves Smooth Сгладить Replace Заменить {name} was changed by another app after it was opened or saved here. Файл {name} был изменён другим приложением после того, как его открыли или сохранили здесь. Replace it with this document, or cancel and use File › Save As to keep both. Замените его этим документом или отмените и используйте Файл › Сохранить как, чтобы сохранить оба. + “{name}” already exists. Replace it? «{name}» уже существует. Заменить? + The name in the save dialog was “{name}”. В окне сохранения было указано имя «{name}». diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c207facb72..a83e28b7ef 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -4283,3 +4283,5 @@ shape Appearance Izgled GPU Memory (MB, 0 = Automatic) GPU memorija (MB, 0 = Automatski) {name} was changed by another app after it was opened or saved here. Datoteku {name} je izmenila druga aplikacija nakon što je ovde otvorena ili sačuvana. Replace it with this document, or cancel and use File › Save As to keep both. Zameni je ovim dokumentom ili otkaži i koristi Datoteka › Sačuvaj kao da zadržiš obe. + “{name}” already exists. Replace it? „{name}“ već postoji. Zameniti? + The name in the save dialog was “{name}”. Ime u dijalogu za čuvanje je bilo „{name}“. diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a8aa0c3c58..9cf46749b6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -3940,3 +3940,5 @@ cameraData Lens Lens GPU Memory (MB, 0 = Automatic) GPU Belleği (MB, 0 = Otomatik) {name} was changed by another app after it was opened or saved here. {name}, burada açıldıktan veya kaydedildikten sonra başka bir uygulama tarafından değiştirildi. Replace it with this document, or cancel and use File › Save As to keep both. Bu belgeyle değiştirin ya da iptal edip ikisini de korumak için Dosya › Farklı Kaydet'i kullanın. + “{name}” already exists. Replace it? '{name}' zaten var. Değiştirilsin mi? + The name in the save dialog was “{name}”. Kaydetme penceresindeki ad '{name}' idi. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 48fb60343c..28d4aa961c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3910,3 +3910,5 @@ curves Smooth Згладити Replace Замінити {name} was changed by another app after it was opened or saved here. Файл {name} змінила інша програма після того, як його тут відкрили або зберегли. Replace it with this document, or cancel and use File › Save As to keep both. Замініть його цим документом або скасуйте й скористайтеся Файл › Зберегти як, щоб зберегти обидва. + “{name}” already exists. Replace it? «{name}» уже існує. Замінити? + The name in the save dialog was “{name}”. У вікні збереження було вказано назву «{name}». diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index f33ef15011..112b44aa1e 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3697,3 +3697,5 @@ curves Smooth Làm mượt Replace Thay thế {name} was changed by another app after it was opened or saved here. {name} đã bị một ứng dụng khác thay đổi sau khi được mở hoặc lưu ở đây. Replace it with this document, or cancel and use File › Save As to keep both. Thay thế bằng tài liệu này, hoặc hủy và dùng Tệp › Lưu thành để giữ cả hai. + “{name}” already exists. Replace it? “{name}” đã tồn tại. Thay thế? + The name in the save dialog was “{name}”. Tên trong hộp thoại lưu là “{name}”. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 75eacc994d..988d3faad0 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2827,3 +2827,5 @@ curves Smooth 平滑 Replace 替换 {name} was changed by another app after it was opened or saved here. {name} 在此处打开或存储后已被其他应用更改。 Replace it with this document, or cancel and use File › Save As to keep both. 用此文档替换它,或取消并使用“文件 › 另存为”以保留两者。 + “{name}” already exists. Replace it? “{name}”已存在。要替换它吗? + The name in the save dialog was “{name}”. 存储对话框中的名称是“{name}”。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 95a11dc593..75be2a7289 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2812,3 +2812,5 @@ curves Smooth 平滑 Replace 取代 {name} was changed by another app after it was opened or saved here. {name} 在此處開啟或儲存後已被其他 App 更改。 Replace it with this document, or cancel and use File › Save As to keep both. 以此文件取代它,或取消並使用「檔案 › 另存新檔」以保留兩者。 + “{name}” already exists. Replace it? 「{name}」已存在。要取代它嗎? + The name in the save dialog was “{name}”. 儲存對話框中的名稱是「{name}」。 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 04c37282e6..8bb3bdc586 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -173,6 +173,7 @@ pub mod proxy; pub mod puppet_ui; pub mod quick_pick; pub mod rasterize_prompt; +pub mod replace_prompt; pub mod retouch_ui; mod rgb_histogram; pub mod rotate_view; @@ -360,6 +361,8 @@ pub struct FileStamp { } /// The [`FileStamp`] of the file at a path, `None` when it can't be read. pub type FileStampFn = Box Option>; +/// Whether two paths reach the same file on disk; false when either can't be read. +pub type SameFileFn = Box bool>; /// Encode a document and write it to a path, reporting the stage to the job; returns the export /// warnings. Runs on a worker thread (see [`Services::save_file`]). It must call /// [`jobs_ui::SaveCtl::commit`] right before replacing the file, and write nothing if that fails. @@ -460,6 +463,10 @@ pub struct Services { /// Size and modification time of a file (native); `None` on the web, where Save never /// writes back to a file. pub file_stamp: Option, + /// Whether two paths reach the same file (native): a save dialog's answer and the path the + /// save writes to after Lowercase Extension, on a file system that ignores letter case. A save + /// asks before replacing a file it finds different (`replace_prompt`). + pub same_file: Option, /// Encode and write in one step on a worker thread, so a large save doesn't freeze the /// window (#2017). With background jobs on, saves use it instead of `export` and `write`. pub save_file: Option, diff --git a/crates/ui-egui/src/replace_prompt.rs b/crates/ui-egui/src/replace_prompt.rs new file mode 100644 index 0000000000..7f301db8e7 --- /dev/null +++ b/crates/ui-egui/src/replace_prompt.rs @@ -0,0 +1,46 @@ +//! A save asks before replacing a file that the save dialog did not ask about. The dialog asks +//! only about the name it answers, and the app can change that name afterwards: it gives a name +//! without an extension the suggested file's (#5039), and lowercases the extension when +//! Preferences ▸ File Handling › Lowercase Extension is on. When a file exists under the changed +//! name, Replace writes over it and Cancel ends the save like the save dialog's Cancel. +//! +//! The prompt is an ordinary dialog in the UI state: `ui.dialog.confirm` replaces the file and +//! `ui.dialog.cancel` keeps it. The file dialog stays open until it is answered +//! ([`PhotocraftApp::file_dialog_open`]). + +use serde_json::{Map, Value, json}; + +use crate::PhotocraftApp; +use crate::file_open::display_name; +use crate::state::DialogKind; + +const MARK: &str = "__replaceSaved"; + +pub fn owns(fields: &Map) -> bool { + fields.contains_key(MARK) +} + +/// Opens the prompt about `path`, the file a save writes to for `answered`, the save dialog's +/// answer. +pub(crate) fn open(app: &mut PhotocraftApp, answered: &str, path: &str) { + let message = crate::i18n::fmt(tl!("“{name}” already exists. Replace it?"), &[("name", display_name(path).as_str())]); + let detail = crate::i18n::fmt(tl!("The name in the save dialog was “{name}”."), &[("name", display_name(answered).as_str())]); + let fields = json!({MARK: true, "__label": "Save", "message": message, "detail": detail, "path": path}); + app.ui.open_dialog(DialogKind::Command, fields.as_object().cloned().unwrap_or_default()); +} + +pub fn body(ui: &mut egui::Ui, fields: &Map) { + ui.label(fields.get("message").and_then(Value::as_str).unwrap_or_default()); + ui.add_space(6.0); + ui.label(fields.get("detail").and_then(Value::as_str).unwrap_or_default()); +} + +/// Replace: the save the prompt held back. +pub fn confirm(app: &mut PhotocraftApp) -> Result { + app.answer_replace(true) +} + +/// Cancel: nothing is written. +pub fn cancel(app: &mut PhotocraftApp) -> Result { + app.answer_replace(false) +} diff --git a/crates/ui-egui/src/swatches_ui.rs b/crates/ui-egui/src/swatches_ui.rs index a18ddee640..e73ec1d440 100644 --- a/crates/ui-egui/src/swatches_ui.rs +++ b/crates/ui-egui/src/swatches_ui.rs @@ -442,6 +442,32 @@ mod tests { assert!(out[0].1.starts_with(b"ASEF")); } + /// The Linux save dialog shows the export's name without its extension, `Swatches`, and asks + /// only about that name. The export adds `.aco`, so with `Swatches.aco` in the folder it asks + /// before replacing it. + #[test] + fn export_asks_before_replacing_the_file_its_added_extension_names() { + use crate::file_dialog::FileDialogAnswer; + let written: std::rc::Rc>> = Default::default(); + let w = written.clone(); + let services = crate::Services { + file_dialog: Some(crate::file_dialog::fake(vec![Some(FileDialogAnswer::SaveTo("/swatches/Swatches".into()))]).0), + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.borrow_mut().push(p.to_string()); + Ok(()) + })), + file_stamp: Some(Box::new(|p: &str| (p == "/swatches/Swatches.aco").then_some(crate::FileStamp { len: 1, modified: None }))), + ..Default::default() + }; + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), services); + export_dialog(&mut app, "aco").unwrap(); + app.poll_file_dialog(&egui::Context::default(), None); + let id = app.ui.dialogs.iter().find(|d| crate::replace_prompt::owns(&d.fields)).map(|d| d.id).expect("the export asks first"); + assert!(written.borrow().is_empty(), "nothing is written before the answer"); + crate::dialogs::confirm(&mut app, id).unwrap(); + assert_eq!(*written.borrow(), ["/swatches/Swatches.aco"]); + } + /// #3658: dragging a group header reorders the folders, and the new order sticks in the UI /// state (UI state is serde, so it saves and reloads with the rest of the panel state). #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15334ac3a2..873dcb5f60 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -66,6 +66,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - `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. + - A save dialog's answer that PhotoCraft changes, by giving a name without an extension the suggested file's or by lowercasing the extension (Preferences › File Handling › Lowercase Extension), opens a replace prompt when a file exists under the changed name: a dialog with `__replaceSaved`, `message`, `detail` and `path` (the file the save would replace). `ui.dialog.confirm` writes the file; `ui.dialog.cancel` ends the save without writing, as Cancel in the save dialog does. The file dialog counts as open until the prompt is answered. A name that differs only in letter case and reaches the same file (Windows and macOS by default) does not open it. - 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"]`). diff --git a/docs/ui-dialogs.md b/docs/ui-dialogs.md index 032edfde5d..8767f59714 100644 --- a/docs/ui-dialogs.md +++ b/docs/ui-dialogs.md @@ -50,6 +50,7 @@ Dispatch order matches `dialogs::show` in `dialogs.rs` (check earlier rows befor | `__stroke` | `stroke_ui.rs` | Edit › Stroke… | | `__createShape` | `shape_dialog.rs` | Click-with-shape-tool creation (rectangle, ellipse, …) | | `__deleteLayer` | `delete_layer_prompt.rs` | Layers panel trash confirmation | +| `__replaceSaved` | `replace_prompt.rs` | Replace prompt when a save's added or lowercased extension names an existing file | | `__newLayer` | `new_layer_ui.rs` | Layer › New › Layer… (⇧⌘N; ⌥⇧⌘N makes the layer without it) | | `__rasterize` | `rasterize_prompt.rs` | Rasterize-before-paint prompt | | `__resetAllTools` | `tool_reset.rs` | Reset All Tools confirmation | @@ -101,6 +102,7 @@ actions with their marker already set. | `__stroke` | `stroke_ui` | | `__createShape` | `shape_dialog` | | `__deleteLayer` | `delete_layer_prompt` | +| `__replaceSaved` | `replace_prompt` | | `__newLayer` | `new_layer_ui` | | `__rasterize` | `rasterize_prompt` | | `__resetAllTools` | `tool_reset` |