mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
Merge PR #946
# Conflicts: # crates/engine/src/library.rs # crates/ui-egui/locales/de.json # crates/ui-egui/locales/es.json # crates/ui-egui/locales/fr.json # crates/ui-egui/locales/ja.json # crates/ui-egui/locales/pt-br.json # crates/ui-egui/locales/ru.json # crates/ui-egui/locales/uk.json # crates/ui-egui/locales/zh-hans.json # crates/ui-egui/locales/zh-hant.json # crates/ui-egui/src/menus.rs # crates/ui-egui/src/panels/settings.rs # crates/ui-egui/src/state.rs # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
@@ -32,6 +32,32 @@ use crate::{EngineError, LibrarySource, Result, Selection, Session};
|
||||
/// Library directory name inside the user's Pictures folder.
|
||||
pub const DEFAULT_NAME: &str = "LightCraft Library";
|
||||
|
||||
/// The library folder `path` names. A library is a folder; naming one of its own files
|
||||
/// (`catalog.snap`, `catalog.log`, `catalog.lock`) means the folder that holds it. Any other
|
||||
/// existing file is an error that says so, instead of the OS's "File exists" from creating the
|
||||
/// folder.
|
||||
pub fn resolve_dir(path: &Path) -> Result<PathBuf> {
|
||||
if !path.is_file() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
let own = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| [lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG, lightcraft_catalog::lock::LOCK].contains(&n));
|
||||
if !own {
|
||||
return Err(EngineError::Other(format!("{} is a file; a library is a folder", path.display())));
|
||||
}
|
||||
Ok(match path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||
Some(dir) => dir.to_path_buf(),
|
||||
None => PathBuf::from("."),
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `dir` already holds a library (its catalog files).
|
||||
pub fn is_library(dir: &Path) -> bool {
|
||||
dir.join(lightcraft_catalog::journal::SNAPSHOT).is_file() || dir.join(lightcraft_catalog::journal::LOG).is_file()
|
||||
}
|
||||
|
||||
/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library`
|
||||
/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows).
|
||||
pub fn default_dir() -> Option<PathBuf> {
|
||||
@@ -262,6 +288,7 @@ impl Session {
|
||||
if let Some(place) = crate::lightroom_folder::lightroom_catalog_at(&dir) {
|
||||
return Err(EngineError::LightroomCatalog { path: dir, catalog: place.catalog });
|
||||
}
|
||||
let dir = resolve_dir(&dir)?;
|
||||
let open = || FsStore::open(&dir).map_err(|e| EngineError::Other(format!("can't open library {}: {e}", dir.display())));
|
||||
let stores = LibraryStores { catalog: Box::new(open()?), files: Box::new(open()?), on_disk: true, dir: dir.clone() };
|
||||
// the library open in this session (same directory): hand its lock over
|
||||
@@ -663,3 +690,35 @@ fn unlocked_warning(lock: Option<&LibraryLock>) -> Option<String> {
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resolve_tests {
|
||||
use super::*;
|
||||
|
||||
/// `--library <dir>/catalog.snap` failed with the OS's "File exists" (creating a folder over
|
||||
/// the file); it opens the library that file belongs to.
|
||||
#[test]
|
||||
fn a_librarys_own_file_opens_its_folder_and_any_other_file_is_a_clear_error() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-resolve-lib-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&dir, false).unwrap();
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
assert!(is_library(&dir));
|
||||
let snap = dir.join(lightcraft_catalog::journal::SNAPSHOT);
|
||||
assert_eq!(resolve_dir(&snap).unwrap(), dir);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&snap, false).unwrap();
|
||||
assert_eq!(s.library.as_ref().map(|l| l.dir.clone()), Some(dir.clone()));
|
||||
drop(s);
|
||||
|
||||
let other = dir.join("notes.txt");
|
||||
std::fs::write(&other, b"x").unwrap();
|
||||
let e = Session::new().with_fs().open_library(&other, false).err().unwrap().to_string();
|
||||
assert!(e.contains("is a file"), "{e}");
|
||||
assert!(!e.contains("File exists"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,6 @@ fn is_lrcat_name(name: &str) -> bool {
|
||||
name.to_lowercase().ends_with(".lrcat")
|
||||
}
|
||||
|
||||
/// A LightCraft library lives in `dir` (its catalog journal is there).
|
||||
pub fn is_lightcraft_library(dir: &Path) -> bool {
|
||||
[lightcraft_catalog::journal::SNAPSHOT, lightcraft_catalog::journal::LOG].iter().any(|f| dir.join(f).is_file())
|
||||
}
|
||||
|
||||
/// The first `*.lrcat` file (by name) at the top level of `dir`, looking at no more than
|
||||
/// [`SCAN_LIMIT`] entries. Unreadable folders and entries are skipped.
|
||||
pub fn find_lrcat(dir: &Path) -> Option<PathBuf> {
|
||||
@@ -93,7 +88,7 @@ fn catalog_or_scan(candidate: Option<PathBuf>, dir: Option<&Path>) -> Option<Pat
|
||||
/// the module docs). `None`: not Lightroom's, open it as a library.
|
||||
pub fn lightroom_catalog_at(path: &Path) -> Option<LightroomPlace> {
|
||||
let is_dir = path.is_dir();
|
||||
if is_dir && is_lightcraft_library(path) {
|
||||
if is_dir && crate::library::is_library(path) {
|
||||
return None;
|
||||
}
|
||||
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
|
||||
|
||||
@@ -2066,5 +2066,13 @@
|
||||
"Content-Aware": "Inhaltsbasiert",
|
||||
"imported earlier switch over with Photo ▸ Reload from Disk.": "Bereits importierte Fotos wechseln mit Foto → Vom Datenträger neu laden zur RAW-Bearbeitung.",
|
||||
"Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "C-RAW-Dateien der Canon EOS R5 Mark II, die als „Nur Vorschau“ geöffnet wurden (je nach Motiv mehr als die Hälfte),",
|
||||
"scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "werden jetzt wie die übrigen RAW-Dateien der Kamera aus ihren Sensordaten mit allen Werkzeugen bearbeitet."
|
||||
"scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "werden jetzt wie die übrigen RAW-Dateien der Kamera aus ihren Sensordaten mit allen Werkzeugen bearbeitet.",
|
||||
"New Library…": "Neue Bibliothek…",
|
||||
"Open Recent Library": "Zuletzt verwendete Bibliothek öffnen",
|
||||
"Recent Libraries": "Zuletzt verwendete Bibliotheken",
|
||||
"Clear Recent Libraries": "Liste leeren",
|
||||
"No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken",
|
||||
"New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen",
|
||||
"New Library": "Neue Bibliothek",
|
||||
"Library name": "Name der Bibliothek"
|
||||
}
|
||||
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "Perceptual",
|
||||
"Relative": "Relativo",
|
||||
"Simulate Paper & Ink": "Simular papel y tinta",
|
||||
"Content-Aware": "Según el contenido"
|
||||
"Content-Aware": "Según el contenido",
|
||||
"New Library…": "Nueva biblioteca…",
|
||||
"Open Recent Library": "Abrir biblioteca reciente",
|
||||
"Recent Libraries": "Bibliotecas recientes",
|
||||
"Clear Recent Libraries": "Borrar bibliotecas recientes",
|
||||
"No Recent Libraries": "No hay bibliotecas recientes",
|
||||
"New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla",
|
||||
"New Library": "Nueva biblioteca",
|
||||
"Library name": "Nombre de la biblioteca"
|
||||
}
|
||||
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "Perceptif",
|
||||
"Relative": "Relatif",
|
||||
"Simulate Paper & Ink": "Simuler le papier et l'encre",
|
||||
"Content-Aware": "Contenu pris en compte"
|
||||
"Content-Aware": "Contenu pris en compte",
|
||||
"New Library…": "Nouvelle bibliothèque…",
|
||||
"Open Recent Library": "Ouvrir une bibliothèque récente",
|
||||
"Recent Libraries": "Bibliothèques récentes",
|
||||
"Clear Recent Libraries": "Effacer les bibliothèques récentes",
|
||||
"No Recent Libraries": "Aucune bibliothèque récente",
|
||||
"New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement",
|
||||
"New Library": "Nouvelle bibliothèque",
|
||||
"Library name": "Nom de la bibliothèque"
|
||||
}
|
||||
|
||||
@@ -1910,5 +1910,13 @@
|
||||
"Perceptual": "知覚的",
|
||||
"Relative": "相対的",
|
||||
"Simulate Paper & Ink": "紙とインキをシミュレート",
|
||||
"Content-Aware": "コンテンツに応じる"
|
||||
"Content-Aware": "コンテンツに応じる",
|
||||
"New Library…": "新しいライブラリ…",
|
||||
"Open Recent Library": "最近使ったライブラリを開く",
|
||||
"Recent Libraries": "最近使ったライブラリ",
|
||||
"Clear Recent Libraries": "最近使ったライブラリを消去",
|
||||
"No Recent Libraries": "最近使ったライブラリはありません",
|
||||
"New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択",
|
||||
"New Library": "新しいライブラリ",
|
||||
"Library name": "ライブラリ名"
|
||||
}
|
||||
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "Perceptual",
|
||||
"Relative": "Relativo",
|
||||
"Simulate Paper & Ink": "Simular papel e tinta",
|
||||
"Content-Aware": "Sensível ao conteúdo"
|
||||
"Content-Aware": "Sensível ao conteúdo",
|
||||
"New Library…": "Nova biblioteca…",
|
||||
"Open Recent Library": "Abrir biblioteca recente",
|
||||
"Recent Libraries": "Bibliotecas recentes",
|
||||
"Clear Recent Libraries": "Limpar bibliotecas recentes",
|
||||
"No Recent Libraries": "Nenhuma biblioteca recente",
|
||||
"New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la",
|
||||
"New Library": "Nova biblioteca",
|
||||
"Library name": "Nome da biblioteca"
|
||||
}
|
||||
|
||||
@@ -1622,5 +1622,13 @@
|
||||
"Perceptual": "Перцепционный",
|
||||
"Relative": "Относительный",
|
||||
"Simulate Paper & Ink": "Имитировать бумагу и краску",
|
||||
"Content-Aware": "С учётом содержимого"
|
||||
"Content-Aware": "С учётом содержимого",
|
||||
"New Library…": "Новая библиотека…",
|
||||
"Open Recent Library": "Открыть недавнюю библиотеку",
|
||||
"Recent Libraries": "Недавние библиотеки",
|
||||
"Clear Recent Libraries": "Очистить список недавних библиотек",
|
||||
"No Recent Libraries": "Нет недавних библиотек",
|
||||
"New Library: Choose Where to Put It": "Новая библиотека: выберите расположение",
|
||||
"New Library": "Новая библиотека",
|
||||
"Library name": "Название библиотеки"
|
||||
}
|
||||
|
||||
@@ -2102,5 +2102,13 @@
|
||||
"Perceptual": "Перцепційний",
|
||||
"Relative": "Відносний",
|
||||
"Simulate Paper & Ink": "Імітувати папір і фарбу",
|
||||
"Content-Aware": "З урахуванням вмісту"
|
||||
"Content-Aware": "З урахуванням вмісту",
|
||||
"New Library…": "Нова бібліотека…",
|
||||
"Open Recent Library": "Відкрити нещодавню бібліотеку",
|
||||
"Recent Libraries": "Нещодавні бібліотеки",
|
||||
"Clear Recent Libraries": "Очистити список нещодавніх бібліотек",
|
||||
"No Recent Libraries": "Немає нещодавніх бібліотек",
|
||||
"New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування",
|
||||
"New Library": "Нова бібліотека",
|
||||
"Library name": "Назва бібліотеки"
|
||||
}
|
||||
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "可感知",
|
||||
"Relative": "相对",
|
||||
"Simulate Paper & Ink": "模拟纸张和油墨",
|
||||
"Content-Aware": "内容识别"
|
||||
"Content-Aware": "内容识别",
|
||||
"New Library…": "新建图库…",
|
||||
"Open Recent Library": "打开最近的图库",
|
||||
"Recent Libraries": "最近的图库",
|
||||
"Clear Recent Libraries": "清除最近的图库",
|
||||
"No Recent Libraries": "没有最近的图库",
|
||||
"New Library: Choose Where to Put It": "新建图库:选择存放位置",
|
||||
"New Library": "新建图库",
|
||||
"Library name": "图库名称"
|
||||
}
|
||||
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "感知",
|
||||
"Relative": "相對",
|
||||
"Simulate Paper & Ink": "模擬紙張和油墨",
|
||||
"Content-Aware": "內容感知"
|
||||
"Content-Aware": "內容感知",
|
||||
"New Library…": "新增圖庫…",
|
||||
"Open Recent Library": "開啟最近的圖庫",
|
||||
"Recent Libraries": "最近的圖庫",
|
||||
"Clear Recent Libraries": "清除最近的圖庫",
|
||||
"No Recent Libraries": "沒有最近的圖庫",
|
||||
"New Library: Choose Where to Put It": "新增圖庫:選擇存放位置",
|
||||
"New Library": "新增圖庫",
|
||||
"Library name": "圖庫名稱"
|
||||
}
|
||||
|
||||
@@ -334,6 +334,8 @@ pub struct LightcraftApp {
|
||||
pub lightroom: Option<lightroom_import::LightroomTask>,
|
||||
/// Last terminal Lightroom result, exposed by the command's status/wait response.
|
||||
pub lightroom_last: Option<Value>,
|
||||
/// The library generation the window title and the recent list were last updated for.
|
||||
pub shown_library: Option<u64>,
|
||||
/// A background export in progress.
|
||||
pub export: Option<export_task::ExportTask>,
|
||||
/// Background file-system work of other commands (Find Missing Photos, auto import…).
|
||||
@@ -418,6 +420,7 @@ impl LightcraftApp {
|
||||
sync_run: None,
|
||||
lightroom: None,
|
||||
lightroom_last: None,
|
||||
shown_library: None,
|
||||
export: None,
|
||||
tasks: Default::default(),
|
||||
last_export_result: None,
|
||||
@@ -1098,6 +1101,29 @@ impl LightcraftApp {
|
||||
self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms);
|
||||
}
|
||||
|
||||
/// Once per library opened (at launch too): name it in the window title, so nobody edits in
|
||||
/// the wrong one, and put it first in File ▸ Open Recent Library.
|
||||
pub(crate) fn library_changed(&mut self, ctx: &egui::Context) {
|
||||
let generation = self.session.library_generation();
|
||||
if self.shown_library == Some(generation) {
|
||||
return;
|
||||
}
|
||||
self.shown_library = Some(generation);
|
||||
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);
|
||||
}
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
|
||||
}
|
||||
|
||||
/// "LightCraft — <library name>" (just "LightCraft" for a library that isn't saved).
|
||||
pub fn window_title(&self) -> String {
|
||||
match panels::settings::open_library_name(self) {
|
||||
Some(name) => format!("LightCraft — {name}"),
|
||||
None => "LightCraft".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
i18n::set_language(self.ui.language);
|
||||
@@ -1111,6 +1137,7 @@ impl LightcraftApp {
|
||||
if std::mem::take(&mut self.ui.quit) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
self.library_changed(&ctx);
|
||||
// panels set it again this frame while the pointer rests on a preset or profile
|
||||
self.hover_preview = None;
|
||||
self.ai_mask_detail(&ctx);
|
||||
|
||||
@@ -45,7 +45,10 @@ impl MenuNode {
|
||||
|
||||
/// User-defined names in parameterized menus are data, not message keys.
|
||||
pub fn display_item_label<'a>(id: &str, params: &Value, label: &'a str) -> &'a str {
|
||||
if matches!(id, "metadata.applyPreset" | "album.addPhotos" | "label.applySet") || (id == "app.export" && params.get("preset").is_some()) {
|
||||
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())
|
||||
{
|
||||
label
|
||||
} else {
|
||||
crate::i18n::tr(label)
|
||||
@@ -66,7 +69,9 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"file.addFolder",
|
||||
"@Import from Device",
|
||||
"---",
|
||||
"app.newLibrary",
|
||||
"app.openLibrary",
|
||||
"@Open Recent Library",
|
||||
"file.backupLibrary",
|
||||
"file.restoreLibrary",
|
||||
"---",
|
||||
@@ -513,6 +518,30 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g))));
|
||||
v
|
||||
}
|
||||
"Open Recent Library" => {
|
||||
let can = !crate::lightroom_import::is_running(app);
|
||||
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
|
||||
.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)))
|
||||
})
|
||||
.collect();
|
||||
if v.is_empty() {
|
||||
v.push(item("app.clearRecentLibraries", Value::Null, "No Recent Libraries", None, false, None));
|
||||
} else {
|
||||
v.push(MenuNode::Separator);
|
||||
v.push(item("app.clearRecentLibraries", Value::Null, "Clear Recent Libraries", None, true, None));
|
||||
}
|
||||
v
|
||||
}
|
||||
"Import from Device" => {
|
||||
let devices = lightcraft_engine::devices::devices();
|
||||
if devices.is_empty() {
|
||||
|
||||
@@ -181,7 +181,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("file.importCurvePresets", "Import Point Curve Presets…", None, ""),
|
||||
("file.exportCurvePresets", "Export Point Curve Presets…", None, ""),
|
||||
("app.settings", "Settings…", Some("Cmd+,"), "Edit"),
|
||||
("app.newLibrary", "New Library…", None, "File"),
|
||||
("app.openLibrary", "Open Library…", None, "File"),
|
||||
("app.recentLibraries", "Recent Libraries", None, ""),
|
||||
("app.clearRecentLibraries", "Clear Recent Libraries", None, ""),
|
||||
// Settings ▸ Display
|
||||
("app.displayProfile", "Display Profile", None, ""),
|
||||
("app.about", "About LightCraft", None, "Help"),
|
||||
@@ -513,6 +516,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.openLibrary" => crate::panels::settings::open_library(app, p),
|
||||
"app.newLibrary" => crate::panels::settings::new_library(app, p),
|
||||
"app.recentLibraries" => crate::panels::settings::recent_libraries(app, p),
|
||||
"app.clearRecentLibraries" => crate::panels::settings::recent_libraries(app, &json!({"clear": true})),
|
||||
"app.displayProfile" => crate::panels::settings::display_profile(app, p),
|
||||
"file.backupLibrary" | "file.restoreLibrary" => {
|
||||
let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() };
|
||||
@@ -1676,10 +1682,11 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool {
|
||||
"file.exportCurvePresets" => !app.session.curve_presets.is_empty(),
|
||||
"view.compare" => app.session.catalog.len() > 1,
|
||||
"view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen,
|
||||
"app.openLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
|
||||
"app.openLibrary" | "app.newLibrary" | "file.addFolder" => app.services.pick_folder.is_some() && !crate::lightroom_import::is_running(app),
|
||||
"file.backupLibrary" => app.services.backup_library.is_some(),
|
||||
"app.openLogFolder" => app.services.reveal.is_some() && app.services.log_file.is_some(),
|
||||
"file.restoreLibrary" => app.services.restore_library.is_some(),
|
||||
"app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(),
|
||||
"compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare,
|
||||
s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => {
|
||||
app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none()
|
||||
|
||||
@@ -182,8 +182,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
|
||||
frame.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
|
||||
let (hr, header) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover());
|
||||
// the open library's name under the title, so nobody edits in the wrong one (issue #902)
|
||||
let library = crate::panels::settings::open_library_name(app);
|
||||
let title_y = if library.is_some() { hr.center().y - 7.0 } else { hr.center().y };
|
||||
ui.painter().text(pos2(hr.left() + 18.0, title_y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
|
||||
if let Some(name) = library {
|
||||
ui.painter().with_clip_rect(hr.shrink2(vec2(18.0, 0.0))).text(
|
||||
pos2(hr.left() + 18.0, hr.center().y + 10.0),
|
||||
Align2::LEFT_CENTER,
|
||||
name,
|
||||
t.font(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
crate::widgets::register(ui.ctx(), "label:libraryName", hr);
|
||||
if let Some(l) = app.session.library.as_ref() {
|
||||
header.on_hover_text(l.dir.display().to_string());
|
||||
}
|
||||
}
|
||||
let counts = app.caches.counts(&app.session.catalog);
|
||||
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
|
||||
egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
|
||||
@@ -122,6 +122,11 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.openLibrary", json!({}));
|
||||
}
|
||||
let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("New Library…")));
|
||||
register(ui.ctx(), "button:settingsNewLibrary", r.rect);
|
||||
if r.clicked() {
|
||||
let _ = app.run("app.newLibrary", json!({}));
|
||||
}
|
||||
if !can {
|
||||
hint(ui, t, crate::i18n::tr("not available here"));
|
||||
}
|
||||
@@ -659,13 +664,146 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
|
||||
app.ui.dialog = Some(crate::state::Dialog::LightroomCatalog { folder: path.clone(), catalog: catalog.clone() });
|
||||
return Ok(json!({"opened": false, "lightroomCatalog": catalog, "path": path}));
|
||||
}
|
||||
// a remembered library that is gone: say so (and forget it) instead of creating an empty one there
|
||||
if app.ui.settings.recent_libraries.contains(&path) && !std::path::Path::new(&path).exists() {
|
||||
app.ui.settings.recent_libraries.retain(|r| r != &path);
|
||||
return Err(format!("{path}: the library is no longer there (moved, renamed or on a disconnected drive); removed from Open Recent Library"));
|
||||
}
|
||||
switch_library(app, &path)
|
||||
}
|
||||
|
||||
/// Close the current library and open the one at `path`. If it can't be opened (locked by
|
||||
/// another program, unreadable, a file), the error is returned and the current library stays open.
|
||||
fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> {
|
||||
let dir = lightcraft_engine::library::resolve_dir(std::path::Path::new(path)).map_err(|e| e.to_string())?;
|
||||
let path = dir.to_string_lossy().into_owned();
|
||||
// the library being left goes in the list too (also when it was opened at launch)
|
||||
let previous = app.session.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy().into_owned());
|
||||
app.session.close_library().map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&path, false).map_err(|e| e.to_string())?;
|
||||
app.session.open_library(&dir, false).map_err(|e| format!("{path}: {e}"))?;
|
||||
app.lightroom_last = None;
|
||||
app.renderer.forget_all();
|
||||
app.ui.compare = None;
|
||||
app.ui.settings.library_path = path.clone();
|
||||
Ok(json!({"path": path, "photos": app.session.catalog.len()}))
|
||||
if let Some(prev) = previous.filter(|p| p != &path && !app.ui.settings.recent_libraries.contains(p)) {
|
||||
remember_library(&mut app.ui.settings.recent_libraries, &prev);
|
||||
}
|
||||
remember_library(&mut app.ui.settings.recent_libraries, &path);
|
||||
Ok(json!({"path": path, "name": library_name(&dir), "photos": app.session.catalog.len()}))
|
||||
}
|
||||
|
||||
/// Put `path` first in the recent list, without duplicates, capped at [`RECENT_LIBRARIES`].
|
||||
pub fn remember_library(recent: &mut Vec<String>, path: &str) {
|
||||
recent.retain(|r| r != path);
|
||||
recent.insert(0, path.to_string());
|
||||
recent.truncate(crate::state::RECENT_LIBRARIES);
|
||||
}
|
||||
|
||||
/// A library's name as people see it: its folder's name.
|
||||
pub fn library_name(dir: &std::path::Path) -> String {
|
||||
dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| dir.display().to_string())
|
||||
}
|
||||
|
||||
/// The open library's name (`None`: in memory, nothing is saved).
|
||||
pub fn open_library_name(app: &LightcraftApp) -> Option<String> {
|
||||
app.session.library.as_ref().filter(|l| l.on_disk).map(|l| library_name(&l.dir))
|
||||
}
|
||||
|
||||
/// `app.newLibrary {path?, name?}`: create a library and switch to it. With `name`, the library
|
||||
/// is the folder `name` inside `path` (or inside a location chosen in a dialog); without it,
|
||||
/// `path` itself, or a new folder named after the default library. The folder must not hold a
|
||||
/// library already, nor anything else.
|
||||
pub fn new_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if crate::lightroom_import::is_running(app) {
|
||||
return Err("wait for Lightroom catalog import to finish before switching libraries".into());
|
||||
}
|
||||
let name = match p.get("name") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(valid_library_name(s)?),
|
||||
Some(_) => return Err("app.newLibrary: name must be a string".into()),
|
||||
};
|
||||
// from the menu: ask for the name first (default prefilled), then for the location
|
||||
if name.is_none() && p.get("path").is_none() && app.services.pick_folder.is_some() {
|
||||
crate::panels::dialogs::prompt(
|
||||
app,
|
||||
"New Library",
|
||||
"Library name",
|
||||
lightcraft_engine::library::DEFAULT_NAME,
|
||||
"app.newLibrary",
|
||||
json!({}),
|
||||
"name",
|
||||
);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let (location, picked) = match p.get("path").and_then(Value::as_str) {
|
||||
Some(x) => (std::path::PathBuf::from(x), false),
|
||||
None => {
|
||||
let req = crate::pick::PickRequest::folder(crate::i18n::tr("New Library: Choose Where to Put It"));
|
||||
match crate::pick::ask(app, "app.newLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) {
|
||||
crate::pick::Picked::Now(v) => match v.into_iter().next() {
|
||||
Some(x) => (std::path::PathBuf::from(x), true),
|
||||
None => return Ok(Value::Null),
|
||||
},
|
||||
crate::pick::Picked::Later => return Ok(Value::Null),
|
||||
crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let dir = match name {
|
||||
Some(n) => location.join(n),
|
||||
// a chosen location gets a new folder in it, named after the default library
|
||||
None if picked => free_name(&location, lightcraft_engine::library::DEFAULT_NAME),
|
||||
None => location,
|
||||
};
|
||||
if lightcraft_engine::library::is_library(&dir) {
|
||||
return Err(format!("{}: there is a library here already; open it with File ▸ Open Library…", dir.display()));
|
||||
}
|
||||
if dir.is_file() {
|
||||
return Err(format!("{}: is a file; a library is a folder", dir.display()));
|
||||
}
|
||||
if std::fs::read_dir(&dir).is_ok_and(|mut d| d.next().is_some()) {
|
||||
return Err(format!("{}: the folder isn't empty; choose an empty folder or a new name", dir.display()));
|
||||
}
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: can't create the library folder: {e}", dir.display()))?;
|
||||
switch_library(app, &dir.to_string_lossy())
|
||||
}
|
||||
|
||||
fn valid_library_name(s: &str) -> Result<&str, String> {
|
||||
let n = s.trim();
|
||||
if n.is_empty() || n == "." || n == ".." || n.contains(['/', '\\']) || n.chars().any(char::is_control) {
|
||||
return Err(format!("app.newLibrary: {s:?} can't be a library name (a folder name, without / or \\)"));
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// `base` inside `location`, or `base 2`, `base 3`… if that is taken.
|
||||
fn free_name(location: &std::path::Path, base: &str) -> std::path::PathBuf {
|
||||
let first = location.join(base);
|
||||
if !first.exists() {
|
||||
return first;
|
||||
}
|
||||
(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.
|
||||
pub fn recent_libraries(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> {
|
||||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||||
app.ui.settings.recent_libraries.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
|
||||
.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)})
|
||||
})
|
||||
.collect();
|
||||
Ok(Value::Array(list))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------- Display profile
|
||||
|
||||
@@ -158,6 +158,8 @@ impl LightsOut {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// How many libraries File ▸ Open Recent Library remembers.
|
||||
pub const RECENT_LIBRARIES: usize = 8;
|
||||
|
||||
/// App-level preferences (Settings dialog). They belong to the app, not a library, and are
|
||||
/// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library
|
||||
@@ -167,6 +169,9 @@ impl LightsOut {
|
||||
pub struct AppSettings {
|
||||
/// Library opened at launch when no `--library` is given (empty = the default location).
|
||||
pub library_path: String,
|
||||
/// Libraries opened recently, newest first (File ▸ Open Recent Library; at most
|
||||
/// [`RECENT_LIBRARIES`]).
|
||||
pub recent_libraries: Vec<String>,
|
||||
pub startup_view: StartupView,
|
||||
/// Ask before moving photos to Recently Deleted (keyboard and menu).
|
||||
pub confirm_delete: bool,
|
||||
@@ -197,6 +202,7 @@ impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
AppSettings {
|
||||
library_path: String::new(),
|
||||
recent_libraries: Vec::new(),
|
||||
startup_view: StartupView::Last,
|
||||
confirm_delete: false,
|
||||
gpu: true,
|
||||
|
||||
@@ -41,3 +41,124 @@ fn opening_another_library_drops_the_old_librarys_cached_panels() {
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn app_in(dir: &std::path::Path) -> LightcraftApp {
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
session.open_library(dir.join("first"), false).unwrap();
|
||||
LightcraftApp::new(session, Services::default())
|
||||
}
|
||||
|
||||
fn recent_submenu(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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #902: File ▸ New Library… creates a named library and opens it; it shows in the window
|
||||
/// title and in Open Recent Library, newest first.
|
||||
#[test]
|
||||
fn new_library_creates_opens_and_is_remembered_and_named() {
|
||||
let dir = temp("newlib");
|
||||
let mut app = app_in(&dir);
|
||||
app.library_changed(&egui::Context::default());
|
||||
assert_eq!(app.window_title(), "LightCraft — first");
|
||||
|
||||
let r = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap();
|
||||
assert_eq!(r["name"], "Client A");
|
||||
let a = dir.join("Client A");
|
||||
assert_eq!(app.session.library.as_ref().unwrap().dir, a);
|
||||
assert!(lightcraft_engine::library::is_library(&a), "the catalog is written");
|
||||
assert_eq!(app.window_title(), "LightCraft — Client A");
|
||||
assert_eq!(app.ui.settings.library_path, a.to_string_lossy());
|
||||
|
||||
// a second New Library at the same place is refused and the current one stays open
|
||||
let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Client A"})).unwrap_err();
|
||||
assert!(e.contains("library here already"), "{e}");
|
||||
let e = app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "../x"})).unwrap_err();
|
||||
assert!(e.contains("library name"), "{e}");
|
||||
assert_eq!(app.session.library.as_ref().unwrap().dir, a);
|
||||
|
||||
let list = app.run("app.recentLibraries", json!({})).unwrap();
|
||||
let names: Vec<_> = list.as_array().unwrap().iter().map(|r| r["name"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(names, ["Client A", "first"]);
|
||||
assert_eq!(list[0]["open"], true);
|
||||
assert_eq!(list[1]["exists"], true);
|
||||
|
||||
// Open Recent Library reopens "first"
|
||||
let items = recent_submenu(&app);
|
||||
let first = items
|
||||
.iter()
|
||||
.find_map(|n| match n {
|
||||
crate::menubar::MenuNode::Item { id, params, enabled, .. }
|
||||
if id == "app.openLibrary" && params["path"].as_str().is_some_and(|p| p.ends_with("first")) =>
|
||||
{
|
||||
assert!(*enabled);
|
||||
Some(params.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("first in Open Recent Library");
|
||||
app.run("app.openLibrary", first).unwrap();
|
||||
assert_eq!(app.window_title(), "LightCraft — first");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// From the menu, New Library… asks for a name (default prefilled), then where to put it.
|
||||
#[test]
|
||||
fn new_library_from_the_menu_asks_for_a_name_then_a_location() {
|
||||
let dir = temp("newlibprompt");
|
||||
let mut app = app_in(&dir);
|
||||
let loc = dir.to_string_lossy().into_owned();
|
||||
app.services.pick_folder = Some(Box::new(move || Some(loc.clone())));
|
||||
app.run("app.newLibrary", json!({})).unwrap();
|
||||
let Some(crate::state::Dialog::TextPrompt { value, command, key, .. }) = app.ui.dialog.as_mut() else {
|
||||
panic!("no name prompt: {:?}", app.ui.dialog)
|
||||
};
|
||||
assert_eq!(value, lightcraft_engine::library::DEFAULT_NAME);
|
||||
assert_eq!((command.as_str(), key.as_str()), ("app.newLibrary", "name"));
|
||||
*value = "Wedding".into();
|
||||
// what the dialog's OK runs
|
||||
app.ui.dialog = None;
|
||||
app.run("app.newLibrary", json!({"name": "Wedding"})).unwrap();
|
||||
assert_eq!(app.session.library.as_ref().unwrap().dir, dir.join("Wedding"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A remembered library that's gone is greyed out in the menu; opening it anyway is an error that
|
||||
/// keeps the current library open and drops it from the list. The list is capped.
|
||||
#[test]
|
||||
fn missing_recent_libraries_are_greyed_out_and_pruned_and_the_list_is_capped() {
|
||||
let dir = temp("recentlib");
|
||||
let mut app = app_in(&dir);
|
||||
let gone = dir.join("gone").to_string_lossy().into_owned();
|
||||
app.ui.settings.recent_libraries = vec![gone.clone()];
|
||||
let disabled = recent_submenu(&app)
|
||||
.iter()
|
||||
.any(|n| matches!(n, crate::menubar::MenuNode::Item { params, enabled: false, .. } if params["path"] == gone.as_str()));
|
||||
assert!(disabled, "missing library greyed out");
|
||||
let e = app.run("app.openLibrary", json!({"path": gone})).unwrap_err();
|
||||
assert!(e.contains("no longer there"), "{e}");
|
||||
assert!(!std::path::Path::new(&gone).exists(), "no empty library created in its place");
|
||||
assert!(app.ui.settings.recent_libraries.is_empty());
|
||||
assert!(app.session.library.as_ref().unwrap().dir.ends_with("first"));
|
||||
|
||||
for i in 0..20 {
|
||||
crate::panels::settings::remember_library(&mut app.ui.settings.recent_libraries, &format!("/x/{i}"));
|
||||
}
|
||||
assert_eq!(app.ui.settings.recent_libraries.len(), crate::state::RECENT_LIBRARIES);
|
||||
assert_eq!(app.ui.settings.recent_libraries[0], "/x/19");
|
||||
app.run("app.clearRecentLibraries", json!({})).unwrap();
|
||||
assert!(app.ui.settings.recent_libraries.is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -13,6 +13,15 @@
|
||||
scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos
|
||||
imported earlier switch over with Photo ▸ Reload from Disk.
|
||||
|
||||
### Switching libraries
|
||||
- File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902).
|
||||
- File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed
|
||||
out. Clear Recent Libraries empties the list.
|
||||
- The open library's name shows under My Photos at the top of the left panel and in the window title.
|
||||
- `lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with
|
||||
"File exists".
|
||||
- Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`.
|
||||
|
||||
## 2026-10-10
|
||||
|
||||
### Dialogs fit the window
|
||||
|
||||
Reference in New Issue
Block a user