mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
Merge PR #789
# Conflicts: # 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/control.rs # crates/ui-egui/src/panels/left.rs # docs/parity-checklist.md
This commit is contained in:
21 files changed
+2071
-210
No files matched your search
@@ -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.",
|
||||
"Sidebar Sections": "Seitenleistenbereiche",
|
||||
"Reset Sidebar Sections": "Seitenleistenbereiche zurücksetzen",
|
||||
"Hide This Section": "Diesen Bereich ausblenden",
|
||||
"No folders to browse": "Keine Ordner zum Durchsuchen",
|
||||
"No photos yet": "Noch keine Fotos",
|
||||
"No imported folders yet": "Noch keine importierten Ordner",
|
||||
"Show Sidebar Section": "Seitenleistenbereich anzeigen",
|
||||
"Move Sidebar Section": "Seitenleistenbereich verschieben"
|
||||
}
|
||||
@@ -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",
|
||||
"Sidebar Sections": "Secciones de la barra lateral",
|
||||
"Reset Sidebar Sections": "Restablecer secciones de la barra lateral",
|
||||
"Hide This Section": "Ocultar esta sección",
|
||||
"No folders to browse": "No hay carpetas para explorar",
|
||||
"No photos yet": "Aún no hay fotos",
|
||||
"No imported folders yet": "Aún no hay carpetas importadas",
|
||||
"Show Sidebar Section": "Mostrar sección de la barra lateral",
|
||||
"Move Sidebar Section": "Mover sección de la barra lateral"
|
||||
}
|
||||
@@ -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",
|
||||
"Sidebar Sections": "Sections de la barre latérale",
|
||||
"Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale",
|
||||
"Hide This Section": "Masquer cette section",
|
||||
"No folders to browse": "Aucun dossier à parcourir",
|
||||
"No photos yet": "Aucune photo pour l’instant",
|
||||
"No imported folders yet": "Aucun dossier importé pour l’instant",
|
||||
"Show Sidebar Section": "Afficher la section de la barre latérale",
|
||||
"Move Sidebar Section": "Déplacer la section de la barre latérale"
|
||||
}
|
||||
@@ -1910,5 +1910,13 @@
|
||||
"Perceptual": "知覚的",
|
||||
"Relative": "相対的",
|
||||
"Simulate Paper & Ink": "紙とインキをシミュレート",
|
||||
"Content-Aware": "コンテンツに応じる"
|
||||
"Content-Aware": "コンテンツに応じる",
|
||||
"Sidebar Sections": "サイドバーのセクション",
|
||||
"Reset Sidebar Sections": "サイドバーのセクションをリセット",
|
||||
"Hide This Section": "このセクションを隠す",
|
||||
"No folders to browse": "参照できるフォルダがありません",
|
||||
"No photos yet": "写真はまだありません",
|
||||
"No imported folders yet": "読み込んだフォルダはまだありません",
|
||||
"Show Sidebar Section": "サイドバーのセクションを表示",
|
||||
"Move Sidebar Section": "サイドバーのセクションを移動"
|
||||
}
|
||||
@@ -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",
|
||||
"Sidebar Sections": "Seções da barra lateral",
|
||||
"Reset Sidebar Sections": "Redefinir seções da barra lateral",
|
||||
"Hide This Section": "Ocultar esta seção",
|
||||
"No folders to browse": "Nenhuma pasta para navegar",
|
||||
"No photos yet": "Ainda não há fotos",
|
||||
"No imported folders yet": "Ainda não há pastas importadas",
|
||||
"Show Sidebar Section": "Mostrar seção da barra lateral",
|
||||
"Move Sidebar Section": "Mover seção da barra lateral"
|
||||
}
|
||||
@@ -1622,5 +1622,15 @@
|
||||
"Perceptual": "Перцепционный",
|
||||
"Relative": "Относительный",
|
||||
"Simulate Paper & Ink": "Имитировать бумагу и краску",
|
||||
"Content-Aware": "С учётом содержимого"
|
||||
"Content-Aware": "С учётом содержимого",
|
||||
"Sidebar Sections": "Разделы боковой панели",
|
||||
"Reset Sidebar Sections": "Сбросить разделы боковой панели",
|
||||
"Hide This Section": "Скрыть этот раздел",
|
||||
"No folders to browse": "Нет папок для просмотра",
|
||||
"No photos yet": "Фотографий пока нет",
|
||||
"No imported folders yet": "Импортированных папок пока нет",
|
||||
"Show Sidebar Section": "Показать раздел боковой панели",
|
||||
"Move Sidebar Section": "Переместить раздел боковой панели",
|
||||
"Move Up": "Переместить вверх",
|
||||
"Move Down": "Переместить вниз"
|
||||
}
|
||||
@@ -2102,5 +2102,13 @@
|
||||
"Perceptual": "Перцепційний",
|
||||
"Relative": "Відносний",
|
||||
"Simulate Paper & Ink": "Імітувати папір і фарбу",
|
||||
"Content-Aware": "З урахуванням вмісту"
|
||||
"Content-Aware": "З урахуванням вмісту",
|
||||
"Sidebar Sections": "Розділи бічної панелі",
|
||||
"Reset Sidebar Sections": "Скинути розділи бічної панелі",
|
||||
"Hide This Section": "Сховати цей розділ",
|
||||
"No folders to browse": "Немає тек для перегляду",
|
||||
"No photos yet": "Фотографій ще немає",
|
||||
"No imported folders yet": "Імпортованих тек ще немає",
|
||||
"Show Sidebar Section": "Показати розділ бічної панелі",
|
||||
"Move Sidebar Section": "Перемістити розділ бічної панелі"
|
||||
}
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "可感知",
|
||||
"Relative": "相对",
|
||||
"Simulate Paper & Ink": "模拟纸张和油墨",
|
||||
"Content-Aware": "内容识别"
|
||||
"Content-Aware": "内容识别",
|
||||
"Sidebar Sections": "边栏分区",
|
||||
"Reset Sidebar Sections": "重置边栏分区",
|
||||
"Hide This Section": "隐藏此分区",
|
||||
"No folders to browse": "没有可浏览的文件夹",
|
||||
"No photos yet": "还没有照片",
|
||||
"No imported folders yet": "还没有导入的文件夹",
|
||||
"Show Sidebar Section": "显示边栏分区",
|
||||
"Move Sidebar Section": "移动边栏分区"
|
||||
}
|
||||
@@ -1908,5 +1908,13 @@
|
||||
"Perceptual": "感知",
|
||||
"Relative": "相對",
|
||||
"Simulate Paper & Ink": "模擬紙張和油墨",
|
||||
"Content-Aware": "內容感知"
|
||||
"Content-Aware": "內容感知",
|
||||
"Sidebar Sections": "側邊欄區段",
|
||||
"Reset Sidebar Sections": "重設側邊欄區段",
|
||||
"Hide This Section": "隱藏此區段",
|
||||
"No folders to browse": "沒有可瀏覽的資料夾",
|
||||
"No photos yet": "還沒有照片",
|
||||
"No imported folders yet": "還沒有匯入的資料夾",
|
||||
"Show Sidebar Section": "顯示側邊欄區段",
|
||||
"Move Sidebar Section": "移動側邊欄區段"
|
||||
}
|
||||
@@ -316,6 +316,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
ok(Value::Null)
|
||||
}
|
||||
"ui.set" => {
|
||||
// an arrangement of the sidebar is checked, not made whole as a settings file's is
|
||||
let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() {
|
||||
Ok(s) => s,
|
||||
Err(e) => return err(e),
|
||||
};
|
||||
let mut v = serde_json::to_value(&app.ui).unwrap_or_default();
|
||||
lightcraft_develop::presets::deep_merge(&mut v, p);
|
||||
match serde_json::from_value::<crate::UiState>(v) {
|
||||
@@ -323,6 +328,23 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
|
||||
if let Err(error) = crate::docking::set_widths(&mut u, p) {
|
||||
return err(error);
|
||||
}
|
||||
if let Some(sidebar) = sidebar {
|
||||
u.sidebar = sidebar;
|
||||
}
|
||||
// `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections
|
||||
if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) {
|
||||
use crate::sidebar::SidebarSection;
|
||||
if p.get("sidebar").is_some() {
|
||||
return err("ui.set: give `sidebar` or the older `collapsedSidebar`, not both".to_string());
|
||||
}
|
||||
if let Some(unknown) = ids.iter().find(|id| id.as_str().and_then(SidebarSection::from_id).is_none()) {
|
||||
return err(format!("ui.set: `collapsedSidebar`: unknown section {unknown} ({})", SidebarSection::ids()));
|
||||
}
|
||||
for section in SidebarSection::ALL {
|
||||
u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id())));
|
||||
}
|
||||
}
|
||||
u.collapsed_sidebar.clear();
|
||||
u.toast = app.ui.toast.clone();
|
||||
u.dialog = app.ui.dialog.clone();
|
||||
u.status = app.ui.status.clone();
|
||||
|
||||
@@ -29,6 +29,7 @@ pub mod pick;
|
||||
pub mod region;
|
||||
pub mod render;
|
||||
pub mod shortcuts;
|
||||
pub mod sidebar;
|
||||
pub mod softpaint;
|
||||
pub mod state;
|
||||
pub mod sync;
|
||||
@@ -1196,6 +1197,7 @@ impl LightcraftApp {
|
||||
pick::poll(self, &ctx);
|
||||
panels::grid::drag_feedback(self, &ctx);
|
||||
panels::left::album_drag_feedback(self, &ctx);
|
||||
panels::left::section_drag_feedback(self, &ctx);
|
||||
panels::keyword_list::drag_feedback(self, &ctx);
|
||||
panels::toast(self, &ctx);
|
||||
self.widgets = widgets::take_registry(&ctx);
|
||||
|
||||
@@ -124,6 +124,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"view.people",
|
||||
"---",
|
||||
"view.leftPanel",
|
||||
"@Sidebar Sections",
|
||||
"view.photoCounts",
|
||||
"view.faceBoxes",
|
||||
"view.filmstrip",
|
||||
@@ -462,6 +463,19 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
|
||||
v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None));
|
||||
v
|
||||
}
|
||||
"Sidebar Sections" => {
|
||||
// one checkable item per section, in the order the sidebar lists them
|
||||
let layout = &app.ui.sidebar;
|
||||
let mut v: Vec<MenuNode> = layout
|
||||
.sections()
|
||||
.iter()
|
||||
.filter(|s| s.id.available())
|
||||
.map(|s| item("view.sidebarSection", json!({"section": s.id.id()}), s.id.title(), None, true, Some(!s.hidden)))
|
||||
.collect();
|
||||
v.push(MenuNode::Separator);
|
||||
v.push(item("view.sidebarReset", Value::Null, "Reset Sidebar Sections", None, !layout.is_default(), None));
|
||||
v
|
||||
}
|
||||
"Grid Info" => ["filename", "exposure", "date"]
|
||||
.into_iter()
|
||||
.zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"])
|
||||
|
||||
@@ -141,6 +141,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("dialog.newFolder", "New Folder…", Some("Cmd+Shift+N"), "File"),
|
||||
("dialog.smartAlbum", "New Smart Album…", None, "File"),
|
||||
("view.photoCounts", "Show Photo Counts", None, "View"),
|
||||
("view.sidebarSection", "Show Sidebar Section", None, ""),
|
||||
("view.sidebarMoveSection", "Move Sidebar Section", None, ""),
|
||||
("view.sidebarReset", "Reset Sidebar Sections", None, ""),
|
||||
("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"),
|
||||
("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"),
|
||||
("tool.keywordPainter", "Keyword Painter", None, ""),
|
||||
@@ -268,6 +271,28 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> {
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
|
||||
/// The left-sidebar section a `view.sidebar*` command names (`section`), which this build must have.
|
||||
fn sidebar_section_param(command: &str, p: &Value) -> Result<crate::sidebar::SidebarSection, String> {
|
||||
use crate::sidebar::SidebarSection;
|
||||
let Some(name) = p.get("section").and_then(Value::as_str) else {
|
||||
return Err(format!("{command}: `section` is needed ({})", SidebarSection::ids()));
|
||||
};
|
||||
let section = SidebarSection::from_id(name).ok_or_else(|| format!("{command}: unknown section `{name}` ({})", SidebarSection::ids()))?;
|
||||
if !section.available() {
|
||||
return Err(format!("{command}: there is no `{name}` section here"));
|
||||
}
|
||||
Ok(section)
|
||||
}
|
||||
|
||||
/// An optional true / false parameter: absent or null is `None`, anything else is refused.
|
||||
fn bool_param(command: &str, p: &Value, key: &str) -> Result<Option<bool>, String> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||||
Some(other) => Err(format!("{command}: `{key}` must be true or false, not {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle UI commands; `None` means "not a UI command — send it to the engine".
|
||||
pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
if matches!(id, "ui.dock" | "ui.dock.reset") {
|
||||
@@ -331,6 +356,60 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
app.ui.show_counts = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.show_counts);
|
||||
Ok(json!({"show": app.ui.show_counts}))
|
||||
}
|
||||
"view.sidebarSection" => {
|
||||
// {section, show?, collapsed?}: show or hide a section of the left sidebar, fold or
|
||||
// unfold it; with neither, a shown section is hidden and a hidden one shown
|
||||
let args = sidebar_section_param(id, p).and_then(|s| Ok((s, bool_param(id, p, "show")?, bool_param(id, p, "collapsed")?)));
|
||||
let (section, show, collapsed) = match args {
|
||||
Ok(args) => args,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let layout = &mut app.ui.sidebar;
|
||||
match (show, collapsed) {
|
||||
(None, None) => layout.set_hidden(section, !layout.is_hidden(section)),
|
||||
_ => {
|
||||
if let Some(show) = show {
|
||||
layout.set_hidden(section, !show);
|
||||
}
|
||||
if let Some(collapsed) = collapsed {
|
||||
layout.set_collapsed(section, collapsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"section": section.id(), "show": !layout.is_hidden(section), "collapsed": layout.is_collapsed(section)}))
|
||||
}
|
||||
"view.sidebarMoveSection" => {
|
||||
// {section, direction: up | down} one place among the shown sections, or
|
||||
// {section, before: <section> | null} just above another section (null: last)
|
||||
let section = match sidebar_section_param(id, p) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Some(Err(e)),
|
||||
};
|
||||
let layout = &mut app.ui.sidebar;
|
||||
let moved = match (p.get("direction"), p.get("before")) {
|
||||
(Some(d), None) => match d.as_str() {
|
||||
Some("up") => layout.move_by(section, true),
|
||||
Some("down") => layout.move_by(section, false),
|
||||
_ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))),
|
||||
},
|
||||
(None, Some(Value::Null)) => layout.move_before(section, None),
|
||||
(None, Some(b)) => {
|
||||
let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id);
|
||||
let Some(before) = before else {
|
||||
return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids())));
|
||||
};
|
||||
layout.move_before(section, Some(before))
|
||||
}
|
||||
_ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))),
|
||||
};
|
||||
let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect();
|
||||
Ok(json!({"section": section.id(), "moved": moved, "order": order}))
|
||||
}
|
||||
"view.sidebarReset" => {
|
||||
// every section shown and open, in the usual order
|
||||
app.ui.sidebar = Default::default();
|
||||
Ok(json!({"sidebar": app.ui.sidebar}))
|
||||
}
|
||||
"view.gridToggle" => {
|
||||
app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid };
|
||||
Ok(Value::Null)
|
||||
|
||||
@@ -932,6 +932,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
export_synonyms,
|
||||
person,
|
||||
add_to_selected,
|
||||
..
|
||||
} => {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim));
|
||||
// several fields: the name takes the focus when the dialog opens, not after
|
||||
@@ -1517,6 +1518,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
export_synonyms,
|
||||
person,
|
||||
add_to_selected,
|
||||
reveal,
|
||||
} => {
|
||||
let mut params = json!({
|
||||
"synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>(),
|
||||
@@ -1543,7 +1545,11 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
params["name"] = json!(name);
|
||||
params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null };
|
||||
params["addToSelected"] = json!(add_to_selected);
|
||||
app.run("keyword.create", params)
|
||||
let r = app.run("keyword.create", params);
|
||||
if *reveal && let Ok(made) = &r {
|
||||
app.ui.reveal_keyword = made["keyword"].as_str().map(str::to_string);
|
||||
}
|
||||
r
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +364,14 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho
|
||||
|
||||
/// Create Keyword Tag: inside the keyword picked in the list, else the default parent.
|
||||
pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog {
|
||||
let parent =
|
||||
app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.catalog.default_keyword_parent());
|
||||
let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k));
|
||||
create_dialog_inside(app, picked)
|
||||
}
|
||||
|
||||
/// Create Keyword Tag for a keyword inside `parent`, else inside the default parent (Put New
|
||||
/// Keywords Inside This Keyword), else at the top level.
|
||||
pub(crate) fn create_dialog_inside(app: &LightcraftApp, parent: Option<String>) -> Dialog {
|
||||
let parent = parent.or_else(|| app.session.catalog.default_keyword_parent());
|
||||
let d = KeywordInfo::default();
|
||||
Dialog::KeywordTag {
|
||||
editing: None,
|
||||
@@ -378,6 +384,7 @@ pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog {
|
||||
export_synonyms: d.export_synonyms,
|
||||
person: d.person,
|
||||
add_to_selected: false,
|
||||
reveal: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +403,7 @@ pub(crate) fn edit_dialog(app: &LightcraftApp, path: &str) -> Dialog {
|
||||
export_synonyms: info.export_synonyms,
|
||||
person: info.person,
|
||||
add_to_selected: false,
|
||||
reveal: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+580
-185
@@ -7,6 +7,7 @@ use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
use crate::icons::{Icon, paint};
|
||||
use crate::sidebar::SidebarSection;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
@@ -101,7 +102,9 @@ fn row_sensed(
|
||||
);
|
||||
let font = t.font(13.5);
|
||||
let color = if selected { t.text } else { t.text_label };
|
||||
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim));
|
||||
// a count of nothing is there, but fainter: the rows that hold photos stand out
|
||||
let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim };
|
||||
let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n)));
|
||||
let label_left = r.left() + 42.0 + indent;
|
||||
let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x);
|
||||
// a label's dot sits just before the count
|
||||
@@ -143,38 +146,416 @@ fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, ope
|
||||
let tr = ui.interact(tri, id, Sense::click());
|
||||
register(ui.ctx(), widget, tri);
|
||||
let col = if tr.hovered() { t.text } else { t.text_dim };
|
||||
let pts = if open {
|
||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
||||
} else {
|
||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE));
|
||||
ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE));
|
||||
tr
|
||||
}
|
||||
|
||||
/// A collapsible section header (Albums, Local, By Date, Keywords): the bold title with a
|
||||
/// disclosure chevron after it; a click folds or unfolds the section (kept in the UI state, so it
|
||||
/// survives restarts). Returns the header's rect and whether the section is now open.
|
||||
fn sidebar_section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> (Rect, bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let title = crate::i18n::tr(title);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
|
||||
register(ui.ctx(), format!("sidebarSection:{id}"), r);
|
||||
if resp.clicked() {
|
||||
app.ui.toggle_sidebar_section(id);
|
||||
/// Whether the tree row `key` is open: what the user last chose this session, else `default`.
|
||||
fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool {
|
||||
ui.data(|d| d.get_temp(key)).unwrap_or(default)
|
||||
}
|
||||
|
||||
/// Open or close the tree row `key` from now on.
|
||||
fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) {
|
||||
ui.data_mut(|d| d.insert_temp(key, open));
|
||||
}
|
||||
|
||||
/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders,
|
||||
/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response
|
||||
/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too).
|
||||
fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response {
|
||||
let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget);
|
||||
if tri.clicked() {
|
||||
*open = !*open;
|
||||
set_tree_open(ui, key, *open);
|
||||
}
|
||||
let open = !app.ui.sidebar_section_collapsed(id);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
|
||||
let text = ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label);
|
||||
let c = pos2(text.right() + 10.0, r.center().y);
|
||||
let col = if resp.hovered() { t.text } else { t.text_dim };
|
||||
let pts = if open {
|
||||
let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") };
|
||||
tri.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
tri
|
||||
}
|
||||
|
||||
/// A disclosure triangle around `c`: pointing down when open, right when closed.
|
||||
fn triangle_points(c: egui::Pos2, open: bool) -> Vec<egui::Pos2> {
|
||||
if open {
|
||||
vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)]
|
||||
} else {
|
||||
vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)]
|
||||
}
|
||||
}
|
||||
|
||||
/// Space above a section's header.
|
||||
const SECTION_GAP: f32 = 10.0;
|
||||
/// Height of a section's header.
|
||||
const SECTION_HEADER: f32 = 34.0;
|
||||
|
||||
/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body`
|
||||
/// draws. Every section goes through here, so they all fold, space and announce themselves alike.
|
||||
/// `rows`: how many rows the section lists at its top level (a folded header says so).
|
||||
fn section(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
section: SidebarSection,
|
||||
rows: usize,
|
||||
body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui),
|
||||
) -> PlacedSection {
|
||||
ui.add_space(SECTION_GAP);
|
||||
let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover());
|
||||
// a hairline in the gap above tells the sections apart
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let edge = visible_right(ui).min(header.right());
|
||||
let y = header.top() - SECTION_GAP / 2.0;
|
||||
ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider));
|
||||
let show = egui::Id::new("left-show-section");
|
||||
if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show))
|
||||
&& asked == section
|
||||
{
|
||||
ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show));
|
||||
// asked for by the frame before (a request the section was not drawn for is forgotten),
|
||||
// and up or down only: the sidebar stays where it was scrolled sideways
|
||||
if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 {
|
||||
let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-view-top"))).unwrap_or(header.top());
|
||||
let delta = vec2(0.0, view_top - header.top());
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
}
|
||||
let open = section_header(app, ui, section, header, rows, false).open;
|
||||
if open {
|
||||
let rows_from = ui.cursor().top();
|
||||
body(app, ui);
|
||||
if ui.cursor().top() <= rows_from {
|
||||
empty_hint(ui, section);
|
||||
}
|
||||
}
|
||||
PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows }
|
||||
}
|
||||
|
||||
/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider
|
||||
/// than the panel while the sidebar scrolls sideways.
|
||||
fn visible_right(ui: &egui::Ui) -> f32 {
|
||||
ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX)
|
||||
}
|
||||
|
||||
/// The header of the open section whose own header has scrolled out of sight stays at the top of
|
||||
/// the sidebar (widget `sidebarSectionPinned:<id>`), so it is always clear which section the rows
|
||||
/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The
|
||||
/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts.
|
||||
fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) {
|
||||
let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top);
|
||||
let rect = pinned.map(|p| {
|
||||
let top = view_top.min(p.bottom - SECTION_HEADER);
|
||||
Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER)
|
||||
});
|
||||
// rows ask where it is (next frame: they are laid out before it)
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-pinned-rect"), rect));
|
||||
let (Some(p), Some(r)) = (pinned, rect) else { return };
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let view = ui.clip_rect();
|
||||
// over the rows scrolling beneath it
|
||||
ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome);
|
||||
ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider));
|
||||
if section_header(app, ui, p.section, r, p.rows, true).toggled {
|
||||
// folded from here: its own header comes into view, not whatever was below the section
|
||||
// (next frame, when the section is laid out folded: see `section`)
|
||||
let frame = ui.ctx().cumulative_frame_nr();
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), (p.section, frame)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// What an open section with nothing to list says in place of its rows (widget
|
||||
/// `sidebarEmpty:<id>`), so the section stays where it is instead of disappearing.
|
||||
fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover());
|
||||
register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r);
|
||||
let text = crate::i18n::tr(section.empty_hint());
|
||||
let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim);
|
||||
note_width(ui, 42.0 + galley.size().x + 18.0);
|
||||
ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||||
}
|
||||
|
||||
/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct PlacedSection {
|
||||
pub section: SidebarSection,
|
||||
pub top: f32,
|
||||
pub bottom: f32,
|
||||
/// Not folded: its rows (or its note that there are none) are drawn.
|
||||
pub open: bool,
|
||||
/// How many rows it lists at its top level.
|
||||
pub rows: usize,
|
||||
}
|
||||
|
||||
/// What a section's header says happened to it this frame.
|
||||
struct HeaderOutcome {
|
||||
open: bool,
|
||||
/// A click folded or unfolded the section.
|
||||
toggled: bool,
|
||||
}
|
||||
|
||||
/// Where a section dragged to `y` would go among the sections drawn: above the first one whose
|
||||
/// middle is below `y` (`Some`), or after them all (`None`).
|
||||
pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option<SidebarSection> {
|
||||
placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section)
|
||||
}
|
||||
|
||||
/// While a section is dragged by its header: a line shows where it would go, releasing over the
|
||||
/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it.
|
||||
fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) {
|
||||
let Some(dragged) = app.ui.dragging_section else { return };
|
||||
let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape)));
|
||||
if esc {
|
||||
// the drag's Esc, not also a dialog's or a view's
|
||||
crate::widgets::take_escape(ui.ctx());
|
||||
app.ui.dragging_section = None;
|
||||
return;
|
||||
}
|
||||
let view = ui.clip_rect();
|
||||
let Some(pos) = pos.filter(|p| view.contains(*p)) else {
|
||||
if released {
|
||||
app.ui.dragging_section = None;
|
||||
}
|
||||
return;
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, col, egui::Stroke::NONE));
|
||||
(r, open)
|
||||
let before = section_drop_before(placed, pos.y);
|
||||
if released {
|
||||
app.ui.dragging_section = None;
|
||||
let before = before.map_or(serde_json::Value::Null, |b| json!(b.id()));
|
||||
if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// the line: in the gap above the section it would go before, or below the last one
|
||||
let y = match before {
|
||||
Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0),
|
||||
None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0),
|
||||
};
|
||||
if let Some(y) = y {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0));
|
||||
ui.painter().rect_filled(line, 1.0, t.accent);
|
||||
register(ui.ctx(), "sidebarSectionDrop", line);
|
||||
}
|
||||
}
|
||||
|
||||
/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the
|
||||
/// sidebar, or with the sidebar closed) is over.
|
||||
pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(section) = app.ui.dragging_section else { return };
|
||||
let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
|
||||
if !down || !app.ui.left_panel {
|
||||
app.ui.dragging_section = None;
|
||||
return;
|
||||
}
|
||||
let Some(pos) = pos else { return };
|
||||
let t = Tokens::get(ctx);
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
egui::Area::new(egui::Id::new("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(
|
||||
ctx,
|
||||
|ui| {
|
||||
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0)));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their
|
||||
/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so
|
||||
/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at
|
||||
/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo
|
||||
/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`).
|
||||
fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = section.id();
|
||||
let title = crate::i18n::tr(section.title());
|
||||
let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") };
|
||||
let resp = ui.interact(r, ui.id().with((interact, id)), Sense::click_and_drag());
|
||||
register(ui.ctx(), format!("{widget}:{id}"), r);
|
||||
let toggled = resp.clicked();
|
||||
if toggled {
|
||||
app.ui.sidebar.toggle_collapsed(section);
|
||||
}
|
||||
// dragged by its header, a section moves among the others (see `section_drag`); a press on
|
||||
// the header's button is the button's, also when it turns into a drag
|
||||
let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p));
|
||||
if resp.drag_started_by(egui::PointerButton::Primary) && !on_button {
|
||||
// picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`)
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned));
|
||||
app.ui.dragging_section = Some(section);
|
||||
}
|
||||
resp.context_menu(|ui| sections_menu(app, ui, Some(section)));
|
||||
let open = !app.ui.sidebar.is_collapsed(section);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title));
|
||||
// what is visible of the header: its bar and count end at the panel's edge, like a row's
|
||||
let edge = r.right().min(visible_right(ui));
|
||||
let hovered = resp.hovered() && app.ui.dragging_section.is_none();
|
||||
if hovered {
|
||||
let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0));
|
||||
ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6));
|
||||
}
|
||||
let c = pos2(r.left() + 10.0, r.center().y);
|
||||
register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0)));
|
||||
let col = if hovered { t.text } else { t.text_dim };
|
||||
ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE));
|
||||
ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label);
|
||||
let button = header_button(section);
|
||||
// (an album being dragged puts "Top Level" in the Albums header)
|
||||
let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some());
|
||||
if !open && rows > 0 && app.ui.show_counts && room {
|
||||
// folded: how much is in there (left of the header's own button)
|
||||
let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 };
|
||||
let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim);
|
||||
register(ui.ctx(), format!("{widget}Count:{id}"), count);
|
||||
}
|
||||
if section == SidebarSection::Albums {
|
||||
top_level_drop_target(app, ui, r);
|
||||
}
|
||||
if let Some(button) = button {
|
||||
show_header_button(app, ui, r, &button);
|
||||
}
|
||||
HeaderOutcome { open, toggled }
|
||||
}
|
||||
|
||||
/// Side of a header's button.
|
||||
const HEADER_BUTTON: f32 = 26.0;
|
||||
|
||||
/// A button at the end of a section's header (see [`header_button`]).
|
||||
struct HeaderButton {
|
||||
/// Its widget is `icon:<id>`.
|
||||
id: &'static str,
|
||||
icon: Icon,
|
||||
/// What it does, in English: its tooltip and spoken name.
|
||||
tooltip: &'static str,
|
||||
action: HeaderAction,
|
||||
}
|
||||
|
||||
/// What a header's button does when clicked.
|
||||
enum HeaderAction {
|
||||
/// Does it at once (usually: opens a dialog).
|
||||
Run(fn(&mut LightcraftApp, &egui::Context)),
|
||||
/// Opens a menu with these items.
|
||||
Menu(fn(&mut LightcraftApp, &mut egui::Ui)),
|
||||
}
|
||||
|
||||
/// The button a section has at the end of its header, if it has one. This is the one place that
|
||||
/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps
|
||||
/// its clicks from folding the section and leaves it room beside a folded section's count.
|
||||
fn header_button(section: SidebarSection) -> Option<HeaderButton> {
|
||||
match section {
|
||||
SidebarSection::Albums => {
|
||||
Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) })
|
||||
}
|
||||
SidebarSection::Keywords => {
|
||||
Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) })
|
||||
}
|
||||
SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of `header` kept for its button: at the visible edge, also when the sidebar is
|
||||
/// scrolled sideways.
|
||||
fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect {
|
||||
let right = visible_right(ui).min(header.right());
|
||||
Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom()))
|
||||
}
|
||||
|
||||
/// Draw `button` at the end of `header` and act on it.
|
||||
fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) {
|
||||
let mut hdr =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip);
|
||||
match button.action {
|
||||
HeaderAction::Run(run) => {
|
||||
if resp.clicked() {
|
||||
run(app, ui.ctx());
|
||||
}
|
||||
}
|
||||
HeaderAction::Menu(items) => {
|
||||
egui::Popup::menu(&resp).show(|ui| items(app, ui));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside
|
||||
/// whatever is picked in the Keyword List, which may not even be on screen).
|
||||
/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`).
|
||||
fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) {
|
||||
let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None);
|
||||
if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog {
|
||||
*reveal = true;
|
||||
}
|
||||
app.ui.dialog = Some(dialog);
|
||||
}
|
||||
|
||||
/// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section,
|
||||
/// show or hide any section, and go back to the usual arrangement. Its items are widgets
|
||||
/// `sidebarMenu:hide`, `sidebarMenu:section:<id>` and `sidebarMenu:reset`.
|
||||
fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option<SidebarSection>) {
|
||||
let mut run = None;
|
||||
if let Some(section) = on {
|
||||
let hide = ui.button(crate::i18n::tr("Hide This Section"));
|
||||
register(ui.ctx(), "sidebarMenu:hide", hide.rect);
|
||||
if hide.clicked() {
|
||||
run = Some(("view.sidebarSection", json!({"section": section.id(), "show": false})));
|
||||
}
|
||||
for (up, label, widget) in [(true, "Move Up", "sidebarMenu:moveUp"), (false, "Move Down", "sidebarMenu:moveDown")] {
|
||||
let item = ui.add_enabled(app.ui.sidebar.can_move(section, up), egui::Button::new(crate::i18n::tr(label)));
|
||||
register(ui.ctx(), widget, item.rect);
|
||||
if item.clicked() {
|
||||
run = Some(("view.sidebarMoveSection", json!({"section": section.id(), "direction": if up { "up" } else { "down" }})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
for s in app.ui.sidebar.sections().iter().filter(|s| s.id.available()) {
|
||||
let shown = ui.selectable_label(!s.hidden, crate::i18n::tr(s.id.title()));
|
||||
register(ui.ctx(), format!("sidebarMenu:section:{}", s.id.id()), shown.rect);
|
||||
if shown.clicked() {
|
||||
run = Some(("view.sidebarSection", json!({"section": s.id.id(), "show": s.hidden})));
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let reset = ui.add_enabled(!app.ui.sidebar.is_default(), egui::Button::new(crate::i18n::tr("Reset Sidebar Sections")));
|
||||
register(ui.ctx(), "sidebarMenu:reset", reset.rect);
|
||||
if reset.clicked() {
|
||||
run = Some(("view.sidebarReset", json!({})));
|
||||
}
|
||||
if let Some((command, params)) = run {
|
||||
if let Err(e) = app.run(command, params) {
|
||||
app.toast(ui.ctx(), e);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu of Albums' +: Create Album, Smart Album, Folder…
|
||||
fn new_album_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
|
||||
id: None,
|
||||
name: String::new(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
parent: None,
|
||||
});
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None });
|
||||
}
|
||||
// only once the albums were put in an order by hand
|
||||
if app.session.catalog.album_children_are_ordered(None) {
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() {
|
||||
let _ = app.run("album.sort", json!({}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
@@ -182,12 +563,18 @@ 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());
|
||||
// the title offers the sections' menu too: where hidden sections come back from
|
||||
let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
|
||||
register(ui.ctx(), "sidebarTitle", hr);
|
||||
title.context_menu(|ui| sections_menu(app, ui, None));
|
||||
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 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| {
|
||||
drag_auto_scroll(app, ui);
|
||||
// where the visible part of the content starts (screen y), for the pinned header
|
||||
let view_top = ui.cursor().top() + viewport.min.y;
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-view-top"), view_top));
|
||||
// rows are as wide as the widest one needs (last frame), at least the panel
|
||||
let wide = viewport.width().max(content_width(ui.ctx()));
|
||||
ui.set_min_width(wide);
|
||||
@@ -214,83 +601,64 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
let _ = app.run("library.source", json!({"kind": "missing"}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// Albums header
|
||||
let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums");
|
||||
top_level_drop_target(app, ui, ar);
|
||||
// the + stays at the visible edge when the sidebar is scrolled sideways
|
||||
let plus_right = (ar.left() + viewport.max.x).min(ar.right());
|
||||
let mut hdr = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
.max_rect(Rect::from_min_max(pos2(plus_right - 50.0, ar.top()), pos2(plus_right, ar.bottom())))
|
||||
.layout(egui::Layout::right_to_left(egui::Align::Center)),
|
||||
);
|
||||
let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album");
|
||||
egui::Popup::menu(&plus).show(|ui| {
|
||||
if ui.button(crate::i18n::tr("Create Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::SmartRules {
|
||||
id: None,
|
||||
name: String::new(),
|
||||
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
|
||||
parent: None,
|
||||
});
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None });
|
||||
}
|
||||
if ui.button(crate::i18n::tr("Create Folder…")).clicked() {
|
||||
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None });
|
||||
}
|
||||
// only once the albums were put in an order by hand
|
||||
if app.session.catalog.album_children_are_ordered(None) {
|
||||
ui.separator();
|
||||
if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() {
|
||||
let _ = app.run("album.sort", json!({}));
|
||||
}
|
||||
}
|
||||
});
|
||||
// the album just made: the folders down to it open, once (also when the section is shut)
|
||||
let reveal = app.ui.reveal_album.take();
|
||||
if albums_open {
|
||||
let kids: AlbumKids =
|
||||
app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect();
|
||||
let cat = &app.session.catalog;
|
||||
let mut open_to = Vec::new();
|
||||
let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent);
|
||||
while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) {
|
||||
open_to.push(p);
|
||||
cur = cat.album(p).and_then(|a| a.parent);
|
||||
}
|
||||
albums_tree(app, ui, &kids, None, 0.0, &open_to);
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
local_section(app, ui);
|
||||
// By date
|
||||
let (_, dates_open) = sidebar_section_header(app, ui, "byDate", "By Date");
|
||||
let groups = if dates_open { app.caches.date_groups(&app.session.catalog) } else { Default::default() };
|
||||
for g in groups.iter() {
|
||||
// year → month → day; a click filters by that prefix, the triangle opens a level
|
||||
if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
|
||||
for (m, n) in &g.months {
|
||||
let label = crate::i18n::date_group_label(m, true);
|
||||
if date_row(app, ui, m, &label, *n, 16.0) {
|
||||
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
|
||||
let label = crate::i18n::date_group_label(d, true);
|
||||
date_row(app, ui, d, &label, *n, 32.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
folders_section(app, ui);
|
||||
keywords_section(app, ui);
|
||||
ui.add_space(10.0);
|
||||
if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() {
|
||||
let _ = app.run("library.source", json!({"kind": "recentlyDeleted"}));
|
||||
}
|
||||
// the album just made: the folders down to it open, once (also when the section is shut)
|
||||
let reveal = app.ui.reveal_album.take();
|
||||
if reveal.is_some() {
|
||||
// …and Albums itself is shown and opened, so the new album can be seen
|
||||
app.ui.sidebar.reveal(SidebarSection::Albums);
|
||||
}
|
||||
// the keyword just made from Keywords' header: the section and the keywords above it open
|
||||
if let Some(path) = app.ui.reveal_keyword.take() {
|
||||
app.ui.sidebar.reveal(SidebarSection::Keywords);
|
||||
let mut above = String::new();
|
||||
let mut names = path.split('|').peekable();
|
||||
while let Some(name) = names.next().filter(|_| names.peek().is_some()) {
|
||||
if !above.is_empty() {
|
||||
above.push('|');
|
||||
}
|
||||
above.push_str(name);
|
||||
set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true);
|
||||
}
|
||||
}
|
||||
// Local keeps track of the folder being browsed whether or not it is drawn
|
||||
let local = local_now(app, ui);
|
||||
let shown: Vec<SidebarSection> = app.ui.sidebar.shown().collect();
|
||||
let mut placed: Vec<PlacedSection> = Vec::with_capacity(shown.len());
|
||||
for s in shown {
|
||||
let at = match s {
|
||||
SidebarSection::Albums => {
|
||||
let rows = app.session.catalog.albums().filter(|a| a.parent.is_none()).count();
|
||||
section(app, ui, s, rows, |app, ui| albums_rows(app, ui, reveal))
|
||||
}
|
||||
SidebarSection::Local => {
|
||||
let Some(local) = &local else { continue };
|
||||
section(app, ui, s, local.places.places.len(), |app, ui| local_rows(app, ui, local))
|
||||
}
|
||||
SidebarSection::ByDate => {
|
||||
let rows = app.caches.date_groups(&app.session.catalog).len();
|
||||
section(app, ui, s, rows, date_rows)
|
||||
}
|
||||
SidebarSection::Folders => {
|
||||
let tree = app.caches.folder_tree(&app.session.catalog);
|
||||
section(app, ui, s, tree.len(), |app, ui| {
|
||||
reveal_chosen(app, ui, &tree);
|
||||
folder_rows(app, ui, &tree, 0.0);
|
||||
})
|
||||
}
|
||||
SidebarSection::Keywords => {
|
||||
let tree = app.caches.keyword_tree(&app.session.catalog);
|
||||
section(app, ui, s, tree.len(), |app, ui| keyword_rows(app, ui, &tree, 0.0))
|
||||
}
|
||||
};
|
||||
placed.push(at);
|
||||
}
|
||||
// room below the last section (a section dragged to the end is dropped here)
|
||||
ui.add_space(SECTION_GAP * 2.0);
|
||||
pinned_header(app, ui, &placed, view_top);
|
||||
section_drag(app, ui, &placed);
|
||||
// what the rows asked for becomes next frame's width
|
||||
let next = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width-next"))).unwrap_or(0.0);
|
||||
if (next - content_width(ui.ctx())).abs() > 0.5 {
|
||||
@@ -301,6 +669,38 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made,
|
||||
/// whose folders open down to it.
|
||||
fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option<u64>) {
|
||||
let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect();
|
||||
let cat = &app.session.catalog;
|
||||
let mut open_to = Vec::new();
|
||||
let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent);
|
||||
while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) {
|
||||
open_to.push(p);
|
||||
cur = cat.album(p).and_then(|a| a.parent);
|
||||
}
|
||||
albums_tree(app, ui, &kids, None, 0.0, &open_to);
|
||||
}
|
||||
|
||||
/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level.
|
||||
fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let groups = app.caches.date_groups(&app.session.catalog);
|
||||
for g in groups.iter() {
|
||||
if date_row(app, ui, &g.year, &crate::i18n::date_group_label(&g.year, true), g.count, 0.0) {
|
||||
for (m, n) in &g.months {
|
||||
let label = crate::i18n::date_group_label(m, true);
|
||||
if date_row(app, ui, m, &label, *n, 16.0) {
|
||||
for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) {
|
||||
let label = crate::i18n::date_group_label(d, true);
|
||||
date_row(app, ui, d, &label, *n, 32.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many library photos have no file (Local browse records are not checked; see
|
||||
/// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes
|
||||
/// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed
|
||||
@@ -341,13 +741,20 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize {
|
||||
job.n.load(Relaxed)
|
||||
}
|
||||
|
||||
/// Local's folders as they stand this frame, and the folder being browsed among them.
|
||||
struct LocalNow {
|
||||
places: LocalPlaces,
|
||||
current: Option<String>,
|
||||
}
|
||||
|
||||
/// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop,
|
||||
/// Downloads, the home folder, the folder being browsed, and Browse Folder….
|
||||
fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse
|
||||
/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this
|
||||
/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web).
|
||||
fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option<LocalNow> {
|
||||
if !SidebarSection::Local.available() {
|
||||
return None;
|
||||
}
|
||||
let (_, open) = sidebar_section_header(app, ui, "local", "Local");
|
||||
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")).unwrap_or_default();
|
||||
let mut builtin: Vec<(String, String)> = Vec::new();
|
||||
if !home.is_empty() {
|
||||
@@ -362,18 +769,20 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder);
|
||||
let current = browsing.as_ref().map(|b| b.path.clone());
|
||||
let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
|
||||
let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations);
|
||||
if current.is_some() {
|
||||
app.ui.local_browse_root = local.browse_root.clone();
|
||||
}
|
||||
if !open {
|
||||
ui.add_space(10.0);
|
||||
return;
|
||||
app.ui.local_browse_root = places.browse_root.clone();
|
||||
}
|
||||
Some(LocalNow { places, current })
|
||||
}
|
||||
|
||||
/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places.
|
||||
fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) {
|
||||
let (local, current) = (&now.places, now.current.as_deref());
|
||||
for (i, (name, path)) in local.places.iter().enumerate() {
|
||||
let transient = local.browse_root.as_deref() == Some(path.as_str());
|
||||
let reveal = local.owner == Some(i);
|
||||
folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient);
|
||||
folder_tree(app, ui, name, path, 0.0, current, reveal, transient);
|
||||
}
|
||||
if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked()
|
||||
{
|
||||
@@ -399,7 +808,6 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let _ = app.run("local.restoreHidden", json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
|
||||
/// Whether two paths name the same folder, however they are spelled (separators, trailing
|
||||
@@ -580,25 +988,21 @@ fn folder_tree(
|
||||
let open_id = egui::Id::new(("folder-open", path.to_string()));
|
||||
let sel = current.is_some_and(|c| same_folder(c, path));
|
||||
let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
if reveal && on_the_way {
|
||||
// opened once per browsed folder: collapsing it again afterwards sticks
|
||||
let revealed_id = egui::Id::new(("folder-revealed", path.to_string()));
|
||||
let target = current.map(str::to_string);
|
||||
if ui.data(|d| d.get_temp::<Option<String>>(revealed_id)) != Some(target.clone()) {
|
||||
open = true;
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(open_id, true);
|
||||
d.insert_temp(revealed_id, target);
|
||||
});
|
||||
set_tree_open(ui, open_id, true);
|
||||
ui.data_mut(|d| d.insert_temp(revealed_id, target));
|
||||
}
|
||||
}
|
||||
let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path);
|
||||
let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("folder-tri", path.to_string())), format!("folderToggle:{path}"));
|
||||
if tr.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
} else if resp.clicked()
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}"));
|
||||
if !tr.clicked()
|
||||
&& resp.clicked()
|
||||
&& let Err(e) = app.run("library.browse", json!({"path": path}))
|
||||
{
|
||||
app.toast(ui.ctx(), e);
|
||||
@@ -658,15 +1062,11 @@ fn folder_tree(
|
||||
/// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open.
|
||||
fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool {
|
||||
let open_id = egui::Id::new(("date-open", key.to_string()));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
let sel = app.session.filter.date.as_deref() == Some(key);
|
||||
let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent);
|
||||
if key.len() < 10 {
|
||||
let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("date-tri", key.to_string())), format!("dateToggle:{key}"));
|
||||
if tr.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}"));
|
||||
}
|
||||
if resp.clicked() {
|
||||
let v = if sel { serde_json::Value::Null } else { json!(key) };
|
||||
@@ -694,10 +1094,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
|
||||
for a in kids {
|
||||
if a.folder {
|
||||
let open_id = egui::Id::new(("folder-open", a.id.0));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true);
|
||||
let mut open = tree_open(ui, open_id, true);
|
||||
if open_to.contains(&a.id) && !open {
|
||||
open = true;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, true));
|
||||
set_tree_open(ui, open_id, true);
|
||||
}
|
||||
let resp =
|
||||
row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None);
|
||||
@@ -707,20 +1107,23 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare
|
||||
app.ui.dragging_album = Some(a.id.0);
|
||||
}
|
||||
if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, true));
|
||||
set_tree_open(ui, open_id, true);
|
||||
}
|
||||
let mut toggled = resp.clicked();
|
||||
// what this frame draws; a click shows from the next one
|
||||
let draw_open = open;
|
||||
let mut toggled = false;
|
||||
if has_children {
|
||||
let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0));
|
||||
toggled |= tri.clicked();
|
||||
let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0));
|
||||
toggled = tri.clicked();
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
folder_menu(app, &tri, a);
|
||||
}
|
||||
if toggled {
|
||||
ui.data_mut(|d| d.insert_temp(open_id, !open));
|
||||
// one click, one toggle: the triangle's is not the row's as well
|
||||
if resp.clicked() && !toggled {
|
||||
set_tree_open(ui, open_id, !open);
|
||||
}
|
||||
folder_menu(app, &resp, a);
|
||||
if open {
|
||||
if draw_open {
|
||||
albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to);
|
||||
}
|
||||
} else {
|
||||
@@ -774,6 +1177,22 @@ fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool {
|
||||
crate::docking::drop_target(ui, rect, ui.input(|i| i.pointer.latest_pos()))
|
||||
}
|
||||
|
||||
/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the
|
||||
/// pointer there is over the header, not over the row.
|
||||
fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool {
|
||||
pointer_over(ui, rect) && !pointer_on_pinned_header(ui)
|
||||
}
|
||||
|
||||
/// Where the pinned header was drawn last frame, if one was (rows are laid out before it).
|
||||
fn pinned_rect(ui: &egui::Ui) -> Option<Rect> {
|
||||
ui.data(|d| d.get_temp::<Option<Rect>>(egui::Id::new("left-pinned-rect"))).flatten()
|
||||
}
|
||||
|
||||
fn pointer_on_pinned_header(ui: &egui::Ui) -> bool {
|
||||
let pos = ui.input(|i| i.pointer.latest_pos());
|
||||
pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p))
|
||||
}
|
||||
|
||||
/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it.
|
||||
const SCROLL_EDGE: f32 = 36.0;
|
||||
/// The fastest the sidebar scrolls for a drag (points per second).
|
||||
@@ -791,11 +1210,20 @@ pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 {
|
||||
/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call
|
||||
/// inside the scroll area.
|
||||
fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) {
|
||||
if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() {
|
||||
if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() {
|
||||
return;
|
||||
}
|
||||
let view = ui.clip_rect();
|
||||
let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return };
|
||||
// a section picked up by its pinned header starts inside the top edge: it scrolls once it has
|
||||
// left the header. Any other drag scrolls there as at any edge, to reach what is above
|
||||
let from_pinned = egui::Id::new("left-section-drag-from-pinned");
|
||||
if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::<bool>(from_pinned)) == Some(true) {
|
||||
if pointer_on_pinned_header(ui) {
|
||||
return;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(from_pinned, false));
|
||||
}
|
||||
let speed = auto_scroll_speed(p.y, view.top(), view.bottom());
|
||||
if speed != 0.0 {
|
||||
// a positive delta moves the content down: the view goes up
|
||||
@@ -908,7 +1336,7 @@ fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f3
|
||||
fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool {
|
||||
let hover_id = egui::Id::new(("album-hover", row.id.0));
|
||||
let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false };
|
||||
let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over(ui, resp.rect));
|
||||
let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect));
|
||||
let Some(pos) = pos else {
|
||||
if ui.data(|d| d.get_temp::<f64>(hover_id)).is_some() {
|
||||
ui.data_mut(|d| d.remove_temp::<f64>(hover_id));
|
||||
@@ -1006,7 +1434,8 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
/// release there adds them.
|
||||
fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) {
|
||||
let Some(ids) = app.ui.dragging_photos.clone() else { return };
|
||||
let over = pointer_over(ui, resp.rect);
|
||||
// only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header
|
||||
let over = pointer_over_row(ui, resp.rect);
|
||||
if !over {
|
||||
return;
|
||||
}
|
||||
@@ -1178,22 +1607,10 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig
|
||||
false
|
||||
}
|
||||
|
||||
/// "Folders": where on disk the library's photos were imported from, with photo counts (see
|
||||
/// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a
|
||||
/// Local folder: its photos and those of the folders inside it fill the grid. The triangle opens
|
||||
/// a level. Only folders holding imported photos are
|
||||
/// listed; every folder on disk is under Local.
|
||||
fn folders_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let tree = app.caches.folder_tree(&app.session.catalog);
|
||||
if tree.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if sidebar_section_header(app, ui, "folders", "Folders").1 {
|
||||
reveal_chosen(app, ui, &tree);
|
||||
folder_rows(app, ui, &tree, 0.0);
|
||||
}
|
||||
}
|
||||
// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see
|
||||
// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local
|
||||
// folder: its photos and those of the folders inside it fill the grid. The triangle opens a
|
||||
// level. Only folders holding imported photos are listed; every folder on disk is under Local.
|
||||
|
||||
/// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows
|
||||
/// above it so it is on screen; folding one by hand afterwards sticks until the choice changes.
|
||||
@@ -1226,7 +1643,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
||||
// a disk starts open, and so does a folder that holds nothing itself and leads to one
|
||||
// folder (`Users` → `me`): the first row where the library branches is what you look for
|
||||
let leads_on = n.own == 0 && n.children.len() == 1;
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume || leads_on);
|
||||
let mut open = tree_open(ui, open_id, n.volume || leads_on);
|
||||
// a row whose path would cover other disks' photos too only opens and closes
|
||||
let selectable = n.selectable;
|
||||
let sel = selectable
|
||||
@@ -1243,11 +1660,8 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
||||
let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), format!("libraryFolderToggle:{}", n.path));
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path));
|
||||
toggled = tr.clicked();
|
||||
tr.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") })
|
||||
});
|
||||
// the triangle sits on the row and takes its clicks: the menu opens from it too
|
||||
row_menu(app, &tr, n);
|
||||
}
|
||||
@@ -1258,13 +1672,10 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
||||
// a source like an album or a Local folder: it replaces what the grid showed
|
||||
let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path}));
|
||||
} else {
|
||||
toggled = true;
|
||||
open = !open;
|
||||
set_tree_open(ui, open_id, open);
|
||||
}
|
||||
}
|
||||
if toggled {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
row_menu(app, &resp, n);
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, indent + 16.0);
|
||||
@@ -1377,30 +1788,14 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
||||
/// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click
|
||||
/// filters the grid by the keyword (children included), the triangle opens a level, and the
|
||||
/// context menu renames, merges or deletes the keyword across the library.
|
||||
fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
let tree = app.caches.keyword_tree(&app.session.catalog);
|
||||
if tree.is_empty() {
|
||||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
if sidebar_section_header(app, ui, "keywords", "Keywords").1 {
|
||||
keyword_rows(app, ui, &tree, 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) {
|
||||
for n in nodes {
|
||||
let open_id = egui::Id::new(("kw-open", n.path.to_lowercase()));
|
||||
let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false);
|
||||
let mut open = tree_open(ui, open_id, false);
|
||||
let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path));
|
||||
let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent);
|
||||
if !n.children.is_empty() {
|
||||
let tr =
|
||||
disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("kw-tri", n.path.to_lowercase())), format!("keywordToggle:{}", n.path));
|
||||
if tr.clicked() {
|
||||
open = !open;
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
}
|
||||
tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path));
|
||||
}
|
||||
let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) });
|
||||
if resp.clicked() {
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
//! The left sidebar's sections and how the user arranged them: their order, which are hidden and
|
||||
//! which are folded shut. No drawing here (that is `panels/left.rs`); this is the arrangement
|
||||
//! itself, saved with the UI state and changed by the `view.sidebar*` commands.
|
||||
//!
|
||||
//! My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a
|
||||
//! section: it stays at the top and can't be hidden, moved or folded.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A section of the left sidebar below My Photos.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SidebarSection {
|
||||
Albums,
|
||||
Local,
|
||||
ByDate,
|
||||
Folders,
|
||||
Keywords,
|
||||
}
|
||||
|
||||
impl SidebarSection {
|
||||
/// Every section, in the order a new library shows them.
|
||||
pub const ALL: [SidebarSection; 5] =
|
||||
[SidebarSection::Albums, SidebarSection::Local, SidebarSection::ByDate, SidebarSection::Folders, SidebarSection::Keywords];
|
||||
|
||||
/// The name commands, the saved UI state and widget ids (`sidebarSection:<id>`) use.
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
SidebarSection::Albums => "albums",
|
||||
SidebarSection::Local => "local",
|
||||
SidebarSection::ByDate => "byDate",
|
||||
SidebarSection::Folders => "folders",
|
||||
SidebarSection::Keywords => "keywords",
|
||||
}
|
||||
}
|
||||
|
||||
/// The heading, in English (translated where it is shown).
|
||||
pub const fn title(self) -> &'static str {
|
||||
match self {
|
||||
SidebarSection::Albums => "Albums",
|
||||
SidebarSection::Local => "Local",
|
||||
SidebarSection::ByDate => "By Date",
|
||||
SidebarSection::Folders => "Folders",
|
||||
SidebarSection::Keywords => "Keywords",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the section says while it has nothing to list, in English.
|
||||
pub const fn empty_hint(self) -> &'static str {
|
||||
match self {
|
||||
SidebarSection::Albums => "No albums yet",
|
||||
SidebarSection::Local => "No folders to browse",
|
||||
SidebarSection::ByDate => "No photos yet",
|
||||
SidebarSection::Folders => "No imported folders yet",
|
||||
SidebarSection::Keywords => "No keywords yet",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(id: &str) -> Option<SidebarSection> {
|
||||
Self::ALL.into_iter().find(|s| s.id() == id)
|
||||
}
|
||||
|
||||
/// Whether this build has the section at all (Local browses folders on disk: not on the web).
|
||||
pub const fn available(self) -> bool {
|
||||
!(cfg!(target_arch = "wasm32") && matches!(self, SidebarSection::Local))
|
||||
}
|
||||
|
||||
/// The ids of every section, for error messages (`albums|local|…`).
|
||||
pub fn ids() -> String {
|
||||
Self::ALL.map(SidebarSection::id).join("|")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where one section stands: shown or hidden, open or folded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct SectionState {
|
||||
pub id: SidebarSection,
|
||||
pub hidden: bool,
|
||||
pub collapsed: bool,
|
||||
}
|
||||
|
||||
impl SectionState {
|
||||
const fn new(id: SidebarSection) -> Self {
|
||||
Self { id, hidden: false, collapsed: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrangement of the sidebar's sections, top to bottom. Every section is in it exactly once,
|
||||
/// whatever was read from disk or sent by an agent: unknown and repeated entries are dropped and
|
||||
/// sections not named come last, in their usual order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(from = "serde_json::Value", into = "Vec<SectionState>")]
|
||||
pub struct SidebarLayout(Vec<SectionState>);
|
||||
|
||||
impl Default for SidebarLayout {
|
||||
fn default() -> Self {
|
||||
Self(SidebarSection::ALL.map(SectionState::new).to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SidebarLayout> for Vec<SectionState> {
|
||||
fn from(layout: SidebarLayout) -> Self {
|
||||
layout.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Lenient on purpose: a settings file edited by hand, written by a newer version, or damaged
|
||||
/// must still open, so whatever can't be understood is left out instead of failing the load.
|
||||
impl From<serde_json::Value> for SidebarLayout {
|
||||
fn from(saved: serde_json::Value) -> Self {
|
||||
let entries = saved.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
let flag = |entry: &serde_json::Value, key: &str| entry.get(key).and_then(serde_json::Value::as_bool).unwrap_or(false);
|
||||
let read = entries.iter().filter_map(|entry| {
|
||||
// an entry is `{id, hidden?, collapsed?}`, or just the id
|
||||
let id = entry.as_str().or_else(|| entry.get("id").and_then(serde_json::Value::as_str))?;
|
||||
Some(SectionState { id: SidebarSection::from_id(id)?, hidden: flag(entry, "hidden"), collapsed: flag(entry, "collapsed") })
|
||||
});
|
||||
Self::from_states(read)
|
||||
}
|
||||
}
|
||||
|
||||
impl SidebarLayout {
|
||||
/// The arrangement an agent or script asks for (`ui.set` `sidebar`), read strictly, unlike a
|
||||
/// settings file: a list whose entries are a section id or `{id, hidden?, collapsed?}`, each
|
||||
/// section at most once. Sections left out follow in the usual order, shown and open. What is
|
||||
/// wrong is said, so a typo doesn't quietly rearrange the sidebar.
|
||||
pub fn parse(asked: &serde_json::Value) -> Result<Self, String> {
|
||||
let entries = asked.as_array().ok_or_else(|| format!("`sidebar` must be a list of sections ({}), not {asked}", SidebarSection::ids()))?;
|
||||
let mut states: Vec<SectionState> = Vec::new();
|
||||
for entry in entries {
|
||||
let name = entry
|
||||
.as_str()
|
||||
.or_else(|| entry.get("id").and_then(serde_json::Value::as_str))
|
||||
.ok_or_else(|| format!("`sidebar`: {entry} names no section (an id, or {{id, hidden, collapsed}})"))?;
|
||||
let id = SidebarSection::from_id(name).ok_or_else(|| format!("`sidebar`: unknown section `{name}` ({})", SidebarSection::ids()))?;
|
||||
if states.iter().any(|s| s.id == id) {
|
||||
return Err(format!("`sidebar`: `{name}` is listed twice"));
|
||||
}
|
||||
let flag = |key: &str| match entry.get(key) {
|
||||
None | Some(serde_json::Value::Null) => Ok(false),
|
||||
Some(serde_json::Value::Bool(b)) => Ok(*b),
|
||||
Some(other) => Err(format!("`sidebar`: `{key}` of `{name}` must be true or false, not {other}")),
|
||||
};
|
||||
states.push(SectionState { id, hidden: flag("hidden")?, collapsed: flag("collapsed")? });
|
||||
}
|
||||
Ok(Self::from_states(states))
|
||||
}
|
||||
|
||||
/// The layout these states describe, made whole (see the type).
|
||||
fn from_states(states: impl IntoIterator<Item = SectionState>) -> Self {
|
||||
let mut all: Vec<SectionState> = Vec::with_capacity(SidebarSection::ALL.len());
|
||||
for state in states.into_iter().chain(SidebarSection::ALL.map(SectionState::new)) {
|
||||
if !all.iter().any(|s| s.id == state.id) {
|
||||
all.push(state);
|
||||
}
|
||||
}
|
||||
Self(all)
|
||||
}
|
||||
|
||||
/// Every section top to bottom, hidden ones included.
|
||||
pub fn sections(&self) -> &[SectionState] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The sections that are shown, top to bottom.
|
||||
pub fn shown(&self) -> impl Iterator<Item = SidebarSection> + '_ {
|
||||
self.0.iter().filter(|s| !s.hidden && s.id.available()).map(|s| s.id)
|
||||
}
|
||||
|
||||
fn state(&self, section: SidebarSection) -> SectionState {
|
||||
self.0.iter().copied().find(|s| s.id == section).unwrap_or(SectionState::new(section))
|
||||
}
|
||||
|
||||
fn state_mut(&mut self, section: SidebarSection) -> Option<&mut SectionState> {
|
||||
self.0.iter_mut().find(|s| s.id == section)
|
||||
}
|
||||
|
||||
pub fn is_hidden(&self, section: SidebarSection) -> bool {
|
||||
self.state(section).hidden
|
||||
}
|
||||
|
||||
pub fn is_collapsed(&self, section: SidebarSection) -> bool {
|
||||
self.state(section).collapsed
|
||||
}
|
||||
|
||||
/// Show or hide a section. It keeps its place and whether it was folded.
|
||||
pub fn set_hidden(&mut self, section: SidebarSection, hidden: bool) {
|
||||
if let Some(s) = self.state_mut(section) {
|
||||
s.hidden = hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fold a section shut or open it.
|
||||
pub fn set_collapsed(&mut self, section: SidebarSection, collapsed: bool) {
|
||||
if let Some(s) = self.state_mut(section) {
|
||||
s.collapsed = collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_collapsed(&mut self, section: SidebarSection) {
|
||||
self.set_collapsed(section, !self.is_collapsed(section));
|
||||
}
|
||||
|
||||
/// Make sure a section can be seen: shown and open (something in it is being pointed out).
|
||||
pub fn reveal(&mut self, section: SidebarSection) {
|
||||
self.set_hidden(section, false);
|
||||
self.set_collapsed(section, false);
|
||||
}
|
||||
|
||||
/// Put `section` just above `before`, or last when `before` is `None`. Returns whether it
|
||||
/// moved. Moving a section above itself or to where it already is changes nothing, and neither
|
||||
/// does a move of a shown section that would only take it past hidden ones: the sidebar would
|
||||
/// look the same, so the arrangement stays the same too.
|
||||
pub fn move_before(&mut self, section: SidebarSection, before: Option<SidebarSection>) -> bool {
|
||||
if before == Some(section) {
|
||||
return false;
|
||||
}
|
||||
let was = self.clone();
|
||||
let moved = self.state(section);
|
||||
self.0.retain(|s| s.id != section);
|
||||
let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len());
|
||||
self.0.insert(at, moved);
|
||||
let seen = !moved.hidden && section.available();
|
||||
if seen && self.shown().eq(was.shown()) {
|
||||
*self = was;
|
||||
return false;
|
||||
}
|
||||
*self != was
|
||||
}
|
||||
|
||||
/// Move `section` one place up or down among the sections that are shown (hidden ones are
|
||||
/// stepped over, so every move is one the user can see). Returns whether it moved: the first
|
||||
/// can't go up, the last can't go down, and a hidden section stays where it is.
|
||||
pub fn move_by(&mut self, section: SidebarSection, up: bool) -> bool {
|
||||
let shown: Vec<SidebarSection> = self.shown().collect();
|
||||
let Some(at) = shown.iter().position(|s| *s == section) else { return false };
|
||||
if up {
|
||||
let Some(above) = at.checked_sub(1).and_then(|i| shown.get(i)) else { return false };
|
||||
self.move_before(section, Some(*above))
|
||||
} else {
|
||||
if at + 1 >= shown.len() {
|
||||
return false;
|
||||
}
|
||||
// below the next one: above the one after it, or last
|
||||
self.move_before(section, shown.get(at + 2).copied())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `section` can move up (or down) among the shown sections.
|
||||
pub fn can_move(&self, section: SidebarSection, up: bool) -> bool {
|
||||
let shown: Vec<SidebarSection> = self.shown().collect();
|
||||
match shown.iter().position(|s| *s == section) {
|
||||
Some(at) if up => at > 0,
|
||||
Some(at) => at + 1 < shown.len(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is the arrangement a new library starts with.
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// Fold shut the sections an older settings file listed as collapsed (`collapsedSidebar`).
|
||||
pub fn adopt_collapsed(&mut self, ids: &[String]) {
|
||||
for section in ids.iter().filter_map(|id| SidebarSection::from_id(id)) {
|
||||
self.set_collapsed(section, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SidebarSection::{Albums, ByDate, Folders, Keywords, Local};
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn order(layout: &SidebarLayout) -> Vec<SidebarSection> {
|
||||
layout.sections().iter().map(|s| s.id).collect()
|
||||
}
|
||||
|
||||
fn read(saved: serde_json::Value) -> SidebarLayout {
|
||||
serde_json::from_value(saved).unwrap()
|
||||
}
|
||||
|
||||
/// Given a new library, the sidebar lists every section, shown and open, in the usual order.
|
||||
#[test]
|
||||
fn a_new_sidebar_shows_every_section_open_in_the_usual_order() {
|
||||
let layout = SidebarLayout::default();
|
||||
assert_eq!(order(&layout), [Albums, Local, ByDate, Folders, Keywords]);
|
||||
assert!(layout.sections().iter().all(|s| !s.hidden && !s.collapsed));
|
||||
assert!(layout.is_default());
|
||||
}
|
||||
|
||||
/// Given an arrangement the user made, when it is saved and read back, then it is the same.
|
||||
#[test]
|
||||
fn an_arrangement_survives_saving() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.move_before(Keywords, Some(Albums));
|
||||
layout.set_hidden(Local, true);
|
||||
layout.set_collapsed(ByDate, true);
|
||||
let saved = serde_json::to_value(&layout).unwrap();
|
||||
assert_eq!(saved[0], json!({"id": "keywords", "hidden": false, "collapsed": false}));
|
||||
assert_eq!(read(saved), layout);
|
||||
}
|
||||
|
||||
/// Given a saved arrangement that is incomplete, repeats itself or names sections this version
|
||||
/// doesn't know, when it is read, then every section is there exactly once: the readable
|
||||
/// entries first, in their order, and the rest after them.
|
||||
#[test]
|
||||
fn a_damaged_arrangement_is_made_whole() {
|
||||
let layout = read(json!([
|
||||
{"id": "keywords", "collapsed": true},
|
||||
{"id": "map"},
|
||||
"folders",
|
||||
{"id": "keywords", "hidden": true},
|
||||
{"id": 7},
|
||||
null,
|
||||
{"id": "albums", "hidden": "yes", "collapsed": 1}
|
||||
]));
|
||||
assert_eq!(order(&layout), [Keywords, Folders, Albums, Local, ByDate]);
|
||||
assert!(layout.is_collapsed(Keywords) && !layout.is_hidden(Keywords), "the first entry for a section counts");
|
||||
assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums), "a flag that isn't true or false is off");
|
||||
}
|
||||
|
||||
/// Given a saved arrangement that is not a list at all, when it is read, then the sidebar is
|
||||
/// the usual one (and the rest of the settings still load).
|
||||
#[test]
|
||||
fn an_unreadable_arrangement_is_the_usual_one() {
|
||||
for saved in [json!(null), json!("albums"), json!(3), json!({"albums": true}), json!([])] {
|
||||
assert!(read(saved.clone()).is_default(), "{saved}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Hiding a section takes it off the sidebar and nothing else: it keeps its place and stays
|
||||
/// folded or open, so showing it again puts it back as it was.
|
||||
#[test]
|
||||
fn a_hidden_section_comes_back_where_and_how_it_was() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.set_collapsed(ByDate, true);
|
||||
layout.set_hidden(ByDate, true);
|
||||
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Local, Folders, Keywords]);
|
||||
assert!(!layout.is_default());
|
||||
layout.set_hidden(ByDate, false);
|
||||
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Local, ByDate, Folders, Keywords]);
|
||||
assert!(layout.is_collapsed(ByDate));
|
||||
}
|
||||
|
||||
/// Revealing a section shows it and opens it.
|
||||
#[test]
|
||||
fn revealing_a_section_shows_and_opens_it() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.set_collapsed(Albums, true);
|
||||
layout.set_hidden(Albums, true);
|
||||
layout.reveal(Albums);
|
||||
assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums));
|
||||
}
|
||||
|
||||
/// A section can be put above any other, or last; the others keep their order.
|
||||
#[test]
|
||||
fn a_section_moves_above_another_or_to_the_end() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
assert!(layout.move_before(Keywords, Some(Local)));
|
||||
assert_eq!(order(&layout), [Albums, Keywords, Local, ByDate, Folders]);
|
||||
assert!(layout.move_before(Albums, None));
|
||||
assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]);
|
||||
// nothing to do: above itself, where it already is
|
||||
assert!(!layout.move_before(Local, Some(Local)));
|
||||
assert!(!layout.move_before(Local, Some(ByDate)));
|
||||
assert!(!layout.move_before(Albums, None));
|
||||
assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]);
|
||||
}
|
||||
|
||||
/// Given a hidden section between two shown ones, when the upper one is dropped right where
|
||||
/// it already appears (above the lower one), then nothing changes: no move that can't be seen.
|
||||
#[test]
|
||||
fn a_move_that_cannot_be_seen_is_no_move() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.set_hidden(Local, true);
|
||||
let was = layout.clone();
|
||||
assert!(!layout.move_before(Albums, Some(ByDate)), "Albums is already right above By Date, as far as anyone can see");
|
||||
assert_eq!(layout, was);
|
||||
// a hidden section can still be put elsewhere (it shows there when it is shown again)
|
||||
assert!(layout.move_before(Local, None));
|
||||
assert_eq!(order(&layout), [Albums, ByDate, Folders, Keywords, Local]);
|
||||
}
|
||||
|
||||
/// Moving a section keeps whether it is hidden or folded.
|
||||
#[test]
|
||||
fn a_moved_section_stays_folded() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.set_collapsed(Folders, true);
|
||||
layout.move_before(Folders, Some(Albums));
|
||||
assert!(layout.is_collapsed(Folders));
|
||||
}
|
||||
|
||||
/// Move Up and Move Down go one shown section at a time and stop at the ends.
|
||||
#[test]
|
||||
fn a_section_moves_one_place_among_the_shown_ones() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.set_hidden(ByDate, true);
|
||||
// down past the hidden By Date in one step
|
||||
assert!(layout.can_move(Local, false));
|
||||
assert!(layout.move_by(Local, false));
|
||||
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Folders, Local, Keywords]);
|
||||
assert!(layout.move_by(Local, false));
|
||||
assert_eq!(layout.shown().collect::<Vec<_>>(), [Albums, Folders, Keywords, Local]);
|
||||
assert!(!layout.can_move(Local, false) && !layout.move_by(Local, false), "the last can't go down");
|
||||
assert!(layout.move_by(Folders, true));
|
||||
assert_eq!(layout.shown().collect::<Vec<_>>(), [Folders, Albums, Keywords, Local]);
|
||||
assert!(!layout.can_move(Folders, true) && !layout.move_by(Folders, true), "the first can't go up");
|
||||
assert!(!layout.can_move(ByDate, true) && !layout.move_by(ByDate, true), "a hidden section doesn't move");
|
||||
}
|
||||
|
||||
/// Sections an older settings file listed as collapsed are folded; names it got wrong are ignored.
|
||||
#[test]
|
||||
fn collapsed_sections_of_an_older_settings_file_stay_folded() {
|
||||
let mut layout = SidebarLayout::default();
|
||||
layout.adopt_collapsed(&["byDate".into(), "nonsense".into(), "keywords".into()]);
|
||||
assert!(layout.is_collapsed(ByDate) && layout.is_collapsed(Keywords) && !layout.is_collapsed(Albums));
|
||||
}
|
||||
|
||||
/// Given an arrangement asked for by an agent, when it is right, then it is taken (sections it
|
||||
/// leaves out follow, shown and open); when anything in it is wrong, then it is refused with
|
||||
/// what is wrong, instead of being made whole as a settings file is.
|
||||
#[test]
|
||||
fn an_arrangement_that_is_asked_for_is_read_strictly() {
|
||||
let layout = SidebarLayout::parse(&json!(["keywords", {"id": "albums", "hidden": true, "collapsed": null}])).unwrap();
|
||||
assert_eq!(order(&layout), [Keywords, Albums, Local, ByDate, Folders]);
|
||||
assert!(layout.is_hidden(Albums) && !layout.is_collapsed(Albums));
|
||||
assert!(SidebarLayout::parse(&json!([])).unwrap().is_default());
|
||||
for (asked, says) in [
|
||||
(json!(null), "must be a list"),
|
||||
(json!({"albums": 1}), "must be a list"),
|
||||
(json!([{"id": "keywrods", "hidden": true}]), "unknown section `keywrods`"),
|
||||
(json!([7]), "names no section"),
|
||||
(json!(["albums", {"id": "albums"}]), "`albums` is listed twice"),
|
||||
(json!([{"id": "albums", "hidden": "yes"}]), "`hidden` of `albums` must be true or false"),
|
||||
(json!([{"id": "albums", "collapsed": 1}]), "`collapsed` of `albums` must be true or false"),
|
||||
] {
|
||||
let e = SidebarLayout::parse(&asked).unwrap_err();
|
||||
assert!(e.contains(says), "{asked}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_are_named_by_their_ids() {
|
||||
for s in SidebarSection::ALL {
|
||||
assert_eq!(SidebarSection::from_id(s.id()), Some(s));
|
||||
assert_eq!(serde_json::to_value(s).unwrap(), json!(s.id()));
|
||||
}
|
||||
assert_eq!(SidebarSection::from_id("Albums"), None);
|
||||
assert_eq!(SidebarSection::ids(), "albums|local|byDate|folders|keywords");
|
||||
}
|
||||
}
|
||||
+20
-10
@@ -385,8 +385,11 @@ pub struct UiState {
|
||||
pub show_counts: bool,
|
||||
/// Face / pet boxes (read from XMP) over the photo in the loupe.
|
||||
pub face_boxes: bool,
|
||||
/// Left-sidebar sections folded shut by their header (`albums`, `local`, `byDate`,
|
||||
/// `keywords`); the rest are open.
|
||||
/// The left sidebar's sections: their order, which are hidden and which are folded shut.
|
||||
pub sidebar: crate::sidebar::SidebarLayout,
|
||||
/// What settings files said before [`UiState::sidebar`]: the ids of the folded sections. Read
|
||||
/// so they stay folded ([`UiState::sanitized`] moves them over), never written.
|
||||
#[serde(skip_serializing)]
|
||||
pub collapsed_sidebar: Vec<String>,
|
||||
/// A face's name being typed in the loupe.
|
||||
#[serde(skip)]
|
||||
@@ -456,9 +459,15 @@ pub struct UiState {
|
||||
/// An album just made: the Albums tree opens the folders down to it, once.
|
||||
#[serde(skip)]
|
||||
pub reveal_album: Option<u64>,
|
||||
/// A keyword just made from the sidebar: Keywords opens down to it, once.
|
||||
#[serde(skip)]
|
||||
pub reveal_keyword: Option<String>,
|
||||
/// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
|
||||
#[serde(skip)]
|
||||
pub dragging_album: Option<u64>,
|
||||
/// A sidebar section being dragged by its header (dropped between sections to move it there).
|
||||
#[serde(skip)]
|
||||
pub dragging_section: Option<crate::sidebar::SidebarSection>,
|
||||
/// Selected curve channel in the Curve flyout.
|
||||
pub curve_channel: String,
|
||||
/// Selected mixer mode: "hue" | "saturation" | "luminance" | "all".
|
||||
@@ -652,6 +661,9 @@ pub enum Dialog {
|
||||
person: bool,
|
||||
/// A new keyword is given to the selected photos.
|
||||
add_to_selected: bool,
|
||||
/// Opened from the sidebar's Keywords header: the new keyword is shown there once made.
|
||||
#[serde(default)]
|
||||
reveal: bool,
|
||||
},
|
||||
/// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`:
|
||||
/// the set being edited (renamed when the name changes); `None` saves a new set (from Recent
|
||||
@@ -875,6 +887,7 @@ impl Default for UiState {
|
||||
grid_info: "filename".into(),
|
||||
show_counts: true,
|
||||
face_boxes: true,
|
||||
sidebar: Default::default(),
|
||||
collapsed_sidebar: Vec::new(),
|
||||
name_edit: None,
|
||||
person_page: None,
|
||||
@@ -900,7 +913,9 @@ impl Default for UiState {
|
||||
quit: false,
|
||||
dragging_photos: None,
|
||||
reveal_album: None,
|
||||
reveal_keyword: None,
|
||||
dragging_album: None,
|
||||
dragging_section: None,
|
||||
curve_channel: "parametric".into(),
|
||||
mixer_mode: "hue".into(),
|
||||
grading_mode: "3way".into(),
|
||||
@@ -979,15 +994,9 @@ impl UiState {
|
||||
self.open_sections.push(id.to_string());
|
||||
}
|
||||
}
|
||||
/// Whether the sidebar section with this id is folded shut (an unknown id: no).
|
||||
pub fn sidebar_section_collapsed(&self, id: &str) -> bool {
|
||||
self.collapsed_sidebar.iter().any(|s| s == id)
|
||||
}
|
||||
pub fn toggle_sidebar_section(&mut self, id: &str) {
|
||||
if self.sidebar_section_collapsed(id) {
|
||||
self.collapsed_sidebar.retain(|s| s != id);
|
||||
} else {
|
||||
self.collapsed_sidebar.push(id.to_string());
|
||||
}
|
||||
crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s))
|
||||
}
|
||||
pub fn flyout_open(&self, id: &str) -> bool {
|
||||
self.open_flyouts.iter().any(|s| s == id)
|
||||
@@ -1005,6 +1014,7 @@ impl UiState {
|
||||
self.left_width = LEFT_WIDTH.clamp(self.left_width);
|
||||
self.right_width = RIGHT_WIDTH.clamp(self.right_width);
|
||||
self.brush_size = self.brush_size.clamp(0.002, 0.5);
|
||||
self.sidebar.adopt_collapsed(&std::mem::take(&mut self.collapsed_sidebar));
|
||||
self.dialog = None;
|
||||
self.fullscreen = false;
|
||||
if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) {
|
||||
|
||||
@@ -220,6 +220,781 @@ fn sidebar_sections_collapse_and_remember_it() {
|
||||
assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums");
|
||||
}
|
||||
|
||||
/// Given a settings file from before sections could be arranged (it lists the folded ones as
|
||||
/// `collapsedSidebar`), when it is loaded, then those sections are still folded, and what is
|
||||
/// saved from then on is the arrangement alone.
|
||||
#[test]
|
||||
fn folded_sections_of_an_older_settings_file_stay_folded() {
|
||||
let old = json!({"leftPanel": true, "collapsedSidebar": ["byDate", "keywords", "no such section"]});
|
||||
let ui = serde_json::from_value::<crate::UiState>(old).unwrap().sanitized();
|
||||
assert!(ui.sidebar_section_collapsed("byDate") && ui.sidebar_section_collapsed("keywords") && !ui.sidebar_section_collapsed("albums"));
|
||||
let saved = serde_json::to_value(&ui).unwrap();
|
||||
assert!(saved.get("collapsedSidebar").is_none(), "{saved}");
|
||||
assert_eq!(saved["sidebar"][2], json!({"id": "byDate", "hidden": false, "collapsed": true}));
|
||||
}
|
||||
|
||||
/// Given a settings file whose sidebar arrangement is damaged, when it is loaded, then the rest of
|
||||
/// the settings load and the sidebar draws every section.
|
||||
#[test]
|
||||
fn a_damaged_sidebar_arrangement_does_not_lose_the_settings() {
|
||||
for sidebar in [json!("oops"), json!([{"id": "folders"}, {"id": "folders"}, 7, {"id": "ghost"}]), json!({"albums": 1})] {
|
||||
let saved = json!({"leftPanel": true, "leftWidth": 300.0, "sidebar": sidebar});
|
||||
let ui = serde_json::from_value::<crate::UiState>(saved.clone()).unwrap().sanitized();
|
||||
assert_eq!(ui.left_width, 300.0, "{saved}");
|
||||
assert_eq!(ui.sidebar.sections().len(), crate::sidebar::SidebarSection::ALL.len(), "{saved}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The sections drawn in the sidebar, top to bottom.
|
||||
fn sections_drawn(h: &Headless) -> Vec<String> {
|
||||
let mut drawn: Vec<(f32, String)> =
|
||||
h.app.widgets.iter().filter_map(|(w, r)| w.strip_prefix("sidebarSection:").map(|id| (r.top(), id.to_string()))).collect();
|
||||
drawn.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
drawn.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
|
||||
/// Given an arrangement of the sidebar (an order, a hidden section), when the sidebar is drawn,
|
||||
/// then its sections are in that order, the hidden one is not there, and every header is the same
|
||||
/// distance below what is above it.
|
||||
#[test]
|
||||
fn the_sidebar_draws_its_sections_as_arranged() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
// (Local is not on every platform)
|
||||
let listed = |drawn: &[String]| drawn.iter().filter(|s| s.as_str() != "local").cloned().collect::<Vec<_>>();
|
||||
let usual = sections_drawn(&h);
|
||||
assert_eq!(listed(&usual), ["albums", "byDate", "folders", "keywords"], "{usual:?}");
|
||||
// the demo library was imported from no folder: Folders says so in place of rows
|
||||
assert!(has(&h, "sidebarEmpty:folders") && !has(&h, "sidebarEmpty:keywords"));
|
||||
let r = h.request("ui.set", json!({"sidebar": [{"id": "keywords"}, {"id": "byDate", "hidden": true}, {"id": "albums"}]}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
let drawn = sections_drawn(&h);
|
||||
assert_eq!(listed(&drawn), ["keywords", "albums", "folders"], "{drawn:?}");
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "a hidden section draws no rows");
|
||||
// every header sits the same gap below the last row above it
|
||||
let mut rows: Vec<egui::Rect> =
|
||||
h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:") || w.starts_with("sidebarEmpty:")).map(|(_, r)| *r).collect();
|
||||
rows.sort_by(|a, b| a.top().total_cmp(&b.top()));
|
||||
for id in &drawn {
|
||||
let header = widget(&h, &format!("sidebarSection:{id}"));
|
||||
let above = rows.iter().rev().find(|r| r.top() < header.top()).map(|r| r.bottom()).unwrap();
|
||||
assert_eq!(header.top() - above, 10.0, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a command that must succeed; its result.
|
||||
fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{command}: {r}");
|
||||
h.step();
|
||||
h.step();
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
/// The View ▸ Sidebar Sections items: (section id or command, checked).
|
||||
fn sidebar_menu_items(h: &Headless) -> Vec<(String, Option<bool>)> {
|
||||
use crate::menubar::MenuNode;
|
||||
let bar = crate::menubar::menu_bar(&h.app);
|
||||
let view = &bar.iter().find(|(t, _)| t == "View").expect("View menu").1;
|
||||
let children = view
|
||||
.iter()
|
||||
.find_map(|n| match n {
|
||||
MenuNode::Submenu { label, children } if label == "Sidebar Sections" => Some(children.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("View ▸ Sidebar Sections");
|
||||
children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
MenuNode::Item { id, params, checked, .. } => Some((params["section"].as_str().unwrap_or(id).to_string(), *checked)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Given the sidebar, when a section is unchecked in View ▸ Sidebar Sections, then it leaves the
|
||||
/// sidebar (header and rows) and the menu shows it unchecked; checking it again brings it back
|
||||
/// as it was. The choice is part of the saved UI state.
|
||||
#[test]
|
||||
fn a_section_is_hidden_and_shown_from_the_view_menu() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let checked = |h: &Headless, id: &str| sidebar_menu_items(h).into_iter().find(|(s, _)| s == id).unwrap_or_else(|| panic!("no {id} item")).1;
|
||||
assert_eq!(checked(&h, "byDate"), Some(true));
|
||||
assert!(has(&h, "sidebarSection:byDate"));
|
||||
// folded first: it must come back folded
|
||||
click(&mut h, "sidebarSection:byDate");
|
||||
let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate"}));
|
||||
assert_eq!(r, json!({"section": "byDate", "show": false, "collapsed": true}));
|
||||
assert!(!has(&h, "sidebarSection:byDate"), "the header is gone");
|
||||
assert_eq!(checked(&h, "byDate"), Some(false));
|
||||
assert_eq!(checked(&h, "albums"), Some(true), "the others stay");
|
||||
// saved and read back
|
||||
let back = serde_json::from_value::<crate::UiState>(serde_json::to_value(&h.app.ui).unwrap()).unwrap().sanitized();
|
||||
assert!(back.sidebar.is_hidden(crate::sidebar::SidebarSection::ByDate));
|
||||
let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate"}));
|
||||
assert_eq!(r["show"], true);
|
||||
assert!(has(&h, "sidebarSection:byDate") && h.app.ui.sidebar_section_collapsed("byDate"), "back, still folded");
|
||||
// saying what is wanted, instead of flipping it: showing a shown section changes nothing
|
||||
let r = exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": true, "collapsed": false}));
|
||||
assert_eq!(r, json!({"section": "byDate", "show": true, "collapsed": false}));
|
||||
assert!(h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "open again: its rows are drawn");
|
||||
}
|
||||
|
||||
/// Given sections moved out of sight, when Reset Sidebar Sections is chosen, then every section
|
||||
/// is shown and open again; the item is only offered when there is something to reset.
|
||||
#[test]
|
||||
fn resetting_the_sidebar_brings_every_section_back() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let reset_enabled = |h: &Headless| {
|
||||
let bar = crate::menubar::menu_bar(&h.app);
|
||||
let json = serde_json::to_string(&bar).unwrap();
|
||||
// the item as the menu model describes it
|
||||
let at = json.find("view.sidebarReset").expect("the reset item");
|
||||
json[at..].split("enabled\":").nth(1).is_some_and(|rest| rest.starts_with("true"))
|
||||
};
|
||||
assert!(!reset_enabled(&h), "nothing to reset yet");
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "keywords", "collapsed": true}));
|
||||
assert!(reset_enabled(&h));
|
||||
assert!(!has(&h, "sidebarSection:albums"));
|
||||
exec(&mut h, "view.sidebarReset", json!({}));
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
assert!(has(&h, "sidebarSection:albums") && !h.app.ui.sidebar_section_collapsed("keywords"));
|
||||
}
|
||||
|
||||
/// Given a section's header, when it is right-clicked and Hide This Section is chosen, then the
|
||||
/// section is hidden; and the menu of the My Photos title, which is always there, shows it again.
|
||||
#[test]
|
||||
fn a_section_is_hidden_from_its_header_and_brought_back_from_the_title() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
right_click(&mut h, "sidebarSection:keywords");
|
||||
assert!(popup_open(&h), "the header has a menu");
|
||||
click(&mut h, "sidebarMenu:hide");
|
||||
assert!(!has(&h, "sidebarSection:keywords") && !popup_open(&h));
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:keyword:")), "its rows went with it");
|
||||
right_click(&mut h, "sidebarTitle");
|
||||
assert!(popup_open(&h), "the title has the menu too");
|
||||
assert!(!has(&h, "sidebarMenu:hide"), "the title is no section: nothing to hide");
|
||||
click(&mut h, "sidebarMenu:section:keywords");
|
||||
assert!(has(&h, "sidebarSection:keywords"));
|
||||
// …and from there every section can go back to the usual arrangement
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": false}));
|
||||
right_click(&mut h, "sidebarTitle");
|
||||
click(&mut h, "sidebarMenu:reset");
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
}
|
||||
|
||||
/// Given an album is being shown, when Albums is hidden, then the grid goes on showing the album.
|
||||
#[test]
|
||||
fn hiding_the_section_of_what_is_shown_keeps_showing_it() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let album = h.app.session.execute("album.create", &json!({"name": "Kept"})).unwrap()["id"].as_u64().unwrap();
|
||||
exec(&mut h, "library.source", json!({"kind": "album", "id": album}));
|
||||
let shown = h.app.session.source;
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false}));
|
||||
assert!(!has(&h, &format!("source:album:{album}")));
|
||||
assert_eq!(h.app.session.source, shown);
|
||||
}
|
||||
|
||||
/// Given Albums is hidden (and folded), when an album is made from the menu, then Albums is back
|
||||
/// and open, so the new album can be seen.
|
||||
#[test]
|
||||
fn a_new_album_brings_a_hidden_albums_section_back() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "albums", "show": false, "collapsed": true}));
|
||||
exec(&mut h, "dialog.newAlbum", json!({"name": "Fresh"}));
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
let id = h.app.session.catalog.albums().find(|a| a.name == "Fresh").expect("the album was made").id.0;
|
||||
assert!(has(&h, "sidebarSection:albums") && has(&h, &format!("source:album:{id}")));
|
||||
}
|
||||
|
||||
/// A command that names no section, an unknown one, or a flag that is no true / false is refused
|
||||
/// with what it takes, and changes nothing.
|
||||
#[test]
|
||||
fn sidebar_commands_refuse_what_they_cannot_read() {
|
||||
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for (params, says) in [
|
||||
(json!({}), "`section` is needed (albums|local|byDate|folders|keywords)"),
|
||||
(json!({"section": 3}), "`section` is needed"),
|
||||
(json!({"section": "map"}), "unknown section `map`"),
|
||||
(json!({"section": "albums", "show": "no"}), "`show` must be true or false"),
|
||||
(json!({"section": "albums", "collapsed": 1}), "`collapsed` must be true or false"),
|
||||
] {
|
||||
let r = h.request("engine.execute", json!({"command": "view.sidebarSection", "params": params}), T);
|
||||
assert_eq!(r["ok"], false, "{params}: {r}");
|
||||
assert!(r.to_string().contains(says), "{params}: {r}");
|
||||
}
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` over a few frames, and release there (after `before_release`).
|
||||
fn drag_between(h: &mut Headless, from: egui::Pos2, to: egui::Pos2, before_release: Vec<egui::Event>) {
|
||||
let m = egui::Modifiers::NONE;
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from));
|
||||
h.app.synthetic.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
for e in before_release {
|
||||
h.app.synthetic.push(e);
|
||||
h.step();
|
||||
}
|
||||
h.app.synthetic.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
h.step();
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn header(h: &Headless, id: &str) -> egui::Rect {
|
||||
widget(h, &format!("sidebarSection:{id}"))
|
||||
}
|
||||
|
||||
/// Given the sidebar, when a section's header is dragged above another section and released,
|
||||
/// then the section is listed there, the others keeping their order; while it is dragged a line
|
||||
/// shows where it would go; and the new order is part of the saved UI state.
|
||||
#[test]
|
||||
fn dragging_a_header_moves_its_section() {
|
||||
// folders too: photos imported from a folder
|
||||
let mut h = folders_app_sized(&["/pics/trip/a.jpg"], [1400.0, 1400.0]);
|
||||
let usual = sections_drawn(&h);
|
||||
let with_local = usual.contains(&"local".to_string());
|
||||
let listed = |ids: &[&str]| ids.iter().filter(|s| with_local || **s != "local").map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(usual, listed(&["albums", "local", "byDate", "folders", "keywords"]));
|
||||
assert!(has(&h, "sidebarEmpty:keywords"), "no keywords in this library: the section says so and stays");
|
||||
// Folders, dragged to just above Albums
|
||||
let (from, to) = (header(&h, "folders").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0));
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: from,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Folders));
|
||||
let line = widget(&h, "sidebarSectionDrop");
|
||||
assert!(
|
||||
line.center().y < header(&h, "albums").top() && line.center().y > header(&h, "albums").top() - 10.0,
|
||||
"the line is in the gap above Albums: {line:?}"
|
||||
);
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: to,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.dragging_section, None);
|
||||
assert!(!has(&h, "sidebarSectionDrop"));
|
||||
assert_eq!(sections_drawn(&h), listed(&["folders", "albums", "local", "byDate", "keywords"]));
|
||||
// dragged below everything: last
|
||||
let end = egui::pos2(header(&h, "keywords").center().x, widget(&h, "panel:left_panel").bottom() - 20.0);
|
||||
let from = header(&h, "albums").center();
|
||||
drag_between(&mut h, from, end, Vec::new());
|
||||
assert_eq!(sections_drawn(&h), listed(&["folders", "local", "byDate", "keywords", "albums"]));
|
||||
// kept with the UI state
|
||||
let back = serde_json::from_value::<crate::UiState>(serde_json::to_value(&h.app.ui).unwrap()).unwrap().sanitized();
|
||||
assert_eq!(back.sidebar, h.app.ui.sidebar);
|
||||
// the header still folds its section with a click
|
||||
click(&mut h, "sidebarSection:folders");
|
||||
assert!(h.app.ui.sidebar_section_collapsed("folders"));
|
||||
}
|
||||
|
||||
/// A drag that ends with Esc, or is released away from the sidebar, moves nothing.
|
||||
#[test]
|
||||
fn a_section_drag_that_is_given_up_moves_nothing() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let usual = sections_drawn(&h);
|
||||
let (from, to) = (header(&h, "keywords").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0));
|
||||
let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
drag_between(&mut h, from, to, vec![esc]);
|
||||
assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "Esc");
|
||||
// released over the photos
|
||||
drag_between(&mut h, from, egui::pos2(800.0, to.y), Vec::new());
|
||||
assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "released outside");
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
}
|
||||
|
||||
/// An album dragged to the Albums header is not a section drag, and the other way round.
|
||||
#[test]
|
||||
fn dragging_a_section_is_not_dragging_an_album() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let (from, to) = (header(&h, "albums").center(), header(&h, "keywords").center() + egui::vec2(0.0, 4.0));
|
||||
let before = h.app.session.catalog.revision;
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: from,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * 0.5));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(h.app.ui.dragging_section.is_some() && h.app.ui.dragging_album.is_none());
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: to,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.session.catalog.revision, before, "no album moved");
|
||||
assert!(!h.app.ui.sidebar.is_default(), "the section did");
|
||||
// …and an album dragged onto another section's header moves no section
|
||||
let album = h.app.session.catalog.albums().find(|a| !a.folder).expect("the demo library has albums").id.0;
|
||||
let arranged = h.app.ui.sidebar.clone();
|
||||
let (from, to) = (widget(&h, &format!("source:album:{album}")).center(), header(&h, "keywords").center());
|
||||
drag_between(&mut h, from, to, Vec::new());
|
||||
assert_eq!((h.app.ui.sidebar.clone(), h.app.ui.dragging_section), (arranged, None));
|
||||
}
|
||||
|
||||
/// Given a section's header menu, Move Up and Move Down move it one shown section at a time
|
||||
/// (stepping over hidden ones), and the command says when there is nowhere to go.
|
||||
#[test]
|
||||
fn a_section_moves_up_and_down_from_its_menu() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
// (Local is not on every platform: taken out, so the same sections are there everywhere)
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false}));
|
||||
assert_eq!(sections_drawn(&h), ["albums", "byDate", "keywords"]);
|
||||
right_click(&mut h, "sidebarSection:keywords");
|
||||
click(&mut h, "sidebarMenu:moveUp");
|
||||
assert_eq!(sections_drawn(&h), ["albums", "keywords", "byDate"]);
|
||||
right_click(&mut h, "sidebarSection:albums");
|
||||
click(&mut h, "sidebarMenu:moveDown");
|
||||
assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"], "one step, over the hidden sections");
|
||||
// the ends: nowhere to go
|
||||
let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "keywords", "direction": "up"}));
|
||||
assert_eq!(r["moved"], false, "{r}");
|
||||
let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "direction": "down"}));
|
||||
assert_eq!(r["moved"], false, "{r}");
|
||||
// above a named section, or last
|
||||
let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": "keywords"}));
|
||||
assert_eq!(r["moved"], true, "{r}");
|
||||
assert_eq!(sections_drawn(&h), ["byDate", "keywords", "albums"]);
|
||||
exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": null}));
|
||||
assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"]);
|
||||
assert_eq!(h.app.ui.sidebar.sections().last().map(|s| s.id.id()), Some("byDate"));
|
||||
for (params, says) in [
|
||||
(json!({"section": "albums"}), "give either `direction`"),
|
||||
(json!({"section": "albums", "direction": "up", "before": null}), "give either `direction`"),
|
||||
(json!({"section": "albums", "direction": "left"}), "`direction` must be"),
|
||||
(json!({"section": "albums", "before": "map"}), "`before` must be a section"),
|
||||
(json!({"direction": "up"}), "`section` is needed"),
|
||||
] {
|
||||
let r = h.request("engine.execute", json!({"command": "view.sidebarMoveSection", "params": params}), T);
|
||||
assert_eq!(r["ok"], false, "{params}: {r}");
|
||||
assert!(r.to_string().contains(says), "{params}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Recently Deleted is one of My Photos' rows: right below the others, above every section.
|
||||
#[test]
|
||||
fn recently_deleted_is_listed_with_my_photos() {
|
||||
let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let (picks, deleted) = (widget(&h, "source:picks"), widget(&h, "source:recentlyDeleted"));
|
||||
assert_eq!(deleted.top(), picks.bottom(), "no gap: the same group");
|
||||
let first = sections_drawn(&h).remove(0);
|
||||
assert_eq!(header(&h, &first).top() - deleted.bottom(), 10.0, "the sections start below it");
|
||||
}
|
||||
|
||||
/// An arrangement set through the control channel is checked: a wrong one is refused and leaves
|
||||
/// the sidebar as it was; the older `collapsedSidebar` list still folds and unfolds sections.
|
||||
#[test]
|
||||
fn an_arrangement_set_by_an_agent_is_checked() {
|
||||
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "keywords", "show": false}));
|
||||
let arranged = h.app.ui.sidebar.clone();
|
||||
for bad in [json!(null), json!({"albums": 1}), json!([{"id": "keywrods", "hidden": true}]), json!(["albums", "albums"])] {
|
||||
let r = h.request("ui.set", json!({"sidebar": bad}), T);
|
||||
assert_eq!(r["ok"], false, "{bad}: {r}");
|
||||
assert_eq!(h.app.ui.sidebar, arranged, "{bad}");
|
||||
}
|
||||
let r = h.request("ui.set", json!({"collapsedSidebar": ["byDate"]}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(h.app.ui.sidebar_section_collapsed("byDate") && !h.app.ui.sidebar_section_collapsed("albums"));
|
||||
assert!(h.app.ui.sidebar.is_hidden(crate::sidebar::SidebarSection::Keywords), "the rest of the arrangement stays");
|
||||
let r = h.request("ui.set", json!({"collapsedSidebar": []}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!h.app.ui.sidebar_section_collapsed("byDate"));
|
||||
// a name it doesn't know, or both ways of saying it at once, is refused and changes nothing
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "albums", "collapsed": true}));
|
||||
let arranged = h.app.ui.sidebar.clone();
|
||||
for bad in [json!({"collapsedSidebar": ["albms"]}), json!({"collapsedSidebar": [], "sidebar": [{"id": "albums", "collapsed": true}]})] {
|
||||
let r = h.request("ui.set", bad.clone(), T);
|
||||
assert_eq!(r["ok"], false, "{bad}: {r}");
|
||||
assert_eq!(h.app.ui.sidebar, arranged, "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a dragged section goes: above the first section whose middle is below the pointer.
|
||||
#[test]
|
||||
fn a_dragged_section_goes_above_the_section_it_is_over_the_top_half_of() {
|
||||
use crate::panels::left::{PlacedSection, section_drop_before};
|
||||
use crate::sidebar::SidebarSection::{Albums, ByDate, Keywords};
|
||||
let placed = [
|
||||
PlacedSection { section: Albums, top: 100.0, bottom: 300.0, open: true, rows: 1 },
|
||||
PlacedSection { section: ByDate, top: 310.0, bottom: 344.0, open: true, rows: 1 },
|
||||
PlacedSection { section: Keywords, top: 354.0, bottom: 500.0, open: true, rows: 1 },
|
||||
];
|
||||
for (y, before) in [
|
||||
(0.0, Some(Albums)),
|
||||
(199.0, Some(Albums)),
|
||||
(201.0, Some(ByDate)),
|
||||
(326.0, Some(ByDate)),
|
||||
(328.0, Some(Keywords)),
|
||||
(430.0, None),
|
||||
(9e9, None),
|
||||
] {
|
||||
assert_eq!(section_drop_before(&placed, y), before, "{y}");
|
||||
}
|
||||
assert_eq!(section_drop_before(&[], 10.0), None);
|
||||
assert_eq!(section_drop_before(&placed, f32::NAN), None);
|
||||
}
|
||||
|
||||
/// By Date opens a year into its months with the triangle, and closes it again.
|
||||
#[test]
|
||||
fn a_year_opens_into_its_months() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:date:")).count();
|
||||
let years = rows(&h);
|
||||
let toggle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(w, _)| w.clone()).expect("a year has a triangle");
|
||||
click(&mut h, &toggle);
|
||||
assert!(rows(&h) > years, "months are listed");
|
||||
click(&mut h, &toggle);
|
||||
assert_eq!(rows(&h), years, "closed again");
|
||||
}
|
||||
|
||||
/// Given a section longer than the sidebar, when its header scrolls out of sight, then a copy of
|
||||
/// the header stays at the top of the sidebar for as long as the section's rows are there; a
|
||||
/// click on it folds the section and brings the section's own header into view.
|
||||
#[test]
|
||||
fn the_header_of_a_long_section_stays_in_sight() {
|
||||
let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for i in 0..40 {
|
||||
h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap();
|
||||
}
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("sidebarSectionPinned:")), "nothing is pinned while every header is in sight");
|
||||
let own = header(&h, "albums");
|
||||
let c = egui::pos2(own.center().x, 400.0);
|
||||
let scroll = |h: &mut Headless, dy: f32| {
|
||||
// the wheel scrolls what the pointer is over
|
||||
h.request("ui.move", json!({"x": c.x, "y": c.y}), T);
|
||||
let r = h.request("ui.scroll", json!({"dy": dy}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
scroll(&mut h, -500.0);
|
||||
assert!(header(&h, "albums").top() < own.top() - 300.0, "the sidebar scrolled");
|
||||
let pinned = widget(&h, "sidebarSectionPinned:albums");
|
||||
let first_row = h
|
||||
.app
|
||||
.widgets
|
||||
.iter()
|
||||
.filter(|(w, r)| w.starts_with("source:album:") && r.bottom() > pinned.bottom())
|
||||
.map(|(_, r)| r.top())
|
||||
.fold(f32::MAX, f32::min);
|
||||
assert!(
|
||||
pinned.top() > header(&h, "albums").bottom() && first_row < pinned.bottom() + 29.0,
|
||||
"at the top of the rows in view: {pinned:?} {first_row}"
|
||||
);
|
||||
assert!(h.app.widgets.iter().any(|(w, r)| w == "icon:albumNew" && pinned.contains_rect(*r)), "with its buttons");
|
||||
// folded from the pinned header: Albums' own header is what the sidebar shows now
|
||||
click(&mut h, "sidebarSectionPinned:albums");
|
||||
h.settle(SETTLE);
|
||||
assert!(h.app.ui.sidebar_section_collapsed("albums"));
|
||||
assert!(!has(&h, "sidebarSectionPinned:albums"), "a folded section has no rows to pin its header over");
|
||||
let own = header(&h, "albums");
|
||||
assert!((own.top() - pinned.top()).abs() < 1.0, "its own header is where the pinned one was, at the top of the list: {own:?} {pinned:?}");
|
||||
}
|
||||
|
||||
/// Scroll the sidebar (the pointer over it) by `dy`.
|
||||
fn scroll_sidebar(h: &mut Headless, dy: f32) {
|
||||
h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T);
|
||||
let r = h.request("ui.scroll", json!({"dy": dy}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a pinned Albums header with album folders scrolled beneath it, when an album is dragged
|
||||
/// onto the pinned header and released, then it goes to the top level — what the header shows —
|
||||
/// and not into the folder hidden under the header.
|
||||
#[test]
|
||||
fn a_drop_on_the_pinned_header_is_not_a_drop_on_the_row_beneath_it() {
|
||||
let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap();
|
||||
for i in 0..30 {
|
||||
make(&mut h, json!({"name": format!("Folder {i:02}"), "folder": true}));
|
||||
}
|
||||
let last = make(&mut h, json!({"name": "Zz last", "folder": true}));
|
||||
let best = make(&mut h, json!({"name": "Best", "parent": last}));
|
||||
h.step();
|
||||
h.step();
|
||||
let parent = |h: &Headless| h.app.session.catalog.album(lightcraft_catalog::AlbumId(best)).unwrap().parent.map(|p| p.0);
|
||||
let row = |h: &Headless| widget(h, &format!("source:album:{best}"));
|
||||
// far enough that Best is in view and Albums' own header is not
|
||||
for _ in 0..8 {
|
||||
if row(&h).bottom() < 600.0 {
|
||||
break;
|
||||
}
|
||||
scroll_sidebar(&mut h, -200.0);
|
||||
}
|
||||
let pinned = widget(&h, "sidebarSectionPinned:albums");
|
||||
assert!(row(&h).bottom() < 600.0 && row(&h).top() > pinned.bottom(), "Best is in view below the pinned header: {:?}", row(&h));
|
||||
let beneath = h.app.widgets.iter().filter(|(w, r)| w.starts_with("source:folder:") && r.contains(pinned.center())).count();
|
||||
assert_eq!(beneath, 1, "a folder row lies under the middle of the pinned header");
|
||||
let from = row(&h).center();
|
||||
drag_between(&mut h, from, pinned.center(), Vec::new());
|
||||
assert_eq!(parent(&h), None, "to the top level, as the header said");
|
||||
}
|
||||
|
||||
/// Given a long section scrolled so that its header is pinned, when another section is dragged up
|
||||
/// to the top edge, then the sidebar scrolls up to reach the sections above, although the pointer
|
||||
/// is over the pinned header there. A section picked up by the pinned header itself does not
|
||||
/// start scrolling until the pointer has left it.
|
||||
#[test]
|
||||
fn a_section_dragged_to_the_top_edge_scrolls_past_the_pinned_header() {
|
||||
let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for i in 0..40 {
|
||||
h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap();
|
||||
}
|
||||
h.step();
|
||||
let press = |h: &mut Headless, at: egui::Pos2| {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
};
|
||||
let hold = |h: &mut Headless, at: egui::Pos2, frames: usize| {
|
||||
for _ in 0..frames {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
h.step();
|
||||
}
|
||||
};
|
||||
let give_up = |h: &mut Headless| {
|
||||
h.app.synthetic.push(egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: egui::pos2(800.0, 400.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
// Local's header is in view below the albums, Albums' is pinned
|
||||
for _ in 0..12 {
|
||||
if has(&h, "sidebarSectionPinned:albums") && header(&h, "byDate").top() < 600.0 {
|
||||
break;
|
||||
}
|
||||
scroll_sidebar(&mut h, -200.0);
|
||||
}
|
||||
let pinned = widget(&h, "sidebarSectionPinned:albums");
|
||||
let own = |h: &Headless| header(h, "albums").top();
|
||||
// picked up by the pinned header: held inside it, nothing scrolls
|
||||
let start = own(&h);
|
||||
press(&mut h, pinned.center());
|
||||
hold(&mut h, pinned.center() + egui::vec2(12.0, 3.0), 12);
|
||||
assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Albums));
|
||||
assert_eq!(own(&h), start, "still on the header it was picked up by");
|
||||
give_up(&mut h);
|
||||
// By Date, picked up by its own header and taken to the top edge: the sidebar scrolls up
|
||||
let start = own(&h);
|
||||
let from = header(&h, "byDate").center();
|
||||
press(&mut h, from);
|
||||
hold(&mut h, from - egui::vec2(0.0, 60.0), 3);
|
||||
hold(&mut h, pinned.center(), 30);
|
||||
assert!(own(&h) > start + 20.0, "scrolled towards the sections above: {start} -> {}", own(&h));
|
||||
give_up(&mut h);
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
}
|
||||
|
||||
/// The pinned header belongs to the section whose rows are at the top of the sidebar: scrolling on
|
||||
/// into the next section pushes it out and pins that section's header instead.
|
||||
#[test]
|
||||
fn the_next_section_takes_over_the_pinned_header() {
|
||||
let mut h = demo([1400.0, 500.0], json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["albums", "keywords"]}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": false}));
|
||||
exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false}));
|
||||
for i in 0..12 {
|
||||
h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap();
|
||||
}
|
||||
h.step();
|
||||
let pinned = |h: &Headless| -> Vec<String> {
|
||||
h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("sidebarSectionPinned:").map(str::to_string)).collect()
|
||||
};
|
||||
let mut seen = Vec::new();
|
||||
for _ in 0..30 {
|
||||
scroll_sidebar(&mut h, -60.0);
|
||||
let now = pinned(&h);
|
||||
assert!(now.len() <= 1, "one header at a time: {now:?}");
|
||||
if let Some(id) = now.first().filter(|id| seen.last() != Some(*id)) {
|
||||
seen.push(id.clone());
|
||||
}
|
||||
}
|
||||
assert_eq!(seen, ["albums", "keywords"]);
|
||||
}
|
||||
|
||||
/// Given the Keywords header, when its + is clicked, then Create Keyword Tag opens (for a keyword
|
||||
/// at the usual place, whatever is picked in the Keyword List) and the section does not fold;
|
||||
/// and the keyword made there is listed in the section.
|
||||
#[test]
|
||||
fn the_keywords_header_creates_a_keyword() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
// something picked in the Keyword List: not where a keyword made from the sidebar goes
|
||||
h.app.ui.keyword_list_selected = Some("aurora".into());
|
||||
let (button, head) = (widget(&h, "icon:keywordNew"), header(&h, "keywords"));
|
||||
assert!(head.contains_rect(button), "the + is in the header: {button:?} in {head:?}");
|
||||
click(&mut h, "icon:keywordNew");
|
||||
assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "the + does not fold Keywords");
|
||||
let Some(crate::state::Dialog::KeywordTag { editing, name, parent, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) };
|
||||
assert_eq!((editing.clone(), parent.clone()), (None, None));
|
||||
*name = "zebra crossing".into();
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed");
|
||||
}
|
||||
|
||||
/// A folded section's row count sits to the left of its header button; a section without a button
|
||||
/// has its count at the edge.
|
||||
#[test]
|
||||
fn a_header_button_stays_with_a_folded_header() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for (section, button) in [("keywords", "icon:keywordNew"), ("albums", "icon:albumNew")] {
|
||||
click(&mut h, &format!("sidebarSection:{section}"));
|
||||
assert!(h.app.ui.sidebar_section_collapsed(section));
|
||||
let (count, plus) = (widget(&h, &format!("sidebarSectionCount:{section}")), widget(&h, button));
|
||||
assert!(count.right() <= plus.left(), "{section}: the count {count:?} is left of the button {plus:?}");
|
||||
}
|
||||
// By Date has no button: its count goes where the others have theirs
|
||||
click(&mut h, "sidebarSection:byDate");
|
||||
let (plain, beside_button) = (widget(&h, "sidebarSectionCount:byDate"), widget(&h, "sidebarSectionCount:keywords"));
|
||||
assert!(plain.right() >= widget(&h, "icon:keywordNew").left() && plain.right() > beside_button.right() + 20.0, "{plain:?} {beside_button:?}");
|
||||
}
|
||||
|
||||
/// Given Keywords is folded and new keywords go inside a parent, when a keyword is made with the
|
||||
/// header's +, then Keywords opens, and so does the parent, so the new keyword is there to see.
|
||||
/// A keyword made elsewhere (the Keyword List) leaves the sidebar as it is.
|
||||
#[test]
|
||||
fn a_keyword_made_from_the_header_is_shown() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
exec(&mut h, "keyword.create", json!({"name": "places", "parent": null}));
|
||||
exec(&mut h, "keyword.setDefaultParent", json!({"keyword": "places"}));
|
||||
click(&mut h, "sidebarSection:keywords");
|
||||
assert!(h.app.ui.sidebar_section_collapsed("keywords"));
|
||||
let confirm = |h: &mut Headless, name: &str| {
|
||||
let Some(crate::state::Dialog::KeywordTag { name: n, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) };
|
||||
*n = name.into();
|
||||
let r = h.request("ui.dialog.confirm", json!({}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
// from the Keyword List: nothing is revealed
|
||||
h.app.ui.dialog = Some(crate::panels::keyword_list::create_dialog(&h.app));
|
||||
confirm(&mut h, "harbour");
|
||||
assert!(h.app.ui.sidebar_section_collapsed("keywords") && !has(&h, "source:keyword:places|harbour"));
|
||||
// from the header's +
|
||||
click(&mut h, "icon:keywordNew");
|
||||
confirm(&mut h, "lighthouse");
|
||||
assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "Keywords opened");
|
||||
assert!(has(&h, "source:keyword:places|lighthouse"), "and its parent, down to the new keyword");
|
||||
}
|
||||
|
||||
/// A press on a header's button that turns into a drag is not a drag of the section.
|
||||
#[test]
|
||||
fn dragging_from_a_header_button_does_not_drag_the_section() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
for button in ["icon:keywordNew", "icon:albumNew"] {
|
||||
let from = widget(&h, button).center();
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: from,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
for i in 1..=6 {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from - egui::vec2(8.0 * i as f32, 30.0 * i as f32)));
|
||||
h.step();
|
||||
assert_eq!(h.app.ui.dragging_section, None, "{button}");
|
||||
}
|
||||
let to = from - egui::vec2(48.0, 180.0);
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: to,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
assert!(h.app.ui.sidebar.is_default());
|
||||
}
|
||||
|
||||
/// A folded section says how many rows it holds (unless photo counts are off); an open one
|
||||
/// doesn't need to.
|
||||
#[test]
|
||||
fn a_folded_section_says_how_much_it_holds() {
|
||||
let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
assert!(!has(&h, "sidebarSectionCount:byDate"));
|
||||
click(&mut h, "sidebarSection:byDate");
|
||||
assert!(has(&h, "sidebarSectionCount:byDate"));
|
||||
exec(&mut h, "view.photoCounts", json!({"show": false}));
|
||||
assert!(!has(&h, "sidebarSectionCount:byDate"));
|
||||
// an empty section has nothing to count
|
||||
exec(&mut h, "view.photoCounts", json!({"show": true}));
|
||||
click(&mut h, "sidebarSection:folders");
|
||||
assert!(h.app.ui.sidebar_section_collapsed("folders") && !has(&h, "sidebarSectionCount:folders"));
|
||||
}
|
||||
|
||||
/// A section's chevron is in the same column as the triangles of the rows below it.
|
||||
#[test]
|
||||
fn section_chevrons_line_up_with_row_triangles() {
|
||||
let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let triangle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(_, r)| *r).expect("a year has a triangle");
|
||||
for id in sections_drawn(&h) {
|
||||
assert_eq!(widget(&h, &format!("sidebarSectionChevron:{id}")).center().x, triangle.center().x, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Albums nest in folders like the other sidebar trees: a folder row has a disclosure triangle
|
||||
/// (`albumToggle:<id>`), plain albums have none, and folding a folder hides what is inside it.
|
||||
#[test]
|
||||
@@ -964,7 +1739,8 @@ fn removing_a_disk_from_the_library_asks_first() {
|
||||
#[test]
|
||||
fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() {
|
||||
let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m";
|
||||
let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]);
|
||||
// (tall enough for the whole chain: Recently Deleted is above it now)
|
||||
let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]);
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut path = String::new();
|
||||
for name in deep.split('/').filter(|n| !n.is_empty()) {
|
||||
@@ -1059,7 +1835,8 @@ fn a_long_name_alone_does_not_make_the_sidebar_scroll() {
|
||||
#[test]
|
||||
fn the_selection_bar_stays_inside_the_panel() {
|
||||
let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m";
|
||||
let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]);
|
||||
// (tall enough for the whole chain: Recently Deleted is above it now)
|
||||
let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]);
|
||||
click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l");
|
||||
let panel = widget(&h, "panel:left_panel");
|
||||
let bar = widget(&h, "highlight:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l");
|
||||
|
||||
@@ -464,7 +464,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) |
|
||||
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
|
||||
| LR-BEHAV-DIALOGS | Dialogs fit the window | P1 | ✅ | `crates/ui-egui/src/panels/dialogs.rs` (`dialog_body`, `list_height`), `crates/ui-egui/src/tests_dialog_fit.rs` | a dialog taller than the window (Export on a 1366×768 screen, issue #781) never runs off it: its options scroll while the title bar and the Cancel / OK row stay on screen; lists that scroll on their own (What's New, All Metadata, shortcuts, smart-album rules, credits) get shorter first; dialogs that fit look as before; Import keeps its own resizable, scrolling window |
|
||||
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|keywords>`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target |
|
||||
| LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`), `crates/ui-egui/src/sidebar.rs` (`SidebarLayout`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date, Folders or Keywords header (or drive `ui.clickWidget` `sidebarSection:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron before the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; photos dropped on a folded Albums section have no target |
|
||||
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:<id>`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top |
|
||||
| LR-BEHAV-SIDEBAR-HIDE | Show and hide left-sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset`, `crates/ui-egui/src/sidebar.rs`, `crates/ui-egui/src/panels/left.rs` (`sections_menu`), `crates/ui-egui/src/menubar.rs`, `crates/ui-egui/src/tests_panels.rs` | View ▸ Sidebar Sections lists Albums, Local, By Date, Folders and Keywords in the sidebar's order, each checked while shown; unchecking one takes its header and rows off the sidebar, checking it puts it back where and how it was (still folded if it was). The same list opens with a right-click on any section header (with Hide This Section first; widgets `sidebarMenu:hide`, `sidebarMenu:section:<id>`, `sidebarMenu:reset`) and on the My Photos title (`sidebarTitle`), which is always there, so hidden sections can always come back. Reset Sidebar Sections (enabled once something changed) shows and opens every section in the usual order. `view.sidebarSection` `{section, show?, collapsed?}` flips shown / hidden when neither flag is given and refuses an unknown section or a flag that is not true / false. Hiding the section of what the grid shows (an album, a keyword filter) leaves the grid as it is; an album made while Albums is hidden or folded brings Albums back, open. Kept with the UI state (`sidebar`). My Photos itself can't be hidden |
|
||||
| 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-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
|
||||
@@ -541,6 +545,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
|
||||
| MENU-VIEW-SORT | Sort submenu | P0 | ✅ | `cmd:library.sort`, `cmd:library.shuffle` | Random + Reshuffle; no colour-label key |
|
||||
| MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | |
|
||||
| MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Show Photo Counts toggles the left panel's counts |
|
||||
| MENU-VIEW-SIDEBARSECTIONS | Sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset` | View ▸ Sidebar Sections ▸ a checkable item per left-sidebar section, then Reset Sidebar Sections (see LR-BEHAV-SIDEBAR-HIDE) |
|
||||
| MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | |
|
||||
| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | off for a Local-only selection, with an Add to My Photos First hint item (issue #684) |
|
||||
| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
|
||||
|
||||
@@ -27,6 +27,20 @@
|
||||
Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after
|
||||
choosing a profile file.
|
||||
|
||||
### Sidebar sections
|
||||
- Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line
|
||||
shows where it will go), or right-click a header for Move Up / Move Down.
|
||||
- Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click
|
||||
the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered.
|
||||
- In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see
|
||||
which section you are in and can fold it from there.
|
||||
- Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows'
|
||||
triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter,
|
||||
and a section with nothing in it says so instead of disappearing.
|
||||
- Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.
|
||||
- Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.
|
||||
- Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.
|
||||
|
||||
### Activity stack
|
||||
- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and
|
||||
folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,
|
||||
|
||||
Reference in new issue
Block a user