From 338b67ffbed54f7dc2efa6934877d04f82c76981 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 12:26:29 -0400 Subject: [PATCH 1/7] LR-BEHAV-SIDEBAR-SECTIONS: sidebar sections share one frame, one tree toggle and one saved arrangement Albums, Local, By Date, Folders and Keywords are drawn by one section frame from SidebarLayout (order, hidden, collapsed per section), which is whole whatever a settings file says. The folded sections of older settings files stay folded. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/left.rs | 383 ++++++++++++++++------------- crates/ui-egui/src/sidebar.rs | 372 ++++++++++++++++++++++++++++ crates/ui-egui/src/state.rs | 19 +- crates/ui-egui/src/tests_panels.rs | 59 +++++ docs/parity.md | 3 +- 6 files changed, 655 insertions(+), 182 deletions(-) create mode 100644 crates/ui-egui/src/sidebar.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..cc7d13f15 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod pick; pub mod region; pub mod render; pub mod shortcuts; +pub mod sidebar; pub mod softpaint; pub mod state; pub mod sync; diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index b435f6eda..a733033c5 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::icons::{Icon, paint}; +use crate::sidebar::SidebarSection; use crate::theme::Tokens; use crate::widgets::{icon_button, register}; @@ -143,38 +144,121 @@ fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, ope let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); let col = if tr.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)); + ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); tr } -/// 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) { +/// Whether the tree row `key` is open: what the user last chose this session, else `default`. +fn tree_open(ui: &egui::Ui, key: egui::Id, default: bool) -> bool { + ui.data(|d| d.get_temp(key)).unwrap_or(default) +} + +/// Open or close the tree row `key` from now on. +fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { + ui.data_mut(|d| d.insert_temp(key, open)); +} + +/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response +/// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). +fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { + let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget); + if tri.clicked() { + *open = !*open; + set_tree_open(ui, key, *open); + } + let label = if *open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }; + tri.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + tri +} + +/// A disclosure triangle around `c`: pointing down when open, right when closed. +fn triangle_points(c: egui::Pos2, open: bool) -> Vec { + 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)] + } +} + +/// Space above a section's header. +const SECTION_GAP: f32 = 10.0; +/// Height of a section's header. +const SECTION_HEADER: f32 = 34.0; + +/// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` +/// draws. Every section goes through here, so they all fold, space and announce themselves alike. +fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui)) { + ui.add_space(SECTION_GAP); + let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); + if section_header(app, ui, section, header) { + body(app, ui); + } +} + +/// A section's header in `r`: 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), and what the section adds +/// to its header (Albums: the + menu) sits at the visible edge. Returns whether the section is +/// open. +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: 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()); + let id = section.id(); + let title = crate::i18n::tr(section.title()); + let resp = ui.interact(r, ui.id().with(("sidebar-section", id)), Sense::click()); register(ui.ctx(), format!("sidebarSection:{id}"), r); if resp.clicked() { - app.ui.toggle_sidebar_section(id); + app.ui.sidebar.toggle_collapsed(section); } - let open = !app.ui.sidebar_section_collapsed(id); + 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); 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) + ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + if section == SidebarSection::Albums { + top_level_drop_target(app, ui, r); + albums_header_actions(app, ui, r); + } + open +} + +/// 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 + let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX); + let plus_right = visible_right.min(header.right()); + let mut hdr = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(plus_right - 50.0, header.top()), pos2(plus_right, header.bottom()))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); + egui::Popup::menu(&plus).show(|ui| { + if ui.button(crate::i18n::tr("Create Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None }); + } + if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::SmartRules { + id: None, + name: String::new(), + rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: None, + }); + } + if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); + } + if ui.button(crate::i18n::tr("Create Folder…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); + } + // only once the albums were put in an order by hand + if app.session.catalog.album_children_are_ordered(None) { + ui.separator(); + if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { + let _ = app.run("album.sort", json!({})); + } + } + }); } pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { @@ -215,80 +299,39 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { { let _ = app.run("library.source", json!({"kind": "missing"})); } - ui.add_space(10.0); - // Albums header - let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums"); - top_level_drop_target(app, ui, ar); - // the + stays at the visible edge when the sidebar is scrolled sideways - let plus_right = (ar.left() + viewport.max.x).min(ar.right()); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(plus_right - 50.0, ar.top()), pos2(plus_right, ar.bottom()))) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); - let plus = icon_button(&mut hdr, "albumNew", Icon::Plus, vec2(26.0, 26.0), false, true, "Create Album"); - egui::Popup::menu(&plus).show(|ui| { - if ui.button(crate::i18n::tr("Create Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: false, parent: None }); - } - if ui.button(crate::i18n::tr("Create Smart Album…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::SmartRules { - id: None, - name: String::new(), - rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, - parent: None, - }); - } - if ui.button(crate::i18n::tr("Create Smart Album from Filter…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: String::new(), parent: None }); - } - if ui.button(crate::i18n::tr("Create Folder…")).clicked() { - app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); - } - // only once the albums were put in an order by hand - if app.session.catalog.album_children_are_ordered(None) { - ui.separator(); - if ui.button(crate::i18n::tr("Sort Albums A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() { - let _ = app.run("album.sort", json!({})); - } - } - }); // 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 albums_open { - let kids: AlbumKids = - app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); - let cat = &app.session.catalog; - let mut open_to = Vec::new(); - let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); - while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { - open_to.push(p); - cur = cat.album(p).and_then(|a| a.parent); - } - albums_tree(app, ui, &kids, None, 0.0, &open_to); - } - ui.add_space(10.0); - local_section(app, ui); - // By date - 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 { - let label = crate::i18n::date_group_label(m, true); - if date_row(app, ui, m, &label, *n, 16.0) { - for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { - let label = crate::i18n::date_group_label(d, true); - date_row(app, ui, d, &label, *n, 32.0); - } + // 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(); + for s in shown { + match s { + SidebarSection::Albums => section(app, ui, s, |app, ui| albums_rows(app, ui, reveal)), + SidebarSection::Local => { + let Some(local) = &local else { continue }; + section(app, ui, s, |app, ui| local_rows(app, ui, local)) + } + SidebarSection::ByDate => section(app, ui, s, date_rows), + SidebarSection::Folders => { + let tree = app.caches.folder_tree(&app.session.catalog); + if tree.is_empty() { + continue; } + section(app, ui, s, |app, ui| { + reveal_chosen(app, ui, &tree); + folder_rows(app, ui, &tree, 0.0); + }) + } + SidebarSection::Keywords => { + let tree = app.caches.keyword_tree(&app.session.catalog); + if tree.is_empty() { + continue; + } + section(app, ui, s, |app, ui| keyword_rows(app, ui, &tree, 0.0)) } } } - folders_section(app, ui); - keywords_section(app, ui); - ui.add_space(10.0); + ui.add_space(SECTION_GAP); if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); } @@ -305,6 +348,38 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// Albums: the tree of albums and the folders they are sorted into. `reveal`: an album just made, +/// whose folders open down to it. +fn albums_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, reveal: Option) { + let kids: AlbumKids = app.session.catalog.album_children_by_parent().into_iter().map(|(k, v)| (k, v.into_iter().cloned().collect())).collect(); + let cat = &app.session.catalog; + let mut open_to = Vec::new(); + let mut cur = reveal.map(AlbumId).and_then(|id| cat.album(id)).and_then(|a| a.parent); + while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { + open_to.push(p); + cur = cat.album(p).and_then(|a| a.parent); + } + albums_tree(app, ui, &kids, None, 0.0, &open_to); +} + +/// By Date: year → month → day; a click filters by that prefix, the triangle opens a level. +fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let groups = app.caches.date_groups(&app.session.catalog); + for g in groups.iter() { + 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 { + let label = crate::i18n::date_group_label(m, true); + if date_row(app, ui, m, &label, *n, 16.0) { + for (d, n) in g.days.iter().filter(|(d, _)| d.starts_with(m.as_str())) { + let label = crate::i18n::date_group_label(d, true); + date_row(app, ui, d, &label, *n, 32.0); + } + } + } + } + } +} + /// How many library photos have no file (Local browse records are not checked; see /// `cmd::missing::checked_path`). Checking stats every file, which on a network share takes /// seconds, so it runs on a worker thread: the count shown is the last finished one, refreshed @@ -345,13 +420,20 @@ fn missing_count(app: &mut LightcraftApp, ui: &mut egui::Ui) -> usize { job.n.load(Relaxed) } +/// Local's folders as they stand this frame, and the folder being browsed among them. +struct LocalNow { + places: LocalPlaces, + current: Option, +} + /// Folders on this computer to browse without adding (Lightroom's Local): Pictures, Desktop, -/// Downloads, the home folder, the folder being browsed, and Browse Folder…. -fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - if cfg!(target_arch = "wasm32") { - return; +/// Downloads, the home folder, the folder being browsed, and the folders kept with Browse +/// Folder…. Worked out every frame the sidebar shows, drawn or not: the row listed for this +/// session only (`local_browse_root`) follows the browsing. `None` where there is no Local (web). +fn local_now(app: &mut LightcraftApp, ui: &egui::Ui) -> Option { + if !SidebarSection::Local.available() { + return None; } - 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() { @@ -366,18 +448,20 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } let browsing = app.session.browse.clone().filter(|_| app.session.source == LibrarySource::Folder); let current = browsing.as_ref().map(|b| b.path.clone()); - let local = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); + let places = local_places(builtin, &app.ui.local_roots, current.as_deref(), app.ui.local_browse_root.as_deref(), &app.ui.hidden_locations); if current.is_some() { - app.ui.local_browse_root = local.browse_root.clone(); - } - if !open { - ui.add_space(10.0); - return; + app.ui.local_browse_root = places.browse_root.clone(); } + Some(LocalNow { places, current }) +} + +/// Local's rows: each place as a folder tree, Browse Folder…, and the way back for hidden places. +fn local_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, now: &LocalNow) { + let (local, current) = (&now.places, now.current.as_deref()); 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); - folder_tree(app, ui, name, path, 0.0, current.as_deref(), reveal, transient); + folder_tree(app, ui, name, path, 0.0, current, reveal, transient); } if app.services.pick_folder.is_some() && row(app, ui, "local:browse", Icon::Plus, crate::i18n::tr("Browse Folder…"), None, false, 0.0).clicked() { @@ -403,7 +487,6 @@ fn local_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("local.restoreHidden", json!({})); } } - ui.add_space(10.0); } /// Whether two paths name the same folder, however they are spelled (separators, trailing @@ -584,25 +667,21 @@ fn folder_tree( let open_id = egui::Id::new(("folder-open", path.to_string())); let sel = current.is_some_and(|c| same_folder(c, path)); let on_the_way = !sel && current.is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); if reveal && on_the_way { // opened once per browsed folder: collapsing it again afterwards sticks let revealed_id = egui::Id::new(("folder-revealed", path.to_string())); let target = current.map(str::to_string); if ui.data(|d| d.get_temp::>(revealed_id)) != Some(target.clone()) { open = true; - ui.data_mut(|d| { - d.insert_temp(open_id, true); - d.insert_temp(revealed_id, target); - }); + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("folder-tri", path.to_string())), format!("folderToggle:{path}")); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } else if resp.clicked() + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); + if !tr.clicked() + && resp.clicked() && let Err(e) = app.run("library.browse", json!({"path": path})) { app.toast(ui.ctx(), e); @@ -662,15 +741,11 @@ fn folder_tree( /// One By Date row (`key`: `YYYY`, `YYYY-MM` or `YYYY-MM-DD`); returns whether it is open. fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, count: usize, indent: f32) -> bool { let open_id = egui::Id::new(("date-open", key.to_string())); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.date.as_deref() == Some(key); let resp = row(app, ui, &format!("date:{key}"), Icon::Clock, label, Some(count), sel, indent); if key.len() < 10 { - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("date-tri", key.to_string())), format!("dateToggle:{key}")); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("dateToggle:{key}")); } if resp.clicked() { let v = if sel { serde_json::Value::Null } else { json!(key) }; @@ -698,10 +773,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare for a in kids { if a.folder { let open_id = egui::Id::new(("folder-open", a.id.0)); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let mut open = tree_open(ui, open_id, true); if open_to.contains(&a.id) && !open { open = true; - ui.data_mut(|d| d.insert_temp(open_id, true)); + set_tree_open(ui, open_id, true); } let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag(), None); @@ -711,20 +786,20 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare app.ui.dragging_album = Some(a.id.0); } if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open), indent) { - ui.data_mut(|d| d.insert_temp(open_id, true)); + set_tree_open(ui, open_id, true); } - let mut toggled = resp.clicked(); + // what this frame draws; a click shows from the next one + let draw_open = open; if has_children { - let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); - toggled |= tri.clicked(); + let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); // the triangle sits on the row and takes its clicks: the menu opens from it too folder_menu(app, &tri, a); } - if toggled { - ui.data_mut(|d| d.insert_temp(open_id, !open)); + if resp.clicked() { + set_tree_open(ui, open_id, !open); } folder_menu(app, &resp, a); - if open { + if draw_open { albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to); } } else { @@ -1149,22 +1224,10 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } -/// "Folders": where on disk the library's photos were imported from, with photo counts (see -/// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a -/// Local folder: its photos and those of the folders inside it fill the grid. The triangle opens -/// a level. Only folders holding imported photos are -/// listed; every folder on disk is under Local. -fn folders_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let tree = app.caches.folder_tree(&app.session.catalog); - if tree.is_empty() { - return; - } - ui.add_space(10.0); - if sidebar_section_header(app, ui, "folders", "Folders").1 { - reveal_chosen(app, ui, &tree); - folder_rows(app, ui, &tree, 0.0); - } -} +// "Folders": where on disk the library's photos were imported from, with photo counts (see +// `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local +// folder: its photos and those of the folders inside it fill the grid. The triangle opens a +// level. Only folders holding imported photos are listed; every folder on disk is under Local. /// Whenever the shown folder changes (a click, an agent, a rename or its undo), open the rows /// above it so it is on screen; folding one by hand afterwards sticks until the choice changes. @@ -1197,7 +1260,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], // a disk starts open, and so does a folder that holds nothing itself and leads to one // folder (`Users` → `me`): the first row where the library branches is what you look for let leads_on = n.own == 0 && n.children.len() == 1; - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(n.volume || leads_on); + let mut open = tree_open(ui, open_id, n.volume || leads_on); // a row whose path would cover other disks' photos too only opens and closes let selectable = n.selectable; let sel = selectable @@ -1214,11 +1277,8 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], let resp = row_sensed(app, ui, &id, Icon::Folder, &name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); let mut toggled = false; if !n.children.is_empty() { - let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), format!("libraryFolderToggle:{}", n.path)); + let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("libraryFolderToggle:{}", n.path)); toggled = tr.clicked(); - tr.widget_info(|| { - egui::WidgetInfo::labeled(egui::WidgetType::Button, true, if open { crate::i18n::tr("Collapse") } else { crate::i18n::tr("Expand") }) - }); // the triangle sits on the row and takes its clicks: the menu opens from it too row_menu(app, &tr, n); } @@ -1229,13 +1289,10 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], // a source like an album or a Local folder: it replaces what the grid showed let _ = app.run("library.source", json!({"kind": "libraryFolder", "path": n.path})); } else { - toggled = true; + open = !open; + set_tree_open(ui, open_id, open); } } - if toggled { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } row_menu(app, &resp, n); if open && !n.children.is_empty() { folder_rows(app, ui, &n.children, indent + 16.0); @@ -1348,30 +1405,14 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F /// "Keywords": the library's keyword tree with photo counts (`a|b|c` keywords nest). A click /// 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 tree = app.caches.keyword_tree(&app.session.catalog); - if tree.is_empty() { - return; - } - ui.add_space(10.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) { for n in nodes { let open_id = egui::Id::new(("kw-open", n.path.to_lowercase())); - let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(false); + let mut open = tree_open(ui, open_id, false); let sel = app.session.filter.keyword.as_deref().is_some_and(|k| k.eq_ignore_ascii_case(&n.path)); let resp = row(app, ui, &format!("keyword:{}", n.path), Icon::Tag, &n.name, Some(n.count), sel, indent); if !n.children.is_empty() { - let tr = - disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("kw-tri", n.path.to_lowercase())), format!("keywordToggle:{}", n.path)); - if tr.clicked() { - open = !open; - ui.data_mut(|d| d.insert_temp(open_id, open)); - } + tree_toggle(ui, &resp, indent, open_id, &mut open, format!("keywordToggle:{}", n.path)); } let resp = resp.on_hover_text(if n.children.is_empty() { n.path.clone() } else { format!("{} (includes the keywords below it)", n.path) }); if resp.clicked() { diff --git a/crates/ui-egui/src/sidebar.rs b/crates/ui-egui/src/sidebar.rs new file mode 100644 index 000000000..a5e80cdb9 --- /dev/null +++ b/crates/ui-egui/src/sidebar.rs @@ -0,0 +1,372 @@ +//! The left sidebar's sections and how the user arranged them: their order, which are hidden and +//! which are folded shut. No drawing here (that is `panels/left.rs`); this is the arrangement +//! itself, saved with the UI state and changed by the `view.sidebar*` commands. +//! +//! My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a +//! section: it stays at the top and can't be hidden, moved or folded. + +use serde::{Deserialize, Serialize}; + +/// A section of the left sidebar below My Photos. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SidebarSection { + Albums, + Local, + ByDate, + Folders, + Keywords, +} + +impl SidebarSection { + /// Every section, in the order a new library shows them. + pub const ALL: [SidebarSection; 5] = + [SidebarSection::Albums, SidebarSection::Local, SidebarSection::ByDate, SidebarSection::Folders, SidebarSection::Keywords]; + + /// The name commands, the saved UI state and widget ids (`sidebarSection:`) use. + pub const fn id(self) -> &'static str { + match self { + SidebarSection::Albums => "albums", + SidebarSection::Local => "local", + SidebarSection::ByDate => "byDate", + SidebarSection::Folders => "folders", + SidebarSection::Keywords => "keywords", + } + } + + /// The heading, in English (translated where it is shown). + pub const fn title(self) -> &'static str { + match self { + SidebarSection::Albums => "Albums", + SidebarSection::Local => "Local", + SidebarSection::ByDate => "By Date", + SidebarSection::Folders => "Folders", + SidebarSection::Keywords => "Keywords", + } + } + + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|s| s.id() == id) + } + + /// Whether this build has the section at all (Local browses folders on disk: not on the web). + pub const fn available(self) -> bool { + !(cfg!(target_arch = "wasm32") && matches!(self, SidebarSection::Local)) + } + + /// The ids of every section, for error messages (`albums|local|…`). + pub fn ids() -> String { + Self::ALL.map(SidebarSection::id).join("|") + } +} + +/// Where one section stands: shown or hidden, open or folded. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct SectionState { + pub id: SidebarSection, + pub hidden: bool, + pub collapsed: bool, +} + +impl SectionState { + const fn new(id: SidebarSection) -> Self { + Self { id, hidden: false, collapsed: false } + } +} + +/// The arrangement of the sidebar's sections, top to bottom. Every section is in it exactly once, +/// whatever was read from disk or sent by an agent: unknown and repeated entries are dropped and +/// sections not named come last, in their usual order. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "serde_json::Value", into = "Vec")] +pub struct SidebarLayout(Vec); + +impl Default for SidebarLayout { + fn default() -> Self { + Self(SidebarSection::ALL.map(SectionState::new).to_vec()) + } +} + +impl From for Vec { + fn from(layout: SidebarLayout) -> Self { + layout.0 + } +} + +/// Lenient on purpose: a settings file edited by hand, written by a newer version, or damaged +/// must still open, so whatever can't be understood is left out instead of failing the load. +impl From for SidebarLayout { + fn from(saved: serde_json::Value) -> Self { + let entries = saved.as_array().map(Vec::as_slice).unwrap_or_default(); + let flag = |entry: &serde_json::Value, key: &str| entry.get(key).and_then(serde_json::Value::as_bool).unwrap_or(false); + let read = entries.iter().filter_map(|entry| { + // an entry is `{id, hidden?, collapsed?}`, or just the id + let id = entry.as_str().or_else(|| entry.get("id").and_then(serde_json::Value::as_str))?; + Some(SectionState { id: SidebarSection::from_id(id)?, hidden: flag(entry, "hidden"), collapsed: flag(entry, "collapsed") }) + }); + Self::from_states(read) + } +} + +impl SidebarLayout { + /// The layout these states describe, made whole (see the type). + fn from_states(states: impl IntoIterator) -> Self { + let mut all: Vec = Vec::with_capacity(SidebarSection::ALL.len()); + for state in states.into_iter().chain(SidebarSection::ALL.map(SectionState::new)) { + if !all.iter().any(|s| s.id == state.id) { + all.push(state); + } + } + Self(all) + } + + /// Every section top to bottom, hidden ones included. + pub fn sections(&self) -> &[SectionState] { + &self.0 + } + + /// The sections that are shown, top to bottom. + pub fn shown(&self) -> impl Iterator + '_ { + self.0.iter().filter(|s| !s.hidden && s.id.available()).map(|s| s.id) + } + + fn state(&self, section: SidebarSection) -> SectionState { + self.0.iter().copied().find(|s| s.id == section).unwrap_or(SectionState::new(section)) + } + + fn state_mut(&mut self, section: SidebarSection) -> Option<&mut SectionState> { + self.0.iter_mut().find(|s| s.id == section) + } + + pub fn is_hidden(&self, section: SidebarSection) -> bool { + self.state(section).hidden + } + + pub fn is_collapsed(&self, section: SidebarSection) -> bool { + self.state(section).collapsed + } + + /// Show or hide a section. It keeps its place and whether it was folded. + pub fn set_hidden(&mut self, section: SidebarSection, hidden: bool) { + if let Some(s) = self.state_mut(section) { + s.hidden = hidden; + } + } + + /// Fold a section shut or open it. + pub fn set_collapsed(&mut self, section: SidebarSection, collapsed: bool) { + if let Some(s) = self.state_mut(section) { + s.collapsed = collapsed; + } + } + + pub fn toggle_collapsed(&mut self, section: SidebarSection) { + self.set_collapsed(section, !self.is_collapsed(section)); + } + + /// Make sure a section can be seen: shown and open (something in it is being pointed out). + pub fn reveal(&mut self, section: SidebarSection) { + self.set_hidden(section, false); + self.set_collapsed(section, false); + } + + /// Put `section` just above `before`, or last when `before` is `None`. Returns whether the + /// order changed (moving a section above itself, or to where it already is, changes nothing). + pub fn move_before(&mut self, section: SidebarSection, before: Option) -> bool { + if before == Some(section) { + return false; + } + let was = self.0.clone(); + let moved = self.state(section); + self.0.retain(|s| s.id != section); + let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len()); + self.0.insert(at, moved); + self.0 != was + } + + /// Move `section` one place up or down among the sections that are shown (hidden ones are + /// stepped over, so every move is one the user can see). Returns whether it moved: the first + /// can't go up, the last can't go down, and a hidden section stays where it is. + pub fn move_by(&mut self, section: SidebarSection, up: bool) -> bool { + let shown: Vec = self.shown().collect(); + let Some(at) = shown.iter().position(|s| *s == section) else { return false }; + if up { + let Some(above) = at.checked_sub(1).and_then(|i| shown.get(i)) else { return false }; + self.move_before(section, Some(*above)) + } else { + let Some(below) = shown.get(at + 1) else { return false }; + // below the next one: above the one after it, or last + self.move_before(section, shown.get(at + 2).copied()) || self.move_before(*below, Some(section)) + } + } + + /// Whether `section` can move up (or down) among the shown sections. + pub fn can_move(&self, section: SidebarSection, up: bool) -> bool { + let shown: Vec = self.shown().collect(); + match shown.iter().position(|s| *s == section) { + Some(at) if up => at > 0, + Some(at) => at + 1 < shown.len(), + None => false, + } + } + + /// Whether this is the arrangement a new library starts with. + pub fn is_default(&self) -> bool { + *self == Self::default() + } + + /// Fold shut the sections an older settings file listed as collapsed (`collapsedSidebar`). + pub fn adopt_collapsed(&mut self, ids: &[String]) { + for section in ids.iter().filter_map(|id| SidebarSection::from_id(id)) { + self.set_collapsed(section, true); + } + } +} + +#[cfg(test)] +mod tests { + use super::SidebarSection::{Albums, ByDate, Folders, Keywords, Local}; + use super::*; + use serde_json::json; + + fn order(layout: &SidebarLayout) -> Vec { + layout.sections().iter().map(|s| s.id).collect() + } + + fn read(saved: serde_json::Value) -> SidebarLayout { + serde_json::from_value(saved).unwrap() + } + + /// Given a new library, the sidebar lists every section, shown and open, in the usual order. + #[test] + fn a_new_sidebar_shows_every_section_open_in_the_usual_order() { + let layout = SidebarLayout::default(); + assert_eq!(order(&layout), [Albums, Local, ByDate, Folders, Keywords]); + assert!(layout.sections().iter().all(|s| !s.hidden && !s.collapsed)); + assert!(layout.is_default()); + } + + /// Given an arrangement the user made, when it is saved and read back, then it is the same. + #[test] + fn an_arrangement_survives_saving() { + let mut layout = SidebarLayout::default(); + layout.move_before(Keywords, Some(Albums)); + layout.set_hidden(Local, true); + layout.set_collapsed(ByDate, true); + let saved = serde_json::to_value(&layout).unwrap(); + assert_eq!(saved[0], json!({"id": "keywords", "hidden": false, "collapsed": false})); + assert_eq!(read(saved), layout); + } + + /// Given a saved arrangement that is incomplete, repeats itself or names sections this version + /// doesn't know, when it is read, then every section is there exactly once: the readable + /// entries first, in their order, and the rest after them. + #[test] + fn a_damaged_arrangement_is_made_whole() { + let layout = read(json!([ + {"id": "keywords", "collapsed": true}, + {"id": "map"}, + "folders", + {"id": "keywords", "hidden": true}, + {"id": 7}, + null, + {"id": "albums", "hidden": "yes", "collapsed": 1} + ])); + assert_eq!(order(&layout), [Keywords, Folders, Albums, Local, ByDate]); + assert!(layout.is_collapsed(Keywords) && !layout.is_hidden(Keywords), "the first entry for a section counts"); + assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums), "a flag that isn't true or false is off"); + } + + /// Given a saved arrangement that is not a list at all, when it is read, then the sidebar is + /// the usual one (and the rest of the settings still load). + #[test] + fn an_unreadable_arrangement_is_the_usual_one() { + for saved in [json!(null), json!("albums"), json!(3), json!({"albums": true}), json!([])] { + assert!(read(saved.clone()).is_default(), "{saved}"); + } + } + + /// Hiding a section takes it off the sidebar and nothing else: it keeps its place and stays + /// folded or open, so showing it again puts it back as it was. + #[test] + fn a_hidden_section_comes_back_where_and_how_it_was() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(ByDate, true); + layout.set_hidden(ByDate, true); + assert_eq!(layout.shown().collect::>(), [Albums, Local, Folders, Keywords]); + assert!(!layout.is_default()); + layout.set_hidden(ByDate, false); + assert_eq!(layout.shown().collect::>(), [Albums, Local, ByDate, Folders, Keywords]); + assert!(layout.is_collapsed(ByDate)); + } + + /// Revealing a section shows it and opens it. + #[test] + fn revealing_a_section_shows_and_opens_it() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(Albums, true); + layout.set_hidden(Albums, true); + layout.reveal(Albums); + assert!(!layout.is_hidden(Albums) && !layout.is_collapsed(Albums)); + } + + /// A section can be put above any other, or last; the others keep their order. + #[test] + fn a_section_moves_above_another_or_to_the_end() { + let mut layout = SidebarLayout::default(); + assert!(layout.move_before(Keywords, Some(Local))); + assert_eq!(order(&layout), [Albums, Keywords, Local, ByDate, Folders]); + assert!(layout.move_before(Albums, None)); + assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); + // nothing to do: above itself, where it already is + assert!(!layout.move_before(Local, Some(Local))); + assert!(!layout.move_before(Local, Some(ByDate))); + assert!(!layout.move_before(Albums, None)); + assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); + } + + /// Moving a section keeps whether it is hidden or folded. + #[test] + fn a_moved_section_stays_folded() { + let mut layout = SidebarLayout::default(); + layout.set_collapsed(Folders, true); + layout.move_before(Folders, Some(Albums)); + assert!(layout.is_collapsed(Folders)); + } + + /// Move Up and Move Down go one shown section at a time and stop at the ends. + #[test] + fn a_section_moves_one_place_among_the_shown_ones() { + let mut layout = SidebarLayout::default(); + layout.set_hidden(ByDate, true); + // down past the hidden By Date in one step + assert!(layout.can_move(Local, false)); + assert!(layout.move_by(Local, false)); + assert_eq!(layout.shown().collect::>(), [Albums, Folders, Local, Keywords]); + assert!(layout.move_by(Local, false)); + assert_eq!(layout.shown().collect::>(), [Albums, Folders, Keywords, Local]); + assert!(!layout.can_move(Local, false) && !layout.move_by(Local, false), "the last can't go down"); + assert!(layout.move_by(Folders, true)); + assert_eq!(layout.shown().collect::>(), [Folders, Albums, Keywords, Local]); + assert!(!layout.can_move(Folders, true) && !layout.move_by(Folders, true), "the first can't go up"); + assert!(!layout.can_move(ByDate, true) && !layout.move_by(ByDate, true), "a hidden section doesn't move"); + } + + /// Sections an older settings file listed as collapsed are folded; names it got wrong are ignored. + #[test] + fn collapsed_sections_of_an_older_settings_file_stay_folded() { + let mut layout = SidebarLayout::default(); + layout.adopt_collapsed(&["byDate".into(), "nonsense".into(), "keywords".into()]); + assert!(layout.is_collapsed(ByDate) && layout.is_collapsed(Keywords) && !layout.is_collapsed(Albums)); + } + + #[test] + fn sections_are_named_by_their_ids() { + for s in SidebarSection::ALL { + assert_eq!(SidebarSection::from_id(s.id()), Some(s)); + assert_eq!(serde_json::to_value(s).unwrap(), json!(s.id())); + } + assert_eq!(SidebarSection::from_id("Albums"), None); + assert_eq!(SidebarSection::ids(), "albums|local|byDate|folders|keywords"); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index b178a167c..966702356 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -344,8 +344,11 @@ 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. + /// The left sidebar's sections: their order, which are hidden and which are folded shut. + pub sidebar: crate::sidebar::SidebarLayout, + /// What settings files said before [`UiState::sidebar`]: the ids of the folded sections. Read + /// so they stay folded ([`UiState::sanitized`] moves them over), never written. + #[serde(skip_serializing)] pub collapsed_sidebar: Vec, /// A face's name being typed in the loupe. #[serde(skip)] @@ -816,6 +819,7 @@ impl Default for UiState { grid_info: "filename".into(), show_counts: true, face_boxes: true, + sidebar: Default::default(), collapsed_sidebar: Vec::new(), name_edit: None, person_page: None, @@ -901,15 +905,9 @@ impl UiState { self.open_sections.push(id.to_string()); } } + /// Whether the sidebar section with this id is folded shut (an unknown id: no). 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()); - } + crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s)) } pub fn flyout_open(&self, id: &str) -> bool { self.open_flyouts.iter().any(|s| s == id) @@ -927,6 +925,7 @@ impl UiState { self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); + self.sidebar.adopt_collapsed(&std::mem::take(&mut self.collapsed_sidebar)); self.dialog = None; self.fullscreen = false; if !crate::state::PREVIEW_LIMITS.contains(&self.settings.preview_limit) { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 1f5cd4d46..6c8128f71 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -220,6 +220,65 @@ fn sidebar_sections_collapse_and_remember_it() { assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums"); } +/// Given a settings file from before sections could be arranged (it lists the folded ones as +/// `collapsedSidebar`), when it is loaded, then those sections are still folded, and what is +/// saved from then on is the arrangement alone. +#[test] +fn folded_sections_of_an_older_settings_file_stay_folded() { + let old = json!({"leftPanel": true, "collapsedSidebar": ["byDate", "keywords", "no such section"]}); + let ui = serde_json::from_value::(old).unwrap().sanitized(); + assert!(ui.sidebar_section_collapsed("byDate") && ui.sidebar_section_collapsed("keywords") && !ui.sidebar_section_collapsed("albums")); + let saved = serde_json::to_value(&ui).unwrap(); + assert!(saved.get("collapsedSidebar").is_none(), "{saved}"); + assert_eq!(saved["sidebar"][2], json!({"id": "byDate", "hidden": false, "collapsed": true})); +} + +/// Given a settings file whose sidebar arrangement is damaged, when it is loaded, then the rest of +/// the settings load and the sidebar draws every section. +#[test] +fn a_damaged_sidebar_arrangement_does_not_lose_the_settings() { + for sidebar in [json!("oops"), json!([{"id": "folders"}, {"id": "folders"}, 7, {"id": "ghost"}]), json!({"albums": 1})] { + let saved = json!({"leftPanel": true, "leftWidth": 300.0, "sidebar": sidebar}); + let ui = serde_json::from_value::(saved.clone()).unwrap().sanitized(); + assert_eq!(ui.left_width, 300.0, "{saved}"); + assert_eq!(ui.sidebar.sections().len(), crate::sidebar::SidebarSection::ALL.len(), "{saved}"); + } +} + +/// The sections drawn in the sidebar, top to bottom. +fn sections_drawn(h: &Headless) -> Vec { + let mut drawn: Vec<(f32, String)> = + h.app.widgets.iter().filter_map(|(w, r)| w.strip_prefix("sidebarSection:").map(|id| (r.top(), id.to_string()))).collect(); + drawn.sort_by(|a, b| a.0.total_cmp(&b.0)); + drawn.into_iter().map(|(_, id)| id).collect() +} + +/// Given an arrangement of the sidebar (an order, a hidden section), when the sidebar is drawn, +/// then its sections are in that order, the hidden one is not there, and every header is the same +/// distance below what is above it. +#[test] +fn the_sidebar_draws_its_sections_as_arranged() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + // (Local is not on every platform, and the demo library was imported from no folder) + let listed = |drawn: &[String]| drawn.iter().filter(|s| !matches!(s.as_str(), "local" | "folders")).cloned().collect::>(); + let usual = sections_drawn(&h); + assert_eq!(listed(&usual), ["albums", "byDate", "keywords"], "{usual:?}"); + let r = h.request("ui.set", json!({"sidebar": [{"id": "keywords"}, {"id": "byDate", "hidden": true}, {"id": "albums"}]}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let drawn = sections_drawn(&h); + assert_eq!(listed(&drawn), ["keywords", "albums"], "{drawn:?}"); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "a hidden section draws no rows"); + // every header sits the same gap below the last row above it + let mut rows: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + for id in &drawn { + let header = widget(&h, &format!("sidebarSection:{id}")); + let above = rows.iter().rev().find(|r| r.top() < header.top()).map(|r| r.bottom()).unwrap(); + assert_eq!(header.top() - above, 10.0, "{id}"); + } +} + /// 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 d0bdc35c2..667c7471a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -525,7 +525,8 @@ 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. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | 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`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | 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-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-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` | | From a64efb149c96686fccc2f211dd7083e8a11278b6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 12:29:28 -0400 Subject: [PATCH 2/7] =?UTF-8?q?LR-BEHAV-SIDEBAR-HIDE:=20show=20and=20hide?= =?UTF-8?q?=20sidebar=20sections=20from=20View=20=E2=96=B8=20Sidebar=20Sec?= =?UTF-8?q?tions=20and=20their=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ui-egui/src/menubar.rs | 14 +++ crates/ui-egui/src/menus.rs | 51 ++++++++++ crates/ui-egui/src/panels/left.rs | 44 ++++++++- crates/ui-egui/src/tests_panels.rs | 148 +++++++++++++++++++++++++++++ docs/parity.md | 14 +-- 5 files changed, 264 insertions(+), 7 deletions(-) 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` | | From 5ac3e556ace556ef11e6678f96e1ed13bd1400c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 12:36:13 -0400 Subject: [PATCH 3/7] LR-BEHAV-SIDEBAR-REORDER: drag sidebar sections by their header, Move Up / Down, Recently Deleted joins My Photos view.sidebarMoveSection; a section with nothing to list stays with a note instead of disappearing (so every move is one you can see). ui.set reads `sidebar` strictly and still takes the older collapsedSidebar list; an album folder's triangle and row can no longer toggle twice in one click. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 15 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/menus.rs | 28 ++++ crates/ui-egui/src/panels/left.rs | 143 ++++++++++++++-- crates/ui-egui/src/sidebar.rs | 66 +++++++- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_panels.rs | 255 ++++++++++++++++++++++++++++- docs/parity.md | 9 +- 8 files changed, 491 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index a8643db1e..73b11a2b7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -316,10 +316,25 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest ok(Value::Null) } "ui.set" => { + // an arrangement of the sidebar is checked, not made whole as a settings file's is + let sidebar = match p.get("sidebar").map(crate::sidebar::SidebarLayout::parse).transpose() { + Ok(s) => s, + Err(e) => return err(e), + }; let mut v = serde_json::to_value(&app.ui).unwrap_or_default(); lightcraft_develop::presets::deep_merge(&mut v, p); match serde_json::from_value::(v) { Ok(mut u) => { + if let Some(sidebar) = sidebar { + u.sidebar = sidebar; + } + // `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections + if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) { + for section in crate::sidebar::SidebarSection::ALL { + u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id()))); + } + } + u.collapsed_sidebar.clear(); u.toast = app.ui.toast.clone(); u.dialog = app.ui.dialog.clone(); u.status = app.ui.status.clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc7d13f15..7609d3939 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1072,6 +1072,7 @@ impl LightcraftApp { pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); + panels::left::section_drag_feedback(self, &ctx); panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 22c27b808..9d7231e0e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -138,6 +138,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.smartAlbum", "New Smart Album…", None, "File"), ("view.photoCounts", "Show Photo Counts", None, "View"), ("view.sidebarSection", "Show Sidebar Section", None, ""), + ("view.sidebarMoveSection", "Move 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"), @@ -370,6 +371,33 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + // {section, direction: up | down} one place among the shown sections, or + // {section, before:
| null} just above another section (null: last) + let section = match sidebar_section_param(id, p) { + Ok(s) => s, + Err(e) => return Some(Err(e)), + }; + let layout = &mut app.ui.sidebar; + let moved = match (p.get("direction"), p.get("before")) { + (Some(d), None) => match d.as_str() { + Some("up") => layout.move_by(section, true), + Some("down") => layout.move_by(section, false), + _ => return Some(Err(format!("{id}: `direction` must be \"up\" or \"down\", not {d}"))), + }, + (None, Some(Value::Null)) => layout.move_before(section, None), + (None, Some(b)) => { + let before = b.as_str().and_then(crate::sidebar::SidebarSection::from_id); + let Some(before) = before else { + return Some(Err(format!("{id}: `before` must be a section ({}) or null, not {b}", crate::sidebar::SidebarSection::ids()))); + }; + layout.move_before(section, Some(before)) + } + _ => return Some(Err(format!("{id}: give either `direction` (up|down) or `before` (a section, or null for last)"))), + }; + let order: Vec<&str> = layout.sections().iter().map(|s| s.id.id()).collect(); + Ok(json!({"section": section.id(), "moved": moved, "order": order})) + } "view.sidebarReset" => { // every section shown and open, in the usual order app.ui.sidebar = Default::default(); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 35e2ab9f3..0ae4afa78 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -188,12 +188,109 @@ const SECTION_HEADER: f32 = 34.0; /// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` /// draws. Every section goes through here, so they all fold, space and announce themselves alike. -fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui)) { +fn section( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + section: SidebarSection, + body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), +) -> PlacedSection { ui.add_space(SECTION_GAP); let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); if section_header(app, ui, section, header) { + let rows_from = ui.cursor().top(); body(app, ui); + if ui.cursor().top() <= rows_from { + empty_hint(ui, section); + } } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top() } +} + +/// What an open section with nothing to list says in place of its rows (widget +/// `sidebarEmpty:`), so the section stays where it is instead of disappearing. +fn empty_hint(ui: &mut egui::Ui, section: SidebarSection) { + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + register(ui.ctx(), format!("sidebarEmpty:{}", section.id()), r); + let text = crate::i18n::tr(section.empty_hint()); + let galley = ui.painter().layout_no_wrap(text.to_string(), t.font(12.5), t.text_dim); + note_width(ui, 42.0 + galley.size().x + 18.0); + ui.painter().galley(pos2(r.left() + 42.0, r.center().y - galley.size().y / 2.0), galley, t.text_dim); +} + +/// Where a section was laid out this frame (screen y): from its header's top to the end of its rows. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct PlacedSection { + pub section: SidebarSection, + pub top: f32, + pub bottom: f32, +} + +/// Where a section dragged to `y` would go among the sections drawn: above the first one whose +/// middle is below `y` (`Some`), or after them all (`None`). +pub(crate) fn section_drop_before(placed: &[PlacedSection], y: f32) -> Option { + placed.iter().find(|p| y < (p.top + p.bottom) / 2.0).map(|p| p.section) +} + +/// While a section is dragged by its header: a line shows where it would go, releasing over the +/// sidebar moves it there (`view.sidebarMoveSection`), releasing elsewhere or Esc leaves it. +fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection]) { + let Some(dragged) = app.ui.dragging_section else { return }; + let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if esc { + app.ui.dragging_section = None; + return; + } + let view = ui.clip_rect(); + let Some(pos) = pos.filter(|p| view.contains(*p)) else { + if released { + app.ui.dragging_section = None; + } + return; + }; + let before = section_drop_before(placed, pos.y); + if released { + app.ui.dragging_section = None; + let before = before.map_or(serde_json::Value::Null, |b| json!(b.id())); + if let Err(e) = app.run("view.sidebarMoveSection", json!({"section": dragged.id(), "before": before})) { + app.toast(ui.ctx(), e); + } + return; + } + // the line: in the gap above the section it would go before, or below the last one + let y = match before { + Some(b) => placed.iter().find(|p| p.section == b).map(|p| p.top - SECTION_GAP / 2.0), + None => placed.last().map(|p| p.bottom + SECTION_GAP / 2.0), + }; + if let Some(y) = y { + let t = Tokens::get(ui.ctx()); + let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(view.right()); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right.min(view.right()) - 8.0, y + 1.0)); + ui.painter().rect_filled(line, 1.0, t.accent); + register(ui.ctx(), "sidebarSectionDrop", line); + } +} + +/// The dragged section's name follows the pointer; a drag that ended anywhere (also outside the +/// sidebar, or with the sidebar closed) is over. +pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(section) = app.ui.dragging_section else { return }; + let (down, pos) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos())); + if !down || !app.ui.left_panel { + app.ui.dragging_section = None; + return; + } + let Some(pos) = pos else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::new("drag-section")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show( + ctx, + |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(section.title())).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }, + ); } /// A section's header in `r`: the bold title with a disclosure chevron after it; a click folds or @@ -204,11 +301,15 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe let t = Tokens::get(ui.ctx()); let id = section.id(); let title = crate::i18n::tr(section.title()); - let resp = ui.interact(r, ui.id().with(("sidebar-section", id)), Sense::click()); + let resp = ui.interact(r, ui.id().with(("sidebar-section", id)), Sense::click_and_drag()); register(ui.ctx(), format!("sidebarSection:{id}"), r); if resp.clicked() { app.ui.sidebar.toggle_collapsed(section); } + // dragged by its header, a section moves among the others (see `section_drag`) + if resp.drag_started_by(egui::PointerButton::Primary) { + app.ui.dragging_section = Some(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)); @@ -234,6 +335,13 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option = app.ui.sidebar.shown().collect(); + let mut placed: Vec = Vec::with_capacity(shown.len()); for s in shown { - match s { + let at = match s { SidebarSection::Albums => section(app, ui, s, |app, ui| albums_rows(app, ui, reveal)), SidebarSection::Local => { let Some(local) = &local else { continue }; @@ -356,9 +468,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { SidebarSection::ByDate => section(app, ui, s, date_rows), SidebarSection::Folders => { let tree = app.caches.folder_tree(&app.session.catalog); - if tree.is_empty() { - continue; - } section(app, ui, s, |app, ui| { reveal_chosen(app, ui, &tree); folder_rows(app, ui, &tree, 0.0); @@ -366,17 +475,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } SidebarSection::Keywords => { let tree = app.caches.keyword_tree(&app.session.catalog); - if tree.is_empty() { - continue; - } section(app, ui, s, |app, ui| keyword_rows(app, ui, &tree, 0.0)) } - } - } - ui.add_space(SECTION_GAP); - if row(app, ui, "recentlyDeleted", Icon::Trash, "Recently Deleted", Some(deleted), src == LibrarySource::RecentlyDeleted, 0.0).clicked() { - let _ = app.run("library.source", json!({"kind": "recentlyDeleted"})); + }; + placed.push(at); } + // room below the last section (a section dragged to the end is dropped here) + ui.add_space(SECTION_GAP * 2.0); + section_drag(app, ui, &placed); // what the rows asked for becomes next frame's width let next = ui.data(|d| d.get_temp::(egui::Id::new("left-content-width-next"))).unwrap_or(0.0); if (next - content_width(ui.ctx())).abs() > 0.5 { @@ -832,12 +938,15 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare } // what this frame draws; a click shows from the next one let draw_open = open; + let mut toggled = false; if has_children { let tri = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("albumToggle:{}", a.id.0)); + toggled = tri.clicked(); // the triangle sits on the row and takes its clicks: the menu opens from it too folder_menu(app, &tri, a); } - if resp.clicked() { + // one click, one toggle: the triangle's is not the row's as well + if resp.clicked() && !toggled { set_tree_open(ui, open_id, !open); } folder_menu(app, &resp, a); @@ -912,7 +1021,7 @@ pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { /// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call /// inside the scroll area. fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { - if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() && app.ui.dragging_section.is_none() { return; } let view = ui.clip_rect(); @@ -1266,7 +1375,7 @@ fn is_within(app: &LightcraftApp, id: lightcraft_catalog::AlbumId, ancestor: lig false } -// "Folders": where on disk the library's photos were imported from, with photo counts (see +// "Folders" (drawn in `show`): where on disk the library's photos were imported from, with photo counts (see // `lightcraft_catalog::folders`). A click makes that folder the source, like an album or a Local // folder: its photos and those of the folders inside it fill the grid. The triangle opens a // level. Only folders holding imported photos are listed; every folder on disk is under Local. diff --git a/crates/ui-egui/src/sidebar.rs b/crates/ui-egui/src/sidebar.rs index a5e80cdb9..4bc49fd61 100644 --- a/crates/ui-egui/src/sidebar.rs +++ b/crates/ui-egui/src/sidebar.rs @@ -45,6 +45,17 @@ impl SidebarSection { } } + /// What the section says while it has nothing to list, in English. + pub const fn empty_hint(self) -> &'static str { + match self { + SidebarSection::Albums => "No albums yet", + SidebarSection::Local => "No folders to browse", + SidebarSection::ByDate => "No photos yet", + SidebarSection::Folders => "No imported folders yet", + SidebarSection::Keywords => "No keywords yet", + } + } + pub fn from_id(id: &str) -> Option { Self::ALL.into_iter().find(|s| s.id() == id) } @@ -109,6 +120,32 @@ impl From for SidebarLayout { } impl SidebarLayout { + /// The arrangement an agent or script asks for (`ui.set` `sidebar`), read strictly, unlike a + /// settings file: a list whose entries are a section id or `{id, hidden?, collapsed?}`, each + /// section at most once. Sections left out follow in the usual order, shown and open. What is + /// wrong is said, so a typo doesn't quietly rearrange the sidebar. + pub fn parse(asked: &serde_json::Value) -> Result { + let entries = asked.as_array().ok_or_else(|| format!("`sidebar` must be a list of sections ({}), not {asked}", SidebarSection::ids()))?; + let mut states: Vec = Vec::new(); + for entry in entries { + let name = entry + .as_str() + .or_else(|| entry.get("id").and_then(serde_json::Value::as_str)) + .ok_or_else(|| format!("`sidebar`: {entry} names no section (an id, or {{id, hidden, collapsed}})"))?; + let id = SidebarSection::from_id(name).ok_or_else(|| format!("`sidebar`: unknown section `{name}` ({})", SidebarSection::ids()))?; + if states.iter().any(|s| s.id == id) { + return Err(format!("`sidebar`: `{name}` is listed twice")); + } + let flag = |key: &str| match entry.get(key) { + None | Some(serde_json::Value::Null) => Ok(false), + Some(serde_json::Value::Bool(b)) => Ok(*b), + Some(other) => Err(format!("`sidebar`: `{key}` of `{name}` must be true or false, not {other}")), + }; + states.push(SectionState { id, hidden: flag("hidden")?, collapsed: flag("collapsed")? }); + } + Ok(Self::from_states(states)) + } + /// The layout these states describe, made whole (see the type). fn from_states(states: impl IntoIterator) -> Self { let mut all: Vec = Vec::with_capacity(SidebarSection::ALL.len()); @@ -194,9 +231,11 @@ impl SidebarLayout { let Some(above) = at.checked_sub(1).and_then(|i| shown.get(i)) else { return false }; self.move_before(section, Some(*above)) } else { - let Some(below) = shown.get(at + 1) else { return false }; + if at + 1 >= shown.len() { + return false; + } // below the next one: above the one after it, or last - self.move_before(section, shown.get(at + 2).copied()) || self.move_before(*below, Some(section)) + self.move_before(section, shown.get(at + 2).copied()) } } @@ -360,6 +399,29 @@ mod tests { assert!(layout.is_collapsed(ByDate) && layout.is_collapsed(Keywords) && !layout.is_collapsed(Albums)); } + /// Given an arrangement asked for by an agent, when it is right, then it is taken (sections it + /// leaves out follow, shown and open); when anything in it is wrong, then it is refused with + /// what is wrong, instead of being made whole as a settings file is. + #[test] + fn an_arrangement_that_is_asked_for_is_read_strictly() { + let layout = SidebarLayout::parse(&json!(["keywords", {"id": "albums", "hidden": true, "collapsed": null}])).unwrap(); + assert_eq!(order(&layout), [Keywords, Albums, Local, ByDate, Folders]); + assert!(layout.is_hidden(Albums) && !layout.is_collapsed(Albums)); + assert!(SidebarLayout::parse(&json!([])).unwrap().is_default()); + for (asked, says) in [ + (json!(null), "must be a list"), + (json!({"albums": 1}), "must be a list"), + (json!([{"id": "keywrods", "hidden": true}]), "unknown section `keywrods`"), + (json!([7]), "names no section"), + (json!(["albums", {"id": "albums"}]), "`albums` is listed twice"), + (json!([{"id": "albums", "hidden": "yes"}]), "`hidden` of `albums` must be true or false"), + (json!([{"id": "albums", "collapsed": 1}]), "`collapsed` of `albums` must be true or false"), + ] { + let e = SidebarLayout::parse(&asked).unwrap_err(); + assert!(e.contains(says), "{asked}: {e}"); + } + } + #[test] fn sections_are_named_by_their_ids() { for s in SidebarSection::ALL { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 966702356..01778290d 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -421,6 +421,9 @@ pub struct UiState { /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). #[serde(skip)] pub dragging_album: Option, + /// A sidebar section being dragged by its header (dropped between sections to move it there). + #[serde(skip)] + pub dragging_section: Option, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -846,6 +849,7 @@ impl Default for UiState { dragging_photos: None, reveal_album: None, dragging_album: None, + dragging_section: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 4686412b9..a7cc59f00 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -259,18 +259,21 @@ fn sections_drawn(h: &Headless) -> Vec { #[test] fn the_sidebar_draws_its_sections_as_arranged() { let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); - // (Local is not on every platform, and the demo library was imported from no folder) - let listed = |drawn: &[String]| drawn.iter().filter(|s| !matches!(s.as_str(), "local" | "folders")).cloned().collect::>(); + // (Local is not on every platform) + let listed = |drawn: &[String]| drawn.iter().filter(|s| s.as_str() != "local").cloned().collect::>(); let usual = sections_drawn(&h); - assert_eq!(listed(&usual), ["albums", "byDate", "keywords"], "{usual:?}"); + assert_eq!(listed(&usual), ["albums", "byDate", "folders", "keywords"], "{usual:?}"); + // the demo library was imported from no folder: Folders says so in place of rows + assert!(has(&h, "sidebarEmpty:folders") && !has(&h, "sidebarEmpty:keywords")); let r = h.request("ui.set", json!({"sidebar": [{"id": "keywords"}, {"id": "byDate", "hidden": true}, {"id": "albums"}]}), T); assert_eq!(r["ok"], true, "{r}"); h.settle(SETTLE); let drawn = sections_drawn(&h); - assert_eq!(listed(&drawn), ["keywords", "albums"], "{drawn:?}"); + assert_eq!(listed(&drawn), ["keywords", "albums", "folders"], "{drawn:?}"); assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("source:date:")), "a hidden section draws no rows"); // every header sits the same gap below the last row above it - let mut rows: Vec = h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).map(|(_, r)| *r).collect(); + let mut rows: Vec = + h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:") || w.starts_with("sidebarEmpty:")).map(|(_, r)| *r).collect(); rows.sort_by(|a, b| a.top().total_cmp(&b.top())); for id in &drawn { let header = widget(&h, &format!("sidebarSection:{id}")); @@ -427,6 +430,242 @@ fn sidebar_commands_refuse_what_they_cannot_read() { assert!(h.app.ui.sidebar.is_default()); } +/// Press on `from`, move to `to` over a few frames, and release there (after `before_release`). +fn drag_between(h: &mut Headless, from: egui::Pos2, to: egui::Pos2, before_release: Vec) { + let m = egui::Modifiers::NONE; + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); + h.step(); + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.step(); + for e in before_release { + h.app.synthetic.push(e); + h.step(); + } + h.app.synthetic.push(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + h.step(); + h.step(); + h.step(); +} + +fn header(h: &Headless, id: &str) -> egui::Rect { + widget(h, &format!("sidebarSection:{id}")) +} + +/// Given the sidebar, when a section's header is dragged above another section and released, +/// then the section is listed there, the others keeping their order; while it is dragged a line +/// shows where it would go; and the new order is part of the saved UI state. +#[test] +fn dragging_a_header_moves_its_section() { + // folders too: photos imported from a folder + let mut h = folders_app_sized(&["/pics/trip/a.jpg"], [1400.0, 1400.0]); + let usual = sections_drawn(&h); + let with_local = usual.contains(&"local".to_string()); + let listed = |ids: &[&str]| ids.iter().filter(|s| with_local || **s != "local").map(|s| s.to_string()).collect::>(); + assert_eq!(usual, listed(&["albums", "local", "byDate", "folders", "keywords"])); + assert!(has(&h, "sidebarEmpty:keywords"), "no keywords in this library: the section says so and stays"); + // Folders, dragged to just above Albums + let (from, to) = (header(&h, "folders").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0)); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } + h.step(); + assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Folders)); + let line = widget(&h, "sidebarSectionDrop"); + assert!( + line.center().y < header(&h, "albums").top() && line.center().y > header(&h, "albums").top() - 10.0, + "the line is in the gap above Albums: {line:?}" + ); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + h.step(); + assert_eq!(h.app.ui.dragging_section, None); + assert!(!has(&h, "sidebarSectionDrop")); + assert_eq!(sections_drawn(&h), listed(&["folders", "albums", "local", "byDate", "keywords"])); + // dragged below everything: last + let end = egui::pos2(header(&h, "keywords").center().x, widget(&h, "panel:left_panel").bottom() - 20.0); + let from = header(&h, "albums").center(); + drag_between(&mut h, from, end, Vec::new()); + assert_eq!(sections_drawn(&h), listed(&["folders", "local", "byDate", "keywords", "albums"])); + // kept with the UI state + let back = serde_json::from_value::(serde_json::to_value(&h.app.ui).unwrap()).unwrap().sanitized(); + assert_eq!(back.sidebar, h.app.ui.sidebar); + // the header still folds its section with a click + click(&mut h, "sidebarSection:folders"); + assert!(h.app.ui.sidebar_section_collapsed("folders")); +} + +/// A drag that ends with Esc, or is released away from the sidebar, moves nothing. +#[test] +fn a_section_drag_that_is_given_up_moves_nothing() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let usual = sections_drawn(&h); + let (from, to) = (header(&h, "keywords").center(), header(&h, "albums").center() - egui::vec2(0.0, 12.0)); + let esc = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + drag_between(&mut h, from, to, vec![esc]); + assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "Esc"); + // released over the photos + drag_between(&mut h, from, egui::pos2(800.0, to.y), Vec::new()); + assert_eq!((sections_drawn(&h), h.app.ui.dragging_section), (usual.clone(), None), "released outside"); + assert!(h.app.ui.sidebar.is_default()); +} + +/// An album dragged to the Albums header is not a section drag, and the other way round. +#[test] +fn dragging_a_section_is_not_dragging_an_album() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let (from, to) = (header(&h, "albums").center(), header(&h, "keywords").center() + egui::vec2(0.0, 4.0)); + let before = h.app.session.catalog.revision; + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * 0.5)); + h.step(); + h.step(); + assert!(h.app.ui.dragging_section.is_some() && h.app.ui.dragging_album.is_none()); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.revision, before, "no album moved"); +} + +/// Given a section's header menu, Move Up and Move Down move it one shown section at a time +/// (stepping over hidden ones), and the command says when there is nowhere to go. +#[test] +fn a_section_moves_up_and_down_from_its_menu() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + // (Local is not on every platform: taken out, so the same sections are there everywhere) + exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false})); + assert_eq!(sections_drawn(&h), ["albums", "byDate", "keywords"]); + right_click(&mut h, "sidebarSection:keywords"); + click(&mut h, "sidebarMenu:moveUp"); + assert_eq!(sections_drawn(&h), ["albums", "keywords", "byDate"]); + right_click(&mut h, "sidebarSection:albums"); + click(&mut h, "sidebarMenu:moveDown"); + assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"], "one step, over the hidden sections"); + // the ends: nowhere to go + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "keywords", "direction": "up"})); + assert_eq!(r["moved"], false, "{r}"); + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "direction": "down"})); + assert_eq!(r["moved"], false, "{r}"); + // above a named section, or last + let r = exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": "keywords"})); + assert_eq!(r["moved"], true, "{r}"); + assert_eq!(sections_drawn(&h), ["byDate", "keywords", "albums"]); + exec(&mut h, "view.sidebarMoveSection", json!({"section": "byDate", "before": null})); + assert_eq!(sections_drawn(&h), ["keywords", "albums", "byDate"]); + assert_eq!(h.app.ui.sidebar.sections().last().map(|s| s.id.id()), Some("byDate")); + for (params, says) in [ + (json!({"section": "albums"}), "give either `direction`"), + (json!({"section": "albums", "direction": "up", "before": null}), "give either `direction`"), + (json!({"section": "albums", "direction": "left"}), "`direction` must be"), + (json!({"section": "albums", "before": "map"}), "`before` must be a section"), + (json!({"direction": "up"}), "`section` is needed"), + ] { + let r = h.request("engine.execute", json!({"command": "view.sidebarMoveSection", "params": params}), T); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert!(r.to_string().contains(says), "{params}: {r}"); + } +} + +/// Recently Deleted is one of My Photos' rows: right below the others, above every section. +#[test] +fn recently_deleted_is_listed_with_my_photos() { + let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let (picks, deleted) = (widget(&h, "source:picks"), widget(&h, "source:recentlyDeleted")); + assert_eq!(deleted.top(), picks.bottom(), "no gap: the same group"); + let first = sections_drawn(&h).remove(0); + assert_eq!(header(&h, &first).top() - deleted.bottom(), 10.0, "the sections start below it"); +} + +/// An arrangement set through the control channel is checked: a wrong one is refused and leaves +/// the sidebar as it was; the older `collapsedSidebar` list still folds and unfolds sections. +#[test] +fn an_arrangement_set_by_an_agent_is_checked() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + exec(&mut h, "view.sidebarSection", json!({"section": "keywords", "show": false})); + let arranged = h.app.ui.sidebar.clone(); + for bad in [json!(null), json!({"albums": 1}), json!([{"id": "keywrods", "hidden": true}]), json!(["albums", "albums"])] { + let r = h.request("ui.set", json!({"sidebar": bad}), T); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.ui.sidebar, arranged, "{bad}"); + } + let r = h.request("ui.set", json!({"collapsedSidebar": ["byDate"]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.sidebar_section_collapsed("byDate") && !h.app.ui.sidebar_section_collapsed("albums")); + assert!(h.app.ui.sidebar.is_hidden(crate::sidebar::SidebarSection::Keywords), "the rest of the arrangement stays"); + let r = h.request("ui.set", json!({"collapsedSidebar": []}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(!h.app.ui.sidebar_section_collapsed("byDate")); +} + +/// Where a dragged section goes: above the first section whose middle is below the pointer. +#[test] +fn a_dragged_section_goes_above_the_section_it_is_over_the_top_half_of() { + use crate::panels::left::{PlacedSection, section_drop_before}; + use crate::sidebar::SidebarSection::{Albums, ByDate, Keywords}; + let placed = [ + PlacedSection { section: Albums, top: 100.0, bottom: 300.0 }, + PlacedSection { section: ByDate, top: 310.0, bottom: 344.0 }, + PlacedSection { section: Keywords, top: 354.0, bottom: 500.0 }, + ]; + for (y, before) in [ + (0.0, Some(Albums)), + (199.0, Some(Albums)), + (201.0, Some(ByDate)), + (326.0, Some(ByDate)), + (328.0, Some(Keywords)), + (430.0, None), + (9e9, None), + ] { + assert_eq!(section_drop_before(&placed, y), before, "{y}"); + } + assert_eq!(section_drop_before(&[], 10.0), None); + assert_eq!(section_drop_before(&placed, f32::NAN), None); +} + +/// By Date opens a year into its months with the triangle, and closes it again. +#[test] +fn a_year_opens_into_its_months() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let rows = |h: &Headless| h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:date:")).count(); + let years = rows(&h); + let toggle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(w, _)| w.clone()).expect("a year has a triangle"); + click(&mut h, &toggle); + assert!(rows(&h) > years, "months are listed"); + click(&mut h, &toggle); + assert_eq!(rows(&h), years, "closed again"); +} + /// 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] @@ -1171,7 +1410,8 @@ fn removing_a_disk_from_the_library_asks_first() { #[test] fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; - let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]); + // (tall enough for the whole chain: Recently Deleted is above it now) + let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); let mut xs: Vec = Vec::new(); let mut path = String::new(); for name in deep.split('/').filter(|n| !n.is_empty()) { @@ -1266,7 +1506,8 @@ fn a_long_name_alone_does_not_make_the_sidebar_scroll() { #[test] fn the_selection_bar_stays_inside_the_panel() { let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; - let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]); + // (tall enough for the whole chain: Recently Deleted is above it now) + let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); let panel = widget(&h, "panel:left_panel"); let bar = widget(&h, "highlight:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); diff --git a/docs/parity.md b/docs/parity.md index 8bfbc7d7e..9394a27c7 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) | 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) | 20 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | +| X. Cross-cutting behaviours (BEHAV) | 21 | 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 | 33 | 15 | 35 | 9 | — | 23/25 (92%) | -| **Total** | 400 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | +| **Total** | 401 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -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. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 523 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.5% of 167. ## Top gaps @@ -526,8 +526,9 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | 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-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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) 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-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | 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` | | From 5e68d92c9ffaa44df23b284c2a2219b60c5be087 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 12:44:46 -0400 Subject: [PATCH 4/7] LR-BEHAV-SIDEBAR-STICKY: a long section's header stays in sight; sections are easier to tell apart Pinned header at the top of the sidebar, hairlines between sections, the fold chevron in the rows' triangle column, a count on folded sections, fainter zero counts. From review: a move nobody could see is no move, ui.set refuses `sidebar` together with `collapsedSidebar` and unknown names in the latter. Translations and What's New for the sidebar work. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 10 ++- crates/ui-egui/locales/es.json | 10 ++- crates/ui-egui/locales/fr.json | 10 ++- crates/ui-egui/locales/ja.json | 10 ++- crates/ui-egui/locales/pt-br.json | 10 ++- crates/ui-egui/locales/ru.json | 12 ++- crates/ui-egui/locales/uk.json | 10 ++- crates/ui-egui/locales/zh-hans.json | 10 ++- crates/ui-egui/locales/zh-hant.json | 10 ++- crates/ui-egui/src/control.rs | 9 +- crates/ui-egui/src/panels/left.rs | 130 ++++++++++++++++++++++------ crates/ui-egui/src/sidebar.rs | 29 ++++++- crates/ui-egui/src/tests_panels.rs | 88 ++++++++++++++++++- docs/parity.md | 7 +- docs/whats-new.md | 13 +++ 15 files changed, 323 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa24e0f10..4b8fe3875 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2031,5 +2031,13 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "Sidebar Sections": "Seitenleistenbereiche", + "Reset Sidebar Sections": "Seitenleistenbereiche zurücksetzen", + "Hide This Section": "Diesen Bereich ausblenden", + "No folders to browse": "Keine Ordner zum Durchsuchen", + "No photos yet": "Noch keine Fotos", + "No imported folders yet": "Noch keine importierten Ordner", + "Show Sidebar Section": "Seitenleistenbereich anzeigen", + "Move Sidebar Section": "Seitenleistenbereich verschieben" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index c4f2e8357..5e434c565 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1876,5 +1876,13 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "Sidebar Sections": "Secciones de la barra lateral", + "Reset Sidebar Sections": "Restablecer secciones de la barra lateral", + "Hide This Section": "Ocultar esta sección", + "No folders to browse": "No hay carpetas para explorar", + "No photos yet": "Aún no hay fotos", + "No imported folders yet": "Aún no hay carpetas importadas", + "Show Sidebar Section": "Mostrar sección de la barra lateral", + "Move Sidebar Section": "Mover sección de la barra lateral" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index f3422bc0a..7f61cb495 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1876,5 +1876,13 @@ "Downloading model": "Téléchargement du modèle", "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", - "Looking for changes": "Recherche des modifications" + "Looking for changes": "Recherche des modifications", + "Sidebar Sections": "Sections de la barre latérale", + "Reset Sidebar Sections": "Réinitialiser les sections de la barre latérale", + "Hide This Section": "Masquer cette section", + "No folders to browse": "Aucun dossier à parcourir", + "No photos yet": "Aucune photo pour l’instant", + "No imported folders yet": "Aucun dossier importé pour l’instant", + "Show Sidebar Section": "Afficher la section de la barre latérale", + "Move Sidebar Section": "Déplacer la section de la barre latérale" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index a7a3a32ef..11f33756f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1878,5 +1878,13 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "Sidebar Sections": "サイドバーのセクション", + "Reset Sidebar Sections": "サイドバーのセクションをリセット", + "Hide This Section": "このセクションを隠す", + "No folders to browse": "参照できるフォルダがありません", + "No photos yet": "写真はまだありません", + "No imported folders yet": "読み込んだフォルダはまだありません", + "Show Sidebar Section": "サイドバーのセクションを表示", + "Move Sidebar Section": "サイドバーのセクションを移動" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index c3d9a8d78..c7a380816 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1876,5 +1876,13 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "Sidebar Sections": "Seções da barra lateral", + "Reset Sidebar Sections": "Redefinir seções da barra lateral", + "Hide This Section": "Ocultar esta seção", + "No folders to browse": "Nenhuma pasta para navegar", + "No photos yet": "Ainda não há fotos", + "No imported folders yet": "Ainda não há pastas importadas", + "Show Sidebar Section": "Mostrar seção da barra lateral", + "Move Sidebar Section": "Mover seção da barra lateral" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index bf9974740..d1f5525ad 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1590,5 +1590,15 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "Sidebar Sections": "Разделы боковой панели", + "Reset Sidebar Sections": "Сбросить разделы боковой панели", + "Hide This Section": "Скрыть этот раздел", + "No folders to browse": "Нет папок для просмотра", + "No photos yet": "Фотографий пока нет", + "No imported folders yet": "Импортированных папок пока нет", + "Show Sidebar Section": "Показать раздел боковой панели", + "Move Sidebar Section": "Переместить раздел боковой панели", + "Move Up": "Переместить вверх", + "Move Down": "Переместить вниз" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c73c05b93..17a682c18 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2070,5 +2070,13 @@ "Downloading model": "Завантаження моделі", "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", - "Looking for changes": "Пошук змін" + "Looking for changes": "Пошук змін", + "Sidebar Sections": "Розділи бічної панелі", + "Reset Sidebar Sections": "Скинути розділи бічної панелі", + "Hide This Section": "Сховати цей розділ", + "No folders to browse": "Немає тек для перегляду", + "No photos yet": "Фотографій ще немає", + "No imported folders yet": "Імпортованих тек ще немає", + "Show Sidebar Section": "Показати розділ бічної панелі", + "Move Sidebar Section": "Перемістити розділ бічної панелі" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 21fa4e4e9..0cba0bbc9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1876,5 +1876,13 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "Sidebar Sections": "边栏分区", + "Reset Sidebar Sections": "重置边栏分区", + "Hide This Section": "隐藏此分区", + "No folders to browse": "没有可浏览的文件夹", + "No photos yet": "还没有照片", + "No imported folders yet": "还没有导入的文件夹", + "Show Sidebar Section": "显示边栏分区", + "Move Sidebar Section": "移动边栏分区" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d2e0a30c2..d99f0bbd3 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1876,5 +1876,13 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "Sidebar Sections": "側邊欄區段", + "Reset Sidebar Sections": "重設側邊欄區段", + "Hide This Section": "隱藏此區段", + "No folders to browse": "沒有可瀏覽的資料夾", + "No photos yet": "還沒有照片", + "No imported folders yet": "還沒有匯入的資料夾", + "Show Sidebar Section": "顯示側邊欄區段", + "Move Sidebar Section": "移動側邊欄區段" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 73b11a2b7..3ed20fb6b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -330,7 +330,14 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest } // `collapsedSidebar`, as scripts from before `sidebar` set it: the folded sections if let Some(ids) = p.get("collapsedSidebar").and_then(Value::as_array) { - for section in crate::sidebar::SidebarSection::ALL { + use crate::sidebar::SidebarSection; + if p.get("sidebar").is_some() { + return err("ui.set: give `sidebar` or the older `collapsedSidebar`, not both".to_string()); + } + if let Some(unknown) = ids.iter().find(|id| id.as_str().and_then(SidebarSection::from_id).is_none()) { + return err(format!("ui.set: `collapsedSidebar`: unknown section {unknown} ({})", SidebarSection::ids())); + } + for section in SidebarSection::ALL { u.sidebar.set_collapsed(section, ids.iter().any(|id| id.as_str() == Some(section.id()))); } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 0ae4afa78..1ef894b32 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -102,7 +102,9 @@ fn row_sensed( ); let font = t.font(13.5); let color = if selected { t.text } else { t.text_label }; - let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), t.text_dim)); + // a count of nothing is there, but fainter: the rows that hold photos stand out + let count_color = |n: usize| if n == 0 { t.text_dim.gamma_multiply(0.55) } else { t.text_dim }; + let count_galley = count.filter(|_| app.ui.show_counts).map(|n| ui.painter().layout_no_wrap(n.to_string(), t.font(12.5), count_color(n))); let label_left = r.left() + 42.0 + indent; let count_left = count_galley.as_ref().map_or(edge - 18.0, |g| edge - 18.0 - g.size().x); // a label's dot sits just before the count @@ -188,22 +190,62 @@ const SECTION_HEADER: f32 = 34.0; /// One sidebar section: the gap above it, its header, and (while it is open) the rows `body` /// draws. Every section goes through here, so they all fold, space and announce themselves alike. +/// `rows`: how many rows the section lists at its top level (a folded header says so). fn section( app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, + rows: usize, body: impl FnOnce(&mut LightcraftApp, &mut egui::Ui), ) -> PlacedSection { ui.add_space(SECTION_GAP); let (header, _) = ui.allocate_exact_size(vec2(ui.available_width(), SECTION_HEADER), Sense::hover()); - if section_header(app, ui, section, header) { + // a hairline in the gap above tells the sections apart + let t = Tokens::get(ui.ctx()); + let edge = visible_right(ui).min(header.right()); + let y = header.top() - SECTION_GAP / 2.0; + ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); + let show = egui::Id::new("left-show-section"); + if ui.data(|d| d.get_temp::(show)) == Some(section) { + ui.data_mut(|d| d.remove::(show)); + ui.scroll_to_rect_animation(header, Some(egui::Align::TOP), egui::style::ScrollAnimation::none()); + } + let open = section_header(app, ui, section, header, rows, false).open; + if open { let rows_from = ui.cursor().top(); body(app, ui); if ui.cursor().top() <= rows_from { empty_hint(ui, section); } } - PlacedSection { section, top: header.top(), bottom: ui.cursor().top() } + PlacedSection { section, top: header.top(), bottom: ui.cursor().top(), open, rows } +} + +/// Where what the panel shows of the sidebar's content ends (screen x): the content is wider +/// than the panel while the sidebar scrolls sideways. +fn visible_right(ui: &egui::Ui) -> f32 { + ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(f32::MAX) +} + +/// The header of the open section whose own header has scrolled out of sight stays at the top of +/// the sidebar (widget `sidebarSectionPinned:`), so it is always clear which section the rows +/// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The +/// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. +fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { + let Some(p) = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top) else { return }; + let top = view_top.min(p.bottom - SECTION_HEADER); + let r = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER); + let t = Tokens::get(ui.ctx()); + let view = ui.clip_rect(); + // over the rows scrolling beneath it + ui.painter().rect_filled(Rect::from_x_y_ranges(view.x_range(), r.y_range()), 0.0, t.chrome); + ui.painter().hline(view.x_range(), r.bottom(), egui::Stroke::new(1.0, t.divider)); + if section_header(app, ui, p.section, r, p.rows, true).toggled { + // folded from here: its own header comes into view, not whatever was below the section + // (next frame, when the section is laid out folded: see `section`) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), p.section)); + ui.ctx().request_repaint(); + } } /// What an open section with nothing to list says in place of its rows (widget @@ -224,6 +266,17 @@ pub(crate) struct PlacedSection { pub section: SidebarSection, pub top: f32, pub bottom: f32, + /// Not folded: its rows (or its note that there are none) are drawn. + pub open: bool, + /// How many rows it lists at its top level. + pub rows: usize, +} + +/// What a section's header says happened to it this frame. +struct HeaderOutcome { + open: bool, + /// A click folded or unfolded the section. + toggled: bool, } /// Where a section dragged to `y` would go among the sections drawn: above the first one whose @@ -238,6 +291,8 @@ fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSect let Some(dragged) = app.ui.dragging_section else { return }; let (released, pos, esc) = ui.input(|i| (i.pointer.primary_released(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); if esc { + // the drag's Esc, not also a dialog's or a view's + crate::widgets::take_escape(ui.ctx()); app.ui.dragging_section = None; return; } @@ -264,8 +319,7 @@ fn section_drag(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSect }; if let Some(y) = y { let t = Tokens::get(ui.ctx()); - let visible_right: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-visible-right"))).unwrap_or(view.right()); - let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right.min(view.right()) - 8.0, y + 1.0)); + let line = Rect::from_min_max(pos2(view.left() + 8.0, y - 1.0), pos2(visible_right(ui).min(view.right()) - 8.0, y + 1.0)); ui.painter().rect_filled(line, 1.0, t.accent); register(ui.ctx(), "sidebarSectionDrop", line); } @@ -293,17 +347,20 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ); } -/// A section's header in `r`: 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), and what the section adds -/// to its header (Albums: the + menu) sits at the visible edge. Returns whether the section is -/// open. -fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect) -> bool { +/// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their +/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// it survives restarts), and what the section adds to its header (Albums: the + menu) sits at +/// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo +/// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). +fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { let t = Tokens::get(ui.ctx()); let id = section.id(); let title = crate::i18n::tr(section.title()); - let resp = ui.interact(r, ui.id().with(("sidebar-section", id)), Sense::click_and_drag()); - register(ui.ctx(), format!("sidebarSection:{id}"), r); - if resp.clicked() { + let (interact, widget) = if pinned { ("sidebar-section-pinned", "sidebarSectionPinned") } else { ("sidebar-section", "sidebarSection") }; + let resp = ui.interact(r, ui.id().with((interact, id)), Sense::click_and_drag()); + register(ui.ctx(), format!("{widget}:{id}"), r); + let toggled = resp.clicked(); + if toggled { app.ui.sidebar.toggle_collapsed(section); } // dragged by its header, a section moves among the others (see `section_drag`) @@ -313,15 +370,30 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe 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); - let c = pos2(text.right() + 10.0, r.center().y); - let col = if resp.hovered() { t.text } else { t.text_dim }; + // what is visible of the header: its bar and count end at the panel's edge, like a row's + let edge = r.right().min(visible_right(ui)); + let hovered = resp.hovered() && app.ui.dragging_section.is_none(); + if hovered { + let bar = Rect::from_min_max(r.min + vec2(8.0, 2.0), pos2((edge - 8.0).max(r.left() + 8.0), r.bottom() - 2.0)); + ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); + } + let c = pos2(r.left() + 10.0, r.center().y); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + let col = if hovered { t.text } else { t.text_dim }; ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); - if section == SidebarSection::Albums { + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); + let has_actions = section == SidebarSection::Albums; + if !open && rows > 0 && app.ui.show_counts { + // folded: how much is in there (left of the header's own buttons) + let right = edge - 18.0 - if has_actions { 30.0 } else { 0.0 }; + let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); + register(ui.ctx(), format!("{widget}Count:{id}"), count); + } + if has_actions { top_level_drop_target(app, ui, r); albums_header_actions(app, ui, r); } - open + HeaderOutcome { open, toggled } } /// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, @@ -368,8 +440,7 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option = Vec::with_capacity(shown.len()); for s in shown { let at = match s { - SidebarSection::Albums => section(app, ui, s, |app, ui| albums_rows(app, ui, reveal)), + SidebarSection::Albums => { + let rows = app.session.catalog.albums().filter(|a| a.parent.is_none()).count(); + section(app, ui, s, rows, |app, ui| albums_rows(app, ui, reveal)) + } SidebarSection::Local => { let Some(local) = &local else { continue }; - section(app, ui, s, |app, ui| local_rows(app, ui, local)) + section(app, ui, s, local.places.places.len(), |app, ui| local_rows(app, ui, local)) + } + SidebarSection::ByDate => { + let rows = app.caches.date_groups(&app.session.catalog).len(); + section(app, ui, s, rows, date_rows) } - SidebarSection::ByDate => section(app, ui, s, date_rows), SidebarSection::Folders => { let tree = app.caches.folder_tree(&app.session.catalog); - section(app, ui, s, |app, ui| { + section(app, ui, s, tree.len(), |app, ui| { reveal_chosen(app, ui, &tree); folder_rows(app, ui, &tree, 0.0); }) } SidebarSection::Keywords => { let tree = app.caches.keyword_tree(&app.session.catalog); - section(app, ui, s, |app, ui| keyword_rows(app, ui, &tree, 0.0)) + section(app, ui, s, tree.len(), |app, ui| keyword_rows(app, ui, &tree, 0.0)) } }; placed.push(at); } // room below the last section (a section dragged to the end is dropped here) ui.add_space(SECTION_GAP * 2.0); + pinned_header(app, ui, &placed, view_top); section_drag(app, ui, &placed); // what the rows asked for becomes next frame's width let next = ui.data(|d| d.get_temp::(egui::Id::new("left-content-width-next"))).unwrap_or(0.0); diff --git a/crates/ui-egui/src/sidebar.rs b/crates/ui-egui/src/sidebar.rs index 4bc49fd61..37b539595 100644 --- a/crates/ui-egui/src/sidebar.rs +++ b/crates/ui-egui/src/sidebar.rs @@ -207,18 +207,25 @@ impl SidebarLayout { self.set_collapsed(section, false); } - /// Put `section` just above `before`, or last when `before` is `None`. Returns whether the - /// order changed (moving a section above itself, or to where it already is, changes nothing). + /// Put `section` just above `before`, or last when `before` is `None`. Returns whether it + /// moved. Moving a section above itself or to where it already is changes nothing, and neither + /// does a move of a shown section that would only take it past hidden ones: the sidebar would + /// look the same, so the arrangement stays the same too. pub fn move_before(&mut self, section: SidebarSection, before: Option) -> bool { if before == Some(section) { return false; } - let was = self.0.clone(); + let was = self.clone(); let moved = self.state(section); self.0.retain(|s| s.id != section); let at = before.and_then(|b| self.0.iter().position(|s| s.id == b)).unwrap_or(self.0.len()); self.0.insert(at, moved); - self.0 != was + let seen = !moved.hidden && section.available(); + if seen && self.shown().eq(was.shown()) { + *self = was; + return false; + } + *self != was } /// Move `section` one place up or down among the sections that are shown (hidden ones are @@ -364,6 +371,20 @@ mod tests { assert_eq!(order(&layout), [Keywords, Local, ByDate, Folders, Albums]); } + /// Given a hidden section between two shown ones, when the upper one is dropped right where + /// it already appears (above the lower one), then nothing changes: no move that can't be seen. + #[test] + fn a_move_that_cannot_be_seen_is_no_move() { + let mut layout = SidebarLayout::default(); + layout.set_hidden(Local, true); + let was = layout.clone(); + assert!(!layout.move_before(Albums, Some(ByDate)), "Albums is already right above By Date, as far as anyone can see"); + assert_eq!(layout, was); + // a hidden section can still be put elsewhere (it shows there when it is shown again) + assert!(layout.move_before(Local, None)); + assert_eq!(order(&layout), [Albums, ByDate, Folders, Keywords, Local]); + } + /// Moving a section keeps whether it is hidden or folded. #[test] fn a_moved_section_stays_folded() { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index a7cc59f00..ddb09401c 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -626,6 +626,14 @@ fn an_arrangement_set_by_an_agent_is_checked() { let r = h.request("ui.set", json!({"collapsedSidebar": []}), T); assert_eq!(r["ok"], true, "{r}"); assert!(!h.app.ui.sidebar_section_collapsed("byDate")); + // a name it doesn't know, or both ways of saying it at once, is refused and changes nothing + exec(&mut h, "view.sidebarSection", json!({"section": "albums", "collapsed": true})); + let arranged = h.app.ui.sidebar.clone(); + for bad in [json!({"collapsedSidebar": ["albms"]}), json!({"collapsedSidebar": [], "sidebar": [{"id": "albums", "collapsed": true}]})] { + let r = h.request("ui.set", bad.clone(), T); + assert_eq!(r["ok"], false, "{bad}: {r}"); + assert_eq!(h.app.ui.sidebar, arranged, "{bad}"); + } } /// Where a dragged section goes: above the first section whose middle is below the pointer. @@ -634,9 +642,9 @@ fn a_dragged_section_goes_above_the_section_it_is_over_the_top_half_of() { use crate::panels::left::{PlacedSection, section_drop_before}; use crate::sidebar::SidebarSection::{Albums, ByDate, Keywords}; let placed = [ - PlacedSection { section: Albums, top: 100.0, bottom: 300.0 }, - PlacedSection { section: ByDate, top: 310.0, bottom: 344.0 }, - PlacedSection { section: Keywords, top: 354.0, bottom: 500.0 }, + PlacedSection { section: Albums, top: 100.0, bottom: 300.0, open: true, rows: 1 }, + PlacedSection { section: ByDate, top: 310.0, bottom: 344.0, open: true, rows: 1 }, + PlacedSection { section: Keywords, top: 354.0, bottom: 500.0, open: true, rows: 1 }, ]; for (y, before) in [ (0.0, Some(Albums)), @@ -666,6 +674,80 @@ fn a_year_opens_into_its_months() { assert_eq!(rows(&h), years, "closed again"); } +/// Given a section longer than the sidebar, when its header scrolls out of sight, then a copy of +/// the header stays at the top of the sidebar for as long as the section's rows are there; a +/// click on it folds the section and brings the section's own header into view. +#[test] +fn the_header_of_a_long_section_stays_in_sight() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + for i in 0..40 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + h.step(); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("sidebarSectionPinned:")), "nothing is pinned while every header is in sight"); + let own = header(&h, "albums"); + let c = egui::pos2(own.center().x, 400.0); + let scroll = |h: &mut Headless, dy: f32| { + // the wheel scrolls what the pointer is over + h.request("ui.move", json!({"x": c.x, "y": c.y}), T); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..60 { + h.step(); + } + }; + scroll(&mut h, -500.0); + assert!(header(&h, "albums").top() < own.top() - 300.0, "the sidebar scrolled"); + let pinned = widget(&h, "sidebarSectionPinned:albums"); + let first_row = h + .app + .widgets + .iter() + .filter(|(w, r)| w.starts_with("source:album:") && r.bottom() > pinned.bottom()) + .map(|(_, r)| r.top()) + .fold(f32::MAX, f32::min); + assert!( + pinned.top() > header(&h, "albums").bottom() && first_row < pinned.bottom() + 29.0, + "at the top of the rows in view: {pinned:?} {first_row}" + ); + assert!(has(&h, "icon:albumNew"), "with its buttons"); + // folded from the pinned header: Albums' own header is what the sidebar shows now + click(&mut h, "sidebarSectionPinned:albums"); + h.settle(SETTLE); + assert!(h.app.ui.sidebar_section_collapsed("albums")); + assert!(!has(&h, "sidebarSectionPinned:albums"), "a folded section has no rows to pin its header over"); + let panel = widget(&h, "panel:left_panel"); + let own = header(&h, "albums"); + assert!(own.top() >= panel.top() && own.bottom() <= panel.bottom(), "its own header is in view: {own:?}"); +} + +/// A folded section says how many rows it holds (unless photo counts are off); an open one +/// doesn't need to. +#[test] +fn a_folded_section_says_how_much_it_holds() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + click(&mut h, "sidebarSection:byDate"); + assert!(has(&h, "sidebarSectionCount:byDate")); + exec(&mut h, "view.photoCounts", json!({"show": false})); + assert!(!has(&h, "sidebarSectionCount:byDate")); + // an empty section has nothing to count + exec(&mut h, "view.photoCounts", json!({"show": true})); + click(&mut h, "sidebarSection:folders"); + assert!(h.app.ui.sidebar_section_collapsed("folders") && !has(&h, "sidebarSectionCount:folders")); +} + +/// A section's chevron is in the same column as the triangles of the rows below it. +#[test] +fn section_chevrons_line_up_with_row_triangles() { + let h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + let triangle = h.app.widgets.iter().find(|(w, _)| w.starts_with("dateToggle:")).map(|(_, r)| *r).expect("a year has a triangle"); + for id in sections_drawn(&h) { + assert_eq!(widget(&h, &format!("sidebarSectionChevron:{id}")).center().x, triangle.center().x, "{id}"); + } +} + /// 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 9394a27c7..aca0c0561 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) | 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) | 21 | 6 | 1 | 1 | 8/8 (100%) | 6/9 (67%) | +| X. Cross-cutting behaviours (BEHAV) | 22 | 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 | 33 | 15 | 35 | 9 | — | 23/25 (92%) | -| **Total** | 401 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | +| **Total** | 402 | 49 | 73 | 36 | 192/200 (96%) | 143/155 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 523 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.5% of 167. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.4%** of 524 in-scope rows — P0 98.0% of 200 · P1 95.2% of 155 · P2 48.8% of 168. ## Top gaps @@ -529,6 +529,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) 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-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | +| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | 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` | | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..1ffe5a8c6 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## October 2026 +### Sidebar sections +- Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line + shows where it will go), or right-click a header for Move Up / Move Down. +- Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click + the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered. +- In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see + which section you are in and can fold it from there. +- Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows' + triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter, + and a section with nothing in it says so instead of disappearing. +- Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos. +- Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, From 9cd9e2961489d27b14b9ebe5ebd671b35a218028 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 12:51:55 -0400 Subject: [PATCH 5/7] LR-BEHAV-SIDEBAR-STICKY: rows beneath the pinned header are not drop targets An album or photos released on the pinned Albums header went to the row scrolled under it. Folding from the pinned header keeps the sideways scroll, its hand-off to the next frame can't go stale, and a section picked up by the pinned header doesn't start scrolling at once. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 60 ++++++++++++++++++++----- crates/ui-egui/src/tests_panels.rs | 71 +++++++++++++++++++++++++++++- docs/parity.md | 2 +- 3 files changed, 120 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 1ef894b32..a35e44289 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -206,9 +206,17 @@ fn section( let y = header.top() - SECTION_GAP / 2.0; ui.painter().hline(header.left() + 18.0..=(edge - 18.0).max(header.left() + 18.0), y, egui::Stroke::new(1.0, t.divider)); let show = egui::Id::new("left-show-section"); - if ui.data(|d| d.get_temp::(show)) == Some(section) { - ui.data_mut(|d| d.remove::(show)); - ui.scroll_to_rect_animation(header, Some(egui::Align::TOP), egui::style::ScrollAnimation::none()); + if let Some((asked, frame)) = ui.data(|d| d.get_temp::<(SidebarSection, u64)>(show)) + && asked == section + { + ui.data_mut(|d| d.remove::<(SidebarSection, u64)>(show)); + // asked for by the frame before (a request the section was not drawn for is forgotten), + // and up or down only: the sidebar stays where it was scrolled sideways + if ui.ctx().cumulative_frame_nr().saturating_sub(frame) <= 2 { + let view_top: f32 = ui.data(|d| d.get_temp(egui::Id::new("left-view-top"))).unwrap_or(header.top()); + let delta = vec2(0.0, view_top - header.top()); + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } } let open = section_header(app, ui, section, header, rows, false).open; if open { @@ -232,9 +240,14 @@ fn visible_right(ui: &egui::Ui) -> f32 { /// belong to, and the section can be folded, moved or hidden from anywhere in a long list. The /// next section's header pushes it out as it arrives. `view_top`: where the visible part starts. fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSection], view_top: f32) { - let Some(p) = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top) else { return }; - let top = view_top.min(p.bottom - SECTION_HEADER); - let r = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER); + let pinned = placed.iter().find(|p| p.open && p.top < view_top && p.bottom > view_top); + let rect = pinned.map(|p| { + let top = view_top.min(p.bottom - SECTION_HEADER); + Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + SECTION_HEADER) + }); + // rows ask where it is (next frame: they are laid out before it) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-pinned-rect"), rect)); + let (Some(p), Some(r)) = (pinned, rect) else { return }; let t = Tokens::get(ui.ctx()); let view = ui.clip_rect(); // over the rows scrolling beneath it @@ -243,7 +256,8 @@ fn pinned_header(app: &mut LightcraftApp, ui: &mut egui::Ui, placed: &[PlacedSec if section_header(app, ui, p.section, r, p.rows, true).toggled { // folded from here: its own header comes into view, not whatever was below the section // (next frame, when the section is laid out folded: see `section`) - ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), p.section)); + let frame = ui.ctx().cumulative_frame_nr(); + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-show-section"), (p.section, frame))); ui.ctx().request_repaint(); } } @@ -383,7 +397,9 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); let has_actions = section == SidebarSection::Albums; - if !open && rows > 0 && app.ui.show_counts { + // (an album being dragged puts "Top Level" there) + let room = !(has_actions && app.ui.dragging_album.is_some()); + if !open && rows > 0 && app.ui.show_counts && room { // folded: how much is in there (left of the header's own buttons) let right = edge - 18.0 - if has_actions { 30.0 } else { 0.0 }; let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); @@ -492,6 +508,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { drag_auto_scroll(app, ui); // where the visible part of the content starts (screen y), for the pinned header let view_top = ui.cursor().top() + viewport.min.y; + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-view-top"), view_top)); // rows are as wide as the widest one needs (last frame), at least the panel let wide = viewport.width().max(content_width(ui.ctx())); ui.set_min_width(wide); @@ -1084,6 +1101,22 @@ fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) } +/// [`pointer_over`] for a row: a row scrolled beneath the pinned header is covered by it, so the +/// pointer there is over the header, not over the row. +fn pointer_over_row(ui: &egui::Ui, rect: Rect) -> bool { + pointer_over(ui, rect) && !pointer_on_pinned_header(ui) +} + +/// Where the pinned header was drawn last frame, if one was (rows are laid out before it). +fn pinned_rect(ui: &egui::Ui) -> Option { + ui.data(|d| d.get_temp::>(egui::Id::new("left-pinned-rect"))).flatten() +} + +fn pointer_on_pinned_header(ui: &egui::Ui) -> bool { + let pos = ui.input(|i| i.pointer.latest_pos()); + pinned_rect(ui).zip(pos).is_some_and(|(r, p)| r.contains(p)) +} + /// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. const SCROLL_EDGE: f32 = 36.0; /// The fastest the sidebar scrolls for a drag (points per second). @@ -1106,6 +1139,11 @@ fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { } let view = ui.clip_rect(); let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + // a section picked up by its pinned header starts inside the top edge: it scrolls once it has + // left the header (albums and photos scroll there as at any edge, to reach the rows above) + if app.ui.dragging_section.is_some() && pointer_on_pinned_header(ui) { + return; + } let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); if speed != 0.0 { // a positive delta moves the content down: the view goes up @@ -1218,7 +1256,7 @@ fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f3 fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { let hover_id = egui::Id::new(("album-hover", row.id.0)); let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over(ui, resp.rect)); + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over_row(ui, resp.rect)); let Some(pos) = pos else { if ui.data(|d| d.get_temp::(hover_id)).is_some() { ui.data_mut(|d| d.remove_temp::(hover_id)); @@ -1316,8 +1354,8 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { /// release there adds them. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { let Some(ids) = app.ui.dragging_photos.clone() else { return }; - let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)); - if !over { + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { return; } let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index ddb09401c..362725a65 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -711,7 +711,7 @@ fn the_header_of_a_long_section_stays_in_sight() { pinned.top() > header(&h, "albums").bottom() && first_row < pinned.bottom() + 29.0, "at the top of the rows in view: {pinned:?} {first_row}" ); - assert!(has(&h, "icon:albumNew"), "with its buttons"); + assert!(h.app.widgets.iter().any(|(w, r)| w == "icon:albumNew" && pinned.contains_rect(*r)), "with its buttons"); // folded from the pinned header: Albums' own header is what the sidebar shows now click(&mut h, "sidebarSectionPinned:albums"); h.settle(SETTLE); @@ -722,6 +722,75 @@ fn the_header_of_a_long_section_stays_in_sight() { assert!(own.top() >= panel.top() && own.bottom() <= panel.bottom(), "its own header is in view: {own:?}"); } +/// Scroll the sidebar (the pointer over it) by `dy`. +fn scroll_sidebar(h: &mut Headless, dy: f32) { + h.request("ui.move", json!({"x": 120.0, "y": 400.0}), T); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..60 { + h.step(); + } +} + +/// Given a pinned Albums header with album folders scrolled beneath it, when an album is dragged +/// onto the pinned header and released, then it goes to the top level — what the header shows — +/// and not into the folder hidden under the header. +#[test] +fn a_drop_on_the_pinned_header_is_not_a_drop_on_the_row_beneath_it() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + for i in 0..30 { + make(&mut h, json!({"name": format!("Folder {i:02}"), "folder": true})); + } + let last = make(&mut h, json!({"name": "Zz last", "folder": true})); + let best = make(&mut h, json!({"name": "Best", "parent": last})); + h.step(); + h.step(); + let parent = |h: &Headless| h.app.session.catalog.album(lightcraft_catalog::AlbumId(best)).unwrap().parent.map(|p| p.0); + let row = |h: &Headless| widget(h, &format!("source:album:{best}")); + // far enough that Best is in view and Albums' own header is not + for _ in 0..8 { + if row(&h).bottom() < 600.0 { + break; + } + scroll_sidebar(&mut h, -200.0); + } + let pinned = widget(&h, "sidebarSectionPinned:albums"); + assert!(row(&h).bottom() < 600.0 && row(&h).top() > pinned.bottom(), "Best is in view below the pinned header: {:?}", row(&h)); + let beneath = h.app.widgets.iter().filter(|(w, r)| w.starts_with("source:folder:") && r.contains(pinned.center())).count(); + assert_eq!(beneath, 1, "a folder row lies under the middle of the pinned header"); + let from = row(&h).center(); + drag_between(&mut h, from, pinned.center(), Vec::new()); + assert_eq!(parent(&h), None, "to the top level, as the header said"); +} + +/// The pinned header belongs to the section whose rows are at the top of the sidebar: scrolling on +/// into the next section pushes it out and pins that section's header instead. +#[test] +fn the_next_section_takes_over_the_pinned_header() { + let mut h = demo([1400.0, 500.0], json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["albums", "keywords"]})); + exec(&mut h, "view.sidebarSection", json!({"section": "local", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "byDate", "show": false})); + exec(&mut h, "view.sidebarSection", json!({"section": "folders", "show": false})); + for i in 0..12 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + let pinned = |h: &Headless| -> Vec { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("sidebarSectionPinned:").map(str::to_string)).collect() + }; + let mut seen = Vec::new(); + for _ in 0..30 { + scroll_sidebar(&mut h, -60.0); + let now = pinned(&h); + assert!(now.len() <= 1, "one header at a time: {now:?}"); + if let Some(id) = now.first().filter(|id| seen.last() != Some(*id)) { + seen.push(id.clone()); + } + } + assert_eq!(seen, ["albums", "keywords"]); +} + /// A folded section says how many rows it holds (unless photo counts are off); an open one /// doesn't need to. #[test] diff --git a/docs/parity.md b/docs/parity.md index aca0c0561..48ec8761c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -529,7 +529,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) 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-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | -| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | +| LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | 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` | | From d3318297702d473b4c7fd422c6ed79dcefd635a5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 13:04:02 -0400 Subject: [PATCH 6/7] LR-BEHAV-SIDEBAR-SECTIONS: a section declares its header button; Keywords gets + (Create Keyword Tag) Albums' + is no longer a special case of the header: header_button says which section has a button and whether it acts or opens a menu, and the frame draws it in the header and its pinned copy. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keyword_list.rs | 10 +- crates/ui-egui/src/panels/left.rs | 140 +++++++++++++++------- crates/ui-egui/src/tests_panels.rs | 37 ++++++ docs/parity.md | 2 +- docs/whats-new.md | 1 + 5 files changed, 145 insertions(+), 45 deletions(-) diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index f201359db..2e680f2b7 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -364,8 +364,14 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho /// Create Keyword Tag: inside the keyword picked in the list, else the default parent. pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { - let parent = - app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.catalog.default_keyword_parent()); + let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)); + create_dialog_inside(app, picked) +} + +/// Create Keyword Tag for a keyword inside `parent`, else inside the default parent (Put New +/// Keywords Inside This Keyword), else at the top level. +pub(crate) fn create_dialog_inside(app: &LightcraftApp, parent: Option) -> Dialog { + let parent = parent.or_else(|| app.session.catalog.default_keyword_parent()); let d = KeywordInfo::default(); Dialog::KeywordTag { editing: None, diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index a35e44289..eb364a450 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -396,22 +396,88 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe let col = if hovered { t.text } else { t.text_dim }; ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); - let has_actions = section == SidebarSection::Albums; - // (an album being dragged puts "Top Level" there) - let room = !(has_actions && app.ui.dragging_album.is_some()); + let button = header_button(section); + // (an album being dragged puts "Top Level" in the Albums header) + let room = !(section == SidebarSection::Albums && app.ui.dragging_album.is_some()); if !open && rows > 0 && app.ui.show_counts && room { - // folded: how much is in there (left of the header's own buttons) - let right = edge - 18.0 - if has_actions { 30.0 } else { 0.0 }; + // folded: how much is in there (left of the header's own button) + let right = edge - 18.0 - if button.is_some() { HEADER_BUTTON + 4.0 } else { 0.0 }; let count = ui.painter().text(pos2(right, r.center().y), Align2::RIGHT_CENTER, rows.to_string(), t.font(12.5), t.text_dim); register(ui.ctx(), format!("{widget}Count:{id}"), count); } - if has_actions { + if section == SidebarSection::Albums { top_level_drop_target(app, ui, r); - albums_header_actions(app, ui, r); + } + if let Some(button) = button { + show_header_button(app, ui, r, &button); } HeaderOutcome { open, toggled } } +/// Side of a header's button. +const HEADER_BUTTON: f32 = 26.0; + +/// A button at the end of a section's header (see [`header_button`]). +struct HeaderButton { + /// Its widget is `icon:`. + id: &'static str, + icon: Icon, + /// What it does, in English: its tooltip and spoken name. + tooltip: &'static str, + action: HeaderAction, +} + +/// What a header's button does when clicked. +enum HeaderAction { + /// Does it at once (usually: opens a dialog). + Run(fn(&mut LightcraftApp, &egui::Context)), + /// Opens a menu with these items. + Menu(fn(&mut LightcraftApp, &mut egui::Ui)), +} + +/// The button a section has at the end of its header, if it has one. This is the one place that +/// says so: the header (its own and the pinned copy) draws the button at the visible edge, keeps +/// its clicks from folding the section and leaves it room beside a folded section's count. +fn header_button(section: SidebarSection) -> Option { + match section { + SidebarSection::Albums => { + Some(HeaderButton { id: "albumNew", icon: Icon::Plus, tooltip: "Create Album", action: HeaderAction::Menu(new_album_menu) }) + } + SidebarSection::Keywords => { + Some(HeaderButton { id: "keywordNew", icon: Icon::Plus, tooltip: "Create Keyword Tag", action: HeaderAction::Run(new_keyword) }) + } + SidebarSection::Local | SidebarSection::ByDate | SidebarSection::Folders => None, + } +} + +/// Draw `button` at the end of `header` and act on it. +fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { + // it stays at the visible edge when the sidebar is scrolled sideways + let right = visible_right(ui).min(header.right()); + let mut hdr = ui.new_child( + egui::UiBuilder::new() + .max_rect(Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom()))) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); + match button.action { + HeaderAction::Run(run) => { + if resp.clicked() { + run(app, ui.ctx()); + } + } + HeaderAction::Menu(items) => { + egui::Popup::menu(&resp).show(|ui| items(app, ui)); + } + } +} + +/// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside +/// whatever is picked in the Keyword List, which may not even be on screen). +fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { + app.ui.dialog = Some(crate::panels::keyword_list::create_dialog_inside(app, None)); +} + /// 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`. @@ -453,42 +519,32 @@ fn sections_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, on: Option`) 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | +| 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) 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-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | diff --git a/docs/whats-new.md b/docs/whats-new.md index 1ffe5a8c6..a7cce10e5 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -13,6 +13,7 @@ triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter, and a section with nothing in it says so instead of disappearing. - Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos. +- Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List. - Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`. ### Activity stack From 02a21b860d430cc91718c28ce31f988a8d41e836 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 10 Oct 2026 13:14:07 -0400 Subject: [PATCH 7/7] LR-BEHAV-SIDEBAR-SECTIONS: review fixes for the header button and the pinned header A keyword made from Keywords' + is shown (the section and its parents open); a press on a header button never starts a section drag; a section dragged to the top edge scrolls past a pinned header again (only a drag picked up by the pinned header waits until it has left it). Tests that could not fail are replaced, and the parity rows say what the code does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 8 +- crates/ui-egui/src/panels/keyword_list.rs | 2 + crates/ui-egui/src/panels/left.rs | 56 ++++++-- crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_panels.rs | 153 +++++++++++++++++++++- docs/parity.md | 6 +- 6 files changed, 208 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c952..9c8d9a5d0 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -764,6 +764,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { export_synonyms, person, add_to_selected, + .. } => { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); // several fields: the name takes the focus when the dialog opens, not after @@ -1260,6 +1261,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { let mut params = json!({ "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), @@ -1286,7 +1288,11 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result) export_synonyms: d.export_synonyms, person: d.person, add_to_selected: false, + reveal: false, } } @@ -402,6 +403,7 @@ pub(crate) fn edit_dialog(app: &LightcraftApp, path: &str) -> Dialog { export_synonyms: info.export_synonyms, person: info.person, add_to_selected: false, + reveal: false, } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index eb364a450..82b7a978d 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -363,7 +363,7 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { /// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their /// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so -/// it survives restarts), and what the section adds to its header (Albums: the + menu) sits at +/// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at /// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo /// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSection, r: Rect, rows: usize, pinned: bool) -> HeaderOutcome { @@ -377,8 +377,12 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe if toggled { app.ui.sidebar.toggle_collapsed(section); } - // dragged by its header, a section moves among the others (see `section_drag`) - if resp.drag_started_by(egui::PointerButton::Primary) { + // dragged by its header, a section moves among the others (see `section_drag`); a press on + // the header's button is the button's, also when it turns into a drag + let on_button = header_button(section).is_some() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| header_button_rect(ui, r).contains(p)); + if resp.drag_started_by(egui::PointerButton::Primary) && !on_button { + // picked up by the pinned header, the drag starts inside the top edge (see `drag_auto_scroll`) + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-section-drag-from-pinned"), pinned)); app.ui.dragging_section = Some(section); } resp.context_menu(|ui| sections_menu(app, ui, Some(section))); @@ -450,15 +454,17 @@ fn header_button(section: SidebarSection) -> Option { } } +/// The end of `header` kept for its button: at the visible edge, also when the sidebar is +/// scrolled sideways. +fn header_button_rect(ui: &egui::Ui, header: Rect) -> Rect { + let right = visible_right(ui).min(header.right()); + Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom())) +} + /// Draw `button` at the end of `header` and act on it. fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, button: &HeaderButton) { - // it stays at the visible edge when the sidebar is scrolled sideways - let right = visible_right(ui).min(header.right()); - let mut hdr = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_max(pos2(right - 50.0, header.top()), pos2(right, header.bottom()))) - .layout(egui::Layout::right_to_left(egui::Align::Center)), - ); + let mut hdr = + ui.new_child(egui::UiBuilder::new().max_rect(header_button_rect(ui, header)).layout(egui::Layout::right_to_left(egui::Align::Center))); let resp = icon_button(&mut hdr, button.id, button.icon, vec2(HEADER_BUTTON, HEADER_BUTTON), false, true, button.tooltip); match button.action { HeaderAction::Run(run) => { @@ -474,8 +480,13 @@ fn show_header_button(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect, /// Keywords' +: Create Keyword Tag, for a keyword at the usual place for new ones (not inside /// whatever is picked in the Keyword List, which may not even be on screen). +/// Made from here, the keyword is shown here: Keywords opens down to it (`reveal_keyword`). fn new_keyword(app: &mut LightcraftApp, _ctx: &egui::Context) { - app.ui.dialog = Some(crate::panels::keyword_list::create_dialog_inside(app, None)); + let mut dialog = crate::panels::keyword_list::create_dialog_inside(app, None); + if let crate::state::Dialog::KeywordTag { reveal, .. } = &mut dialog { + *reveal = true; + } + app.ui.dialog = Some(dialog); } /// The menu of a section's header (`on`) and of the My Photos title (`None`): hide this section, @@ -600,6 +611,19 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // …and Albums itself is shown and opened, so the new album can be seen app.ui.sidebar.reveal(SidebarSection::Albums); } + // the keyword just made from Keywords' header: the section and the keywords above it open + if let Some(path) = app.ui.reveal_keyword.take() { + app.ui.sidebar.reveal(SidebarSection::Keywords); + let mut above = String::new(); + let mut names = path.split('|').peekable(); + while let Some(name) = names.next().filter(|_| names.peek().is_some()) { + if !above.is_empty() { + above.push('|'); + } + above.push_str(name); + set_tree_open(ui, egui::Id::new(("kw-open", above.to_lowercase())), true); + } + } // 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(); @@ -1196,9 +1220,13 @@ fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { let view = ui.clip_rect(); let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; // a section picked up by its pinned header starts inside the top edge: it scrolls once it has - // left the header (albums and photos scroll there as at any edge, to reach the rows above) - if app.ui.dragging_section.is_some() && pointer_on_pinned_header(ui) { - return; + // left the header. Any other drag scrolls there as at any edge, to reach what is above + let from_pinned = egui::Id::new("left-section-drag-from-pinned"); + if app.ui.dragging_section.is_some() && ui.data(|d| d.get_temp::(from_pinned)) == Some(true) { + if pointer_on_pinned_header(ui) { + return; + } + ui.data_mut(|d| d.insert_temp(from_pinned, false)); } let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); if speed != 0.0 { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 01778290d..df9c0e496 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -418,6 +418,9 @@ pub struct UiState { /// An album just made: the Albums tree opens the folders down to it, once. #[serde(skip)] pub reveal_album: Option, + /// A keyword just made from the sidebar: Keywords opens down to it, once. + #[serde(skip)] + pub reveal_keyword: Option, /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). #[serde(skip)] pub dragging_album: Option, @@ -610,6 +613,9 @@ pub enum Dialog { person: bool, /// A new keyword is given to the selected photos. add_to_selected: bool, + /// Opened from the sidebar's Keywords header: the new keyword is shown there once made. + #[serde(default)] + reveal: bool, }, /// Edit Keyword Set: a set's name and its nine slots (an empty one is an empty slot). `replaces`: /// the set being edited (renamed when the name changes); `None` saves a new set (from Recent @@ -848,6 +854,7 @@ impl Default for UiState { quit: false, dragging_photos: None, reveal_album: None, + reveal_keyword: None, dragging_album: None, dragging_section: None, curve_channel: "parametric".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 2aa08f5ba..c35b0fa89 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -555,6 +555,13 @@ fn dragging_a_section_is_not_dragging_an_album() { h.step(); h.step(); assert_eq!(h.app.session.catalog.revision, before, "no album moved"); + assert!(!h.app.ui.sidebar.is_default(), "the section did"); + // …and an album dragged onto another section's header moves no section + let album = h.app.session.catalog.albums().find(|a| !a.folder).expect("the demo library has albums").id.0; + let arranged = h.app.ui.sidebar.clone(); + let (from, to) = (widget(&h, &format!("source:album:{album}")).center(), header(&h, "keywords").center()); + drag_between(&mut h, from, to, Vec::new()); + assert_eq!((h.app.ui.sidebar.clone(), h.app.ui.dragging_section), (arranged, None)); } /// Given a section's header menu, Move Up and Move Down move it one shown section at a time @@ -717,9 +724,8 @@ fn the_header_of_a_long_section_stays_in_sight() { h.settle(SETTLE); assert!(h.app.ui.sidebar_section_collapsed("albums")); assert!(!has(&h, "sidebarSectionPinned:albums"), "a folded section has no rows to pin its header over"); - let panel = widget(&h, "panel:left_panel"); let own = header(&h, "albums"); - assert!(own.top() >= panel.top() && own.bottom() <= panel.bottom(), "its own header is in view: {own:?}"); + assert!((own.top() - pinned.top()).abs() < 1.0, "its own header is where the pinned one was, at the top of the list: {own:?} {pinned:?}"); } /// Scroll the sidebar (the pointer over it) by `dy`. @@ -764,6 +770,78 @@ fn a_drop_on_the_pinned_header_is_not_a_drop_on_the_row_beneath_it() { assert_eq!(parent(&h), None, "to the top level, as the header said"); } +/// Given a long section scrolled so that its header is pinned, when another section is dragged up +/// to the top edge, then the sidebar scrolls up to reach the sections above, although the pointer +/// is over the pinned header there. A section picked up by the pinned header itself does not +/// start scrolling until the pointer has left it. +#[test] +fn a_section_dragged_to_the_top_edge_scrolls_past_the_pinned_header() { + let mut h = demo([1400.0, 700.0], json!({"view": "photoGrid", "leftPanel": true})); + for i in 0..40 { + h.app.session.execute("album.create", &json!({"name": format!("Album {i:02}")})).unwrap(); + } + h.step(); + let press = |h: &mut Headless, at: egui::Pos2| { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + }; + let hold = |h: &mut Headless, at: egui::Pos2, frames: usize| { + for _ in 0..frames { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.step(); + } + }; + let give_up = |h: &mut Headless| { + h.app.synthetic.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.app.synthetic.push(egui::Event::PointerButton { + pos: egui::pos2(800.0, 400.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + }; + // Local's header is in view below the albums, Albums' is pinned + for _ in 0..12 { + if has(&h, "sidebarSectionPinned:albums") && header(&h, "byDate").top() < 600.0 { + break; + } + scroll_sidebar(&mut h, -200.0); + } + let pinned = widget(&h, "sidebarSectionPinned:albums"); + let own = |h: &Headless| header(h, "albums").top(); + // picked up by the pinned header: held inside it, nothing scrolls + let start = own(&h); + press(&mut h, pinned.center()); + hold(&mut h, pinned.center() + egui::vec2(12.0, 3.0), 12); + assert_eq!(h.app.ui.dragging_section, Some(crate::sidebar::SidebarSection::Albums)); + assert_eq!(own(&h), start, "still on the header it was picked up by"); + give_up(&mut h); + // By Date, picked up by its own header and taken to the top edge: the sidebar scrolls up + let start = own(&h); + let from = header(&h, "byDate").center(); + press(&mut h, from); + hold(&mut h, from - egui::vec2(0.0, 60.0), 3); + hold(&mut h, pinned.center(), 30); + assert!(own(&h) > start + 20.0, "scrolled towards the sections above: {start} -> {}", own(&h)); + give_up(&mut h); + assert!(h.app.ui.sidebar.is_default()); +} + /// The pinned header belongs to the section whose rows are at the top of the sidebar: scrolling on /// into the next section pushes it out and pins that section's header instead. #[test] @@ -813,8 +891,8 @@ fn the_keywords_header_creates_a_keyword() { assert!(has(&h, "source:keyword:zebra crossing"), "the new keyword is listed"); } -/// A section's header button is part of every form of the header: folded (its row count sits to -/// the left of the button) and pinned. +/// A folded section's row count sits to the left of its header button; a section without a button +/// has its count at the edge. #[test] fn a_header_button_stays_with_a_folded_header() { let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); @@ -824,8 +902,71 @@ fn a_header_button_stays_with_a_folded_header() { let (count, plus) = (widget(&h, &format!("sidebarSectionCount:{section}")), widget(&h, button)); assert!(count.right() <= plus.left(), "{section}: the count {count:?} is left of the button {plus:?}"); } - // sections without a button have none, and their count goes to the edge - assert!(!h.app.widgets.iter().any(|(w, _)| w == "icon:byDateNew" || w == "icon:foldersNew")); + // By Date has no button: its count goes where the others have theirs + click(&mut h, "sidebarSection:byDate"); + let (plain, beside_button) = (widget(&h, "sidebarSectionCount:byDate"), widget(&h, "sidebarSectionCount:keywords")); + assert!(plain.right() >= widget(&h, "icon:keywordNew").left() && plain.right() > beside_button.right() + 20.0, "{plain:?} {beside_button:?}"); +} + +/// Given Keywords is folded and new keywords go inside a parent, when a keyword is made with the +/// header's +, then Keywords opens, and so does the parent, so the new keyword is there to see. +/// A keyword made elsewhere (the Keyword List) leaves the sidebar as it is. +#[test] +fn a_keyword_made_from_the_header_is_shown() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + exec(&mut h, "keyword.create", json!({"name": "places", "parent": null})); + exec(&mut h, "keyword.setDefaultParent", json!({"keyword": "places"})); + click(&mut h, "sidebarSection:keywords"); + assert!(h.app.ui.sidebar_section_collapsed("keywords")); + let confirm = |h: &mut Headless, name: &str| { + let Some(crate::state::Dialog::KeywordTag { name: n, .. }) = h.app.ui.dialog.as_mut() else { panic!("{:?}", h.app.ui.dialog) }; + *n = name.into(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + // from the Keyword List: nothing is revealed + h.app.ui.dialog = Some(crate::panels::keyword_list::create_dialog(&h.app)); + confirm(&mut h, "harbour"); + assert!(h.app.ui.sidebar_section_collapsed("keywords") && !has(&h, "source:keyword:places|harbour")); + // from the header's + + click(&mut h, "icon:keywordNew"); + confirm(&mut h, "lighthouse"); + assert!(!h.app.ui.sidebar_section_collapsed("keywords"), "Keywords opened"); + assert!(has(&h, "source:keyword:places|lighthouse"), "and its parent, down to the new keyword"); +} + +/// A press on a header's button that turns into a drag is not a drag of the section. +#[test] +fn dragging_from_a_header_button_does_not_drag_the_section() { + let mut h = demo([1400.0, 1400.0], json!({"view": "photoGrid", "leftPanel": true})); + for button in ["icon:keywordNew", "icon:albumNew"] { + let from = widget(&h, button).center(); + h.app.synthetic.push(egui::Event::PointerMoved(from)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: from, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + for i in 1..=6 { + h.app.synthetic.push(egui::Event::PointerMoved(from - egui::vec2(8.0 * i as f32, 30.0 * i as f32))); + h.step(); + assert_eq!(h.app.ui.dragging_section, None, "{button}"); + } + let to = from - egui::vec2(48.0, 180.0); + h.app.synthetic.push(egui::Event::PointerButton { + pos: to, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + } + assert!(h.app.ui.sidebar.is_default()); } /// A folded section says how many rows it holds (unless photo counts are off); an open one diff --git a/docs/parity.md b/docs/parity.md index 4e18474f2..7765e3f0d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -525,10 +525,10 @@ 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. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | 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`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) is not a section: it stays on top | +| 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 before 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). the click itself is UI-only, like the By Date / Keywords disclosure triangles, and `cmd:view.sidebarSection` `{section, collapsed}` folds and unfolds for agents; 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. A section with nothing to list (Folders before anything is imported, Keywords before any is added) stays, open, with a one-line note in place of rows (`sidebarEmpty:`) instead of disappearing. `ui.set` reads `sidebar` strictly (`SidebarLayout::parse`): an unknown or repeated section, or a flag that is not true / false, is refused and changes nothing; sections left out follow in the usual order, shown and open; the older `collapsedSidebar` list still folds and unfolds. A section may have one button at the end of its header, declared in one place (`header_button`: an icon, a tooltip, and either an action or a menu): the frame draws it at the visible edge in the header and its pinned copy, keeps its click from folding the section and leaves it room beside a folded section's count. Albums has + (`icon:albumNew`: Create Album / Smart Album / Folder…), Keywords has + (`icon:keywordNew`: Create Keyword Tag, for a keyword inside the default parent, not inside what the Keyword List has picked; once made, Keywords is shown and opened down to it). My Photos (All Photos, Recently Added, Picks, Missing Photos, Recently Deleted) 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-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | +| LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before:
\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | 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`) | |