#1290: choose what opens at launch, the last library or a list of recent ones

Settings > General > At launch: Open the last library (default, as before)
or Ask me. With Ask me the desktop app opens nothing and the library-problem
window lists the recent libraries instead (name, folder, when last opened;
missing, locked or no-longer-library ones greyed out with the reason; the
last one selected): Open, Open Another..., New Library..., Remove from List,
Quit, with arrows / Return / Esc. Alt/Option held in the first 2 s after the
window appears shows the same list once over the library that opened (winit
reports no modifiers before the first frame; macOS only on a change).

File > Open Recent Library > Remove from List and
app.recentLibraries {remove: path} forget one entry; nothing on disk is
touched. The list reports lastOpened, kept under a new settings key
(recentLibrariesOpened) so older files and versions keep their list.

--library and LIGHTCRAFT_LIBRARY keep priority (launch_plan), --memory never
asks, lightcraft-cli never reads ui.json. Settings are saved while the list
shows; command-line files go into whichever library opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbegtYaPuwqZ81pGfELsFK
This commit is contained in:
Claude
2026-10-23 10:49:51 +00:00
parent 23e181283c
commit 2c70336fc3
11 changed files with 1137 additions and 54 deletions
+60 -19
View File
@@ -3,8 +3,10 @@
//! 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 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 (`<config>/models`) and slideshow templates
/// (`<config>/slideshow-templates`) are kept in.
fn open_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_demo: bool) -> (Session, Option<LibraryProblem>) {
let (mut s, problem) = open_library_session(in_memory, dir, seed_demo);
/// (`<config>/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<LibraryProblem>) {
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 <config>/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]
+16 -1
View File
@@ -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"
}
+11 -1
View File
@@ -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<panels::library_problem::LibraryProblem>,
/// 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()));
}
+11 -7
View File
@@ -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<Vec<MenuNode>>
"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<MenuNode> = 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<MenuNode> = 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
+414 -16
View File
@@ -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<PathBuf>, 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<PathBuf>, env: Option<PathBuf>, settings: Option<&AppSettings>, default: Option<PathBuf>) -> 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<String>,
/// Why it can't be opened (`None`: it can), as checked when the window opened or when
/// opening it failed.
pub unavailable: Option<String>,
}
/// 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<RecentLibrary>,
/// The highlighted row (`None`: the list is empty).
pub selected: Option<usize>,
/// 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<u64>,
}
/// 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<Chooser>,
}
impl LibraryProblem {
/// The window for a library at `path` that failed to open with `error`.
pub fn new(path: impl Into<String>, error: impl Into<String>) -> 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<RecentLibrary> = 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<serde_json::Value> = 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<String> {
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<String>) {
}
}
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<String>) {
}
}
/// 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:<index>`.
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) {
+40 -7
View File
@@ -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<Value, String> {
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<Value> = app
.ui
.settings
let settings = &app.ui.settings;
let list: Vec<Value> = 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
+23
View File
@@ -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<String>,
/// 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<String, String>,
/// 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,
+524
View File
@@ -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<PathBuf> {
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<Option<&str>> = 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<String>, last: &str, picked: Option<String>) -> 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<crate::menubar::MenuNode> {
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);
}
+17
View File
@@ -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:<index>`; 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
File diff suppressed because one or more lines are too long
+17
View File
@@ -1,5 +1,22 @@
# What's new in LightCraft
## 2026-10-23
### Choose which library opens at launch
- Settings ▸ General ▸ **At launch**: *Open the last library* (as before) or *Ask me*. With *Ask me*, LightCraft
starts with a window listing your recent libraries: each one's name, the folder it is in and when you last opened
it. One that is gone (moved, renamed, on a disconnected drive), open in another program or no longer a library is
greyed out with the reason. The last library is selected; **Open** (or Return, or a double-click) opens it,
**Open Another…** asks for a folder, **New Library…** makes one, **Quit** (or Esc) quits. ↑ and ↓ move through
the list.
- Holding Alt (Option on a Mac) as the window appears shows the same list once, over the library that opened: open
another one from it, or press Esc to keep working where you were. On a Mac, an Option key held from before
LightCraft starts is only noticed once it changes, so press it as the window appears.
- `--library` and `LIGHTCRAFT_LIBRARY` still decide the library when given, and `lightcraft-cli` never asks.
- File ▸ Open Recent Library ▸ **Remove from List** forgets one library, and the launch window's **Remove from
List** does the same. Only the entry goes: the library and its photos stay where they are. Agents use
`app.recentLibraries {remove: path}`; its list now also says when each library was last opened (`lastOpened`).
## 2026-10-21
### Sharper resampling in HDR and panorama merges