Sidebar: collapsible Albums, Local, By Date and Keywords sections

Each header folds its section on click, with a chevron after the title
showing the state. The choice is kept in the UI state across restarts.
The Albums "+" button keeps working inside its header.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-07 18:49:00 -04:00
parent 7aab3fd97f
commit 7f3d9ce25f
No known key found for this signature in database
4 changed files with 110 additions and 17 deletions

View File

@ -55,6 +55,31 @@ fn row(
resp
}
/// 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);
}
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 {
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));
(r, open)
}
pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider));
@ -85,8 +110,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
ui.add_space(10.0);
// Albums header
let (ar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(ar.left() + 18.0, ar.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Albums"), t.semibold(13.5), t.text_label);
let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums");
let mut hdr = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_max(pos2(ar.right() - 50.0, ar.top()), ar.right_bottom()))
@ -111,14 +135,16 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true });
}
});
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
if albums_open {
let albums: Vec<Album> = app.session.catalog.albums().cloned().collect();
albums_tree(app, ui, &albums, None, 0.0);
}
ui.add_space(10.0);
local_section(app, ui);
// By date
let (dr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(dr.left() + 18.0, dr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("By Date"), t.semibold(13.5), t.text_label);
for g in app.caches.date_groups(&app.session.catalog).iter() {
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 {
@ -190,9 +216,7 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if cfg!(target_arch = "wasm32") {
return;
}
let t = Tokens::get(ui.ctx());
let (lr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(lr.left() + 18.0, lr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Local"), t.semibold(13.5), t.text_label);
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() {
@ -211,6 +235,10 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) {
if current.is_some() {
app.ui.local_browse_root = local.browse_root.clone();
}
if !open {
ui.add_space(10.0);
return;
}
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);
@ -666,15 +694,14 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig
/// 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 t = Tokens::get(ui.ctx());
let tree = app.caches.keyword_tree(&app.session.catalog);
if tree.is_empty() {
return;
}
ui.add_space(10.0);
let (kr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover());
ui.painter().text(pos2(kr.left() + 18.0, kr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Keywords"), t.semibold(13.5), t.text_label);
keyword_rows(app, ui, &tree, 0.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) {

View File

@ -266,6 +266,9 @@ 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.
pub collapsed_sidebar: Vec<String>,
/// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched).
pub hidden_locations: Vec<String>,
/// Copies opened in an external editor this session (reloaded when the window is focused
@ -573,6 +576,7 @@ impl Default for UiState {
grid_info: "filename".into(),
show_counts: true,
face_boxes: true,
collapsed_sidebar: Vec::new(),
hidden_locations: Vec::new(),
dragging_control: None,
external_edits: Vec::new(),
@ -641,6 +645,16 @@ impl UiState {
self.open_sections.push(id.to_string());
}
}
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());
}
}
pub fn flyout_open(&self, id: &str) -> bool {
self.open_flyouts.iter().any(|s| s == id)
}

View File

@ -148,3 +148,54 @@ fn a_narrow_window_shrinks_the_panels_without_forgetting_their_width() {
h.settle(SETTLE);
assert_eq!(widget(&h, "panel:right_panel").width(), 480.0);
}
/// The left sidebar's Albums, Local, By Date and Keywords headers fold their sections; the choice
/// is part of the saved UI state.
#[test]
fn sidebar_sections_collapse_and_remember_it() {
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id);
let click = |h: &mut Headless, id: &str| {
let r = h.request("ui.clickWidget", json!({"id": id}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
};
// By Date lists years while open; its header folds them away and back
assert!(has(&h, "sidebarSection:byDate"));
let year_rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:date:")).count();
assert!(year_rows(&h) > 0, "the demo library has dated photos");
click(&mut h, "sidebarSection:byDate");
assert_eq!(year_rows(&h), 0, "folded");
assert!(h.app.ui.sidebar_section_collapsed("byDate") && !h.app.ui.sidebar_section_collapsed("albums"));
assert!(has(&h, "sidebarSection:byDate"), "the header stays so it can be reopened");
// the choice survives a save/load of the UI state
let saved = serde_json::to_value(&h.app.ui).unwrap();
let back: crate::state::UiState = serde_json::from_value(saved).unwrap();
assert!(back.sidebar_section_collapsed("byDate"));
click(&mut h, "sidebarSection:byDate");
assert!(year_rows(&h) > 0, "unfolded again");
// Albums folds too, and the plus button inside its header still works on its own
click(&mut h, "sidebarSection:albums");
assert!(h.app.ui.sidebar_section_collapsed("albums"));
assert!(has(&h, "icon:albumNew"), "the Create Album button stays in the header");
click(&mut h, "sidebarSection:albums");
assert!(!h.app.ui.sidebar_section_collapsed("albums"));
// Keywords and Local fold their rows too
let rows = |h: &Headless, prefix: &str| h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).count();
assert!(rows(&h, "source:keyword:") > 0, "the demo library has keywords");
click(&mut h, "sidebarSection:keywords");
assert_eq!(rows(&h, "source:keyword:"), 0, "keywords folded");
click(&mut h, "sidebarSection:keywords");
assert!(rows(&h, "source:keyword:") > 0);
if has(&h, "sidebarSection:local") {
click(&mut h, "sidebarSection:local");
assert_eq!(rows(&h, "source:local:"), 0, "local folded");
assert!(!has(&h, "source:local:browse"), "Browse Folder… folds with it");
click(&mut h, "sidebarSection:local");
assert!(!h.app.ui.sidebar_section_collapsed("local"));
}
// a click on the plus is the button's, not the header's
click(&mut h, "icon:albumNew");
assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums");
}

View File

@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 5 | 0 | 3 | 3 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 16 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| X. Cross-cutting behaviours (BEHAV) | 17 | 4 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 81 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 74 | 3 | 3 | 1 | 49/52 (94%) | 22/23 (96%) |
| Lightroom Classic extras | 31 | 10 | 39 | 9 | — | 21/22 (95%) |
| **Total** | 388 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) |
| **Total** | 389 | 34 | 86 | 37 | 194/200 (97%) | 139/149 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.7%** of 508 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.2% of 159.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 509 in-scope rows — P0 98.5% of 200 · P1 95.6% of 149 · P2 41.6% of 160.
<!-- /parity:summary -->
## Top gaps
@ -501,6 +501,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric |
| 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`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) |
| LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | |
| 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-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` | |