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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-10 12:26:29 -04:00
co-authored by Claude Opus 5.5
parent b70ae1741e
commit 338b67ffbe
6 changed files with 655 additions and 182 deletions
+1
View File
@@ -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;
+212 -171
View File
@@ -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<egui::Pos2> {
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<SidebarSection> = 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<u64>) {
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<String>,
}
/// 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<LocalNow> {
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::<Option<String>>(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() {
+372
View File
@@ -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:<id>`) 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<SidebarSection> {
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<SectionState>")]
pub struct SidebarLayout(Vec<SectionState>);
impl Default for SidebarLayout {
fn default() -> Self {
Self(SidebarSection::ALL.map(SectionState::new).to_vec())
}
}
impl From<SidebarLayout> for Vec<SectionState> {
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<serde_json::Value> 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<Item = SectionState>) -> Self {
let mut all: Vec<SectionState> = 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<Item = SidebarSection> + '_ {
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<SidebarSection>) -> 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<SidebarSection> = 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<SidebarSection> = 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<SidebarSection> {
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::<Vec<_>>(), [Albums, Local, Folders, Keywords]);
assert!(!layout.is_default());
layout.set_hidden(ByDate, false);
assert_eq!(layout.shown().collect::<Vec<_>>(), [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::<Vec<_>>(), [Albums, Folders, Local, Keywords]);
assert!(layout.move_by(Local, false));
assert_eq!(layout.shown().collect::<Vec<_>>(), [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::<Vec<_>>(), [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");
}
}
+9 -10
View File
@@ -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<String>,
/// 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) {
+59
View File
@@ -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::<crate::UiState>(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::<crate::UiState>(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<String> {
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::<Vec<_>>();
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<egui::Rect> = 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:<id>`), plain albums have none, and folding a folder hides what is inside it.
#[test]
+2 -1
View File
@@ -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:<albums\|local\|byDate\|keywords>`) 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:<albums\|local\|byDate\|folders\|keywords>`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`sidebar` in the UI state; the `collapsedSidebar` list of older settings files is still read). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target |
| LR-BEHAV-SIDEBAR-SECTIONS | Left-sidebar sections share one frame and one arrangement | P2 | ✅ | `crates/ui-egui/src/sidebar.rs` (`SidebarSection`, `SidebarLayout`), `crates/ui-egui/src/panels/left.rs` (`section`, `section_header`, `tree_toggle`), `crates/ui-egui/src/tests_panels.rs` | Albums, Local, By Date, Folders and Keywords are drawn by one `section` frame (the same gap above, header, fold and accessibility name) from one arrangement, `sidebar` in the UI state: a list of `{id, hidden, collapsed}` top to bottom, also settable with `ui.set`. Whatever is read, every section is in it exactly once (unknown and repeated entries are dropped, the rest follow in the usual order), so a damaged or newer settings file never loses the other settings. Rows that open (album folders, Local folders, dates, library folders, keywords) share `tree_toggle`: the same triangle, the same remembered open state, announced as Expand / Collapse. My Photos (All Photos, Recently Added, Picks, Missing Photos) is not a section: it stays on top |
| LR-BEHAV-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` | |