diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 00ef74ad..a4247941 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -159,6 +159,7 @@ fn services(originals: Originals, backend: Option, ctx: egui::Context) lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() })), reveal: None, + pick_folder: None, } } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 6ac150ec..7604b7c6 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -3,7 +3,8 @@ //! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [files or folders…]` //! //! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else -//! `$LIGHTCRAFT_LIBRARY`, else `~/Pictures/LightCraft Library`; a new library starts with the +//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else +//! `~/Pictures/LightCraft Library`; a new library starts with the //! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command //! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes //! nothing (demo photos unless files are given; used by the README showcase scripts). @@ -57,16 +58,13 @@ fn config_dir() -> Option { } } -fn load_prefs(app: &mut LightcraftApp) { +/// The saved UI state and app settings (`/ui.json`), if any. +fn load_prefs() -> Option { if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { - return; - } - if let Some(p) = config_dir().map(|d| d.join("ui.json")) - && let Ok(bytes) = std::fs::read(&p) - && let Ok(ui) = serde_json::from_slice::(&bytes) - { - app.ui = ui.sanitized(); + return None; } + let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?; + serde_json::from_slice::(&bytes).ok().map(UiState::sanitized) } fn save_prefs(app: &LightcraftApp) { @@ -83,6 +81,7 @@ fn save_prefs(app: &LightcraftApp) { fn services() -> Services { Services { + pick_folder: Some(Box::new(|| rfd::FileDialog::new().set_title("Open Library").pick_folder().map(|p| p.to_string_lossy().to_string()))), reveal: Some(Box::new(|path: &str| { let status = if cfg!(target_os = "macos") { std::process::Command::new("open").args(["-R", path]).status() @@ -192,7 +191,7 @@ fn main() -> eframe::Result { let mut files = Vec::new(); let mut seed_demo = true; let mut in_memory = false; - let mut library_dir = lightcraft_engine::library::default_dir(); + let mut library_dir: Option = None; let mut args = std::env::args().skip(1); while let Some(a) = args.next() { match a.as_str() { @@ -215,6 +214,16 @@ fn main() -> eframe::Result { _ => files.push(a), } } + let prefs = load_prefs(); + // --library, else the library last opened from Settings, else the default location + let library_dir = library_dir.or_else(|| { + prefs + .as_ref() + .map(|u| u.settings.library_path.clone()) + .filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none()) + .map(Into::into) + }); + let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("LightCraft") @@ -232,7 +241,9 @@ fn main() -> eframe::Result { Box::new(move |cc| { let session = open_session(in_memory, library_dir, seed_demo && files.is_empty()); let mut app = LightcraftApp::new(session, services()); - load_prefs(&mut app); + if let Some(ui) = prefs { + app.ui = ui; + } app.integrated_titlebar = cfg!(target_os = "macos"); if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index abba999f..4286de77 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -19,6 +19,9 @@ use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu use serde_json::Value; const QUIT: &str = "app.quit"; +/// Settings… (the app menu on macOS; Edit elsewhere). +const SETTINGS: &str = "app.settings"; +const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo /// or the active mask): left to the egui handler, which knows the context. const CONTEXTUAL: &[&str] = &["X", "Delete"]; @@ -117,6 +120,7 @@ fn accelerator(sc: &str) -> Option { "=" => Code::Equal, "-" => Code::Minus, "'" => Code::Quote, + "," => Code::Comma, "Delete" => Code::Backspace, _ => return None, }; @@ -161,6 +165,14 @@ fn structure_of(bar: &[(String, Vec)]) -> String { s } +/// Drop a trailing separator (left behind when an item moved to the app menu). +fn tidy_separators(mut nodes: Vec) -> Vec { + while matches!(nodes.last(), Some(MenuNode::Separator)) { + nodes.pop(); + } + nodes +} + impl NativeMenu { /// Build the menu bar and install it as the application menu (main thread, after launch). pub fn install(app: &mut LightcraftApp, ctx: &egui::Context) -> NativeMenu { @@ -185,10 +197,13 @@ impl NativeMenu { // the application menu let app_menu = Submenu::new("LightCraft", true); let about = MenuItem::with_id("app.about", "About LightCraft", true, None); + let settings = MenuItem::with_id(SETTINGS, "Settings…", true, accelerator(SETTINGS_KEY)); let quit = MenuItem::with_id(QUIT, "Quit LightCraft", true, accelerator("Cmd+Q")); let _ = app_menu.append_items(&[ &about, &PredefinedMenuItem::separator(), + &settings, + &PredefinedMenuItem::separator(), &PredefinedMenuItem::services(None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::hide(Some("Hide LightCraft")), @@ -208,8 +223,10 @@ impl NativeMenu { &PredefinedMenuItem::separator(), ]); } - // About lives in the app menu on macOS - let nodes: Vec = nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about")).cloned().collect(); + // About and Settings live in the app menu on macOS + let nodes: Vec = + nodes.iter().filter(|n| !matches!(n, MenuNode::Item { id, .. } if id == "app.about" || id == SETTINGS)).cloned().collect(); + let nodes = tidy_separators(nodes); self.append_nodes(&sub, &nodes); if title == "Window" { let _ = sub.append_items(&[&PredefinedMenuItem::separator(), &PredefinedMenuItem::bring_all_to_front(None)]); @@ -281,6 +298,7 @@ impl NativeMenu { .filter_map(|i| i.shortcut.clone()) .filter(|sc| !(self.text_focus && yields_to_text(sc))) .collect::>(); + app.native_shortcuts.insert(SETTINGS_KEY.to_string()); } /// Per frame: run chosen items, then sync labels / enabled / checked and the text-focus @@ -291,8 +309,8 @@ impl NativeMenu { ctx.send_viewport_cmd(egui::ViewportCommand::Close); continue; } - if key == "app.about" { - let _ = app.run("app.about", serde_json::json!({})); + if key == "app.about" || key == SETTINGS { + let _ = app.run(&key, serde_json::json!({})); continue; } if let Some(it) = self.items.get(&key) { diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 5d4f46d9..553fddf0 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -181,6 +181,11 @@ pub struct Photo { /// Virtual copies' name ("Copy 1"). #[serde(default, skip_serializing_if = "Option::is_none")] pub copy_name: Option, + /// The settings import gave the photo when a user default (a raw/JPEG default preset, see + /// the engine's import defaults) changed them from [`Photo::camera_defaults`]. They count as + /// unedited, and Reset returns to them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub import_look: Option>, } impl Photo { @@ -211,11 +216,20 @@ impl Photo { embedded_lens: None, copy_of: None, copy_name: None, + import_look: None, } } - /// The develop settings import gives this photo (raws: as-shot white balance and the raw - /// defaults; embedded lens corrections on when the file has them). + /// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's + /// default preset applied on top of them ([`Photo::import_look`]). pub fn import_defaults(&self) -> DevelopSettings { + match &self.import_look { + Some(l) => (**l).clone(), + None => self.camera_defaults(), + } + } + /// The built-in defaults for this photo, before any user default preset: raws start from + /// their as-shot white balance; embedded lens corrections on when the file has them. + pub fn camera_defaults(&self) -> DevelopSettings { let mut d = match self.as_shot_wb { Some((t, tint)) => DevelopSettings::for_raw(t, tint), None => DevelopSettings::default(), diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 32ce552f..81f94945 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -128,7 +128,12 @@ pub fn specs() -> Vec { .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) .filter_map(|(id, d)| { - let fresh = DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() }; + // back to the photo's import defaults when a default preset gave it its look + let look = s.catalog.photo(id).and_then(|p| p.import_look.clone()); + let fresh = match look { + Some(l) => (*l).clone(), + None => DevelopSettings { wb: lightcraft_develop::WhiteBalance { mode: WbMode::AsShot, ..d.wb }, ..Default::default() }, + }; s.develop_op(id, fresh, "Reset") }) .collect(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 8a385ec5..642f1e1b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -11,6 +11,7 @@ mod library; mod masks; mod merge; mod organize; +mod prefs; mod preset_files; mod query; mod xmp; @@ -111,6 +112,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(query::specs()); v.extend(xmp::specs()); v.extend(preset_files::specs()); + v.extend(prefs::specs()); v }) } diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs new file mode 100644 index 00000000..01a157ff --- /dev/null +++ b/crates/engine/src/cmd/prefs.rs @@ -0,0 +1,91 @@ +//! Library preferences (Settings → Import / Performance): develop defaults applied on import and +//! the thumbnail cache budget. Saved in the library's `prefs.json` (see [`crate::library`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::import::{CameraDefault, ImportDefaults}; +use crate::{Result, Session}; + +const ID: &str = "library.preferences"; + +fn current(s: &Session) -> Value { + json!({ + "xmp": s.xmp, + "import": s.import_defaults, + "cacheMb": if s.cache_mb == 0 { s.cache_bytes() >> 20 } else { u64::from(s.cache_mb) }, + "persistent": s.library.is_some(), + }) +} + +/// `"default"`, `""` or null = no preset; anything else must name an existing preset. +fn preset_ref(s: &Session, v: &Value) -> Result> { + match v { + Value::Null => Ok(None), + Value::String(x) if x.is_empty() || x == "default" => Ok(None), + Value::String(x) if s.presets.iter().any(|p| p.id == *x) => Ok(Some(x.clone())), + Value::String(x) => Err(bad(ID, format!("unknown preset `{x}` (see preset.list)"))), + other => Err(bad(ID, format!("preset must be a preset id or \"default\", not {other}"))), + } +} + +fn prefs(s: &mut Session, p: &Value) -> Result { + let mut d: ImportDefaults = s.import_defaults.clone(); + let mut changed = false; + if let Some(i) = p.get("import") { + if let Some(v) = i.get("rawPreset") { + d.raw_preset = preset_ref(s, v)?; + } + if let Some(v) = i.get("otherPreset") { + d.other_preset = preset_ref(s, v)?; + } + if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { + d.per_camera = v; + } + if let Some(a) = i.get("cameras").and_then(Value::as_array) { + d.cameras.clear(); + for c in a { + let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + if camera.is_empty() { + return Err(bad(ID, "each camera default needs `camera` (make + model)")); + } + let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?; + d.cameras.push(CameraDefault { camera, preset }); + } + } + changed = true; + } + // set or clear one camera's default: {camera: {camera: "Make Model", preset: id|null}} + if let Some(c) = p.get("camera") { + let camera = c.get("camera").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + if camera.is_empty() { + return Err(bad(ID, "`camera.camera` (make + model) is required")); + } + let preset = preset_ref(s, c.get("preset").unwrap_or(&Value::Null))?; + d.cameras.retain(|x| !x.camera.eq_ignore_ascii_case(&camera)); + if c.get("remove").and_then(Value::as_bool) != Some(true) { + d.cameras.push(CameraDefault { camera, preset }); + } + changed = true; + } + s.import_defaults = d; + if let Some(mb) = p.get("cacheMb") { + let mb = mb.as_u64().ok_or_else(|| bad(ID, "cacheMb must be a number of megabytes"))?; + s.set_cache_mb(mb.clamp(0, 1 << 20) as u32)?; + } else if changed { + s.save_prefs()?; + } + Ok(current(s)) +} + +pub fn specs() -> Vec { + vec![cmd!( + "library.preferences", + "Library Preferences", + [], + None, + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}]}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, persistent}", + always, + prefs + )] +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 2be62172..683f16a1 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use lightcraft_catalog::{Op, Photo, PhotoId, Source}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; @@ -59,14 +59,74 @@ pub struct ImportReport { /// The develop settings a photo gets on import: raws start from their as-shot white balance with /// default sharpening / colour noise reduction, and file-embedded lens corrections on (as the -/// camera intended). +/// camera intended); a user default preset ([`ImportDefaults`]) goes on top. pub fn import_defaults(p: &Photo) -> lightcraft_develop::DevelopSettings { p.import_defaults() } -/// Does the photo still look as imported (its embedded camera preview is then a fair stand-in)? +/// Does the photo still look as the camera rendered it (its embedded camera preview is then a +/// fair stand-in)? Not when a default preset changed it on import. pub fn has_import_look(p: &Photo) -> bool { - *p.develop == import_defaults(p) + *p.develop == p.camera_defaults() +} + +/// User defaults applied on import (Settings → Import; saved in the library's `prefs.json`). +/// Presets are referenced by id; a preset that no longer exists is ignored. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ImportDefaults { + /// Preset applied to raw files (`None` = the LightCraft default). + pub raw_preset: Option, + /// Use a camera's own default (below) when the photo's camera has one. + pub per_camera: bool, + /// Per-camera raw defaults, keyed by the camera's make + model as shown in Info + /// (`"Canon EOS R5"`). + pub cameras: Vec, + /// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none). + pub other_preset: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct CameraDefault { + /// Make + model (`Meta::camera`). + pub camera: String, + /// Preset id; `None` = the LightCraft default for this camera. + pub preset: Option, +} + +impl ImportDefaults { + /// The preset id that applies to a photo of this kind and camera, if any. + pub fn preset_for(&self, raw: bool, camera: &str) -> Option<&str> { + if !raw { + return self.other_preset.as_deref(); + } + if self.per_camera + && let Some(c) = self.cameras.iter().find(|c| !camera.is_empty() && c.camera.eq_ignore_ascii_case(camera)) + { + return c.preset.as_deref(); + } + self.raw_preset.as_deref() + } +} + +/// Give a freshly imported photo its default settings: the camera defaults, then the matching +/// default preset ([`ImportDefaults::preset_for`]), remembered as the photo's import look. +pub fn apply_import_defaults(s: &Session, p: &mut Photo) { + let base = p.camera_defaults(); + let raw = p.kind == lightcraft_catalog::MediaKind::Raw; + let preset = s.import_defaults.preset_for(raw, &p.meta.camera).and_then(|id| s.presets.iter().find(|x| x.id == id)); + match preset { + Some(pr) => { + let look = std::sync::Arc::new(pr.apply(&base, 1.0)); + p.develop = look.clone(); + p.import_look = (*look != base).then_some(look); + } + None => { + p.develop = std::sync::Arc::new(base); + p.import_look = None; + } + } } pub fn is_supported(path: &Path) -> bool { @@ -237,7 +297,7 @@ pub fn import(s: &mut Session, paths: &[String], mode: ImportMode) -> crate::Res p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.develop = std::sync::Arc::new(import_defaults(&p)); + apply_import_defaults(s, &mut p); let raw = p.kind == lightcraft_catalog::MediaKind::Raw; let packet = crate::sidecar::find_sidecar(&path, s.xmp.naming) .and_then(|f| std::fs::read_to_string(f).ok()) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index afe665af..6edde4b9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -102,6 +102,10 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option, + /// Develop defaults applied on import (persisted in prefs.json). + pub import_defaults: import::ImportDefaults, + /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). + pub cache_mb: u32, } impl Default for Session { @@ -137,6 +141,8 @@ impl Session { library: None, xmp: sidecar::XmpPrefs::default(), last_export: None, + import_defaults: import::ImportDefaults::default(), + cache_mb: 0, } } @@ -353,4 +359,6 @@ mod tests_merge; #[cfg(test)] mod tests_organize; #[cfg(test)] +mod tests_prefs; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 8b8a9982..3ec3b58e 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -5,7 +5,7 @@ //! LightCraft Library/ //! catalog.snap catalog.log (lightcraft-catalog journal) //! presets.json view.json (user presets + favourites; last source/filter/sort/selection) -//! prefs.json (library preferences: XMP sidecars) +//! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) //! thumbs/ (rendered thumbnail cache, safe to delete) //! Originals/ (photos imported with "copy into library") //! ``` @@ -99,11 +99,16 @@ impl Library { } #[derive(Default, Serialize, Deserialize)] -#[serde(default)] +#[serde(default, rename_all = "camelCase")] struct PrefsFile { xmp: crate::sidecar::XmpPrefs, /// Parameters of the last export (for Export with Previous). + #[serde(alias = "last_export")] last_export: Option, + /// Develop defaults for imported photos. + import: crate::import::ImportDefaults, + /// Thumbnail disk cache budget (MB, 0 = default). + cache_mb: u32, } fn presets_json(s: &Session) -> String { @@ -165,6 +170,8 @@ impl Session { let prefs = read_json::(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.import_defaults = prefs.import; + self.cache_mb = prefs.cache_mb; // view state if let Some(v) = read_json::(files.as_mut(), "view.json") { self.source = v.source; @@ -180,8 +187,10 @@ impl Session { self.selection = Selection::single(*first); } let presets_written = presets_json(self); + // photo ids are per library: drop decoded sources of the previous one + self.media.clear_sources(); if on_disk { - self.media.attach_disk_cache(&dir.join("thumbs")); + self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); } let view_written = self.view_json(); self.library = Some(Library { dir, on_disk, journal, files, report, last_error: None, presets_written, view_written }); @@ -262,11 +271,33 @@ impl Session { /// Save the library preferences (no-op for in-memory sessions). pub fn save_prefs(&mut self) -> Result<()> { - let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone() }).unwrap_or_default(); + let v = serde_json::to_vec_pretty(&PrefsFile { + xmp: self.xmp, + last_export: self.last_export.clone(), + import: self.import_defaults.clone(), + cache_mb: self.cache_mb, + }) + .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; lib.files.write_atomic("prefs.json", &v).map_err(|e| EngineError::Other(format!("prefs: {e}"))) } + /// The thumbnail disk cache budget in bytes ([`Session::cache_mb`], else the default). + pub fn cache_bytes(&self) -> u64 { + if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 } + } + + /// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which + /// trims it to the new size. Saved with the library preferences. + pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> { + self.cache_mb = mb; + if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) { + let dir = lib.thumbs_dir(); + self.media.attach_disk_cache(&dir, self.cache_bytes()); + } + self.save_prefs() + } + /// Compact the log into a snapshot now. pub fn compact_library(&mut self) -> Result<()> { self.persist()?; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 946cbc27..3447a357 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -143,8 +143,15 @@ fn source_bytes(img: &Rgb32f) -> usize { impl MediaCache { /// Keep rendered thumbnails on disk in `dir` as well. - pub fn attach_disk_cache(&mut self, dir: &std::path::Path) { - self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, DISK_CACHE_BYTES)); + pub fn attach_disk_cache(&mut self, dir: &std::path::Path, disk_bytes: u64) { + self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes)); + } + + /// Forget every decoded source (photo ids changed meaning, e.g. another library was opened). + pub fn clear_sources(&mut self) { + self.thumbs = Lru::new(THUMB_SOURCE_BYTES.min(self.budget)); + self.previews.clear(); + self.full = None; } /// Bytes the caches may hold together. diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs new file mode 100644 index 00000000..2c6802b2 --- /dev/null +++ b/crates/engine/src/tests_prefs.rs @@ -0,0 +1,134 @@ +//! Library preferences: import defaults (raw / other / per camera) applied by import, and their +//! persistence in prefs.json. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::MediaKind; +use lightcraft_develop::Preset; +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-prefs-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn user_preset(id: &str, settings: serde_json::Value) -> Preset { + Preset { id: id.into(), name: id.into(), group: "User Presets".into(), settings, favorite: false, builtin: false } +} + +fn with_presets(s: &mut Session) { + s.presets.push(user_preset("user.rawlook", json!({"light": {"exposure": 0.5}, "effects": {"clarity": 20.0}}))); + s.presets.push(user_preset("user.canonlook", json!({"light": {"contrast": 35.0}}))); + s.presets.push(user_preset("user.jpeglook", json!({"color": {"vibrance": 15.0}}))); +} + +fn dng(dir: &Path, name: &str, make: &str, model: &str) { + let meta = lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + std::fs::write(dir.join(name), crate::tests_xmp::synthetic_dng_with(None, meta)).unwrap(); +} + +fn png(path: &Path) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i * 7) as u8, (i * 3) as u8, 90, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn photo_named<'a>(s: &'a Session, name: &str) -> &'a lightcraft_catalog::Photo { + s.catalog.photos().find(|p| p.file_name == name).unwrap_or_else(|| panic!("{name} not imported")) +} + +#[test] +fn raw_and_other_defaults_are_applied_on_import() { + let src = temp_dir("apply"); + dng(&src, "nikon.dng", "Nikon", "Z 9"); + dng(&src, "canon.dng", "Canon", "EOS R5"); + png(&src.join("plain.png")); + let mut s = Session::new().with_fs(); + with_presets(&mut s); + let r = s + .execute( + "library.preferences", + &json!({"import": {"rawPreset": "user.rawlook", "otherPreset": "user.jpeglook", "perCamera": true, + "cameras": [{"camera": "Canon EOS R5", "preset": "user.canonlook"}]}}), + ) + .unwrap(); + assert_eq!(r["import"]["rawPreset"], "user.rawlook", "{r}"); + assert!(s.execute("library.preferences", &json!({"import": {"rawPreset": "nope"}})).is_err(), "unknown presets are rejected"); + + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(s.catalog.len(), 3); + let nikon = photo_named(&s, "nikon.dng"); + assert_eq!(nikon.kind, MediaKind::Raw); + assert_eq!(nikon.meta.camera, "Nikon Z 9"); + assert_eq!((nikon.develop.light.exposure, nikon.develop.effects.clarity), (0.5, 20.0), "global raw default"); + assert_eq!(nikon.develop.detail.sharpen_amount, 40.0, "raw camera defaults stay underneath the preset"); + assert!(!nikon.is_edited(), "the default look counts as unedited"); + assert!(!crate::import::has_import_look(nikon), "a preset look isn't what the embedded preview shows"); + let canon = photo_named(&s, "canon.dng"); + assert_eq!((canon.develop.light.contrast, canon.develop.light.exposure), (35.0, 0.0), "per-camera default wins"); + let plain = photo_named(&s, "plain.png"); + assert_eq!((plain.develop.color.vibrance, plain.develop.light.exposure), (15.0, 0.0), "non-raw default"); + + // editing then Reset returns to the default look + let id = nikon.id; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.0})).unwrap(); + assert!(s.catalog.photo(id).unwrap().is_edited()); + s.execute("develop.reset", &json!({})).unwrap(); + let p = s.catalog.photo(id).unwrap(); + assert_eq!(p.develop.light.exposure, 0.5); + assert!(!p.is_edited()); + + // per-camera off: the Canon gets the global raw default; clearing it gives the plain defaults + let mut s2 = Session::new().with_fs(); + with_presets(&mut s2); + s2.execute("library.preferences", &json!({"camera": {"camera": "canon eos r5", "preset": "user.canonlook"}})).unwrap(); + s2.execute("library.import", &json!({"paths": [src.join("canon.dng").to_string_lossy()]})).unwrap(); + let c = s2.catalog.photos().next().unwrap(); + assert_eq!(c.develop.light.contrast, 0.0, "per-camera defaults are off unless enabled"); + assert!(c.import_look.is_none()); + let _ = std::fs::remove_dir_all(&src); +} + +#[test] +fn preferences_persist_with_the_library() { + let lib = temp_dir("lib"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + with_presets(&mut s); + s.persist().unwrap(); // presets.json + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute( + "library.preferences", + &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), + ) + .unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.xmp.auto_write); + assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook")); + assert!(s.import_defaults.per_camera); + assert_eq!(s.import_defaults.cameras.len(), 1); + assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook")); + assert_eq!(s.cache_mb, 512); + assert_eq!(s.cache_bytes(), 512 << 20); + let r = s.execute("library.preferences", &json!({})).unwrap(); + assert_eq!(r["cacheMb"], 512); + assert_eq!(r["persistent"], true); + // older prefs.json files (snake_case `last_export`, no import section) still load + std::fs::write(lib.join("prefs.json"), r#"{"xmp": {"autoWrite": false}, "last_export": {"format": "png"}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_export, Some(json!({"format": "png"}))); + assert_eq!(s.import_defaults, Default::default()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 2997d793..d919cfcf 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() { } fn synthetic_dng(xmp: &str) -> Vec { + synthetic_dng_with(Some(xmp), lightcraft_meta::Metadata::default()) +} + +/// A tiny Bayer DNG written by our own DNG writer (optional embedded XMP, camera metadata). +pub(crate) fn synthetic_dng_with(xmp: Option<&str>, metadata: lightcraft_meta::Metadata) -> Vec { use lightcraft_raw::*; let (w, h) = (32usize, 24usize); let cfa = Cfa::bayer("RGGB").unwrap(); @@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec { wb_multipliers: None, linearized: false, opcodes: OpcodeLists::default(), - metadata: lightcraft_meta::Metadata::default(), + metadata, }; - write_dng(&raw, &DngWriteOptions { xmp: Some(xmp.to_string()), ..Default::default() }).unwrap() + write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } #[test] diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index c7e3c05a..dfd630ee 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -463,6 +463,133 @@ mod tests { h.settle(SETTLE); } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through + /// the engine; the delete confirmation guards ⌫. + #[test] + fn settings_dialog_by_keyboard_and_clicks() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); + for tab in ["import", "performance", "interface", "general"] { + let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + assert_eq!(r["ok"], true, "{tab}: {r}"); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); + } + // General: confirm before delete, startup view + h.request("ui.clickWidget", json!({"id": "check:settings.confirmDelete"}), t); + assert!(h.app.ui.settings.confirm_delete); + h.request("ui.clickWidget", json!({"id": "button:settingsStartup-2"}), t); + 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); + 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); + // 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); + assert_eq!(h.app.session.cache_mb, 512); + h.request("ui.clickWidget", json!({"id": "button:settingsPreview-2"}), t); + assert_eq!(h.app.ui.settings.preview_edge, 3840); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-2"}), t); + assert_eq!(h.app.ui.settings.memory_mb, 1024); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), 1024 << 20, "applied through app.memoryBudget"); + h.request("ui.clickWidget", json!({"id": "button:settingsMemory-0"}), t); + h.step(); + assert_eq!(lightcraft_engine::memory::budget(), lightcraft_engine::memory::default_budget(), "back to automatic"); + // Import: per-camera defaults + h.request("ui.clickWidget", json!({"id": "button:settingsTab-import"}), t); + h.request("ui.clickWidget", json!({"id": "check:settings.perCamera"}), t); + assert!(h.app.session.import_defaults.per_camera); + let img = h.snapshot(SETTLE); + assert_eq!(img.size, [1300, 820]); + // Escape closes; ⌫ now asks first + h.request("ui.key", json!({"key": "escape"}), t); + assert_eq!(h.app.ui.dialog, None); + let active = h.app.session.active().unwrap(); + h.request("ui.key", json!({"key": "delete"}), t); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + assert!(!h.app.session.catalog.photo(active).unwrap().deleted); + let r = h.request("ui.dialog.confirm", json!({}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(active).unwrap().deleted); + // app settings survive a save/load round trip of the UI state (ui.json) + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::(&saved).unwrap().sanitized(); + assert_eq!(back.settings, h.app.ui.settings); + assert_eq!(back.view, crate::state::ViewMode::Detail, "startup view applied on load"); + h.settle(SETTLE); + } + + /// F: full-screen preview (arrows step, I cycles the info overlay, Esc exits); ⌘I cycles the + /// overlay in Detail; ⇧⌘F asks the host for window full screen. + #[test] + fn full_screen_preview_and_info_overlay_by_keyboard() { + use crate::state::InfoOverlay; + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + let vis: Vec = h.app.session.visible_cloned().iter().map(|p| p.0).collect(); + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[0]]}}), t); + h.request("ui.key", json!({"key": "f"}), t); + assert!(h.app.ui.fullscreen); + h.request("ui.key", json!({"key": "right"}), t); + assert_eq!(h.app.session.active().map(|p| p.0), Some(vis[1])); + let right = h.app.ui.right; + h.request("ui.key", json!({"key": "i"}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Basic); + assert_eq!(h.app.ui.right, right, "I doesn't open the Info panel in full screen"); + let img = h.snapshot(SETTLE); + // no chrome: the frame's corners are black + for (x, y) in [(2usize, 2usize), (997, 2), (2, 697), (997, 697)] { + let c = img.pixels[y * 1000 + x]; + assert!(c.r() < 8 && c.g() < 8 && c.b() < 8, "({x},{y}) = {c:?}"); + } + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:infoOverlay")); + h.request("ui.key", json!({"key": "escape"}), t); + assert!(!h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid, "Esc leaves full screen only, back to where it was entered"); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Exposure); + h.request("ui.key", json!({"key": "i", "cmd": true}), t); + assert_eq!(h.app.ui.info_overlay, InfoOverlay::Off); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(h.app.window_is_fullscreen); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), t); + assert!(!h.app.window_is_fullscreen); + h.settle(SETTLE); + } + + /// The Navigator appears when zoomed in; clicking it pans to that point. + #[test] + fn navigator_pans_the_zoomed_loupe() { + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail", "right": "none"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "hidden at Fit"); + h.request("engine.execute", json!({"command": "view.zoom100"}), t); + h.settle(SETTLE); + assert!(h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "navigator shown when zoomed"); + h.request("ui.clickWidget", json!({"id": "canvas:navigator", "fx": 0.1, "fy": 0.2}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.1).abs() < 0.03 && (v - 0.2).abs() < 0.03, "pan {:?}", h.app.ui.pan); + assert_eq!(h.app.ui.zoom, crate::state::Zoom::Percent(100), "the click didn't reach the loupe (which would zoom out)"); + h.settle(SETTLE); + let nav = h.app.widgets.iter().rev().find(|(w, _)| w == "canvas:navigator").map(|(_, r)| *r).unwrap(); + let to = nav.min + egui::vec2(nav.width() * 0.8, nav.height() * 0.7); + h.request("ui.dragWidget", json!({"id": "canvas:navigator", "fx": 0.5, "fy": 0.5, "toX": to.x, "toY": to.y}), t); + let (u, v) = h.app.ui.pan; + assert!((u - 0.8).abs() < 0.03 && (v - 0.7).abs() < 0.03, "drag pan {:?}", h.app.ui.pan); + h.request("engine.execute", json!({"command": "view.navigator"}), t); + h.settle(SETTLE); + assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off"); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c0ba22ec..3718fe82 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -32,6 +32,8 @@ pub type PickFiles = Box Vec>; pub type SaveFile = Box Option>; pub type WriteFn = Box Result<(), String>>; pub type PngEncode = Box Vec>; +/// A folder chooser (`None` = cancelled). +pub type PickFolder = Box Option>; /// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux). pub type RevealFn = Box Result<(), String>>; @@ -49,6 +51,8 @@ pub struct Services { pub png: Option, /// Show a file in the system file manager (desktop only). pub reveal: Option, + /// Choose a folder (Settings → General → Open Library…; desktop only). + pub pick_folder: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -97,6 +101,12 @@ pub struct LightcraftApp { /// The look the loupe shows while the pointer rests on a preset or profile (set by the /// panels each frame; nothing is committed, no history entry). pub hover_preview: Option, + /// The window is in full screen (as last reported by the host, or as last requested). + pub window_is_fullscreen: bool, + /// The GPU preference last applied (`app.gpu`), to apply Settings changes once. + gpu_applied: Option, + /// The memory budget setting last applied (MB, 0 = automatic). + memory_applied: Option, } impl LightcraftApp { @@ -128,6 +138,9 @@ impl LightcraftApp { loupe_shown: None, merge: merge::MergeState::default(), hover_preview: None, + window_is_fullscreen: false, + gpu_applied: None, + memory_applied: None, } } @@ -300,6 +313,7 @@ impl LightcraftApp { } self.last_time = now; self.drain_control(ctx); + self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); } @@ -321,6 +335,28 @@ impl LightcraftApp { } } + /// Apply settings that act outside the UI state: GPU rendering and window full screen. + fn apply_settings(&mut self, ctx: &egui::Context) { + if self.gpu_applied != Some(self.ui.settings.gpu) { + self.gpu_applied = Some(self.ui.settings.gpu); + let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu})); + } + let mb = self.ui.settings.memory_mb; + // automatic at startup: leave the engine's default alone + if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) { + let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb }; + let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); + } + self.memory_applied = Some(mb); + if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { + self.window_is_fullscreen = fs; + } + if let Some(on) = self.ui.window_fullscreen.take() { + ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on)); + self.window_is_fullscreen = on; + } + } + /// Inject synthetic events (pointer events one per frame; key sequences up to the release). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { @@ -346,6 +382,15 @@ impl LightcraftApp { let t0 = now_ms(); // panels set it again this frame while the pointer rests on a preset or profile self.hover_preview = None; + if self.ui.fullscreen { + // full-screen preview: the photo alone on black + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + panels::dialogs::show(self, &ctx); + panels::toast(self, &ctx); + self.widgets = widgets::take_registry(&ctx); + self.perf.frame_ms = now_ms() - t0; + return; + } // Order matters: earlier panels take the full edge (top bar spans the window; the tool strip, // right panels and left panel run to the bottom; the bottom bar sits between them). panels::topbar::show(self, ui); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index e5efb5fb..065cbb52 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -54,6 +54,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ &[ "file.addPhotos", "---", + "app.openLibrary", + "---", "dialog.newAlbum", "dialog.newFolder", "dialog.newSmartAlbum", @@ -70,7 +72,19 @@ const LAYOUT: &[(&str, &[&str])] = &[ ), ( "Edit", - &["edit.undo", "edit.redo", "---", "develop.copy", "dialog.copySettings", "develop.paste", "---", "library.selectAll", "library.selectNone"], + &[ + "edit.undo", + "edit.redo", + "---", + "develop.copy", + "dialog.copySettings", + "develop.paste", + "---", + "library.selectAll", + "library.selectNone", + "---", + "app.settings", + ], ), ( "View", @@ -84,6 +98,11 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.leftPanel", "view.filmstrip", "view.histogram", + "view.navigator", + "view.infoOverlay", + "---", + "view.fullScreenPreview", + "view.enterFullScreen", "---", "view.showOriginal", "view.beforeAfter", @@ -202,6 +221,9 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "view.navigator" => Some(u.navigator), + "view.fullScreenPreview" => Some(u.fullscreen), + "view.enterFullScreen" => Some(app.window_is_fullscreen), "panel.edit" => panel(RightPanel::Edit), "panel.profiles" => panel(RightPanel::Profiles), "panel.crop" => panel(RightPanel::Crop), @@ -402,6 +424,9 @@ pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result Option { if app.ui.dialog.is_some() { app.ui.dialog = None; + } else if app.ui.fullscreen { + app.ui.fullscreen = false; } else if !app.ui.tool.is_empty() { app.ui.tool.clear(); } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { @@ -137,6 +145,50 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.fullscreen = !app.ui.fullscreen; + if app.ui.fullscreen { + app.ui.zoom = Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + } + Ok(json!({"fullscreen": app.ui.fullscreen})) + } + "view.enterFullScreen" => { + // applied by the host's frame logic (it knows the window's current state) + let on = p.get("on").and_then(Value::as_bool); + app.ui.window_fullscreen = Some(on.unwrap_or(!app.window_is_fullscreen)); + Ok(json!({"windowFullscreen": app.ui.window_fullscreen})) + } + "view.infoOverlay" => { + app.ui.info_overlay = match p.get("mode").and_then(Value::as_str) { + Some(m) => match serde_json::from_value(json!(m)) { + Ok(v) => v, + Err(_) => return Some(Err(format!("unknown info overlay `{m}` (off|basic|exposure)"))), + }, + None => app.ui.info_overlay.next(), + }; + let label = match app.ui.info_overlay { + crate::state::InfoOverlay::Off => "Info Overlay Off", + crate::state::InfoOverlay::Basic => "Info Overlay: File", + crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", + }; + app.toast(&ctx, label); + Ok(json!({"infoOverlay": app.ui.info_overlay})) + } + "view.navigator" => { + app.ui.navigator = !app.ui.navigator; + Ok(json!({"navigator": app.ui.navigator})) + } + "app.settings" => { + let tab = p.get("tab").and_then(Value::as_str).unwrap_or("general"); + if !crate::panels::settings::TABS.iter().any(|(id, _)| *id == tab) { + return Some(Err(format!("unknown settings tab `{tab}` (general|import|performance|interface)"))); + } + app.ui.dialog = Some(Dialog::Settings { tab: tab.into() }); + Ok(Value::Null) + } + "app.openLibrary" => crate::panels::settings::open_library(app, p), "view.filmstrip" => { app.ui.filmstrip = !app.ui.filmstrip; Ok(Value::Null) @@ -460,6 +512,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { } "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), "view.compare" => app.session.catalog.len() > 1, + "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, + "app.openLibrary" => app.services.pick_folder.is_some(), "compare.swap" | "compare.makeSelect" => app.ui.view == ViewMode::Compare, s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(), _ => true, @@ -502,6 +556,20 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { v } +/// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead +/// of deleting; true when it did (the dialog's OK runs `photo.delete`). +pub fn confirm_delete(app: &mut LightcraftApp) -> bool { + if !app.ui.settings.confirm_delete { + return false; + } + let count = app.session.targets(&json!({})).len(); + if count == 0 { + return false; + } + app.ui.dialog = Some(Dialog::ConfirmDelete { count }); + true +} + /// Reveal the active photo's original in the system file manager. fn show_in_finder(app: &mut LightcraftApp) -> Result { let id = app.session.active().ok_or("no photo selected")?; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index f442b716..03d3c38d 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -119,10 +119,12 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let full = ui.max_rect(); - let film_h = if app.ui.filmstrip { t.film_h } else { 0.0 }; + let fullscreen = app.ui.fullscreen; + let show_film = app.ui.filmstrip && !fullscreen; + let film_h = if show_film { t.film_h } else { 0.0 }; let canvas = Rect::from_min_max(full.min, pos2(full.right(), full.bottom() - film_h)); app.canvas_rect = Some(canvas); - if app.ui.filmstrip { + if show_film { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { @@ -131,11 +133,20 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let d = (*photo.develop).clone(); - let crop_tool = app.ui.right == RightPanel::Crop; + // the full-screen preview shows the photo only: no tool overlays + let right = if fullscreen { RightPanel::None } else { app.ui.right }; + let crop_tool = right == RightPanel::Crop; let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &d, !crop_tool, photo.embedded_lens.as_ref()); let aspect = frame.aspect() as f32; let ppp = ui.ctx().pixels_per_point(); - let area = canvas.shrink(if crop_tool { 48.0 } else { 24.0 }); + let area = canvas.shrink(if fullscreen { + 0.0 + } else if crop_tool { + 48.0 + } else { + 24.0 + }); + let max_edge = app.ui.settings.preview_edge.clamp(512, 8192) as f32; let native = [photo.width.max(1) as usize, photo.height.max(1) as usize]; // two views (before, after): side by side or stacked let split = matches!(app.ui.before_after, BeforeAfter::SideBySide | BeforeAfter::TopBottom); @@ -163,7 +174,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); let scale = if interacting { 0.6 } else { 1.0 }; - let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(2560.0) as usize; + let want = (img_rect.width().max(img_rect.height()) * ppp * scale).min(max_edge) as usize; let (rw, rh) = if aspect >= 1.0 { (want, (want as f32 / aspect) as usize) } else { ((want as f32 * aspect) as usize, want) }; if let Some(job) = app.session.loupe_job(id, rw.max(8), rh.max(8), !crop_tool) { let job = if interacting { job.draft() } else { job }; @@ -193,7 +204,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let nf = Frame::with_lens(np.width.max(1) as usize, np.height.max(1) as usize, &np.develop, !crop_tool, np.embedded_lens.as_ref()); let na = nf.aspect() as f32; let nr = fit_rect(main_area, na, app.ui.zoom, [np.width.max(1) as usize, np.height.max(1) as usize], ppp, app.ui.pan); - let nw = (nr.width().max(nr.height()) * ppp).min(2560.0) as usize; + let nw = (nr.width().max(nr.height()) * ppp).min(max_edge) as usize; let (w, h) = if na >= 1.0 { (nw, (nw as f32 / na) as usize) } else { ((nw as f32 * na) as usize, nw) }; if let Some(job) = app.session.loupe_job(nid, w.max(8), h.max(8), !crop_tool) { app.renderer.prefetch(Slot::Prefetch(n as u8), job, PREFETCH_PRIORITY); @@ -272,22 +283,119 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } p.line_segment([pos2(img_rect.left(), mid), pos2(img_rect.right(), mid)], Stroke::new(1.5, Color32::WHITE)); } - if app.ui.show_clipping && !show_before { + if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); } register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); - match app.ui.right { + info_overlay(app, &p, canvas, &photo); + match right { RightPanel::Crop => crop_overlay(app, ui, &resp, &map, &frame, &d, id), RightPanel::Masking => mask_overlay(app, ui, &resp, &map, &d), RightPanel::Remove => remove_overlay(app, ui, &resp, &map, &d), RightPanel::RedEye => eye_overlay(app, ui, &resp, &map, &d), _ => general_interaction(app, ui, &resp, &map, img_rect, canvas, native, aspect), } + // drawn and hit-tested above the loupe and its tools: clicks on it pan + navigator(app, ui, canvas, img_rect, id); resp.context_menu(|ui| super::grid::context_menu(app, ui, id)); } +/// The info overlay at the canvas' top left (`view.infoOverlay`): file name with the capture date +/// and size, or with the camera and exposure. +fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &lightcraft_catalog::Photo) { + use crate::state::InfoOverlay; + let lines: Vec = match app.ui.info_overlay { + InfoOverlay::Off => return, + InfoOverlay::Basic => { + let date = photo.captured.as_deref().unwrap_or(&photo.imported); + vec![photo.file_name.clone(), format!("{} · {} × {}", pretty_date(date), photo.width, photo.height)] + } + InfoOverlay::Exposure => { + let m = &photo.meta; + let mut exp = Vec::new(); + if !m.shutter.is_empty() { + exp.push(format!("{} s", m.shutter)); + } + if let Some(a) = m.aperture { + exp.push(format!("f/{a:.1}")); + } + if let Some(i) = m.iso { + exp.push(format!("ISO {i}")); + } + if let Some(f) = m.focal_mm { + exp.push(format!("{f:.0} mm")); + } + let camera = [m.camera.as_str(), m.lens.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" · "); + let mut v = vec![photo.file_name.clone()]; + v.push(if exp.is_empty() { "No exposure information".into() } else { exp.join(" ") }); + if !camera.is_empty() { + v.push(camera); + } + v + } + }; + let t = Tokens::get(p.ctx()); + let mut y = canvas.top() + 12.0; + for (i, l) in lines.iter().enumerate() { + let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) }; + let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE); + let at = pos2(canvas.left() + 14.0, y); + // a soft shadow keeps the text readable on bright photos + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g.clone(), Color32::WHITE); + y += g.size().y + 3.0; + } + register(p.ctx(), "canvas:infoOverlay", Rect::from_min_max(canvas.min, pos2(canvas.left() + 320.0, y))); +} + +/// `2026-09-30T12:00:00` → `2026-09-30 12:00`. +fn pretty_date(iso: &str) -> String { + let d = iso.replacen('T', " ", 1); + d.get(..16).map(str::to_string).unwrap_or(d) +} + +/// Size of the Navigator mini map (points). +const NAV_W: f32 = 180.0; + +/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the +/// visible region outlined; click or drag on it to pan. +fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) { + let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0; + if !app.ui.navigator || !zoomed || app.ui.fullscreen { + return; + } + let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)); + let aspect = img.width() / img.height().max(1.0); + let (w, h) = if aspect >= 1.0 { (NAV_W, NAV_W / aspect) } else { (NAV_W * aspect, NAV_W) }; + let frame = Rect::from_min_size(pos2(canvas.right() - w - 16.0, canvas.bottom() - h - 16.0), vec2(w, h)); + let p = ui.painter_at(canvas); + p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190)); + match tex { + Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE), + None => p.rect_filled(frame, 0.0, Color32::from_gray(40)), + }; + // the visible part of the photo, in normalized image coordinates + let vis = canvas.intersect(img); + let n = |q: Pos2| pos2((q.x - img.left()) / img.width(), (q.y - img.top()) / img.height()); + let (a, b) = (n(vis.min), n(vis.max)); + let to_nav = |q: Pos2| pos2(frame.left() + q.x * frame.width(), frame.top() + q.y * frame.height()); + let view = Rect::from_min_max(to_nav(a), to_nav(b)); + p.rect_stroke(view, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + register(ui.ctx(), "canvas:navigator", frame); + let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); + if (resp.clicked() || resp.dragged()) + && let Some(q) = resp.interact_pointer_pos() + { + // centre the view on the point under the pointer + let u = ((q.x - frame.left()) / frame.width()).clamp(0.0, 1.0); + let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0); + app.ui.pan = (u, v); + } +} + /// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes. const PREFETCH_PRIORITY: u32 = 4; @@ -335,6 +443,9 @@ fn targeted_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respon /// The diagnostic overlay the loupe shows (Point Color's visualized range). fn view_overlay(app: &LightcraftApp, d: &DevelopSettings) -> lightcraft_pipeline::Overlay { use lightcraft_pipeline::Overlay; + if app.ui.fullscreen { + return Overlay::None; + } if app.ui.right == RightPanel::Remove && app.ui.visualize_spots { return Overlay::Spots(app.ui.spots_threshold.clamp(0.0, 100.0).round() as u8); } @@ -909,7 +1020,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { } else if resp.hovered() { p.rect_filled(cr, 0.0, t.cell_selected.gamma_multiply(0.6)); } - if let Some(ph) = app.session.catalog.photo(*id) { + let names = app.ui.settings.film_names; + if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() }; p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim); @@ -925,6 +1037,11 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); } + if app.ui.settings.film_badges + && let Some(ph) = app.session.catalog.photo(*id) + { + film_badges(p, &t, fr, ph); + } if let Some(st) = app.session.catalog.stack_of(*id) { let text = if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; @@ -950,6 +1067,32 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { }); } +/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface). +fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) { + use crate::icons::{Icon, paint}; + use lightcraft_catalog::Flag; + let edited = ph.is_edited(); + if ph.rating == 0 && ph.flag == Flag::None && !edited { + return; + } + let bar = Rect::from_min_max(pos2(fr.left(), fr.bottom() - 15.0), fr.right_bottom()); + p.rect_filled(bar, 0.0, Color32::from_black_alpha(130)); + let y = bar.center().y; + let mut x = bar.left() + 3.0; + for i in 0..ph.rating { + paint(p, Rect::from_min_size(pos2(x + i as f32 * 9.0, y - 4.0), vec2(8.0, 8.0)), Icon::StarFilled, t.star); + } + x += ph.rating as f32 * 9.0 + 2.0; + match ph.flag { + Flag::Pick => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagPick, t.pick), + Flag::Reject => paint(p, Rect::from_min_size(pos2(x, y - 5.0), vec2(10.0, 10.0)), Icon::FlagReject, t.reject), + Flag::None => {} + } + if edited { + paint(p, Rect::from_min_size(pos2(bar.right() - 13.0, y - 5.0), vec2(10.0, 10.0)), Icon::Sliders, t.text_label); + } +} + /// Straighten tool: drag along a horizon (or a vertical) to set the crop angle; double-click = Auto. /// The image is shown unrotated in the crop view, so the line's on-screen angle is its image angle. fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 498a81e1..8f5bb92d 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -28,6 +28,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", Dialog::Merge { opts } => opts.title(), + Dialog::Settings { .. } => "Settings", + Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", }; @@ -205,6 +207,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { field(ui, "Folder", |ui| ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY))); } Dialog::Merge { opts } => crate::merge::body(app, ui, opts), + Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), + Dialog::ConfirmDelete { count } => { + let what = if *count == 1 { "this photo".to_string() } else { format!("these {count} photos") }; + ui.label(format!("Move {what} to Recently Deleted?")); + ui.label(egui::RichText::new("They can be restored from Recently Deleted until it is emptied.").color(t.text_dim)); + } Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); @@ -247,12 +255,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.horizontal(|ui| { - let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts); + let informational = matches!(dlg, Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. }); if !informational && ui.button("Cancel").clicked() { close = true; } let ok = match dlg { Dialog::Merge { .. } => "Merge", + Dialog::ConfirmDelete { .. } => "Delete", _ if informational => "Close", _ => "OK", }; @@ -317,7 +326,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), - Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), + Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9886c8f1..c949eda6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -203,7 +203,11 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square p.rect_filled(br, 2.0, Color32::from_gray(26)); p.galley(br.min + vec2(4.0, 1.5), g, t.text_label); } - let show_badges = resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None; + let show_badges = match app.ui.settings.grid_badges { + crate::state::GridBadges::Auto => resp.hovered() || selected || photo.rating > 0 || photo.flag != Flag::None, + crate::state::GridBadges::Always => true, + crate::state::GridBadges::Never => false, + }; if show_badges { let bar = Rect::from_min_max(pos2(img_rect.left(), img_rect.bottom() - 24.0), img_rect.right_bottom()); if resp.hovered() || selected { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 67787028..ee238621 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod masking; pub mod presets; pub mod profiles; pub mod right; +pub mod settings; pub mod strip; pub mod topbar; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs new file mode 100644 index 00000000..fad8ac51 --- /dev/null +++ b/crates/ui-egui/src/panels/settings.rs @@ -0,0 +1,337 @@ +//! The Settings dialog (⌘,): General, Import, Performance, Interface. +//! +//! Changes apply immediately (no OK/Cancel). Where they are stored: +//! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, +//! preview size, filmstrip/grid badges, last library) and Auto Advance live in the UI state, +//! saved by the host in its config folder (`ui.json`); +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the +//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, +//! so they travel with the library. + +use egui::RichText; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::{GridBadges, PREVIEW_EDGES, StartupView}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// (id, label) of the tabs, in order. +pub const TABS: &[(&str, &str)] = &[("general", "General"), ("import", "Import"), ("performance", "Performance"), ("interface", "Interface")]; + +/// Thumbnail cache sizes offered (MB). +const CACHE_SIZES: [u32; 5] = [512, 1024, 2048, 4096, 8192]; + +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.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() { + *tab = id.to_string(); + } + } + }); + ui.separator(); + ui.add_space(2.0); + match tab.as_str() { + "import" => import_tab(app, ui, &t), + "performance" => performance_tab(app, ui, &t), + "interface" => interface_tab(app, ui, &t), + _ => general_tab(app, ui, &t), + } +} + +fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(4.0); + ui.label(RichText::new(text).font(t.semibold(12.5)).color(t.text)); +} + +fn row(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(LABEL_W); + ui.label(RichText::new(label).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn hint(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.label(RichText::new(text).size(11.0).color(t.text_dim)); +} + +/// A checkbox addressable as `check:{id}`; true when toggled. +fn check(ui: &mut egui::Ui, id: &str, value: &mut bool, label: &str) -> bool { + let r = ui.checkbox(value, label); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed() +} + +/// Mutually exclusive buttons (`button:{id}-{index}`). +fn choices(ui: &mut egui::Ui, id: &str, options: &[(V, &str)], value: &mut V) -> bool { + let mut changed = false; + ui.spacing_mut().item_spacing.x = 4.0; + for (i, (v, l)) in options.iter().enumerate() { + if crate::widgets::text_button(ui, &format!("{id}-{i}"), l, *value == *v).clicked() && *value != *v { + *value = *v; + changed = true; + } + } + changed +} + +// ------------------------------------------------------------------------------------- General + +fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Library"); + let location = match &app.session.library { + Some(l) => l.dir.display().to_string(), + None => "In memory — nothing is saved".to_string(), + }; + row(ui, t, "Location", |ui| { + ui.label(RichText::new(location).color(t.text)); + }); + row(ui, t, "", |ui| { + let can = app.services.pick_folder.is_some(); + let r = ui.add_enabled(can, egui::Button::new("Open Library…")); + register(ui.ctx(), "button:settingsOpenLibrary", r.rect); + if r.clicked() { + let _ = app.run("app.openLibrary", json!({})); + } + if !can { + hint(ui, t, "not available here"); + } + }); + heading(ui, t, "Startup"); + row(ui, t, "Open in", |ui| { + choices( + ui, + "settingsStartup", + &[(StartupView::Last, "Last view"), (StartupView::Grid, "Photo Grid"), (StartupView::Detail, "Detail")], + &mut app.ui.settings.startup_view, + ); + }); + heading(ui, t, "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"); +} + +// -------------------------------------------------------------------------------------- Import + +/// A preset picker: `None` = `none_label`. Returns the new choice when it changed. +fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Option<&str>, none_label: &str) -> Option> { + let name = |pid: &str| app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| format!("{pid} (missing)")); + let text = current.map(name).unwrap_or_else(|| none_label.to_string()); + let mut out = None; + let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + if ui.selectable_label(current.is_none(), none_label).clicked() { + out = Some(None); + } + let mut group = ""; + for p in &app.session.presets { + if p.group != group { + group = &p.group; + ui.label(RichText::new(group).size(10.5).weak()); + } + if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() { + out = Some(Some(p.id.clone())); + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + out.filter(|v| v.as_deref() != current) +} + +/// Cameras of the raws in the library plus those with a stored default, sorted. +fn cameras(app: &LightcraftApp) -> Vec { + let mut v: Vec = app + .session + .catalog + .photos() + .filter(|p| p.kind == lightcraft_catalog::MediaKind::Raw && !p.meta.camera.is_empty()) + .map(|p| p.meta.camera.clone()) + .chain(app.session.import_defaults.cameras.iter().map(|c| c.camera.clone())) + .collect(); + v.sort_by_key(|c| c.to_lowercase()); + v.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + v +} + +fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let d = app.session.import_defaults.clone(); + heading(ui, t, "Raw defaults"); + hint(ui, t, "Settings new raw photos start from. Changing them doesn't touch photos already in the library."); + row(ui, t, "Raw photos", |ui| { + if let Some(v) = preset_combo(app, ui, "settingsRawPreset", d.raw_preset.as_deref(), "LightCraft Default") { + let _ = app.run("library.preferences", json!({"import": {"rawPreset": v}})); + } + }); + let mut per = d.per_camera; + row(ui, t, "", |ui| { + if check(ui, "settings.perCamera", &mut per, "Use camera-specific defaults") { + let _ = app.run("library.preferences", json!({"import": {"perCamera": per}})); + } + }); + if per { + let cams = cameras(app); + if cams.is_empty() { + hint(ui, t, "No raw photos yet: cameras appear here once their photos are in the library."); + } + egui::ScrollArea::vertical().max_height(150.0).id_salt("settingsCameras").show(ui, |ui| { + for (i, cam) in cams.iter().enumerate() { + let entry = d.cameras.iter().find(|c| c.camera.eq_ignore_ascii_case(cam)); + row(ui, t, cam, |ui| { + // "Raw default" = no entry; otherwise the entry's preset (None = LightCraft Default) + const RAW_DEFAULT: &str = "\u{1}raw"; + let current = match entry { + None => Some(RAW_DEFAULT), + Some(e) => e.preset.as_deref(), + }; + let mut pick = None; + let label = match current { + Some(RAW_DEFAULT) => "Same as raw default".to_string(), + None => "LightCraft Default".to_string(), + Some(pid) => app.session.presets.iter().find(|p| p.id == pid).map(|p| p.name.clone()).unwrap_or_else(|| pid.to_string()), + }; + let id = format!("settingsCamera-{i}"); + let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + if ui.selectable_label(current == Some(RAW_DEFAULT), "Same as raw default").clicked() { + pick = Some(json!({"camera": cam, "remove": true})); + } + if ui.selectable_label(current.is_none(), "LightCraft Default").clicked() { + pick = Some(json!({"camera": cam, "preset": null})); + } + for p in &app.session.presets { + if ui.selectable_label(current == Some(p.id.as_str()), &p.name).clicked() { + pick = Some(json!({"camera": cam, "preset": p.id})); + } + } + }); + register(ui.ctx(), format!("combo:{id}"), r.response.rect); + if let Some(c) = pick { + let _ = app.run("library.preferences", json!({"camera": c})); + } + }); + } + }); + } + heading(ui, t, "Other images (JPEG, PNG, TIFF, HEIC…)"); + row(ui, t, "Non-raw photos", |ui| { + if let Some(v) = preset_combo(app, ui, "settingsOtherPreset", d.other_preset.as_deref(), "None") { + let _ = app.run("library.preferences", json!({"import": {"otherPreset": v}})); + } + }); + heading(ui, t, "XMP sidecars"); + let mut xmp = app.session.xmp; + if check(ui, "settings.autoWriteXmp", &mut xmp.auto_write, "Automatically write changes into XMP sidecars") { + let _ = app.run("library.xmpPreferences", json!({"autoWrite": xmp.auto_write})); + } + row(ui, t, "Sidecar names", |ui| { + use lightcraft_engine::sidecar::SidecarNaming as N; + let mut n = xmp.naming; + if choices(ui, "settingsXmpNaming", &[(N::Stem, "IMG_1.xmp"), (N::Full, "IMG_1.CR3.xmp")], &mut n) { + let naming = if n == N::Full { "full" } else { "stem" }; + let _ = app.run("library.xmpPreferences", json!({"naming": naming})); + } + }); + if app.session.library.is_none() { + hint(ui, t, "In-memory session: these settings last until LightCraft quits."); + } +} + +// --------------------------------------------------------------------------------- Performance + +fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::gpu; + heading(ui, t, "Rendering"); + check(ui, "settings.gpu", &mut app.ui.settings.gpu, "Use the GPU for rendering"); + let status = if !gpu::available() { + "No usable GPU found: rendering on the CPU".to_string() + } else { + format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) + }; + hint(ui, t, &status); + row(ui, t, "Preview size", |ui| { + let opts: Vec<(u32, String)> = PREVIEW_EDGES.iter().map(|e| (*e, format!("{e} px"))).collect(); + let opts: Vec<(u32, &str)> = opts.iter().map(|(e, l)| (*e, l.as_str())).collect(); + choices(ui, "settingsPreview", &opts, &mut app.ui.settings.preview_edge); + }); + hint(ui, t, "Largest long edge the Detail view renders at; larger is sharper on big displays but slower."); + row(ui, t, "Memory for caches", |ui| { + let auto = format!("Automatic ({} MB)", lightcraft_engine::memory::default_budget() >> 20); + let opts = [(0u32, auto.as_str()), (512, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB")]; + choices(ui, "settingsMemory", &opts, &mut app.ui.settings.memory_mb); + }); + heading(ui, t, "Thumbnail cache"); + let cur = (app.session.cache_bytes() >> 20) as u32; + row(ui, t, "Size limit", |ui| { + let opts = [(512u32, "512 MB"), (1024, "1 GB"), (2048, "2 GB"), (4096, "4 GB"), (8192, "8 GB")]; + let mut v = if CACHE_SIZES.contains(&cur) { cur } else { 2048 }; + if choices(ui, "settingsCache", &opts, &mut v) { + let _ = app.run("library.preferences", json!({"cacheMb": v})); + } + }); + let used = app.session.media.rendered.disk().map(|d| d.size()); + row(ui, t, "In use", |ui| { + ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + let r = ui.button("Clear Cache"); + register(ui.ctx(), "button:settingsClearCache", r.rect); + if r.clicked() { + let _ = app.run("library.clearPreviews", json!({})); + } + }); +} + +// ---------------------------------------------------------------------------------- Interface + +fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "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"); + heading(ui, t, "Grid"); + row(ui, t, "Ratings & flags", |ui| { + choices( + ui, + "settingsGridBadges", + &[(GridBadges::Auto, "When rated or hovered"), (GridBadges::Always, "Always"), (GridBadges::Never, "Never")], + &mut app.ui.settings.grid_badges, + ); + }); + check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, "Detail"); + check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); + row(ui, t, "Info overlay", |ui| { + use crate::state::InfoOverlay as I; + choices(ui, "settingsInfo", &[(I::Off, "Off"), (I::Basic, "File & date"), (I::Exposure, "Exposure")], &mut app.ui.info_overlay); + }); +} + +// ------------------------------------------------------------------------------- Open Library… + +/// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, +/// or a folder chosen in a dialog. Remembered as the library to open at launch. +pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => x.to_string(), + None => match app.services.pick_folder.as_mut() { + Some(pick) => match pick() { + Some(x) => x, + None => return Ok(Value::Null), + }, + None => return Err("no folder dialog on this platform".into()), + }, + }; + app.session.close_library().map_err(|e| e.to_string())?; + app.session.open_library(&path, false).map_err(|e| e.to_string())?; + app.renderer.forget_all(); + app.ui.compare = None; + app.ui.settings.library_path = path.clone(); + Ok(json!({"path": path, "photos": app.session.catalog.len()})) +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 5aceea13..192208ab 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -209,6 +209,16 @@ impl Renderer { } /// Is a request for `slot` queued or running? + /// Drop every texture and cached stage (another library was opened: photo ids changed meaning). + pub fn forget_all(&mut self) { + self.textures.clear(); + self.stages.clear(); + self.quick_tried.clear(); + self.prefetched.clear(); + self.pending.clear(); + self.queue.clear(); + } + pub fn is_pending(&self, slot: Slot) -> bool { self.pending.contains_key(&slot) } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 01f91fae..83a2bf78 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,6 +41,7 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { "[" => Some(Key::OpenBracket), "]" => Some(Key::CloseBracket), "'" => Some(Key::Quote), + "," => Some(Key::Comma), _ => None, }) } @@ -156,6 +157,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { _ => app.toast(ctx, "Unflagged"), } } else { + // in the full-screen preview (no panels) I cycles the info overlay instead + if f == "panel.info" && app.ui.fullscreen { + let _ = app.run("view.infoOverlay", json!({})); + continue; + } // Delete acts on what's being edited: the active mask in the Masking panel; never the // photo while retouching (spots are removed from their own panel). if f == "photo.delete" { @@ -170,6 +176,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { R::Remove | R::RedEye => continue, _ => {} } + if crate::menus::confirm_delete(app) { + continue; + } } // X is both reject (library) and swap crop aspect (crop tool) if f == "photo.reject" && app.ui.right == crate::state::RightPanel::Crop { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index c968c9a4..006c1aa2 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -77,6 +77,94 @@ pub enum CropOverlay { None, } +/// Which view the app opens in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StartupView { + /// Whatever was showing at quit. + #[default] + Last, + Grid, + Detail, +} + +/// When grid cells show their rating / flag / edited badges. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GridBadges { + /// On hover, on the selection, and on rated or flagged photos. + #[default] + Auto, + Always, + Never, +} + +/// The loupe's info overlay (Cmd+I cycles; I in the full-screen preview). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum InfoOverlay { + #[default] + Off, + /// File name, capture date and dimensions. + Basic, + /// File name, camera, lens and exposure (shutter, aperture, ISO, focal length). + Exposure, +} + +impl InfoOverlay { + pub fn next(self) -> InfoOverlay { + match self { + InfoOverlay::Off => InfoOverlay::Basic, + InfoOverlay::Basic => InfoOverlay::Exposure, + InfoOverlay::Exposure => InfoOverlay::Off, + } + } +} + +/// 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 +/// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct AppSettings { + /// Library opened at launch when no `--library` is given (empty = the default location). + pub library_path: String, + pub startup_view: StartupView, + /// Ask before moving photos to Recently Deleted (keyboard and menu). + pub confirm_delete: bool, + /// GPU rendering allowed (`app.gpu`). + pub gpu: bool, + /// Largest long edge (pixels) the loupe renders at. + pub preview_edge: u32, + /// Memory the caches may hold together, in MB (0 = automatic; `app.memoryBudget`). + pub memory_mb: u32, + /// Filmstrip: file names above the thumbnails. + pub film_names: bool, + /// Filmstrip: rating / flag / edited badges on the thumbnails. + pub film_badges: bool, + /// Grid: when to show the rating / flag / edited badges. + pub grid_badges: GridBadges, +} + +impl Default for AppSettings { + fn default() -> Self { + AppSettings { + library_path: String::new(), + startup_view: StartupView::Last, + confirm_delete: false, + gpu: true, + preview_edge: 2560, + memory_mb: 0, + film_names: true, + film_badges: true, + grid_badges: GridBadges::Auto, + } + } +} + +/// Preview sizes offered in Settings → Performance. +pub const PREVIEW_EDGES: [u32; 4] = [1600, 2560, 3840, 5120]; + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { @@ -132,6 +220,17 @@ pub struct UiState { pub filter_bar: bool, /// Culling: after a rating, flag or colour-label key, move to the next photo. pub auto_advance: bool, + /// Full-screen preview (F): the photo alone on black, no chrome. + pub fullscreen: bool, + /// Info overlay on the loupe. + pub info_overlay: InfoOverlay, + /// Navigator mini map in the loupe while zoomed in. + pub navigator: bool, + /// App preferences (Settings dialog). + pub settings: AppSettings, + /// Window full screen requested (⇧⌘F); the host applies it (`None` = no change pending). + #[serde(skip)] + pub window_fullscreen: Option, /// Compare view: (select, candidate) photo ids. #[serde(skip)] pub compare: Option<(u64, u64)>, @@ -197,6 +296,14 @@ pub enum Dialog { Merge { opts: crate::merge::MergeDialog, }, + /// Settings (preferences): `tab` = general | import | performance | interface. + Settings { + tab: String, + }, + /// Confirm moving photos to Recently Deleted. + ConfirmDelete { + count: usize, + }, About, Shortcuts, } @@ -239,6 +346,11 @@ impl Default for UiState { visualize_spots: false, spots_threshold: 50.0, auto_advance: false, + fullscreen: false, + info_overlay: InfoOverlay::Off, + navigator: true, + settings: AppSettings::default(), + window_fullscreen: None, filter_bar: false, compare: None, toast: None, @@ -277,6 +389,15 @@ impl UiState { self.thumb_size = self.thumb_size.clamp(90.0, 480.0); self.brush_size = self.brush_size.clamp(0.002, 0.5); self.dialog = None; + self.fullscreen = false; + if !crate::state::PREVIEW_EDGES.contains(&self.settings.preview_edge) { + self.settings.preview_edge = AppSettings::default().preview_edge; + } + match self.settings.startup_view { + StartupView::Last => {} + StartupView::Grid => self.view = ViewMode::PhotoGrid, + StartupView::Detail => self.view = ViewMode::Detail, + } self } } diff --git a/docs/parity.md b/docs/parity.md index f3473fbb..bd7bfb0d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -21,9 +21,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | Section | ✅ | 🟡 | ⬜ | 🚫 | P0 done | P1 done | |---|---:|---:|---:|---:|---:|---:| -| A. Import (IMP) | 2 | 4 | 7 | 1 | 1/4 (25%) | 1/4 (25%) | +| A. Import (IMP) | 3 | 4 | 6 | 1 | 1/4 (25%) | 2/4 (50%) | | B. Library management (LIB) | 12 | 4 | 9 | 2 | 7/9 (78%) | 4/9 (44%) | -| C. Views & navigation (VIEW) | 9 | 2 | 6 | 0 | 7/9 (78%) | 1/4 (25%) | +| C. Views & navigation (VIEW) | 12 | 2 | 3 | 0 | 7/9 (78%) | 4/4 (100%) | | D. Search & filter (FILT) | 7 | 2 | 4 | 0 | 4/4 (100%) | 3/4 (75%) | | E. Metadata (META) | 2 | 2 | 2 | 0 | 2/2 (100%) | 0/2 (0%) | | F. Edit panel — global adjustments (EDIT) | 36 | 2 | 10 | 1 | 28/28 (100%) | 8/14 (57%) | @@ -42,13 +42,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 6 | 6 | 6 | 0 | 3/7 (43%) | 3/7 (43%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) | +| V. Preferences (PREF) | 4 | 1 | 3 | 3 | 1/1 (100%) | 3/4 (75%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 0 | 1 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 10 | 3 | 4 | 1 | 7/8 (88%) | 3/5 (60%) | -| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) | -| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) | +| Y. Menus | 50 | 18 | 17 | 8 | 38/47 (81%) | 10/23 (43%) | +| Z. Keyboard shortcuts (desktop) | 49 | 16 | 15 | 1 | 40/52 (77%) | 9/23 (39%) | | Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) | -| **Total** | 215 | 101 | 184 | 37 | 156/198 (79%) | 50/144 (35%) | +| **Total** | 230 | 99 | 171 | 37 | 159/198 (80%) | 62/144 (43%) | ## Top gaps @@ -117,7 +117,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-DEVICE | Import from camera/card | P1 | ⬜ | | no device detection | | LR-IMP-AUTO | Watched-folder auto import | P2 | ⬜ | | | | LR-IMP-PRESET | Preset on import | P2 | ⬜ | | | -| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ⬜ | `crates/engine/src/import.rs` | raws get embedded lens corrections on import; no user raw-default setting | +| LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | 🟡 | `cmd:file.importPresets`, `cmd:preset.import` | presets (.lcpreset, XMP `crs:`) only; no profile import; Adobe profile formats are deliberately unsupported | | LR-IMP-LOCAL | Work on files in place | P0 | 🟡 | `cmd:library.import` (mode add), `crates/engine/src/sidecar.rs`, `cmd:library.toggleAutoWriteXmp` | files referenced in place with XMP sidecars; no browse-a-folder-without-adding view | @@ -166,14 +166,14 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | -| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | | +| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | | LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | | LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar | -| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ⬜ | | | +| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | | LR-VIEW-BEFOREAFTER | Before / after | P0 | 🟡 | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | all four layouts; "before" is always the original (no before-state from history) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `crates/ui-egui/src/panels/compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; no zoom-link toggle | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | -| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ⬜ | | | +| LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | | LR-VIEW-SLIDESHOW | Slideshow | P2 | ⬜ | | | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ⬜ | | | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | @@ -458,11 +458,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ⬜ | `cmd:library.xmpPreferences` | no settings dialog; only XMP preferences (by command) | -| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | 🟡 | `cmd:library.clearPreviews`, `cmd:library.compact` | bounded thumbnail cache; no UI for size/location | +| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs` | General / Import / Performance / Interface tabs; app settings in ui.json, library settings in prefs.json | +| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General | | LR-PREF-ACCOUNT | Account | OOS | 🚫 | | | -| LR-PREF-INTERFACE | Interface options | P1 | ⬜ | | | -| LR-PREF-PERFORMANCE | GPU / performance | P1 | 🟡 | `cmd:app.gpu` | by command only | +| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay | +| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (1600–5120 px), memory budget, thumbnail cache size in Settings | | LR-PREF-PEOPLE | Face recognition | P2 | ⬜ | | | | LR-PREF-WATERMARK | Watermark settings | P1 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` | per export in the dialog; not saved as a preference | | LR-PREF-SHORTCUTS | Shortcut customisation | — | 🚫 | | not customisable in the reference app either; a keymap editor would be an extra | @@ -511,7 +511,7 @@ visible menu bar yet: the menu model is only exposed through the control channel |---|---|---|---|---|---| | MENU-BAR | Menu bar rendering | P1 | ✅ | `crates/ui-egui/src/menubar.rs`, `apps/lightcraft/src/native_menu.rs` | native macOS menu bar (muda) with live labels/enabled/checked; in-window menus on web/Windows/Linux; ⌫ and X stay egui-handled (contextual), so they show no key in the native menu | | MENU-APP-ABOUT | About | P2 | ✅ | `cmd:app.about` | | -| MENU-APP-SETTINGS | Settings… | P0 | ⬜ | | see LR-PREF-GENERAL | +| MENU-APP-SETTINGS | Settings… | P0 | ✅ | `cmd:app.settings` | app menu on macOS, Edit menu elsewhere | | MENU-APP-UPDATES | Check for updates | P2 | ⬜ | | | | MENU-APP-SYNC | Sync status / pause | OOS | 🚫 | | | | MENU-APP-SIGNOUT | Sign out | OOS | 🚫 | | | @@ -545,8 +545,8 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-SQUAREGRID | Square Grid | P0 | ✅ | `cmd:view.squareGrid` | | | MENU-VIEW-DETAIL | Detail | P0 | ✅ | `cmd:view.detail` | | | MENU-VIEW-EDIT | Edit | P0 | ✅ | `cmd:panel.edit` | | -| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ⬜ | | | -| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ⬜ | | | +| MENU-VIEW-FULLSCREENPREVIEW | Full Screen Preview | P1 | ✅ | `cmd:view.fullScreenPreview` | | +| MENU-VIEW-ENTERFULLSCREEN | Enter Full Screen | P1 | ✅ | `cmd:view.enterFullScreen` | | | MENU-VIEW-PHOTOSPANEL | Show/Hide photos panel | P0 | ✅ | `cmd:view.leftPanel` | | | MENU-VIEW-FILMSTRIP | Show/Hide filmstrip | P0 | ✅ | `cmd:view.filmstrip` | | | MENU-VIEW-INFO | Show/Hide info | P0 | ✅ | `cmd:panel.info` | | @@ -554,7 +554,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-ACTIVITY | Show/Hide activity (comments) | OOS | 🚫 | | our History panel is `panel.activity` | | MENU-VIEW-VERSIONS | Show/Hide versions | P1 | ✅ | `cmd:panel.versions` | | | MENU-VIEW-HISTOGRAM | Show/Hide histogram | P0 | ✅ | `cmd:view.histogram` | | -| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ⬜ | | | +| MENU-VIEW-INFOOVERLAY | Show info overlay | P1 | ✅ | `cmd:view.infoOverlay` | cycles | | MENU-VIEW-SHOWORIGINAL | Show Original | P0 | ✅ | `cmd:view.showOriginal` | | | MENU-VIEW-BEFOREAFTER | Before/After submenu | P0 | ✅ | `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom` | | | MENU-VIEW-ZOOM | Zoom in / out / toggle / fit / 1:1 | P0 | ✅ | `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.zoomFit`, `cmd:view.zoom100` | | @@ -613,7 +613,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP | Crop & rotate — C | P0 | ✅ | `cmd:panel.crop` | | | KEY-DETAIL | Detail — D | P0 | ✅ | `cmd:view.detail` | | | KEY-EDIT | Edit — E | P0 | ✅ | `cmd:panel.edit` | | -| KEY-FULLSCREEN | Full-screen preview — F | P1 | ⬜ | | no command | +| KEY-FULLSCREEN | Full-screen preview — F | P1 | ✅ | `cmd:view.fullScreenPreview` | | | KEY-GRID | Grid — G | P0 | ✅ | `cmd:view.photoGrid` | | | KEY-INFO | Info — I | P0 | ✅ | `cmd:panel.info` | | | KEY-KEYWORDS | Keywords — K | P0 | ✅ | `cmd:panel.keywords` | | @@ -627,7 +627,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-SELECTALL | Select all — ⌘A | P0 | ✅ | `cmd:library.selectAll` | | | KEY-SELECTNONE | Select none — ⌘D | P0 | ✅ | `cmd:library.selectNone` | secondary binding (primary ⌘⇧A) | | KEY-PASTESELECTED | Paste selected — ⇧⌘V | P0 | ⬜ | | no command | -| KEY-PREFS | Settings — ⌘, | P0 | ⬜ | | no command | +| KEY-PREFS | Settings — ⌘, | P0 | ✅ | `cmd:app.settings` | | | KEY-SEARCH | Search — ⌘F | P0 | ⬜ | | no focus-search command | | KEY-VISUALIZESPOTS | Visualize spots — A | P1 | ⬜ | | | | KEY-CYCLEOVERLAY | Cycle overlay — O | P0 | 🟡 | `cmd:view.maskOverlay`, `cmd:view.cropOverlay` | O toggles the mask overlay; crop overlays cycle on ⇧O | @@ -642,7 +642,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-MASKCOLOR | Cycle mask colour — ⇧O | P1 | ⬜ | | ⇧O cycles crop overlays | | KEY-EXPORTPREV | Export with previous — ⌘E | P0 | 🟡 | `cmd:app.exportPrevious` | bound to ⌥⇧⌘E (Classic); ⌘E not bound | | KEY-EXPORTDIALOG | Export dialog — ⇧E | P0 | ✅ | `cmd:dialog.export` | secondary binding (primary ⌘⇧E) | -| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ⬜ | | | +| KEY-ENTERFULLSCREEN | Window full screen — ⇧⌘F | P1 | ✅ | `cmd:view.enterFullScreen` | | | KEY-STACK | Group / ungroup stack — ⌘G / ⇧⌘G | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup` | also S expand/collapse, ⇧S top of stack | | KEY-GUIDEDUPRIGHT | Guided Upright — ⇧G | P1 | 🟡 | `cmd:geometry.upright` | button in the crop panel; ⇧G = Square Grid | | KEY-HIDE | Hide / hide others — ⌘H / ⌥⌘H | P1 | 🟡 | | platform default (unverified) |