From 3e163f3f3d65b8a628010de21ae85320f7891834 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:51:31 +0200 Subject: [PATCH 1/2] LRC-LIB-LIBFOLDERS: the sidebar trees narrow their indent step before names are cut (#2298) Every level of Albums, Local, By Date, Folders and Keywords still steps one level right of its parent, but the step is chosen each frame from the panel's width and the deepest open row: 16 pt while that row keeps about 80 pt for its name and room for its count, else 14, 12, 10 or 8 pt. Past 8 pt the sidebar scrolls sideways as before. Callers keep passing indents in 16 pt steps; `indent_x` maps them to the drawn step for the icon, name, chevron, album drop line and the width a row asks for. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 75 +++++++++++++++++++++++---- crates/ui-egui/src/tests_panels.rs | 82 ++++++++++++++++++++++++------ docs/parity-checklist.md | 3 +- 3 files changed, 135 insertions(+), 25 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3bb8deba2..8fc70b98b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -36,6 +36,53 @@ fn note_width(ui: &egui::Ui, w: f32) { }); } +/// One level of a sidebar tree when the panel has room: callers pass `indent` in these steps. +pub(crate) const INDENT_STEP: f32 = 16.0; + +/// The narrowest a level is drawn: the chevron still steps visibly right of its parent's. +pub(crate) const MIN_INDENT_STEP: f32 = 8.0; + +/// What a row keeps for its name when the step is chosen: the deepest row's name is not cut to +/// less than this while a narrower step would still keep it. +const MIN_NAME_ROOM: f32 = 80.0; + +/// Room kept for a count at the end of the deepest row (three digits and their gap). +const COUNT_ROOM: f32 = 40.0; + +/// The step a level is drawn with this frame: [`INDENT_STEP`] while the deepest open row fits the +/// panel, narrower when it would not, never below [`MIN_INDENT_STEP`]. From the last frame. +pub(crate) fn indent_step(ctx: &egui::Context) -> f32 { + ctx.data(|d| d.get_temp::(egui::Id::new("left-indent-step"))).unwrap_or(INDENT_STEP) +} + +/// How far right a row at `indent` (in [`INDENT_STEP`]s) is drawn this frame. +fn indent_x(ctx: &egui::Context, indent: f32) -> f32 { + indent / INDENT_STEP * indent_step(ctx) +} + +/// [`indent_x`] for a row being laid out, which also notes how deep the tree goes, for next +/// frame's step. +fn drawn_indent(ui: &egui::Ui, indent: f32) -> f32 { + ui.data_mut(|d| { + let m = d.get_temp_mut_or_insert_with::(egui::Id::new("left-deepest-indent-next"), || 0.0); + *m = m.max(indent); + }); + indent_x(ui.ctx(), indent) +} + +/// The widest step (16, 14, 12, 10 or 8 pt) at which a row `deepest` deep (in [`INDENT_STEP`]s) +/// keeps [`MIN_NAME_ROOM`] for its name and its count in a panel `panel_w` wide. In steps, so a +/// row does not slide sideways at every point the panel is dragged; when even the narrowest step +/// does not fit, the sidebar scrolls sideways as before. +pub(crate) fn step_for(panel_w: f32, deepest: f32) -> f32 { + let levels = deepest / INDENT_STEP; + if levels < 1.0 { + return INDENT_STEP; + } + let room = panel_w - 42.0 - MIN_NAME_ROOM - 18.0 - COUNT_ROOM; + [16.0, 14.0, 12.0, 10.0].into_iter().find(|step| levels * step <= room).unwrap_or(MIN_INDENT_STEP) +} + fn row( app: &mut LightcraftApp, ui: &mut egui::Ui, @@ -82,6 +129,7 @@ fn row_sensed( ) -> egui::Response { let label = if matches!(id, "all" | "recentlyAdded" | "picks" | "missing" | "recentlyDeleted") { crate::i18n::tr(label) } else { label }; let t = Tokens::get(ui.ctx()); + let indent = drawn_indent(ui, indent); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_HEIGHT), sense); if scroll_asked(ui, id) { ui.scroll_to_rect_animation(r, None, egui::style::ScrollAnimation::none()); @@ -153,6 +201,7 @@ fn out_of_sight(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, label: &str, c } ui.allocate_space(r.size()); register(ui.ctx(), format!("source:{id}"), r); + let indent = drawn_indent(ui, indent); let t = Tokens::get(ui.ctx()); let painter = ui.painter(); let name_w = painter.layout_no_wrap(label.to_string(), t.font(13.5), t.text_label).size().x; @@ -178,7 +227,7 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option) -> f32 /// its response: the caller flips the open state on `clicked()`. fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { let t = Tokens::get(ui.ctx()); - let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y); + let c = pos2(row.rect.left() + 10.0 + indent_x(ui.ctx(), indent), row.rect.center().y); let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); @@ -626,6 +675,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.set_max_width(wide); ui.data_mut(|d| { d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32); + d.insert_temp(egui::Id::new("left-deepest-indent-next"), 0.0f32); // where the visible part of the content ends (screen x), for what stays at the edge d.insert_temp(egui::Id::new("left-visible-right"), ui.cursor().left() + viewport.max.x); }); @@ -713,6 +763,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next)); ui.ctx().request_repaint(); } + // …and the deepest row drawn (or passed over out of sight) sets next frame's step + let deepest = ui.data(|d| d.get_temp::(egui::Id::new("left-deepest-indent-next"))).unwrap_or(0.0); + let step = step_for(viewport.width(), deepest); + if step != indent_step(ui.ctx()) { + ui.data_mut(|d| d.insert_temp(egui::Id::new("left-indent-step"), step)); + ui.ctx().request_repaint(); + } }); }); } @@ -738,7 +795,7 @@ fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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) { + if date_row(app, ui, m, &label, *n, INDENT_STEP) { 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); @@ -1112,9 +1169,9 @@ fn folder_tree( synchronize_item(app, ui, &held.0, &held.1); } }); - if open && indent < 16.0 * 8.0 { + if open && indent < INDENT_STEP * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + INDENT_STEP, current, reveal, false); } } } @@ -1151,7 +1208,7 @@ type AlbumKids = std::collections::HashMap, Vec>; /// `open_to`: folders forced open this frame (the way down to an album that was just made). fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { // 64 folders deep at most (16 points a level): a catalog that loops must not overflow the stack - if indent > 64.0 * 16.0 { + if indent > 64.0 * INDENT_STEP { return; } let Some(kids) = all.get(&parent) else { return }; @@ -1188,7 +1245,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, pare } folder_menu(app, &resp, a); if draw_open { - albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to); + albums_tree(app, ui, all, Some(a.id), indent + INDENT_STEP, open_to); } } else { let sel = app.session.source == LibrarySource::Album(a.id); @@ -1440,7 +1497,7 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp } Some(AlbumDrop::Place { parent, before, above }) => { let y = if above { resp.rect.top() + 0.5 } else { resp.rect.bottom() - 0.5 }; - let (x0, x1) = (resp.rect.left() + 18.0 + indent, ui.clip_rect().right() - 8.0); + let (x0, x1) = (resp.rect.left() + 18.0 + indent_x(ui.ctx(), indent), ui.clip_rect().right() - 8.0); ui.painter().circle_stroke(pos2(x0, y), 3.0, egui::Stroke::new(1.5, t.accent)); ui.painter().line_segment([pos2(x0 + 3.0, y), pos2(x1.max(x0 + 3.0), y)], egui::Stroke::new(2.0, t.accent)); if released { @@ -1747,7 +1804,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], folder_row(app, ui, n, &id, &name, indent, open_id, &mut open); } if open && !n.children.is_empty() { - folder_rows(app, ui, &n.children, indent + 16.0); + folder_rows(app, ui, &n.children, indent + INDENT_STEP); } } } @@ -2098,7 +2155,7 @@ fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode keyword_row(app, ui, n, &id, indent, open_id, &mut open); } if open { - keyword_rows(app, ui, &n.children, indent + 16.0); + keyword_rows(app, ui, &n.children, indent + INDENT_STEP); } } } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 8e360f862..612c1a4fb 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1795,21 +1795,30 @@ fn removing_a_disk_from_the_library_asks_first() { assert_eq!(h.app.session.visible().len(), 1, "everything on that disk left the library"); } -/// However deep the folders go, each level is indented one step more than its parent, and the -/// sidebar scrolls sideways to reach them instead of squeezing the nesting. +/// The x of each folder's chevron down the chain `path` (`/a/b/c` → `/a`, `/a/b`, `/a/b/c`). +fn chevron_xs(h: &Headless, path: &str) -> Vec { + let mut xs: Vec = Vec::new(); + let mut at = String::new(); + for name in path.split('/').filter(|n| !n.is_empty()) { + at.push('/'); + at.push_str(name); + xs.push(widget(h, &format!("libraryFolderToggle:{at}")).center().x); + } + xs +} + +/// However deep the folders go, each level is indented one step more than its parent. The step +/// narrows to make room, never below the narrowest, and past that the sidebar scrolls sideways +/// instead of squeezing the nesting further. #[test] fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { - let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m"; + let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w/x/y/z"; // (tall enough for the whole chain: Recently Deleted is above it now) - let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1000.0]); - let mut xs: Vec = Vec::new(); - let mut path = String::new(); - for name in deep.split('/').filter(|n| !n.is_empty()) { - path.push('/'); - path.push_str(name); - xs.push(widget(&h, &format!("libraryFolderToggle:{path}")).center().x); - } - assert!(xs.len() == 13 && xs.windows(2).all(|w| (w[1] - w[0] - 16.0).abs() < 0.5), "every level steps right by one indent: {xs:?}"); + let mut h = folders_app_sized(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")], [1400.0, 1700.0]); + let xs = chevron_xs(&h, deep); + let step = crate::panels::left::MIN_INDENT_STEP; + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), step, "26 levels do not fit the default panel even at the narrowest step"); + assert!(xs.len() == 26 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "every level steps right by one indent: {xs:?}"); let panel = widget(&h, "panel:left_panel"); assert!(crate::panels::left::content_width(&h.view.ctx) > panel.width(), "the content is wider than the panel: the bar appears"); assert!(widget(&h, "source:all").width() > panel.width(), "the rows are as wide as the widest one, so there is something to scroll to"); @@ -1819,9 +1828,52 @@ fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() { } // what a row needs is known before it is drawn off screen, so the plus stays reachable assert!(widget(&h, "icon:albumNew").right() <= panel.right(), "Create Album stays inside the visible panel"); - // a click in the middle of a deep row chooses it; only the little triangle folds it - click(&mut h, "source:libfolder:/a/b/c/d/e/f/g/h/i/j/k/l"); - assert_eq!(h.app.session.library_folder.as_deref(), Some("/a/b/c/d/e/f/g/h/i/j/k/l"), "the row's middle is the row, not its triangle"); + // a click in the middle of a deep row chooses it; only the little chevron folds it + let chosen = "/a/b/c/d/e/f/g/h/i/j/k/l"; + click(&mut h, &format!("source:libfolder:{chosen}")); + assert_eq!(h.app.session.library_folder.as_deref(), Some(chosen), "the row's middle is the row, not its chevron"); +} + +/// A library below a deep path (issue #2298): in the default panel the levels step right by +/// less than a full step, so the deepest folder's name has room and nothing scrolls sideways; +/// a wide panel goes back to full steps, and a shallow tree never narrows. +#[test] +fn a_deep_library_path_narrows_the_indent_instead_of_cutting_the_names() { + let deep = "/Users/someone/Library/CloudStorage/Nextcloud-cloud.example.com/RAW/Club/2026-09-24 Team/RAW"; + let mut h = folders_app_sized(&[&format!("{deep}/a/1.jpg"), &format!("{deep}/b/2.jpg")], [1400.0, 1200.0]); + let full = crate::panels::left::INDENT_STEP; + let narrow = crate::panels::left::indent_step(&h.view.ctx); + assert!(narrow < full && narrow >= crate::panels::left::MIN_INDENT_STEP, "the step narrows in the default panel: {narrow}"); + let xs = chevron_xs(&h, deep); + assert!(xs.windows(2).all(|w| (w[1] - w[0] - narrow).abs() < 0.5), "every level still steps right by the same amount: {xs:?}"); + let panel = widget(&h, "panel:left_panel"); + assert!(crate::panels::left::content_width(&h.view.ctx) <= panel.width(), "nothing scrolls sideways"); + let id = format!("libfolder:{deep}"); + let (label, count) = (widget(&h, &format!("label:{id}")), widget(&h, &format!("count:{id}"))); + assert!(label.width() > 20.0 && label.right() + 4.0 <= count.left(), "the deepest name shows, left of its count: {label:?} {count:?}"); + // a wide panel has room for full steps again + let r = h.request("ui.set", json!({"leftWidth": 480.0}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a wide panel indents by the full step"); + let xs = chevron_xs(&h, deep); + assert!(xs.windows(2).all(|w| (w[1] - w[0] - full).abs() < 0.5), "{xs:?}"); + // a shallow tree keeps the full step in the default panel + let h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]); + assert_eq!(crate::panels::left::indent_step(&h.view.ctx), full, "a shallow tree is drawn as before"); +} + +/// The step is chosen in whole bands from the panel's width and the deepest row. +#[test] +fn the_indent_step_narrows_in_bands() { + use crate::panels::left::step_for; + // (room for the steps: the panel less the row's margins, a name's 80 pt and a count's 40) + assert_eq!(step_for(268.0, 0.0), 16.0, "top-level rows only"); + assert_eq!(step_for(268.0, 5.0 * 16.0), 16.0, "five levels fit at full steps"); + assert_eq!(step_for(268.0, 7.0 * 16.0), 12.0, "seven levels at 12 pt"); + assert_eq!(step_for(268.0, 9.0 * 16.0), 8.0, "nine levels at 8 pt"); + assert_eq!(step_for(268.0, 30.0 * 16.0), 8.0, "never below 8 pt: the sidebar scrolls instead"); + assert_eq!(step_for(480.0, 9.0 * 16.0), 16.0, "a wide panel keeps full steps"); } /// A sidebar that fits needs no sideways scrolling. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 4e0531793..db565a2d4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -702,7 +702,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes (16 pt while the deepest open row of any sidebar tree fits the panel with room for its name and count; narrower in bands of 14, 12, 10 and 8 pt when it would not, the same for every tree, chosen from the last frame; issue #2298): past 8 pt the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | @@ -891,3 +891,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | | 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | | 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | +| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) | From 1399dcd3a5d5eddfdffcee86e53a2783a4f5c7c2 Mon Sep 17 00:00:00 2001 From: helgehelge123 <117832631+helgehelge123@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:09:55 +0200 Subject: [PATCH 2/2] LRC-LIB-LIBFOLDERS: By Date's day rows indent by two INDENT_STEPs, not a literal 32 pt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/left.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 8fc70b98b..09da9845a 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -798,7 +798,7 @@ fn date_rows(app: &mut LightcraftApp, ui: &mut egui::Ui) { if date_row(app, ui, m, &label, *n, INDENT_STEP) { 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); + date_row(app, ui, d, &label, *n, 2.0 * INDENT_STEP); } } }