diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 95739b3e9..587dae180 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -3,8 +3,10 @@ //! 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 the library last opened with Settings → Open Library…, else -//! `~/Pictures/LightCraft Library`; a new library starts with the +//! `$LIGHTCRAFT_LIBRARY`, else — with Settings ▸ General ▸ At launch set to *Ask me*, or Alt/Option +//! held as the window appears — the one chosen in a list of the recent libraries, else the library +//! last opened, else `~/Pictures/LightCraft Library` +//! ([`lightcraft_ui_egui::panels::library_problem::launch_plan`]); 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). A library @@ -39,7 +41,7 @@ mod window_gpu; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; -use lightcraft_ui_egui::panels::library_problem::LibraryProblem; +use lightcraft_ui_egui::panels::library_problem::{Launch, LibraryProblem}; use lightcraft_ui_egui::{LightcraftApp, Services, UiState}; /// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name. @@ -363,7 +365,7 @@ struct PrefsWriter { impl PrefsWriter { fn save(&mut self, app: &LightcraftApp) -> Result<(), String> { - self.save_ui(&app.ui, app.library_problem.is_some()) + self.save_ui(&app.ui, app.library_problem.as_ref().is_some_and(LibraryProblem::saves_nothing)) } /// Write `ui` if it changed since the last write; `temporary` (the library couldn't be @@ -718,9 +720,13 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> } /// The library session with the folders face models (`/models`) and slideshow templates -/// (`/slideshow-templates`) are kept in. -fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { - let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); +/// (`/slideshow-templates`) are kept in. With [`Launch::Ask`] no library is opened yet: +/// the window lists the recent ones. +fn open_session(in_memory: bool, launch: &Launch, seed_demo: bool) -> (Session, Option) { + let (mut s, problem) = match launch { + Launch::Ask => (Session::new().with_fs().with_default_denoise_models().with_system_clock(), None), + Launch::Open { dir, .. } => open_library_session(in_memory, dir.clone(), seed_demo), + }; s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there @@ -769,7 +775,9 @@ lightcraft — photo library + raw developer USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported) OPTIONS: - --library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY) + --library DIR open (or create) this library (env LIGHTCRAFT_LIBRARY; default: the last library opened, or + a list of the recent ones with Settings > General > At launch: Ask me or Alt/Option held, + else ~/Pictures/LightCraft Library) --no-demo don't seed a new library with the procedural demo photos --memory throwaway in-memory session (alias --demo); nothing is saved --control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT; @@ -900,15 +908,15 @@ fn main() -> eframe::Result { if let Some(ui) = &prefs { lightcraft_ui_egui::i18n::set_language(ui.language); } - // --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); + // --library, else LIGHTCRAFT_LIBRARY, else the list of recent libraries if Settings say Ask + // me, else the library last opened from Settings, else the default location + let launch = lightcraft_ui_egui::panels::library_problem::launch_plan( + in_memory, + library_dir, + std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()).map(Into::into), + prefs.as_ref().map(|u| &u.settings), + lightcraft_engine::library::default_dir(), + ); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); @@ -961,7 +969,7 @@ fn main() -> eframe::Result { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); - let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); + let (mut session, mut problem) = open_session(in_memory, &launch, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) // and the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) @@ -972,6 +980,11 @@ fn main() -> eframe::Result { if let Some(ui) = prefs { app.ui = ui; } + // the recent libraries to choose from (issue #1290), each checked now + if launch == Launch::Ask { + problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + } + app.alt_asks_for_library = matches!(launch, Launch::Open { alt_asks: true, .. }); lightcraft_ui_egui::i18n::set_language(app.ui.language); app.integrated_titlebar = cfg!(target_os = "macos"); app.notices.extend(prefs_warning); @@ -1145,12 +1158,40 @@ mod tests { /// adds to Apple Photos on a worker (headless sessions wait instead). #[test] fn the_desktop_session_adds_to_apple_photos_in_the_background() { - let (s, problem) = open_session(true, None, false); + let (s, problem) = open_session(true, &Launch::Open { dir: None, alt_asks: false }, false); assert!(problem.is_none()); assert!(s.apple_photos_background); assert!(!lightcraft_engine::Session::new().apple_photos_background); } + /// Issue #1290: with Settings ▸ At launch ▸ Ask me nothing is opened (nor seeded with demo + /// photos) before the user chooses, and the settings are still saved while the list shows (a + /// removed entry is kept), unlike the failed-library window's temporary session. + #[test] + fn asking_at_launch_opens_nothing_and_still_saves_the_settings() { + let (s, problem) = open_session(false, &Launch::Ask, true); + assert!(problem.is_none() && s.library.is_none() && s.catalog.is_empty()); + assert!(s.apple_photos_background, "the same desktop session as an opened one"); + + let d = dir("writer-ask"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(s, Services::default()); + app.ui.settings.recent_libraries = vec!["/gone/a".into(), "/gone/b".into()]; + app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + let mut w = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + app.ui.settings.recent_libraries.remove(0); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + // the failed-library window still writes nothing + app.library_problem = Some(LibraryProblem::new("/gone/b", "not there")); + app.ui.settings.recent_libraries.clear(); + w.save(&app).unwrap(); + let saved: UiState = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(saved.settings.recent_libraries, ["/gone/b"]); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #103: a damaged ui.json (which holds the library location) is kept aside and /// reported, not silently replaced by the defaults. #[test] diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 2f22a70c1..e3619d974 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -2340,5 +2340,20 @@ "Checking the GPU…": "Vérification du GPU…", "CPU": "Processeur", "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", - "Checking the GPU for faces": "Vérification du GPU pour les visages" + "Checking the GPU for faces": "Vérification du GPU pour les visages", + "At launch": "Au lancement", + "Open the last library": "Ouvrir la dernière bibliothèque", + "Ask me": "Me demander", + "Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.": "« Me demander » affiche vos bibliothèques récentes au lancement. Maintenir Alt (Option sur Mac) pendant que la fenêtre apparaît le demande une fois.", + "Open a Library": "Ouvrir une bibliothèque", + "Choose the library to open.": "Choisissez la bibliothèque à ouvrir.", + "No recent libraries. Open one from a folder, or make a new one.": "Aucune bibliothèque récente. Ouvrez-en une depuis un dossier, ou créez-en une nouvelle.", + "Remove from List": "Retirer de la liste", + "The library itself stays where it is.": "La bibliothèque elle-même reste où elle est.", + "Open": "Ouvrir", + "Open Another…": "Ouvrir une autre…", + "Open now": "Ouverte en ce moment", + "Last opened": "Ouverte le", + "Not found: moved, renamed or on a disconnected drive": "Introuvable : déplacée, renommée ou sur un disque déconnecté", + "There is no LightCraft library in this folder any more": "Ce dossier ne contient plus de bibliothèque LightCraft" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1b2517907..3528babe5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,8 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_launch_library; +#[cfg(test)] mod tests_layout_image; #[cfg(test)] mod tests_library_problem; @@ -483,6 +485,9 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// Alt/Option held as the window first appears offers the recent libraries once (issue + /// #1290); the desktop host arms it when nothing chose the library already. + pub alt_asks_for_library: bool, /// The activity stack shows every task, not just the first few ("+N more" was clicked). pub activity_expanded: bool, } @@ -562,6 +567,7 @@ impl LightcraftApp { screen_profile: None, screen_profile_read: None, library_problem: None, + alt_asks_for_library: false, activity_expanded: false, model_setup: Default::default(), } @@ -1077,7 +1083,7 @@ impl LightcraftApp { pub fn logic(&mut self, ctx: &egui::Context) { i18n::set_language(self.ui.language); let t0 = now_ms(); - panels::library_problem::logic(self); + panels::library_problem::logic(self, ctx); self.logic_inner(ctx); self.perf.logic_ms = now_ms() - t0; } @@ -1425,6 +1431,10 @@ impl LightcraftApp { if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) { let path = l.dir.to_string_lossy().into_owned(); panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path); + // when, for the launch window (issue #1290); only the listed libraries keep a time + let settings = &mut self.ui.settings; + settings.recent_opened.insert(path, (self.session.clock)()); + settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p)); } ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1ac3e9926..2dde300a8 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -48,6 +48,7 @@ pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a s if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) || (id == "app.openLibrary" && params.get("path").is_some()) + || (id == "app.recentLibraries" && params.get("remove").is_some()) { label } else { @@ -631,23 +632,26 @@ pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option> "Open Recent Library" => { let can = crate::panels::settings::can_switch_library(app).is_ok(); let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let mut v: Vec = app - .ui - .settings - .recent_libraries + let recent = &app.ui.settings.recent_libraries; + let label = |r: &str| { + let d = std::path::Path::new(r); + format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()) + }; + let mut v: Vec = recent .iter() .map(|r| { let d = std::path::Path::new(r); // a missing library (moved, drive disconnected) is shown greyed out - let label = - format!("{} — {}", crate::panels::settings::library_name(d), d.parent().map(|p| p.display().to_string()).unwrap_or_default()); - item("app.openLibrary", json!({"path": r}), label, None, can && d.is_dir(), Some(open.as_deref() == Some(d))) + item("app.openLibrary", json!({"path": r}), label(r), None, can && d.is_dir(), Some(open.as_deref() == Some(d))) }) .collect(); if v.is_empty() { v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None)); } else { v.push(MenuNode::Separator); + // one entry at a time, missing ones too: only the list forgets it (issue #1290) + let forget = recent.iter().map(|r| item("app.recentLibraries", json!({"remove": r}), label(r), None, true, None)).collect(); + v.push(MenuNode::Submenu { label: "Remove from List".into(), children: forget }); v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None)); } v diff --git a/crates/ui-egui/src/panels/library_problem.rs b/crates/ui-egui/src/panels/library_problem.rs index 212e57d90..48c4c5c9e 100644 --- a/crates/ui-egui/src/panels/library_problem.rs +++ b/crates/ui-egui/src/panels/library_problem.rs @@ -2,14 +2,77 @@ //! blocking window says which library and why, and offers Try Again, Choose Another Library…, //! Continue Without Saving and Quit. Continuing shows a banner under the top bar for as long as the //! session is temporary. +//! +//! The same window lists the recent libraries to choose from when Settings ▸ General ▸ At launch +//! is *Ask me*, or when Alt/Option is held as the window first appears (issue #1290): Open, Open +//! Another…, New Library…, Remove from List and Quit, with the arrow keys, Return and Esc. +//! [`launch_plan`] decides at launch which of the two the desktop app starts with. + +use std::path::{Path, PathBuf}; use egui::{Align2, RichText, vec2}; use serde_json::json; use crate::LightcraftApp; +use crate::state::{AppSettings, LaunchLibrary}; use crate::theme::Tokens; use crate::widgets::register; +/// How long after the window appears holding Alt/Option still asks (egui time, seconds). The +/// modifier keys can't be read before the first frame, and macOS reports one held since before +/// launch only once it changes, so it is watched over the first frames. +pub const ALT_SECONDS: f64 = 2.0; + +/// What the desktop app opens at launch (issue #1290). +#[derive(Clone, Debug, PartialEq)] +pub enum Launch { + /// Open this library folder (`None`: there is nowhere to keep one; the problem window says + /// so). `alt_asks`: holding Alt/Option as the window appears offers the recent libraries. + Open { dir: Option, alt_asks: bool }, + /// Open nothing yet: the window lists the recent libraries ([`LibraryProblem::choose`]). + Ask, +} + +/// What to open at launch: `--library` (`flag`), else `$LIGHTCRAFT_LIBRARY` (`env`), else with +/// *Ask me* the list, else the library last opened, else `default` (the default location). +/// `settings` is `None` without a settings file; `memory` (`--memory`) never asks. +pub fn launch_plan(memory: bool, flag: Option, env: Option, settings: Option<&AppSettings>, default: Option) -> Launch { + if memory { + return Launch::Open { dir: None, alt_asks: false }; + } + if let Some(dir) = flag.or(env) { + return Launch::Open { dir: Some(dir), alt_asks: false }; + } + if settings.is_some_and(|s| s.at_launch == LaunchLibrary::Ask) { + return Launch::Ask; + } + let last = settings.map(|s| s.library_path.clone()).filter(|p| !p.is_empty()).map(PathBuf::from); + Launch::Open { dir: last.or(default), alt_asks: true } +} + +/// One library in the launch window's list. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RecentLibrary { + pub path: String, + /// When it was last opened (ISO 8601), if known. + pub opened: Option, + /// Why it can't be opened (`None`: it can), as checked when the window opened or when + /// opening it failed. + pub unavailable: Option, +} + +/// The launch window's list of recent libraries (issue #1290). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Chooser { + /// Newest first, as File ▸ Open Recent Library lists them. + pub libraries: Vec, + /// The highlighted row (`None`: the list is empty). + pub selected: Option, + /// The library open behind the window (Alt/Option held at launch), by + /// [`lightcraft_engine::Session::library_generation`]: Esc keeps it. `None`: no library is open. + pub over: Option, +} + /// A library that failed to open (set by the host at launch). #[derive(Clone, Debug, Default, PartialEq)] pub struct LibraryProblem { @@ -24,25 +87,94 @@ pub struct LibraryProblem { /// Try Again reopens `path` as a library folder (`false` in the browser, whose storage isn't /// a folder: reloading the page tries again). pub can_retry: bool, + /// The window lists the recent libraries to choose from instead (issue #1290); `error` then + /// says why the last one tried didn't open. + pub chooser: Option, } impl LibraryProblem { + /// The window for a library at `path` that failed to open with `error`. pub fn new(path: impl Into, error: impl Into) -> LibraryProblem { LibraryProblem { path: path.into(), error: error.into(), can_retry: true, ..Default::default() } } + /// The window listing the recent libraries, each checked now: one that is gone, isn't a + /// LightCraft library any more, or can't be opened (another program has it, unreadable) is + /// greyed out with the reason. The last library opened is selected, or else the first one + /// that can be opened. + pub fn choose(session: &lightcraft_engine::Session, settings: &AppSettings) -> LibraryProblem { + // as many as File ▸ Open Recent Library keeps (a hand-edited settings file may hold more) + let libraries: Vec = settings + .recent_libraries + .iter() + .take(crate::state::RECENT_LIBRARIES) + .map(|path| RecentLibrary { + path: path.clone(), + opened: settings.recent_opened.get(path).cloned(), + unavailable: unavailable(session, path), + }) + .collect(); + let can = |l: &RecentLibrary| l.unavailable.is_none(); + let selected = libraries + .iter() + .position(|l| l.path == settings.library_path && can(l)) + .or_else(|| libraries.iter().position(can)) + .or((!libraries.is_empty()).then_some(0)); + LibraryProblem { chooser: Some(Chooser { libraries, selected, over: None }), ..Default::default() } + } + + /// The session promises to save nothing (the failed-library window or Continue Without + /// Saving): the host writes no settings meanwhile. The launch list is not such a session. + pub fn saves_nothing(&self) -> bool { + self.chooser.is_none() + } + /// `ui.inspect` → `libraryProblem`. pub fn to_json(&self) -> serde_json::Value { - json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len()}) + let chooser = self.chooser.as_ref().map(|c| { + let libraries: Vec = c + .libraries + .iter() + .map(|l| json!({"path": l.path, "lastOpened": l.opened, "available": l.unavailable.is_none(), "reason": l.unavailable})) + .collect(); + json!({"libraries": libraries, "selected": c.selected, "libraryOpen": c.over.is_some()}) + }); + json!({"path": self.path, "error": self.error, "temporarySession": self.dismissed, "pendingImport": self.pending_import.len(), "chooser": chooser}) } } +/// Why the library at `path` can't be opened now (`None`: it can). +fn unavailable(session: &lightcraft_engine::Session, path: &str) -> Option { + let dir = Path::new(path); + if !dir.exists() { + return Some(crate::i18n::tr("Not found: moved, renamed or on a disconnected drive").to_string()); + } + // a folder that is there, readable and not held by another program + if let Err(e) = session.check_library(dir) { + return Some(e.to_string()); + } + // opening would make a new, empty library in it + if !lightcraft_engine::library::is_library(dir) { + return Some(crate::i18n::tr("There is no LightCraft library in this folder any more").to_string()); + } + None +} + /// What the user chose. +#[derive(Clone, Copy, Debug, PartialEq)] enum Choice { Retry, Choose, Temporary, Quit, + /// The launch list: open the selected library. + Open, + /// The launch list: forget the selected library. + Remove, + /// The launch list: make a library (New Library…). + New, + /// The launch list over a library already open: close the window and keep it. + Keep, } /// Open `path` (`None`: ask for a folder) through `app.openLibrary`; on success the problem is @@ -64,7 +196,17 @@ fn open(app: &mut LightcraftApp, path: Option) { } } Err(e) => { + // a failed switch reopens the library that was open behind the launch list + let generation = app.session.library.as_ref().map(|_| app.session.library_generation()); if let Some(p) = app.library_problem.as_mut() { + if let Some(c) = p.chooser.as_mut() { + if c.over.is_some() { + c.over = generation; + } + if let Some(l) = c.libraries.iter_mut().find(|l| path.as_deref() == Some(l.path.as_str())) { + l.unavailable = Some(e.clone()); + } + } if let Some(path) = path { p.path = path; } @@ -75,11 +217,104 @@ fn open(app: &mut LightcraftApp, path: Option) { } } -/// Clear the problem once a library is open (e.g. through Settings → Open Library…). -pub fn logic(app: &mut LightcraftApp) { - if app.library_problem.is_some() && app.session.library.is_some() { - app.library_problem = None; +/// Clear the problem once a library is open (e.g. through Settings → Open Library…) — another +/// one than the launch list was opened over; offer the list when Alt/Option is held as the window +/// appears; and the list's keys. +pub fn logic(app: &mut LightcraftApp, ctx: &egui::Context) { + if let Some(p) = &app.library_problem + && app.session.library.is_some() + && p.chooser.as_ref().and_then(|c| c.over) != Some(app.session.library_generation()) + { + // files from the command line go into the library opened another way (New Library…, + // File ▸ Open Library…), as through the window's own buttons + let files = app.library_problem.take().map(|p| p.pending_import).unwrap_or_default(); + if !files.is_empty() + && let Err(e) = crate::import::start_paths(app, files) + { + log::warn!("import: {e}"); + } } + alt_at_launch(app, ctx); + chooser_keys(app, ctx); +} + +/// Alt/Option held during the first [`ALT_SECONDS`] after the window appears opens the launch list +/// once, over the library that opened (opening another one closes it). Not while files from the +/// command line are importing into that library. +fn alt_at_launch(app: &mut LightcraftApp, ctx: &egui::Context) { + if !app.alt_asks_for_library { + return; + } + let (alt, time) = ctx.input(|i| (i.modifiers.alt, i.time)); + if time > ALT_SECONDS || app.library_problem.is_some() { + app.alt_asks_for_library = false; + return; + } + if !alt { + return; + } + app.alt_asks_for_library = false; + if crate::panels::settings::can_switch_library(app).is_err() { + return; + } + let mut p = LibraryProblem::choose(&app.session, &app.ui.settings); + if let Some(c) = p.chooser.as_mut() { + c.over = app.session.library.as_ref().map(|_| app.session.library_generation()); + } + app.library_problem = Some(p); + ctx.request_repaint(); +} + +/// The launch list on screen (no dialog over it, e.g. New Library's name). +fn chooser_shown(app: &LightcraftApp) -> Option<&Chooser> { + let p = app.library_problem.as_ref().filter(|p| !p.dismissed)?; + p.chooser.as_ref().filter(|_| app.ui.dialog.is_none()) +} + +/// ↑ / ↓ move the selection, Return opens it (unless a button has the keyboard focus: it acts +/// itself), Esc quits — or, over a library already open, keeps it. Taken before the shortcuts. +fn chooser_keys(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(c) = chooser_shown(app) else { return }; + let (n, selected, over) = (c.libraries.len(), c.selected, c.over.is_some()); + let focused = ctx.memory(|m| m.focused().is_some()); + use egui::{Key, Modifiers}; + let (up, down, enter, esc) = ctx.input_mut(|i| { + let up = i.consume_key(Modifiers::NONE, Key::ArrowUp); + let down = i.consume_key(Modifiers::NONE, Key::ArrowDown); + let enter = !focused && i.consume_key(Modifiers::NONE, Key::Enter); + (up, down, enter, i.consume_key(Modifiers::NONE, Key::Escape)) + }); + if (up || down) && n > 0 { + let at = match selected { + Some(s) if up => s.saturating_sub(1), + Some(s) => (s + 1).min(n - 1), + None => 0, + }; + select(app, at); + } + if enter { + act(app, Choice::Open); + } else if esc { + act(app, if over { Choice::Keep } else { Choice::Quit }); + } +} + +/// Highlight row `at` of the launch list. +fn select(app: &mut LightcraftApp, at: usize) { + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) + && at < c.libraries.len() + { + c.selected = Some(at); + } +} + +/// Dim everything behind the window; it swallows clicks, so nothing there can be used meanwhile. +fn dim(ctx: &egui::Context) { + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { + ui.allocate_rect(screen, egui::Sense::click_and_drag()); + ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); + }); } /// The blocking window (until a choice is made). @@ -88,13 +323,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if problem.dismissed { return; } + if let Some(chooser) = &problem.chooser { + // a dialog it opened (New Library's name) goes first; the list comes back after it + if app.ui.dialog.is_none() { + show_chooser(app, ctx, &problem, chooser); + } + return; + } let t = Tokens::get(ctx); - let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("library-problem-dim")).order(egui::Order::Middle).fixed_pos(screen.min).interactable(true).show(ctx, |ui| { - // swallows clicks: nothing behind the window can be used meanwhile - ui.allocate_rect(screen, egui::Sense::click_and_drag()); - ui.painter().rect_filled(screen, 0.0, egui::Color32::from_black_alpha(160)); - }); + dim(ctx); let mut choice = None; let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); egui::Window::new(crate::i18n::tr("Your library couldn't be opened")) @@ -139,10 +376,49 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { button(ui, "libraryQuit", "Quit", true, Choice::Quit); }); }); + if let Some(c) = choice { + act(app, c); + } +} + +/// Carry out `choice` (a button or key of either window). +fn act(app: &mut LightcraftApp, choice: Choice) { + let Some(problem) = app.library_problem.clone() else { return }; + let chosen = problem.chooser.as_ref().and_then(|c| c.selected.and_then(|i| c.libraries.get(i)).map(|l| (l.clone(), c.over))); match choice { - Some(Choice::Retry) => open(app, Some(problem.path)), - Some(Choice::Choose) => open(app, None), - Some(Choice::Temporary) => { + Choice::Retry => open(app, Some(problem.path)), + Choice::Choose => open(app, None), + Choice::Open => { + let Some((library, over)) = chosen.filter(|(l, _)| l.unavailable.is_none()) else { return }; + // the library already open behind the list: nothing to switch + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).is_some_and(|l| l.dir == Path::new(&library.path)); + if over.is_some() && open_now { + app.library_problem = None; + } else { + open(app, Some(library.path)); + } + } + Choice::Remove => { + let Some((library, _)) = chosen else { return }; + crate::panels::settings::forget_library(&mut app.ui.settings, &library.path); + if let Some(c) = app.library_problem.as_mut().and_then(|p| p.chooser.as_mut()) { + c.libraries.retain(|l| l.path != library.path); + c.selected = c.selected.map(|s| s.min(c.libraries.len().saturating_sub(1))).filter(|_| !c.libraries.is_empty()); + } + } + Choice::New => { + // asks for the name, then the place; the list shows again if that's cancelled + match app.run("app.newLibrary", json!({})) { + Ok(_) => {} + Err(e) => { + if let Some(p) = app.library_problem.as_mut() { + p.error = e; + } + } + } + } + Choice::Keep => app.library_problem = None, + Choice::Temporary => { if let Some(p) = app.library_problem.as_mut() { p.dismissed = true; } @@ -154,11 +430,133 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { log::warn!("import: {e}"); } } - Some(Choice::Quit) => app.ui.quit = true, - None => {} + Choice::Quit => app.ui.quit = true, } } +/// The launch list (issue #1290): the recent libraries, greyed out with the reason when they +/// can't be opened, and Open / Open Another… / New Library… / Remove from List / Quit. +fn show_chooser(app: &mut LightcraftApp, ctx: &egui::Context, problem: &LibraryProblem, chooser: &Chooser) { + let t = Tokens::get(ctx); + dim(ctx); + let mut choice = None; + let mut clicked_row = None; + let can_pick = app.services.pick_folder.is_some(); + let open_now = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); + let frame = egui::Frame::window(&ctx.global_style()).inner_margin(egui::Margin::symmetric(18, 14)); + egui::Window::new(crate::i18n::tr("Open a Library")) + .id(egui::Id::new("library-chooser")) + .order(egui::Order::Foreground) + .collapsible(false) + .resizable(false) + .frame(frame) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .default_width(520.0) + .show(ctx, |ui| { + // a fixed width: a wrapped label in an auto-sized, centred window would move it every frame + ui.set_width(500.0); + ui.spacing_mut().item_spacing.y = 8.0; + ui.label(RichText::new(crate::i18n::tr("Choose the library to open.")).color(t.text_label)); + if chooser.libraries.is_empty() { + let r = ui.label(RichText::new(crate::i18n::tr("No recent libraries. Open one from a folder, or make a new one.")).color(t.text_dim)); + register(ui.ctx(), "indicator:noRecentLibraries", r.rect); + } + egui::ScrollArea::vertical().max_height(300.0).auto_shrink([false, true]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 2.0; + for (i, library) in chooser.libraries.iter().enumerate() { + let r = recent_row(ui, &t, i, library, chooser.selected == Some(i), open_now.as_deref() == Some(Path::new(&library.path))); + if r.double_clicked() { + clicked_row = Some((i, true)); + } else if r.clicked() { + clicked_row = Some((i, false)); + } + } + }); + if !problem.error.is_empty() { + ui.add(egui::Label::new(RichText::new(&problem.error).color(t.caution)).wrap()); + } + let selected = chooser.selected.and_then(|i| chooser.libraries.get(i)); + let mut button = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool, c: Choice| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label)).min_size(vec2(0.0, 26.0))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + choice = Some(c); + } + }; + ui.horizontal(|ui| { + button(ui, "libraryForget", "Remove from List", selected.is_some(), Choice::Remove); + ui.label(RichText::new(crate::i18n::tr("The library itself stays where it is.")).size(11.0).color(t.text_dim)); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + button(ui, "libraryOpen", "Open", selected.is_some_and(|l| l.unavailable.is_none()), Choice::Open); + button(ui, "libraryOpenAnother", "Open Another…", can_pick, Choice::Choose); + button(ui, "libraryNew", "New Library…", can_pick, Choice::New); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + button(ui, "libraryQuit", "Quit", true, Choice::Quit); + }); + }); + }); + if let Some((i, open)) = clicked_row { + select(app, i); + if open { + act(app, Choice::Open); + } + } + if let Some(c) = choice { + act(app, c); + } +} + +/// One row of the launch list: the library's name and when it was last opened, the folder it is +/// in, and why it can't be opened (then greyed out). Registered as `libraryRecent:`. +fn recent_row(ui: &mut egui::Ui, t: &Tokens, i: usize, library: &RecentLibrary, selected: bool, open: bool) -> egui::Response { + let dir = Path::new(&library.path); + let name = crate::panels::settings::library_name(dir); + let parent = dir.parent().map(|p| p.display().to_string()).unwrap_or_default(); + let ok = library.unavailable.is_none(); + let (main, dim) = if ok { (t.text, t.text_dim) } else { (t.text_disabled, t.text_disabled) }; + let background = ui.painter().add(egui::Shape::Noop); + let inner = egui::Frame::NONE.inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.spacing_mut().item_spacing.y = 1.0; + ui.horizontal(|ui| { + ui.add(egui::Label::new(RichText::new(name).font(t.semibold(12.5)).color(main)).truncate().selectable(false)); + let when = if open { + Some(crate::i18n::tr("Open now").to_string()) + } else { + library + .opened + .as_deref() + .and_then(|iso| iso.get(..10)) + .map(|day| format!("{} {}", crate::i18n::tr("Last opened"), crate::i18n::date_group_label(day, false))) + }; + if let Some(when) = when { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.add(egui::Label::new(RichText::new(when).size(11.0).color(dim)).selectable(false)); + }); + } + }); + ui.add(egui::Label::new(RichText::new(parent).size(11.0).color(dim)).truncate().selectable(false)); + if let Some(why) = &library.unavailable { + ui.add(egui::Label::new(RichText::new(why).size(11.0).color(t.caution)).wrap().selectable(false)); + } + }); + let r = ui.interact(inner.response.rect, egui::Id::new(("library-recent", i)), egui::Sense::click()); + register(ui.ctx(), format!("libraryRecent:{i}"), r.rect); + let fill = if selected { + Some(t.tool_active) + } else if r.hovered() { + Some(t.hover) + } else { + None + }; + if let Some(fill) = fill { + ui.painter().set(background, egui::Shape::rect_filled(r.rect, 4.0, fill)); + } + r +} + /// The banner under the top bar while the session is temporary (Continue Without Saving). pub fn banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { if !app.library_problem.as_ref().is_some_and(|p| p.dismissed) { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2f844f4ae..6b17cec91 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -137,6 +137,19 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { hint(ui, t, crate::i18n::tr("not available here")); } }); + // where libraries are folders to choose from (not in the browser, which keeps one) + if app.services.pick_folder.is_some() { + use crate::state::LaunchLibrary; + row(ui, t, crate::i18n::tr("At launch"), |ui| { + choices( + ui, + "settingsAtLaunch", + &[(LaunchLibrary::Last, "Open the last library"), (LaunchLibrary::Ask, "Ask me")], + &mut app.ui.settings.at_launch, + ); + }); + hint(ui, t, crate::i18n::tr("Ask me lists your recent libraries at launch. Holding Alt (Option on a Mac) as the window appears asks once.")); + } heading(ui, t, crate::i18n::tr("Startup")); row(ui, t, crate::i18n::tr("Open in"), |ui| { choices( @@ -1292,27 +1305,47 @@ fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf { (2..1000u32).map(|i| location.join(format!("{base} {i}"))).find(|p| !p.exists()).unwrap_or(first) } -/// `app.recentLibraries {clear?: bool}`: the recently opened libraries, newest first, as -/// `[{path, name, exists, open}]` (`exists: false`: moved or on a disconnected drive). `clear` -/// forgets them all. +/// `app.recentLibraries {remove?: path, clear?: bool}`: the recently opened libraries, newest +/// first, as `[{path, name, exists, open, lastOpened}]` (`exists: false`: moved or on a +/// disconnected drive; `lastOpened`: ISO 8601 time, `null` when not known). `remove` forgets that +/// one entry (an error if the list doesn't hold it) and `clear` forgets them all; neither touches +/// anything on disk (issue #1290). pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result { + match p.get("remove") { + None | Some(Value::Null) => {} + Some(Value::String(path)) => { + if !forget_library(&mut app.ui.settings, path) { + return Err(format!("app.recentLibraries: {path:?} is not in the recent libraries")); + } + } + Some(_) => return Err("app.recentLibraries: remove must be a library path (a string)".into()), + } if p.get("clear").and_then(Value::as_bool) == Some(true) { app.ui.settings.recent_libraries.clear(); + app.ui.settings.recent_opened.clear(); } let open = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.clone()); - let list: Vec = app - .ui - .settings + let settings = &app.ui.settings; + let list: Vec = settings .recent_libraries .iter() .map(|r| { let d = std::path::Path::new(r); - json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d)}) + json!({"path": r, "name": library_name(d), "exists": d.is_dir(), "open": open.as_deref() == Some(d), "lastOpened": settings.recent_opened.get(r)}) }) .collect(); Ok(Value::Array(list)) } +/// Forget `path` in File ▸ Open Recent Library (and when it was opened); the library itself is +/// left where it is. False if the list didn't hold it. +pub fn forget_library(settings: &mut crate::state::AppSettings, path: &str) -> bool { + let before = settings.recent_libraries.len(); + settings.recent_libraries.retain(|r| r != path); + settings.recent_opened.remove(path); + settings.recent_libraries.len() != before +} + // ---------------------------------------------------------------------------- Display profile /// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8cb37d3ef..356973a10 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -166,6 +166,18 @@ pub enum StartupView { Detail, } +/// What the desktop app opens at launch (Settings ▸ General ▸ At launch; issue #1290). `--library` +/// and `LIGHTCRAFT_LIBRARY` come first either way. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LaunchLibrary { + /// The library open when the app last quit (holding Alt/Option as the window appears asks once). + #[default] + Last, + /// A window lists the recent libraries to choose from. + Ask, +} + /// When grid cells show their rating / flag / edited badges. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -407,6 +419,15 @@ pub struct AppSettings { /// Libraries opened recently, newest first (File ▸ Open Recent Library; at most /// [`RECENT_LIBRARIES`]). pub recent_libraries: Vec, + /// When each recent library was last opened (path → the session clock's ISO 8601 time), for + /// the launch window. A separate key so files from earlier versions, and earlier versions + /// reading this one, keep their list (issue #1290). + #[serde(rename = "recentLibrariesOpened", deserialize_with = "lenient")] + pub recent_opened: std::collections::BTreeMap, + /// Open the last library at launch, or ask (a value this version doesn't know reads as the + /// default). + #[serde(deserialize_with = "lenient")] + pub at_launch: LaunchLibrary, pub startup_view: StartupView, /// Ask before moving photos to Recently Deleted. Saved under a new key: earlier versions saved /// `confirmDelete: false` by default (#1239). @@ -470,6 +491,8 @@ impl Default for AppSettings { AppSettings { library_path: String::new(), recent_libraries: Vec::new(), + recent_opened: Default::default(), + at_launch: LaunchLibrary::Last, startup_view: StartupView::Last, confirm_delete: true, ask_before_move: true, diff --git a/crates/ui-egui/src/tests_launch_library.rs b/crates/ui-egui/src/tests_launch_library.rs new file mode 100644 index 000000000..8f3767d9b --- /dev/null +++ b/crates/ui-egui/src/tests_launch_library.rs @@ -0,0 +1,524 @@ +//! Issue #1290: what opens at launch. Settings ▸ General ▸ At launch opens the last library or +//! asks with a window listing the recent ones (greyed out with the reason when they can't be +//! opened); Alt/Option held as the window appears asks once; one recent entry can be forgotten from +//! File ▸ Open Recent Library, the window and `app.recentLibraries {remove}`. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::library_problem::{ALT_SECONDS, Launch, LibraryProblem, launch_plan}; +use crate::state::{AppSettings, LaunchLibrary}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// A fresh scratch folder for one test. +fn temp(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-launch-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A library on disk at `dir` (written, then closed: as left by an earlier run). +fn make_library(dir: &Path) -> String { + let mut s = lightcraft_engine::Session::new().with_fs(); + s.open_library(dir, false).unwrap(); + s.close_library().unwrap(); + drop(s); + assert!(lightcraft_engine::library::is_library(dir)); + dir.to_string_lossy().into_owned() +} + +/// Enough frames for a synthetic click (move, press, release) and what it starts. +fn settle(h: &mut Headless) { + for _ in 0..6 { + h.step(); + } +} + +/// The widget `id` was drawn in the last frame. +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +/// `Some(s)` as a path, for the launch plan's arguments. +fn path(s: &str) -> Option { + Some(PathBuf::from(s)) +} + +/// `--library`, then `LIGHTCRAFT_LIBRARY`, then the setting; `--memory` never asks. +#[test] +fn the_launch_plan_puts_the_flag_and_the_variable_before_the_setting() { + let ask = AppSettings { at_launch: LaunchLibrary::Ask, library_path: "/last".into(), ..AppSettings::default() }; + let last = AppSettings { library_path: "/last".into(), recent_libraries: vec!["/last".into(), "/older".into()], ..AppSettings::default() }; + let fresh = AppSettings::default(); + let open = |dir: &str, alt_asks| Launch::Open { dir: path(dir), alt_asks }; + // --library, then LIGHTCRAFT_LIBRARY, whatever the setting says; neither asks on Alt + for s in [None, Some(&ask), Some(&last)] { + assert_eq!(launch_plan(false, path("/flag"), path("/env"), s, path("/default")), open("/flag", false)); + assert_eq!(launch_plan(false, None, path("/env"), s, path("/default")), open("/env", false)); + } + // Ask me asks, with or without a recent list + assert_eq!(launch_plan(false, None, None, Some(&ask), path("/default")), Launch::Ask); + let ask_empty = AppSettings { at_launch: LaunchLibrary::Ask, ..AppSettings::default() }; + assert_eq!(launch_plan(false, None, None, Some(&ask_empty), None), Launch::Ask); + // Open the last library: the last one, else the default place; Alt can still ask + assert_eq!(launch_plan(false, None, None, Some(&last), path("/default")), open("/last", true)); + assert_eq!(launch_plan(false, None, None, Some(&fresh), path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, path("/default")), open("/default", true)); + assert_eq!(launch_plan(false, None, None, None, None), Launch::Open { dir: None, alt_asks: true }); + // --memory never asks and opens no folder + assert_eq!(launch_plan(true, None, None, Some(&ask), path("/default")), Launch::Open { dir: None, alt_asks: false }); + assert_eq!(launch_plan(true, path("/flag"), None, Some(&last), None), Launch::Open { dir: None, alt_asks: false }); +} + +/// Each listed library is checked when the window opens; the last one opened is selected. +#[test] +fn the_list_checks_each_library_and_selects_the_last_one() { + let dir = temp("choose"); + let (a, b, held, plain) = (dir.join("A"), dir.join("B"), dir.join("Held"), dir.join("Plain")); + let (a, b, held_path) = (make_library(&a), make_library(&b), make_library(&held)); + std::fs::create_dir_all(&plain).unwrap(); + std::fs::write(plain.join("notes.txt"), b"not a library").unwrap(); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let plain = plain.to_string_lossy().into_owned(); + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap(); + let session = lightcraft_engine::Session::new().with_fs(); + + let mut settings = AppSettings { + recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), b.clone()], + library_path: b.clone(), + ..AppSettings::default() + }; + settings.recent_opened.insert(b.clone(), "2026-10-20T08:30:00".into()); + let p = LibraryProblem::choose(&session, &settings); + assert!(!p.saves_nothing(), "the launch list is not a temporary session: removals are saved"); + let c = p.chooser.as_ref().unwrap(); + let why: Vec> = c.libraries.iter().map(|l| l.unavailable.as_deref()).collect(); + assert!(why[0].unwrap().contains("Not found"), "{why:?}"); + assert!(why[1].unwrap().contains("already open in another LightCraft"), "{why:?}"); + assert_eq!(why[2], None); + assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}"); + assert_eq!(why[4], None); + assert_eq!(c.selected, Some(4), "the last library opened"); + assert_eq!(c.libraries[4].opened.as_deref(), Some("2026-10-20T08:30:00")); + assert_eq!(c.libraries[2].opened, None, "no time known"); + let json = p.to_json(); + assert_eq!(json["chooser"]["libraries"][1]["available"], false); + assert_eq!(json["chooser"]["selected"], 4); + + // the last library can't be opened: the first one that can is selected + settings.library_path = held_path.clone(); + assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(2)); + // none can: the first row, Open greyed out + settings.recent_libraries = vec![gone.clone()]; + assert_eq!(LibraryProblem::choose(&session, &settings).chooser.unwrap().selected, Some(0)); + // an empty list selects nothing + settings.recent_libraries.clear(); + let c = LibraryProblem::choose(&session, &settings).chooser.unwrap(); + assert!(c.libraries.is_empty() && c.selected.is_none()); + assert!(LibraryProblem::new("/x", "e").saves_nothing(), "the failed-library window still saves nothing"); + drop(lock); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the desktop host starts it with *Ask me*: no library open, the list showing. +fn asking(recent: Vec, last: &str, picked: Option) -> Headless { + let session = lightcraft_engine::Session::new().with_fs(); + // (an encoder for the screenshots LIGHTCRAFT_TEST_SHOTS asks for) + let png = |img: &lightcraft_raster::Rgba8| { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default() + }; + let services = Services { + png: Some(Box::new(png)), + write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), + ..Default::default() + }; + let mut app = LightcraftApp::new(session, services); + app.ui.settings.recent_libraries = recent; + app.ui.settings.library_path = last.to_string(); + app.ui.settings.at_launch = LaunchLibrary::Ask; + if let Some(p) = picked { + app.services.pick_folder = Some(Box::new(move || Some(p.clone()))); + } + app.library_problem = Some(LibraryProblem::choose(&app.session, &app.ui.settings)); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + h +} + +/// The window lists every recent library, greys out a missing one and opens the chosen one. +#[test] +fn the_window_lists_the_libraries_and_opens_the_chosen_one() { + let dir = temp("window"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + assert!(h.app.session.library.is_none(), "nothing opened before the choice"); + for id in ["libraryRecent:0", "libraryRecent:1", "libraryRecent:2", "button:libraryOpen", "button:libraryOpenAnother", "button:libraryNew"] { + assert!(has(&h, id), "{id}"); + } + assert!(has(&h, "button:libraryQuit") && has(&h, "button:libraryForget")); + assert!(!has(&h, "button:libraryRetry"), "not the failed-library window"); + if let Some(p) = std::env::var_os("LIGHTCRAFT_TEST_SHOTS") { + let r = h.request("ui.screenshot", json!({"path": Path::new(&p).join("launch-list.png").to_string_lossy(), "headless": true}), T); + assert_eq!(r["ok"], true, "{r}"); + } + let inspect = h.request("ui.inspect", json!({}), T); + let c = &inspect["result"]["libraryProblem"]["chooser"]; + assert_eq!(c["selected"], 0, "{c}"); + assert_eq!(c["libraries"][1]["available"], false, "{c}"); + + // ↓ onto the missing one: Return does nothing there (Open is greyed out) + assert_eq!(h.request("ui.key", json!({"key": "ArrowDown"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none() && h.app.library_problem.is_some()); + // ↓ again, then Open: Beta opens and the window goes away + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpen"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(b.clone())); + assert!(h.app.library_problem.is_none()); + assert!(!has(&h, "button:libraryOpen")); + assert_eq!(h.app.ui.settings.library_path, b); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Return opens the selection; a library taken meanwhile stays listed with the reason. +#[test] +fn return_opens_the_selected_library_and_a_failure_says_why() { + let dir = temp("return"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let mut h = asking(vec![a.clone(), b.clone()], &b, None); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + // another program takes Beta after the window opened: Open says so and keeps the window + let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&b), "another LightCraft").unwrap(); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert!(h.app.session.library.is_none()); + let p = h.app.library_problem.as_ref().unwrap(); + assert!(p.error.contains("already open"), "{}", p.error); + assert!(p.chooser.as_ref().unwrap().libraries[1].unavailable.is_some(), "greyed out now"); + drop(lock); + // ↑ to Alpha, Return: it opens + h.request("ui.key", json!({"key": "ArrowUp"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(a)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Quit and Esc quit without opening anything. +#[test] +fn quit_and_esc_quit_from_the_window() { + let dir = temp("quit"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, None); + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(h.quit_requested(), "Esc quits when no library is open"); + assert!(h.app.session.library.is_none(), "and opens nothing"); + drop(h); + + let mut h = asking(vec![a.clone()], &a, None); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryQuit"}), T)["ok"], true); + settle(&mut h); + assert!(h.quit_requested()); + assert!(h.app.session.library.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Open Another… runs the folder dialog of `app.openLibrary`, also with an empty list. +#[test] +fn open_another_asks_for_a_folder_and_an_empty_list_still_offers_it() { + let dir = temp("another"); + let other = make_library(&dir.join("Elsewhere")); + let mut h = asking(Vec::new(), "", Some(other.clone())); + assert!(has(&h, "indicator:noRecentLibraries")); + assert!(!has(&h, "libraryRecent:0")); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(other.clone())); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.ui.settings.recent_libraries.first(), Some(&other), "remembered once opened"); + drop(h); + + // without a folder dialog (the browser) Open Another… and New Library… are greyed out + let mut h = asking(Vec::new(), "", None); + h.request("ui.clickWidget", json!({"id": "button:libraryOpenAnother"}), T); + h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T); + settle(&mut h); + assert!(h.app.library_problem.is_some() && h.app.ui.dialog.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// New Library… asks for a name in front of the window; command-line photos go into it. +#[test] +fn new_library_from_the_window_asks_for_a_name_and_the_list_returns_if_cancelled() { + let dir = temp("newlib"); + let a = make_library(&dir.join("Alpha")); + let mut h = asking(vec![a.clone()], &a, Some(dir.to_string_lossy().into_owned())); + // a photo given on the command line waits for the library chosen + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 3, 9, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let photo = dir.join("a.png"); + std::fs::write(&photo, png).unwrap(); + h.app.library_problem.as_mut().unwrap().pending_import = vec![photo.to_string_lossy().into_owned()]; + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryNew"}), T)["ok"], true); + settle(&mut h); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::TextPrompt { .. })), "{:?}", h.app.ui.dialog); + assert!(!has(&h, "button:libraryOpen"), "the name prompt is in front"); + h.request("ui.dialog.cancel", json!({}), T); + settle(&mut h); + assert!(has(&h, "button:libraryOpen"), "the list is back"); + // what the prompt's OK runs: the library is made where the folder dialog says, and opens + h.app.run("app.newLibrary", json!({"name": "Fresh"})).unwrap(); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(dir.join("Fresh"))); + assert!(h.app.library_problem.is_none()); + h.step_until(T, |h| h.app.import.is_none() && h.app.session.catalog.len() == 1); + assert_eq!(h.app.session.catalog.len(), 1, "the command-line photo is imported into the new library"); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Remove from List in the window forgets the selected entry and deletes nothing. +#[test] +fn remove_from_list_forgets_one_library_and_deletes_nothing() { + let dir = temp("forget"); + let (a, b) = (make_library(&dir.join("Alpha")), make_library(&dir.join("Beta"))); + let gone = dir.join("Gone").to_string_lossy().into_owned(); + let mut h = asking(vec![a.clone(), gone.clone(), b.clone()], &a, None); + h.app.ui.settings.recent_opened.insert(gone.clone(), "2026-01-01T00:00:00".into()); + // the window: the missing one can be selected and removed + h.request("ui.clickWidget", json!({"id": "libraryRecent:1"}), T); + settle(&mut h); + assert_eq!(h.app.library_problem.as_ref().unwrap().chooser.as_ref().unwrap().selected, Some(1)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, [a.clone(), b.clone()]); + assert!(!h.app.ui.settings.recent_opened.contains_key(&gone), "its time goes too"); + let c = h.app.library_problem.as_ref().unwrap().chooser.clone().unwrap(); + assert_eq!(c.libraries.len(), 2); + assert_eq!(c.selected, Some(1)); + // removing an existing library leaves its folder and catalog alone + h.request("ui.clickWidget", json!({"id": "button:libraryForget"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.recent_libraries, vec![a.clone()]); + assert!(lightcraft_engine::library::is_library(Path::new(&b)), "nothing is deleted on disk"); + settle(&mut h); + assert!(!has(&h, "libraryRecent:1")); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Open Recent Library's items. +fn open_recent(app: &LightcraftApp) -> Vec { + use crate::menubar::MenuNode; + let bar = crate::menubar::menu_bar(app); + let file = &bar.iter().find(|(t, _)| t == "File").unwrap().1; + match file.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Open Recent Library")) { + Some(MenuNode::Submenu { children, .. }) => children.clone(), + _ => panic!("no Open Recent Library submenu in {file:?}"), + } +} + +/// File ▸ Open Recent Library ▸ Remove from List and `app.recentLibraries {remove}`. +#[test] +fn open_recent_library_and_the_command_remove_one_entry() { + use crate::menubar::MenuNode; + let dir = temp("menu"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + let (first, gone) = (dir.join("first").to_string_lossy().into_owned(), dir.join("gone").to_string_lossy().into_owned()); + app.ui.settings.recent_libraries = vec![first.clone(), gone.clone(), "/elsewhere/third".into()]; + + let items = open_recent(&app); + let Some(MenuNode::Submenu { children, .. }) = items.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Remove from List")) + else { + panic!("no Remove from List in {items:?}") + }; + assert_eq!(children.len(), 3, "every entry, missing ones too"); + let MenuNode::Item { id, params, label, enabled, .. } = &children[1] else { panic!("{children:?}") }; + assert_eq!((id.as_str(), params, *enabled), ("app.recentLibraries", &json!({"remove": gone}), true)); + assert!(label.starts_with("gone — "), "{label}"); + assert_eq!(crate::menubar::display_item_label(id, params, label), label, "a path is never translated"); + // what clicking it runs + let list = app.run(id, params.clone()).unwrap(); + assert_eq!(list.as_array().unwrap().len(), 2); + assert_eq!(app.ui.settings.recent_libraries, [first.clone(), "/elsewhere/third".to_string()]); + + // the command: the open library can be forgotten too (it stays open), and nothing is deleted + let list = app.run("app.recentLibraries", json!({"remove": first})).unwrap(); + assert_eq!(list, json!([{"path": "/elsewhere/third", "name": "third", "exists": false, "open": false, "lastOpened": null}])); + assert!(app.session.library.is_some() && lightcraft_engine::library::is_library(&dir.join("first"))); + let e = app.run("app.recentLibraries", json!({"remove": first})).unwrap_err(); + assert!(e.contains("not in the recent libraries"), "{e}"); + let e = app.run("app.recentLibraries", json!({"remove": 3})).unwrap_err(); + assert!(e.contains("remove must be"), "{e}"); + app.run("app.recentLibraries", json!({"remove": "/elsewhere/third"})).unwrap(); + // an empty list has nothing to remove + let items = open_recent(&app); + assert!(!items.iter().any(|n| matches!(n, MenuNode::Submenu { .. })), "{items:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// `atLaunch` and `recentLibrariesOpened` round-trip, and older or odd files still load. +#[test] +fn the_setting_and_the_open_times_are_kept_in_the_settings_file() { + // files from earlier versions: no key, the default; a value from a later version: the default + let old: crate::UiState = serde_json::from_str(r#"{"settings": {"recentLibraries": ["/a"], "startupView": "detail"}}"#).unwrap(); + assert_eq!(old.settings.at_launch, LaunchLibrary::Last); + assert!(old.settings.recent_opened.is_empty()); + assert_eq!(old.settings.recent_libraries, ["/a"]); + let odd: crate::UiState = + serde_json::from_str(r#"{"settings": {"atLaunch": "someday", "recentLibrariesOpened": 7, "startupView": "detail"}}"#).unwrap(); + assert_eq!(odd.settings.at_launch, LaunchLibrary::Last); + assert!(odd.settings.recent_opened.is_empty()); + assert_eq!(odd.settings.startup_view, crate::state::StartupView::Detail, "the rest of the file still loads"); + + let mut ui = crate::UiState::default(); + ui.settings.at_launch = LaunchLibrary::Ask; + ui.settings.recent_libraries = vec!["/a".into()]; + ui.settings.recent_opened.insert("/a".into(), "2026-10-23T09:00:00".into()); + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["settings"]["atLaunch"], "ask"); + assert_eq!(saved["settings"]["recentLibraries"], json!(["/a"]), "the list keeps its old shape"); + assert_eq!(saved["settings"]["recentLibrariesOpened"], json!({"/a": "2026-10-23T09:00:00"})); + let back: crate::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.settings, ui.settings); +} + +/// The Settings choice and `ui.set` change the setting; an opened library gets its time. +#[test] +fn settings_choose_ask_me_and_opening_a_library_records_when() { + let dir = temp("settings"); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("first"), false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.services.pick_folder = Some(Box::new(|| None)); + app.ui.settings.recent_opened.insert("/forgotten".into(), "2020-01-01T00:00:00".into()); + let mut h = Headless::new(app, [1300.0, 900.0], 1.0); + settle(&mut h); + let first = dir.join("first").to_string_lossy().into_owned(); + // the session clock's time (tests: a fixed one), only for listed libraries + assert_eq!(h.app.ui.settings.recent_opened.get(&first).map(String::as_str), Some("2026-09-30T12:00:00")); + assert!(!h.app.ui.settings.recent_opened.contains_key("/forgotten")); + + h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), T); + settle(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-1"}), T)["ok"], true); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + h.request("ui.clickWidget", json!({"id": "button:settingsAtLaunch-0"}), T); + settle(&mut h); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Last); + // also through ui.set, as scripts change settings + let r = h.request("ui.set", json!({"settings": {"atLaunch": "ask"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.settings.at_launch, LaunchLibrary::Ask); + drop(h); + let _ = std::fs::remove_dir_all(&dir); +} + +/// The app as the host starts it with *Open the last library*: `first` open, Alt watched. +fn opened_last(dir: &Path) -> (Headless, String, String) { + let (first, second) = (make_library(&dir.join("first")), make_library(&dir.join("second"))); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(&first, false).unwrap(); + let mut app = LightcraftApp::new(session, Services::default()); + app.ui.settings.recent_libraries = vec![first.clone(), second.clone()]; + app.ui.settings.library_path = first.clone(); + app.alt_asks_for_library = true; + (Headless::new(app, [1300.0, 900.0], 1.0), first, second) +} + +/// Alt held as the window appears shows the list once over the open library; Esc keeps it. +#[test] +fn alt_held_as_the_window_appears_asks_once_over_the_last_library() { + let dir = temp("alt"); + let (mut h, first, _) = opened_last(&dir); + settle(&mut h); + assert!(h.app.library_problem.is_none(), "no Alt: the last library, no window"); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + let c = h.app.library_problem.as_ref().and_then(|p| p.chooser.clone()).expect("the list shows"); + assert_eq!(c.selected, Some(0)); + assert!(c.over.is_some(), "over the library that opened"); + assert!(c.libraries[0].unavailable.is_none(), "the open library itself can be chosen"); + assert!(has(&h, "button:libraryOpen")); + // Esc keeps the library that opened + h.request("ui.key", json!({"key": "Escape"}), T); + settle(&mut h); + assert!(!h.quit_requested(), "Esc doesn't quit while a library is open"); + assert!(h.app.library_problem.is_none()); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(first.clone())); + // once only + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + assert!(h.app.library_problem.is_none()); + drop(h); + + // choosing another library from it switches to that one + let dir2 = temp("alt-switch"); + let (mut h, _, second2) = opened_last(&dir2); + h.app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::ALT)); + settle(&mut h); + h.request("ui.key", json!({"key": "ArrowDown"}), T); + settle(&mut h); + h.request("ui.key", json!({"key": "Enter"}), T); + settle(&mut h); + assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.to_string_lossy().into_owned()), Some(second2)); + assert!(h.app.library_problem.is_none()); + drop(h); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&dir2); +} + +/// The Alt watch ends after the first seconds and needs the host to arm it. +#[test] +fn alt_is_only_watched_while_the_window_first_appears_and_when_armed() { + let frame = |app: &mut LightcraftApp, ctx: &egui::Context, time: f64, alt: bool| { + let modifiers = if alt { egui::Modifiers::ALT } else { egui::Modifiers::NONE }; + let raw = egui::RawInput { time: Some(time), events: vec![egui::Event::ModifiersChanged(modifiers)], ..Default::default() }; + ctx.run_ui(raw, |ui| crate::panels::library_problem::logic(app, ui.ctx())).textures_delta.clear(); + }; + let new_app = |armed| { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + app.alt_asks_for_library = armed; + app + }; + // too late: the watch ends, a later Alt does nothing + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, ALT_SECONDS + 0.5, false); + assert!(!app.alt_asks_for_library); + frame(&mut app, &ctx, ALT_SECONDS + 0.6, true); + assert!(app.library_problem.is_none()); + // not armed (--library, LIGHTCRAFT_LIBRARY, --memory, the browser) + let (mut app, ctx) = (new_app(false), egui::Context::default()); + frame(&mut app, &ctx, 0.1, true); + assert!(app.library_problem.is_none()); + // armed, in time + let (mut app, ctx) = (new_app(true), egui::Context::default()); + frame(&mut app, &ctx, 0.1, false); + assert!(app.alt_asks_for_library && app.library_problem.is_none()); + frame(&mut app, &ctx, 0.2, true); + assert!(app.library_problem.as_ref().is_some_and(|p| p.chooser.is_some())); + assert!(!app.alt_asks_for_library); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index dd41bcdb8..1ade1b76e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -167,6 +167,23 @@ are `button:libraryRetry`, `button:libraryChoose`, `button:libraryTemporary` (Co `button:libraryQuit`. A temporary session shows a banner (`indicator:temporarySession`, `button:libraryReopen`) and writes nothing. `app.openLibrary` opening a library ends it. +### Choosing the library at launch + +With Settings ▸ General ▸ At launch set to Ask me (`ui.set {"settings": {"atLaunch": "ask"}}`; `"last"`, the +default, opens the last library), or Alt/Option held during the first 2 s after the window appears, the desktop app +shows a window listing the recent libraries (issue #1290). `--library` and `LIGHTCRAFT_LIBRARY` skip it, and so does +`--memory`; `lightcraft-cli` never shows it. `ui.inspect` → `libraryProblem.chooser` is `{libraries: [{path, +lastOpened, available, reason}], selected, libraryOpen}` (else `null`; `libraryOpen: true` when it was opened over +the last library with Alt). Each row is the widget `libraryRecent:`; the buttons are `button:libraryOpen`, +`button:libraryOpenAnother` (the folder dialog of `app.openLibrary`), `button:libraryNew` (`app.newLibrary`), +`button:libraryForget` (Remove from List) and `button:libraryQuit`; `ui.key` ArrowUp / ArrowDown move the selection, +Enter opens it and Escape quits (over an open library: closes the window and keeps it). `app.openLibrary {path}` +opening a library ends it. While it shows, the settings are saved as usual (a removal is kept). + +`app.recentLibraries` returns `[{path, name, exists, open, lastOpened}]` (`lastOpened`: ISO 8601, `null` when not +known; kept in `ui.json` → `settings.recentLibrariesOpened`). `{remove: path}` forgets one entry (an error if the list +doesn't hold it) and `{clear: true}` (or `app.clearRecentLibraries`) all of them; nothing on disk is touched. + ### Open Library on a Lightroom catalog `app.openLibrary` never makes a LightCraft library inside a Lightroom Classic catalog (issue #891): given the catalog diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 99f17561e..a780418bc 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (LRC-LIB-CATALOG-SWITCH, LR-PREF-GENERAL: At launch opens the last library or asks with a list of recent ones, one recent entry can be removed, #1290; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -429,7 +429,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-