Merge PR #2701: #2701 3fe02631e LRC-LIB-CATALOG-SWITCH: choose what opens at launch, the last library

# Conflicts:
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/src/panels/settings.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
francoisgn
2026-10-23 16:39:33 +03:00
11 changed files with 1203 additions and 53 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.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.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]
+19 -1
View File
@@ -2363,5 +2363,23 @@
"When importing a Lightroom catalog": "À l’importation d’un catalogue Lightroom",
"Ask where to import it": "Demander où l’importer",
"Always merge into the open library": "Toujours fusionner avec la bibliothèque ouverte",
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque."
"The import reads the catalog without changing it. Next, choose whether its photos, edits, keywords and collections join the library open now or go into a new library.": "L’importation lit le catalogue sans le modifier. Ensuite, choisissez si ses photos, retouches, mots-clés et collections rejoignent la bibliothèque ouverte ou une nouvelle bibliothèque.",
"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",
"This is a file, not a library folder": "C’est un fichier, pas un dossier de bibliothèque",
"This folder can't be read:": "Ce dossier ne peut pas être lu :",
"The library's catalog can't be read:": "Le catalogue de la bibliothèque ne peut pas être lu :"
}
+11 -1
View File
@@ -130,6 +130,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;
@@ -513,6 +515,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,
}
@@ -595,6 +600,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(),
}
@@ -1110,7 +1116,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;
}
@@ -1496,6 +1502,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 {
@@ -693,23 +694,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
+423 -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,103 @@ 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 without writing anything
/// ([`unavailable`]): one that is gone, isn't a LightCraft library any more or can't be read
/// is greyed out with the reason. The last library opened is selected, or else the first one
/// that can be opened.
pub fn choose(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(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 (`None`: as far as can be told without opening it).
///
/// Read-only: the folder and its catalog file are looked at, never locked or written (the list
/// may name libraries on other drives, network shares or sleeping disks). A library another
/// program has open is therefore found when Open is pressed: the open fails and the window says
/// who has it.
fn unavailable(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());
}
if !dir.is_dir() {
return Some(crate::i18n::tr("This is a file, not a library folder").to_string());
}
if let Err(e) = std::fs::read_dir(dir) {
return Some(format!("{} {e}", crate::i18n::tr("This folder can't be read:")));
}
// 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());
}
// the catalog the open reads first: the snapshot, else the log alone (opened to read, not read)
let snapshot = dir.join(lightcraft_catalog::journal::SNAPSHOT);
let catalog = if snapshot.is_file() { snapshot } else { dir.join(lightcraft_catalog::journal::LOG) };
if let Err(e) = std::fs::File::open(&catalog) {
return Some(format!("{} {e}", crate::i18n::tr("The library's catalog can't be read:")));
}
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 +205,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 +226,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.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 +332,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 +385,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 +439,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
@@ -268,6 +268,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."));
}
// File ▸ Import Lightroom Catalog… (#2672): the window that asks where a catalog goes, or not
ui.label(RichText::new(crate::i18n::tr("When importing a Lightroom catalog")).color(t.text_label));
row(ui, t, "", |ui| {
@@ -1441,27 +1454,47 @@ pub(crate) fn free_name(location: &std::path::Path, base: &str) -> std::path::Pa
(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).
@@ -491,6 +512,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,
+576
View File
@@ -0,0 +1,576 @@
//! 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 file = dir.join("a-file.txt");
std::fs::write(&file, b"a file").unwrap();
let (gone, plain, file) =
(dir.join("Gone").to_string_lossy().into_owned(), plain.to_string_lossy().into_owned(), file.to_string_lossy().into_owned());
// another program has it open: listing doesn't take locks, so it is found when opened
let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap();
let mut settings = AppSettings {
recent_libraries: vec![gone.clone(), held_path.clone(), a.clone(), plain.clone(), file.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(&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_eq!(why[1], None, "a lock is not probed: {why:?}");
assert_eq!(why[2], None);
assert!(why[3].unwrap().contains("no LightCraft library"), "{why:?}");
assert!(why[4].unwrap().contains("not a library folder"), "{why:?}");
assert_eq!(why[5], None);
assert_eq!(c.selected, Some(5), "the last library opened");
assert_eq!(c.libraries[5].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"][0]["available"], false);
assert_eq!(json["chooser"]["selected"], 5);
// the last library can't be opened: the first one that can is selected
settings.library_path = plain.clone();
assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(1));
// none can: the first row, Open greyed out
settings.recent_libraries = vec![gone.clone(), file.clone()];
assert_eq!(LibraryProblem::choose(&settings).chooser.unwrap().selected, Some(0));
// an empty list selects nothing
settings.recent_libraries.clear();
let c = LibraryProblem::choose(&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);
}
/// Every file under `dir`: relative path → (contents, modification time).
fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, (Vec<u8>, std::time::SystemTime)> {
let mut out = std::collections::BTreeMap::new();
let mut todo = vec![dir.to_path_buf()];
while let Some(d) = todo.pop() {
for e in std::fs::read_dir(&d).unwrap() {
let p = e.unwrap().path();
let m = std::fs::metadata(&p).unwrap();
if m.is_dir() {
todo.push(p.clone());
}
let bytes = if m.is_file() { std::fs::read(&p).unwrap() } else { Vec::new() };
out.insert(p.strip_prefix(dir).unwrap().to_path_buf(), (bytes, m.modified().unwrap()));
}
}
out
}
/// Listing the recent libraries (and drawing the window) writes nothing into any of them: no
/// lock file, no owner file, nothing modified — free, held by another program or not a library.
#[test]
fn listing_the_libraries_leaves_every_folder_byte_identical() {
let dir = temp("readonly");
let (free, fresh, held) = (dir.join("Free"), dir.join("Fresh"), dir.join("Held"));
let (free, held_path) = (make_library(&free), make_library(&held));
// a library whose lock files were never made (copied over from elsewhere, say)
let fresh_path = make_library(&fresh);
for f in [lightcraft_catalog::lock::LOCK, lightcraft_catalog::lock::OWNER] {
let _ = std::fs::remove_file(fresh.join(f));
}
std::fs::create_dir_all(dir.join("Plain")).unwrap();
let lock = lightcraft_catalog::LibraryLock::acquire(Path::new(&held_path), "another LightCraft").unwrap();
let recent = vec![free.clone(), fresh_path.clone(), held_path.clone(), dir.join("Plain").to_string_lossy().into_owned()];
let before = snapshot(&dir);
// (mtime granularity: a write in the same instant would still change contents or add files)
let settings = AppSettings { recent_libraries: recent.clone(), library_path: free.clone(), ..AppSettings::default() };
let p = LibraryProblem::choose(&settings);
assert_eq!(snapshot(&dir), before, "checking the rows must not create or change any file");
assert!(p.chooser.as_ref().unwrap().libraries.iter().take(3).all(|l| l.unavailable.is_none()));
let mut h = asking(recent, &free, None);
settle(&mut h);
assert!(has(&h, "libraryRecent:2"));
drop(h);
assert_eq!(snapshot(&dir), before, "listing must not create or change any file");
assert!(!fresh.join(lightcraft_catalog::lock::LOCK).exists() && !fresh.join(lightcraft_catalog::lock::OWNER).exists());
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.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 in another LightCraft"), "who has it: {}", 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);
}
+19
View File
@@ -168,6 +168,25 @@ 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}` (`available` is checked read-only: the folder and its catalog
file are looked at, never locked or written, so a library another program has open shows as available and Open
reports who has it in `libraryProblem.error`) (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
+18
View File
@@ -15,6 +15,24 @@
- To import as before without the question, choose Settings ▸ General ▸ When importing a Lightroom catalog ▸
Always merge into the open library, or tick Don't ask again in the window. Ask where to import it brings the
question back.
## 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), can't be read or no longer holds a library is
greyed out with the reason. Making the list only looks: it never locks or writes anything in your libraries, so one
that another program has open is reported, with who has it, when you press **Open**, and the window stays. 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