mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
#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:
+60
-19
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user