From 7f3d9ce25f2e0a4701dcb06649267b9cafde27d9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Wed, 7 Oct 2026 18:49:00 -0400 Subject: [PATCH] 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 --- crates/ui-egui/src/panels/left.rs | 55 ++++++++++++++++++++++-------- crates/ui-egui/src/state.rs | 14 ++++++++ crates/ui-egui/src/tests_panels.rs | 51 +++++++++++++++++++++++++++ docs/parity.md | 7 ++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 760eefde..e8d348eb 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -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 = app.session.catalog.albums().cloned().collect(); - albums_tree(app, ui, &albums, None, 0.0); + if albums_open { + let albums: Vec = 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) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index bdbbcf29..9d4df859 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -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, /// Local sidebar locations hidden with “Remove from Local” (folders on disk are untouched). pub hidden_locations: Vec, /// 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) } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 7de05e0b..a289e2ee 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -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"); +} diff --git a/docs/parity.md b/docs/parity.md index 82729971..e32f3d49 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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. ## 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:`) 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` | |