diff --git a/Cargo.lock b/Cargo.lock index 3d94582d6..12f046a3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3086,6 +3086,7 @@ dependencies = [ "serde_json", "winit", "winresource", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 465e81d3b..149a5c232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,9 @@ muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2 = { version = "0.6", default-features = false, features = ["std"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] } objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] } +# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸ +# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it. +zbus = "=5.19.0" web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 69a1644fd..a4ef7cbee 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -228,11 +228,14 @@ fn services(originals: Originals, backend: Option, files: Files, frozen reveal: None, log_file: None, open_with: None, + check_editor: None, open_url: Some(Box::new(|url: &str| { let w = web_sys::window().ok_or("no window")?; w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) })), pick_folder: None, + // the browser reports prefers-color-scheme to egui + system_theme: None, } } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 41e6107e8..bbe495c7d 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -40,6 +40,9 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } objc2 = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs new file mode 100644 index 000000000..74333a301 --- /dev/null +++ b/apps/lightcraft/src/appearance.rs @@ -0,0 +1,198 @@ +//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux +//! desktops whose windowing layer reports no theme to egui (many Wayland compositors). +//! +//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker +//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a +//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer. +//! Elsewhere egui's own report (`Context::system_theme`) is used. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest +/// another variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by +/// absolute path, and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Wake the first frame after a slow initial Read as well as subsequent real changes. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock, code: u8) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = wake.get() + { + ctx.request_repaint(); + } +} + +/// The host's system-appearance service (Linux), or `None` where egui's report is enough. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup is queued rather than lost. + let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, &worker_wake, code_of(initial)); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, &worker_wake, code); + } + }) + .is_err() + { + return None; + } + // The first frame shows the right theme when the portal answers quickly; a slow one + // only delays the switch (the worker wakes the UI when the value changes). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn delayed_initial_read_repaints_only_on_change() { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }; + let ctx = egui::Context::default(); + let settle = |ctx: &egui::Context| { + for _ in 0..8 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + }; + settle(&ctx); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let wake = OnceLock::new(); + assert!(wake.set(ctx.clone()).is_ok()); + let value = AtomicU8::new(0); + super::publish(&value, &wake, 2); + assert_eq!(value.load(Ordering::Relaxed), 2); + let after_change = calls.load(Ordering::Relaxed); + assert!(after_change > 0); + settle(&ctx); + let after_settle = calls.load(Ordering::Relaxed); + super::publish(&value, &wake, 2); + assert_eq!(calls.load(Ordering::Relaxed), after_settle); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_values_and_gsettings_output_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] { + assert_eq!(super::decode(u32::from(super::code_of(theme))), theme); + } + } +} diff --git a/apps/lightcraft/src/editors.rs b/apps/lightcraft/src/editors.rs new file mode 100644 index 000000000..9a8cc22d2 --- /dev/null +++ b/apps/lightcraft/src/editors.rs @@ -0,0 +1,242 @@ +//! Starting external editors for Photo ▸ Edit In: PhotoCraft, found where its installers put it, +//! or any other application the user named in Settings ▸ External Editing. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The name the UI asks for when it means PhotoCraft (`lightcraft_ui_egui::state::PHOTOCRAFT`). +const PHOTOCRAFT: &str = lightcraft_ui_egui::state::PHOTOCRAFT; +/// PhotoCraft's application id (Flatpak, desktop entry). +const PHOTOCRAFT_ID: &str = "ai.storyteller.photocraft"; + +/// How an editor is started. +#[derive(Debug, PartialEq)] +enum Launcher { + /// The system's default application for the file. + Default, + /// A program file, given the photo as its argument. + Program(PathBuf), + /// macOS: `open -a ` (an application name or bundle path). + MacApp(String), + /// The PhotoCraft Flatpak, with the file forwarded into its sandbox. + Flatpak, +} + +/// Whether `app` can be started (Settings ▸ External Editing names it; "" = the system default): +/// for PhotoCraft, that it is installed. The error says what to do instead. +pub fn check(app: &str) -> Result<(), String> { + launcher(app).map(|_| ()) +} + +/// Open `path` in `app` (see [`check`]). Spawned, never waited for: the editor runs alongside. +pub fn open_with(path: &str, app: &str) -> Result<(), String> { + let mut c = match launcher(app)? { + Launcher::Default if cfg!(target_os = "macos") => { + let mut c = Command::new("open"); + c.arg(path); + c + } + Launcher::Default if cfg!(target_os = "windows") => windows_default_command(path), + Launcher::Default => { + let mut c = Command::new("xdg-open"); + c.arg(path); + c + } + Launcher::MacApp(app) => { + let mut c = Command::new("open"); + c.args(["-a", &app, path]); + c + } + Launcher::Program(p) => { + let mut c = Command::new(p); + c.arg(path); + c + } + Launcher::Flatpak => { + let mut c = Command::new("flatpak"); + c.args(["run", "--file-forwarding", PHOTOCRAFT_ID, "@@", path, "@@"]); + c + } + }; + c.spawn().map(|_| ()).map_err(|e| e.to_string()) +} + +/// Windows: open `path` in its default application through Explorer, given the path as its one +/// argument. Not `cmd /C start`: cmd.exe would read `&`, `^`, `%` or `|` in a file name as its own +/// syntax (running what follows an `&` as a command). +fn windows_default_command(path: &str) -> Command { + let mut c = Command::new("explorer.exe"); + c.arg(path.replace('/', "\\")); + c +} + +fn launcher(app: &str) -> Result { + let app = app.trim(); + if app.is_empty() { + return Ok(Launcher::Default); + } + if !app.eq_ignore_ascii_case(PHOTOCRAFT) { + return Ok(if cfg!(target_os = "macos") { Launcher::MacApp(app.to_string()) } else { Launcher::Program(PathBuf::from(app)) }); + } + find_photocraft().ok_or_else(|| { + "PhotoCraft isn't installed. Get it from getartcraft.com/apps/photocraft, or give where it is in Settings ▸ External Editing.".to_string() + }) +} + +fn find_photocraft() -> Option { + let env = |k: &str| std::env::var_os(k).map(|v| v.to_string_lossy().to_string()); + if cfg!(target_os = "macos") { + // LaunchServices finds the app wherever it is; these are only the usual places + let home = env("HOME").map(|h| Path::new(&h).join("Applications/PhotoCraft.app")); + let found = [Some(PathBuf::from("/Applications/PhotoCraft.app")), home].into_iter().flatten().find(|p| p.is_dir()); + return Some(Launcher::MacApp(found.map_or_else(|| PHOTOCRAFT.to_string(), |p| p.to_string_lossy().to_string()))); + } + if cfg!(target_os = "windows") { + let install_dir = reg_query(r"HKLM\Software\PhotoCraft", Some("InstallDir")); + let app_path = reg_query(r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\photocraft.exe", None); + return windows_candidates(install_dir, app_path, env).into_iter().find(|p| p.is_file()).map(Launcher::Program); + } + // Linux, FreeBSD: on the PATH (.deb, .rpm, tarball), a desktop entry (AppImage, which + // registers one on first run), else the Flatpak + let on_path = + env("PATH").into_iter().flat_map(|p| std::env::split_paths(&p).collect::>()).map(|d| d.join("photocraft")).find(|p| p.is_file()); + let from_entry = || { + desktop_entry_dirs(env).into_iter().find_map(|d| { + let text = std::fs::read_to_string(d.join(format!("{PHOTOCRAFT_ID}.desktop"))).ok()?; + desktop_exec(&text).filter(|p| p.is_file()) + }) + }; + if let Some(p) = on_path.or_else(from_entry) { + return Some(Launcher::Program(p)); + } + let flatpak = Command::new("flatpak").args(["info", PHOTOCRAFT_ID]).output().is_ok_and(|o| o.status.success()); + flatpak.then_some(Launcher::Flatpak) +} + +/// Where the Windows installers put `photocraft.exe`, most specific first: the folder the MSI +/// recorded (`HKLM\Software\PhotoCraft\InstallDir`), its App Paths entry, then the default +/// folders (per machine, then per user). +fn windows_candidates(install_dir: Option, app_path: Option, env: impl Fn(&str) -> Option) -> Vec { + let mut out: Vec = Vec::new(); + out.extend(install_dir.map(|d| Path::new(&d).join("photocraft.exe"))); + out.extend(app_path.map(|p| PathBuf::from(p.trim_matches('"')))); + for var in ["ProgramW6432", "ProgramFiles"] { + out.extend(env(var).map(|d| Path::new(&d).join("PhotoCraft").join("photocraft.exe"))); + } + out.extend(env("LOCALAPPDATA").map(|d| Path::new(&d).join("Programs").join("PhotoCraft").join("photocraft.exe"))); + out +} + +/// A string value from the Windows registry (`reg query`; `None` = the key's default value). +fn reg_query(key: &str, name: Option<&str>) -> Option { + let mut c = Command::new("reg"); + c.args(["query", key]); + match name { + Some(n) => c.args(["/v", n]), + None => c.arg("/ve"), + }; + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + c.creation_flags(CREATE_NO_WINDOW); + } + let out = c.output().ok().filter(|o| o.status.success())?; + reg_value(&String::from_utf8_lossy(&out.stdout)) +} + +/// The value in `reg query` output: the text after `REG_SZ` (or `REG_EXPAND_SZ`) on its line. +fn reg_value(output: &str) -> Option { + output.lines().find_map(|l| { + let (_, v) = l.split_once("REG_EXPAND_SZ").or_else(|| l.split_once("REG_SZ"))?; + Some(v.trim().to_string()).filter(|v| !v.is_empty()) + }) +} + +/// The folders desktop entries live in (XDG base directories). +fn desktop_entry_dirs(env: impl Fn(&str) -> Option) -> Vec { + let mut dirs = Vec::new(); + match env("XDG_DATA_HOME") { + Some(d) if !d.is_empty() => dirs.push(PathBuf::from(d)), + _ => dirs.extend(env("HOME").map(|h| Path::new(&h).join(".local/share"))), + } + let system = env("XDG_DATA_DIRS").filter(|d| !d.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend(system.split(':').filter(|d| !d.is_empty()).map(PathBuf::from)); + dirs.into_iter().map(|d| d.join("applications")).collect() +} + +/// The program a desktop entry's `Exec=` line starts (quoted or not, its field codes dropped). +fn desktop_exec(entry: &str) -> Option { + let exec = entry.lines().find_map(|l| l.strip_prefix("Exec="))?.trim(); + let program = match exec.strip_prefix('"') { + Some(rest) => rest.split('"').next()?, + None => exec.split_whitespace().next()?, + }; + // a bare name (`photocraft %F`) was already looked for on the PATH + Some(PathBuf::from(program)).filter(|p| p.is_absolute()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A file name with cmd.exe syntax in it goes to Explorer as one plain argument, not + /// through a shell. + #[test] + fn windows_default_open_goes_through_explorer_not_cmd() { + let path = r"C:/Edits/a & b ^c %PATH% | d.psd"; + let c = windows_default_command(path); + assert_eq!(c.get_program(), "explorer.exe"); + let args: Vec<_> = c.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + assert_eq!(args, [r"C:\Edits\a & b ^c %PATH% | d.psd"]); + } + + #[test] + fn windows_lookup_prefers_the_recorded_install_folder() { + let env = |k: &str| match k { + "ProgramFiles" => Some(r"C:\Program Files".to_string()), + "LOCALAPPDATA" => Some(r"C:\Users\Simon\AppData\Local".to_string()), + _ => None, + }; + let c = windows_candidates(Some(r"D:\Apps\PhotoCraft\".into()), Some(r#""C:\Program Files\PhotoCraft\photocraft.exe""#.into()), env); + let c: Vec = c.iter().map(|p| p.to_string_lossy().replace('/', "\\")).collect(); + assert!(c[0].starts_with(r"D:\Apps\PhotoCraft") && c[0].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c[1], r"C:\Program Files\PhotoCraft\photocraft.exe", "App Paths, quotes dropped"); + assert!(c[2].starts_with(r"C:\Program Files") && c[2].ends_with("photocraft.exe"), "{c:?}"); + assert!(c[3].contains("Programs") && c[3].ends_with("photocraft.exe"), "{c:?}"); + assert_eq!(c.len(), 4); + } + + #[test] + fn reg_query_output_is_read() { + let out = "\r\nHKEY_LOCAL_MACHINE\\Software\\PhotoCraft\r\n InstallDir REG_SZ C:\\Program Files\\PhotoCraft\\\r\n\r\n"; + assert_eq!(reg_value(out).as_deref(), Some(r"C:\Program Files\PhotoCraft\")); + let default = "\r\nHKEY_LOCAL_MACHINE\\...\\photocraft.exe\r\n (Default) REG_SZ C:\\Program Files\\PhotoCraft\\photocraft.exe\r\n"; + assert_eq!(reg_value(default).as_deref(), Some(r"C:\Program Files\PhotoCraft\photocraft.exe")); + assert_eq!(reg_value("ERROR: The system was unable to find the specified registry key or value."), None); + } + + #[test] + fn desktop_entries_give_their_program() { + let appimage = "[Desktop Entry]\nName=PhotoCraft\nExec=\"/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage\" %F\n"; + assert_eq!(desktop_exec(appimage), Some(PathBuf::from("/home/simon/Apps/photocraft-1.2-linux-x86_64.AppImage"))); + assert_eq!(desktop_exec("Exec=/opt/photocraft/photocraft %F\n"), Some(PathBuf::from("/opt/photocraft/photocraft"))); + assert_eq!(desktop_exec("Exec=photocraft %F\nTryExec=photocraft\n"), None, "a bare name is a PATH lookup"); + assert_eq!(desktop_exec("[Desktop Entry]\n"), None); + let dirs = desktop_entry_dirs(|k| (k == "HOME").then(|| "/home/simon".to_string())); + assert_eq!(dirs[0], PathBuf::from("/home/simon/.local/share/applications")); + assert!(dirs.contains(&PathBuf::from("/usr/share/applications"))); + } + + #[test] + fn other_editors_are_used_as_named() { + assert_eq!(launcher("").unwrap(), Launcher::Default); + assert_eq!(launcher(" ").unwrap(), Launcher::Default); + let named = launcher("/usr/bin/gimp").unwrap(); + if cfg!(target_os = "macos") { + assert_eq!(named, Launcher::MacApp("/usr/bin/gimp".into())); + } else { + assert_eq!(named, Launcher::Program("/usr/bin/gimp".into())); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 2fc1aaf95..770a30df4 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -23,8 +23,10 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; +mod appearance; mod control_server; mod dialog_filter; +mod editors; mod logging; #[cfg(target_os = "macos")] mod native_menu; @@ -447,28 +449,8 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> pick_folder: Some(Box::new(|| { file_dialog().set_title(lightcraft_ui_egui::i18n::tr("Open Library")).pick_folder().map(|p| p.to_string_lossy().to_string()) })), - open_with: Some(Box::new(|path: &str, app: &str| { - // spawned, never waited for: the editor runs alongside - let app = app.trim(); - let mut c = if cfg!(target_os = "macos") { - let mut c = std::process::Command::new("open"); - if !app.is_empty() { - c.args(["-a", app]); - } - c - } else if app.is_empty() { - if cfg!(target_os = "windows") { - let mut c = std::process::Command::new("cmd"); - c.args(["/C", "start", ""]); - c - } else { - std::process::Command::new("xdg-open") - } - } else { - std::process::Command::new(app) - }; - c.arg(path).spawn().map(|_| ()).map_err(|e| e.to_string()) - })), + open_with: Some(Box::new(editors::open_with)), + check_editor: Some(Box::new(editors::check)), open_url: Some(Box::new(|url: &str| { if !url.starts_with("https://") { return Err("only https links are opened".into()); @@ -605,6 +587,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // the library is a folder on disk: backed up with the user's other files backup_library: None, restore_library: None, + system_theme: appearance::service(), } } diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 02ba7b319..36392dbcd 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -1,4 +1,4 @@ -//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), lossless WebP (`image-webp`) and, +//! Encoders: JPEG (`jpeg-encoder`), PNG (`png`), TIFF (`tiff`), flat PSD (our own), lossless WebP (`image-webp`) and, //! on native targets with the `avif` feature, AVIF (`ravif`/`rav1e`). Metadata (ICC/EXIF/XMP) is //! embedded where the format allows. @@ -32,7 +32,7 @@ impl<'a> EncodeImage<'a> { EncodeImage { width, height, channels, samples } } - fn validate(&self) -> Result<()> { + pub(crate) fn validate(&self) -> Result<()> { if !(1..=4).contains(&self.channels) { return Err(Error::Encode(format!("unsupported channel count {}", self.channels))); } @@ -291,6 +291,12 @@ pub fn encode_tiff(img: &EncodeImage, compression: TiffCompression, meta: &Encod Ok(cur.into_inner()) } +/// Encode a flat PSD (8/16-bit gray or RGB, no layers; alpha dropped): the hand-off an image +/// editor such as PhotoCraft saves back to in place. ICC, EXIF, XMP and resolution are embedded. +pub fn encode_psd(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + crate::psd::encode(img, meta) +} + /// Encode lossless WebP (8-bit). ICC/EXIF/XMP are embedded in the extended (VP8X) container. pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result> { img.validate()?; diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 79763ca2e..bd573248c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -38,7 +38,7 @@ mod webp; pub use encode::{ ChromaSubsampling, EncodeImage, EncodeMeta, HDR_REFERENCE_WHITE_NITS, Samples, TiffCompression, encode_avif, encode_avif_pq, encode_jpeg, - encode_png, encode_png_pq, encode_tiff, encode_webp_lossless, + encode_png, encode_png_pq, encode_psd, encode_tiff, encode_webp_lossless, }; pub use sniff::{Format, sniff}; pub use space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs index 3d26984ef..7cccec0f8 100644 --- a/crates/codecs/src/psd.rs +++ b/crates/codecs/src/psd.rs @@ -1,8 +1,11 @@ //! Photoshop PSD/PSB merged-composite reader (clean-room, from the public file-format description): //! 8/16/32-bit gray, RGB, CMYK, indexed, duotone (as gray); raw or PackBits/RLE image data; //! ICC (resource 1039), EXIF (1058), XMP (1060); transparency when the layer count is negative. +//! Also a flat-file writer ([`encode`]): the merged image of an 8- or 16-bit gray/RGB document +//! with no layers, so an image editor opens it as one background layer and saves back to it. use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; +use crate::encode::{EncodeImage, EncodeMeta, Samples}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; const F: Format = Format::Psd; @@ -211,6 +214,129 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) } +/// The largest side a PSD (version 1) may have; larger documents need PSB. +pub const MAX_SIDE: u32 = 30_000; + +/// Write a flat PSD: header, the image resources for `meta` (resolution 1005, ICC 1039, EXIF +/// 1058, XMP 1060), an empty layer section and the merged image. 8-bit planes are PackBits +/// compressed, 16-bit ones stored raw (PackBits saves little on 16-bit photos). Gray or RGB; +/// alpha is dropped (the render is opaque). Float samples are refused. +pub(crate) fn encode(img: &EncodeImage, meta: &EncodeMeta) -> Result> { + img.validate()?; + let (w, h) = (img.width, img.height); + if w > MAX_SIDE || h > MAX_SIDE { + return Err(Error::Encode(format!("{w} × {h} is larger than a PSD can hold ({MAX_SIDE} px a side): use TIFF"))); + } + let ch = usize::from(img.channels); + let color = if ch <= 2 { 1 } else { 3 }; + let (depth, bpc) = match img.samples { + Samples::U8(_) => (8u16, 1usize), + Samples::U16(_) => (16, 2), + Samples::F32(_) => return Err(Error::Encode("32-bit float PSD is not supported: use TIFF".into())), + }; + let (wu, hu) = (w as usize, h as usize); + let mut b = Vec::new(); + b.extend_from_slice(b"8BPS"); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&(color as u16).to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&depth.to_be_bytes()); + b.extend_from_slice(&(if color == 1 { 1u16 } else { 3 }).to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + let res = resource_section(meta); + b.extend_from_slice(&u32::try_from(res.len()).map_err(|_| Error::Encode("metadata too large for PSD".into()))?.to_be_bytes()); + b.extend_from_slice(&res); + b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask information: none + // planar samples, one plane per colour channel (big-endian) + let plane = |c: usize| -> Vec { + let mut p = Vec::with_capacity(wu * hu * bpc); + match img.samples { + Samples::U8(s) => p.extend(s.iter().skip(c).step_by(ch).take(wu * hu)), + Samples::U16(s) => s.iter().skip(c).step_by(ch).take(wu * hu).for_each(|v| p.extend_from_slice(&v.to_be_bytes())), + Samples::F32(_) => {} + } + p + }; + if bpc == 1 { + b.extend_from_slice(&1u16.to_be_bytes()); + let mut counts = Vec::with_capacity(color * hu * 2); + let mut data = Vec::new(); + for c in 0..color { + for row in plane(c).chunks_exact(wu) { + let start = data.len(); + packbits(row, &mut data); + let n = u16::try_from(data.len() - start).map_err(|_| Error::Encode("PSD row too long".into()))?; + counts.extend_from_slice(&n.to_be_bytes()); + } + } + b.extend_from_slice(&counts); + b.extend_from_slice(&data); + } else { + b.extend_from_slice(&0u16.to_be_bytes()); + for c in 0..color { + b.extend_from_slice(&plane(c)); + } + } + Ok(b) +} + +/// The image resources section for `meta`. +fn resource_section(meta: &EncodeMeta) -> Vec { + let mut out = Vec::new(); + let mut add = |id: u16, data: &[u8]| { + out.extend_from_slice(b"8BIM"); + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(&[0, 0]); // empty Pascal name, padded to even + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + out.extend_from_slice(data); + if data.len() % 2 == 1 { + out.push(0); + } + }; + if let Some(ppi) = meta.ppi.filter(|p| *p > 0) { + // ResolutionInfo: 16.16 fixed pixels per inch, unit 1 (ppi), display unit 1 (inches), twice + let fixed = (u32::from(ppi) << 16).to_be_bytes(); + let one = 1u16.to_be_bytes(); + add(1005, &[&fixed[..], &one, &one, &fixed, &one, &one].concat()); + } + if let Some(icc) = meta.icc { + add(1039, icc); + } + if let Some(exif) = meta.exif { + add(1058, exif); + } + if let Some(xmp) = meta.xmp { + add(1060, xmp.as_bytes()); + } + out +} + +/// PackBits: append one encoded row of `src` to `out` (runs of 2+ equal bytes as repeats, the +/// rest as literals of at most 128 bytes). +fn packbits(src: &[u8], out: &mut Vec) { + let mut rest = src; + while let Some(&b) = rest.first() { + // a run of 2 to 128 equal bytes + let run = rest.iter().take(128).take_while(|&&x| x == b).count(); + if run >= 2 { + // 1 - run in 2..=128: -1..=-127, as a signed byte + out.push((1 - run as i16) as i8 as u8); + out.push(b); + rest = rest.get(run..).unwrap_or_default(); + continue; + } + // a literal of 1 to 128 bytes: up to the next pair of equal bytes, or the end of the row + let unpaired = rest.windows(2).take(128).take_while(|w| w.first() != w.get(1)).count(); + let len = if unpaired.saturating_add(1) == rest.len() { rest.len() } else { unpaired }; + let (lit, tail) = rest.split_at_checked(len.clamp(1, 128)).unwrap_or((rest, &[][..])); + out.push(lit.len().saturating_sub(1) as u8); + out.extend_from_slice(lit); + rest = tail; + } +} + /// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources, /// without reading the merged image data. Refused, as a decode would refuse it: an unsupported /// depth, colour mode or compression, or merged image data that runs past the end of the file @@ -434,4 +560,55 @@ mod tests { let q = d.image.get(0, 0); assert!(p[0] > 0.0 && (p[0] - q[0]).abs() < 1e-6, "{p:?}"); } + #[test] + fn packbits_round_trips() { + let rows: [&[u8]; 6] = [&[], &[7], &[1, 1], &[1, 2, 3, 3, 3, 4, 5, 5], &[9; 300], &(0..=255u8).cycle().take(700).collect::>()]; + for row in rows { + let mut enc = Vec::new(); + super::packbits(row, &mut enc); + let mut out = vec![0u8; row.len()]; + unpackbits(&enc, &mut out).unwrap(); + assert_eq!(out, row); + } + } + + #[test] + fn flat_psd_round_trips_8_and_16_bit() { + let icc = crate::icc::write_named(crate::NamedSpace::AdobeRgb); + let meta = EncodeMeta { icc: Some(&icc), xmp: Some(""), ppi: Some(300), ..Default::default() }; + // 8-bit RGBA (alpha dropped), 5 × 3 with a flat run and a gradient + let px: Vec = (0..15u8).flat_map(|i| [i * 10, if i < 8 { 200 } else { i }, 255 - i, 255]).collect(); + let b = encode(&EncodeImage::new(5, 3, 4, Samples::U8(&px)), &meta).unwrap(); + assert_eq!(super::header(&b).unwrap(), (5, 3, 1)); + let d = decode(&b, &DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (5, 3)); + assert_eq!(d.icc.as_deref(), Some(&icc[..]), "the ICC profile is kept"); + assert_eq!(d.xmp.as_deref(), Some("")); + // 16-bit RGB, raw planes: the samples come back exactly + let px16: Vec = (0..4 * 2 * 3).map(|i| (i as u16) * 2731).collect(); + let b = encode(&EncodeImage::new(4, 2, 3, Samples::U16(&px16)), &meta).unwrap(); + assert_eq!(&b[22..24], &16u16.to_be_bytes()); + let mut r = Rd { b: &b, p: 26 }; + let cm = r.u32().unwrap() as usize; + r.take(cm).unwrap(); + let res = r.u32().unwrap() as usize; + r.take(res).unwrap(); + assert_eq!(r.u32().unwrap(), 0, "no layers"); + assert_eq!(r.u16().unwrap(), 0, "16-bit planes are raw"); + let red: Vec = (0..8).map(|_| r.u16().unwrap()).collect(); + assert_eq!(red, px16.iter().step_by(3).copied().collect::>()); + assert!(decode(&b, &DecodeOptions::default()).is_ok()); + // gray 16-bit + let g = encode(&EncodeImage::new(2, 2, 1, Samples::U16(&[0, 1000, 40000, 65535])), &EncodeMeta::default()).unwrap(); + assert_eq!((&g[12..14], &g[24..26]), (&1u16.to_be_bytes()[..], &1u16.to_be_bytes()[..]), "one channel, grayscale mode"); + assert!(decode(&g, &DecodeOptions::default()).is_ok()); + } + + #[test] + fn flat_psd_refuses_what_it_cannot_hold() { + let e = encode(&EncodeImage::new(1, 1, 3, Samples::F32(&[0.5; 3])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("float"), "{e}"); + let e = encode(&EncodeImage::new(MAX_SIDE + 1, 1, 1, Samples::U8(&vec![0; MAX_SIDE as usize + 1])), &EncodeMeta::default()).unwrap_err(); + assert!(e.to_string().contains("TIFF"), "{e}"); + } } diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 9be183517..a681ec14b 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -197,6 +197,24 @@ pub fn specs() -> Vec { Ok(json!({"path": to, "relinked": n})) } ), + cmd!( + "folder.create", + "New Folder on Disk", + [], + None, + "{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}", + always, + create_folder + ), + cmd!( + "photo.moveToFolder", + "Move to Folder", + [], + None, + "{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}", + always, + move_to_folder + ), cmd!( "library.browse", "Browse Folder", @@ -251,6 +269,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}")) } +/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the +/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites. +pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> { + match (from.is_empty(), to.is_empty()) { + (false, false) => rename_folder_on_disk(from, to), + (false, true) => remove_empty_folder(from), + (true, false) => { + if Path::new(to).exists() { + return Err(format!("{to} already exists")); + } + std::fs::create_dir(to).map_err(|e| format!("{to}: {e}")) + } + (true, true) => Ok(()), + } +} + +/// Remove an empty folder; one with anything in it (hidden files too) is refused. +fn remove_empty_folder(path: &str) -> std::result::Result<(), String> { + let p = Path::new(path); + if !p.is_dir() { + return Err(format!("{path} is not a folder")); + } + let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?; + if entries.next().is_some() { + return Err(format!("{path} is not empty")); + } + std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}")) +} + +/// A folder path parameter without trailing separators (`None` when missing or blank). +fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty()) +} + +/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty. +fn create_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "folder.create"; + let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?; + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..") + .ok_or_else(|| bad(C, "give a folder name (no slashes)"))?; + if !Path::new(parent).is_dir() { + return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)"))); + } + let path = Path::new(parent).join(name).to_string_lossy().to_string(); + if Path::new(&path).exists() { + return Err(bad(C, format!("{path} already exists: choose another name"))); + } + folder_on_disk("", &path).map_err(|e| bad(C, e))?; + let undo = crate::FolderMove { from: path.clone(), to: String::new() }; + if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) { + let _ = remove_empty_folder(&path); + return Err(e); + } + Ok(json!({"path": path})) +} + +/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink +/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name +/// already taken in `folder` refuses the whole move. +fn move_to_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.moveToFolder"; + let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?; + let dir = Path::new(folder); + if !dir.is_dir() { + return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); + } + let ids = s.targets(p); + if ids.is_empty() { + return Err(bad(C, "no photos to move: select some or give ids")); + } + let mut plans: Vec = Vec::new(); + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut clashes: Vec = Vec::new(); + for id in ids { + let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?; + if ph.local { + return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name))); + } + let lightcraft_catalog::Source::File { path } = &ph.source else { + return Err(bad(C, format!("{} has no file on disk", ph.file_name))); + }; + if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) { + continue; // a virtual copy of a photo already moving + } + let src = Path::new(path); + if !src.is_file() { + return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)"))); + } + let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) }; + if src.parent() == Some(dir) { + continue; // already there + } + let to = dir.join(file); + let to_s = to.to_string_lossy().to_string(); + if to.exists() || !taken.insert(to_s.to_lowercase()) { + clashes.push(file.to_string_lossy().to_string()); + continue; + } + let name = ph.file_name.clone(); + plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) }); + } + if !clashes.is_empty() { + let s_ = if clashes.len() == 1 { "" } else { "s" }; + return Err(bad( + C, + format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")), + )); + } + let n = plans.len(); + if n == 0 { + return Ok(json!({"moved": 0, "folder": folder})); + } + let plural = |k: usize| if k == 1 { "" } else { "s" }; + let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No); + let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string()); + s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?; + Ok(json!({"moved": n, "folder": folder})) +} + /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { // a library folder chosen at or below it follows too, as a source or as a filter diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index ba4060a61..bc2137948 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -120,8 +120,8 @@ pub fn specs() -> Vec { } /// Edit in an external editor, the engine half: render the photo with its edits as a 16-bit -/// TIFF next to the original (`-Edit.tif`, never overwriting), add it to the library and -/// stack it on top of the original. The host opens the file in the editor. +/// TIFF (or PSD, or 8-bit) next to the original (`-Edit.tif`, never overwriting), add it to +/// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result { const C: &str = "photo.editExternal"; let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; @@ -139,9 +139,19 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { "prophoto" | "proPhoto" => lightcraft_pipeline::OutputSpace::ProPhoto, _ => lightcraft_pipeline::OutputSpace::AdobeRgb, }; + let format = match super::str_param(p, "format").unwrap_or("tiff") { + "tiff" | "tif" => crate::export::ExportFormat::Tiff, + "psd" => crate::export::ExportFormat::Psd, + f => return Err(bad(C, format!("format {f:?}: use tiff or psd"))), + }; + let bit_depth = match p.get("bitDepth").and_then(Value::as_u64).unwrap_or(16) { + 8 => 8, + 16 => 16, + d => return Err(bad(C, format!("bitDepth {d}: use 8 or 16"))), + }; let opts = crate::export::ExportOptions { - format: crate::export::ExportFormat::Tiff, - bit_depth: Some(16), + format, + bit_depth: Some(bit_depth), color_space: space, naming: "{name}-Edit".into(), conflict: crate::export::Conflict::Unique, @@ -156,7 +166,8 @@ fn edit_external(s: &mut Session, p: &Value) -> Result { }; let to = crate::export::Destination { dir, exact: None }; crate::export::export_batch(s, &[id], &opts, &to, &mut write, &|path| Path::new(path).exists()).map_err(|e| bad(C, e))?; - let out = written.into_iter().find(|w| w.ends_with(".tif")).ok_or_else(|| bad(C, "nothing was written"))?; + let ext = format!(".{}", format.extension()); + let out = written.into_iter().find(|w| w.ends_with(&ext)).ok_or_else(|| bad(C, "nothing was written"))?; let r = s.execute("library.import", &json!({"paths": [out]}))?; let new = r["imported"].get(0).and_then(Value::as_u64).ok_or_else(|| bad(C, "the edit copy could not be added"))?; // stack: the edit on top of the original, expanded so both show @@ -352,7 +363,7 @@ pub fn edit_specs() -> Vec { "Edit Copy for External Editor", [], None, - "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, dir?} — render the active photo with its edits as a 16-bit TIFF `-Edit.tif` next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", + "{colorSpace?: adobeRgb (default) | proPhoto | displayP3 | srgb, format?: tiff (default) | psd, bitDepth?: 16 (default) | 8, dir?} — render the active photo with its edits as `-Edit.tif` (or `.psd`, flat, which PhotoCraft saves back to in place) next to it, add it stacked on the original and select it → {path, id, original} (the app then opens it in the external editor)", has_active, edit_external ), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index ebfaf92ef..fdfbf5509 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -18,6 +18,8 @@ pub enum ExportFormat { Jpeg, Png, Tiff, + /// A flat PSD (8/16-bit, no layers): what an image editor such as PhotoCraft saves back to. + Psd, Webp, Avif, /// The original file, unchanged, with an XMP sidecar holding the edits. @@ -32,6 +34,7 @@ impl ExportFormat { "jpeg" | "jpg" => Self::Jpeg, "png" => Self::Png, "tiff" | "tif" => Self::Tiff, + "psd" => Self::Psd, "webp" => Self::Webp, "avif" => Self::Avif, "original" => Self::Original, @@ -44,6 +47,7 @@ impl ExportFormat { Self::Jpeg => "jpg", Self::Png => "png", Self::Tiff => "tif", + Self::Psd => "psd", Self::Webp => "webp", Self::Avif => "avif", // the original keeps its own extension (see `ExportOptions::file_name_for`) @@ -1079,6 +1083,8 @@ impl ExportOptions { (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Psd, Some(8)) => OutputDepth::U8, + (ExportFormat::Psd, _) => OutputDepth::U16, (ExportFormat::Avif, _) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, (ExportFormat::Avif, _) => OutputDepth::U8, @@ -1091,6 +1097,7 @@ impl ExportOptions { ExportFormat::Jpeg | ExportFormat::Webp | ExportFormat::Original | ExportFormat::Dng => &[(8, "8-bit")], ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Psd => &[(16, "16-bit"), (8, "8-bit")], ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], } } @@ -1259,6 +1266,7 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, o.tiff_compression, &meta), + ExportFormat::Psd => encode::encode_psd(&e, &meta), ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), f @ (ExportFormat::Original | ExportFormat::Dng) => return Err(format!("{f:?} export does not encode pixels")), @@ -1369,6 +1377,7 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) (ExportFormat::Png, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => lightcraft_codecs::encode_png_pq(w, h, v, &meta), (ExportFormat::Png, _) => encode::encode_png(&e, &meta), (ExportFormat::Tiff, _) => encode::encode_tiff(&e, o.tiff_compression, &meta), + (ExportFormat::Psd, _) => encode::encode_psd(&e, &meta), (ExportFormat::Avif, DeepSamples::F32(v)) if img.space == OutputSpace::Rec2020 => { lightcraft_codecs::encode_avif_pq(w, h, v, o.quality, 8, &meta) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9769fb807..32761a595 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -108,7 +108,8 @@ pub struct UndoEntry { pub label: String, pub op: Op, /// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder - /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. + /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An + /// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder). pub folder: Option, /// Inverses of logged path corrections from a partially failed history step. pub(crate) retry_inverse: Vec, @@ -658,11 +659,11 @@ impl Session { let op = &entry.op; let folder = entry.folder.as_ref(); if let Some(f) = folder { - cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; + cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; } let undo_folder = || { if let Some(f) = folder { - let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from); + let _ = cmd::browse::folder_on_disk(&f.to, &f.from); } }; let moves = self.file_moves(op); diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 85c988eae..2bb5511c2 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session}; /// Library directory name inside the user's Pictures folder. pub const DEFAULT_NAME: &str = "LightCraft Library"; +/// The library folder `path` names. A library is a folder; naming one of its own files +/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other +/// existing file is an error that says so, instead of the OS's "File exists" from creating the +/// folder. +pub fn resolve_dir(path: &Path) -> Result { + if !path.is_file() { + return Ok(path.to_path_buf()); + } + let own = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n)); + if !own { + return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display()))); + } + Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) { + Some(dir) => dir.to_path_buf(), + None => PathBuf::from("."), + }) +} + +/// Whether `dir` already holds a library (its catalog files). +pub fn is_library(dir: &Path) -> bool { + dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file() +} + /// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library` /// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows). pub fn default_dir() -> Option { @@ -246,6 +272,32 @@ impl SettingsLoad { } impl Session { + /// Check, without closing the library open now, that [`Session::open_library`] can be expected + /// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not + /// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]). + /// Nothing is created; a library folder that exists gets its lock taken and released again. + /// Opening can still fail afterwards (a damaged catalog, a disk going away). + pub fn check_library(&self, dir: impl AsRef) -> Result { + let dir = dir.as_ref().to_path_buf(); + if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) { + return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog }); + } + let dir = resolve_dir(&dir)?; + if !dir.exists() { + return Ok(dir); + } + if !dir.is_dir() { + return Err(EngineError::Other(format!("{} is not a folder", dir.display()))); + } + std::fs::read_dir(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())))?; + // the library open in this session keeps its lock when reopened; any other must be free + let current = self.library.as_ref().is_some_and(|l| l.on_disk && same_dir(&l.dir, &dir)); + if !current && is_library(&dir) { + drop(LibraryLock::acquire(&dir, &program_name()).map_err(|e| EngineError::LibraryInUse(e.to_string()))?); + } + Ok(dir) + } + /// Open (or create) the library at `dir` into this session, replacing its catalog. With /// `seed_demo`, a newly created library starts with the procedural demo photos. /// @@ -262,6 +314,7 @@ impl Session { if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) { return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog }); } + let dir = resolve_dir(&dir)?; let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display()))); let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() }; // the library open in this session (same directory): hand its lock over @@ -666,3 +719,63 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option { .to_string() }) } + +#[cfg(test)] +mod resolve_tests { + use super::*; + + /// `--library /catalog.snap` failed with the OS's "File exists" (creating a folder over + /// the file); it opens the library that file belongs to. + #[test] + fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() { + let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(is_library(&dir)); + let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT); + assert_eq!(resolve_dir(&snap).unwrap(), dir); + let mut s = Session::new().with_fs(); + s.open_library(&snap, false).unwrap(); + assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone())); + drop(s); + + let other = dir.join("notes.txt"); + std::fs::write(&other, b"x").unwrap(); + let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string(); + assert!(e.contains("is a file"), "{e}"); + assert!(!e.contains("File exists"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// `check_library` refuses what `open_library` would, without touching the open library. + #[test] + fn check_library_refuses_locked_libraries_files_and_lightroom_catalogs() { + let dir = std::env::temp_dir().join(format!("lc-check-lib-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::create_dir_all(&b).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&b, false).unwrap(); + s.close_library().unwrap(); + s.open_library(&a, false).unwrap(); + assert_eq!(s.check_library(&a).unwrap(), a, "the open library keeps its lock"); + assert_eq!(s.check_library(&b).unwrap(), b); + assert_eq!(s.check_library(dir.join("new")).unwrap(), dir.join("new")); + assert!(!dir.join("new").exists(), "nothing created"); + let held = LibraryLock::acquire(&b, "other").unwrap(); + assert!(matches!(s.check_library(&b), Err(EngineError::LibraryInUse(_)))); + drop(held); + assert!(s.check_library(&b).is_ok(), "the probe released its lock"); + std::fs::write(dir.join("notes.txt"), b"x").unwrap(); + assert!(s.check_library(dir.join("notes.txt")).is_err()); + std::fs::write(dir.join("Cat.lrcat"), b"x").unwrap(); + assert!(matches!(s.check_library(dir.join("Cat.lrcat")), Err(EngineError::LightroomCatalog { .. }))); + assert!(s.library.as_ref().is_some_and(|l| l.dir == a)); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/lightroom_folder.rs b/crates/engine/src/lightroom_folder.rs index ae6a03abf..0b5a85659 100644 --- a/crates/engine/src/lightroom_folder.rs +++ b/crates/engine/src/lightroom_folder.rs @@ -56,11 +56,6 @@ fn is_lrcat_name(name: &str) -> bool { name.to_lowercase().ends_with(".lrcat") } -/// A LightCraft library lives in `dir` (its catalog journal is there). -pub fn is_lightcraft_library(dir: &Path) -> bool { - [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG].iter().any(|f| dir.join(f).is_file()) -} - /// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than /// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped. pub fn find_lrcat(dir: &Path) -> Option { @@ -93,7 +88,7 @@ fn catalog_or_scan(candidate: Option, dir: Option<&Path>) -> Option Option { let is_dir = path.is_dir(); - if is_dir && is_lightcraft_library(path) { + if is_dir && crate::library::is_library(path) { return None; } let parent = path.parent().filter(|p| !p.as_os_str().is_empty()); diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 7b6c825ab..199484e14 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -391,7 +391,8 @@ impl MoveFs for RealFs { let _ = std::fs::remove_file(b); }), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), } } @@ -413,6 +414,57 @@ impl MoveFs for RealFs { } } +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + if !same_contents(a, b).unwrap_or(false) { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + +/// Whether files `a` and `b` hold the same bytes: lengths first, then 1 MiB chunks at a time, so +/// a large raw or video is never read into memory whole. +fn same_contents(a: &Path, b: &Path) -> std::io::Result { + use std::io::Read; + const CHUNK: usize = 1 << 20; + let (mut fa, mut fb) = (std::fs::File::open(a)?, std::fs::File::open(b)?); + if fa.metadata()?.len() != fb.metadata()?.len() { + return Ok(false); + } + let (mut ba, mut bb) = (vec![0u8; CHUNK], vec![0u8; CHUNK]); + loop { + let n = read_full(&mut fa, &mut ba)?; + let m = read_full(&mut fb, &mut bb)?; + if n != m || ba.get(..n) != bb.get(..m) { + return Ok(false); + } + if n == 0 { + return Ok(true); + } + } +} + +/// Fill `buf` from `r` as far as the file goes; the number of bytes read (less than the buffer +/// only at the end of the file). +fn read_full(r: &mut impl std::io::Read, buf: &mut [u8]) -> std::io::Result { + let mut filled = 0; + while let Some(rest) = buf.get_mut(filled..).filter(|r| !r.is_empty()) { + match r.read(rest) { + Ok(0) => break, + Ok(k) => filled = filled.saturating_add(k), + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(filled) +} + /// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive /// volume): the rename must go through a temporary name. fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { @@ -939,6 +991,13 @@ impl Session { /// [`Session::apply_rename`] on the file system `fs`. pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { // A partly completed rename can already have the requested photo name. Repair // its sidecars before treating that empty move list as a successful retry. self.repair_sidecars_with(fs)?; @@ -972,7 +1031,7 @@ impl Session { return Err(EngineError::Other(self.follow_stuck(&op, e, true).0)); } if n > 0 - && let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op) + && let Err(err) = self.commit(&label(n), op) { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); if let Err(e) = move_all(fs, &back) { @@ -1066,6 +1125,31 @@ pub(crate) mod tests { use super::*; use std::cell::RefCell; + /// The cross-volume move's check compares files chunk by chunk: equal files across several + /// chunks, a difference in the last chunk, different lengths, a missing file. + #[test] + fn same_contents_compares_in_chunks() { + let dir = std::env::temp_dir().join(format!("lc-same-contents-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let big: Vec = (0..(2usize << 20) + 123).map(|i| (i % 251) as u8).collect(); + let (a, b) = (dir.join("a"), dir.join("b")); + std::fs::write(&a, &big).unwrap(); + std::fs::write(&b, &big).unwrap(); + assert!(same_contents(&a, &b).unwrap()); + let mut other = big.clone(); + *other.last_mut().unwrap() ^= 1; + std::fs::write(&b, &other).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&b, &big[..big.len() - 1]).unwrap(); + assert!(!same_contents(&a, &b).unwrap()); + std::fs::write(&a, b"").unwrap(); + std::fs::write(&b, b"").unwrap(); + assert!(same_contents(&a, &b).unwrap()); + assert!(same_contents(&a, &dir.join("missing")).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + /// A model volume: files by path with contents; case-insensitive (default APFS/NTFS) or /// case-sensitive (Linux, case-sensitive APFS). `fail` makes renames from a matching source fail. pub(crate) struct FakeFs { diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 9f51ab408..b55e88c8a 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -564,3 +564,124 @@ fn a_label_comes_off_a_folder_whose_photos_are_gone() { assert_eq!(s.catalog.folder_record("/pics/trip"), None); assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos"); } + +// ---- folder.create and photo.moveToFolder (issue #765) + +/// A library photo of the real file `path` (written with `body`); returns its id. +fn add_file(s: &mut Session, path: &str, body: &str) -> lightcraft_catalog::PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: lightcraft_catalog::PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +#[test] +fn a_folder_is_created_on_disk_and_undo_removes_it() { + let dir = Scratch::new("create"); + let mut s = Session::new(); + let r = s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap(); + assert_eq!(r["path"], dir.path("picks")); + assert!(dir.0.join("picks").is_dir()); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap_err().to_string().contains("already exists")); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "../x"})).is_err(), "one name, no slashes"); + assert!(s.execute("folder.create", &json!({"parent": dir.path("nope"), "name": "x"})).unwrap_err().to_string().contains("not a folder")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!dir.0.join("picks").exists(), "undo removes it"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("picks").is_dir(), "redo makes it again"); + std::fs::write(dir.0.join("picks/note.txt"), "x").unwrap(); + assert!(s.execute("edit.undo", &json!({})).is_err(), "a folder with something in it is never removed"); + assert!(dir.0.join("picks/note.txt").is_file()); +} + +#[test] +fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() { + let dir = Scratch::new("move-photos"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::write(dir.path("in/a.xmp"), "").unwrap(); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + // a virtual copy shares its master's file and goes along + let vc = s.catalog.alloc_photo_id(); + let p = Photo::new(vc, Source::File { path: dir.path("in/a.jpg") }, "a.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap(); + assert_eq!(r["moved"], 2); + assert_eq!(std::fs::read_to_string(dir.path("out/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("out/a.xmp").is_file(), "the sidecar goes along"); + assert!(!dir.0.join("in/a.jpg").exists() && !dir.0.join("in/a.xmp").exists()); + assert_eq!(file_of(&s, a), dir.path("out/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); + assert_eq!(file_of(&s, vc), dir.path("out/a.jpg"), "the virtual copy follows"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert_eq!(file_of(&s, vc), dir.path("in/a.jpg")); + assert!(dir.0.join("in/a.jpg").is_file() && dir.0.join("in/a.xmp").is_file() && dir.0.join("in/b.jpg").is_file()); + assert!(!dir.0.join("out/a.jpg").exists()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("out/b.jpg").is_file()); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); +} + +#[test] +fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() { + let dir = Scratch::new("move-clash"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + std::fs::write(dir.path("out/b.jpg"), "OTHER").unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("b.jpg already in") && e.contains("nothing was moved"), "{e}"); + assert_eq!(std::fs::read_to_string(dir.path("out/b.jpg")).unwrap(), "OTHER"); + assert!(dir.0.join("in/a.jpg").is_file() && !dir.0.join("out/a.jpg").exists(), "all or nothing"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[test] +fn moving_to_a_missing_folder_or_a_missing_original_is_refused() { + let dir = Scratch::new("move-missing"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("nope")})).unwrap_err().to_string(); + assert!(e.contains("not a folder"), "{e}"); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + std::fs::remove_file(dir.path("in/a.jpg")).unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("missing or offline"), "{e}"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[cfg(unix)] +#[test] +fn a_destination_without_write_permission_reports_an_error_and_keeps_everything() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("move-perm"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("ro")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("ro/probe"), "x").is_ok(); + if !root { + assert!(s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("ro")})).is_err()); + assert!(dir.0.join("in/a.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert!(s.execute("folder.create", &json!({"parent": dir.path("ro"), "name": "x"})).is_err()); + } + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o755)).unwrap(); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index e3137da00..57981d920 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -566,6 +566,45 @@ fn external_edit_copy_is_stacked() { let _ = std::fs::remove_dir_all(&dir); } +/// Edit in PhotoCraft (engine half): a flat 16-bit PSD `-Edit` copy, which PhotoCraft saves back +/// to in place (it writes a TIFF only through Save As); its saved pixels come back on Reload. +#[test] +fn external_edit_copy_as_psd_round_trips() { + let dir = temp_dir("extedit-psd"); + let src = dir.join("pier.png"); + write_png(&src, 40); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let orig = s.active().unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); + let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with("pier-Edit.psd"), "{path}"); + let d = lightcraft_codecs::decode(&std::fs::read(&path).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16); + assert!(d.icc.is_some(), "the colour space travels with the file"); + let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); + // the editor saves the document back over the same file: Reload shows its pixels + let before = s.render_now(new, 16, 16).unwrap().image.data; + let (w, h) = (d.width as u32, d.height as u32); + let px = vec![1000u16; (w * h * 3) as usize]; + let saved = + lightcraft_codecs::encode_psd(&lightcraft_codecs::EncodeImage::new(w, h, 3, lightcraft_codecs::Samples::U16(&px)), &Default::default()) + .unwrap(); + std::fs::write(&path, saved).unwrap(); + assert_eq!(s.execute("photo.reload", &json!({"ids": [new.0]})).unwrap()["reloaded"], json!([new.0])); + assert_ne!(s.render_now(new, 16, 16).unwrap().image.data, before); + // 8-bit, and what it can't do + s.execute("library.select", &json!({"ids": [orig.0]})).unwrap(); + let r8 = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 8})).unwrap(); + let d8 = lightcraft_codecs::decode(&std::fs::read(r8["path"].as_str().unwrap()).unwrap(), Default::default()).unwrap(); + assert_eq!(d8.bit_depth, 8); + assert!(s.execute("photo.editExternal", &json!({"format": "jpeg"})).is_err()); + assert!(s.execute("photo.editExternal", &json!({"bitDepth": 32})).is_err()); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 03d3eace8..f5f1b88e3 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)", + "New Folder in “{name}”": "Neuer Ordner in „{name}“", + "Moved {} photo(s)": "{} Foto(s) verschoben" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 4db9e9d15..4a6397632 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2030,6 +2030,19 @@ "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Appearance": "Erscheinungsbild", + "Appearance Mode": "Erscheinungsmodus", + "Sync with System": "Mit System synchronisieren", + "Light Mode": "Heller Modus", + "Dark Mode": "Dunkler Modus", + "Next Appearance Mode": "Nächster Erscheinungsmodus", + "Light Theme": "Helles Design", + "Dark Theme": "Dunkles Design", + "In Use": "In Verwendung", + "Charcoal": "Anthrazit", + "Midnight": "Mitternacht", + "Silver": "Silber", + "Paper": "Papier", "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", @@ -2074,5 +2087,22 @@ "No photos yet": "Noch keine Fotos", "No imported folders yet": "Noch keine importierten Ordner", "Show Sidebar Section": "Seitenleistenbereich anzeigen", - "Move Sidebar Section": "Seitenleistenbereich verschieben" + "Move Sidebar Section": "Seitenleistenbereich verschieben", + "New Library…": "Neue Bibliothek…", + "Open Recent Library": "Zuletzt verwendete Bibliothek öffnen", + "Recent Libraries": "Zuletzt verwendete Bibliotheken", + "Clear Recent Libraries": "Liste leeren", + "No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken", + "New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen", + "New Library": "Neue Bibliothek", + "Library name": "Name der Bibliothek", + "New Folder on Disk": "Neuer Ordner auf dem Datenträger", + "New Folder on Disk…": "Neuer Ordner auf dem Datenträger…", + "Move to Folder": "In Ordner verschieben", + "Move to Folder…": "In Ordner verschieben…", + "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", + "Appearance Settings…": "Erscheinungsbild-Einstellungen…", + "Add to favorites": "Zu Favoriten hinzufügen", + "Remove from favorites": "Aus Favoriten entfernen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 12e00be0c..f2abad69c 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", "{what} merge cancelled": "Combinación {what} cancelada", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)", + "New Folder in “{name}”": "Nueva carpeta en «{name}»", + "Moved {} photo(s)": "{} foto(s) movida(s)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 5611732e0..86b42e8b1 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1875,6 +1875,19 @@ "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", "Wait for the import to finish": "Espera a que termine la importación", + "Appearance": "Apariencia", + "Appearance Mode": "Modo de apariencia", + "Sync with System": "Sincronizar con el sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo oscuro", + "Next Appearance Mode": "Siguiente modo de apariencia", + "Light Theme": "Tema claro", + "Dark Theme": "Tema oscuro", + "In Use": "En uso", + "Charcoal": "Carbón", + "Midnight": "Medianoche", + "Silver": "Plata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", @@ -1916,5 +1929,22 @@ "No photos yet": "Aún no hay fotos", "No imported folders yet": "Aún no hay carpetas importadas", "Show Sidebar Section": "Mostrar sección de la barra lateral", - "Move Sidebar Section": "Mover sección de la barra lateral" + "Move Sidebar Section": "Mover sección de la barra lateral", + "New Library…": "Nueva biblioteca…", + "Open Recent Library": "Abrir biblioteca reciente", + "Recent Libraries": "Bibliotecas recientes", + "Clear Recent Libraries": "Borrar bibliotecas recientes", + "No Recent Libraries": "No hay bibliotecas recientes", + "New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla", + "New Library": "Nueva biblioteca", + "Library name": "Nombre de la biblioteca", + "New Folder on Disk": "Nueva carpeta en el disco", + "New Folder on Disk…": "Nueva carpeta en el disco…", + "Move to Folder": "Mover a carpeta", + "Move to Folder…": "Mover a carpeta…", + "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", + "Appearance Settings…": "Ajustes de apariencia…", + "Add to favorites": "Añadir a favoritos", + "Remove from favorites": "Quitar de favoritos" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 452453baf..bc9d0ed2b 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", "{what} merge cancelled": "Fusion {what} annulée", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)", + "New Folder in “{name}”": "Nouveau dossier dans « {name} »", + "Moved {} photo(s)": "{} photo(s) déplacée(s)" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 3cacc7b54..afee27aac 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1916,5 +1916,35 @@ "No photos yet": "Aucune photo pour l’instant", "No imported folders yet": "Aucun dossier importé pour l’instant", "Show Sidebar Section": "Afficher la section de la barre latérale", - "Move Sidebar Section": "Déplacer la section de la barre latérale" + "Move Sidebar Section": "Déplacer la section de la barre latérale", + "New Library…": "Nouvelle bibliothèque…", + "Open Recent Library": "Ouvrir une bibliothèque récente", + "Recent Libraries": "Bibliothèques récentes", + "Clear Recent Libraries": "Effacer les bibliothèques récentes", + "No Recent Libraries": "Aucune bibliothèque récente", + "New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement", + "New Library": "Nouvelle bibliothèque", + "Library name": "Nom de la bibliothèque", + "New Folder on Disk": "Nouveau dossier sur le disque", + "New Folder on Disk…": "Nouveau dossier sur le disque…", + "Move to Folder": "Déplacer vers un dossier", + "Move to Folder…": "Déplacer vers un dossier…", + "Move Selected Photos Here": "Déplacer ici les photos sélectionnées", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien", + "Appearance Settings…": "Réglages d’apparence…", + "Add to favorites": "Ajouter aux favoris", + "Remove from favorites": "Retirer des favoris", + "Appearance": "Apparence", + "Appearance Mode": "Mode d’apparence", + "Sync with System": "Synchroniser avec le système", + "Light Mode": "Mode clair", + "Dark Mode": "Mode sombre", + "Next Appearance Mode": "Mode d’apparence suivant", + "Light Theme": "Thème clair", + "Dark Theme": "Thème sombre", + "In Use": "Utilisé", + "Charcoal": "Anthracite", + "Midnight": "Minuit", + "Silver": "Argent", + "Paper": "Papier" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index ecfd34233..b905a5afb 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", "{what} merge cancelled": "{what}合成をキャンセルしました", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)", + "New Folder in “{name}”": "「{name}」に新規フォルダ", + "Moved {} photo(s)": "{} 枚の写真を移動しました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 3dbd39e94..b5e21e09c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1877,6 +1877,19 @@ "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Appearance": "外観", + "Appearance Mode": "外観モード", + "Sync with System": "システムと同期", + "Light Mode": "ライトモード", + "Dark Mode": "ダークモード", + "Next Appearance Mode": "次の外観モード", + "Light Theme": "ライトテーマ", + "Dark Theme": "ダークテーマ", + "In Use": "使用中", + "Charcoal": "チャコール", + "Midnight": "ミッドナイト", + "Silver": "シルバー", + "Paper": "ペーパー", "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", @@ -1918,5 +1931,22 @@ "No photos yet": "写真はまだありません", "No imported folders yet": "読み込んだフォルダはまだありません", "Show Sidebar Section": "サイドバーのセクションを表示", - "Move Sidebar Section": "サイドバーのセクションを移動" + "Move Sidebar Section": "サイドバーのセクションを移動", + "New Library…": "新しいライブラリ…", + "Open Recent Library": "最近使ったライブラリを開く", + "Recent Libraries": "最近使ったライブラリ", + "Clear Recent Libraries": "最近使ったライブラリを消去", + "No Recent Libraries": "最近使ったライブラリはありません", + "New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択", + "New Library": "新しいライブラリ", + "Library name": "ライブラリ名", + "New Folder on Disk": "ディスクに新規フォルダ", + "New Folder on Disk…": "ディスクに新規フォルダ…", + "Move to Folder": "フォルダへ移動", + "Move to Folder…": "フォルダへ移動…", + "Move Selected Photos Here": "選択した写真をここへ移動", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません", + "Appearance Settings…": "外観設定…", + "Add to favorites": "お気に入りに追加", + "Remove from favorites": "お気に入りから削除" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index c16ecf1eb..15debd727 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", "{what} merge cancelled": "{what} mesclagem cancelada", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)", + "New Folder in “{name}”": "Nova pasta em “{name}”", + "Moved {} photo(s)": "{} foto(s) movida(s)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 58c4f48a7..217cde981 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1875,6 +1875,19 @@ "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", "Wait for the import to finish": "Aguarde a importação terminar", + "Appearance": "Aparência", + "Appearance Mode": "Modo de aparência", + "Sync with System": "Sincronizar com o sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo escuro", + "Next Appearance Mode": "Próximo modo de aparência", + "Light Theme": "Tema claro", + "Dark Theme": "Tema escuro", + "In Use": "Em uso", + "Charcoal": "Carvão", + "Midnight": "Meia-noite", + "Silver": "Prata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", @@ -1916,5 +1929,22 @@ "No photos yet": "Ainda não há fotos", "No imported folders yet": "Ainda não há pastas importadas", "Show Sidebar Section": "Mostrar seção da barra lateral", - "Move Sidebar Section": "Mover seção da barra lateral" + "Move Sidebar Section": "Mover seção da barra lateral", + "New Library…": "Nova biblioteca…", + "Open Recent Library": "Abrir biblioteca recente", + "Recent Libraries": "Bibliotecas recentes", + "Clear Recent Libraries": "Limpar bibliotecas recentes", + "No Recent Libraries": "Nenhuma biblioteca recente", + "New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la", + "New Library": "Nova biblioteca", + "Library name": "Nome da biblioteca", + "New Folder on Disk": "Nova pasta no disco", + "New Folder on Disk…": "Nova pasta no disco…", + "Move to Folder": "Mover para pasta", + "Move to Folder…": "Mover para pasta…", + "Move Selected Photos Here": "Mover as fotos selecionadas para cá", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve", + "Appearance Settings…": "Configurações de aparência…", + "Add to favorites": "Adicionar aos favoritos", + "Remove from favorites": "Remover dos favoritos" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 39958cb54..a7ff2a355 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", "{what} merge cancelled": "{what}: слияние отменено", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)", + "New Folder in “{name}”": "Новая папка в «{name}»", + "Moved {} photo(s)": "Перемещено фото: {}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 477a25d16..8c227f546 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1588,6 +1588,19 @@ "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", + "Appearance": "Оформление", + "Appearance Mode": "Режим оформления", + "Sync with System": "Как в системе", + "Light Mode": "Светлый режим", + "Dark Mode": "Тёмный режим", + "Next Appearance Mode": "Следующий режим оформления", + "Light Theme": "Светлая тема", + "Dark Theme": "Тёмная тема", + "In Use": "Используется", + "Charcoal": "Графит", + "Midnight": "Полночь", + "Silver": "Серебро", + "Paper": "Бумага", "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", @@ -1632,5 +1645,22 @@ "Show Sidebar Section": "Показать раздел боковой панели", "Move Sidebar Section": "Переместить раздел боковой панели", "Move Up": "Переместить вверх", - "Move Down": "Переместить вниз" + "Move Down": "Переместить вниз", + "New Library…": "Новая библиотека…", + "Open Recent Library": "Открыть недавнюю библиотеку", + "Recent Libraries": "Недавние библиотеки", + "Clear Recent Libraries": "Очистить список недавних библиотек", + "No Recent Libraries": "Нет недавних библиотек", + "New Library: Choose Where to Put It": "Новая библиотека: выберите расположение", + "New Library": "Новая библиотека", + "Library name": "Название библиотеки", + "New Folder on Disk": "Новая папка на диске", + "New Folder on Disk…": "Новая папка на диске…", + "Move to Folder": "Переместить в папку", + "Move to Folder…": "Переместить в папку…", + "Move Selected Photos Here": "Переместить выбранные фото сюда", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает", + "Appearance Settings…": "Настройки оформления…", + "Add to favorites": "Добавить в избранное", + "Remove from favorites": "Удалить из избранного" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 258b3eef1..e846acab3 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", "{what} merge cancelled": "Об’єднання {what} скасовано", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)", + "New Folder in “{name}”": "Нова тека в «{name}»", + "Moved {} photo(s)": "Переміщено фото: {}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index d19e26b2d..9ca8ac132 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2110,5 +2110,35 @@ "No photos yet": "Фотографій ще немає", "No imported folders yet": "Імпортованих тек ще немає", "Show Sidebar Section": "Показати розділ бічної панелі", - "Move Sidebar Section": "Перемістити розділ бічної панелі" + "Move Sidebar Section": "Перемістити розділ бічної панелі", + "New Library…": "Нова бібліотека…", + "Open Recent Library": "Відкрити нещодавню бібліотеку", + "Recent Libraries": "Нещодавні бібліотеки", + "Clear Recent Libraries": "Очистити список нещодавніх бібліотек", + "No Recent Libraries": "Немає нещодавніх бібліотек", + "New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування", + "New Library": "Нова бібліотека", + "Library name": "Назва бібліотеки", + "New Folder on Disk": "Нова тека на диску", + "New Folder on Disk…": "Нова тека на диску…", + "Move to Folder": "Перемістити до теки", + "Move to Folder…": "Перемістити до теки…", + "Move Selected Photos Here": "Перемістити вибрані фото сюди", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує", + "Appearance Settings…": "Налаштування вигляду…", + "Add to favorites": "Додати до вибраного", + "Remove from favorites": "Видалити з вибраного", + "Appearance": "Вигляд", + "Appearance Mode": "Режим вигляду", + "Sync with System": "Як у системі", + "Light Mode": "Світлий режим", + "Dark Mode": "Темний режим", + "Next Appearance Mode": "Наступний режим вигляду", + "Light Theme": "Світла тема", + "Dark Theme": "Темна тема", + "In Use": "Використовується", + "Charcoal": "Графіт", + "Midnight": "Опівніч", + "Silver": "Срібло", + "Paper": "Папір" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index b0ee5bfaa..8cf3a7d86 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", "{what} merge cancelled": "已取消 {what} 合并", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)", + "New Folder in “{name}”": "在“{name}”中新建文件夹", + "Moved {} photo(s)": "已移动 {} 张照片" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 16e4d6abd..517e67c25 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1875,6 +1875,19 @@ "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", + "Appearance": "外观", + "Appearance Mode": "外观模式", + "Sync with System": "与系统同步", + "Light Mode": "浅色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一个外观模式", + "Light Theme": "浅色主题", + "Dark Theme": "深色主题", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "银色", + "Paper": "纸白", "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", @@ -1916,5 +1929,22 @@ "No photos yet": "还没有照片", "No imported folders yet": "还没有导入的文件夹", "Show Sidebar Section": "显示边栏分区", - "Move Sidebar Section": "移动边栏分区" + "Move Sidebar Section": "移动边栏分区", + "New Library…": "新建图库…", + "Open Recent Library": "打开最近的图库", + "Recent Libraries": "最近的图库", + "Clear Recent Libraries": "清除最近的图库", + "No Recent Libraries": "没有最近的图库", + "New Library: Choose Where to Put It": "新建图库:选择存放位置", + "New Library": "新建图库", + "Library name": "图库名称", + "New Folder on Disk": "在磁盘上新建文件夹", + "New Folder on Disk…": "在磁盘上新建文件夹…", + "Move to Folder": "移动到文件夹", + "Move to Folder…": "移动到文件夹…", + "Move Selected Photos Here": "将所选照片移到此处", + "Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖", + "Appearance Settings…": "外观设置…", + "Add to favorites": "添加到收藏", + "Remove from favorites": "从收藏中移除" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 5415a9aa7..75981129b 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -231,5 +231,7 @@ "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", "{what} merge cancelled": "已取消{what}合併", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)" + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)", + "New Folder in “{name}”": "在「{name}」中新增資料夾", + "Moved {} photo(s)": "已移動 {} 張照片" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 501942ac9..83417ded0 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1875,6 +1875,19 @@ "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", + "Appearance": "外觀", + "Appearance Mode": "外觀模式", + "Sync with System": "與系統同步", + "Light Mode": "淺色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一個外觀模式", + "Light Theme": "淺色主題", + "Dark Theme": "深色主題", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "銀色", + "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", @@ -1916,5 +1929,22 @@ "No photos yet": "還沒有照片", "No imported folders yet": "還沒有匯入的資料夾", "Show Sidebar Section": "顯示側邊欄區段", - "Move Sidebar Section": "移動側邊欄區段" + "Move Sidebar Section": "移動側邊欄區段", + "New Library…": "新增圖庫…", + "Open Recent Library": "開啟最近的圖庫", + "Recent Libraries": "最近的圖庫", + "Clear Recent Libraries": "清除最近的圖庫", + "No Recent Libraries": "沒有最近的圖庫", + "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置", + "New Library": "新增圖庫", + "Library name": "圖庫名稱", + "New Folder on Disk": "在磁碟上新增資料夾", + "New Folder on Disk…": "在磁碟上新增資料夾…", + "Move to Folder": "移至資料夾", + "Move to Folder…": "移至資料夾…", + "Move Selected Photos Here": "將所選照片移至此處", + "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫", + "Appearance Settings…": "外觀設定…", + "Add to favorites": "加入最愛", + "Remove from favorites": "從最愛移除" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 98fbbabe9..d6e2c20db 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3331,10 +3331,21 @@ mod tests { assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); // Interface: filmstrip names off, grid badges always h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards (one row per theme, with its favorite star) sit above these controls; + // scroll the compact settings viewport past them. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -700.0}), t); + for _ in 0..4 { + h.step(); + } h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); assert!(!h.app.ui.settings.film_names); h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 700.0}), t); + for _ in 0..4 { + h.step(); + } // Performance: the thumbnail cache size goes to the library preferences h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f4bd067fb..b82194bbb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -91,6 +91,12 @@ pub enum Icon { GradeMidtones, /// Colour grading, highlights wheel: an open circle with rays. GradeHighlights, + /// Appearance Mode ▸ Auto (follow the system): a monitor on its stand. + Monitor, + /// Appearance Mode ▸ Light: a sun (a disc with eight rays). + Sun, + /// Appearance Mode ▸ Dark: a crescent moon. + Moon, } struct Pen<'a> { @@ -186,6 +192,24 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(4.0, 10.0), (16.0, 10.0)]); pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); } + Monitor => { + pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); + pen.line(&[(10.0, 13.5), (10.0, 16.5)]); + pen.line(&[(6.5, 16.5), (13.5, 16.5)]); + } + Sun => { + pen.circle(10.0, 10.0, 3.2); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(10.0 + 5.4 * c, 10.0 + 5.4 * s), (10.0 + 7.4 * c, 10.0 + 7.4 * s)]); + } + } + Moon => { + // outer arc of the disc, then the inner arc of the bite, meeting at the horns + pen.arc(10.0, 10.0, 6.5, 2.9, 263.8); + pen.arc(13.4, 6.4, 5.0, 214.9, 51.8); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7c7e46e0..8f837a825 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_appearance; +#[cfg(test)] mod tests_before_after; #[cfg(test)] mod tests_compare; @@ -58,10 +60,10 @@ mod tests_curve; #[cfg(test)] mod tests_date_picker; #[cfg(test)] -mod tests_dialogs; -#[cfg(test)] mod tests_dialog_fit; #[cfg(test)] +mod tests_dialogs; +#[cfg(test)] mod tests_filmstrip; #[cfg(test)] mod tests_grading; @@ -150,10 +152,18 @@ pub type OpenWithFn = Box Result<(), String>>; /// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: /// not known). pub type ScreenProfileFn = Box Option>>; +/// Whether an external editor can be started (its name as in [`Services::open_with`]); the +/// error says why not and what to do (e.g. PhotoCraft isn't installed). +pub type CheckEditorFn = Box Result<(), String>>; /// Something the host does with the session (web: back up or restore the library in browser /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -204,12 +214,17 @@ pub struct Services { pub open_url: Option, /// Open a file in an external editor (Edit in External Editor; desktop only). pub open_with: Option, + /// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none = + /// assume it can). + pub check_editor: Option, /// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the /// user keeps (web only: there the library lives in browser storage, which the browser may /// clear; on the desktop it is a folder backed up like any other). pub backup_library: Option, /// File ▸ Restore Library from Backup… (web only; keeps the current library). pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -347,6 +362,8 @@ pub struct LightcraftApp { pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option, + /// The library generation the window title and the recent list were last updated for. + pub shown_library: Option, /// A background export in progress. pub export: Option, /// Background file-system work of other commands (Find Missing Photos, auto import…). @@ -435,6 +452,7 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + shown_library: None, export: None, tasks: Default::default(), last_export_result: None, @@ -843,6 +861,20 @@ impl LightcraftApp { } } + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it /// on the CPU (photo textures come from the renderer's CPU copies). pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { @@ -946,6 +978,7 @@ impl LightcraftApp { } else { self.fonts_ready = true; } + self.sync_theme(ctx); panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { @@ -1213,6 +1246,29 @@ impl LightcraftApp { self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms); } + /// Once per library opened (at launch too): name it in the window title, so nobody edits in + /// the wrong one, and put it first in File ▸ Open Recent Library. + pub(crate) fn library_changed(&mut self, ctx: &egui::Context) { + let generation = self.session.library_generation(); + if self.shown_library == Some(generation) { + return; + } + self.shown_library = Some(generation); + if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { + let path = l.dir.to_string_lossy().into_owned(); + panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + } + ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); + } + + /// "LightCraft — " (just "LightCraft" for a library that isn't saved). + pub fn window_title(&self) -> String { + match panels::settings::open_library_name(self) { + Some(name) => format!("LightCraft — {name}"), + None => "LightCraft".to_string(), + } + } + /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { i18n::set_language(self.ui.language); @@ -1226,6 +1282,7 @@ impl LightcraftApp { if std::mem::take(&mut self.ui.quit) { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } + self.library_changed(&ctx); // panels set it again this frame while the pointer rests on a preset or profile self.hover_preview = None; self.ai_mask_detail(&ctx); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b7b2e7673..58bcb390f 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -45,7 +45,10 @@ impl MenuNode { /// User-defined names in parameterized menus are data, not message keys. pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str { - if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) { + if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") + || (id == "app.export" && params.get("preset").is_some()) + || (id == "app.openLibrary" && params.get("path").is_some()) + { label } else { crate::i18n::tr(label) @@ -66,7 +69,9 @@ const LAYOUT: &[(&str, &[&str])] = &[ "file.addFolder", "@Import from Device", "---", + "app.newLibrary", "app.openLibrary", + "@Open Recent Library", "file.backupLibrary", "file.restoreLibrary", "---", @@ -131,6 +136,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Appearance", "---", "view.fullScreenPreview", "view.lightsOut", @@ -476,6 +482,29 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("view.sidebarReset", Value::Null, "Reset Sidebar Sections", None, !layout.is_default(), None)); v } + "Appearance" => { + use crate::state::AppearanceMode; + use crate::theme::ThemeKind; + let s = &app.ui.settings; + let mut v: Vec = [ + ("auto", "Sync with System", AppearanceMode::Auto), + ("light", "Light Mode", AppearanceMode::Light), + ("dark", "Dark Mode", AppearanceMode::Dark), + ] + .into_iter() + .map(|(mode, label, m)| item("view.appearance", json!({"mode": mode}), label, None, true, Some(s.appearance_mode == m))) + .collect(); + v.push(item("view.appearance", Value::Null, "Next Appearance Mode", None, true, None)); + // the saved theme of each family is checked + for group in [[ThemeKind::Charcoal, ThemeKind::Midnight], [ThemeKind::Silver, ThemeKind::Paper]] { + v.push(MenuNode::Separator); + for k in group { + let on = if k.is_dark() { s.dark_theme.kind() == k } else { s.light_theme.kind() == k }; + v.push(item("view.theme", json!({"theme": k.id()}), k.label(), None, true, Some(on))); + } + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) @@ -527,6 +556,30 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } + "Open Recent Library" => { + let can = !crate::lightroom_import::is_running(app); + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let mut v: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + // a missing library (moved, drive disconnected) is shown greyed out + let label = + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); + item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + }) + .collect(); + if v.is_empty() { + v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); + } else { + v.push(MenuNode::Separator); + v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); + } + v + } "Import from Device" => { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index bfe87da9d..d5e0ee2f1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -148,6 +148,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.faceModel", "Add Face Model…", None, ""), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -159,7 +164,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.export", "Export…", None, "File"), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), - ("photo.editInExternal", "Edit in External Editor", Some("Cmd+Shift+E"), "Photo"), + ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), + ("photo.editInExternal", "Edit in Other Editor", Some("Cmd+Alt+E"), "Photo>Edit In"), ("dialog.mergeHdr", "HDR…", Some("Ctrl+H"), "Photo>Photo Merge"), ("dialog.mergePanorama", "Panorama…", Some("Ctrl+M"), "Photo>Photo Merge"), ("dialog.mergeHdrPanorama", "HDR Panorama…", None, "Photo>Photo Merge"), @@ -184,7 +190,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.importCurvePresets", "Import Point Curve Presets…", None, ""), ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), + ("app.newLibrary", "New Library…", None, "File"), ("app.openLibrary", "Open Library…", None, "File"), + ("app.recentLibraries", "Recent Libraries", None, ""), + ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display ("app.displayProfile", "Display Profile", None, ""), ("app.about", "About LightCraft", None, "Help"), @@ -293,6 +302,41 @@ fn bool_param(command: &str, p: &Value, key: &str) -> Result, Strin } } +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if matches!(id, "ui.dock" | "ui.dock.reset") { @@ -410,6 +454,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option appearance_command(app, p), + "view.theme" => theme_command(app, p), "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) @@ -592,6 +638,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::settings::open_library(app, p), + "app.newLibrary" => crate::panels::settings::new_library(app, p), + "app.recentLibraries" => crate::panels::settings::recent_libraries(app, p), + "app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})), "app.displayProfile" => crate::panels::settings::display_profile(app, p), "file.backupLibrary" | "file.restoreLibrary" => { let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; @@ -1168,30 +1217,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - // render an edit copy (stacked on the original), then open it in the editor - let mut params = p.clone(); - if !params.is_object() { - params = json!({}); - } - let r = match app.session.execute("photo.editExternal", ¶ms) { - Ok(r) => r, - Err(e) => return Some(Err(e.to_string())), - }; - let path = r["path"].as_str().unwrap_or_default().to_string(); - if let Some(id) = r["id"].as_u64() { - app.ui.external_edits.push(id); - } - let editor = p.get("app").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| app.ui.settings.external_editor.clone()); - if let Some(f) = app.services.open_with.as_mut() - && let Err(e) = f(&path, &editor) - { - app.toast(&ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)); - } - let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(&ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)); - Ok(r) - } + "photo.editInPhotoCraft" => edit_in(app, &ctx, p, true), + "photo.editInExternal" => edit_in(app, &ctx, p, false), "dialog.cull" => { app.ui.dialog = Some(Dialog::Cull { reject_below: 0.0, pick_best: true }); Ok(Value::Null) @@ -1777,6 +1804,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { app.session.active().is_some() } "photo.tagFromTracklog" => app.session.active().is_some() && app.services.pick_tracklog.is_some(), + "photo.editInPhotoCraft" | "photo.editInExternal" => app.session.active().is_some() && app.services.open_with.is_some(), "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "app.showInFinder" => { @@ -1791,10 +1819,11 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, - "app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), + "app.openLibrary" | "app.newLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app), "file.backupLibrary" => app.services.backup_library.is_some(), "app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(), "file.restoreLibrary" => app.services.restore_library.is_some(), + "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() @@ -1883,3 +1912,41 @@ fn show_in_finder(app: &mut LightcraftApp) -> Result { reveal(&path)?; Ok(json!({"path": path})) } + +/// Photo ▸ Edit In: render a copy of the active photo with its edits (stacked on the original) +/// as Settings ▸ External Editing asks for that editor, then open it there. `p` may override the +/// file (`format`, `colorSpace`, `bitDepth`) and the editor (`app`). PhotoCraft is checked first, +/// so a missing editor leaves no copy behind. +fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: bool) -> Result { + let settings = &app.ui.settings; + let (file, configured) = if photocraft { + let path = settings.photocraft_path.trim(); + (settings.photocraft_file, if path.is_empty() { crate::state::PHOTOCRAFT.to_string() } else { path.to_string() }) + } else { + (settings.external_file, settings.external_editor.clone()) + }; + let editor = p.get("app").and_then(Value::as_str).map_or(configured, str::to_string); + if let Some(check) = app.services.check_editor.as_ref() + && let Err(e) = check(&editor) + { + app.toast(ctx, e.clone()); + return Err(e); + } + let mut params = file.params(); + if let (Some(o), Some(given)) = (params.as_object_mut(), p.as_object()) { + for (k, v) in given.iter().filter(|(k, _)| k.as_str() != "app") { + o.insert(k.clone(), v.clone()); + } + } + let r = app.session.execute("photo.editExternal", ¶ms).map_err(|e| e.to_string())?; + let path = r["path"].as_str().unwrap_or_default().to_string(); + if let Some(id) = r["id"].as_u64() { + app.ui.external_edits.push(id); + } + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + match app.services.open_with.as_mut().map(|f| f(&path, &editor)) { + Some(Err(e)) => app.toast(ctx, crate::i18n::tr_format!("Couldn't open the editor: {e}", e = e)), + _ => app.toast(ctx, crate::i18n::tr_format!("{name} opened for editing; it is stacked with the original", name = name)), + } + Ok(r) +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 7990dea41..6af883bc6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -171,8 +171,14 @@ pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", " /// Help ▸ What's New (docs/whats-new.md). pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +/// The import review and Settings keep window ids of their own, so the size each is given +/// doesn't carry over to the other dialogs. fn window_id(dialog: &Dialog) -> egui::Id { - egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" }) + egui::Id::new(match dialog { + Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Settings { .. } => "lightcraft-settings-dialog", + _ => "lightcraft-dialog", + }) } /// Block the underlying panels from the first frame, before the window establishes its modal @@ -676,7 +682,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui, "Format", "exportFormat", - &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], + &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Psd, "PSD"), (F::Webp, "WebP"), (F::Avif, "AVIF"), (F::Dng, "DNG"), (F::Original, "Original files")], &mut opts.format, ); if !opts.format.is_rendered() { diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index a63b0b8e9..a03fbe944 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -907,6 +907,18 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Open in Detail")).clicked() { let _ = app.run("view.detail", json!({})); } + // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor + // (desktop only: the web can't start programs) + ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { + ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { + let _ = app.run("photo.editInPhotoCraft", json!({})); + } + if ui.button(crate::i18n::tr("Edit in Other Editor")).clicked() { + let _ = app.run("photo.editInExternal", json!({})); + } + }); + }); if ui.button(crate::i18n::tr("Find Similar Photos")).clicked() { match app.run("library.findSimilar", json!({"id": id.0})) { Ok(r) => { @@ -984,6 +996,22 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + let can_pick = app.services.pick_folder.is_some() || app.services.picker.is_some(); + if can_pick && ui.button(crate::i18n::tr("Move to Folder…")).clicked() { + // the folder dialog goes through the shared picker (off the UI thread where the host has + // one: then photo.moveToFolder runs when it closes) + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Move to Folder…")); + let picked = + crate::pick::ask(app, "photo.moveToFolder", &json!({}), "folder", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])); + if let crate::pick::Picked::Now(v) = picked + && let Some(into) = v.into_iter().next() + { + match app.run("photo.moveToFolder", json!({"folder": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { let _ = app.run("photo.reload", json!({})); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 8be2aca9c..a14332c82 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -567,7 +567,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); register(ui.ctx(), "sidebarTitle", hr); title.context_menu(|ui| sections_menu(app, ui, None)); - ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + // the open library's name under the title, so nobody edits in the wrong one (issue #902) + let library = crate::panels::settings::open_library_name(app); + let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y }; + ui.painter().text(pos2(hr.left() + 18.0, title_y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text); + if let Some(name) = library { + ui.painter().with_clip_rect(hr.shrink2(vec2(18.0, 0.0))).text( + pos2(hr.left() + 18.0, hr.center().y + 10.0), + Align2::LEFT_CENTER, + name, + t.font(11.0), + t.text_dim, + ); + crate::widgets::register(ui.ctx(), "label:libraryName", hr); + if let Some(l) = app.session.library.as_ref() { + title.on_hover_text(l.dir.display().to_string()); + } + } let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { @@ -1764,6 +1780,30 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F } ui.close(); } + if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), + hint: "Folder name (created on disk)".into(), + value: String::new(), + command: "folder.create".into(), + params: json!({"parent": path}), + key: "name".into(), + }); + ui.close(); + } + let selected = app.session.selection.ids.len(); + if selected > 0 + && ui + .button(crate::i18n::tr("Move Selected Photos Here")) + .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) + .clicked() + { + match app.run("photo.moveToFolder", json!({"folder": path})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), + Err(e) => app.toast(ui.ctx(), e), + } + ui.close(); + } if app.services.reveal.is_some() && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() && let Some(f) = app.services.reveal.as_mut() diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 75fea9fa4..3c7261e20 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -36,9 +36,8 @@ const LABEL_W: f32 = 150.0; /// The dialog body for `tab` (the tab bar switches `tab`). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { let t = Tokens::get(ui.ctx()); - ui.set_min_width(560.0); - ui.set_min_height(330.0); - ui.horizontal(|ui| { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { @@ -122,6 +121,11 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { if r.clicked() { let _ = app.run("app.openLibrary", json!({})); } + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…"))); + register(ui.ctx(), "button:settingsNewLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.newLibrary", json!({})); + } if !can { hint(ui, t, crate::i18n::tr("not available here")); } @@ -138,19 +142,47 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); - heading(ui, t, crate::i18n::tr("External Editor")); - row(ui, t, crate::i18n::tr("Application"), |ui| { - let r = ui - .add(egui::TextEdit::singleline(&mut app.ui.settings.external_editor).hint_text(crate::i18n::tr("System default")).desired_width(220.0)); - register(ui.ctx(), "field:externalEditor", r.rect); + heading(ui, t, crate::i18n::tr("External Editing")); + row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { + crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) + .hint(crate::i18n::tr("Found automatically")) + .width(220.0) + .show(ui); }); + edit_file_rows(ui, t, "photocraftFile", &mut app.ui.settings.photocraft_file); hint( ui, t, crate::i18n::tr( - "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.", ), ); + row(ui, t, crate::i18n::tr("Other editor"), |ui| { + crate::text_field::TextField::singleline("field:externalEditor", &mut app.ui.settings.external_editor) + .hint(crate::i18n::tr("System default")) + .width(220.0) + .show(ui); + }); + edit_file_rows(ui, t, "externalFile", &mut app.ui.settings.external_file); + hint(ui, t, crate::i18n::tr("Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).")); +} + +/// The file Edit In hands an editor: format, colour space and bit depth (`button:{id}Format-…`). +fn edit_file_rows(ui: &mut egui::Ui, t: &Tokens, id: &str, file: &mut crate::state::EditFile) { + use crate::state::{EditFormat, EditSpace}; + row(ui, t, "", |ui| { + choices(ui, &format!("{id}Format"), &[(EditFormat::Psd, "PSD"), (EditFormat::Tiff, "TIFF")], &mut file.format); + ui.separator(); + choices(ui, &format!("{id}Depth"), &[(16u8, "16-bit"), (8, "8-bit")], &mut file.bit_depth); + }); + row(ui, t, "", |ui| { + choices( + ui, + &format!("{id}Space"), + &[(EditSpace::AdobeRgb, "Adobe RGB"), (EditSpace::ProPhoto, "ProPhoto"), (EditSpace::DisplayP3, "P3"), (EditSpace::Srgb, "sRGB")], + &mut file.color_space, + ); + }); } // -------------------------------------------------------------------------------------- Import @@ -607,6 +639,8 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); @@ -628,6 +662,229 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + favorites: &mut Vec, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + for (v, kind) in options { + let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let favorite = favorites.iter().any(|id| id == kind.id()); + let r = ui + .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) + .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!( + "{}: {}", + crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), + crate::i18n::tr(kind.label()) + ), + ) + }); + register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); + if favorite || hovered { + crate::icons::paint( + ui.painter(), + r.rect.shrink(4.0), + if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, + if favorite { t.accent } else { t.text_dim }, + ); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != kind.id()); + } else { + favorites.push(kind.id().into()); + } + } + }); + }); + } + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, @@ -667,13 +924,165 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() }); return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path})); } + // a remembered library that is gone: say so (and forget it) instead of creating an empty one there + if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() { + app.ui.settings.recent_libraries.retain(|r| r != &path); + return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library")); + } + switch_library(app, &path) +} + +/// Close the current library and open the one at `path`. If it can't be opened (locked by +/// another program, unreadable, a file), the error is returned and the current library stays open: +/// the target is checked before anything is closed, and if opening it still fails the previous +/// library is opened again (both errors are reported if that fails too). +fn switch_library(app: &mut LightcraftApp, path: &str) -> Result { + let dir = app.session.check_library(std::path::Path::new(path)).map_err(|e| format!("{path}: {e}"))?; + let path = dir.to_string_lossy().into_owned(); + // the library being left goes in the list too (also when it was opened at launch) + let previous_dir = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let previous = previous_dir.as_ref().map(|d| d.to_string_lossy().into_owned()); app.session.close_library().map_err(|e| e.to_string())?; - app.session.open_library(&path, false).map_err(|e| e.to_string())?; + let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None; app.renderer.forget_all(); app.ui.compare = None; + if let Err(err) = opened { + let Some(prev) = previous_dir else { return Err(err) }; + return match app.session.open_library(&prev, false) { + Ok(_) => Err(err), + Err(e) => Err(format!("{err}; reopening the previous library {} failed too: {e}", prev.display())), + }; + } app.ui.settings.library_path = path.clone(); - Ok(json!({"path": path, "photos": app.session.catalog.len()})) + if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) { + remember_library(&mut app.ui.settings.recent_libraries, &prev); + } + remember_library(&mut app.ui.settings.recent_libraries, &path); + Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()})) +} + +/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`]. +pub fn remember_library(recent: &mut Vec, path: &str) { + recent.retain(|r| r != path); + recent.insert(0, path.to_string()); + recent.truncate(crate::state::RECENT_LIBRARIES); +} + +/// A library's name as people see it: its folder's name. +pub fn library_name(dir: &std::path::Path) -> String { + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string()) +} + +/// The open library's name (`None`: in memory, nothing is saved). +pub fn open_library_name(app: &LightcraftApp) -> Option { + app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir)) +} + +/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library +/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it, +/// `path` itself, or a new folder named after the default library. The folder must not hold a +/// library already, nor anything else. +pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result { + if crate::lightroom_import::is_running(app) { + return Err("wait for Lightroom catalog import to finish before switching libraries".into()); + } + let name = match p.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(s)) => Some(valid_library_name(s)?), + Some(_) => return Err("app.newLibrary: name must be a string".into()), + }; + // from the menu: ask for the name first (default prefilled), then for the location + if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() { + crate::panels::dialogs::prompt( + app, + "New Library", + "Library name", + lightcraft_engine::library::DEFAULT_NAME, + "app.newLibrary", + json!({}), + "name", + ); + return Ok(Value::Null); + } + let (location, picked) = match p.get("path").and_then(Value::as_str) { + Some(x) => (std::path::PathBuf::from(x), false), + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It")); + match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => (std::path::PathBuf::from(x), true), + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } + }; + let dir = match name { + Some(n) => location.join(n), + // a chosen location gets a new folder in it, named after the default library + None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME), + None => location, + }; + if lightcraft_engine::library::is_library(&dir) { + return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display())); + } + if dir.is_file() { + return Err(format!("{}: is a file; a library is a folder", dir.display())); + } + if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) { + return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display())); + } + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?; + switch_library(app, &dir.to_string_lossy()) +} + +/// Characters a folder name can't hold on Windows (and `/`, which no system allows). +const RESERVED_NAME_CHARS: [char; 9] = ['<', '>', ':', '"', '/', '\\', '|', '?', '*']; + +fn valid_library_name(s: &str) -> Result<&str, String> { + let n = s.trim(); + // Windows drops trailing dots and spaces from a folder name: refuse them rather than + // creating a folder whose name differs from the one asked for (spaces around the name + // are trimmed first) + let bad = n.is_empty() || n == "." || n == ".." || n.ends_with(['.', ' ']) || n.contains(RESERVED_NAME_CHARS) || n.chars().any(char::is_control); + if bad { + return Err(format!( + "app.newLibrary: {s:?} can't be a library name (a folder name without < > : \" / \\ | ? * and not ending in a dot or space)" + )); + } + Ok(n) +} + +/// `base` inside `location`, or `base 2`, `base 3`… if that is taken. +fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { + let first = location.join(base); + if !first.exists() { + return first; + } + (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) +} + +/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as +/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` +/// forgets them all. +pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + if p.get("clear").and_then(Value::as_bool) == Some(true) { + app.ui.settings.recent_libraries.clear(); + } + let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let list: Vec = app + .ui + .settings + .recent_libraries + .iter() + .map(|r| { + let d = std::path::Path::new(r); + json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + }) + .collect(); + Ok(Value::Array(list)) } // ---------------------------------------------------------------------------- Display profile diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 6462c87d6..a95805f83 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,7 +53,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + // room for the filter icon and the six icons on the right + let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; @@ -187,6 +188,50 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } x -= 40.0; } + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + register(ui.ctx(), "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + resp.context_menu(|ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui.ctx(), "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); + } + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); + ui.close(); + } + } + } + }); let _ = Align2::CENTER_CENTER; }); } + +/// The appearance button's icon and the mode's name for its tooltip. +pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static str) { + use crate::state::AppearanceMode; + match mode { + AppearanceMode::Auto => (Icon::Monitor, "Sync with System"), + AppearanceMode::Light => (Icon::Sun, "Light Mode"), + AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 7a0b71a39..b76066d55 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -158,6 +158,69 @@ impl LightsOut { } } } +/// How many libraries File ▸ Open Recent Library remembers. +pub const RECENT_LIBRARIES: usize = 8; + +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library @@ -167,6 +230,9 @@ impl LightsOut { pub struct AppSettings { /// Library opened at launch when no `--library` is given (empty = the default location). pub library_path: String, + /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most + /// [`RECENT_LIBRARIES`]). + pub recent_libraries: Vec, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted (keyboard and menu). pub confirm_delete: bool, @@ -176,8 +242,15 @@ pub struct AppSettings { /// drawn at, up to the photo's own pixels and [`LOUPE_EDGE_CEILING`]). Not the old /// `previewEdge` key: its 2560 px default was the soft-image bug (issue #323). pub preview_limit: u32, - /// Edit in External Editor: the application ("" = the system's default for TIFF files). + /// Edit in Other Editor: the application ("" = the system's default for the file type). pub external_editor: String, + /// The file Edit in Other Editor hands over. + pub external_file: EditFile, + /// Edit in PhotoCraft: where PhotoCraft is ("" = found where its installer put it; a portable + /// copy needs its path). + pub photocraft_path: String, + /// The file Edit in PhotoCraft hands over. + pub photocraft_file: EditFile, /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). pub memory_mb: u32, /// Filmstrip: file names above the thumbnails. @@ -188,6 +261,15 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, + /// Theme shortcuts in the appearance context menu. + #[serde(deserialize_with = "deserialize_favorite_themes")] + pub favorite_themes: Vec, /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, @@ -197,21 +279,87 @@ impl Default for AppSettings { fn default() -> Self { AppSettings { library_path: String::new(), + recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: false, gpu: true, preview_limit: 0, external_editor: String::new(), + external_file: EditFile::default(), + photocraft_path: String::new(), + photocraft_file: EditFile { format: EditFormat::Psd, ..EditFile::default() }, memory_mb: 0, film_names: true, film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, + favorite_themes: Vec::new(), display_profile: String::new(), } } } +/// The editor name that means PhotoCraft (Photo ▸ Edit In ▸ Edit in PhotoCraft): the host finds +/// where it is installed. +pub const PHOTOCRAFT: &str = "PhotoCraft"; + +/// The file format Edit In renders the copy as. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditFormat { + #[default] + Tiff, + /// A flat PSD: PhotoCraft saves back to it in place (a TIFF it saves only through Save As). + Psd, +} + +/// The colour space of the Edit In copy. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EditSpace { + #[default] + AdobeRgb, + ProPhoto, + DisplayP3, + Srgb, +} + +/// The file Photo ▸ Edit In renders for an editor (Settings ▸ External Editing). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct EditFile { + pub format: EditFormat, + pub color_space: EditSpace, + /// 16 or 8. + pub bit_depth: u8, +} + +impl Default for EditFile { + fn default() -> Self { + EditFile { format: EditFormat::Tiff, color_space: EditSpace::AdobeRgb, bit_depth: 16 } + } +} + +impl EditFile { + /// The `photo.editExternal` params for this file. + pub fn params(&self) -> serde_json::Value { + let format = match self.format { + EditFormat::Tiff => "tiff", + EditFormat::Psd => "psd", + }; + let space = match self.color_space { + EditSpace::AdobeRgb => "adobeRgb", + EditSpace::ProPhoto => "proPhoto", + EditSpace::DisplayP3 => "displayP3", + EditSpace::Srgb => "srgb", + }; + serde_json::json!({"format": format, "colorSpace": space, "bitDepth": if self.bit_depth == 8 { 8 } else { 16 }}) + } +} + /// Preview size limits offered in Settings → Performance (0 = Automatic). pub const PREVIEW_LIMITS: [u32; 5] = [0, 1600, 2560, 3840, 5120]; @@ -232,6 +380,29 @@ pub const LOUPE_EDGE_STEP: usize = 64; pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling @@ -1031,3 +1202,14 @@ impl UiState { self } } + +fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let values = Vec::::deserialize(d)?; + let mut seen = std::collections::BTreeSet::new(); + for id in &values { + if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { + return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); + } + } + Ok(values) +} diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs new file mode 100644 index 000000000..947e6cf9f --- /dev/null +++ b/crates/ui-egui/src/tests_appearance.rs @@ -0,0 +1,247 @@ +//! Appearance Mode: Auto (follow the system), Dark or Light, each with its own saved theme. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{AppSettings, AppearanceMode, DarkTheme, LightTheme, UiState}; +use crate::theme::ThemeKind; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }) +} + +/// One frame of logic on `ctx`: where the theme is applied. +fn frame(app: &mut LightcraftApp, ctx: &egui::Context) { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + output.textures_delta.clear(); +} + +#[test] +fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { + let s = AppSettings::default(); + assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); + assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); + // a ui.json saved before appearance modes existed + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); + assert_eq!(old.settings.light_theme, LightTheme::Silver); + assert!(old.settings.confirm_delete); + // and the Charcoal tokens are the ones LightCraft always had + assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); +} + +#[test] +fn choices_validate_and_survive_a_restart() { + let mut a = app(); + let ctx = egui::Context::default(); + let set = |a: &mut LightcraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + match crate::control::handle(a, &ctx, &req) { + crate::control::Outcome::Done(reply) => reply, + _ => panic!("expected a reply"), + } + }; + let ok = set(&mut a, json!({"settings": {"appearanceMode": "auto", "darkTheme": "midnight", "lightTheme": "paper"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); + for bad in [json!({"appearanceMode": "sepia"}), json!({"darkTheme": "paper"}), json!({"lightTheme": "midnight"}), json!({"appearanceMode": 3})] { + let reply = set(&mut a, json!({ "settings": bad })); + assert_eq!(reply["ok"], false, "{bad} accepted"); + } + assert_eq!((a.ui.settings.dark_theme, a.ui.settings.light_theme), (DarkTheme::Midnight, LightTheme::Paper), "a rejected value changes nothing"); + let saved = serde_json::to_string(&a.ui).unwrap(); + assert!(saved.contains(r#""appearanceMode":"auto""#) && saved.contains(r#""darkTheme":"midnight""#) && saved.contains(r#""lightTheme":"paper""#)); + let restarted: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restarted.settings, a.ui.settings); +} + +#[test] +fn the_button_cycles_auto_light_dark_without_losing_theme_choices() { + let mut a = app(); + let ctx = egui::Context::default(); + a.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + a.run("view.theme", json!({"theme": "midnight"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Dark, "a dark theme fixes the mode to Dark"); + for (mode, shown) in + [(AppearanceMode::Auto, ThemeKind::Midnight), (AppearanceMode::Light, ThemeKind::Paper), (AppearanceMode::Dark, ThemeKind::Midnight)] + { + let r = a.run("view.appearance", json!({})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, mode); + assert_eq!(r["darkTheme"], "midnight"); + assert_eq!(r["lightTheme"], "paper"); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(shown)); + assert_eq!(crate::theme::Tokens::get(&ctx).chrome, crate::theme::Tokens::for_kind(shown).chrome); + let icon = match mode { + AppearanceMode::Auto => crate::icons::Icon::Monitor, + AppearanceMode::Light => crate::icons::Icon::Sun, + AppearanceMode::Dark => crate::icons::Icon::Moon, + }; + assert_eq!(crate::panels::topbar::appearance_icon(mode).0, icon); + } + // a light theme from the menu fixes Light + a.run("view.theme", json!({"theme": "silver"})).unwrap(); + assert_eq!( + (a.ui.settings.appearance_mode, a.ui.settings.light_theme, a.ui.settings.dark_theme), + (AppearanceMode::Light, LightTheme::Silver, DarkTheme::Midnight) + ); + // bad parameters are errors, never a panic, and change nothing + for p in [json!({}), json!({"theme": 4}), json!({"theme": "neon"})] { + assert!(a.run("view.theme", p.clone()).is_err(), "{p}"); + } + for p in [json!({"mode": "sepia"}), json!({"mode": 1}), json!({"mode": []})] { + assert!(a.run("view.appearance", p.clone()).is_err(), "{p}"); + } + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Light); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); +} + +#[test] +fn auto_follows_the_system_and_falls_back_to_dark() { + use std::sync::{Arc, Mutex}; + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let mut a = app(); + let reported = Arc::clone(&system); + a.services.system_theme = Some(Box::new(move |_| *reported.lock().unwrap())); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); + assert_eq!(ctx.theme(), egui::Theme::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + assert_eq!(ctx.theme(), egui::Theme::Dark); + // no answer from the host or from egui: dark + *system.lock().unwrap() = None; + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + // without a host service, egui's report is used + let mut plain = app(); + plain.ui.settings.appearance_mode = AppearanceMode::Auto; + let ctx = egui::Context::default(); + let input = egui::RawInput { system_theme: Some(egui::Theme::Light), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + assert_eq!(plain.system_theme(ui.ctx()), Some(egui::Theme::Light)); + plain.logic(ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); +} + +#[test] +fn view_menu_lists_modes_and_themes_with_checks() { + let mut a = app(); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + let menus = crate::menubar::menu_bar(&a); + let text = serde_json::to_string(&menus).unwrap(); + for label in ["Appearance", "Sync with System", "Light Mode", "Dark Mode", "Next Appearance Mode", "Charcoal", "Midnight", "Silver", "Paper"] { + assert!(text.contains(&format!("\"{label}\"")), "{label} missing from the menus"); + } + assert!(text.contains(r#""params":{"theme":"paper"}"#)); +} + +#[test] +fn the_top_bar_button_and_settings_cards_drive_the_mode() { + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.settle(Duration::from_secs(60)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Auto); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Silver)); + // Settings ▸ Interface: the mode buttons and the theme radios + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Paper)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAppearance-1"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Charcoal)); +} + +#[test] +fn interface_settings_fit_small_windows_and_stack_theme_choices() { + // Desktop windows have a 900×560 minimum; also stress a smaller headless viewport. + for size in [[900.0, 560.0], [640.0, 480.0]] { + let mut h = Headless::new(app(), size, 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").unwrap().1; + assert!(window.width() <= size[0], "{size:?}: {window:?}"); + assert!(window.height() <= size[1], "{size:?}: {window:?}"); + let silver = h.app.widgets.iter().find(|(id, _)| id == "radio:theme-silver").unwrap().1; + let mode = h.app.widgets.iter().find(|(id, _)| id == "button:settingsAppearance-0").unwrap().1; + assert!(silver.top() > mode.bottom() + 80.0, "theme choices must sit below their preview"); + assert!(silver.right() <= window.right(), "theme choices stay inside the modal"); + } +} + +#[test] +fn favorites_validate_roundtrip_and_stars_toggle_without_selecting() { + for bad in [json!(["paper", "paper"]), json!(["unknown"]), json!([3]), json!("paper")] { + assert!(serde_json::from_value::(json!({"favoriteThemes": bad})).is_err()); + } + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + assert_eq!(h.request("ui.hoverWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.favorite_themes, vec!["paper"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + h.request("ui.scroll", json!({"dy": -90.0}), T); + for _ in 0..4 { + h.step(); + } + let pixels = h.paint(); + let image = + lightcraft_raster::Rgba8 { width: pixels.size[0], height: pixels.size[1], data: pixels.pixels.iter().map(|c| c.to_array()).collect() }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(std::path::Path::new(&path).join("lightcraft-favorites.png"), png).unwrap(); + } + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Silver, "starring does not select"); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap(), h.app.ui.settings); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert!(h.app.ui.settings.favorite_themes.is_empty()); + h.app.ui.dialog = None; + h.app.ui.settings.favorite_themes = vec!["midnight".into(), "paper".into()]; + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance", "button": "right"}), T)["ok"], true); + h.step(); + h.step(); + let light = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-paper").unwrap().1; + let dark = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-midnight").unwrap().1; + assert!(light.top() < dark.top(), "light favorites precede dark favorites"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "menu:themeFavorite-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(h.app.ui.settings.dark_theme, DarkTheme::Charcoal); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index d5e15f4ce..652e811c9 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -970,6 +970,60 @@ fn edit_in_external_editor_opens_the_copy() { h.settle(SETTLE); } +/// Edit in PhotoCraft: a PSD copy (what PhotoCraft saves back to), handed to "PhotoCraft" for +/// the host to find; when the host can't find it, no copy is made. +#[test] +fn edit_in_photocraft_opens_a_psd_copy() { + let dir = std::env::temp_dir().join(format!("lc-ui-pcraft-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc>> = Default::default(); + let installed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (o, i) = (opened.clone(), installed.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + check_editor: Some(Box::new(move |app: &str| { + if app == crate::state::PHOTOCRAFT && !i.load(std::sync::atomic::Ordering::SeqCst) { + Err("PhotoCraft isn't installed".into()) + } else { + Ok(()) + } + })), + ..Default::default() + }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let n = h.app.session.catalog.len(); + let params = json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}); + let r = h.request("engine.execute", params.clone(), T); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!((h.app.session.catalog.len(), opened.lock().unwrap().len()), (n, 0), "no copy without PhotoCraft"); + installed.store(true, std::sync::atomic::Ordering::SeqCst); + let r = h.request("engine.execute", params, T); + assert_eq!(r["ok"], true, "{r}"); + let calls = opened.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + assert!(calls[0].0.ends_with("-Edit.psd") && std::path::Path::new(&calls[0].0).exists(), "{calls:?}"); + assert_eq!(calls[0].1, "PhotoCraft"); + let d = lightcraft_codecs::decode(&std::fs::read(&calls[0].0).unwrap(), Default::default()).unwrap(); + assert_eq!(d.bit_depth, 16, "16-bit by default"); + // a portable copy: its path is what the host is given; the file follows the settings + h.app.ui.settings.photocraft_path = "/opt/pc/photocraft".into(); + h.app.ui.settings.photocraft_file.format = crate::state::EditFormat::Tiff; + h.app.ui.settings.photocraft_file.bit_depth = 8; + let r = h.request("engine.execute", json!({"command": "photo.editInPhotoCraft", "params": {"dir": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + let last = opened.lock().unwrap().last().cloned().unwrap(); + assert!(last.0.ends_with(".tif") && last.1 == "/opt/pc/photocraft", "{last:?}"); + assert!(crate::menus::ui_enabled(&h.app, "photo.editInPhotoCraft")); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index d7bffc0d8..8c5208e3d 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -41,3 +41,162 @@ fn opening_another_library_drops_the_old_librarys_cached_panels() { let _ = std::fs::remove_dir_all(&dir); } + +fn temp(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn app_in(dir: &std::path::Path) -> LightcraftApp { + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + LightcraftApp::new(session, Services::default()) +} + +fn recent_submenu(app: &LightcraftApp) -> Vec { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// Issue #902: File ▸ New Library… creates a named library and opens it; it shows in the window +/// title and in Open Recent Library, newest first. +#[test] +fn new_library_creates_opens_and_is_remembered_and_named() { + let dir = temp("newlib"); + let mut app = app_in(&dir); + app.library_changed(&egui::Context::default()); + assert_eq!(app.window_title(), "LightCraft — first"); + + let r = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap(); + assert_eq!(r["name"], "Client A"); + let a = dir.join("Client A"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + assert!(lightcraft_engine::library::is_library(&a), "the catalog is written"); + assert_eq!(app.window_title(), "LightCraft — Client A"); + assert_eq!(app.ui.settings.library_path, a.to_string_lossy()); + + // a second New Library at the same place is refused and the current one stays open + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap_err(); + assert!(e.contains("library here already"), "{e}"); + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "../x"})).unwrap_err(); + assert!(e.contains("library name"), "{e}"); + assert_eq!(app.session.library.as_ref().unwrap().dir, a); + + let list = app.run("app.recentLibraries", json!({})).unwrap(); + let names: Vec<_> = list.as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect(); + assert_eq!(names, ["Client A", "first"]); + assert_eq!(list[0]["open"], true); + assert_eq!(list[1]["exists"], true); + + // Open Recent Library reopens "first" + let items = recent_submenu(&app); + let first = items + .iter() + .find_map(|n| match n { + crate::menubar::MenuNode::Item { id, params, enabled, .. } + if id == "app.openLibrary" && params["path"].as_str().is_some_and(|p| p.ends_with("first")) => + { + assert!(*enabled); + Some(params.clone()) + } + _ => None, + }) + .expect("first in Open Recent Library"); + app.run("app.openLibrary", first).unwrap(); + assert_eq!(app.window_title(), "LightCraft — first"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// From the menu, New Library… asks for a name (default prefilled), then where to put it. +#[test] +fn new_library_from_the_menu_asks_for_a_name_then_a_location() { + let dir = temp("newlibprompt"); + let mut app = app_in(&dir); + let loc = dir.to_string_lossy().into_owned(); + app.services.pick_folder = Some(Box::new(move || Some(loc.clone()))); + app.run("app.newLibrary", json!({})).unwrap(); + let Some(crate::state::Dialog::TextPrompt { value, command, key, .. }) = app.ui.dialog.as_mut() else { + panic!("no name prompt: {:?}", app.ui.dialog) + }; + assert_eq!(value, lightcraft_engine::library::DEFAULT_NAME); + assert_eq!((command.as_str(), key.as_str()), ("app.newLibrary", "name")); + *value = "Wedding".into(); + // what the dialog's OK runs + app.ui.dialog = None; + app.run("app.newLibrary", json!({"name": "Wedding"})).unwrap(); + assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Wedding")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A remembered library that's gone is greyed out in the menu; opening it anyway is an error that +/// keeps the current library open and drops it from the list. The list is capped. +#[test] +fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() { + let dir = temp("recentlib"); + let mut app = app_in(&dir); + let gone = dir.join("gone").to_string_lossy().into_owned(); + app.ui.settings.recent_libraries = vec![gone.clone()]; + let disabled = recent_submenu(&app) + .iter() + .any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str())); + assert!(disabled, "missing library greyed out"); + let e = app.run("app.openLibrary", json!({"path": gone})).unwrap_err(); + assert!(e.contains("no longer there"), "{e}"); + assert!(!std::path::Path::new(&gone).exists(), "no empty library created in its place"); + assert!(app.ui.settings.recent_libraries.is_empty()); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first")); + + for i in 0..20 { + crate::panels::settings::remember_library(&mut app.ui.settings.recent_libraries, &format!("/x/{i}")); + } + assert_eq!(app.ui.settings.recent_libraries.len(), crate::state::RECENT_LIBRARIES); + assert_eq!(app.ui.settings.recent_libraries[0], "/x/19"); + app.run("app.clearRecentLibraries", json!({})).unwrap(); + assert!(app.ui.settings.recent_libraries.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A library another program has open is refused before the current one is closed: the current +/// library stays open with its photos, and the window title keeps its name. +#[test] +fn switching_to_a_locked_library_keeps_the_current_one_open() { + let dir = temp("lockedlib"); + let mut app = app_in(&dir); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Other"})).unwrap(); + app.run("app.openLibrary", json!({"path": dir.join("first").to_string_lossy()})).unwrap(); + // "another program" holds Other's lock + let other = dir.join("Other"); + let held = lightcraft_catalog::LibraryLock::acquire(&other, "someone else").unwrap(); + let e = app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap_err(); + assert!(e.contains("already open"), "{e}"); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first"), "the current library stays"); + assert!(app.session.library.as_ref().unwrap().on_disk); + assert!(app.ui.settings.library_path.ends_with("first")); + drop(held); + app.run("app.openLibrary", json!({"path": other.to_string_lossy()})).unwrap(); + assert!(app.session.library.as_ref().unwrap().dir.ends_with("Other")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Library names that can't be folder names everywhere (Windows' reserved characters, a +/// trailing dot) are refused; spaces around a name are trimmed. +#[test] +fn library_names_must_work_as_folder_names_on_every_system() { + let dir = temp("libnames"); + let mut app = app_in(&dir); + for bad in ["ab", "a:b", "a\"b", "a/b", "a\\b", "a|b", "a?b", "a*b", "name.", "..", " ", "a\u{7}"] { + let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": bad})).unwrap_err(); + assert!(e.contains("library name"), "{bad:?}: {e}"); + } + assert!(app.session.library.as_ref().unwrap().dir.ends_with("first")); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": " Trip 2026 "})).unwrap(); + assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Trip 2026")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index cd29014e9..a87a196ae 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,5 +1,9 @@ //! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the //! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code. +//! +//! Themes come in two families, picked by the Appearance Mode setting (Settings ▸ Interface): +//! - dark: **Charcoal** (the default, the observed dark look) and **Midnight** (near black); +//! - light: **Silver** (light grey chrome) and **Paper** (near white). use std::sync::Arc; @@ -75,6 +79,44 @@ pub struct Tokens { pub film_h: f32, } +/// An interface theme. Each belongs to the dark or the light family. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ThemeKind { + #[default] + Charcoal, + Midnight, + Silver, + Paper, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper]; + + pub fn is_dark(self) -> bool { + matches!(self, ThemeKind::Charcoal | ThemeKind::Midnight) + } + + /// The theme's id (settings value and command suffix). + pub fn id(self) -> &'static str { + match self { + ThemeKind::Charcoal => "charcoal", + ThemeKind::Midnight => "midnight", + ThemeKind::Silver => "silver", + ThemeKind::Paper => "paper", + } + } + + /// The English display name. + pub fn label(self) -> &'static str { + match self { + ThemeKind::Charcoal => "Charcoal", + ThemeKind::Midnight => "Midnight", + ThemeKind::Silver => "Silver", + ThemeKind::Paper => "Paper", + } + } +} + impl Default for Tokens { fn default() -> Self { Tokens { @@ -118,6 +160,92 @@ impl Default for Tokens { } impl Tokens { + /// The tokens of `kind`. Metrics are shared; only colours change. + pub fn for_kind(kind: ThemeKind) -> Tokens { + let rgb = |v: u32| Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + let base = Tokens::default(); + match kind { + ThemeKind::Charcoal => base, + ThemeKind::Midnight => Tokens { + chrome: rgb(0x1e1e1e), + canvas: rgb(0x121212), + grid_bg: rgb(0x080808), + cell: rgb(0x121212), + cell_selected: rgb(0x262626), + divider: rgb(0x0e0e0e), + inset: rgb(0x181818), + field: rgb(0x161616), + field_border: rgb(0x333333), + button: rgb(0x191919), + button_border: rgb(0x404040), + hover: rgb(0x2e2e2e), + pressed: rgb(0x343434), + tool_active: rgb(0x343434), + ..base + }, + ThemeKind::Silver => Tokens { + chrome: rgb(0xe6e6e6), + canvas: rgb(0xcfcfcf), + grid_bg: rgb(0xd9d9d9), + cell: rgb(0xcbcbcb), + cell_selected: rgb(0xb4b4b4), + divider: rgb(0xc4c4c4), + inset: rgb(0xdcdcdc), + field: rgb(0xf6f6f6), + field_border: rgb(0xb8b8b8), + button: rgb(0xf2f2f2), + button_border: rgb(0xb0b0b0), + hover: rgb(0xd2d2d2), + pressed: rgb(0xc4c4c4), + tool_active: rgb(0xcacaca), + text: rgb(0x1c1c1c), + text_label: rgb(0x383838), + text_dim: rgb(0x6a6a6a), + text_disabled: rgb(0xa2a2a2), + icon: rgb(0x555555), + track: rgb(0xa8a8a8), + thumb: rgb(0x5e5e5e), + thumb_hover: rgb(0x262626), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + ThemeKind::Paper => Tokens { + chrome: rgb(0xfafafa), + canvas: rgb(0xececec), + grid_bg: rgb(0xf2f2f2), + cell: rgb(0xe8e8e8), + cell_selected: rgb(0xd2d2d2), + divider: rgb(0xe0e0e0), + inset: rgb(0xf0f0f0), + field: rgb(0xffffff), + field_border: rgb(0xcccccc), + button: rgb(0xffffff), + button_border: rgb(0xc6c6c6), + hover: rgb(0xebebeb), + pressed: rgb(0xdedede), + tool_active: rgb(0xe2e2e2), + text: rgb(0x161616), + text_label: rgb(0x343434), + text_dim: rgb(0x707070), + text_disabled: rgb(0xababab), + icon: rgb(0x585858), + track: rgb(0xbdbdbd), + thumb: rgb(0x606060), + thumb_hover: rgb(0x202020), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + } + } + pub fn get(ctx: &egui::Context) -> Tokens { ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_default() } @@ -215,15 +343,32 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +fn theme_id() -> egui::Id { + egui::Id::new("lightcraft-theme") +} + +/// The theme `ctx` is styled with (none before the first [`apply_kind`]). +pub fn current(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(theme_id())) +} + +/// Style `ctx` with the default theme. pub fn apply(ctx: &egui::Context) { - // Cmd+= / Cmd+- / Cmd+0 (Ctrl on Windows and Linux) are View ▸ Zoom In / Zoom Out / Zoom to Fit - // for the photo. egui would take the same keys at the end of every frame for its browser-style - // interface zoom (issue #566); on macOS the native menu bar got them first, elsewhere the whole - // UI scaled. + apply_kind(ctx, ThemeKind::default()); +} + +/// Style `ctx` with `kind`. The visuals go to both of egui's styles, so egui following the +/// system's dark/light setting on its own never swaps in its stock look. +pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { + // Keep photo zoom shortcuts from resizing egui itself. ctx.options_mut(|o| o.zoom_with_keyboard = false); - let t = Tokens::default(); - ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); - let mut v = Visuals::dark(); + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::NULL, t); + d.insert_temp(theme_id(), kind); + }); + let dark = kind.is_dark(); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome; v.window_fill = t.chrome; v.extreme_bg_color = t.field; @@ -234,8 +379,9 @@ pub fn apply(ctx: &egui::Context) { v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; - v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; + let (popup, window) = if dark { (140, 160) } else { (50, 60) }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(popup) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(window) }; let w = &mut v.widgets; craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to( w, @@ -244,8 +390,10 @@ pub fn apply(ctx: &egui::Context) { ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + ctx.set_visuals_of(egui::Theme::Dark, v.clone()); + ctx.set_visuals_of(egui::Theme::Light, v); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 966d1f8af..da38cf648 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -95,6 +95,12 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, +Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: +"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. +Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, +`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the +XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing. Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or `{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8be7e280e..c752eff93 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -383,7 +383,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). No JXL encoder; non-raw → DNG not supported | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | @@ -399,7 +399,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-PHOTOS | Export to the system photo library | P2 | ⬜ | | | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs` | a 16-bit TIFF `-Edit` copy with the edits (Adobe RGB / ProPhoto / P3 / sRGB) next to the original, added stacked on top of it and opened in the editor set in Settings ▸ General (or the system default) | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -424,7 +424,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-