mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 20:17:18 -06:00
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:
co-authored by
Claude Sonnet 5.5
parent
96a9c6e04d
commit
79403a5680
@@ -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" => {
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>| -> 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])
|
||||
|
||||
Reference in New Issue
Block a user