From a46c0071a0320faebc253da402700f26fa51166d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 19:57:27 -0400 Subject: [PATCH 01/19] Albums: pin that albums are created only inside folders Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 978e8067..9806a81e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -118,6 +118,29 @@ fn albums_crud() { s.execute("album.delete", &json!({"id": f})).unwrap(); } +/// Given an album and a folder, when something is created inside each, then only the folder +/// accepts it: the sidebar only lists children of folders, so a child of a plain album would +/// be invisible. +#[test] +fn albums_and_smart_albums_are_created_only_inside_folders() { + let mut s = demo(); + let folder = s.execute("album.create", &json!({"name": "Trips", "folder": true})).unwrap()["id"].as_u64().unwrap(); + let album = s.execute("album.create", &json!({"name": "Best", "parent": folder})).unwrap()["id"].as_u64().unwrap(); + for (cmd, params) in [ + ("album.create", json!({"name": "Nested", "parent": album})), + ("album.create", json!({"name": "Nested", "folder": true, "parent": album})), + ("album.createSmart", json!({"name": "Nested", "parent": album})), + ("album.create", json!({"name": "Nested", "parent": 987_654})), + ] { + assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params} must be refused"); + } + let n = s.catalog.albums().count(); + let sub = s.execute("album.create", &json!({"name": "Sub", "folder": true, "parent": folder})).unwrap()["id"].as_u64().unwrap(); + let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": sub})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(s.catalog.albums().count(), n + 2); + assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(smart)).unwrap().parent, Some(lightcraft_catalog::AlbumId(sub))); +} + #[test] fn filters_and_delete_restore() { let mut s = demo(); From e8b18ae636cbecf3d4d1d68a79a8fc56d1222b95 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:01:56 -0400 Subject: [PATCH 02/19] Sidebar: one disclosure-triangle helper for the Local, By Date, Folders and Keywords trees Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 74 ++++++++++--------------------- 1 file changed, 24 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 868a71cd..304a4586 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -107,6 +107,25 @@ fn row_named( resp } +/// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing +/// when closed, down when open, registered as widget `widget` for the control channel. Returns +/// its response: the caller flips the open state on `clicked()`. +fn disclosure_triangle(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 tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + 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)); + 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. @@ -516,7 +535,6 @@ fn folder_tree( reveal: bool, transient: bool, ) { - let t = Tokens::get(ui.ctx()); 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)); @@ -534,17 +552,7 @@ fn folder_tree( } } let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); - let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); - let tr = ui.interact(tri, egui::Id::new(("folder-tri", path.to_string())), Sense::click()); - register(ui.ctx(), format!("folderToggle:{path}"), 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)); + 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)); @@ -607,23 +615,12 @@ 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 t = Tokens::get(ui.ctx()); 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 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 c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); - let tr = ui.interact(tri, egui::Id::new(("date-tri", key.to_string())), Sense::click()); - register(ui.ctx(), format!("dateToggle:{key}"), 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)); + 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)); @@ -838,7 +835,6 @@ fn reveal_chosen(app: &LightcraftApp, ui: &egui::Ui, tree: &[FolderNode]) { } fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], indent: f32) { - let t = Tokens::get(ui.ctx()); for n in nodes { let key = lightcraft_catalog::query::folder_key(&n.path); let open_id = egui::Id::new(("libfolder-open", key.clone())); @@ -855,18 +851,7 @@ fn folder_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[FolderNode], 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 - let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); - let tr = ui.interact(tri, egui::Id::new(("libfolder-tri", key)), Sense::click()); - register(ui.ctx(), format!("libraryFolderToggle:{}", n.path), 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)); + let tr = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("libfolder-tri", key)), 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") }) @@ -977,25 +962,14 @@ fn keywords_section(app: &mut LightcraftApp, ui: &mut egui::Ui) { } fn keyword_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, nodes: &[KeywordNode], indent: f32) { - let t = Tokens::get(ui.ctx()); 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 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() { - // disclosure triangle left of the icon - let c = pos2(resp.rect.left() + 10.0 + indent, resp.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); - let tr = ui.interact(tri, egui::Id::new(("kw-tri", n.path.to_lowercase())), Sense::click()); - register(ui.ctx(), format!("keywordToggle:{}", n.path), 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)); + 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)); From 96a9c6e04daea57ef16a2510ad20a1d034d6c6c5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:04:29 -0400 Subject: [PATCH 03/19] Albums: folders get the same disclosure triangle as the other sidebar trees Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 4 +++- crates/ui-egui/src/tests_panels.rs | 36 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 304a4586..5771c9d4 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -651,7 +651,9 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent let open_id = egui::Id::new(("folder-open", a.id.0)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent); - if resp.clicked() { + // a folder is no source, so its row folds it too; the triangle is the same click, aimed + let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); + if resp.clicked() || tri.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } folder_menu(app, &resp, a); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index aad86fd5..d0cf302f 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -220,6 +220,42 @@ fn sidebar_sections_collapse_and_remember_it() { assert!(!h.app.ui.sidebar_section_collapsed("albums"), "the plus does not fold Albums"); } +/// Albums nest in folders like the other sidebar trees: a folder row has a disclosure triangle +/// (`albumToggle:`), plain albums have none, and folding a folder hides what is inside it. +#[test] +fn album_folders_have_a_disclosure_triangle() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + let trips = make(&mut h, json!({"name": "Trips", "folder": true})); + let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips})); + let best = make(&mut h, json!({"name": "Best", "parent": europe})); + let loose = make(&mut h, json!({"name": "Loose"})); + h.step(); + h.step(); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let toggle = |h: &mut Headless, id: u64| { + let r = h.request("ui.clickWidget", json!({"id": format!("albumToggle:{id}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + }; + // folders start open and show their albums; only folders get a triangle + assert!(has(&h, &format!("source:album:{best}")), "open by default"); + assert!(has(&h, &format!("albumToggle:{trips}")) && has(&h, &format!("albumToggle:{europe}"))); + assert!(!has(&h, &format!("albumToggle:{best}")) && !has(&h, &format!("albumToggle:{loose}")), "albums have no triangle"); + // folding the outer folder hides everything inside it, the inner triangle included + toggle(&mut h, trips); + assert!(!has(&h, &format!("source:folder:{europe}")) && !has(&h, &format!("source:album:{best}"))); + assert!(has(&h, &format!("source:album:{loose}")), "siblings stay"); + // opening it again leaves the inner folder as it was + toggle(&mut h, trips); + assert!(has(&h, &format!("source:album:{best}"))); + toggle(&mut h, europe); + assert!(!has(&h, &format!("source:album:{best}")) && has(&h, &format!("source:folder:{europe}"))); + // the triangle is not the row: clicking it does not make the folder the source + assert_ne!(h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(europe))); +} + /// A headless app over a library of file-backed photos that exist only in the catalog. fn folders_app(paths: &[&str]) -> Headless { folders_app_sized(paths, [1400.0, 900.0]) From 79403a5680aeda5fd322811f54dcf409f0b8d37c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:08:53 -0400 Subject: [PATCH 04/19] Albums: create albums, smart albums and folders inside a folder from its context menu The dialog.new* commands take the folder to create in, the dialogs remember it, and the Albums tree opens the folders down to the new row. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/menus.rs | 15 +++++-- crates/ui-egui/src/panels/dialogs.rs | 23 +++++++--- crates/ui-egui/src/panels/left.rs | 47 +++++++++++++++++---- crates/ui-egui/src/state.rs | 12 ++++++ crates/ui-egui/src/tests_panels.rs | 63 ++++++++++++++++++++++++++++ 5 files changed, 143 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 62461749..9829e057 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -230,6 +230,11 @@ fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { } /// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). +/// The `parent` folder id of a `dialog.new*` command (none: the top level). +fn parent_param(p: &Value) -> Option { + p.get("parent").and_then(Value::as_u64) +} + pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { let h = s.strip_prefix('#').unwrap_or(s); @@ -807,11 +812,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true }); + app.ui.dialog = + Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) }); Ok(Value::Null) } "dialog.newAlbum" => { - app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false }); + app.ui.dialog = + Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) }); Ok(Value::Null) } "dialog.autoStack" => { @@ -838,17 +845,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Dialog::SmartRules { id: None, name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() }, + parent: parent_param(p), }, }); Ok(Value::Null) } "dialog.newSmartAlbum" => { - app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); + app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) }); Ok(Value::Null) } "dialog.captureTime" => { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index cd087776..4eff2f39 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -231,7 +231,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } - Dialog::SmartRules { id, name, rules } => { + Dialog::SmartRules { id, name, rules, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); crate::widgets::register(ui.ctx(), "field:smartName", r.rect); ui.add_space(6.0); @@ -252,7 +252,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } - Dialog::NewSmartAlbum { name } => { + Dialog::NewSmartAlbum { name, .. } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text(crate::i18n::tr("Name")).desired_width(f32::INFINITY)); r.request_focus(); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { @@ -979,6 +979,13 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str } } +/// Runs a command that creates an album and asks the Albums tree to open the folders down to it. +fn created_in(app: &mut LightcraftApp, command: &str, params: serde_json::Value) -> Result { + let r = app.run(command, params)?; + app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64); + Ok(r) +} + pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { match dlg { Dialog::SamModel { then, .. } => { @@ -995,7 +1002,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), + Dialog::NewAlbum { name, folder, parent } => { + created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent})) + } Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), Dialog::TextPrompt { value, command, params, key, .. } => { let mut p = params.clone(); @@ -1033,7 +1042,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + Dialog::SmartRules { id, name, rules, parent } => { let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() }; match id { Some(id) => { @@ -1042,10 +1051,12 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}})), + None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})), } } - Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), + Dialog::NewSmartAlbum { name, parent } => { + created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent})) + } Dialog::CreatePreset { name, group, groups } => app.run( "preset.create", json!({ diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 5771c9d4..100493e2 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -201,25 +201,34 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 }); + 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() }); + 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 }); + app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); } }); if albums_open { let albums: Vec = app.session.catalog.albums().cloned().collect(); - albums_tree(app, ui, &albums, None, 0.0); + // the album just made: the folders down to it open, once + let reveal = app.ui.reveal_album.take(); + let mut open_to = Vec::new(); + let mut cur = reveal.map(AlbumId).and_then(|id| albums.iter().find(|a| a.id == 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 = albums.iter().find(|a| a.id == p).and_then(|a| a.parent); + } + albums_tree(app, ui, &albums, None, 0.0, &open_to); } ui.add_space(10.0); local_section(app, ui); @@ -643,13 +652,18 @@ fn browse_all_photos(app: &mut LightcraftApp, on: bool) { } } -fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent: Option, indent: f32) { +/// `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: &[Album], parent: Option, indent: f32, open_to: &[AlbumId]) { let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect(); kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase())); for a in kids { if a.folder { let open_id = egui::Id::new(("folder-open", a.id.0)); - let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + let mut open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); + if open_to.contains(&a.id) && !open { + open = true; + ui.data_mut(|d| d.insert_temp(open_id, true)); + } let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); @@ -658,7 +672,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } folder_menu(app, &resp, a); if open { - albums_tree(app, ui, all, Some(a.id), indent + 16.0); + albums_tree(app, ui, all, Some(a.id), indent + 16.0, open_to); } } else { let sel = app.session.source == LibrarySource::Album(a.id); @@ -707,6 +721,23 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { + if a.folder { + // the same commands as File ▸ New…, aimed at this folder + ui.menu_button(crate::i18n::tr("New"), |ui| { + for (label, command) in [ + ("Album…", "dialog.newAlbum"), + ("Smart Album…", "dialog.smartAlbum"), + ("Smart Album from Filter…", "dialog.newSmartAlbum"), + ("Folder…", "dialog.newFolder"), + ] { + if ui.button(crate::i18n::tr(label)).clicked() { + let _ = app.run(command, json!({"parent": a.id.0})); + ui.close(); + } + } + }); + ui.separator(); + } if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } @@ -725,7 +756,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { if a.is_smart() && ui.button(crate::i18n::tr("Edit Smart Album…")).clicked() { // older smart albums keep their filter fields; the editor works on the rule set let rules = a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(); - app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules }); + app.ui.dialog = Some(crate::state::Dialog::SmartRules { id: Some(a.id.0), name: a.name.clone(), rules, parent: None }); } if a.is_smart() && ui.button(crate::i18n::tr("Update Rules from Current Filter")).clicked() { let _ = app.run("album.setRules", json!({"id": a.id.0, "fromView": true})); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 17f5c58b..5f00f5a5 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -372,6 +372,9 @@ pub struct UiState { /// Photos being dragged from the grid (dropped on an album to add them). #[serde(skip)] pub dragging_photos: Option>, + /// An album just made: the Albums tree opens the folders down to it, once. + #[serde(skip)] + pub reveal_album: Option, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -465,9 +468,12 @@ impl Dialog { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// `parent`: the folder to create it in (none: the top level). NewAlbum { name: String, folder: bool, + #[serde(default)] + parent: Option, }, RenameAlbum { id: u64, @@ -525,6 +531,8 @@ pub enum Dialog { /// Save the current view (source + filter) as a smart album. NewSmartAlbum { name: String, + #[serde(default)] + parent: Option, }, /// Help ▸ What's New. WhatsNew, @@ -549,6 +557,9 @@ pub enum Dialog { id: Option, name: String, rules: lightcraft_catalog::RuleSet, + /// The folder a new smart album is created in (ignored when editing). + #[serde(default)] + parent: Option, }, /// `groups`: the settings groups the preset includes (`SettingsGroup` ids). CreatePreset { @@ -663,6 +674,7 @@ impl Default for UiState { renaming_component: None, quit: false, dragging_photos: None, + reveal_album: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index d0cf302f..edb71fde 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -256,6 +256,69 @@ fn album_folders_have_a_disclosure_triangle() { assert_ne!(h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(europe))); } +/// Creating from a folder's menu: every `dialog.new*` command takes the folder to create in, the +/// dialog remembers it, and the new row shows up even when that folder (and the one holding it) +/// was folded shut. +#[test] +fn albums_are_created_inside_the_chosen_folder() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + let trips = make(&mut h, json!({"name": "Trips", "folder": true})); + let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips})); + h.step(); + h.step(); + let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); + let create = |h: &mut Headless, command: &str, name: &str, parent: Option| -> u64 { + let before: Vec = h.app.session.catalog.albums().map(|a| a.id.0).collect(); + let mut params = json!({"name": name}); + if let Some(p) = parent { + params["parent"] = json!(p); + } + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let new: Vec = h.app.session.catalog.albums().map(|a| a.id.0).filter(|i| !before.contains(i)).collect(); + assert_eq!(new.len(), 1, "{command} made exactly one album: {new:?}"); + new[0] + }; + let parent_of = |h: &Headless, id: u64| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0); + // fold both folders: the new rows must still be visible afterwards + for f in [europe, trips] { + let r = h.request("ui.clickWidget", json!({"id": format!("albumToggle:{f}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + } + assert!(!has(&h, &format!("source:folder:{europe}")), "Trips is folded"); + let album = create(&mut h, "dialog.newAlbum", "Day 1", Some(europe)); + assert_eq!(parent_of(&h, album), Some(europe)); + assert!(has(&h, &format!("source:album:{album}")), "the new album is shown, its folders opened to it"); + let folder = create(&mut h, "dialog.newFolder", "Italy", Some(europe)); + assert_eq!(parent_of(&h, folder), Some(europe)); + assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(folder)).unwrap().folder); + let smart = create(&mut h, "dialog.smartAlbum", "Rated", Some(folder)); + assert_eq!(parent_of(&h, smart), Some(folder)); + assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(smart)).unwrap().is_smart()); + let from_view = create(&mut h, "dialog.newSmartAlbum", "From view", Some(trips)); + assert_eq!(parent_of(&h, from_view), Some(trips)); + // without a folder they are still made at the top level + let top = create(&mut h, "dialog.newAlbum", "Loose", None); + assert_eq!(parent_of(&h, top), None); +} + +/// Saved or scripted dialogs from before the folder parameter still load. +#[test] +fn album_dialogs_without_a_parent_still_deserialize() { + use crate::state::Dialog; + let d: Dialog = serde_json::from_value(json!({"kind": "newAlbum", "name": "x", "folder": false})).unwrap(); + assert!(matches!(d, Dialog::NewAlbum { parent: None, .. })); + let d: Dialog = serde_json::from_value(json!({"kind": "newSmartAlbum", "name": "x"})).unwrap(); + assert!(matches!(d, Dialog::NewSmartAlbum { parent: None, .. })); +} + /// A headless app over a library of file-backed photos that exist only in the catalog. fn folders_app(paths: &[&str]) -> Headless { folders_app_sized(paths, [1400.0, 900.0]) From 4abf70e254d20b3cce8350710baab4248fadc54a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:08:53 -0400 Subject: [PATCH 05/19] Parity: album folders fold like the other trees and create inside themselves Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 2eef5106..e6d908d8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -151,7 +151,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Album… / Smart Album… / Smart Album from Filter… / Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving is by the Move to menu or `album.move` (no drag yet), no custom order (alphabetical, folders first) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | @@ -620,7 +620,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | From 5443ae28f9adf515048f18e10483160129ca5390 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:14:20 -0400 Subject: [PATCH 06/19] Albums: address review of folder creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show a dialog's error and keep it open instead of closing it, open the folder menu from the triangle too, draw a triangle only on folders that hold something, reveal even when the section is folded, reuse the translated Create … labels, and fix a misplaced doc comment. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 13 +++++++++- crates/ui-egui/src/panels/left.rs | 23 ++++++++++------ crates/ui-egui/src/tests_panels.rs | 39 ++++++++++++++++++++++++++-- 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9829e057..17ed2ef6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -229,12 +229,12 @@ fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { } } -/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). /// The `parent` folder id of a `dialog.new*` command (none: the top level). fn parent_param(p: &Value) -> Option { p.get("parent").and_then(Value::as_u64) } +/// An sRGB colour from `"#rrggbb"` or `[r, g, b]` (0..255). pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { if let Some(s) = v.as_str() { let h = s.strip_prefix('#').unwrap_or(s); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 4eff2f39..b1c168e6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -847,7 +847,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { match confirm_dialog(app, &dlg) { // the import review stays open on an error (e.g. an unusable folder template), and so // does Export (e.g. no folder chosen) so the choices aren't lost - Err(e) if matches!(dlg, Dialog::Import { .. } | Dialog::Export { .. }) => app.toast(ctx, e), + Err(e) + if matches!( + dlg, + Dialog::Import { .. } + | Dialog::Export { .. } + | Dialog::NewAlbum { .. } + | Dialog::NewSmartAlbum { .. } + | Dialog::SmartRules { .. } + ) => + { + app.toast(ctx, e) + } // the SAM 3 dialog stays open to show the download (or why it can't start) Err(e) if matches!(dlg, Dialog::SamModel { .. }) => { if let Dialog::SamModel { error, .. } = &mut dlg { diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 100493e2..12db28b2 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -218,10 +218,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.dialog = Some(crate::state::Dialog::NewAlbum { name: String::new(), folder: true, parent: None }); } }); + // 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 albums: Vec = app.session.catalog.albums().cloned().collect(); - // the album just made: the folders down to it open, once - let reveal = app.ui.reveal_album.take(); let mut open_to = Vec::new(); let mut cur = reveal.map(AlbumId).and_then(|id| albums.iter().find(|a| a.id == id)).and_then(|a| a.parent); while let Some(p) = cur.filter(|p| !open_to.contains(p) && open_to.len() < 64) { @@ -666,8 +666,15 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent); // a folder is no source, so its row folds it too; the triangle is the same click, aimed - let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); - if resp.clicked() || tri.clicked() { + let has_children = all.iter().any(|c| c.parent == Some(a.id)); + let mut toggled = resp.clicked(); + 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(); + // 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)); } folder_menu(app, &resp, a); @@ -725,10 +732,10 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { // the same commands as File ▸ New…, aimed at this folder ui.menu_button(crate::i18n::tr("New"), |ui| { for (label, command) in [ - ("Album…", "dialog.newAlbum"), - ("Smart Album…", "dialog.smartAlbum"), - ("Smart Album from Filter…", "dialog.newSmartAlbum"), - ("Folder…", "dialog.newFolder"), + ("Create Album…", "dialog.newAlbum"), + ("Create Smart Album…", "dialog.smartAlbum"), + ("Create Smart Album from Filter…", "dialog.newSmartAlbum"), + ("Create Folder…", "dialog.newFolder"), ] { if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(command, json!({"parent": a.id.0})); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index edb71fde..5f81ee36 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -252,8 +252,37 @@ fn album_folders_have_a_disclosure_triangle() { assert!(has(&h, &format!("source:album:{best}"))); toggle(&mut h, europe); assert!(!has(&h, &format!("source:album:{best}")) && has(&h, &format!("source:folder:{europe}"))); - // the triangle is not the row: clicking it does not make the folder the source - assert_ne!(h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(europe))); + // an empty folder has nothing to open: no triangle until it holds something + let empty = make(&mut h, json!({"name": "Empty", "folder": true})); + h.step(); + h.step(); + assert!(has(&h, &format!("source:folder:{empty}")) && !has(&h, &format!("albumToggle:{empty}"))); + make(&mut h, json!({"name": "First", "parent": empty})); + h.step(); + h.step(); + assert!(has(&h, &format!("albumToggle:{empty}")), "it gets one with its first child"); +} + +/// A dialog that cannot do its job (here: the chosen parent is a plain album, as when the folder +/// was deleted or turned out not to be one) stays open with the error shown, the typed name kept. +#[test] +fn creating_in_a_non_folder_keeps_the_dialog_open() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let plain = h.app.session.execute("album.create", &json!({"name": "Plain"})).unwrap()["id"].as_u64().unwrap(); + let before = h.app.session.catalog.albums().count(); + let r = h.request("engine.execute", json!({"command": "dialog.newAlbum", "params": {"name": "Kept", "parent": plain}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.albums().count(), before, "nothing was made"); + assert!( + matches!(&h.app.ui.dialog, Some(crate::state::Dialog::NewAlbum { name, .. }) if name == "Kept"), + "the dialog stays, with its text: {:?}", + h.app.ui.dialog + ); } /// Creating from a folder's menu: every `dialog.new*` command takes the folder to create in, the @@ -265,6 +294,7 @@ fn albums_are_created_inside_the_chosen_folder() { let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); let trips = make(&mut h, json!({"name": "Trips", "folder": true})); let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips})); + make(&mut h, json!({"name": "Seed", "parent": europe})); h.step(); h.step(); let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id); @@ -317,6 +347,11 @@ fn album_dialogs_without_a_parent_still_deserialize() { assert!(matches!(d, Dialog::NewAlbum { parent: None, .. })); let d: Dialog = serde_json::from_value(json!({"kind": "newSmartAlbum", "name": "x"})).unwrap(); assert!(matches!(d, Dialog::NewSmartAlbum { parent: None, .. })); + let mut v = serde_json::to_value(Dialog::SmartRules { id: None, name: "x".into(), rules: Default::default(), parent: Some(3) }).unwrap(); + assert_eq!(v["parent"], 3); + v.as_object_mut().unwrap().remove("parent"); + let d: Dialog = serde_json::from_value(v).unwrap(); + assert!(matches!(d, Dialog::SmartRules { parent: None, .. })); } /// A headless app over a library of file-backed photos that exist only in the catalog. From aaae39fae0b6c2dbf8416278778592d8a672a8b5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:20:38 -0400 Subject: [PATCH 07/19] Albums: drag an album or folder onto a folder to move it A folder under the pointer is outlined when the drop is allowed and opens after resting there; a drop zone under the tree takes an album back to the top level. The move is album.move, so it is one undo step. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/left.rs | 133 +++++++++++++++++++++++++++- crates/ui-egui/src/state.rs | 4 + crates/ui-egui/src/tests_panels.rs | 137 +++++++++++++++++++++++++++++ 4 files changed, 272 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fd2c0611..14250ab3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -909,6 +909,7 @@ impl LightcraftApp { export_task::poll(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); + panels::left::album_drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(t0); diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 12db28b2..e851473f 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -52,10 +52,27 @@ fn row_named( count: Option, selected: bool, indent: f32, +) -> egui::Response { + row_sensed(app, ui, id, icon, label, spoken, count, selected, indent, Sense::click()) +} + +/// [`row_named`] with the given `sense` (rows that can be dragged add `drag`). +#[allow(clippy::too_many_arguments)] +fn row_sensed( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + icon: Icon, + label: &str, + spoken: Option<&str>, + count: Option, + selected: bool, + indent: f32, + sense: Sense, ) -> 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 (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), sense); register(ui.ctx(), format!("source:{id}"), r); let said = spoken.unwrap_or(label); let name = match count { @@ -229,6 +246,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { cur = albums.iter().find(|a| a.id == p).and_then(|a| a.parent); } albums_tree(app, ui, &albums, None, 0.0, &open_to); + top_level_drop_zone(app, ui); } ui.add_space(10.0); local_section(app, ui); @@ -664,9 +682,15 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent open = true; ui.data_mut(|d| d.insert_temp(open_id, true)); } - let resp = row(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, false, indent); + let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag()); // a folder is no source, so its row folds it too; the triangle is the same click, aimed let has_children = all.iter().any(|c| c.parent == Some(a.id)); + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } + if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open)) { + ui.data_mut(|d| d.insert_temp(open_id, true)); + } let mut toggled = resp.clicked(); if has_children { let tri = disclosure_triangle(ui, &resp, indent, open, egui::Id::new(("album-tri", a.id.0)), format!("albumToggle:{}", a.id.0)); @@ -691,7 +715,10 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent let target = app.session.target_album.filter(|t| app.session.catalog.album(*t).is_some()).or_else(|| app.session.catalog.quick_collection()); let label = if target == Some(a.id) { format!("{} +", a.name) } else { a.name.clone() }; - let mut resp = row(app, ui, &format!("album:{}", a.id.0), icon, &label, Some(n), sel, indent); + let mut resp = row_sensed(app, ui, &format!("album:{}", a.id.0), icon, &label, None, Some(n), sel, indent, Sense::click_and_drag()); + if resp.drag_started() { + app.ui.dragging_album = Some(a.id.0); + } if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -706,6 +733,106 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } +/// How long a dragged album must rest on a closed folder before it opens. +const HOVER_OPEN_SECS: f64 = 0.6; + +/// Whether the album `dragged` may be dropped into the folder `target` (`None`: the top level): +/// a folder other than where it already is, and not itself or something inside it. +fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option) -> bool { + let Some(d) = app.session.catalog.album(dragged) else { return false }; + match target { + None => d.parent.is_some(), + Some(t) => app.session.catalog.album(t).is_some_and(|f| f.folder) && d.parent != Some(t) && !is_within(app, t, dragged), + } +} + +/// Moves the dragged album (`album.move`, one undo step) and ends the drag; the folders down to it +/// open so it shows in its new place. +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + match app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})) { + Ok(_) => { + app.ui.reveal_album = Some(dragged.0); + ui.ctx().request_repaint(); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_album = None; +} + +/// A folder row while an album is dragged: outlined under the pointer when the drop is allowed, +/// opened (`open`, when it has something inside) once the pointer rests on it, and the album moved +/// in on release. Returns whether it opened the folder. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, folder: &Album, open: Option<&mut bool>) -> bool { + let hover_id = egui::Id::new(("album-hover", folder.id.0)); + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { + ui.data_mut(|d| d.remove_temp::(hover_id)); + return false; + }; + if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)) { + ui.data_mut(|d| d.remove_temp::(hover_id)); + return false; + } + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); + let mut opened = false; + if let Some(open) = open + && !*open + { + if now - since >= HOVER_OPEN_SECS { + *open = true; + opened = true; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + } + if can_drop_album(app, dragged, Some(folder.id)) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if ui.input(|i| i.pointer.any_released()) { + drop_album(app, ui, dragged, Some(folder.id)); + } + } + opened +} + +/// While an album that sits in a folder is dragged: a row under the tree that takes it back to the +/// top level. +fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; + if !can_drop_album(app, dragged, None) { + return; + } + let t = Tokens::get(ui.ctx()); + let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); + register(ui.ctx(), "albumDrop:top", r); + let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| r.contains(p)); + let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; + ui.painter().rect_stroke(r.shrink2(vec2(8.0, 1.0)), 4.0, stroke, egui::StrokeKind::Inside); + ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Top Level"), t.font(13.5), t.text_dim); + if over && ui.input(|i| i.pointer.any_released()) { + drop_album(app, ui, dragged, None); + } +} + +/// While an album is dragged in the sidebar: its name at the pointer; the drag ends when the +/// button is up (the drop targets act on the release frame, before this runs). +pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(id) = app.ui.dragging_album else { return }; + let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); + if released || !down { + app.ui.dragging_album = None; + return; + } + let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + egui::Area::new(egui::Id::new("drag-album")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(egui::Color32::WHITE).font(t.semibold(12.0))); + }); + }); +} + /// An album row while photos are dragged from the grid: highlighted under the pointer; a /// release there adds them. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, a: &Album) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 5f00f5a5..bbf936dd 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -375,6 +375,9 @@ pub struct UiState { /// An album just made: the Albums tree opens the folders down to it, once. #[serde(skip)] pub reveal_album: Option, + /// An album or folder row being dragged in the sidebar (dropped on a folder to move it there). + #[serde(skip)] + pub dragging_album: Option, /// Selected curve channel in the Curve flyout. pub curve_channel: String, /// Selected mixer mode: "hue" | "saturation" | "luminance" | "all". @@ -675,6 +678,7 @@ impl Default for UiState { quit: false, dragging_photos: None, reveal_album: None, + dragging_album: None, curve_channel: "parametric".into(), mixer_mode: "hue".into(), grading_mode: "3way".into(), diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 5f81ee36..ba67867c 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -354,6 +354,143 @@ fn album_dialogs_without_a_parent_still_deserialize() { assert!(matches!(d, Dialog::SmartRules { parent: None, .. })); } +/// An album tree to drag in: `Archive` (empty folder), `Trips` ⊃ `Sub` (folder) + `Best`, `Loose`. +struct AlbumTree { + h: Headless, + archive: u64, + trips: u64, + sub: u64, + best: u64, + loose: u64, +} + +fn album_tree() -> AlbumTree { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap(); + let archive = make(&mut h, json!({"name": "Archive", "folder": true})); + let trips = make(&mut h, json!({"name": "Trips", "folder": true})); + let sub = make(&mut h, json!({"name": "Sub", "folder": true, "parent": trips})); + let best = make(&mut h, json!({"name": "Best", "parent": trips})); + let loose = make(&mut h, json!({"name": "Loose"})); + h.step(); + h.step(); + AlbumTree { h, archive, trips, sub, best, loose } +} + +impl AlbumTree { + fn row(&self, id: u64) -> egui::Rect { + let h = &self.h; + let name = ["source:folder:", "source:album:"].iter().map(|p| format!("{p}{id}")).find(|n| h.app.widgets.iter().any(|(w, _)| w == n)); + widget(h, &name.unwrap_or_else(|| panic!("no row for album {id}"))) + } + /// The middle of the row just under the last row of the Albums tree (where the top-level drop zone is). + fn below_tree(&self) -> egui::Pos2 { + let bottom = (self.h.app.widgets.iter()) + .filter(|(w, r)| (w.starts_with("source:album:") || w.starts_with("source:folder:")) && r.left() < 300.0) + .map(|(_, r)| r.bottom()) + .fold(0.0, f32::max); + egui::pos2(100.0, bottom + 14.5) + } + fn parent(&self, id: u64) -> Option { + self.h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0) + } + /// Press at `from`, move through each `(point, frames held there)`, release at the last one. + fn drag(&mut self, from: egui::Pos2, path: &[(egui::Pos2, usize)]) { + let mut ev = |e| self.h.app.synthetic.push(e); + ev(egui::Event::PointerMoved(from)); + ev(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + let mut at = from; + for (to, hold) in path { + for i in 1..=8 { + at = from + (*to - from) * (i as f32 / 8.0); + ev(egui::Event::PointerMoved(at)); + } + for _ in 0..*hold { + ev(egui::Event::PointerMoved(*to)); + } + at = *to; + } + ev(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + for _ in 0..400 { + if self.h.app.synthetic.is_empty() { + break; + } + self.h.step(); + } + self.h.step(); + self.h.step(); + } + fn drag_row(&mut self, id: u64, onto: u64) { + let (from, to) = (self.row(id).center(), self.row(onto).center()); + self.drag(from, &[(to, 3)]); + } +} + +/// Given albums and folders in the sidebar, when an album or folder is dragged onto a folder, +/// then it moves into it (as Move to does); onto a plain album, itself or something inside it, it +/// stays where it was. +#[test] +fn dragging_an_album_onto_a_folder_moves_it() { + let mut t = album_tree(); + t.drag_row(t.loose, t.archive); + assert_eq!(t.parent(t.loose), Some(t.archive), "an album into a folder"); + assert!(t.h.app.ui.dragging_album.is_none(), "the drag ended"); + // it is shown inside its new folder, which opened for it + assert!(t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:album:{}", t.loose))); + t.drag_row(t.sub, t.archive); + assert_eq!(t.parent(t.sub), Some(t.archive), "a folder into a folder"); + // refused: onto a plain album, onto itself, into its own subfolder + t.drag_row(t.best, t.loose); + assert_eq!(t.parent(t.best), Some(t.trips), "a plain album is no destination"); + let (from, aside) = (t.row(t.archive).center(), t.row(t.archive).center() + egui::vec2(40.0, 0.0)); + t.drag(from, &[(aside, 3)]); + assert_eq!(t.parent(t.archive), None, "not onto itself"); + t.drag_row(t.archive, t.sub); + assert_eq!(t.parent(t.archive), None, "not into something inside it"); + assert!(t.h.app.ui.dragging_album.is_none()); + // a plain click still only opens the album + let c = t.row(t.best).center(); + t.drag(c, &[(c, 0)]); + assert_eq!(t.h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(t.best))); +} + +/// Dragging out of a folder: the drop zone under the tree puts the album back at the top level; +/// it only shows when there is somewhere to go, and the move is one undo step. +#[test] +fn dragging_an_album_to_the_top_level() { + let mut t = album_tree(); + let has = |t: &AlbumTree, id: &str| t.h.app.widgets.iter().any(|(w, _)| w == id); + assert!(!has(&t, "albumDrop:top"), "no zone unless an album is being dragged"); + // below the last row of the tree, where the zone appears once an album with a folder is dragged + let below = t.below_tree(); + let from = t.row(t.best).center(); + t.drag(from, &[(below, 3)]); + assert_eq!(t.parent(t.best), None, "moved out of Trips"); + // a top-level album has nowhere to go: dropping it there does nothing + let (from, below) = (t.row(t.best).center(), t.below_tree()); + t.drag(from, &[(below, 3)]); + assert_eq!(t.parent(t.best), None); + t.h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(t.parent(t.best), Some(t.trips), "one undo puts it back"); +} + +/// Holding a dragged album over a closed folder opens it, so the drop can go into a subfolder. +#[test] +fn a_dragged_album_opens_the_folder_it_hovers_over() { + let mut t = album_tree(); + let r = t.h.request("ui.clickWidget", json!({"id": format!("albumToggle:{}", t.trips)}), T); + assert_eq!(r["ok"], true, "{r}"); + t.h.step(); + t.h.step(); + assert!(!t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:folder:{}", t.sub)), "Trips is folded"); + // where Sub's row will be once Trips is open: one row below it + let trips = t.row(t.trips); + let inside = trips.center() + egui::vec2(0.0, trips.height()); + let from = t.row(t.loose).center(); + t.drag(from, &[(trips.center(), 90), (inside, 3)]); + assert_eq!(t.parent(t.loose), Some(t.sub), "dropped on Sub, which was only there after the hover"); +} + /// A headless app over a library of file-backed photos that exist only in the catalog. fn folders_app(paths: &[&str]) -> Headless { folders_app_sized(paths, [1400.0, 900.0]) From 63335e2b0acc5aaaccd8bb489bcee7e7dfe186d5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:20:43 -0400 Subject: [PATCH 08/19] Parity: albums and folders move by dragging onto a folder Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index e6d908d8..ca6d974e 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -151,7 +151,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Album… / Smart Album… / Smart Album from Filter… / Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving is by the Move to menu or `album.move` (no drag yet), no custom order (alphabetical, folders first) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Album… / Smart Album… / Smart Album from Filter… / Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; a "Top Level" zone under the tree appears while dragging something that sits in a folder), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; no custom order (alphabetical, folders first) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | @@ -512,7 +512,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); no tiling beyond the buffer limit (CPU) | | 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 | -| 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`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging) | +| 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_zone`) | 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) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | | LR-BEHAV-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 | From 67aed0b4f4fdceb2079e63f9aea935a84a37a814 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:25:03 -0400 Subject: [PATCH 09/19] Albums: a drag ends with the main button, Esc cancels it, hidden rows are no targets Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 35 ++++++++++++++++++++---------- crates/ui-egui/src/tests_panels.rs | 29 +++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index e851473f..065d1cfc 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -733,6 +733,12 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } } +/// Whether the pointer is over `rect` and over what the panel shows of it (a row scrolled out of +/// sight is not a drop target). +fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { + ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) +} + /// How long a dragged album must rest on a closed folder before it opens. const HOVER_OPEN_SECS: f64 = 0.6; @@ -764,12 +770,11 @@ fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: /// in on release. Returns whether it opened the folder. fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, folder: &Album, open: Option<&mut bool>) -> bool { let hover_id = egui::Id::new(("album-hover", folder.id.0)); - let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { - ui.data_mut(|d| d.remove_temp::(hover_id)); - return false; - }; - if !ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| resp.rect.contains(p)) { - ui.data_mut(|d| d.remove_temp::(hover_id)); + let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; + if !pointer_over(ui, resp.rect) { + if ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } return false; } let now = ui.input(|i| i.time); @@ -788,7 +793,7 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp if can_drop_album(app, dragged, Some(folder.id)) { let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { + if ui.input(|i| i.pointer.primary_released()) { drop_album(app, ui, dragged, Some(folder.id)); } } @@ -805,11 +810,11 @@ fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); register(ui.ctx(), "albumDrop:top", r); - let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| r.contains(p)); + let over = pointer_over(ui, r); let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; ui.painter().rect_stroke(r.shrink2(vec2(8.0, 1.0)), 4.0, stroke, egui::StrokeKind::Inside); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Top Level"), t.font(13.5), t.text_dim); - if over && ui.input(|i| i.pointer.any_released()) { + if over && ui.input(|i| i.pointer.primary_released()) { drop_album(app, ui, dragged, None); } } @@ -818,9 +823,17 @@ fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// button is up (the drop targets act on the release frame, before this runs). pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(id) = app.ui.dragging_album else { return }; - let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); - if released || !down { + let (released, down, pos, esc) = + ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if released || !down || esc { app.ui.dragging_album = None; + // forget how long the pointer rested on each folder + let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); + ctx.data_mut(|d| { + for id in ids { + d.remove_temp::(egui::Id::new(("album-hover", id))); + } + }); return; } let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index ba67867c..c9ee05ca 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -396,6 +396,10 @@ impl AlbumTree { } /// Press at `from`, move through each `(point, frames held there)`, release at the last one. fn drag(&mut self, from: egui::Pos2, path: &[(egui::Pos2, usize)]) { + self.drag_with(from, path, vec![]); + } + /// [`Self::drag`] with `before_release` events sent just before the button comes up. + fn drag_with(&mut self, from: egui::Pos2, path: &[(egui::Pos2, usize)], before_release: Vec) { let mut ev = |e| self.h.app.synthetic.push(e); ev(egui::Event::PointerMoved(from)); ev(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); @@ -410,6 +414,9 @@ impl AlbumTree { } at = *to; } + for e in before_release { + ev(e); + } ev(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); for _ in 0..400 { if self.h.app.synthetic.is_empty() { @@ -454,6 +461,28 @@ fn dragging_an_album_onto_a_folder_moves_it() { assert_eq!(t.h.app.session.source, lightcraft_engine::LibrarySource::Album(lightcraft_catalog::AlbumId(t.best))); } +/// A drag ends only with the main button, and Esc abandons it: a release of another button over a +/// folder does not drop there, and Esc then release drops nothing. +#[test] +fn an_album_drag_ignores_other_buttons_and_esc_cancels_it() { + let mut t = album_tree(); + let (from, to) = (t.row(t.loose).center(), t.row(t.archive).center()); + let m = egui::Modifiers::default(); + let secondary = |pressed| egui::Event::PointerButton { pos: to, button: egui::PointerButton::Secondary, pressed, modifiers: m }; + // a right click over the folder mid-drag; the drag then ends elsewhere (on the Loose row itself) + t.drag_with(from, &[(to, 3), (from, 2)], vec![secondary(true), secondary(false)]); + assert_eq!(t.parent(t.loose), None, "the secondary release is not a drop"); + assert!(t.h.app.ui.dragging_album.is_none()); + let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: m }; + let (from, to) = (t.row(t.loose).center(), t.row(t.archive).center()); + t.drag_with(from, &[(to, 3)], vec![esc(true), esc(false)]); + assert_eq!(t.parent(t.loose), None, "Esc abandons the drag, the release drops nothing"); + assert!(t.h.app.ui.dragging_album.is_none()); + // and a drag that is left alone still drops + t.drag_row(t.loose, t.archive); + assert_eq!(t.parent(t.loose), Some(t.archive)); +} + /// Dragging out of a folder: the drop zone under the tree puts the album back at the top level; /// it only shows when there is somewhere to go, and the move is one undo step. #[test] From 39f5f114de066cdc41ea61fc6fe584d3d9bbe80f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:30:52 -0400 Subject: [PATCH 10/19] Catalog: albums can be ordered by hand (format version 3) Album.order and Op::SetAlbumOrder; album_children lists folders first, then by place, then by name. Moving an album drops its place and undo restores it. Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/journal.rs | 3 +- crates/catalog/src/lib.rs | 34 ++++++- crates/catalog/src/local.rs | 1 + crates/catalog/src/model.rs | 6 +- crates/catalog/src/tests.rs | 14 ++- crates/catalog/src/tests_album_order.rs | 102 +++++++++++++++++++++ crates/catalog/src/tests_format_version.rs | 16 +++- crates/catalog/src/tests_journal.rs | 2 + crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/demo.rs | 16 +++- 10 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 crates/catalog/src/tests_album_order.rs diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 96187cfa..5973975d 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -45,6 +45,7 @@ //! | (none) | early builds: a log without a snapshot | | //! | 1 | up to v0.2.0 | | //! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | +//! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -69,7 +70,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 2; +pub const VERSION: u32 = 3; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 2934c7a6..8a19b6e2 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -136,6 +136,11 @@ pub enum Op { id: AlbumId, parent: Option, }, + /// Where the album stands among its siblings (`None`: by name). Format version 3. + SetAlbumOrder { + id: AlbumId, + order: Option, + }, SetAlbumPhotos { id: AlbumId, photos: Vec, @@ -282,6 +287,18 @@ impl Catalog { pub fn albums(&self) -> impl Iterator { self.albums.values() } + /// The albums and folders directly inside `parent` (`None`: the top level), in the order the + /// sidebar lists them: folders first, then by their place if the user ordered them (those + /// without one after, by name), else by name. + pub fn album_children(&self, parent: Option) -> Vec<&Album> { + let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect(); + kids.sort_by_cached_key(|a| (!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)); + kids + } + /// Whether anything inside `parent` has a place of its own (the folder is ordered by hand). + pub fn album_children_are_ordered(&self, parent: Option) -> bool { + self.albums.values().any(|a| a.parent == parent && a.order.is_some()) + } /// The Quick Collection, once something was added to it. pub fn quick_collection(&self) -> Option { self.albums.values().find(|a| a.quick).map(|a| a.id) @@ -479,7 +496,20 @@ impl Catalog { } } let a = self.album_mut(id)?; - Op::MoveAlbum { id, parent: std::mem::replace(&mut a.parent, parent) } + if a.parent == parent { + return Ok(Op::MoveAlbum { id, parent }); + } + // its place belonged to the old neighbours: the undo gives it back with the folder + let old_order = a.order.take(); + let old_parent = std::mem::replace(&mut a.parent, parent); + match old_order { + Some(_) => Op::Batch { ops: vec![Op::MoveAlbum { id, parent: old_parent }, Op::SetAlbumOrder { id, order: old_order }] }, + None => Op::MoveAlbum { id, parent: old_parent }, + } + } + Op::SetAlbumOrder { id, order } => { + let a = self.album_mut(id)?; + Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) } } Op::SetAlbumPhotos { id, photos } => { let a = self.album_mut(id)?; @@ -671,6 +701,8 @@ impl Catalog { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_album_order; +#[cfg(test)] mod tests_background; #[cfg(test)] mod tests_folders; diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 79a4a42d..4291f45f 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -295,6 +295,7 @@ impl Op { Op::RemoveAlbum { .. } | Op::RenameAlbum { .. } | Op::MoveAlbum { .. } + | Op::SetAlbumOrder { .. } | Op::SetAlbumRules { .. } | Op::RemoveStack { .. } | Op::SetLabelName { .. } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index ae53b486..957f5b68 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -405,12 +405,16 @@ pub struct Album { /// The Quick Collection: a regular album that B adds to (one per library). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub quick: bool, + /// Its place among the albums of the same kind in its folder, once the user ordered them by + /// hand (see [`crate::Catalog::album_children`]); `None`: listed by name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub order: Option, } impl Album { /// A regular (manual) album. pub fn new(id: AlbumId, name: impl Into) -> Album { - Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false } + Album { id, name: name.into(), parent: None, folder: false, photos: Vec::new(), cover: None, smart: None, quick: false, order: None } } pub fn is_smart(&self) -> bool { self.smart.is_some() diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index 49f0ac4d..8d34d5ef 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -58,12 +58,22 @@ fn albums_and_folders() { let a = photo(&mut c, "a.jpg", "2026-04-01"); let f = c.alloc_album_id(); c.apply(Op::AddAlbum { - album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false }, + album: Album { id: f, name: "Trips".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false, order: None }, }) .unwrap(); let al = c.alloc_album_id(); c.apply(Op::AddAlbum { - album: Album { id: al, name: "Alps".into(), parent: Some(f), folder: false, photos: vec![a], cover: None, smart: None, quick: false }, + album: Album { + id: al, + name: "Alps".into(), + parent: Some(f), + folder: false, + photos: vec![a], + cover: None, + smart: None, + quick: false, + order: None, + }, }) .unwrap(); assert_eq!(c.albums_of(a), vec![al]); diff --git a/crates/catalog/src/tests_album_order.rs b/crates/catalog/src/tests_album_order.rs new file mode 100644 index 00000000..dfd463f9 --- /dev/null +++ b/crates/catalog/src/tests_album_order.rs @@ -0,0 +1,102 @@ +//! Album order: the children of a folder (or of the top level) are listed folders first, then +//! alphabetically, until some of them are given a place of their own (`Album.order`); moving an +//! album to another folder drops its place, and undoing the move brings it back. + +use crate::*; + +fn add(c: &mut Catalog, name: &str, parent: Option, folder: bool) -> AlbumId { + let id = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album { parent, folder, ..Album::new(id, name) } }).unwrap(); + id +} + +fn names(c: &Catalog, parent: Option) -> Vec { + c.album_children(parent).iter().map(|a| a.name.clone()).collect() +} + +fn place(c: &mut Catalog, id: AlbumId, order: Option) { + c.apply(Op::SetAlbumOrder { id, order }).unwrap(); +} + +/// Given albums that were never ordered, when they are listed, then folders come first and each +/// group is alphabetical regardless of case. +#[test] +fn children_are_folders_first_then_alphabetical() { + let mut c = Catalog::new(); + add(&mut c, "beta", None, false); + add(&mut c, "Alpha", None, false); + add(&mut c, "zoo", None, true); + add(&mut c, "Archive", None, true); + assert_eq!(names(&c, None), ["Archive", "zoo", "Alpha", "beta"]); + assert!(!c.album_children_are_ordered(None)); +} + +/// Given some children with a place of their own, then they follow it; the others come after, by +/// name; folders stay ahead of albums. +#[test] +fn ordered_children_follow_their_order() { + let mut c = Catalog::new(); + let a = add(&mut c, "A", None, false); + let b = add(&mut c, "B", None, false); + let cc = add(&mut c, "C", None, false); + add(&mut c, "D", None, false); + let f = add(&mut c, "F", None, true); + place(&mut c, cc, Some(0)); + place(&mut c, a, Some(1)); + assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]); + assert!(c.album_children_are_ordered(None)); + // equal places fall back to the name, so a damaged catalog still lists deterministically + place(&mut c, b, Some(1)); + assert_eq!(names(&c, None), ["F", "C", "A", "B", "D"]); + // another container is unaffected + let inner = add(&mut c, "Z", Some(f), false); + add(&mut c, "Y", Some(f), false); + assert_eq!(names(&c, Some(f)), ["Y", "Z"]); + assert!(!c.album_children_are_ordered(Some(f))); + place(&mut c, inner, Some(0)); + assert_eq!(names(&c, Some(f)), ["Z", "Y"]); +} + +/// A place is one undo step, and `None` puts the album back among the alphabetical ones. +#[test] +fn a_place_can_be_set_cleared_and_undone() { + let mut c = Catalog::new(); + let a = add(&mut c, "A", None, false); + add(&mut c, "B", None, false); + let inv = c.apply(Op::SetAlbumOrder { id: a, order: Some(5) }).unwrap(); + assert_eq!(c.album(a).unwrap().order, Some(5)); + c.apply(inv).unwrap(); + assert_eq!(c.album(a).unwrap().order, None); + assert!(c.apply(Op::SetAlbumOrder { id: AlbumId(999), order: Some(1) }).is_err(), "no such album"); +} + +/// Moving to another folder drops the album's place there (it belonged to the old neighbours), +/// and undoing the move gives it back. +#[test] +fn moving_drops_the_place_and_undo_restores_it() { + let mut c = Catalog::new(); + let from = add(&mut c, "From", None, true); + let to = add(&mut c, "To", None, true); + let a = add(&mut c, "A", Some(from), false); + place(&mut c, a, Some(3)); + let inv = c.apply(Op::MoveAlbum { id: a, parent: Some(to) }).unwrap(); + assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(to), None)); + c.apply(inv).unwrap(); + assert_eq!((c.album(a).unwrap().parent, c.album(a).unwrap().order), (Some(from), Some(3))); + // "moving" to where it already is changes nothing + c.apply(Op::MoveAlbum { id: a, parent: Some(from) }).unwrap(); + assert_eq!(c.album(a).unwrap().order, Some(3)); +} + +/// Catalogs written before albums had an order load as they were; an unset order is not written. +#[test] +fn albums_without_an_order_load_and_save_unchanged() { + let old = r#"{"id":4,"name":"Old","parent":null,"folder":false,"photos":[],"cover":null}"#; + let a: Album = serde_json::from_str(old).unwrap(); + assert_eq!(a.order, None); + assert!(!serde_json::to_string(&a).unwrap().contains("order")); + let mut a = a; + a.order = Some(2); + let back: Album = serde_json::from_str(&serde_json::to_string(&a).unwrap()).unwrap(); + assert_eq!(back.order, Some(2)); +} diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 3f2e8816..cb80ce67 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -47,6 +47,19 @@ fn v1_library_loads_and_is_upgraded() { assert_eq!((r.replayed, r.upgraded_from), (1, None)); } +/// A library saved before albums could be ordered by hand opens as it was and is rewritten as v3. +#[test] +fn v2_library_loads_and_is_upgraded() { + let (base, log, full) = legacy_parts(); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":2,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes()); + m.set(LOG, log.into_bytes()); + let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert_eq!(c.to_snapshot(), full.to_snapshot()); + assert_eq!((r.replayed, r.upgraded_from), (1, Some(2))); + assert_eq!(snapshot_version(&m), 3); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -179,8 +192,9 @@ fn op_variants_are_versioned() { | Op::SetLabelName { .. } | Op::Batch { .. } => 1, Op::SetBrowsed { .. } => 2, + Op::SetAlbumOrder { .. } => 3, } } - let newest = since(&Op::SetBrowsed { folder: String::new(), at: None }); + let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 0c9513e3..0594b6d8 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -59,6 +59,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { cover: None, smart: None, quick: false, + order: None, }, } } @@ -77,6 +78,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), 9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) }, + 9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) }, 9 if b.is_multiple_of(2) => { Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } } } diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 436ad123..4ff64eb3 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -784,7 +784,7 @@ pub fn specs() -> Vec { let cover = photos.first().copied(); s.commit( if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false } }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, )?; Ok(json!({"id": id.0})) }), diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 2f392cb7..f9d8b7c9 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -62,7 +62,17 @@ pub fn load(s: &mut Session) { } let folder = s.catalog.alloc_album_id(); ops.push(Op::AddAlbum { - album: Album { id: folder, name: "Travel 2026".into(), parent: None, folder: true, photos: vec![], cover: None, smart: None, quick: false }, + album: Album { + id: folder, + name: "Travel 2026".into(), + parent: None, + folder: true, + photos: vec![], + cover: None, + smart: None, + quick: false, + order: None, + }, }); let by_kw = |kw: &str| -> Vec<_> { scenes.iter().zip(&ids).filter(|(sc, _)| sc.meta.keywords.contains(&kw)).map(|(_, id)| *id).collect() }; for (name, parent, photos) in [ @@ -75,7 +85,9 @@ pub fn load(s: &mut Session) { ] { let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); - ops.push(Op::AddAlbum { album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false } }); + ops.push(Op::AddAlbum { + album: Album { id, name: name.into(), parent, folder: false, photos, cover, smart: None, quick: false, order: None }, + }); } for op in ops { let _ = s.catalog.apply(op); From 2c5cb708bb50c26793ea9a83b5818a55254a307a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:31:23 -0400 Subject: [PATCH 11/19] Engine: album.reorder and album.sort Place an album or folder before a sibling of its kind (also in another folder), or last of its kind, in one undo step; album.sort goes back to by name for one folder. Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/library.rs | 78 +++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_album_order.rs | 132 +++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 crates/engine/src/tests_album_order.rs diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 4ff64eb3..11f66589 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -119,6 +119,14 @@ fn album_param(p: &Value, key: &str, c: &str) -> Result { p.get(key).and_then(Value::as_u64).map(AlbumId).ok_or_else(|| bad(c, format!("missing album `{key}`"))) } +/// An optional album id: absent or `null` is `None`, anything but a number is an error. +fn opt_album_param(p: &Value, key: &str, c: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(|n| Some(AlbumId(n))).ok_or_else(|| bad(c, format!("`{key}` must be an album id or null"))), + } +} + fn strs(p: &Value, key: &str) -> Vec { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default() } @@ -880,6 +888,76 @@ pub fn specs() -> Vec { s.commit("Move Album", Op::MoveAlbum { id, parent })?; ok() }), + cmd!( + "album.reorder", + "Reorder Album", + [], + None, + "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}", + always, + |s, p| { + let id = album_param(p, "id", "album.reorder")?; + let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; + let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; + let before = opt_album_param(p, "before", "album.reorder")?; + let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); + let at = match before { + Some(b) => { + let at = order.iter().position(|x| *x == b).ok_or_else(|| bad("album.reorder", "`before` is not inside that folder"))?; + if s.catalog.album(b).is_none_or(|x| x.folder != me.folder) { + return Err(bad("album.reorder", "folders are placed among folders and albums among albums")); + } + at + } + None if me.folder => order.iter().take_while(|x| s.catalog.album(**x).is_some_and(|a| a.folder)).count(), + None => order.len(), + }; + order.insert(at.min(order.len()), id); + let moved = me.parent != parent; + let mut ops = Vec::new(); + if moved { + ops.push(Op::MoveAlbum { id, parent }); + } + for (i, aid) in order.iter().enumerate() { + let want = u32::try_from(i).ok(); + // a moved album starts without a place in its new folder + let have = if moved && *aid == id { None } else { s.catalog.album(*aid).and_then(|a| a.order) }; + if have != want { + ops.push(Op::SetAlbumOrder { id: *aid, order: want }); + } + } + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Reorder Album", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), + cmd!( + "album.sort", + "Sort Albums by Name", + [], + None, + "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}", + always, + |s, p| { + let parent = opt_album_param(p, "parent", "album.sort")?; + let ops: Vec = s + .catalog + .album_children(parent) + .into_iter() + .filter(|a| a.order.is_some()) + .map(|a| Op::SetAlbumOrder { id: a.id, order: None }) + .collect(); + if ops.is_empty() { + return Ok(json!({"changed": 0})); + } + let changed = ops.len(); + s.commit("Sort Albums by Name", Op::Batch { ops })?; + Ok(json!({"changed": changed})) + } + ), cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f26b4ca7..0ecc431e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -781,6 +781,8 @@ pub fn json_delta(old: &Value, new: &Value) -> Option { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_album_order; +#[cfg(test)] mod tests_color; #[cfg(test)] mod tests_export; diff --git a/crates/engine/src/tests_album_order.rs b/crates/engine/src/tests_album_order.rs new file mode 100644 index 00000000..324c0db5 --- /dev/null +++ b/crates/engine/src/tests_album_order.rs @@ -0,0 +1,132 @@ +//! Ordering albums by hand: `album.reorder` puts an album (or folder) before a sibling, or last of +//! its kind, in one undo step, also when it changes folder; `album.sort` goes back to by name. + +use lightcraft_catalog::AlbumId; +use serde_json::{Value, json}; + +use crate::Session; + +fn make(s: &mut Session, name: &str, parent: Option, folder: bool) -> u64 { + let mut p = json!({"name": name, "folder": folder}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + s.execute("album.create", &p).unwrap()["id"].as_u64().unwrap() +} + +fn names(s: &Session, parent: Option) -> Vec { + s.catalog.album_children(parent.map(AlbumId)).iter().map(|a| a.name.clone()).collect() +} + +fn reorder(s: &mut Session, id: u64, params: Value) -> Result { + let mut p = params; + p["id"] = json!(id); + s.execute("album.reorder", &p).map_err(|e| e.to_string()) +} + +/// Given three albums listed by name, when one is placed before another, then the list follows; +/// "no sibling" puts it last (among albums). +#[test] +fn an_album_is_placed_before_a_sibling_or_last() { + let mut s = Session::new(); + let (a, b, c) = (make(&mut s, "A", None, false), make(&mut s, "B", None, false), make(&mut s, "C", None, false)); + reorder(&mut s, c, json!({"before": a})).unwrap(); + assert_eq!(names(&s, None), ["C", "A", "B"]); + assert!(s.catalog.album_children_are_ordered(None)); + reorder(&mut s, c, json!({"before": null})).unwrap(); + assert_eq!(names(&s, None), ["A", "B", "C"]); + reorder(&mut s, a, json!({})).unwrap(); + assert_eq!(names(&s, None), ["B", "C", "A"], "no `before`: last"); + reorder(&mut s, b, json!({"before": b})).expect_err("not before itself"); + let _ = (a, b); +} + +/// Folders stay ahead of albums: a folder is placed among folders, an album among albums. +#[test] +fn folders_and_albums_keep_to_their_own_group() { + let mut s = Session::new(); + let (f1, f2) = (make(&mut s, "F1", None, true), make(&mut s, "F2", None, true)); + let (a1, a2) = (make(&mut s, "A1", None, false), make(&mut s, "A2", None, false)); + reorder(&mut s, f2, json!({"before": f1})).unwrap(); + reorder(&mut s, a2, json!({"before": a1})).unwrap(); + assert_eq!(names(&s, None), ["F2", "F1", "A2", "A1"]); + reorder(&mut s, f1, json!({"before": a1})).expect_err("a folder is not placed among albums"); + reorder(&mut s, a1, json!({"before": f1})).expect_err("an album is not placed among folders"); + reorder(&mut s, f2, json!({"before": null})).unwrap(); + assert_eq!(names(&s, None), ["F1", "F2", "A2", "A1"], "last of the folders"); +} + +/// Placing an album in another folder moves it there, and one undo undoes both. +#[test] +fn placing_across_folders_moves_it_and_undoes_in_one_step() { + let mut s = Session::new(); + let folder = make(&mut s, "Trips", None, true); + let (x, y) = (make(&mut s, "X", Some(folder), false), make(&mut s, "Y", Some(folder), false)); + let loose = make(&mut s, "Loose", None, false); + reorder(&mut s, y, json!({"before": x})).unwrap(); + assert_eq!(names(&s, Some(folder)), ["Y", "X"]); + reorder(&mut s, loose, json!({"parent": folder, "before": x})).unwrap(); + assert_eq!(names(&s, Some(folder)), ["Y", "Loose", "X"]); + assert_eq!(s.catalog.album(AlbumId(loose)).unwrap().parent, Some(AlbumId(folder))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s, Some(folder)), ["Y", "X"]); + assert_eq!(s.catalog.album(AlbumId(loose)).unwrap().parent, None); + assert_eq!(s.catalog.album(AlbumId(loose)).unwrap().order, None); +} + +/// Bad requests change nothing: a `before` that is not in the target folder, a target that is not +/// a folder or lies inside the album, an unknown album. +#[test] +fn bad_placements_are_refused_and_change_nothing() { + let mut s = Session::new(); + let f = make(&mut s, "F", None, true); + let inner = make(&mut s, "Inner", Some(f), true); + let (a, b) = (make(&mut s, "A", None, false), make(&mut s, "B", Some(f), false)); + let before = s.catalog.to_snapshot(); + reorder(&mut s, a, json!({"before": b})).expect_err("B is in F, not at the top level"); + reorder(&mut s, a, json!({"parent": b})).expect_err("a plain album is no folder"); + reorder(&mut s, f, json!({"parent": inner})).expect_err("a folder into itself"); + reorder(&mut s, 987_654, json!({})).expect_err("no such album"); + reorder(&mut s, a, json!({"before": 987_654})).expect_err("no such sibling"); + assert_eq!(s.catalog.to_snapshot(), before, "nothing changed"); +} + +/// `album.sort` drops the hand order of one folder (and only that one): by name again. +#[test] +fn sorting_goes_back_to_by_name_for_one_folder() { + let mut s = Session::new(); + let f = make(&mut s, "F", None, true); + let (x, y) = (make(&mut s, "X", Some(f), false), make(&mut s, "Y", Some(f), false)); + let (a, b) = (make(&mut s, "A", None, false), make(&mut s, "B", None, false)); + reorder(&mut s, y, json!({"before": x})).unwrap(); + reorder(&mut s, b, json!({"before": a})).unwrap(); + assert_eq!(names(&s, Some(f)), ["Y", "X"]); + assert_eq!(names(&s, None), ["F", "B", "A"]); + let r = s.execute("album.sort", &json!({"parent": f})).unwrap(); + assert_eq!(r["changed"], 2); + assert_eq!(names(&s, Some(f)), ["X", "Y"]); + assert_eq!(names(&s, None), ["F", "B", "A"], "the top level keeps its order"); + assert!(!s.catalog.album_children_are_ordered(Some(AlbumId(f)))); + // nothing to do: no undo step is made + let r = s.execute("album.sort", &json!({"parent": f})).unwrap(); + assert_eq!(r["changed"], 0); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s, Some(f)), ["Y", "X"], "the one undo step was the sort"); + let r = s.execute("album.sort", &json!({})).unwrap(); + assert_eq!(r["changed"], 3, "every child of the top level had a place: F, B and A"); + assert_eq!(names(&s, None), ["F", "A", "B"]); +} + +/// Moving an album with `album.move` out of an ordered folder leaves the old neighbours' order. +#[test] +fn moving_out_of_an_ordered_folder_keeps_the_others_in_place() { + let mut s = Session::new(); + let f = make(&mut s, "F", None, true); + let (x, y, z) = (make(&mut s, "X", Some(f), false), make(&mut s, "Y", Some(f), false), make(&mut s, "Z", Some(f), false)); + reorder(&mut s, z, json!({"before": x})).unwrap(); + assert_eq!(names(&s, Some(f)), ["Z", "X", "Y"]); + s.execute("album.move", &json!({"id": x, "parent": null})).unwrap(); + assert_eq!(names(&s, Some(f)), ["Z", "Y"]); + assert_eq!(names(&s, None), ["F", "X"]); + let _ = y; +} From 34a4b61f7ed1b66a85cb656d4d4cbf705f7297e4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:33:36 -0400 Subject: [PATCH 12/19] Albums: one-pass children map, before-itself is a no-op, more order tests Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/lib.rs | 18 +++++++++++++- crates/catalog/src/tests_album_order.rs | 20 ++++++++++++++++ crates/engine/src/cmd/library.rs | 8 +++++-- crates/engine/src/tests_album_order.rs | 31 +++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 8a19b6e2..884ec658 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -292,9 +292,19 @@ impl Catalog { /// without one after, by name), else by name. pub fn album_children(&self, parent: Option) -> Vec<&Album> { let mut kids: Vec<&Album> = self.albums.values().filter(|a| a.parent == parent).collect(); - kids.sort_by_cached_key(|a| (!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id)); + kids.sort_by_cached_key(|a| album_order_key(a)); kids } + /// [`Self::album_children`] of every folder (and of the top level, under `None`) in one pass: + /// what a tree view that draws all of them wants each frame. + pub fn album_children_by_parent(&self) -> std::collections::HashMap, Vec<&Album>> { + let mut map: std::collections::HashMap, Vec<&Album>> = std::collections::HashMap::new(); + for a in self.albums.values() { + map.entry(a.parent).or_default().push(a); + } + map.values_mut().for_each(|kids| kids.sort_by_cached_key(|a| album_order_key(a))); + map + } /// Whether anything inside `parent` has a place of its own (the folder is ordered by hand). pub fn album_children_are_ordered(&self, parent: Option) -> bool { self.albums.values().any(|a| a.parent == parent && a.order.is_some()) @@ -698,6 +708,12 @@ impl Catalog { } } +/// How siblings are listed: folders first, then the ones with a place of their own by it, then by +/// name; the id settles any tie. +fn album_order_key(a: &Album) -> (bool, bool, u32, String, AlbumId) { + (!a.folder, a.order.is_none(), a.order.unwrap_or(0), a.name.to_lowercase(), a.id) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/catalog/src/tests_album_order.rs b/crates/catalog/src/tests_album_order.rs index dfd463f9..a6fb597b 100644 --- a/crates/catalog/src/tests_album_order.rs +++ b/crates/catalog/src/tests_album_order.rs @@ -57,6 +57,26 @@ fn ordered_children_follow_their_order() { assert_eq!(names(&c, Some(f)), ["Z", "Y"]); } +/// The one-pass map lists every folder's children exactly as `album_children` does. +#[test] +fn the_children_map_matches_the_children_of_each_folder() { + let mut c = Catalog::new(); + let f = add(&mut c, "F", None, true); + let g = add(&mut c, "G", Some(f), true); + for (name, parent) in [("b", None), ("a", None), ("y", Some(f)), ("x", Some(f)), ("q", Some(g))] { + add(&mut c, name, parent, false); + } + let x = c.albums().find(|a| a.name == "x").unwrap().id; + place(&mut c, x, Some(7)); + let map = c.album_children_by_parent(); + for parent in [None, Some(f), Some(g)] { + let from_map: Vec = map.get(&parent).map(|v| v.iter().map(|a| a.id).collect()).unwrap_or_default(); + let direct: Vec = c.album_children(parent).iter().map(|a| a.id).collect(); + assert_eq!(from_map, direct, "{parent:?}"); + } + assert!(map.get(&Some(AlbumId(999))).is_none()); +} + /// A place is one undo step, and `None` puts the album back among the alphabetical ones. #[test] fn a_place_can_be_set_cleared_and_undone() { diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 11f66589..d900eacf 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -893,13 +893,17 @@ pub fn specs() -> Vec { "Reorder Album", [], None, - "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}", + "{id, parent?: folderId|null, before?: albumId|null} — places the album (or folder) in `parent` (default: where it is; null: the top level) before the sibling `before` of the same kind, or last of its kind; the folder is then ordered by hand. One undo step. → {changed}: how many edits it took (0: already there)", always, |s, p| { let id = album_param(p, "id", "album.reorder")?; let me = s.catalog.album(id).ok_or_else(|| bad("album.reorder", "no such album"))?; let parent = if p.get("parent").is_some() { opt_album_param(p, "parent", "album.reorder")? } else { me.parent }; let before = opt_album_param(p, "before", "album.reorder")?; + if before == Some(id) { + // before itself: where it is + return Ok(json!({"changed": 0})); + } let mut order: Vec = s.catalog.album_children(parent).into_iter().filter(|a| a.id != id).map(|a| a.id).collect(); let at = match before { Some(b) => { @@ -939,7 +943,7 @@ pub fn specs() -> Vec { "Sort Albums by Name", [], None, - "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}", + "{parent?: folderId|null} — drops the hand order of the albums inside the folder (default: the top level), so they are listed by name → {changed}: how many albums lost their place", always, |s, p| { let parent = opt_album_param(p, "parent", "album.sort")?; diff --git a/crates/engine/src/tests_album_order.rs b/crates/engine/src/tests_album_order.rs index 324c0db5..cff524cb 100644 --- a/crates/engine/src/tests_album_order.rs +++ b/crates/engine/src/tests_album_order.rs @@ -37,8 +37,8 @@ fn an_album_is_placed_before_a_sibling_or_last() { assert_eq!(names(&s, None), ["A", "B", "C"]); reorder(&mut s, a, json!({})).unwrap(); assert_eq!(names(&s, None), ["B", "C", "A"], "no `before`: last"); - reorder(&mut s, b, json!({"before": b})).expect_err("not before itself"); - let _ = (a, b); + assert_eq!(reorder(&mut s, b, json!({"before": b})).unwrap()["changed"], 0, "before itself is where it is"); + assert_eq!(names(&s, None), ["B", "C", "A"]); } /// Folders stay ahead of albums: a folder is placed among folders, an album among albums. @@ -74,6 +74,33 @@ fn placing_across_folders_moves_it_and_undoes_in_one_step() { assert_eq!(s.catalog.album(AlbumId(loose)).unwrap().order, None); } +/// Undo and redo of placing across folders, of sorting, and of moving a folder that has children. +#[test] +fn placing_and_sorting_undo_and_redo() { + let mut s = Session::new(); + let top = make(&mut s, "Top", None, true); + let child = make(&mut s, "Child", Some(top), false); + let (a, b) = (make(&mut s, "A", None, false), make(&mut s, "B", None, false)); + reorder(&mut s, b, json!({"before": a})).unwrap(); + // a folder with children, placed inside another folder + let other = make(&mut s, "Other", None, true); + reorder(&mut s, top, json!({"parent": other})).unwrap(); + assert_eq!(names(&s, Some(other)), ["Top"]); + assert_eq!(names(&s, Some(top)), ["Child"], "its children came along"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.album(AlbumId(top)).unwrap().parent, None); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.catalog.album(AlbumId(top)).unwrap().parent, Some(AlbumId(other))); + // sort, undo, redo + s.execute("album.sort", &json!({})).unwrap(); + assert_eq!(names(&s, None), ["Other", "A", "B"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&s, None), ["Other", "B", "A"], "the hand order is back"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(names(&s, None), ["Other", "A", "B"]); + assert_eq!(s.catalog.album(AlbumId(child)).unwrap().parent, Some(AlbumId(top))); +} + /// Bad requests change nothing: a `before` that is not in the target folder, a target that is not /// a folder or lies inside the album, an unknown album. #[test] From 8036f98acac1447664509a1ef3af2f0b1d966d3a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:43:30 -0400 Subject: [PATCH 13/19] Catalog: tidy a test assertion Co-Authored-By: Claude Sonnet 5.5 --- crates/catalog/src/tests_album_order.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/catalog/src/tests_album_order.rs b/crates/catalog/src/tests_album_order.rs index a6fb597b..112633f4 100644 --- a/crates/catalog/src/tests_album_order.rs +++ b/crates/catalog/src/tests_album_order.rs @@ -74,7 +74,7 @@ fn the_children_map_matches_the_children_of_each_folder() { let direct: Vec = c.album_children(parent).iter().map(|a| a.id).collect(); assert_eq!(from_map, direct, "{parent:?}"); } - assert!(map.get(&Some(AlbumId(999))).is_none()); + assert!(!map.contains_key(&Some(AlbumId(999)))); } /// A place is one undo step, and `None` puts the album back among the alphabetical ones. From 085c25b10b56341f278bdc44ca289e49a8d1b15d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:43:31 -0400 Subject: [PATCH 14/19] =?UTF-8?q?Albums:=20drop=20beside=20a=20row=20to=20?= =?UTF-8?q?place=20it=20by=20hand;=20Sort=20A=E2=80=93Z=20goes=20back=20to?= =?UTF-8?q?=20by=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping on the edge of a row places the album there (album.reorder) with a line showing where; a folder's middle still means inside. A closed folder opens only when the drop would go inside it. Sort Contents A–Z and Sort Albums A–Z (album.sort) appear once a folder is ordered by hand. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 174 +++++++++++++++++++++++------ crates/ui-egui/src/tests_panels.rs | 121 +++++++++++++++++++- 2 files changed, 258 insertions(+), 37 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 065d1cfc..64b3a98b 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -234,18 +234,27 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { 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 albums: Vec = app.session.catalog.albums().cloned().collect(); + 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| albums.iter().find(|a| a.id == id)).and_then(|a| a.parent); + 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 = albums.iter().find(|a| a.id == p).and_then(|a| a.parent); + cur = cat.album(p).and_then(|a| a.parent); } - albums_tree(app, ui, &albums, None, 0.0, &open_to); + albums_tree(app, ui, &kids, None, 0.0, &open_to); top_level_drop_zone(app, ui); } ui.add_space(10.0); @@ -670,10 +679,12 @@ fn browse_all_photos(app: &mut LightcraftApp, on: bool) { } } +/// The albums inside each folder (`None`: the top level), in the order they are listed. +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: &[Album], parent: Option, indent: f32, open_to: &[AlbumId]) { - let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect(); - kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase())); +fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &AlbumKids, parent: Option, indent: f32, open_to: &[AlbumId]) { + let Some(kids) = all.get(&parent) else { return }; for a in kids { if a.folder { let open_id = egui::Id::new(("folder-open", a.id.0)); @@ -684,11 +695,11 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent } let resp = row_sensed(app, ui, &format!("folder:{}", a.id.0), Icon::Folder, &a.name, None, None, false, indent, Sense::click_and_drag()); // a folder is no source, so its row folds it too; the triangle is the same click, aimed - let has_children = all.iter().any(|c| c.parent == Some(a.id)); + let has_children = all.get(&Some(a.id)).is_some_and(|v| !v.is_empty()); if resp.drag_started() { app.ui.dragging_album = Some(a.id.0); } - if album_drag_over(app, ui, &resp, a, has_children.then_some(&mut open)) { + 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)); } let mut toggled = resp.clicked(); @@ -719,6 +730,7 @@ fn albums_tree(app: &mut LightcraftApp, ui: &mut egui::Ui, all: &[Album], parent if resp.drag_started() { app.ui.dragging_album = Some(a.id.0); } + album_drag_over(app, ui, &resp, a, None, indent); if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -752,10 +764,10 @@ fn can_drop_album(app: &LightcraftApp, dragged: AlbumId, target: Option } } -/// Moves the dragged album (`album.move`, one undo step) and ends the drag; the folders down to it -/// open so it shows in its new place. -fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { - match app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})) { +/// Ends a drag that ran `result`: the folders down to the album open so it shows in its new place. +fn finish_drop(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, result: Result) { + forget_hover(app, ui.ctx()); + match result { Ok(_) => { app.ui.reveal_album = Some(dragged.0); ui.ctx().request_repaint(); @@ -765,24 +777,103 @@ fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: app.ui.dragging_album = None; } -/// A folder row while an album is dragged: outlined under the pointer when the drop is allowed, -/// opened (`open`, when it has something inside) once the pointer rests on it, and the album moved -/// in on release. Returns whether it opened the folder. -fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, folder: &Album, open: Option<&mut bool>) -> bool { - let hover_id = egui::Id::new(("album-hover", folder.id.0)); +/// Forgets how long the pointer rested on each folder (a new drag starts counting again). +fn forget_hover(app: &LightcraftApp, ctx: &egui::Context) { + let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); + ctx.data_mut(|d| { + for id in ids { + d.remove_temp::(egui::Id::new(("album-hover", id))); + } + }); +} + +/// Moves the dragged album into a folder (`album.move`, one undo step; `None`: the top level). +fn drop_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, target: Option) { + let r = app.run("album.move", json!({"id": dragged.0, "parent": target.map(|t| t.0)})); + finish_drop(app, ui, dragged, r); +} + +/// Places the dragged album in `parent` before the sibling `before` (`None`: last of its kind), by +/// hand (`album.reorder`, one undo step). +fn place_album(app: &mut LightcraftApp, ui: &egui::Ui, dragged: AlbumId, parent: Option, before: Option) { + let r = app.run("album.reorder", json!({"id": dragged.0, "parent": parent.map(|p| p.0), "before": before.map(|b| b.0)})); + finish_drop(app, ui, dragged, r); +} + +/// What dropping the dragged album on a row would do. +#[derive(Clone, Copy, Debug, PartialEq)] +enum AlbumDrop { + /// Into the folder. + Into(AlbumId), + /// Beside the row: in `parent`, before `before` (`None`: last of its kind); the line is drawn + /// above the row (`above`) or below it. + Place { parent: Option, before: Option, above: bool }, +} + +/// What dropping `dragged` at `frac` (0 = top edge, 1 = bottom edge) of the row of `target` does, +/// if anything. An album over a folder goes inside it; a folder over a folder goes inside by its +/// middle and beside it by its edges; an album over an album goes before it by its top half and +/// after it by its bottom half; a folder over an album does nothing. Placing where it already +/// is, or inside itself, is nothing too. `expanded`: `target` is a folder shown open. +fn album_drop_at(app: &LightcraftApp, dragged: AlbumId, target: &Album, frac: f32, expanded: bool) -> Option { + let cat = &app.session.catalog; + let d = cat.album(dragged)?; + let into = || can_drop_album(app, dragged, Some(target.id)).then_some(AlbumDrop::Into(target.id)); + if d.folder != target.folder { + return if target.folder { into() } else { None }; + } + if target.id == dragged { + return None; + } + let after = frac >= if target.folder { 0.7 } else { 0.5 }; + // the middle of a folder, and the bottom of an open one (its first child is next), mean inside + if target.folder && frac >= 0.3 && (!after || expanded) { + return into(); + } + let parent = target.parent; + if parent.is_some_and(|p| is_within(app, p, dragged)) { + return None; + } + let sibs = cat.album_children(parent); + let at = sibs.iter().position(|a| a.id == target.id)?; + let was = sibs.iter().position(|a| a.id == dragged); + if !after { + (was.map(|w| w + 1) != Some(at)).then_some(AlbumDrop::Place { parent, before: Some(target.id), above: true }) + } else { + let next = sibs.iter().skip(at + 1).find(|a| a.id != dragged).filter(|a| a.folder == target.folder).map(|a| a.id); + (was != Some(at + 1)).then_some(AlbumDrop::Place { parent, before: next, above: false }) + } +} + +/// A row while an album is dragged: a folder outlined under the pointer when the drop would go +/// inside it, a line above or below the row when it would go beside it, and the move or placement +/// done on release. A closed folder with something inside (`open`) opens once the pointer rests on +/// it; returns whether it did. +fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, row: &Album, open: Option<&mut bool>, indent: f32) -> bool { + let hover_id = egui::Id::new(("album-hover", row.id.0)); let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return false }; - if !pointer_over(ui, resp.rect) { + let pos = ui.input(|i| i.pointer.latest_pos()).filter(|_| pointer_over(ui, resp.rect)); + let Some(pos) = pos else { if ui.data(|d| d.get_temp::(hover_id)).is_some() { ui.data_mut(|d| d.remove_temp::(hover_id)); } return false; - } - let now = ui.input(|i| i.time); - let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); + }; + let expanded = open.as_deref().copied().unwrap_or(false); + let frac = ((pos.y - resp.rect.top()) / resp.rect.height().max(1.0)).clamp(0.0, 1.0); + let drop = album_drop_at(app, dragged, row, frac, expanded); let mut opened = false; - if let Some(open) = open + // only a drop that goes inside opens the folder: on its edges the rows would shift under the pointer + let inside = matches!(drop, Some(AlbumDrop::Into(_))); + if !inside && ui.data(|d| d.get_temp::(hover_id)).is_some() { + ui.data_mut(|d| d.remove_temp::(hover_id)); + } + if inside + && let Some(open) = open && !*open { + let now = ui.input(|i| i.time); + let since = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(hover_id, || now)); if now - since >= HOVER_OPEN_SECS { *open = true; opened = true; @@ -790,12 +881,25 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } } - if can_drop_album(app, dragged, Some(folder.id)) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.primary_released()) { - drop_album(app, ui, dragged, Some(folder.id)); + let t = Tokens::get(ui.ctx()); + let released = ui.input(|i| i.pointer.primary_released()); + match drop { + Some(AlbumDrop::Into(folder)) => { + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + drop_album(app, ui, dragged, Some(folder)); + } } + 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); + 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 { + place_album(app, ui, dragged, parent, before); + } + } + None => {} } opened } @@ -827,13 +931,7 @@ pub fn album_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.input(|i| (i.pointer.primary_released(), i.pointer.primary_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); if released || !down || esc { app.ui.dragging_album = None; - // forget how long the pointer rested on each folder - let ids: Vec = app.session.catalog.albums().filter(|a| a.folder).map(|a| a.id.0).collect(); - ctx.data_mut(|d| { - for id in ids { - d.remove_temp::(egui::Id::new(("album-hover", id))); - } - }); + forget_hover(app, ctx); return; } let (Some(pos), Some(name)) = (pos, app.session.catalog.album(AlbumId(id)).map(|a| a.name.clone())) else { return }; @@ -883,6 +981,12 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } }); + if app.session.catalog.album_children_are_ordered(Some(a.id)) + && ui.button(crate::i18n::tr("Sort Contents A–Z")).on_hover_text(crate::i18n::tr("Go back to listing them by name")).clicked() + { + let _ = app.run("album.sort", json!({"parent": a.id.0})); + ui.close(); + } ui.separator(); } if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index c9ee05ca..364fdc2b 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -427,6 +427,19 @@ impl AlbumTree { self.h.step(); self.h.step(); } + /// Drag `id` to `frac` of the way down the row of `onto` (0 = its top edge, 1 = its bottom edge). + fn drag_to(&mut self, id: u64, onto: u64, frac: f32) { + let (from, r) = (self.row(id).center(), self.row(onto)); + self.drag(from, &[(egui::pos2(r.left() + 100.0, r.top() + frac * r.height()), 3)]); + } + fn id_of(&self, name: &str) -> u64 { + self.h.app.session.catalog.albums().find(|a| a.name == name).unwrap_or_else(|| panic!("no album {name}")).id.0 + } + /// The names inside `parent`, folders or albums only. + fn kids(&self, parent: Option, folders: bool) -> Vec { + let cat = &self.h.app.session.catalog; + cat.album_children(parent.map(lightcraft_catalog::AlbumId)).iter().filter(|a| a.folder == folders).map(|a| a.name.clone()).collect() + } fn drag_row(&mut self, id: u64, onto: u64) { let (from, to) = (self.row(id).center(), self.row(onto).center()); self.drag(from, &[(to, 3)]); @@ -446,9 +459,10 @@ fn dragging_an_album_onto_a_folder_moves_it() { assert!(t.h.app.widgets.iter().any(|(w, _)| *w == format!("source:album:{}", t.loose))); t.drag_row(t.sub, t.archive); assert_eq!(t.parent(t.sub), Some(t.archive), "a folder into a folder"); - // refused: onto a plain album, onto itself, into its own subfolder + // refused: onto itself, into its own subfolder; onto a plain album it only goes beside it t.drag_row(t.best, t.loose); - assert_eq!(t.parent(t.best), Some(t.trips), "a plain album is no destination"); + assert_eq!(t.parent(t.best), Some(t.archive), "a plain album is no destination: Best lands beside Loose, not inside"); + assert_eq!(t.parent(t.loose), Some(t.archive)); let (from, aside) = (t.row(t.archive).center(), t.row(t.archive).center() + egui::vec2(40.0, 0.0)); t.drag(from, &[(aside, 3)]); assert_eq!(t.parent(t.archive), None, "not onto itself"); @@ -483,6 +497,109 @@ fn an_album_drag_ignores_other_buttons_and_esc_cancels_it() { assert_eq!(t.parent(t.loose), Some(t.archive)); } +/// Given the top-level albums Garden, Loose, Night Sky, Portfolio listed by name, when an album is +/// dropped on the top or bottom half of another, then it is placed before or after it and the +/// list is ordered by hand from then on. +#[test] +fn dropping_on_the_edge_of_an_album_places_it_there() { + let mut t = album_tree(); + let id = |t: &AlbumTree, name: &str| t.h.app.session.catalog.albums().find(|a| a.name == name).unwrap().id.0; + let (garden, night, portfolio) = (id(&t, "Garden"), id(&t, "Night Sky"), id(&t, "Portfolio")); + assert_eq!(t.kids(None, false), ["Garden", "Loose", "Night Sky", "Portfolio"]); + t.drag_to(portfolio, garden, 0.2); + assert_eq!(t.kids(None, false), ["Portfolio", "Garden", "Loose", "Night Sky"], "before Garden"); + t.drag_to(garden, night, 0.8); + assert_eq!(t.kids(None, false), ["Portfolio", "Loose", "Night Sky", "Garden"], "after Night Sky"); + assert!(t.h.app.session.catalog.album_children_are_ordered(None)); + // one undo step per drop + t.h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(t.kids(None, false), ["Portfolio", "Garden", "Loose", "Night Sky"]); + assert!(t.h.app.ui.dragging_album.is_none()); +} + +/// Dropping beside an album in another folder moves the album there, at that place. +#[test] +fn dropping_beside_an_album_in_another_folder_moves_it_there() { + let mut t = album_tree(); + t.drag_to(t.loose, t.best, 0.2); + assert_eq!(t.parent(t.loose), Some(t.trips)); + assert_eq!(t.kids(Some(t.trips), false), ["Loose", "Best"]); +} + +/// Where nothing would change, nothing is done: no edit, so no hand order appears. +#[test] +fn dropping_where_it_already_is_changes_nothing() { + let mut t = album_tree(); + let garden = t.id_of("Garden"); + assert_eq!(t.kids(None, false)[..2], ["Garden", "Loose"]); + t.drag_to(t.loose, garden, 0.8); + t.drag_to(garden, t.loose, 0.2); + assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Loose already follows Garden"); + // a real placement orders the albums; the same drop again then changes nothing + t.drag_to(t.loose, garden, 0.2); + assert!(t.h.app.session.catalog.album_children_are_ordered(None)); + let snapshot = t.h.app.session.catalog.to_snapshot(); + t.drag_to(t.loose, garden, 0.2); + assert_eq!(t.h.app.session.catalog.to_snapshot(), snapshot); +} + +/// The bottom of an open folder is inside it; from the bottom edge of a closed one a folder goes +/// after it (last of the folders); a folder over an album does nothing. +#[test] +fn folders_go_after_a_closed_folder_and_inside_an_open_one() { + let mut t = album_tree(); + t.drag_to(t.archive, t.trips, 0.9); + assert_eq!(t.parent(t.archive), Some(t.trips), "the bottom of an open folder is inside it"); + let travel = t.id_of("Travel 2026"); + assert_eq!(t.kids(None, true), ["Travel 2026", "Trips"]); + let r = t.h.request("ui.clickWidget", json!({"id": format!("albumToggle:{}", t.trips)}), T); + assert_eq!(r["ok"], true, "{r}"); + t.h.step(); + t.h.step(); + t.drag_to(travel, t.trips, 0.9); + assert_eq!(t.kids(None, true), ["Trips", "Travel 2026"], "after Trips, the last folder"); + assert_eq!(t.parent(travel), None); + // folders are not placed among albums + t.drag_to(t.trips, t.loose, 0.5); + assert_eq!(t.kids(None, true), ["Trips", "Travel 2026"]); +} + +/// Resting on a closed folder's edge (where the drop goes beside it) does not open it: the rows +/// would shift under the pointer. Only a drop that goes inside opens it. +#[test] +fn hovering_a_folders_edge_does_not_open_it() { + let mut t = album_tree(); + let r = t.h.request("ui.clickWidget", json!({"id": format!("albumToggle:{}", t.trips)}), T); + assert_eq!(r["ok"], true, "{r}"); + t.h.step(); + t.h.step(); + let (from, r) = (t.row(t.archive).center(), t.row(t.trips)); + t.drag(from, &[(egui::pos2(r.left() + 100.0, r.top() + 0.9 * r.height()), 90)]); + assert_eq!(t.parent(t.archive), None, "dropped after Trips, not inside it"); + assert_eq!(t.kids(None, true), ["Travel 2026", "Trips", "Archive"]); +} + +/// Folders are placed among folders by their edges; their middle still means "inside"; an album +/// over a folder is always "inside"; where nothing would change nothing is done or ordered. +#[test] +fn folders_are_placed_by_their_edges_and_entered_by_their_middle() { + let mut t = album_tree(); + assert_eq!(t.kids(None, true), ["Archive", "Travel 2026", "Trips"]); + t.drag_to(t.trips, t.archive, 0.1); + assert_eq!(t.kids(None, true), ["Trips", "Archive", "Travel 2026"], "Trips before Archive"); + assert_eq!(t.parent(t.trips), None); + t.drag_to(t.sub, t.archive, 0.5); + assert_eq!(t.parent(t.sub), Some(t.archive), "the middle of a folder is inside it"); + // an album anywhere over a folder row goes inside, even at its edge + t.drag_to(t.loose, t.archive, 0.05); + assert_eq!(t.parent(t.loose), Some(t.archive)); + // already in place: no change, so no hand order is made + let mut t = album_tree(); + let travel = t.h.app.session.catalog.albums().find(|a| a.name == "Travel 2026").unwrap().id.0; + t.drag_to(travel, t.trips, 0.1); + assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Travel 2026 already sits right before Trips"); +} + /// Dragging out of a folder: the drop zone under the tree puts the album back at the top level; /// it only shows when there is somewhere to go, and the move is one undo step. #[test] From 922ea25d881c972101f96b784719da53fbfb8fa0 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:43:31 -0400 Subject: [PATCH 15/19] Docs: album tree, drag and hand order (parity, what's new) Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 6 +++--- docs/whats-new.md | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index ca6d974e..0d1c7d10 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -150,8 +150,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | no drag photos onto album (LR-BEHAV-DRAGDROP), no album sort | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Album… / Smart Album… / Smart Album from Filter… / Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; a "Top Level" zone under the tree appears while dragging something that sits in a folder), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; no custom order (alphabetical, folders first) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; a "Top Level" zone under the tree appears while dragging something that sits in a folder), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | @@ -620,7 +620,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) | | MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) | | MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu | -| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | +| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), New ▸ Create Album / Smart Album / Smart Album from Filter / Folder (on a folder: made inside it), move to a folder or the top level, rename, delete; smart albums: update rules | | MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) | | MENU-CTX-PRESET | Preset menu | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs` | favourite, update with current settings, rename, move to group, delete; group: export | | MENU-CTX-PROFILE | Profile favourites | P2 | ✅ | `cmd:profile.favorite`, `crates/ui-egui/src/panels/profiles.rs` | star in the profile browser; Favorites group first | diff --git a/docs/whats-new.md b/docs/whats-new.md index f503d56f..5d9a173b 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,19 @@ ## October 2026 +### Albums tree +- Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords. +- Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… + makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`). +- Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a + "Top Level" strip under the tree takes it back out; Esc cancels). Drop on the top or bottom half of another album, + or the edge of another folder, to place it before or after it: that folder is then ordered by hand. Right-click ▸ + Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open + folder, fold that folder first (the bottom of an open folder means "inside"). Agents use `album.reorder` + (`id`, `parent?`, `before?`) and `album.sort`. +- The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library, + older versions refuse it, as with every format change. + ### Library keyboard culling - Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour. - Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too. From 24b80b4ee3666a98e19bb14d0a99e9e70ad0fdb1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:52:08 -0400 Subject: [PATCH 16/19] Albums: the Albums header is the way back to the top level It replaces the strip under the tree, which could be scrolled out of view and shifted the rows when it appeared; the header is always visible and nothing moves. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 20 ++++++++++-------- crates/ui-egui/src/tests_panels.rs | 33 ++++++++++++++---------------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 64b3a98b..98182936 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -208,6 +208,7 @@ 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"); + 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( @@ -255,7 +256,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { cur = cat.album(p).and_then(|a| a.parent); } albums_tree(app, ui, &kids, None, 0.0, &open_to); - top_level_drop_zone(app, ui); } ui.add_space(10.0); local_section(app, ui); @@ -904,20 +904,22 @@ fn album_drag_over(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Resp opened } -/// While an album that sits in a folder is dragged: a row under the tree that takes it back to the -/// top level. -fn top_level_drop_zone(app: &mut LightcraftApp, ui: &mut egui::Ui) { +/// While an album that sits in a folder is dragged: the "Albums" header takes it back to the top +/// level (outlined, with a hint). The header stays in view however long the tree is, and nothing +/// moves when it appears. +fn top_level_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, header: Rect) { let Some(dragged) = app.ui.dragging_album.map(AlbumId) else { return }; if !can_drop_album(app, dragged, None) { return; } let t = Tokens::get(ui.ctx()); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 29.0), Sense::hover()); - register(ui.ctx(), "albumDrop:top", r); - let over = pointer_over(ui, r); + register(ui.ctx(), "albumDrop:top", header); + let over = pointer_over(ui, header); let stroke = if over { egui::Stroke::new(1.5, t.accent) } else { egui::Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)) }; - ui.painter().rect_stroke(r.shrink2(vec2(8.0, 1.0)), 4.0, stroke, egui::StrokeKind::Inside); - ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Top Level"), t.font(13.5), t.text_dim); + ui.painter().rect_stroke(header.shrink2(vec2(8.0, 3.0)), 4.0, stroke, egui::StrokeKind::Inside); + // right-aligned, left of the + button + let edge = header.right().min(ui.clip_rect().right()); + ui.painter().text(pos2(edge - 56.0, header.center().y), Align2::RIGHT_CENTER, crate::i18n::tr("Top Level"), t.font(12.5), t.text_dim); if over && ui.input(|i| i.pointer.primary_released()) { drop_album(app, ui, dragged, None); } diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 364fdc2b..98b6da64 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -383,13 +383,9 @@ impl AlbumTree { let name = ["source:folder:", "source:album:"].iter().map(|p| format!("{p}{id}")).find(|n| h.app.widgets.iter().any(|(w, _)| w == n)); widget(h, &name.unwrap_or_else(|| panic!("no row for album {id}"))) } - /// The middle of the row just under the last row of the Albums tree (where the top-level drop zone is). - fn below_tree(&self) -> egui::Pos2 { - let bottom = (self.h.app.widgets.iter()) - .filter(|(w, r)| (w.starts_with("source:album:") || w.starts_with("source:folder:")) && r.left() < 300.0) - .map(|(_, r)| r.bottom()) - .fold(0.0, f32::max); - egui::pos2(100.0, bottom + 14.5) + /// The middle of the "Albums" header, where an album is dropped to take it to the top level. + fn albums_header(&self) -> egui::Pos2 { + widget(&self.h, "sidebarSection:albums").center() } fn parent(&self, id: u64) -> Option { self.h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0) @@ -600,22 +596,23 @@ fn folders_are_placed_by_their_edges_and_entered_by_their_middle() { assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Travel 2026 already sits right before Trips"); } -/// Dragging out of a folder: the drop zone under the tree puts the album back at the top level; -/// it only shows when there is somewhere to go, and the move is one undo step. +/// Dragging out of a folder: the "Albums" header is the way back to the top level (it stays in +/// view however long the tree is), only offered for an album that sits in a folder, and the move +/// is one undo step. The drop does not fold the section. #[test] -fn dragging_an_album_to_the_top_level() { +fn dragging_an_album_to_the_albums_header_takes_it_to_the_top_level() { let mut t = album_tree(); let has = |t: &AlbumTree, id: &str| t.h.app.widgets.iter().any(|(w, _)| w == id); - assert!(!has(&t, "albumDrop:top"), "no zone unless an album is being dragged"); - // below the last row of the tree, where the zone appears once an album with a folder is dragged - let below = t.below_tree(); - let from = t.row(t.best).center(); - t.drag(from, &[(below, 3)]); + assert!(!has(&t, "albumDrop:top"), "no target unless an album is being dragged"); + let (from, header) = (t.row(t.best).center(), t.albums_header()); + t.drag(from, &[(header, 3)]); assert_eq!(t.parent(t.best), None, "moved out of Trips"); - // a top-level album has nowhere to go: dropping it there does nothing - let (from, below) = (t.row(t.best).center(), t.below_tree()); - t.drag(from, &[(below, 3)]); + assert!(!t.h.app.ui.sidebar_section_collapsed("albums"), "dropping on the header does not fold it"); + // a top-level album has nowhere to go: dropping it on the header does nothing + let (from, header) = (t.row(t.best).center(), t.albums_header()); + t.drag(from, &[(header, 3)]); assert_eq!(t.parent(t.best), None); + assert!(!t.h.app.ui.sidebar_section_collapsed("albums")); t.h.app.session.execute("edit.undo", &json!({})).unwrap(); assert_eq!(t.parent(t.best), Some(t.trips), "one undo puts it back"); } From 5fefe91640ec13ca7dfe65e254fe119ab559dc40 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:52:08 -0400 Subject: [PATCH 17/19] Docs: top-level drop is on the Albums header Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 4 ++-- docs/whats-new.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 0d1c7d10..b1e36b87 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -151,7 +151,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP) | -| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; a "Top Level" zone under the tree appears while dragging something that sits in a folder), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | +| LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album), live; match-all only (no any/none rule groups, no rule editor dialog — rules come from the filter bar or `album.setRules`); sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | @@ -512,7 +512,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); no tiling beyond the buffer limit (CPU) | | 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 | -| 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_zone`) | 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) | +| 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`) | 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) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | | LR-BEHAV-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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 5d9a173b..3004617a 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,7 @@ - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`). - Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a - "Top Level" strip under the tree takes it back out; Esc cancels). Drop on the top or bottom half of another album, + "Albums" header takes it back to the top level; Esc cancels). Drop on the top or bottom half of another album, or the edge of another folder, to place it before or after it: that folder is then ordered by hand. Right-click ▸ Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open folder, fold that folder first (the bottom of an open folder means "inside"). Agents use `album.reorder` From 8ff45f5237c6bc83305dc84a23fba0d403504775 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:59:12 -0400 Subject: [PATCH 18/19] Sidebar: dragging albums or photos near its top or bottom edge scrolls it Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/panels/left.rs | 31 +++++++++++++ crates/ui-egui/src/tests_panels.rs | 72 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 98182936..8863e5d1 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -179,6 +179,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let counts = app.caches.counts(&app.session.catalog); let (total, picks, deleted) = (counts.total, counts.picks, counts.deleted); egui::ScrollArea::both().id_salt("left-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + drag_auto_scroll(app, ui); // 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); @@ -751,6 +752,36 @@ fn pointer_over(ui: &egui::Ui, rect: Rect) -> bool { ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) } +/// How close to the top or bottom edge of the sidebar (points) a dragged album starts scrolling it. +const SCROLL_EDGE: f32 = 36.0; +/// The fastest the sidebar scrolls for a drag (points per second). +const SCROLL_MAX_SPEED: f32 = 700.0; + +/// How fast the sidebar scrolls (points per second, positive = down) for a dragged item with the +/// pointer at `y`, the visible part being `top..bottom`: none in the middle, faster the nearer to +/// the edge, the top speed at and past it. +pub fn auto_scroll_speed(y: f32, top: f32, bottom: f32) -> f32 { + let depth = |dist: f32| ((SCROLL_EDGE - dist) / SCROLL_EDGE).clamp(0.0, 1.0); + SCROLL_MAX_SPEED * (depth(bottom - y) - depth(y - top)) +} + +/// While an album, or photos from the grid, are dragged over the sidebar: scrolls it when the +/// pointer is near its top or bottom edge, so rows beyond the visible part can be reached. Call +/// inside the scroll area. +fn drag_auto_scroll(app: &LightcraftApp, ui: &egui::Ui) { + if app.ui.dragging_album.is_none() && app.ui.dragging_photos.is_none() { + return; + } + let view = ui.clip_rect(); + let Some(p) = ui.input(|i| i.pointer.latest_pos()).filter(|p| p.x >= view.left() && p.x <= view.right()) else { return }; + let speed = auto_scroll_speed(p.y, view.top(), view.bottom()); + if speed != 0.0 { + // a positive delta moves the content down: the view goes up + ui.scroll_with_delta_animation(vec2(0.0, -speed * ui.input(|i| i.stable_dt).min(0.1)), egui::style::ScrollAnimation::none()); + ui.ctx().request_repaint(); + } +} + /// How long a dragged album must rest on a closed folder before it opens. const HOVER_OPEN_SECS: f64 = 0.6; diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 98b6da64..66eb360e 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -596,6 +596,78 @@ fn folders_are_placed_by_their_edges_and_entered_by_their_middle() { assert!(!t.h.app.session.catalog.album_children_are_ordered(None), "Travel 2026 already sits right before Trips"); } +/// Holding a dragged album near the top or bottom edge of the sidebar scrolls it that way; in the +/// middle, or beside the sidebar, nothing scrolls. (Each drag ends with Esc: nothing is dropped.) +#[test] +fn a_dragged_album_scrolls_the_sidebar_at_its_edges() { + let mut t = album_tree(); + for i in 0..60 { + t.h.app.session.execute("album.create", &json!({"name": format!("Z{i:02}")})).unwrap(); + } + t.h.step(); + t.h.step(); + let last = t.id_of("Z59"); + let panel = widget(&t.h, "panel:left_panel"); + let (cx, top, bottom) = (panel.center().x, panel.top(), panel.bottom()); + let start = t.row(last).top(); + assert!(start > bottom, "the last album starts below the visible sidebar: {start} vs {bottom}"); + // a row in the middle of the visible sidebar: where a drag starts + let pick = |t: &AlbumTree| { + let rows = t.h.app.widgets.iter().filter(|(w, r)| w.starts_with("source:album:") && r.top() > top + 120.0 && r.bottom() < bottom - 120.0); + rows.map(|(_, r)| r.center()).next().expect("a visible album row") + }; + let esc = |pressed| egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let hold = |t: &mut AlbumTree, to: egui::Pos2| { + let from = pick(t); + t.drag_with(from, &[(to, 90)], vec![esc(true), esc(false)]); + }; + hold(&mut t, egui::pos2(cx, panel.center().y)); + assert_eq!(t.row(last).top(), start, "no scrolling in the middle"); + hold(&mut t, egui::pos2(panel.right() + 200.0, bottom - 6.0)); + assert_eq!(t.row(last).top(), start, "no scrolling beside the sidebar"); + hold(&mut t, egui::pos2(cx, bottom - 6.0)); + let scrolled = t.row(last).top(); + assert!(scrolled < start - 100.0, "the bottom edge scrolls down: {start} -> {scrolled}"); + hold(&mut t, egui::pos2(cx, top + 70.0)); + let back = t.row(last).top(); + assert!(back > scrolled + 100.0, "the top edge scrolls back up: {scrolled} -> {back}"); + assert!(t.h.app.ui.dragging_album.is_none()); +} + +/// Photos dragged from the grid scroll the sidebar the same way, to reach an album further down. +#[test] +fn dragged_photos_scroll_the_sidebar_at_its_edges() { + let mut t = album_tree(); + for i in 0..60 { + t.h.app.session.execute("album.create", &json!({"name": format!("Z{i:02}")})).unwrap(); + } + t.h.step(); + t.h.step(); + let (last, panel) = (t.id_of("Z59"), widget(&t.h, "panel:left_panel")); + let start = t.row(last).top(); + let first = t.h.app.session.visible_cloned()[0].0; + let thumb = widget(&t.h, &format!("thumb:{first}")).center(); + t.drag(thumb, &[(egui::pos2(panel.center().x, panel.bottom() - 6.0), 90)]); + assert!(t.row(last).top() < start - 100.0, "scrolled down: {start} -> {}", t.row(last).top()); + assert!(t.h.app.ui.dragging_photos.is_none(), "the drag ended"); +} + +/// How fast the sidebar scrolls for a pointer at `y` (+ down) is zero in the middle, ramps up +/// within the edge band and stays at the top speed past the edge. +#[test] +fn auto_scroll_speed_ramps_up_at_the_edges() { + use crate::panels::left::auto_scroll_speed; + let (top, bottom) = (100.0, 700.0); + assert_eq!(auto_scroll_speed(400.0, top, bottom), 0.0); + assert_eq!(auto_scroll_speed(top + 60.0, top, bottom), 0.0, "outside the band"); + let (slow, fast, past) = + (auto_scroll_speed(bottom - 30.0, top, bottom), auto_scroll_speed(bottom - 5.0, top, bottom), auto_scroll_speed(bottom + 80.0, top, bottom)); + assert!(0.0 < slow && slow < fast && fast <= past, "{slow} {fast} {past}"); + assert_eq!(past, auto_scroll_speed(bottom + 400.0, top, bottom), "capped"); + let up = auto_scroll_speed(top + 5.0, top, bottom); + assert!(up < 0.0 && (up + fast).abs() < 1e-3, "the same ramp upwards: {up} vs {fast}"); +} + /// Dragging out of a folder: the "Albums" header is the way back to the top level (it stays in /// view however long the tree is), only offered for an album that sits in a folder, and the move /// is one undo step. The drop does not fold the section. From 733846b326c714f385aedd9c2305e12dfd8e7c6a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:59:12 -0400 Subject: [PATCH 19/19] Docs: sidebar auto-scroll while dragging Co-Authored-By: Claude Sonnet 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index b1e36b87..f8e1e317 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -512,7 +512,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); no tiling beyond the buffer limit (CPU) | | 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 | -| 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`) | 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) | +| LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | | LR-BEHAV-SIDEBAR-COLLAPSE | Collapsible left-sidebar sections | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`sidebar_section_header`), `crates/ui-egui/src/state.rs` (`collapsed_sidebar`), `crates/ui-egui/src/tests_panels.rs` | click the Albums, Local, By Date or Keywords header (or drive `ui.clickWidget` `sidebarSection:`) to fold or unfold the section; a chevron after the title shows the state; the choice is kept across restarts (`collapsedSidebar` in the UI state). UI-only (no command), like the By Date / Keywords disclosure triangles; photos dropped on a folded Albums section have no target | | LR-BEHAV-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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 3004617a..c82eb1af 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,7 +7,7 @@ - Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`). - Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a - "Albums" header takes it back to the top level; Esc cancels). Drop on the top or bottom half of another album, + "Albums" header takes it back to the top level; Esc cancels; holding a dragged album or photos near the top or bottom edge of the sidebar scrolls it). Drop on the top or bottom half of another album, or the edge of another folder, to place it before or after it: that folder is then ordered by hand. Right-click ▸ Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open folder, fold that folder first (the bottom of an open folder means "inside"). Agents use `album.reorder`