From 79403a5680aeda5fd322811f54dcf409f0b8d37c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 8 Oct 2026 20:08:53 -0400 Subject: [PATCH] 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 624617490..9829e0576 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 cd087776d..4eff2f391 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 5771c9d48..100493e2b 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 17f5c58b7..5f00f5a52 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 d0cf302fa..edb71fde9 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])