Sidebar: scrolls sideways so deep folders keep their nesting
Every level indents one step more than its parent however deep it goes (the five-level indent cap is gone). The whole sidebar scrolls in both directions: rows report how wide they need to be, the widest one of the last frame sets the content width (at least the panel's), and a bar appears only when it overflows. Folder names are no longer cut in the tree; Create Album stays at the visible edge. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5d13d59520
commit
6f33aacd22
|
|
@ -10,6 +10,21 @@ use crate::icons::{Icon, paint};
|
|||
use crate::theme::Tokens;
|
||||
use crate::widgets::{icon_button, register};
|
||||
|
||||
/// How wide the sidebar's content needs to be, from the widest row of the last frame: rows are
|
||||
/// drawn at this width (or the panel's, if wider), and the sidebar scrolls sideways when it
|
||||
/// exceeds the panel. One frame behind, which needs no second layout pass.
|
||||
pub(crate) fn content_width(ctx: &egui::Context) -> f32 {
|
||||
ctx.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width"))).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// A row says how wide it needs to be.
|
||||
fn note_width(ui: &egui::Ui, w: f32) {
|
||||
ui.data_mut(|d| {
|
||||
let m = d.get_temp_mut_or_insert_with::<f32>(egui::Id::new("left-content-width-next"), || 0.0);
|
||||
*m = m.max(w);
|
||||
});
|
||||
}
|
||||
|
||||
fn row(
|
||||
app: &mut LightcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
|
|
@ -57,16 +72,19 @@ fn row_named(
|
|||
icon,
|
||||
if selected { t.text } else { t.icon },
|
||||
);
|
||||
ui.painter().text(
|
||||
let label_rect = ui.painter().text(
|
||||
pos2(r.left() + 42.0 + indent, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
label,
|
||||
t.font(13.5),
|
||||
if selected { t.text } else { t.text_label },
|
||||
);
|
||||
let mut needed = label_rect.right() - r.left() + 18.0;
|
||||
if let Some(n) = count.filter(|_| app.ui.show_counts) {
|
||||
ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim);
|
||||
let c = ui.painter().text(pos2(r.right() - 18.0, r.center().y), Align2::RIGHT_CENTER, n.to_string(), t.font(12.5), t.text_dim);
|
||||
needed += c.width() + 16.0;
|
||||
}
|
||||
note_width(ui, needed);
|
||||
let _ = app;
|
||||
resp
|
||||
}
|
||||
|
|
@ -106,7 +124,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
ui.painter().text(pos2(hr.left() + 18.0, hr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("My Photos"), t.semibold(15.0), t.text);
|
||||
let counts = app.caches.counts(&app.session.catalog);
|
||||
let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted);
|
||||
egui::ScrollArea::vertical().id_salt("left-scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
// rows are as wide as the widest one needs (last frame), at least the panel
|
||||
let wide = viewport.width().max(content_width(ui.ctx()));
|
||||
ui.set_min_width(wide);
|
||||
ui.set_max_width(wide);
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width-next"), 0.0f32));
|
||||
let src = app.session.source;
|
||||
for (id, icon, label, count, s) in [
|
||||
("all", Icon::Photos, "All Photos", Some(total), LibrarySource::All),
|
||||
|
|
@ -127,9 +150,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
ui.add_space(10.0);
|
||||
// Albums header
|
||||
let (ar, albums_open) = sidebar_section_header(app, ui, "albums", "Albums");
|
||||
// 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(ar.right() - 50.0, ar.top()), ar.right_bottom()))
|
||||
.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");
|
||||
|
|
@ -180,6 +205,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
|||
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"}));
|
||||
}
|
||||
// what the rows asked for becomes next frame's width
|
||||
let next = ui.data(|d| d.get_temp::<f32>(egui::Id::new("left-content-width-next"))).unwrap_or(0.0);
|
||||
if (next - content_width(ui.ctx())).abs() > 0.5 {
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("left-content-width"), next));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Some(w) = resized {
|
||||
|
|
@ -773,10 +804,6 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) {
|
|||
open_above(ui, tree, &chosen);
|
||||
}
|
||||
|
||||
/// Deeper folders keep this indent: a chain of a dozen single folders would otherwise push every
|
||||
/// label out of the sidebar.
|
||||
const MAX_FOLDER_INDENT: f32 = 16.0 * 5.0;
|
||||
|
||||
fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for n in nodes {
|
||||
|
|
@ -792,14 +819,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
|||
&& app.session.source == LibrarySource::LibraryFolder
|
||||
&& app.session.library_folder.as_deref().is_some_and(|f| same_folder(f, &n.path));
|
||||
let name = if selectable { n.name.clone() } else { crate::i18n::tr("This Computer").to_string() };
|
||||
// room for the name: between the icon and the count
|
||||
let font = t.font(13.5);
|
||||
let count_w =
|
||||
if app.ui.show_counts { ui.painter().layout_no_wrap(n.count.to_string(), t.font(12.5), egui::Color32::WHITE).size().x } else { 0.0 };
|
||||
let room = ui.available_width() - 42.0 - indent - count_w - 28.0;
|
||||
let label =
|
||||
crate::widgets::elide_head(&name, room, |s| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x);
|
||||
let resp = row_named(app, ui, &format!("libfolder:{}", n.path), Icon::Folder, &label, Some(&name), Some(n.count), sel, indent);
|
||||
let resp = row_named(app, ui, &format!("libfolder:{}", n.path), Icon::Folder, &name, Some(&name), Some(n.count), sel, indent);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
// disclosure triangle left of the icon
|
||||
|
|
@ -837,7 +857,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode],
|
|||
}
|
||||
row_menu(app, &resp, n);
|
||||
if open && !n.children.is_empty() {
|
||||
folder_rows(app, ui, &n.children, (indent + 16.0).min(MAX_FOLDER_INDENT));
|
||||
folder_rows(app, ui, &n.children, indent + 16.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,25 +429,37 @@ 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, a row keeps its name, count and click targets inside the panel:
|
||||
/// the indent stops growing after five levels instead of pushing the label out of the row.
|
||||
/// 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.
|
||||
#[test]
|
||||
fn a_deep_chain_of_folders_keeps_its_rows_usable() {
|
||||
fn a_deep_chain_keeps_its_nesting_and_the_sidebar_scrolls_sideways() {
|
||||
let deep = "/a/b/c/d/e/f/g/h/i/j/k/l/m";
|
||||
let mut h = folders_app(&[&format!("{deep}/x/1.jpg"), &format!("{deep}/y/2.jpg")]);
|
||||
let toggles: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("libraryFolderToggle:")).cloned().collect();
|
||||
assert!(toggles.len() >= 12, "the chain opens by itself: {}", toggles.len());
|
||||
let row = widget(&h, "source:libfolder:/a");
|
||||
for (id, r) in &toggles {
|
||||
assert!(r.center().x <= row.left() + 10.0 + 16.0 * 5.0 + 1.0, "{id}: the triangle stays near the left edge ({r:?})");
|
||||
let mut xs: Vec<f32> = 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 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");
|
||||
// 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
|
||||
let leaf = format!("source:libfolder:{deep}/x");
|
||||
assert!(has(&h, "libraryFolderToggle:/a/b/c/d/e/f/g/h/i/j/k/l/m") || has(&h, &leaf));
|
||||
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 sidebar that fits needs no sideways scrolling.
|
||||
#[test]
|
||||
fn a_sidebar_that_fits_does_not_scroll_sideways() {
|
||||
let h = folders_app(&["/pics/trip/a.jpg", "/pics/home/b.jpg"]);
|
||||
let panel = widget(&h, "panel:left_panel");
|
||||
assert!(crate::panels::left::content_width(&h.view.ctx) <= panel.width(), "no horizontal bar for a shallow tree");
|
||||
}
|
||||
|
||||
/// A long folder name is trimmed before it can run under the photo count.
|
||||
#[test]
|
||||
fn a_long_folder_name_never_runs_under_the_photo_count() {
|
||||
|
|
|
|||
|
|
@ -718,7 +718,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
|
|||
| LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) |
|
||||
| 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; 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`) |
|
||||
| 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`, `crates/catalog/src/folders.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/<host>`), NFS (`/Network/Servers/<host>`), 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; deep chains stop indenting after five levels, long names are cut with the full path in the tooltip (the grid title too); 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. 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…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers 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); creating a folder and moving photos between folders are not here |
|
||||
| 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`, `crates/catalog/src/folders.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/<host>`), NFS (`/Network/Servers/<host>`), 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 stays at the visible edge); the grid title is cut with the full name on hover; 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. 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…, Show in Finder (on disk, photos relinked, as in Local) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row offers 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); creating a folder and moving photos between folders are not here |
|
||||
| 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), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) |
|
||||
| LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue