Merge branch 'worktree-agent-ad641003294616c82'

# Conflicts:
#	crates/engine/src/library.rs
#	crates/ui-egui/src/lib.rs
#	docs/parity.md
This commit is contained in:
Brandon Thomas 2026-10-01 13:21:16 -04:00
commit 799ecc08bc
26 changed files with 1352 additions and 65 deletions

View File

@ -159,6 +159,7 @@ fn services(originals: Originals, backend: Option<Backend>, ctx: egui::Context)
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
reveal: None,
pick_folder: None,
}
}

View File

@ -3,7 +3,8 @@
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [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<std::path::PathBuf> {
}
}
fn load_prefs(app: &mut LightcraftApp) {
/// The saved UI state and app settings (`<config>/ui.json`), if any.
fn load_prefs() -> Option<UiState> {
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::<UiState>(&bytes)
{
app.ui = ui.sanitized();
return None;
}
let bytes = std::fs::read(config_dir()?.join("ui.json")).ok()?;
serde_json::from_slice::<UiState>(&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<std::path::PathBuf> = 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());

View File

@ -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<Accelerator> {
"=" => Code::Equal,
"-" => Code::Minus,
"'" => Code::Quote,
"," => Code::Comma,
"Delete" => Code::Backspace,
_ => return None,
};
@ -161,6 +165,14 @@ fn structure_of(bar: &[(String, Vec<MenuNode>)]) -> String {
s
}
/// Drop a trailing separator (left behind when an item moved to the app menu).
fn tidy_separators(mut nodes: Vec<MenuNode>) -> Vec<MenuNode> {
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<MenuNode> = 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<MenuNode> =
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::<HashSet<_>>();
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) {

View File

@ -181,6 +181,11 @@ pub struct Photo {
/// Virtual copies' name ("Copy 1").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub copy_name: Option<String>,
/// 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<Arc<DevelopSettings>>,
}
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(),

View File

@ -128,7 +128,12 @@ pub fn specs() -> Vec<CommandSpec> {
.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();

View File

@ -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
})
}

View File

@ -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<Option<String>> {
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<Value> {
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<CommandSpec> {
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
)]
}

View File

@ -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<String>,
/// 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<CameraDefault>,
/// Preset applied to non-raw images (JPEG, PNG, TIFF, HEIC…; `None` = none).
pub other_preset: Option<String>,
}
#[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<String>,
}
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())

View File

@ -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<serde_json::Value>,
/// 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;

View File

@ -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<serde_json::Value>,
/// 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::<PrefsFile>(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::<ViewFile>(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()?;

View File

@ -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.

View File

@ -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);
}

View File

@ -174,6 +174,11 @@ fn foreign_crs_sidecar_on_import_and_read_from_file() {
}
fn synthetic_dng(xmp: &str) -> Vec<u8> {
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<u8> {
use lightcraft_raw::*;
let (w, h) = (32usize, 24usize);
let cfa = Cfa::bayer("RGGB").unwrap();
@ -203,9 +208,9 @@ fn synthetic_dng(xmp: &str) -> Vec<u8> {
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]

View File

@ -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::<crate::UiState>(&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<u64> = 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]);

View File

@ -32,6 +32,8 @@ pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
/// A folder chooser (`None` = cancelled).
pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux).
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
@ -49,6 +51,8 @@ pub struct Services {
pub png: Option<PngEncode>,
/// Show a file in the system file manager (desktop only).
pub reveal: Option<RevealFn>,
/// Choose a folder (Settings → General → Open Library…; desktop only).
pub pick_folder: Option<PickFolder>,
}
#[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<HoverPreview>,
/// 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<bool>,
/// The memory budget setting last applied (MB, 0 = automatic).
memory_applied: Option<u32>,
}
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);

View File

@ -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<bool> {
"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<Valu
if culling_cmd {
crate::panels::compare::target_active(app, &mut params);
}
if id == "photo.delete" && crate::menus::confirm_delete(app) {
return Ok(Value::Null);
}
let r = app.run(id, params);
if culling_cmd && r.is_ok() && app.ui.auto_advance {
crate::panels::compare::advance(app);

View File

@ -38,6 +38,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"),
("view.back", "Back to Grid", Some("Escape"), ""),
("view.filterBar", "Filter Bar", Some("Shift+F"), "View"),
("view.fullScreenPreview", "Full Screen Preview", Some("F"), "View"),
("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"),
("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"),
("view.navigator", "Navigator", None, "View"),
("panel.edit", "Edit", Some("E"), "Window"),
("panel.profiles", "Profile Browser", None, "Window"),
("panel.crop", "Crop & Rotate", Some("C"), "Window"),
@ -73,6 +77,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"),
("file.importPresets", "Import Presets…", None, "File"),
("file.exportPresets", "Export Presets…", None, "File"),
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
("app.openLibrary", "Open Library…", None, "File"),
("app.about", "About LightCraft", None, "Help"),
("app.shortcuts", "Keyboard Shortcuts", Some("Cmd+/"), "Help"),
("app.export", "Export Now", None, ""),
@ -128,6 +134,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
"view.back" => {
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<Re
}
Ok(Value::Null)
}
"view.fullScreenPreview" => {
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<MenuEntry> {
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<Value, String> {
let id = app.session.active().ok_or("no photo selected")?;

View File

@ -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<String> = 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::<Vec<_>>().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) {

View File

@ -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<serde_jso
}),
),
Dialog::Merge { opts } => 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),
}
}

View File

@ -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 {

View File

@ -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;

View File

@ -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<R>(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<V: PartialEq + Copy>(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<Option<String>> {
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<String> {
let mut v: Vec<String> = 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<Value, String> {
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()}))
}

View File

@ -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)
}

View File

@ -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 {

View File

@ -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<bool>,
/// 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
}
}

View File

@ -21,9 +21,9 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
<!-- parity:summary -->
| 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%) |
<!-- /parity:summary -->
## 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) |