From 4e75f135e9194908414f60dde0fcc8d869297a94 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 04:50:29 -0800 Subject: [PATCH 1/9] Docs: consolidate assessment target metadata --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 15d40489a..a76d8e44a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (font-family fields accept the mouse wheel, #5253; Units › East Asian Type acts, with Q and Ha units; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence From d13660e8bc70317c2212410cd5cf50b046e22d0f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 04:54:22 -0800 Subject: [PATCH 2/9] Tests: isolate font wheel picking from missing-glyph protection --- crates/ui-egui/src/tests/font_menu.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index 624c32c7e..fa80c359d 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -206,6 +206,9 @@ fn a_press_outside_the_menu_ends_the_preview_at_once() { /// A document with point type in Inter, selected; its id. fn selected_inter(app: &mut VectorcraftApp) -> u64 { app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + // These tests exercise font picking, including system fonts without Latin glyphs. Missing + // glyph protection would intentionally keep the sample text in its previous font. + app.run("prefs.set", json!({"key": "missingGlyphProtection", "value": false})).unwrap(); let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap(); app.session.execute("select.set", &json!({"ids": [id]})).unwrap(); if !crate::panel_docking::showing(&app.ui, "character") { From d11764c11751897ad076d9db327c6e144e71cac4 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:54:57 +0530 Subject: [PATCH 3/9] Fix #5236: Edit Original opens linked files in a chosen app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preferences › File Handling › Edit Original With picks the app Links panel › Edit Original opens a linked file in: System Default (as before), PhotoCraft (found in /Applications or ~/Applications on macOS, on the PATH elsewhere) or Other Application, with a path field and Choose…. The desktop host's new `open_file_with` service starts only an application (an .app bundle on macOS, an .exe on Windows, an executable file elsewhere), with the path as its only argument and never through a shell. Edit Original still refuses control requests. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/host_services.rs | 143 +++++++++++++++++++++++ crates/engine/src/cmd/prefscmds.rs | 2 + crates/engine/src/prefs.rs | 5 + crates/ui-egui/src/io.rs | 4 +- crates/ui-egui/src/io/export.rs | 10 ++ crates/ui-egui/src/panels/links.rs | 2 +- crates/ui-egui/src/panels/tests_links.rs | 42 +++++++ crates/ui-egui/src/prefs_dialog.rs | 84 +++++++++++++ crates/ui-egui/src/prefs_dialog/tests.rs | 27 +++++ crates/ui-egui/src/services.rs | 7 ++ docs/control-protocol.md | 4 + docs/mcp.md | 4 +- 12 files changed, 331 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index e25332b61..e23cea243 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -300,6 +300,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { reveal: Some(Box::new(reveal)), // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), + // Edit Original with Preferences › File Handling › Edit Original With set (#5236). + open_file_with: Some(Box::new(open_file_with)), pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] @@ -334,3 +336,144 @@ fn open_file(path: &str) -> Result<(), String> { c.arg(path); c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) } + +/// Edit Original with Preferences › File Handling › Edit Original With set (#5236): open `path` in +/// `app` (`photocraft`, or an application's path). The app must be an application ([`check_app`]); +/// it gets the path as its only argument and is never started through a shell. +fn open_file_with(app: &str, path: &str) -> Result<(), String> { + explorer_safe(path)?; + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from); + let app = resolve_app(app, home.as_deref(), std::env::var_os("PATH").as_deref())?; + check_app(&app)?; + #[cfg(target_os = "macos")] + let mut c = { + let mut c = std::process::Command::new("open"); + c.arg("-a").arg(&app).arg(path); + c + }; + #[cfg(not(target_os = "macos"))] + let mut c = { + let mut c = std::process::Command::new(&app); + c.arg(path); + c + }; + c.spawn().map(|_| ()).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) +} + +/// The application Edit Original With names: PhotoCraft where it is installed for `photocraft` +/// ([`photocraft_candidates`]), else the path as given. +pub(crate) fn resolve_app(app: &str, home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Result { + if app.trim().eq_ignore_ascii_case("photocraft") { + return photocraft_candidates(home, path_var) + .into_iter() + .find(|p| check_app(p).is_ok()) + .ok_or_else(|| "PhotoCraft isn't installed: choose another app in Preferences › File Handling › Edit Original With".to_string()); + } + Ok(std::path::PathBuf::from(app.trim())) +} + +/// Where PhotoCraft may be installed: the Applications folders on macOS, else a `photocraft` +/// program on the `PATH`. +pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Vec { + if cfg!(target_os = "macos") { + let mut v = vec![std::path::PathBuf::from("/Applications/PhotoCraft.app")]; + v.extend(home.map(|h| h.join("Applications/PhotoCraft.app"))); + return v; + } + let name = if cfg!(windows) { "photocraft.exe" } else { "photocraft" }; + path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default() +} + +/// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on +/// Windows, an executable file elsewhere? Anything else (a document, a folder, a script on +/// Windows) is refused, so the preference can only ever open an installed app. +pub(crate) fn check_app(app: &std::path::Path) -> Result<(), String> { + let ext = app.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let ok = if cfg!(target_os = "macos") { + ext == "app" && app.is_dir() + } else if cfg!(windows) { + ext == "exe" && app.is_file() + } else { + executable_file(app) + }; + if ok { Ok(()) } else { Err(format!("{} isn't an application Edit Original can open files in", app.display())) } +} + +#[cfg(unix)] +fn executable_file(p: &std::path::Path) -> bool { + use std::os::unix::fs::PermissionsExt as _; + std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) +} + +#[cfg(not(unix))] +fn executable_file(p: &std::path::Path) -> bool { + p.is_file() +} + +#[cfg(test)] +mod open_with_tests { + use super::{check_app, photocraft_candidates, resolve_app}; + + /// A fresh, empty folder under the system's temporary folder. + fn scratch(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("vc-open-with-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// #5236: Edit Original With only ever starts an application, never a document, a folder or a + /// missing path. + #[test] + fn only_applications_are_started() { + let d = scratch("check"); + let doc = d.join("notes.txt"); + std::fs::write(&doc, "hi").unwrap(); + assert!(check_app(&doc).is_err(), "a document"); + assert!(check_app(&d).is_err(), "a folder"); + assert!(check_app(&d.join("Missing.app")).is_err(), "a missing path"); + if cfg!(target_os = "macos") { + let app = d.join("Editor.app"); + std::fs::create_dir_all(&app).unwrap(); + assert!(check_app(&app).is_ok(), "an .app bundle"); + } + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::os::unix::fs::PermissionsExt as _; + let tool = d.join("editor"); + std::fs::write(&tool, "#!/bin/sh\n").unwrap(); + assert!(check_app(&tool).is_err(), "not executable yet"); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(check_app(&tool).is_ok(), "an executable file"); + } + let _ = std::fs::remove_dir_all(&d); + } + + /// `photocraft` is looked for where PhotoCraft installs; any other value is taken as a path. + #[test] + fn photocraft_is_found_where_it_installs() { + let d = scratch("find"); + assert_eq!(resolve_app("/Apps/Other.app", None, None).unwrap(), std::path::PathBuf::from("/Apps/Other.app")); + if cfg!(target_os = "macos") { + let candidates = photocraft_candidates(Some(&d), None); + assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app")); + assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}"); + std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap(); + assert!(resolve_app("PhotoCraft", Some(&d), None).unwrap().ends_with("PhotoCraft.app")); + } else { + let bin = d.join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + let path_var = std::env::join_paths([&bin]).unwrap(); + assert!(resolve_app("photocraft", None, Some(&path_var)).is_err(), "not installed"); + let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" }); + std::fs::write(&exe, "").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + assert_eq!(resolve_app("photocraft", None, Some(&path_var)).unwrap(), exe); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 6950eeaac..d1aff0ca5 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -372,6 +372,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), // File Handling (continued) p!("templatesFolder", "File Handling", "Files", "Templates Folder", text), + // "" (the system's default app), `photocraft` or an application's path (#5236). + p!("editOriginalApp", "File Handling", "Files", "Edit Original With", text), p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool), // Native saves (`document.save {compress}`) p!("useCompression", "File Handling", "Files", "Use Compression", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index db3ee7e75..46c3d111f 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -253,6 +253,10 @@ pub struct Prefs { // File Handling (continued) /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). pub templates_folder: String, + /// The app Links panel › Edit Original opens a linked file in (#5236): "" = the system's + /// default app for the file, `photocraft` = PhotoCraft wherever it is installed, else the path + /// of an application. + pub edit_original_app: String, /// Open older native files as " [Converted]" so Save asks for a new name. pub append_converted: bool, /// File Handling → Use Compression: native saves are gzip-compressed (`document.save @@ -464,6 +468,7 @@ impl Default for Prefs { pdf_presets: vec![], screen_export_presets: vec![], templates_folder: String::new(), + edit_original_app: String::new(), append_converted: true, use_compression: false, web_export_presets: vec![], diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 89c102f35..38a67400a 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -18,7 +18,9 @@ mod open; mod save; pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, export_pdf, hide_exports, reveal, save_command_output}; -pub(crate) use export::{export_files, exported_with_notes, file_url, open_in_app, reveal_path, run_to_file, save_command_output_named}; +pub(crate) use export::{ + export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named, +}; pub(crate) use open::{EXPORT_PRESETS, import_presets, opened}; pub use open::{ MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document, diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 60c7656dd..592d60ec0 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -217,6 +217,16 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St app.services.open_file.as_mut().ok_or("no app to open files here")?(path) } +/// Edit Original: open `path` in the app Preferences › File Handling › Edit Original With names +/// (#5236), or in the system's default app for it when that is System Default (""). +pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { + let with = app.session.prefs.edit_original_app.trim().to_string(); + if with.is_empty() { + return open_in_app(app, path); + } + app.services.open_file_with.as_mut().ok_or("no app to open files in here")?(&with, path) +} + /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; /// without one the web downloads them (the ZIP, or each file) and the other frontends get them /// back as base64. With `openLocation`, the file manager shows the first file written. When an diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 9e751cf1f..9fc91f506 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -227,7 +227,7 @@ pub(crate) fn open_file(app: &mut VectorcraftApp, p: &Value, reveal: bool) -> Re } else { app.refuse_automation("links.editOriginal")?; openable_original(&path)?; - crate::io::open_in_app(app, &path)?; + crate::io::open_original(app, &path)?; } Ok(json!({ "path": path })) } diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 98e1dd250..fb323122c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -168,6 +168,48 @@ fn edit_original_and_show_in_folder_hand_the_file_to_the_system() { assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); } +/// #5236: Preferences › File Handling › Edit Original With picks the app Edit Original opens the +/// file in: System Default ("") hands it to the system's default app as before, PhotoCraft or an +/// application's path to that app. +#[test] +fn edit_original_opens_the_file_in_the_chosen_app() { + /// What each open asked for: (the app, `None` for the system's default, and the path). + type Opened = Rc, String)>>>; + let dir = Folder::new("with"); + let (mut app, linked, _) = app(&dir); + let opened: Opened = Rc::default(); + let (o, w) = (opened.clone(), opened.clone()); + app.services.open_file = Some(Box::new(move |p: &str| { + o.borrow_mut().push((None, p.to_string())); + Ok(()) + })); + app.services.open_file_with = Some(Box::new(move |with: &str, p: &str| { + w.borrow_mut().push((Some(with.to_string()), p.to_string())); + Ok(()) + })); + let path = app.run("links.editOriginal", json!({"id": linked})).unwrap()["path"].as_str().unwrap().to_string(); + for value in ["photocraft", "/Applications/Example.app"] { + app.run("prefs.set", json!({"key": "editOriginalApp", "value": value})).unwrap(); + app.run("links.editOriginal", json!({"id": linked})).unwrap(); + } + app.run("prefs.set", json!({"key": "editOriginalApp", "value": ""})).unwrap(); + app.run("links.editOriginal", json!({"id": linked})).unwrap(); + assert_eq!( + *opened.borrow(), + [ + (None, path.clone()), + (Some("photocraft".into()), path.clone()), + (Some("/Applications/Example.app".into()), path.clone()), + (None, path.clone()), + ] + ); + // An app the host can't open in (the web) is an error, not the default app instead. + app.run("prefs.set", json!({"key": "editOriginalApp", "value": "photocraft"})).unwrap(); + app.services.open_file_with = None; + assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); + assert_eq!(opened.borrow().len(), 4); +} + /// Edit Original hands only regular image and document files to the system's default app. #[test] fn edit_original_opens_image_and_document_files_only() { diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index b91c149f4..03ee49be9 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -176,6 +176,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); let (mut ok, mut cancel, mut reset) = (false, false, false); + d.fields.insert(CAN_PICK_APP.into(), json!(crate::picks::can(app, &crate::picks::PickRequest::Open(app_pick())))); let cat = d.str("__category"); let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]); crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::unique("dialog-preferences"), -20.0, 20, |ui| { @@ -242,6 +243,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert(k, v); } } + if d.fields.remove(CHOOSE_APP).is_some() { + choose_app(app, &mut d); + } app.ui.dialog = Some(d); if cancel { app.ui.dialog = None; @@ -380,6 +384,7 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { } }); } + PrefKind::Text if sp.key == "editOriginalApp" => edit_original_row(ui, d, sp.key, sp.label, &v), PrefKind::Text => { labeled(ui, sp.label, |ui| { let mut s = v.as_str().unwrap_or("").to_string(); @@ -410,6 +415,85 @@ fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: & }); } +/// File Handling › Edit Original With (#5236): System Default (""), PhotoCraft (`photocraft`) or +/// Other Application, which shows a field for the application's path. +fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, v: &Value) { + const OPTIONS: [&str; 3] = ["System Default", "PhotoCraft", "Other Application"]; + let cur = v.as_str().unwrap_or("").trim().to_string(); + let photocraft = cur.eq_ignore_ascii_case("photocraft"); + // Other Application with no path typed yet is still System Default: remember the choice. + let chose_other = egui::Id::unique("prefs-edit-original-other"); + let mut other = !photocraft && (!cur.is_empty() || ui.ctx().data(|m| m.get_temp::(chose_other)).unwrap_or(false)); + let at = if photocraft { + 1 + } else if other { + 2 + } else { + 0 + }; + labeled(ui, label, |ui| { + if let Some(i) = widgets::dropdown(ui, key, OPTIONS[at], &OPTIONS, 260.0) { + other = i == 2; + ui.ctx().data_mut(|m| m.insert_temp(chose_other, other)); + let new = match i { + 0 => String::new(), + 1 => "photocraft".to_string(), + _ if photocraft => String::new(), + _ => cur.clone(), + }; + d.fields.insert(key.into(), json!(new)); + } + }); + if other { + let can_pick = d.bool(CAN_PICK_APP); + labeled(ui, "Application Path", |ui| { + let mut s = if photocraft { String::new() } else { cur.clone() }; + let width = if can_pick { 186.0 } else { 260.0 }; + let field = egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(width); + let hint = if cfg!(target_os = "macos") { "/Applications/Example.app" } else { "" }; + if ui.add(field.hint_text(hint)).changed() { + d.fields.insert(key.into(), json!(s)); + } + // The open dialog runs from `show`, which has the app ([`choose_app`]). + if can_pick && widgets::flat_button(ui, tl!("Choose…"), 68.0).clicked() { + d.fields.insert(CHOOSE_APP.into(), json!(true)); + } + }); + } +} + +/// UI-only dialog fields for Edit Original With's Choose… button: whether the host shows an open +/// dialog, and that the button was clicked this frame. +const CAN_PICK_APP: &str = "__canPickApp"; +const CHOOSE_APP: &str = "__chooseApp"; + +/// The open dialog Edit Original With's Choose… shows: applications (`.app` bundles in +/// Applications on macOS, `.exe` files on Windows, any file elsewhere). +fn app_pick() -> crate::FilePick { + let filters: Vec = if cfg!(target_os = "macos") { + vec![("Applications".into(), vec!["app".into()])] + } else if cfg!(windows) { + vec![("Programs".into(), vec!["exe".into()])] + } else { + vec![] + }; + let folder = cfg!(target_os = "macos").then(|| "/Applications".to_string()); + crate::FilePick { name: String::new(), folder, filters } +} + +/// Edit Original With › Choose…: the picked application into the dialog's `editOriginalApp`. Shown +/// off the UI thread, the answer fills the dialog as it is then ([`crate::picks::in_dialog`]). +fn choose_app(app: &mut VectorcraftApp, d: &mut Dialog) { + let r = crate::picks::in_dialog(app, d, |app, d| { + if let Some(path) = crate::picks::open(app, &app_pick()) { + d.fields.insert("editOriginalApp".into(), json!(path)); + } + Ok(()) + }); + // Picking a file doesn't fail. + let _ = r; +} + fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { // The app's outlined checkbox (it translates `label`): egui's own has no border at rest. let on = d.bool(key); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 80fb23a17..32dee4b4b 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -391,3 +391,30 @@ fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() { assert!(vectorcraft_text::hyphen::hyphen_points("typography").is_empty()); assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]); } + +/// #5236: File Handling › Edit Original With offers System Default, PhotoCraft and Other +/// Application, whose Choose… puts the picked application into the preference. +#[test] +fn edit_original_with_takes_a_chosen_application() { + let mut a = VectorcraftApp::new(Session::new(), Default::default()); + open(&mut a, Some("File Handling")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("Edit Original With") && text.contains("System Default"), "{text}"); + let asked: std::rc::Rc>> = Default::default(); + let seen = asked.clone(); + a.services.pick_open = Some(Box::new(move |p: &crate::FilePick| { + seen.borrow_mut().push(p.clone()); + Some("/Applications/Editor.app".into()) + })); + let mut d = a.ui.dialog.clone().unwrap(); + choose_app(&mut a, &mut d); + assert_eq!(d.str("editOriginalApp"), "/Applications/Editor.app"); + let pick = asked.borrow()[0].clone(); + if cfg!(target_os = "macos") { + assert_eq!((pick.folder.as_deref(), pick.filters[0].1.clone()), (Some("/Applications"), vec!["app".to_string()])); + } + // OK saves it; the dialog's own fields (`__…`) are not preferences. + a.ui.dialog = Some(d); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.edit_original_app, "/Applications/Editor.app"); +} diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 45f363eda..7e00322bc 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -65,6 +65,9 @@ pub(crate) fn browsable_url(url: &str) -> Result<(), String> { /// Shows a file in the system file manager, or opens it in its app. pub type RevealFn = Box Result<(), String>>; +/// Opens a file in a chosen app: `(app, path)`. +pub type OpenWithFn = Box Result<(), String>>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -116,6 +119,10 @@ pub struct Services { /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, + /// Open a file in a chosen app (desktop: Edit Original with Preferences › File Handling › Edit + /// Original With set): `(app, path)`, where `app` is `photocraft` or an application's path. + /// Without it (the web) Edit Original with an app set answers with an error. + pub open_file_with: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3da08ad0f..8c624dcb5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -668,6 +668,10 @@ one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.li `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). +The preference `editOriginalApp` (Preferences › File Handling › Edit Original With, #5236) picks the app: `""` (System +Default, the system's default app for the file), `"photocraft"` (PhotoCraft where it is installed) or an application's +path. The app must be an application (an `.app` bundle on macOS, an `.exe` on Windows, an executable file elsewhere), +and gets the file's path as its only argument. Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), diff --git a/docs/mcp.md b/docs/mcp.md index 6878e137b..2befc0205 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4216,7 +4216,9 @@ file is missing stays linked: relink it first); `links.unembed {id, path}` write clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and `links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window, -except for a placed document, which opens in a tab. +except for a placed document, which opens in a tab. The preference `editOriginalApp` (Preferences › File Handling › +Edit Original With) picks the app Edit Original opens the file in: `""` (the system's default app), `"photocraft"` or an +application's path (#5236). ```json {"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}} From 695cc04e06904254db1531de41570d70a33d43b4 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:28:25 +0530 Subject: [PATCH 4/9] Fix #5236: Edit Original With keeps its Other choice in the dialog and paths with spaces The Other Application choice (before a path is typed) is a `__` field of the Preferences dialog instead of egui memory, so a dialog opened again or reset starts at System Default. The path field shows the stored value untrimmed, so spaces can be typed. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/prefs_dialog.rs | 19 ++++++++++++------- crates/ui-egui/src/prefs_dialog/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 03ee49be9..97be22146 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -242,6 +242,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { d.fields.insert(k, v); } + d.fields.remove(CHOSE_OTHER_APP); } if d.fields.remove(CHOOSE_APP).is_some() { choose_app(app, &mut d); @@ -419,11 +420,13 @@ fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: & /// Other Application, which shows a field for the application's path. fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, v: &Value) { const OPTIONS: [&str; 3] = ["System Default", "PhotoCraft", "Other Application"]; - let cur = v.as_str().unwrap_or("").trim().to_string(); + // The field shows the value as stored, spaces included (the host trims it when it opens). + let raw = v.as_str().unwrap_or("").to_string(); + let cur = raw.trim().to_string(); let photocraft = cur.eq_ignore_ascii_case("photocraft"); - // Other Application with no path typed yet is still System Default: remember the choice. - let chose_other = egui::Id::unique("prefs-edit-original-other"); - let mut other = !photocraft && (!cur.is_empty() || ui.ctx().data(|m| m.get_temp::(chose_other)).unwrap_or(false)); + // Other Application with no path typed yet is still System Default: the dialog remembers the + // choice in a field of its own, which a new dialog and Reset Preferences start without. + let mut other = !photocraft && (!cur.is_empty() || d.bool(CHOSE_OTHER_APP)); let at = if photocraft { 1 } else if other { @@ -434,12 +437,12 @@ fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, labeled(ui, label, |ui| { if let Some(i) = widgets::dropdown(ui, key, OPTIONS[at], &OPTIONS, 260.0) { other = i == 2; - ui.ctx().data_mut(|m| m.insert_temp(chose_other, other)); + d.fields.insert(CHOSE_OTHER_APP.into(), json!(other)); let new = match i { 0 => String::new(), 1 => "photocraft".to_string(), _ if photocraft => String::new(), - _ => cur.clone(), + _ => raw.clone(), }; d.fields.insert(key.into(), json!(new)); } @@ -447,7 +450,7 @@ fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, if other { let can_pick = d.bool(CAN_PICK_APP); labeled(ui, "Application Path", |ui| { - let mut s = if photocraft { String::new() } else { cur.clone() }; + let mut s = if photocraft { String::new() } else { raw.clone() }; let width = if can_pick { 186.0 } else { 260.0 }; let field = egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(width); let hint = if cfg!(target_os = "macos") { "/Applications/Example.app" } else { "" }; @@ -466,6 +469,8 @@ fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, /// dialog, and that the button was clicked this frame. const CAN_PICK_APP: &str = "__canPickApp"; const CHOOSE_APP: &str = "__chooseApp"; +/// UI-only dialog field: Other Application was chosen, though no path is typed yet. +const CHOSE_OTHER_APP: &str = "__choseOtherApp"; /// The open dialog Edit Original With's Choose… shows: applications (`.app` bundles in /// Applications on macOS, `.exe` files on Windows, any file elsewhere). diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 32dee4b4b..143adc6e6 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -418,3 +418,25 @@ fn edit_original_with_takes_a_chosen_application() { confirm(&mut a).unwrap(); assert_eq!(a.session.prefs.edit_original_app, "/Applications/Editor.app"); } + +/// #5327 review: choosing Other Application before a path is typed is remembered by the dialog +/// itself, so a dialog opened again (or reset) starts at System Default, and a stored path keeps +/// its spaces. +#[test] +fn edit_original_with_other_is_the_dialogs_own_choice() { + let mut a = VectorcraftApp::new(Session::new(), Default::default()); + open(&mut a, Some("File Handling")); + assert!(!a.ui.dialog.as_ref().unwrap().bool(CHOSE_OTHER_APP), "a new dialog starts without the choice"); + let shown = |a: &mut VectorcraftApp| crate::tests_labels::painted_text(a, |a, ui| show(a, ui.ctx())).contains("Application Path"); + assert!(!shown(&mut a), "System Default shows no path field"); + a.ui.dialog.as_mut().unwrap().fields.insert(CHOSE_OTHER_APP.into(), json!(true)); + assert!(shown(&mut a), "Other Application with no path yet shows the field"); + a.ui.dialog = None; + open(&mut a, Some("File Handling")); + assert!(!shown(&mut a), "opened again: System Default"); + // A path with spaces is kept as typed. + let path = "/Applications/Visual Studio Code.app"; + a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!(path)); + confirm(&mut a).unwrap(); + assert_eq!(a.session.prefs.edit_original_app, path); +} From 7c5670b7978537f00f1c205388c4da6b4975a224 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:46:48 +0530 Subject: [PATCH 5/9] Fix #5236: report a failed open -a, and keep Other Application while its path is cleared macOS: wait for `open -a` (it returns once Launch Services has the request) and report its error, such as a folder named .app that isn't a bundle; other platforms still start the editor without waiting. A saved or typed path also sets the dialog's Other Application choice, so clearing it to type another keeps the field. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/host_services.rs | 14 ++++++++++++++ crates/ui-egui/src/prefs_dialog.rs | 5 +++++ crates/ui-egui/src/prefs_dialog/tests.rs | 5 +++++ 3 files changed, 24 insertions(+) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index e23cea243..4004050ff 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -357,6 +357,20 @@ fn open_file_with(app: &str, path: &str) -> Result<(), String> { c.arg(path); c }; + // macOS: `open` returns once Launch Services has the request, so waiting for it is brief and + // reports an app it can't open (a folder named `.app` that isn't a bundle). Elsewhere the + // editor itself runs: waiting would last until it closes. + #[cfg(target_os = "macos")] + { + let out = c.output().map_err(|e| format!("can't open {path} in {}: {e}", app.display()))?; + if out.status.success() { + return Ok(()); + } + let why = String::from_utf8_lossy(&out.stderr).trim().to_string(); + let why = if why.is_empty() { out.status.to_string() } else { why }; + Err(format!("can't open {path} in {}: {why}", app.display())) + } + #[cfg(not(target_os = "macos"))] c.spawn().map(|_| ()).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 97be22146..ef18c3ec5 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -427,6 +427,11 @@ fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, // Other Application with no path typed yet is still System Default: the dialog remembers the // choice in a field of its own, which a new dialog and Reset Preferences start without. let mut other = !photocraft && (!cur.is_empty() || d.bool(CHOSE_OTHER_APP)); + // A path (saved or typed) also counts as the choice, so clearing it to type another keeps the + // field. + if other && !d.bool(CHOSE_OTHER_APP) { + d.fields.insert(CHOSE_OTHER_APP.into(), json!(true)); + } let at = if photocraft { 1 } else if other { diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 143adc6e6..c2557aede 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -434,6 +434,11 @@ fn edit_original_with_other_is_the_dialogs_own_choice() { a.ui.dialog = None; open(&mut a, Some("File Handling")); assert!(!shown(&mut a), "opened again: System Default"); + // A saved path counts as the choice: clearing it to type another keeps the field. + a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!("/Applications/Old.app")); + assert!(shown(&mut a)); + a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!("")); + assert!(shown(&mut a), "the path cleared: still Other Application"); // A path with spaces is kept as typed. let path = "/Applications/Visual Studio Code.app"; a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!(path)); From 79c8d1a7e02190ce0f60e6d75445303ba1031631 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:56:29 +0530 Subject: [PATCH 6/9] Fix #5236: cap the wait for open -a at two seconds A stalled `open -a` no longer holds the window: Edit Original waits at most two seconds for its exit status (enough to report an app it can't open), then leaves the launch running and reaps it on a thread of its own. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/host_services.rs | 39 ++++++++++++++++++++------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 4004050ff..2568ae03b 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -357,18 +357,39 @@ fn open_file_with(app: &str, path: &str) -> Result<(), String> { c.arg(path); c }; - // macOS: `open` returns once Launch Services has the request, so waiting for it is brief and - // reports an app it can't open (a folder named `.app` that isn't a bundle). Elsewhere the - // editor itself runs: waiting would last until it closes. + // macOS: `open` returns once Launch Services has the request, so a short wait reports an app + // it can't open (a folder named `.app` that isn't a bundle). The wait is capped so a stalled + // `open` never holds the window: past it, the launch is left to run. Elsewhere the editor + // itself runs: waiting would last until it closes. #[cfg(target_os = "macos")] { - let out = c.output().map_err(|e| format!("can't open {path} in {}: {e}", app.display()))?; - if out.status.success() { - return Ok(()); + const WAIT: std::time::Duration = std::time::Duration::from_secs(2); + let fail = |why: String| format!("can't open {path} in {}: {why}", app.display()); + let mut child = c.stderr(std::process::Stdio::piped()).spawn().map_err(|e| fail(e.to_string()))?; + let started = std::time::Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + let mut why = String::new(); + if let Some(mut err) = child.stderr.take() { + // Best effort: without its message the exit status says why. + let _ = std::io::Read::read_to_string(&mut err, &mut why); + } + let why = why.trim().to_string(); + return Err(fail(if why.is_empty() { status.to_string() } else { why })); + } + Ok(None) if started.elapsed() < WAIT => std::thread::sleep(std::time::Duration::from_millis(20)), + Ok(None) => { + // Reaped off the UI thread once it ends. + std::thread::spawn(move || { + let _ = child.wait(); + }); + return Ok(()); + } + Err(e) => return Err(fail(e.to_string())), + } } - let why = String::from_utf8_lossy(&out.stderr).trim().to_string(); - let why = if why.is_empty() { out.status.to_string() } else { why }; - Err(format!("can't open {path} in {}: {why}", app.display())) } #[cfg(not(target_os = "macos"))] c.spawn().map(|_| ()).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) From d7aa1c561f9a98ced3595576ec1537aa9834c0e5 Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:18:32 +0530 Subject: [PATCH 7/9] Fix #5236: Edit Original is one checkbox, Use System Defaults for Edit Original MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review on #5327: Preferences › File Handling › Edit Original With (an app path and Choose…) becomes the checkbox Use System Defaults for Edit Original, on by default. Off, Edit Original opens a raster image in the app that made it, PhotoCraft, looked for only where it installs; any other file, or no PhotoCraft, goes to the system's default app. The preference is a boolean, so prefs.set over control or MCP can no longer name an executable. The label has a row in all 26 catalogs. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/host_services.rs | 52 ++++++------- crates/engine/src/cmd/prefscmds.rs | 4 +- crates/engine/src/prefs.rs | 10 +-- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/io/export.rs | 18 +++-- crates/ui-egui/src/panels/tests_links.rs | 54 +++++++------- crates/ui-egui/src/prefs_dialog.rs | 94 ------------------------ crates/ui-egui/src/prefs_dialog/tests.rs | 55 ++------------ crates/ui-egui/src/services.rs | 12 +-- docs/control-protocol.md | 8 +- docs/mcp.md | 6 +- 36 files changed, 113 insertions(+), 226 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 2568ae03b..2ed5f5530 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -300,8 +300,8 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services { reveal: Some(Box::new(reveal)), // Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders. open_file: Some(Box::new(open_file)), - // Edit Original with Preferences › File Handling › Edit Original With set (#5236). - open_file_with: Some(Box::new(open_file_with)), + // Edit Original of a raster image with Use System Defaults for Edit Original off (#5236). + open_in_photocraft: Some(Box::new(open_in_photocraft)), pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())), // Sample Color from Screen: the system's color sampler. #[cfg(target_os = "macos")] @@ -337,14 +337,14 @@ fn open_file(path: &str) -> Result<(), String> { c.spawn().map(|_| ()).map_err(|e| format!("can't open {path}: {e}")) } -/// Edit Original with Preferences › File Handling › Edit Original With set (#5236): open `path` in -/// `app` (`photocraft`, or an application's path). The app must be an application ([`check_app`]); -/// it gets the path as its only argument and is never started through a shell. -fn open_file_with(app: &str, path: &str) -> Result<(), String> { +/// Edit Original with Preferences › File Handling › Use System Defaults for Edit Original off +/// (#5236): open the raster image `path` in PhotoCraft, found only where it installs +/// ([`find_photocraft`]); `Ok(false)` when it isn't installed. PhotoCraft gets the path as its only +/// argument and is never started through a shell. +fn open_in_photocraft(path: &str) -> Result { explorer_safe(path)?; let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(std::path::PathBuf::from); - let app = resolve_app(app, home.as_deref(), std::env::var_os("PATH").as_deref())?; - check_app(&app)?; + let Some(app) = find_photocraft(home.as_deref(), std::env::var_os("PATH").as_deref()) else { return Ok(false) }; #[cfg(target_os = "macos")] let mut c = { let mut c = std::process::Command::new("open"); @@ -369,7 +369,7 @@ fn open_file_with(app: &str, path: &str) -> Result<(), String> { let started = std::time::Instant::now(); loop { match child.try_wait() { - Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) if status.success() => return Ok(true), Ok(Some(status)) => { let mut why = String::new(); if let Some(mut err) = child.stderr.take() { @@ -385,26 +385,19 @@ fn open_file_with(app: &str, path: &str) -> Result<(), String> { std::thread::spawn(move || { let _ = child.wait(); }); - return Ok(()); + return Ok(true); } Err(e) => return Err(fail(e.to_string())), } } } #[cfg(not(target_os = "macos"))] - c.spawn().map(|_| ()).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) + c.spawn().map(|_| true).map_err(|e| format!("can't open {path} in {}: {e}", app.display())) } -/// The application Edit Original With names: PhotoCraft where it is installed for `photocraft` -/// ([`photocraft_candidates`]), else the path as given. -pub(crate) fn resolve_app(app: &str, home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Result { - if app.trim().eq_ignore_ascii_case("photocraft") { - return photocraft_candidates(home, path_var) - .into_iter() - .find(|p| check_app(p).is_ok()) - .ok_or_else(|| "PhotoCraft isn't installed: choose another app in Preferences › File Handling › Edit Original With".to_string()); - } - Ok(std::path::PathBuf::from(app.trim())) +/// PhotoCraft where it is installed: the first of [`photocraft_candidates`] that is an application. +pub(crate) fn find_photocraft(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Option { + photocraft_candidates(home, path_var).into_iter().find(|p| check_app(p).is_ok()) } /// Where PhotoCraft may be installed: the Applications folders on macOS, else a `photocraft` @@ -421,7 +414,7 @@ pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Op /// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on /// Windows, an executable file elsewhere? Anything else (a document, a folder, a script on -/// Windows) is refused, so the preference can only ever open an installed app. +/// Windows) found where PhotoCraft installs is not taken for it. pub(crate) fn check_app(app: &std::path::Path) -> Result<(), String> { let ext = app.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); let ok = if cfg!(target_os = "macos") { @@ -447,7 +440,7 @@ fn executable_file(p: &std::path::Path) -> bool { #[cfg(test)] mod open_with_tests { - use super::{check_app, photocraft_candidates, resolve_app}; + use super::{check_app, find_photocraft, photocraft_candidates}; /// A fresh, empty folder under the system's temporary folder. fn scratch(name: &str) -> std::path::PathBuf { @@ -457,8 +450,8 @@ mod open_with_tests { d } - /// #5236: Edit Original With only ever starts an application, never a document, a folder or a - /// missing path. + /// #5236: only an application counts as PhotoCraft, never a document, a folder or a missing + /// path. #[test] fn only_applications_are_started() { let d = scratch("check"); @@ -484,22 +477,21 @@ mod open_with_tests { let _ = std::fs::remove_dir_all(&d); } - /// `photocraft` is looked for where PhotoCraft installs; any other value is taken as a path. + /// PhotoCraft is looked for only where it installs. #[test] fn photocraft_is_found_where_it_installs() { let d = scratch("find"); - assert_eq!(resolve_app("/Apps/Other.app", None, None).unwrap(), std::path::PathBuf::from("/Apps/Other.app")); if cfg!(target_os = "macos") { let candidates = photocraft_candidates(Some(&d), None); assert_eq!(candidates.first().map(|p| p.to_string_lossy().into_owned()).as_deref(), Some("/Applications/PhotoCraft.app")); assert!(candidates.contains(&d.join("Applications/PhotoCraft.app")), "{candidates:?}"); std::fs::create_dir_all(d.join("Applications/PhotoCraft.app")).unwrap(); - assert!(resolve_app("PhotoCraft", Some(&d), None).unwrap().ends_with("PhotoCraft.app")); + assert!(find_photocraft(Some(&d), None).unwrap().ends_with("PhotoCraft.app")); } else { let bin = d.join("bin"); std::fs::create_dir_all(&bin).unwrap(); let path_var = std::env::join_paths([&bin]).unwrap(); - assert!(resolve_app("photocraft", None, Some(&path_var)).is_err(), "not installed"); + assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed"); let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" }); std::fs::write(&exe, "").unwrap(); #[cfg(unix)] @@ -507,7 +499,7 @@ mod open_with_tests { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); } - assert_eq!(resolve_app("photocraft", None, Some(&path_var)).unwrap(), exe); + assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe)); } let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index d1aff0ca5..ad24afacb 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -372,8 +372,8 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool), // File Handling (continued) p!("templatesFolder", "File Handling", "Files", "Templates Folder", text), - // "" (the system's default app), `photocraft` or an application's path (#5236). - p!("editOriginalApp", "File Handling", "Files", "Edit Original With", text), + // Off: raster images open in PhotoCraft where it is installed (#5236). + p!("editOriginalSystemDefaults", "File Handling", "Files", "Use System Defaults for Edit Original", bool), p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool), // Native saves (`document.save {compress}`) p!("useCompression", "File Handling", "Files", "Use Compression", bool), diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 46c3d111f..5deb00ddd 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -253,10 +253,10 @@ pub struct Prefs { // File Handling (continued) /// Where Save as Template and New from Template start ("" = `Documents/VectorCraft Templates`). pub templates_folder: String, - /// The app Links panel › Edit Original opens a linked file in (#5236): "" = the system's - /// default app for the file, `photocraft` = PhotoCraft wherever it is installed, else the path - /// of an application. - pub edit_original_app: String, + /// File Handling → Use System Defaults for Edit Original (#5236): Links panel › Edit Original + /// opens a linked file in the system's default app for it; off, in the app that made it + /// (PhotoCraft for a raster image, where it is installed; the system's app otherwise). + pub edit_original_system_defaults: bool, /// Open older native files as " [Converted]" so Save asks for a new name. pub append_converted: bool, /// File Handling → Use Compression: native saves are gzip-compressed (`document.save @@ -468,7 +468,7 @@ impl Default for Prefs { pdf_presets: vec![], screen_export_presets: vec![], templates_folder: String::new(), - edit_original_app: String::new(), + edit_original_system_defaults: true, append_converted: true, use_compression: false, web_export_presets: vec![], diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 614054f41..d64cd5117 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1799,6 +1799,7 @@ font-technology Variable Променливи Manually Ръчно Mark as trapped Маркиране като прехванато Mark Older Files as [Converted] When Opened Маркиране на по-стари файлове като [Конвертирани] при отваряне + Use System Defaults for Edit Original Системни настройки по подразбиране за „Редактиране на оригинал“ Marker… Маркер… Marks Маркери Marks and Bleed Маркери и изтичане diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index a0b4e236d..7e29b1908 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3560,6 +3560,7 @@ theme Appearance Izgled List Preferences Izlistaj postavke Manually Ručno Mark Older Files as [Converted] When Opened Označi starije datoteke kao [Pretvoreno] pri otvaranju + Use System Defaults for Edit Original Koristi sistemske zadane postavke za Uredi original Match User Interface Brightness Uskladi sa svjetlinom korisničkog interfejsa Light Gray Svijetlosiva Medium Gray Srednje siva diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 2a1f0a518..2c9ea1267 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2330,6 +2330,7 @@ digits Malayalam Malaiàlam Manually Manualment Mark as trapped Marca com a reventat Mark Older Files as [Converted] When Opened Marca els fitxers antics com a [Convertit] en obrir-los + Use System Defaults for Edit Original Utilitza els valors per defecte del sistema per a Edita l’original Marker… Marcador… Marks Marques Marks and Bleed Marques i sangrat diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index b9e6fd740..dede785a7 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3601,6 +3601,7 @@ theme Appearance Motiv List Preferences Vypsat předvolby Manually Ručně Mark Older Files as [Converted] When Opened Při otevření označit starší soubory jako [Converted] + Use System Defaults for Edit Original Pro Upravit originál použít výchozí aplikace systému Match User Interface Brightness Odpovídat jasu uživatelského rozhraní Light Gray Světle šedá Medium Gray Středně šedá diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 20a0a4dfe..6d307f60f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3328,6 +3328,7 @@ theme Appearance Udseende List Preferences Oplist indstillinger Manually Manuelt Mark Older Files as [Converted] When Opened Markér ældre filer som [konverteret] ved åbning + Use System Defaults for Edit Original Anvend systemets standarder til Redigér original Match User Interface Brightness Match lysstyrken i brugergrænsefladen Light Gray Lysegrå Medium Gray Mellemgrå diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 94430d21e..f1aece539 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1861,6 +1861,7 @@ file-filter Templates Vorlagen Manually Manuell Mark as trapped Als überfüllt kennzeichnen Mark Older Files as [Converted] When Opened Ältere Dateien beim Öffnen als [Konvertiert] kennzeichnen + Use System Defaults for Edit Original Systemstandards für „Original bearbeiten“ verwenden Marker… Markierung… Marks Marken Marks and Bleed Marken und Anschnitt diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 0a7d2be71..56e14d395 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2651,6 +2651,7 @@ theme Appearance Εμφάνιση Manually Χειροκίνητα Mark as trapped Σήμανση ως επικαλυπτόμενου Mark Older Files as [Converted] When Opened Σήμανση παλαιότερων αρχείων ως [Μετατράπηκε] κατά το άνοιγμα + Use System Defaults for Edit Original Χρήση προεπιλογών συστήματος για την Επεξεργασία πρωτοτύπου Marker… Δείκτης… Marks Σημάδια Marks and Bleed Σημάδια και ξάκρισμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4e5ba28d4..84b860be9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1864,6 +1864,7 @@ font-technology Variable Variables Manually Manualmente Mark as trapped Marcar como reventado Mark Older Files as [Converted] When Opened Marcar archivos antiguos como [Convertido] al abrirlos + Use System Defaults for Edit Original Usar valores predeterminados del sistema para Editar original Marker… Marcador… Marks Marcas Marks and Bleed Marcas y sangrado diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index c75f15ba5..14d0957e0 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3525,6 +3525,7 @@ theme Appearance Ulkoasu List Preferences Luettele asetukset Manually Manuaalisesti Mark Older Files as [Converted] When Opened Merkitse vanhemmat tiedostot [Muunnettu] avattaessa + Use System Defaults for Edit Original Käytä järjestelmän oletuksia toiminnossa Muokkaa alkuperäistä Match User Interface Brightness Sovita käyttöliittymän kirkkauteen Light Gray Vaaleanharmaa Medium Gray Keskiharmaa diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 20b608bb4..0e8434dc4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1858,6 +1858,7 @@ font-technology Variable Variables Manually Manuellement Mark as trapped Marquer comme grossi-maigri Mark Older Files as [Converted] When Opened Marquer les anciens fichiers comme [Converti] à l’ouverture + Use System Defaults for Edit Original Utiliser les applications par défaut du système pour Modifier l’original Marker… Marqueur… Marks Repères Marks and Bleed Repères et fond perdu diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 12e8b0ec1..e3fee02cb 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2351,6 +2351,7 @@ file-filter Templates Sablonok Manually Kézzel Mark as trapped Megjelölés csapdázottként Mark Older Files as [Converted] When Opened Régebbi fájlok megjelölése [Átalakítva] címkével megnyitáskor + Use System Defaults for Edit Original Rendszer alapértelmezéseinek használata az Eredeti szerkesztéséhez Marker… Jelölő… Marks Jelek Marks and Bleed Jelek és kifutó diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8e4aa69f2..06cf4da3a 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3328,6 +3328,7 @@ theme Appearance Tampilan List Preferences Daftarkan preferensi Manually Secara manual Mark Older Files as [Converted] When Opened Tandai file lama sebagai [Converted] saat dibuka + Use System Defaults for Edit Original Gunakan default sistem untuk Edit asli Match User Interface Brightness Sesuaikan dengan kecerahan antarmuka pengguna Light Gray Abu-abu terang Medium Gray Abu-abu sedang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 6fd38b0f7..742624e12 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1893,6 +1893,7 @@ file-filter Templates Modelli Manually Manualmente Mark as trapped Contrassegna come sottoposto a trapping Mark Older Files as [Converted] When Opened Contrassegna i file meno recenti come [Convertito] all'apertura + Use System Defaults for Edit Original Usa le impostazioni predefinite di sistema per Modifica originale Marker… Indicatore… Marks Segni Marks and Bleed Segni e abbondanza diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5096fef13..1584618d1 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2094,6 +2094,7 @@ digits Malayalam マラヤーラム数字 Manage Workspaces ワークスペースを管理 Manually 手動 Mark Older Files as [Converted] When Opened 古いファイルを開くときに[変換]と表示 + Use System Defaults for Edit Original 「オリジナルを編集」にシステムのデフォルトを使用 Mark as trapped トラップ済みとしてマーク Marks トンボ Match User Interface Brightness ユーザーインターフェイスの明るさに合わせる diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5389e1f09..fabc4a369 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3138,6 +3138,7 @@ theme Appearance იერსახე List Preferences პარამეტრების სია Manually ხელით Mark Older Files as [Converted] When Opened ძველი ფაილების მარკირება როგორც [გარდაქმნილი] გახსნისას + Use System Defaults for Edit Original სისტემის ნაგულისხმევის გამოყენება ორიგინალის რედაქტირებისთვის Match User Interface Brightness ინტერფეისის სიკაშკაშესთან შესაბამისობა Light Gray ღია ნაცრისფერი Medium Gray საშუალო ნაცრისფერი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 8596570c7..7376f14d8 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2084,6 +2084,7 @@ digits Malayalam 말라얄람어 Manage Workspaces 작업 영역 관리 Manually 수동 Mark Older Files as [Converted] When Opened 열 때 이전 파일을 [Converted]로 표시 + Use System Defaults for Edit Original 원본 편집에 시스템 기본값 사용 Mark as trapped 트래핑됨으로 표시 Marks 표시 Match User Interface Brightness 사용자 인터페이스 밝기에 맞춤 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 4cde5d403..c2670658d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3342,6 +3342,7 @@ theme Appearance Vormgeving List Preferences Voorkeuren weergeven Manually Handmatig Mark Older Files as [Converted] When Opened Oudere bestanden bij het openen markeren als [geconverteerd] + Use System Defaults for Edit Original Systeemstandaarden gebruiken voor Origineel bewerken Match User Interface Brightness Aanpassen aan helderheid van gebruikersinterface Light Gray Lichtgrijs Medium Gray Middelgrijs diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 52800ade0..10ebc96bd 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3464,6 +3464,7 @@ theme Appearance Wygląd List Preferences Wyświetl preferencje Manually Ręcznie Mark Older Files as [Converted] When Opened Oznaczaj starsze pliki jako [Przekonwertowane] przy otwieraniu + Use System Defaults for Edit Original Używaj ustawień domyślnych systemu dla polecenia Edytuj oryginał Match User Interface Brightness Dopasuj do jasności interfejsu Light Gray Jasnoszary Medium Gray Średnio szary diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8369f4d23..f43b74b97 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1585,6 +1585,7 @@ Manage Workspaces… Gerenciar Espaços de Trabalho… Manually Manualmente Mark Older Files as [Converted] When Opened Marcar Arquivos Antigos como [Convertido] Ao Abrir + Use System Defaults for Edit Original Usar Padrões do Sistema para Editar Original Mark as trapped Marcar como sobreimpresso Marker… Marcador… Marks Marcas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index e6bf3f77c..0a42894b6 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1890,6 +1890,7 @@ file-filter Templates Шаблоны Manually Вручную Mark as trapped Пометить как треппинг Mark Older Files as [Converted] When Opened Помечать старые файлы как [Преобразованные] при открытии + Use System Defaults for Edit Original Использовать системные значения по умолчанию для «Редактировать оригинал» Marker… Маркер… Marks Метки Marks and Bleed Метки и вылет diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f12351040..214b19aed 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1805,6 +1805,7 @@ font-technology Variable Variabla Manually Manuellt Mark as trapped Märk som överfyllt Mark Older Files as [Converted] When Opened Märk äldre filer som [Konverterad] när de öppnas + Use System Defaults for Edit Original Använd systemets standardinställningar för Redigera original Marker… Markör… Marks Märken Marks and Bleed Märken och utfall diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b198e1af1..cdf14d3f5 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1699,6 +1699,7 @@ font-technology Variable Değişken Manually Manuel olarak Mark as trapped Sıkışmış olarak işaretle Mark Older Files as [Converted] When Opened Eski Dosyaları Açıldığında [Dönüştürülmüş] Olarak İşaretle + Use System Defaults for Edit Original Orijinali Düzenle için Sistem Varsayılanlarını Kullan Marker… işaretleyici… Marks İşaretler Marks and Bleed İşaretler ve Taşma Payı diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index d468a63d7..112ed5840 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1887,6 +1887,7 @@ file-filter Templates Шаблони Manually Вручну Mark as trapped Позначити як трепінг Mark Older Files as [Converted] When Opened Помічати старі файли як [Перетворені] при відкритті + Use System Defaults for Edit Original Використовувати системні типові програми для «Редагувати оригінал» Marker… Маркер… Marks Мітки Marks and Bleed Мітки та виліт diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 9d4acddcb..05b1dda5c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2172,6 +2172,7 @@ digits Hindi Hindi Manually Thủ công Mark as trapped Đánh dấu đã chồng màu Mark Older Files as [Converted] When Opened Đánh dấu tệp cũ là [Đã chuyển đổi] khi mở + Use System Defaults for Edit Original Dùng mặc định hệ thống cho Chỉnh sửa bản gốc Marker… Ký hiệu đánh dấu… Marks Dấu in Marks and Bleed Dấu in và chảy tràn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0426b415f..336057c43 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1774,6 +1774,7 @@ file-filter Templates 模板 Manually 手动 Mark as trapped 标示为已陷印 Mark Older Files as [Converted] When Opened 打开旧文件时标记为 [已转换] + Use System Defaults for Edit Original “编辑原稿”使用系统默认设置 Marker… 标记… Marks 标记 Marks and Bleed 标记和出血 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 92a1a9808..4ddbf0f69 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2728,6 +2728,7 @@ file-filter Templates 範本 Manually 手動 Mark as trapped 標示為已陷印 Mark Older Files as [Converted] When Opened 開啟舊檔時標示為 [已轉換] + Use System Defaults for Edit Original 「編輯原稿」使用系統預設值 Marker… 標記… Marks 標記 Marks and Bleed 標記與出血 diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 592d60ec0..f32323151 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -217,14 +217,20 @@ pub(crate) fn open_in_app(app: &mut VectorcraftApp, path: &str) -> Result<(), St app.services.open_file.as_mut().ok_or("no app to open files here")?(path) } -/// Edit Original: open `path` in the app Preferences › File Handling › Edit Original With names -/// (#5236), or in the system's default app for it when that is System Default (""). +/// Edit Original: open `path` in the system's default app for it, or with Preferences › File +/// Handling › Use System Defaults for Edit Original off, in the app that made it (#5236): PhotoCraft +/// for a raster image where it is installed. Any other file, or no PhotoCraft, uses the system's app. pub(crate) fn open_original(app: &mut VectorcraftApp, path: &str) -> Result<(), String> { - let with = app.session.prefs.edit_original_app.trim().to_string(); - if with.is_empty() { - return open_in_app(app, path); + let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().into_owned()).unwrap_or_default(); + let raster = vectorcraft_engine::cmd::fileio::readable_format(&ext).is_some_and(|f| f.raster); + if !app.session.prefs.edit_original_system_defaults + && raster + && let Some(open) = app.services.open_in_photocraft.as_mut() + && open(path)? + { + return Ok(()); } - app.services.open_file_with.as_mut().ok_or("no app to open files in here")?(&with, path) + open_in_app(app, path) } /// File → Export for Screens with `document.exportForScreens` params: the files go into `folder`; diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index fb323122c..215cf7148 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -1,7 +1,7 @@ //! The Links panel, its flyout, Link Info, Edit Original / Show in Folder, the Placement Options //! dialog and the Properties panel's image section, drawn headless. -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; use serde_json::json; @@ -168,44 +168,40 @@ fn edit_original_and_show_in_folder_hand_the_file_to_the_system() { assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); } -/// #5236: Preferences › File Handling › Edit Original With picks the app Edit Original opens the -/// file in: System Default ("") hands it to the system's default app as before, PhotoCraft or an -/// application's path to that app. +/// #5236: with Preferences › File Handling › Use System Defaults for Edit Original off, Edit +/// Original opens a raster image in the app that made it, PhotoCraft, and falls back to the +/// system's default app when PhotoCraft isn't installed or the file isn't a raster image. #[test] -fn edit_original_opens_the_file_in_the_chosen_app() { - /// What each open asked for: (the app, `None` for the system's default, and the path). - type Opened = Rc, String)>>>; +fn edit_original_opens_raster_images_in_photocraft_without_system_defaults() { + /// What each open asked for: (`true` for PhotoCraft, the path). + type Opened = Rc>>; let dir = Folder::new("with"); let (mut app, linked, _) = app(&dir); let opened: Opened = Rc::default(); - let (o, w) = (opened.clone(), opened.clone()); + let installed = Rc::new(Cell::new(true)); + let (o, w, i) = (opened.clone(), opened.clone(), installed.clone()); app.services.open_file = Some(Box::new(move |p: &str| { - o.borrow_mut().push((None, p.to_string())); + o.borrow_mut().push((false, p.to_string())); Ok(()) })); - app.services.open_file_with = Some(Box::new(move |with: &str, p: &str| { - w.borrow_mut().push((Some(with.to_string()), p.to_string())); - Ok(()) + app.services.open_in_photocraft = Some(Box::new(move |p: &str| { + if i.get() { + w.borrow_mut().push((true, p.to_string())); + } + Ok(i.get()) })); + // On (the default): the system's app. let path = app.run("links.editOriginal", json!({"id": linked})).unwrap()["path"].as_str().unwrap().to_string(); - for value in ["photocraft", "/Applications/Example.app"] { - app.run("prefs.set", json!({"key": "editOriginalApp", "value": value})).unwrap(); - app.run("links.editOriginal", json!({"id": linked})).unwrap(); - } - app.run("prefs.set", json!({"key": "editOriginalApp", "value": ""})).unwrap(); + app.run("prefs.set", json!({"key": "editOriginalSystemDefaults", "value": false})).unwrap(); app.run("links.editOriginal", json!({"id": linked})).unwrap(); - assert_eq!( - *opened.borrow(), - [ - (None, path.clone()), - (Some("photocraft".into()), path.clone()), - (Some("/Applications/Example.app".into()), path.clone()), - (None, path.clone()), - ] - ); - // An app the host can't open in (the web) is an error, not the default app instead. - app.run("prefs.set", json!({"key": "editOriginalApp", "value": "photocraft"})).unwrap(); - app.services.open_file_with = None; + installed.set(false); + app.run("links.editOriginal", json!({"id": linked})).unwrap(); + installed.set(true); + // A file that isn't a raster image goes to the system's app. + crate::io::open_original(&mut app, "/Art/plan.emf").unwrap(); + assert_eq!(*opened.borrow(), [(false, path.clone()), (true, path.clone()), (false, path), (false, "/Art/plan.emf".to_string())]); + // PhotoCraft failing to open it is said, not hidden behind the system's app. + app.services.open_in_photocraft = Some(Box::new(|_: &str| Err("PhotoCraft didn't start".to_string()))); assert!(app.run("links.editOriginal", json!({"id": linked})).is_err()); assert_eq!(opened.borrow().len(), 4); } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index ef18c3ec5..b91c149f4 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -176,7 +176,6 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let Some(mut d) = app.ui.dialog.clone() else { return }; let t = Tokens::get(ctx); let (mut ok, mut cancel, mut reset) = (false, false, false); - d.fields.insert(CAN_PICK_APP.into(), json!(crate::picks::can(app, &crate::picks::PickRequest::Open(app_pick())))); let cat = d.str("__category"); let cat = PREF_CATEGORIES.iter().find(|c| **c == cat).copied().unwrap_or(PREF_CATEGORIES[0]); crate::dialogs::modal::show(ctx, tl!("Preferences"), egui::Id::unique("dialog-preferences"), -20.0, 20, |ui| { @@ -242,10 +241,6 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for (k, v) in Prefs::default().to_json().as_object().cloned().unwrap_or_default() { d.fields.insert(k, v); } - d.fields.remove(CHOSE_OTHER_APP); - } - if d.fields.remove(CHOOSE_APP).is_some() { - choose_app(app, &mut d); } app.ui.dialog = Some(d); if cancel { @@ -385,7 +380,6 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) { } }); } - PrefKind::Text if sp.key == "editOriginalApp" => edit_original_row(ui, d, sp.key, sp.label, &v), PrefKind::Text => { labeled(ui, sp.label, |ui| { let mut s = v.as_str().unwrap_or("").to_string(); @@ -416,94 +410,6 @@ fn choice_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, opts: & }); } -/// File Handling › Edit Original With (#5236): System Default (""), PhotoCraft (`photocraft`) or -/// Other Application, which shows a field for the application's path. -fn edit_original_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, v: &Value) { - const OPTIONS: [&str; 3] = ["System Default", "PhotoCraft", "Other Application"]; - // The field shows the value as stored, spaces included (the host trims it when it opens). - let raw = v.as_str().unwrap_or("").to_string(); - let cur = raw.trim().to_string(); - let photocraft = cur.eq_ignore_ascii_case("photocraft"); - // Other Application with no path typed yet is still System Default: the dialog remembers the - // choice in a field of its own, which a new dialog and Reset Preferences start without. - let mut other = !photocraft && (!cur.is_empty() || d.bool(CHOSE_OTHER_APP)); - // A path (saved or typed) also counts as the choice, so clearing it to type another keeps the - // field. - if other && !d.bool(CHOSE_OTHER_APP) { - d.fields.insert(CHOSE_OTHER_APP.into(), json!(true)); - } - let at = if photocraft { - 1 - } else if other { - 2 - } else { - 0 - }; - labeled(ui, label, |ui| { - if let Some(i) = widgets::dropdown(ui, key, OPTIONS[at], &OPTIONS, 260.0) { - other = i == 2; - d.fields.insert(CHOSE_OTHER_APP.into(), json!(other)); - let new = match i { - 0 => String::new(), - 1 => "photocraft".to_string(), - _ if photocraft => String::new(), - _ => raw.clone(), - }; - d.fields.insert(key.into(), json!(new)); - } - }); - if other { - let can_pick = d.bool(CAN_PICK_APP); - labeled(ui, "Application Path", |ui| { - let mut s = if photocraft { String::new() } else { raw.clone() }; - let width = if can_pick { 186.0 } else { 260.0 }; - let field = egui::TextEdit::singleline(&mut s).align(egui::Align2::LEFT_CENTER).desired_width(width); - let hint = if cfg!(target_os = "macos") { "/Applications/Example.app" } else { "" }; - if ui.add(field.hint_text(hint)).changed() { - d.fields.insert(key.into(), json!(s)); - } - // The open dialog runs from `show`, which has the app ([`choose_app`]). - if can_pick && widgets::flat_button(ui, tl!("Choose…"), 68.0).clicked() { - d.fields.insert(CHOOSE_APP.into(), json!(true)); - } - }); - } -} - -/// UI-only dialog fields for Edit Original With's Choose… button: whether the host shows an open -/// dialog, and that the button was clicked this frame. -const CAN_PICK_APP: &str = "__canPickApp"; -const CHOOSE_APP: &str = "__chooseApp"; -/// UI-only dialog field: Other Application was chosen, though no path is typed yet. -const CHOSE_OTHER_APP: &str = "__choseOtherApp"; - -/// The open dialog Edit Original With's Choose… shows: applications (`.app` bundles in -/// Applications on macOS, `.exe` files on Windows, any file elsewhere). -fn app_pick() -> crate::FilePick { - let filters: Vec = if cfg!(target_os = "macos") { - vec![("Applications".into(), vec!["app".into()])] - } else if cfg!(windows) { - vec![("Programs".into(), vec!["exe".into()])] - } else { - vec![] - }; - let folder = cfg!(target_os = "macos").then(|| "/Applications".to_string()); - crate::FilePick { name: String::new(), folder, filters } -} - -/// Edit Original With › Choose…: the picked application into the dialog's `editOriginalApp`. Shown -/// off the UI thread, the answer fills the dialog as it is then ([`crate::picks::in_dialog`]). -fn choose_app(app: &mut VectorcraftApp, d: &mut Dialog) { - let r = crate::picks::in_dialog(app, d, |app, d| { - if let Some(path) = crate::picks::open(app, &app_pick()) { - d.fields.insert("editOriginalApp".into(), json!(path)); - } - Ok(()) - }); - // Picking a file doesn't fail. - let _ = r; -} - fn bool_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { // The app's outlined checkbox (it translates `label`): egui's own has no border at rest. let on = d.bool(key); diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index c2557aede..2e4711505 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -392,56 +392,17 @@ fn hyphenation_exceptions_dialog_ok_reaches_the_hyphenator() { assert_eq!(vectorcraft_text::hyphen::hyphen_points("happen"), vec![3]); } -/// #5236: File Handling › Edit Original With offers System Default, PhotoCraft and Other -/// Application, whose Choose… puts the picked application into the preference. +/// #5236: File Handling › Use System Defaults for Edit Original is a checkbox, on by default, and +/// OK saves it off. #[test] -fn edit_original_with_takes_a_chosen_application() { +fn use_system_defaults_for_edit_original_is_a_checkbox() { let mut a = VectorcraftApp::new(Session::new(), Default::default()); + assert!(a.session.prefs.edit_original_system_defaults, "on by default"); open(&mut a, Some("File Handling")); let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); - assert!(text.contains("Edit Original With") && text.contains("System Default"), "{text}"); - let asked: std::rc::Rc>> = Default::default(); - let seen = asked.clone(); - a.services.pick_open = Some(Box::new(move |p: &crate::FilePick| { - seen.borrow_mut().push(p.clone()); - Some("/Applications/Editor.app".into()) - })); - let mut d = a.ui.dialog.clone().unwrap(); - choose_app(&mut a, &mut d); - assert_eq!(d.str("editOriginalApp"), "/Applications/Editor.app"); - let pick = asked.borrow()[0].clone(); - if cfg!(target_os = "macos") { - assert_eq!((pick.folder.as_deref(), pick.filters[0].1.clone()), (Some("/Applications"), vec!["app".to_string()])); - } - // OK saves it; the dialog's own fields (`__…`) are not preferences. - a.ui.dialog = Some(d); + assert!(text.contains("Use System Defaults for Edit Original"), "{text}"); + assert!(a.ui.dialog.as_ref().unwrap().bool("editOriginalSystemDefaults")); + a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalSystemDefaults".into(), json!(false)); confirm(&mut a).unwrap(); - assert_eq!(a.session.prefs.edit_original_app, "/Applications/Editor.app"); -} - -/// #5327 review: choosing Other Application before a path is typed is remembered by the dialog -/// itself, so a dialog opened again (or reset) starts at System Default, and a stored path keeps -/// its spaces. -#[test] -fn edit_original_with_other_is_the_dialogs_own_choice() { - let mut a = VectorcraftApp::new(Session::new(), Default::default()); - open(&mut a, Some("File Handling")); - assert!(!a.ui.dialog.as_ref().unwrap().bool(CHOSE_OTHER_APP), "a new dialog starts without the choice"); - let shown = |a: &mut VectorcraftApp| crate::tests_labels::painted_text(a, |a, ui| show(a, ui.ctx())).contains("Application Path"); - assert!(!shown(&mut a), "System Default shows no path field"); - a.ui.dialog.as_mut().unwrap().fields.insert(CHOSE_OTHER_APP.into(), json!(true)); - assert!(shown(&mut a), "Other Application with no path yet shows the field"); - a.ui.dialog = None; - open(&mut a, Some("File Handling")); - assert!(!shown(&mut a), "opened again: System Default"); - // A saved path counts as the choice: clearing it to type another keeps the field. - a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!("/Applications/Old.app")); - assert!(shown(&mut a)); - a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!("")); - assert!(shown(&mut a), "the path cleared: still Other Application"); - // A path with spaces is kept as typed. - let path = "/Applications/Visual Studio Code.app"; - a.ui.dialog.as_mut().unwrap().fields.insert("editOriginalApp".into(), json!(path)); - confirm(&mut a).unwrap(); - assert_eq!(a.session.prefs.edit_original_app, path); + assert!(!a.session.prefs.edit_original_system_defaults); } diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs index 7e00322bc..3d3524788 100644 --- a/crates/ui-egui/src/services.rs +++ b/crates/ui-egui/src/services.rs @@ -65,8 +65,8 @@ pub(crate) fn browsable_url(url: &str) -> Result<(), String> { /// Shows a file in the system file manager, or opens it in its app. pub type RevealFn = Box Result<(), String>>; -/// Opens a file in a chosen app: `(app, path)`. -pub type OpenWithFn = Box Result<(), String>>; +/// Opens a file in a fixed app; `Ok(false)` when that app isn't installed. +pub type OpenInFn = Box Result>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] @@ -119,10 +119,10 @@ pub struct Services { /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, - /// Open a file in a chosen app (desktop: Edit Original with Preferences › File Handling › Edit - /// Original With set): `(app, path)`, where `app` is `photocraft` or an application's path. - /// Without it (the web) Edit Original with an app set answers with an error. - pub open_file_with: Option, + /// Open a file in PhotoCraft where it is installed (desktop: Edit Original of a raster image + /// with Preferences › File Handling › Use System Defaults for Edit Original off); `Ok(false)` + /// when it isn't. Without it Edit Original uses `open_file`. + pub open_in_photocraft: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, /// Show the system's color sampler, which picks a color anywhere on the screen (macOS, and diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8c624dcb5..b7f63de17 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -668,10 +668,10 @@ one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.li `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). -The preference `editOriginalApp` (Preferences › File Handling › Edit Original With, #5236) picks the app: `""` (System -Default, the system's default app for the file), `"photocraft"` (PhotoCraft where it is installed) or an application's -path. The app must be an application (an `.app` bundle on macOS, an `.exe` on Windows, an executable file elsewhere), -and gets the file's path as its only argument. +The preference `editOriginalSystemDefaults` (Preferences › File Handling › Use System Defaults for Edit Original, on +by default, #5236) opens the file in the system's default app for it; off, in the app that made it: a raster image in +PhotoCraft, looked for only where it installs (the Applications folders on macOS, the `PATH` elsewhere), and any other +file, or a raster image without PhotoCraft installed, in the system's default app. Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), diff --git a/docs/mcp.md b/docs/mcp.md index 2befc0205..bbd4451f5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -4216,9 +4216,9 @@ file is missing stays linked: relink it first); `links.unembed {id, path}` write clip?}` decides how a relinked or updated file takes an image's place (default `bounds`: stretched into the old bounds; `clip` puts it in a clip group of the old bounds when it is larger). The UI commands `links.editOriginal` and `links.reveal` open the linked file in its app or show it in its folder (desktop). Edit Original runs only from the app's window, -except for a placed document, which opens in a tab. The preference `editOriginalApp` (Preferences › File Handling › -Edit Original With) picks the app Edit Original opens the file in: `""` (the system's default app), `"photocraft"` or an -application's path (#5236). +except for a placed document, which opens in a tab. With the preference `editOriginalSystemDefaults` (Preferences › +File Handling › Use System Defaults for Edit Original, on by default) off, Edit Original opens a raster image in the app +that made it, PhotoCraft, where it is installed, and anything else in the system's default app (#5236). ```json {"name":"run_command","arguments":{"command":"links.list","params":{"show":"missing"}}} From 46f1ddf170bc43b59805b28401646e4ad4c629da Mon Sep 17 00:00:00 2001 From: ruchirguitar <113586074+ruchirguitar@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:16:46 +0530 Subject: [PATCH 8/9] Fix #5236: find PhotoCraft where its Windows installer puts it Per CodeRabbit on #5327: on Windows Edit Original looked for photocraft.exe only on the PATH, but PhotoCraft's MSI installs it to ArtCraft\PhotoCraft under %LOCALAPPDATA%\Programs (per user, the default) or Program Files (for everyone), neither on the PATH. Look first at the path the installer registers under App Paths (HKCU, then HKLM; `reg query`, as i18n reads the locale, which also covers a folder chosen in setup), then those two default folders, then the PATH. Each candidate must still be an .exe. Co-Authored-By: Claude Opus 5.5 --- apps/vectorcraft/src/host_services.rs | 109 +++++++++++++++++++++++++- docs/control-protocol.md | 5 +- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index 2ed5f5530..fc54c504b 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -400,8 +400,8 @@ pub(crate) fn find_photocraft(home: Option<&std::path::Path>, path_var: Option<& photocraft_candidates(home, path_var).into_iter().find(|p| check_app(p).is_ok()) } -/// Where PhotoCraft may be installed: the Applications folders on macOS, else a `photocraft` -/// program on the `PATH`. +/// Where PhotoCraft may be installed: the Applications folders on macOS; on Windows where its +/// installer puts it ([`windows_candidates`]); else a `photocraft` program on the `PATH`. pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Option<&std::ffi::OsStr>) -> Vec { if cfg!(target_os = "macos") { let mut v = vec![std::path::PathBuf::from("/Applications/PhotoCraft.app")]; @@ -409,7 +409,73 @@ pub(crate) fn photocraft_candidates(home: Option<&std::path::Path>, path_var: Op return v; } let name = if cfg!(windows) { "photocraft.exe" } else { "photocraft" }; - path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default() + let on_path = path_var.map(|p| std::env::split_paths(p).map(|dir| dir.join(name)).collect()).unwrap_or_default(); + if cfg!(windows) { + let folders: Vec = [ + std::env::var_os("LOCALAPPDATA").map(|d| std::path::PathBuf::from(d).join("Programs")), + std::env::var_os("ProgramW6432").map(std::path::PathBuf::from), + std::env::var_os("ProgramFiles").map(std::path::PathBuf::from), + ] + .into_iter() + .flatten() + .collect(); + return windows_candidates(registered_photocraft(), &folders, on_path); + } + on_path +} + +/// Windows: PhotoCraft's installer puts it in `ArtCraft\PhotoCraft` under a folder of +/// `program_folders` (for the current user `%LOCALAPPDATA%\Programs`, for everyone Program Files) +/// and registers its path under App Paths, which also covers a folder chosen in setup. So: the +/// `registered` paths first, then those folders, then `on_path`. +pub(crate) fn windows_candidates( + registered: Vec, + program_folders: &[std::path::PathBuf], + on_path: Vec, +) -> Vec { + let mut v = registered; + for f in program_folders { + let p = f.join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + if !v.contains(&p) { + v.push(p); + } + } + v.extend(on_path); + v +} + +/// The paths PhotoCraft's installer registers under App Paths, for the current user and then for +/// everyone (`reg query`, so no extra dependency; a hidden console window). +#[cfg(windows)] +fn registered_photocraft() -> Vec { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + // reg.exe by its full path, so nothing named `reg` in the app's folder or on PATH runs instead. + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| r"C:\Windows".into()); + ["HKCU", "HKLM"] + .into_iter() + .filter_map(|hive| { + let key = format!(r"{hive}\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe"); + let out = std::process::Command::new(root.join("System32").join("reg.exe")) + .args(["query", &key, "/ve"]) + .creation_flags(CREATE_NO_WINDOW) + .output() + .ok()?; + registered_path(&String::from_utf8_lossy(&out.stdout)) + }) + .collect() +} + +#[cfg(not(windows))] +fn registered_photocraft() -> Vec { + Vec::new() +} + +/// The path in `reg query … /ve` output like +/// ` (Default) REG_SZ C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe`. +#[cfg_attr(not(any(windows, test)), allow(dead_code))] +pub(crate) fn registered_path(text: &str) -> Option { + text.lines().find_map(|l| l.split_once("REG_SZ")).map(|(_, v)| v.trim().trim_matches('"')).filter(|v| !v.is_empty()).map(std::path::PathBuf::from) } /// Is `app` an application Edit Original may start: an `.app` bundle on macOS, an `.exe` on @@ -440,7 +506,7 @@ fn executable_file(p: &std::path::Path) -> bool { #[cfg(test)] mod open_with_tests { - use super::{check_app, find_photocraft, photocraft_candidates}; + use super::{check_app, find_photocraft, photocraft_candidates, registered_path, windows_candidates}; /// A fresh, empty folder under the system's temporary folder. fn scratch(name: &str) -> std::path::PathBuf { @@ -503,4 +569,39 @@ mod open_with_tests { } let _ = std::fs::remove_dir_all(&d); } + + /// #5327 review: on Windows PhotoCraft is looked for where its installer puts it: the path it + /// registers, then `ArtCraft\PhotoCraft` in the per-user and per-machine program folders, then + /// the `PATH`. + #[test] + fn windows_looks_where_the_photocraft_installer_puts_it() { + use std::path::PathBuf; + let registered = vec![PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe")]; + let folders = [PathBuf::from("C:/Users/me/AppData/Local/Programs"), PathBuf::from("C:/Program Files"), PathBuf::from("C:/Program Files")]; + let on_path = vec![PathBuf::from("C:/bin/photocraft.exe")]; + let exe = |f: &str| PathBuf::from(f).join("ArtCraft").join("PhotoCraft").join("photocraft.exe"); + assert_eq!( + windows_candidates(registered, &folders, on_path), + [ + PathBuf::from("D:/Tools/PhotoCraft/photocraft.exe"), + exe("C:/Users/me/AppData/Local/Programs"), + exe("C:/Program Files"), + PathBuf::from("C:/bin/photocraft.exe"), + ] + ); + } + + /// The registered path is read from `reg query … /ve` output, spaces and quotes included; + /// no value, no path. + #[test] + fn the_registered_path_is_read_from_reg_query() { + let out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\ArtCraft\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(registered_path(out), Some(std::path::PathBuf::from(r"C:\Program Files\ArtCraft\PhotoCraft\photocraft.exe"))); + assert_eq!( + registered_path(" (Standard) REG_SZ \"C:\\A B\\photocraft.exe\""), + Some(std::path::PathBuf::from(r"C:\A B\photocraft.exe")) + ); + assert_eq!(registered_path("ERROR: The system was unable to find the specified registry key or value."), None); + assert_eq!(registered_path(" (Default) REG_SZ "), None); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b7f63de17..cdb7471df 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -670,8 +670,9 @@ the system and return `{path}`. Edit Original opens only a regular file whose ex reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). The preference `editOriginalSystemDefaults` (Preferences › File Handling › Use System Defaults for Edit Original, on by default, #5236) opens the file in the system's default app for it; off, in the app that made it: a raster image in -PhotoCraft, looked for only where it installs (the Applications folders on macOS, the `PATH` elsewhere), and any other -file, or a raster image without PhotoCraft installed, in the system's default app. +PhotoCraft, looked for only where it installs (the Applications folders on macOS; on Windows the path its installer +registers under App Paths, then `ArtCraft\PhotoCraft` in the per-user and per-machine program folders, then the `PATH`; +the `PATH` elsewhere), and any other file, or a raster image without PhotoCraft installed, in the system's default app. Package: File → Package… (`ui.packageDialog`) opens the `package` dialog for a saved document (a document never saved gets a `confirm` dialog whose OK runs `file.saveAs`). Fields: `folder` (the location), `name` (the package folder), From f3a9114e558dc690367fe6a13e0e26e7db6a5840 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 3 Nov 2026 19:58:28 +0700 Subject: [PATCH 9/9] Edit Original: the PhotoCraft lookup test holds where PhotoCraft is installed --- apps/vectorcraft/src/host_services.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/vectorcraft/src/host_services.rs b/apps/vectorcraft/src/host_services.rs index fc54c504b..81a20e601 100644 --- a/apps/vectorcraft/src/host_services.rs +++ b/apps/vectorcraft/src/host_services.rs @@ -557,7 +557,11 @@ mod open_with_tests { let bin = d.join("bin"); std::fs::create_dir_all(&bin).unwrap(); let path_var = std::env::join_paths([&bin]).unwrap(); - assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed"); + // On Windows an installed PhotoCraft (registered, or in a program folder) comes first, so + // there only the `PATH` candidate is checked. + if !cfg!(windows) { + assert!(find_photocraft(None, Some(&path_var)).is_none(), "not installed"); + } let exe = bin.join(if cfg!(windows) { "photocraft.exe" } else { "photocraft" }); std::fs::write(&exe, "").unwrap(); #[cfg(unix)] @@ -565,7 +569,12 @@ mod open_with_tests { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); } - assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe)); + if cfg!(windows) { + assert_eq!(photocraft_candidates(None, Some(&path_var)).last(), Some(&exe)); + assert!(check_app(&exe).is_ok()); + } else { + assert_eq!(find_photocraft(None, Some(&path_var)), Some(exe)); + } } let _ = std::fs::remove_dir_all(&d); }