mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-26 11:09:23 -06:00
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:
co-authored by
Claude Opus 5.5
parent
338b67ffbe
commit
a64efb149c
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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` | |
|
||||
|
||||
Reference in New Issue
Block a user