LR-BEHAV-SIDEBAR-HIDE: show and hide sidebar sections from View ▸ Sidebar Sections and their headers

view.sidebarSection / view.sidebarReset; the same list opens from a section
header's and the My Photos title's context menu. A new album brings a hidden
Albums section back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-10 12:29:28 -04:00
co-authored by Claude Opus 5.5
parent 338b67ffbe
commit a64efb149c
5 changed files with 264 additions and 7 deletions
+14
View File
@@ -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<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"])
+51
View File
@@ -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<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, "library.inspectLightroom" | "library.importLightroom") {
@@ -324,6 +348,33 @@ 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.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)
+43 -1
View File
@@ -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:<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})));
}
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<SidebarSection> = app.ui.sidebar.shown().collect();
+148
View File
@@ -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<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());
}
/// 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]
+8 -6
View File
@@ -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.
<!-- /parity:summary -->
## 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:<albums\|local\|byDate\|folders\|keywords>`) 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:<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-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` | |