diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e6..d59b1328a 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -124,6 +124,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.people", "---", "view.leftPanel", + "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", "view.filmstrip", @@ -460,6 +461,19 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { 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 = 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"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c9b397f60..22c27b808 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,6 +137,8 @@ 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.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, ""), @@ -264,6 +266,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 { + 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, 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> { if matches!(id, "library.inspectLightroom" | "library.importLightroom") { @@ -324,6 +348,33 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {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.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) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a733033c5..35e2ab9f3 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -209,6 +209,7 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe if resp.clicked() { app.ui.sidebar.toggle_collapsed(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)); let text = ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); @@ -222,6 +223,40 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe open } +/// 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:` and `sidebarMenu:reset`. +fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option) { + 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}))); + } + 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 + at the end of the Albums header: Create Album, Smart Album, Folder… fn albums_header_actions(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { // the + stays at the visible edge when the sidebar is scrolled sideways @@ -267,7 +302,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let width = app.ui.left_width; let resized = super::resizable_side(ui, true, "left_panel", frame, width, crate::state::LEFT_WIDTH, 0.0, |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); @@ -301,6 +339,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } // 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); + } // Local keeps track of the folder being browsed whether or not it is drawn let local = local_now(app, ui); let shown: Vec = app.ui.sidebar.shown().collect(); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 6c8128f71..4686412b9 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -279,6 +279,154 @@ fn the_sidebar_draws_its_sections_as_arranged() { } } +/// 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)> { + 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::(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()); +} + /// Albums nest in folders like the other sidebar trees: a folder row has a disclosure triangle /// (`albumToggle:`), plain albums have none, and folding a folder hides what is inside it. #[test] diff --git a/docs/parity.md b/docs/parity.md index 667c7471a..8bfbc7d7e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 2 | 1 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | +| V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | -| X. Cross-cutting behaviours (BEHAV) | 18 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | -| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | +| X. Cross-cutting behaviours (BEHAV) | 20 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | +| Y. Menus | 83 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | -| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 393 | 39 | 84 | 36 | 192/200 (96%) | 142/152 (93%) | +| Lightroom Classic extras | 33 | 15 | 35 | 9 | — | 23/25 (92%) | +| **Total** | 400 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.9%** of 516 in-scope rows — P0 98.0% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.3%** of 522 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.2% of 166. ## Top gaps @@ -527,6 +527,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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` (`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:`) to fold or unfold the section; a chevron after 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). UI-only (no command), like the By Date / Keywords disclosure triangles; 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. My Photos (All Photos, Recently Added, Picks, Missing Photos) 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:`, `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-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` | | @@ -602,6 +603,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` | | | MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |