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` |