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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-08 21:02:06 -04:00
co-authored by Claude Sonnet 5.5
parent 96a9c6e04d
commit 79403a5680
5 changed files with 143 additions and 17 deletions
+12 -3
View File
@@ -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<u64> {
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<Re
Ok(Value::Null)
}
"dialog.newFolder" => {
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<Re
id: Some(a.id.0),
name: a.name.clone(),
rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(),
parent: None,
},
None => 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" => {
+17 -6
View File
@@ -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<serde_json::Value, String> {
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<serde_json::Value, String> {
match dlg {
Dialog::SamModel { then, .. } => {
@@ -995,7 +1002,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
r
}
Dialog::NewAlbum { name, folder } => 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<serde_jso
}
app.run("photo.analyze", p)
}
Dialog::SmartRules { id, name, rules } => {
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<serde_jso
}
app.run("album.setRules", json!({"id": id, "replace": true, "rules": {"ruleSet": rules}}))
}
None => 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!({
+39 -8
View File
@@ -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<Album> = 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<AlbumId>, 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<AlbumId>, 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}));
+12
View File
@@ -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<Vec<u64>>,
/// An album just made: the Albums tree opens the folders down to it, once.
#[serde(skip)]
pub reveal_album: Option<u64>,
/// 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<u64>,
},
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<u64>,
},
/// Help ▸ What's New.
WhatsNew,
@@ -549,6 +557,9 @@ pub enum Dialog {
id: Option<u64>,
name: String,
rules: lightcraft_catalog::RuleSet,
/// The folder a new smart album is created in (ignored when editing).
#[serde(default)]
parent: Option<u64>,
},
/// `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(),
+63
View File
@@ -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", &params).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>| -> u64 {
let before: Vec<u64> = 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<u64> = 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])