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` | |