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..884ec658 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,28 @@ 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| 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()) + } /// 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 +506,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)?; @@ -668,9 +708,17 @@ 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)] +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..112633f4 --- /dev/null +++ b/crates/catalog/src/tests_album_order.rs @@ -0,0 +1,122 @@ +//! 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"]); +} + +/// 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.contains_key(&Some(AlbumId(999)))); +} + +/// 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..d900eacf 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() } @@ -784,7 +792,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})) }), @@ -880,6 +888,80 @@ 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}: 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) => { + 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}: how many albums lost their place", + 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/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); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c3fcdd33..b2f57d24 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -822,6 +822,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.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(); diff --git a/crates/engine/src/tests_album_order.rs b/crates/engine/src/tests_album_order.rs new file mode 100644 index 00000000..cff524cb --- /dev/null +++ b/crates/engine/src/tests_album_order.rs @@ -0,0 +1,159 @@ +//! 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"); + 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. +#[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); +} + +/// 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] +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; +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea82b0d7..91cf64f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -934,6 +934,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/menus.rs b/crates/ui-egui/src/menus.rs index d4c716b4..c024a44d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -232,6 +232,11 @@ fn adjust_brush(app: &mut LightcraftApp, k: f32, df: f32) -> Value { } } +/// 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() { @@ -834,11 +839,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" => { @@ -865,17 +872,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 5c05d0ae..37a60e74 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -236,7 +236,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); @@ -257,7 +257,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)) { @@ -858,7 +858,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 { @@ -994,6 +1005,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, .. } => { @@ -1010,7 +1028,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(); @@ -1049,7 +1069,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) => { @@ -1058,10 +1078,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 868a71cd..8863e5d1 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 { @@ -107,6 +124,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. @@ -143,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); @@ -172,6 +209,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( @@ -182,25 +220,43 @@ 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 }); + } + // 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(); - albums_tree(app, ui, &albums, None, 0.0); + 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| 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 = cat.album(p).and_then(|a| a.parent); + } + albums_tree(app, ui, &kids, None, 0.0, &open_to); } ui.add_space(10.0); local_section(app, ui); @@ -516,7 +572,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 +589,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 +652,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)); @@ -646,20 +680,42 @@ 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) { - let mut kids: Vec<&Album> = all.iter().filter(|a| a.parent == parent).collect(); - kids.sort_by_key(|a| (!a.folder, a.name.to_lowercase())); +/// 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: &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)); - 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() { + 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_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.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), indent) { + 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)); + 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); 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); @@ -671,7 +727,11 @@ 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); + } + album_drag_over(app, ui, &resp, a, None, indent); if !a.is_smart() { drop_target(app, ui, &resp, a); } @@ -686,6 +746,237 @@ 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 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; + +/// 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), + } +} + +/// 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(); + } + Err(e) => app.toast(ui.ctx(), e), + } + app.ui.dragging_album = None; +} + +/// 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 }; + 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 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; + // 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; + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } + } + 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 +} + +/// 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()); + 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(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); + } +} + +/// 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, 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_hover(app, ctx); + 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) { @@ -708,6 +999,29 @@ 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 [ + ("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})); + ui.close(); + } + } + }); + 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() { let _ = app.run("album.addPhotos", json!({"id": a.id.0})); } @@ -726,7 +1040,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})); @@ -838,7 +1152,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 +1168,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 +1279,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)); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 66084cf1..f8e6c330 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -397,6 +397,12 @@ 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, + /// 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". @@ -500,9 +506,12 @@ pub struct NameEdit { #[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, @@ -560,6 +569,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, @@ -591,6 +602,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 { @@ -714,6 +728,8 @@ impl Default for UiState { renaming_component: None, 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 aad86fd5..66eb360e 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -220,6 +220,492 @@ 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}"))); + // 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 +/// 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})); + 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); + 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, .. })); + 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, .. })); +} + +/// 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 "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) + } + /// 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() }); + 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; + } + 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() { + break; + } + self.h.step(); + } + 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)]); + } +} + +/// 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 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.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"); + 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))); +} + +/// 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)); +} + +/// 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"); +} + +/// 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. +#[test] +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 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"); + 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"); +} + +/// 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]) diff --git a/docs/parity.md b/docs/parity.md index ed95faab..066bd45a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -155,8 +155,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`), `cmd:album.move` | moving is command-only (no drag, no "Move to" menu) | +| 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; 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 | @@ -518,7 +518,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_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 | @@ -628,7 +628,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 ▸ 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 a2229750..e81535ff 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 + "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` + (`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.