mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-10 18:39:51 -06:00
LR-BEHAV-EMPTY-STATES: an empty library leads to Import
A library with no photos opened on the Detail view, whose empty state said "Choose a photo in the grid or filmstrip" while the grid was empty and hidden: the only screen that mentioned import was off screen, and Import itself was nowhere but the File menu and its shortcut. - An empty library (no photos, or only deleted or browsed ones) opens on the Photo Grid with the sidebar (Square Grid stays as it is), in the desktop app and in `lightcraft-cli snapshot`. The desktop app switches before it notes what its settings file holds, so the switch alone isn't saved. - The grid and the Detail view show "Your library is empty" with Import Photos… (primary) and Import Lightroom Catalog… (not on the web) as buttons, and a line saying photos and folders can be dropped into the window, with the key Import Photos… is bound to now (`⌘⇧I` on macOS, `Ctrl+Shift+I` elsewhere, or a remapped one). Not while a folder is browsed under Local or Recently Deleted is shown: their photos aren't in the library but are on screen. - The right-hand panel says "No photos yet / Import photos to get started" there instead of "Select a photo to edit". - The top bar has an Import button beside Export in every library. The centred search field's reserve grows by both sides' share (620, and a 190 minimum), so the filter icon stays clear of the icons on the right at every window width, also with macOS's title bar in the window. The top bar's icons are named for screen readers, and a refused click says why in a toast. - `empty_state` is `empty_message` with buttons that do the next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3b4279fc24
commit
18e808c08f
@@ -791,6 +791,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
// before the first frame can start background adapter discovery.
|
||||
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
|
||||
app.ui.settings.gpu = false;
|
||||
// as the desktop app opens it: an empty library on the grid, where Import is offered
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
|
||||
@@ -871,6 +871,11 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// an empty library opens where Import is offered (a grid, with the sidebar); before
|
||||
// the snapshot below, so this alone isn't written to the settings file
|
||||
if problem.is_none() && files.is_empty() {
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
}
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
|
||||
@@ -956,6 +956,11 @@
|
||||
"No new presets": "Keine neuen Vorgaben",
|
||||
"No photo selected": "Kein Foto ausgewählt",
|
||||
"No photos": "Keine Fotos",
|
||||
"Your library is empty": "Deine Bibliothek ist leer",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.",
|
||||
"or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster",
|
||||
"Import photos to get started": "Importiere Fotos, um loszulegen",
|
||||
"or drop photos into this window": "oder ziehe Fotos in dieses Fenster",
|
||||
"No photos found": "Keine Fotos gefunden",
|
||||
"No photos in this folder": "Keine Fotos in diesem Ordner",
|
||||
"No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern",
|
||||
|
||||
@@ -931,6 +931,11 @@
|
||||
"No new presets": "No hay ajustes preestablecidos nuevos",
|
||||
"No photo selected": "No hay ninguna foto seleccionada",
|
||||
"No photos": "Sin fotos",
|
||||
"Your library is empty": "Tu biblioteca está vacía",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Las fotos que importes aparecen aquí. No se copia ni se mueve nada a menos que lo elijas.",
|
||||
"or drop photos and folders into this window": "o arrastra fotos y carpetas a esta ventana",
|
||||
"Import photos to get started": "Importa fotos para empezar",
|
||||
"or drop photos into this window": "o arrastra fotos a esta ventana",
|
||||
"No photos found": "No se encontraron fotos",
|
||||
"No photos in this folder": "No hay fotos en esta carpeta",
|
||||
"No photos match the active filters": "Ninguna foto coincide con los filtros activos",
|
||||
|
||||
@@ -908,6 +908,11 @@
|
||||
"No new presets": "Aucun nouveau préréglage",
|
||||
"No photo selected": "Aucune photo sélectionnée",
|
||||
"No photos": "Aucune photo",
|
||||
"Your library is empty": "Votre bibliothèque est vide",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Les photos importées apparaissent ici. Rien n’est copié ni déplacé, sauf si vous le choisissez.",
|
||||
"or drop photos and folders into this window": "ou déposez des photos et des dossiers dans cette fenêtre",
|
||||
"Import photos to get started": "Importez des photos pour commencer",
|
||||
"or drop photos into this window": "ou déposez des photos dans cette fenêtre",
|
||||
"No photos found": "Aucune photo trouvée",
|
||||
"No photos in this folder": "Aucune photo dans ce dossier",
|
||||
"No photos match the active filters": "Aucune photo ne correspond aux filtres actifs",
|
||||
|
||||
@@ -956,6 +956,11 @@
|
||||
"No new presets": "新しいプリセットはありません",
|
||||
"No photo selected": "写真が選択されていません",
|
||||
"No photos": "写真がありません",
|
||||
"Your library is empty": "ライブラリは空です",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "読み込んだ写真はここに表示されます。選択しない限り、コピーや移動は行われません。",
|
||||
"or drop photos and folders into this window": "または写真やフォルダーをこのウィンドウにドロップ",
|
||||
"Import photos to get started": "写真を読み込んで始めましょう",
|
||||
"or drop photos into this window": "または写真をこのウィンドウにドロップ",
|
||||
"No photos found": "写真が見つかりません",
|
||||
"No photos in this folder": "このフォルダに写真はありません",
|
||||
"No photos match the active filters": "現在のフィルターに一致する写真がありません",
|
||||
|
||||
@@ -920,6 +920,11 @@
|
||||
"No new presets": "Nenhuma predefinição nova",
|
||||
"No photo selected": "Nenhuma foto selecionada",
|
||||
"No photos": "Sem fotos",
|
||||
"Your library is empty": "Sua biblioteca está vazia",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "As fotos que você importar aparecem aqui. Nada é copiado ou movido, a menos que você escolha.",
|
||||
"or drop photos and folders into this window": "ou arraste fotos e pastas para esta janela",
|
||||
"Import photos to get started": "Importe fotos para começar",
|
||||
"or drop photos into this window": "ou arraste fotos para esta janela",
|
||||
"No photos found": "Nenhuma foto encontrada",
|
||||
"No photos in this folder": "Nenhuma foto nesta pasta",
|
||||
"No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos",
|
||||
|
||||
@@ -760,6 +760,11 @@
|
||||
"No new presets": "Новых пресетов нет",
|
||||
"No photo selected": "Фото не выбрано",
|
||||
"No photos": "Нет фото",
|
||||
"Your library is empty": "Библиотека пуста",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.",
|
||||
"or drop photos and folders into this window": "или перетащите фото и папки в это окно",
|
||||
"Import photos to get started": "Импортируйте фото, чтобы начать",
|
||||
"or drop photos into this window": "или перетащите фото в это окно",
|
||||
"No photos found": "Фото не найдены",
|
||||
"No photos in this folder": "В этой папке нет фото",
|
||||
"No photos match the active filters": "Нет фото, соответствующих активным фильтрам",
|
||||
|
||||
@@ -1093,6 +1093,11 @@
|
||||
"No new presets": "Немає нових наборів налаштувань",
|
||||
"No photo selected": "Фото не вибрано",
|
||||
"No photos": "Немає фото",
|
||||
"Your library is empty": "Бібліотека порожня",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "Імпортовані фото з’являться тут. Нічого не копіюється й не переміщується, доки ви самі цього не оберете.",
|
||||
"or drop photos and folders into this window": "або перетягніть фото й теки в це вікно",
|
||||
"Import photos to get started": "Імпортуйте фото, щоб почати",
|
||||
"or drop photos into this window": "або перетягніть фото в це вікно",
|
||||
"No photos found": "Фото не знайдено",
|
||||
"No photos in this folder": "У цій теці немає фото",
|
||||
"No photos match the active filters": "Жодне фото не відповідає активним фільтрам",
|
||||
|
||||
@@ -978,6 +978,11 @@
|
||||
"No new presets": "没有新预设",
|
||||
"No photo selected": "未选择照片",
|
||||
"No photos": "没有照片",
|
||||
"Your library is empty": "图库是空的",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "导入的照片会显示在这里。除非你选择,否则不会复制或移动任何文件。",
|
||||
"or drop photos and folders into this window": "或将照片和文件夹拖放到此窗口",
|
||||
"Import photos to get started": "导入照片以开始使用",
|
||||
"or drop photos into this window": "或将照片拖放到此窗口",
|
||||
"No photos found": "未找到照片",
|
||||
"No photos in this folder": "此文件夹中没有照片",
|
||||
"No photos match the active filters": "没有照片符合当前筛选条件",
|
||||
|
||||
@@ -978,6 +978,11 @@
|
||||
"No new presets": "沒有新預設集",
|
||||
"No photo selected": "尚未選取照片",
|
||||
"No photos": "沒有照片",
|
||||
"Your library is empty": "照片庫是空的",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.": "匯入的照片會顯示在這裡。除非你選擇,否則不會複製或移動任何檔案。",
|
||||
"or drop photos and folders into this window": "或將照片和資料夾拖放到此視窗",
|
||||
"Import photos to get started": "匯入照片以開始使用",
|
||||
"or drop photos into this window": "或將照片拖放到此視窗",
|
||||
"No photos found": "找不到照片",
|
||||
"No photos in this folder": "此資料夾沒有照片",
|
||||
"No photos match the active filters": "沒有照片符合目前的篩選條件",
|
||||
|
||||
@@ -65,6 +65,8 @@ mod tests_dialog_fit;
|
||||
#[cfg(test)]
|
||||
mod tests_dialogs;
|
||||
#[cfg(test)]
|
||||
mod tests_empty_library;
|
||||
#[cfg(test)]
|
||||
mod tests_error_kinds;
|
||||
#[cfg(test)]
|
||||
mod tests_filmstrip;
|
||||
|
||||
@@ -370,7 +370,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max));
|
||||
}
|
||||
let Some(id) = app.session.active() else {
|
||||
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
|
||||
if super::empty_library::shown(&app.session) {
|
||||
super::empty_library::show(app, ui, canvas);
|
||||
} else {
|
||||
super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip");
|
||||
}
|
||||
return;
|
||||
};
|
||||
let Some(photo) = app.session.catalog.photo(id).cloned() else { return };
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! A library with no photos yet: what the grid and the Detail view show instead of a dead end,
|
||||
//! with Import Photos… and Import Lightroom Catalog… as buttons, and the view it opens on.
|
||||
|
||||
use egui::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use super::EmptyAction;
|
||||
use crate::LightcraftApp;
|
||||
use crate::shortcuts::Keymap;
|
||||
use crate::state::ViewMode;
|
||||
|
||||
/// The library holds no photos: none, or only ones in Recently Deleted or browsed under Local.
|
||||
pub fn is_empty(session: &lightcraft_engine::Session) -> bool {
|
||||
!session.catalog.photos().any(|p| p.in_library())
|
||||
}
|
||||
|
||||
/// What is shown is the empty library: not a folder browsed under Local nor Recently Deleted,
|
||||
/// whose photos aren't in the library but are on screen.
|
||||
pub fn shown(session: &lightcraft_engine::Session) -> bool {
|
||||
use lightcraft_engine::LibrarySource;
|
||||
!matches!(session.source, LibrarySource::Folder | LibrarySource::RecentlyDeleted) && is_empty(session)
|
||||
}
|
||||
|
||||
/// At launch, an empty library opens on a grid with the sidebar, where the way in is (Detail had
|
||||
/// only "Choose a photo in the grid" to say). Square Grid stays as it is; a library with photos
|
||||
/// opens as left. The desktop host calls this before it notes what its settings file holds, so
|
||||
/// the switch isn't saved for its own sake.
|
||||
pub fn open_on_empty_library(app: &mut LightcraftApp) {
|
||||
if is_empty(&app.session) {
|
||||
if app.ui.view != ViewMode::SquareGrid {
|
||||
app.ui.view = ViewMode::PhotoGrid;
|
||||
}
|
||||
app.ui.left_panel = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The line under the buttons: dropping works anywhere in the window, and the shortcut is the
|
||||
/// one Import Photos… is bound to now, written as menus write it.
|
||||
pub fn hint(keymap: &Keymap, mac: bool) -> String {
|
||||
// the browser build reads dropped files, not folders
|
||||
let drop = if cfg!(target_arch = "wasm32") {
|
||||
crate::i18n::tr("or drop photos into this window")
|
||||
} else {
|
||||
crate::i18n::tr("or drop photos and folders into this window")
|
||||
};
|
||||
match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") {
|
||||
Some(sc) => format!("{drop} · {}", crate::menubar::shortcut_text(sc, mac)),
|
||||
None => drop.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" };
|
||||
const IMPORT_LIGHTROOM: EmptyAction =
|
||||
EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" };
|
||||
|
||||
/// The empty library's message and buttons, centred in `rect`. A click runs the command as its
|
||||
/// menu item does; a refusal shows as a toast.
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// the browser build can't read a Lightroom catalog from disk: only offer what works there
|
||||
let actions: &[EmptyAction] = if cfg!(target_arch = "wasm32") { &[IMPORT_PHOTOS] } else { &[IMPORT_PHOTOS, IMPORT_LIGHTROOM] };
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
let hint = hint(&app.ui.settings.keymap, mac);
|
||||
let clicked = super::empty_state(
|
||||
ui,
|
||||
rect,
|
||||
"Your library is empty",
|
||||
"Photos you import show up here. Nothing is copied or moved unless you choose to.",
|
||||
actions,
|
||||
Some(&hint),
|
||||
);
|
||||
if let Some(command) = clicked
|
||||
&& let Err(e) = app.run(command, json!({}))
|
||||
{
|
||||
app.toast_error(ui.ctx(), e);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
} else if app.session.source == lightcraft_engine::LibrarySource::Folder {
|
||||
super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local");
|
||||
} else if super::empty_library::shown(&app.session) {
|
||||
super::empty_library::show(app, ui, ui.max_rect());
|
||||
} else {
|
||||
super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here");
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod denoise;
|
||||
pub mod detail;
|
||||
pub mod dialogs;
|
||||
pub mod edit;
|
||||
pub mod empty_library;
|
||||
pub mod faces;
|
||||
pub mod filterbar;
|
||||
pub mod grid;
|
||||
@@ -99,3 +100,72 @@ pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) {
|
||||
p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label);
|
||||
p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim);
|
||||
}
|
||||
|
||||
/// A button under an empty state's message: the command it runs, its label, and the id it is
|
||||
/// registered under (`button:…`) for scripts and tests.
|
||||
pub struct EmptyAction {
|
||||
pub command: &'static str,
|
||||
pub label: &'static str,
|
||||
pub widget: &'static str,
|
||||
}
|
||||
|
||||
/// [`empty_message`] with the next step under it: a row of buttons (the first is the primary
|
||||
/// one, filled with the accent) and an optional dimmed hint line below them. The block is
|
||||
/// centred in `rect` as one. Returns the command of the button clicked this frame.
|
||||
pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actions: &[EmptyAction], hint: Option<&str>) -> Option<&'static str> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let wrap = (rect.width() - 48.0).max(80.0);
|
||||
let centred = |ui: &egui::Ui, text: &str, font: egui::FontId, color: egui::Color32| {
|
||||
let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, wrap);
|
||||
job.halign = egui::Align::Center;
|
||||
ui.painter().layout_job(job)
|
||||
};
|
||||
let title = centred(ui, crate::i18n::tr(title), t.semibold(18.0), t.text_label);
|
||||
let body = centred(ui, crate::i18n::tr(body), t.font(13.0), t.text_dim);
|
||||
let hint = hint.map(|h| centred(ui, h, t.font(12.0), t.text_dim));
|
||||
// the buttons' widths, and whether they fit side by side (else one above the other)
|
||||
const BUTTON_H: f32 = 30.0;
|
||||
const GAP: f32 = 8.0;
|
||||
let widths: Vec<f32> =
|
||||
actions.iter().map(|a| ui.painter().layout_no_wrap(crate::i18n::tr(a.label).to_string(), t.semibold(13.0), t.text).size().x + 32.0).collect();
|
||||
let row_w = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32;
|
||||
let side_by_side = row_w <= wrap;
|
||||
let buttons_h = match (actions.len(), side_by_side) {
|
||||
(0, _) => 0.0,
|
||||
(_, true) => BUTTON_H,
|
||||
(n, false) => n as f32 * BUTTON_H + (n - 1) as f32 * GAP,
|
||||
};
|
||||
let gap_above_buttons = if actions.is_empty() { 0.0 } else { 20.0 };
|
||||
let hint_h = hint.as_ref().map_or(0.0, |g| 12.0 + g.size().y);
|
||||
let block = title.size().y + 6.0 + body.size().y + gap_above_buttons + buttons_h + hint_h;
|
||||
let top = rect.center().y - block / 2.0;
|
||||
let p = ui.painter().clone();
|
||||
let body_top = top + title.size().y + 6.0;
|
||||
let buttons_top = body_top + body.size().y + gap_above_buttons;
|
||||
p.galley(pos2(rect.center().x, top), title, t.text_label);
|
||||
p.galley(pos2(rect.center().x, body_top), body, t.text_dim);
|
||||
let mut clicked = None;
|
||||
let mut x = rect.center().x - row_w / 2.0;
|
||||
for (i, (a, w)) in actions.iter().zip(&widths).enumerate() {
|
||||
let r = if side_by_side {
|
||||
let r = Rect::from_min_size(pos2(x, buttons_top), vec2(*w, BUTTON_H));
|
||||
x += w + GAP;
|
||||
r
|
||||
} else {
|
||||
let w = widths.iter().copied().fold(0.0, f32::max).min(wrap);
|
||||
Rect::from_min_size(pos2(rect.center().x - w / 2.0, buttons_top + i as f32 * (BUTTON_H + GAP)), vec2(w, BUTTON_H))
|
||||
};
|
||||
let label = egui::RichText::new(crate::i18n::tr(a.label)).font(t.semibold(13.0));
|
||||
let button =
|
||||
if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) };
|
||||
let resp = ui.put(r, button);
|
||||
crate::widgets::register(ui.ctx(), a.widget, resp.rect);
|
||||
if resp.clicked() {
|
||||
clicked = Some(a.command);
|
||||
}
|
||||
}
|
||||
if let Some(g) = hint {
|
||||
p.galley(pos2(rect.center().x, buttons_top + buttons_h + 12.0), g, t.text_dim);
|
||||
}
|
||||
clicked
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
return;
|
||||
}
|
||||
let r = ui.max_rect();
|
||||
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
|
||||
// every tab (Edit, Info, Versions…) says it: nothing to show until photos come in
|
||||
if super::empty_library::shown(&app.session) {
|
||||
super::empty_message(ui, r, "No photos yet", "Import photos to get started");
|
||||
} else {
|
||||
super::empty_message(ui, r, "No photo selected", "Select a photo to edit");
|
||||
}
|
||||
return;
|
||||
};
|
||||
egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
|
||||
@@ -53,12 +53,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if app.integrated_titlebar {
|
||||
window_handle(app, ui, full, left as f32, 12.0);
|
||||
}
|
||||
// room for the filter icon and the six icons on the right
|
||||
let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0);
|
||||
// room for the filter icon and the seven icons on the right
|
||||
// (the field is centred, so each icon on the right takes its width from both sides)
|
||||
let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0);
|
||||
if !app.native_menu {
|
||||
// leave room for the in-window menus left of the (centred) search field
|
||||
let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0;
|
||||
sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0);
|
||||
sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0);
|
||||
}
|
||||
let search_left = full.center().x - sw / 2.0;
|
||||
ui.horizontal_centered(|ui| {
|
||||
@@ -119,6 +120,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
// filter icon right of the search field
|
||||
let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0));
|
||||
let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click());
|
||||
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr("Filter bar")));
|
||||
register(ui.ctx(), "icon:filter", fr);
|
||||
let filtering = app.session.filter != Default::default() || app.ui.filter_bar;
|
||||
paint(
|
||||
@@ -161,15 +163,24 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
)
|
||||
});
|
||||
let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed");
|
||||
// Import Photos… beside Export, always one click away, with the key it is bound to now
|
||||
let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac;
|
||||
let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") {
|
||||
Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)),
|
||||
None => crate::i18n::tr("Import Photos…").to_string(),
|
||||
};
|
||||
for (id, icon, tip, cmd) in [
|
||||
("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"),
|
||||
("cloud", Icon::Cloud, cloud_tip, ""),
|
||||
("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"),
|
||||
("share", Icon::Share, "Export", "dialog.export"),
|
||||
("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"),
|
||||
("bell", Icon::Bell, "Activity", "panel.activity"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip));
|
||||
// named for screen readers, as the icon buttons on the left are
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(tip)));
|
||||
register(ui.ctx(), format!("icon:{id}"), r);
|
||||
let warn = id == "cloud" && unsaved.is_some();
|
||||
let colour = if warn {
|
||||
@@ -186,8 +197,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas);
|
||||
register(ui.ctx(), "indicator:unsaved", r);
|
||||
}
|
||||
if resp.clicked() && !cmd.is_empty() {
|
||||
let _ = app.run(cmd, json!({}));
|
||||
// a refusal says why (Import while a Lightroom catalog is coming in)
|
||||
if resp.clicked()
|
||||
&& !cmd.is_empty()
|
||||
&& let Err(e) = app.run(cmd, json!({}))
|
||||
{
|
||||
app.toast_error(ui.ctx(), e);
|
||||
}
|
||||
x -= 40.0;
|
||||
}
|
||||
@@ -195,7 +210,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode);
|
||||
let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0));
|
||||
let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode));
|
||||
let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip);
|
||||
let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip.clone()));
|
||||
register(ui.ctx(), "icon:appearance", r);
|
||||
paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon });
|
||||
if resp.clicked() {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! LR-BEHAV-EMPTY-STATES: a library with no photos leads to Import. It opens on the Photo Grid
|
||||
//! with the sidebar, the grid and the Detail view offer Import Photos… and Import Lightroom
|
||||
//! Catalog… as buttons, and the top bar has an Import button for every library.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::panels::empty_library;
|
||||
use crate::state::ViewMode;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
|
||||
fn has(h: &Headless, id: &str) -> bool {
|
||||
h.app.widgets.iter().any(|(w, _)| w == id)
|
||||
}
|
||||
|
||||
fn app(session: lightcraft_engine::Session) -> LightcraftApp {
|
||||
LightcraftApp::new(session, Services::default())
|
||||
}
|
||||
|
||||
/// A picker that records that it was asked and gives no files (the person cancelled).
|
||||
fn counting_picker(h: &mut Headless) -> Rc<Cell<usize>> {
|
||||
let asked = Rc::new(Cell::new(0));
|
||||
let seen = asked.clone();
|
||||
h.app.services.pick_files = Some(Box::new(move || {
|
||||
seen.set(seen.get() + 1);
|
||||
Vec::new()
|
||||
}));
|
||||
asked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_opens_on_the_grid_with_the_sidebar() {
|
||||
// Given a library with no photos, left in the Detail view with the sidebar hidden
|
||||
let mut a = app(lightcraft_engine::Session::new());
|
||||
a.ui.view = ViewMode::Detail;
|
||||
a.ui.left_panel = false;
|
||||
// When it opens at launch
|
||||
empty_library::open_on_empty_library(&mut a);
|
||||
// Then it shows the Photo Grid and the sidebar, where Import is offered
|
||||
assert_eq!(a.ui.view, ViewMode::PhotoGrid);
|
||||
assert!(a.ui.left_panel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_library_left_on_the_square_grid_stays_there() {
|
||||
let mut a = app(lightcraft_engine::Session::new());
|
||||
a.ui.view = ViewMode::SquareGrid;
|
||||
empty_library::open_on_empty_library(&mut a);
|
||||
assert_eq!(a.ui.view, ViewMode::SquareGrid);
|
||||
assert!(a.ui.left_panel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_with_photos_opens_as_it_was_left() {
|
||||
let mut a = app(lightcraft_engine::Session::with_demo());
|
||||
a.ui.view = ViewMode::Detail;
|
||||
a.ui.left_panel = false;
|
||||
empty_library::open_on_empty_library(&mut a);
|
||||
assert_eq!(a.ui.view, ViewMode::Detail);
|
||||
assert!(!a.ui.left_panel);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_photos_leave_the_library_empty() {
|
||||
let mut s = lightcraft_engine::Session::with_demo();
|
||||
assert!(!empty_library::is_empty(&s));
|
||||
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
|
||||
s.selection.ids = ids.clone();
|
||||
s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
assert!(empty_library::is_empty(&s), "photos in Recently Deleted are not in the library");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_grid_offers_import_as_buttons() {
|
||||
let mut a = app(lightcraft_engine::Session::new());
|
||||
a.ui.view = ViewMode::PhotoGrid;
|
||||
let mut h = Headless::new(a, [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "button:emptyImportPhotos"));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
assert!(has(&h, "button:emptyImportLightroom"));
|
||||
// a click asks for the photos, as File ▸ Import Photos… does
|
||||
let asked = counting_picker(&mut h);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "button:emptyImportPhotos"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(asked.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_empty_detail_view_offers_import_instead_of_the_grid() {
|
||||
// the dead end the critique found: Detail pointed at a grid with nothing in it
|
||||
let mut a = app(lightcraft_engine::Session::new());
|
||||
a.ui.view = ViewMode::Detail;
|
||||
let mut h = Headless::new(a, [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "button:emptyImportPhotos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_with_photos_but_none_selected_keeps_its_message() {
|
||||
let mut a = app(lightcraft_engine::Session::with_demo());
|
||||
a.ui.view = ViewMode::Detail;
|
||||
let mut h = Headless::new(a, [1300.0, 900.0], 1.0);
|
||||
h.app.session.selection.ids.clear();
|
||||
h.app.session.selection.active = None;
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.session.active().is_none());
|
||||
assert!(!has(&h, "button:emptyImportPhotos"), "a library with photos needs no import prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_top_bar_imports_in_every_library() {
|
||||
let mut h = Headless::new(app(lightcraft_engine::Session::with_demo()), [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "icon:import"));
|
||||
let asked = counting_picker(&mut h);
|
||||
assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:import"}), T)["ok"], true);
|
||||
h.step();
|
||||
assert_eq!(asked.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hint_shows_the_shortcut_as_bound() {
|
||||
let mut keymap = crate::state::AppSettings::default().keymap;
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I");
|
||||
assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I");
|
||||
// a remapped shortcut is the one shown
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap();
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I");
|
||||
// and none at all leaves the key out
|
||||
crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap();
|
||||
assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_browsed_under_local_is_not_the_empty_library() {
|
||||
// its photos aren't in the library, but they are what is on screen: no import prompt
|
||||
let mut a = app(lightcraft_engine::Session::new());
|
||||
a.ui.view = ViewMode::Detail;
|
||||
a.session.source = lightcraft_engine::LibrarySource::Folder;
|
||||
assert!(empty_library::is_empty(&a.session));
|
||||
assert!(!empty_library::shown(&a.session));
|
||||
let mut h = Headless::new(a, [1300.0, 900.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!has(&h, "button:emptyImportPhotos"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recently_deleted_is_not_the_empty_library() {
|
||||
// the deleted photos are on screen there: no import prompt beside them
|
||||
let mut s = lightcraft_engine::Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect();
|
||||
s.selection.ids = ids.clone();
|
||||
s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
s.source = lightcraft_engine::LibrarySource::RecentlyDeleted;
|
||||
assert!(empty_library::is_empty(&s));
|
||||
assert!(!empty_library::shown(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_import_button_leaves_the_filter_and_appearance_icons_apart() {
|
||||
// the search field is centred: a seventh icon on the right must not push it into them
|
||||
// with the menus in the window (Windows, Linux, web) and in the native menu bar (macOS)
|
||||
for (width, native_menu) in [900.0, 906.0, 1024.0, 1200.0, 1600.0].into_iter().flat_map(|w| [(w, false), (w, true)]) {
|
||||
let mut a = app(lightcraft_engine::Session::with_demo());
|
||||
// a native menu bar comes with the title bar drawn in the window (macOS): a wider left margin
|
||||
a.native_menu = native_menu;
|
||||
a.integrated_titlebar = native_menu;
|
||||
let mut h = Headless::new(a, [width, 800.0], 1.0);
|
||||
h.step();
|
||||
h.step();
|
||||
let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} at {width}"));
|
||||
let (filter, appearance, import) = (rect("icon:filter"), rect("icon:appearance"), rect("icon:import"));
|
||||
assert!(!filter.intersects(appearance), "{width} (native menu {native_menu}): filter {filter:?} meets appearance {appearance:?}");
|
||||
assert!(!filter.intersects(import), "{width} (native menu {native_menu}): filter {filter:?} meets import {import:?}");
|
||||
}
|
||||
}
|
||||
@@ -470,7 +470,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another |
|
||||
| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button |
|
||||
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
|
||||
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens |
|
||||
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
| LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu |
|
||||
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## 2026-10-14
|
||||
|
||||
### An empty library leads to Import
|
||||
- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a
|
||||
Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app,
|
||||
Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into
|
||||
the window. The shortcut they show is the one Import Photos… has now.
|
||||
- The top bar has an Import button, beside Export, in every library.
|
||||
|
||||
### Hard-edged Heal and Clone spots
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
Reference in New Issue
Block a user