This commit is contained in:
Brandon Thomas 2026-10-09 00:17:58 -04:00
commit ab73d3d2e0
21 changed files with 1406 additions and 88 deletions

View File

@ -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)]

View File

@ -136,6 +136,11 @@ pub enum Op {
id: AlbumId,
parent: Option<AlbumId>,
},
/// Where the album stands among its siblings (`None`: by name). Format version 3.
SetAlbumOrder {
id: AlbumId,
order: Option<u32>,
},
SetAlbumPhotos {
id: AlbumId,
photos: Vec<PhotoId>,
@ -282,6 +287,28 @@ impl Catalog {
pub fn albums(&self) -> impl Iterator<Item = &Album> {
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<AlbumId>) -> 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<Option<AlbumId>, Vec<&Album>> {
let mut map: std::collections::HashMap<Option<AlbumId>, 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<AlbumId>) -> 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<AlbumId> {
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;

View File

@ -295,6 +295,7 @@ impl Op {
Op::RemoveAlbum { .. }
| Op::RenameAlbum { .. }
| Op::MoveAlbum { .. }
| Op::SetAlbumOrder { .. }
| Op::SetAlbumRules { .. }
| Op::RemoveStack { .. }
| Op::SetLabelName { .. }

View File

@ -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<u32>,
}
impl Album {
/// A regular (manual) album.
pub fn new(id: AlbumId, name: impl Into<String>) -> 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()

View File

@ -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]);

View File

@ -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<AlbumId>, 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<AlbumId>) -> Vec<String> {
c.album_children(parent).iter().map(|a| a.name.clone()).collect()
}
fn place(c: &mut Catalog, id: AlbumId, order: Option<u32>) {
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<AlbumId> = map.get(&parent).map(|v| v.iter().map(|a| a.id).collect()).unwrap_or_default();
let direct: Vec<AlbumId> = 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));
}

View File

@ -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");
}

View File

@ -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") } }
}

View File

@ -119,6 +119,14 @@ fn album_param(p: &Value, key: &str, c: &str) -> Result<AlbumId> {
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<Option<AlbumId>> {
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<String> {
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<CommandSpec> {
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<CommandSpec> {
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<AlbumId> = 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<Op> = 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));

View File

@ -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);

View File

@ -822,6 +822,8 @@ pub fn json_delta(old: &Value, new: &Value) -> Option<Value> {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_export;

View File

@ -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, &params).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();

View File

@ -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<u64>, 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<u64>) -> Vec<String> {
s.catalog.album_children(parent.map(AlbumId)).iter().map(|a| a.name.clone()).collect()
}
fn reorder(s: &mut Session, id: u64, params: Value) -> Result<Value, String> {
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;
}

View File

@ -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);

View File

@ -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<u64> {
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<Re
Ok(Value::Null)
}
"dialog.newFolder" => {
app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true });
app.ui.dialog =
Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: true, parent: parent_param(p) });
Ok(Value::Null)
}
"dialog.newAlbum" => {
app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false });
app.ui.dialog =
Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false, parent: parent_param(p) });
Ok(Value::Null)
}
"dialog.autoStack" => {
@ -865,17 +872,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
id: Some(a.id.0),
name: a.name.clone(),
rules: a.smart.as_ref().and_then(|f| f.rule_set.clone()).unwrap_or_default(),
parent: None,
},
None => Dialog::SmartRules {
id: None,
name: p.get("name").and_then(Value::as_str).unwrap_or("").into(),
rules: lightcraft_catalog::RuleSet { rules: vec![crate::panels::rules_editor::new_rule()], ..Default::default() },
parent: parent_param(p),
},
});
Ok(Value::Null)
}
"dialog.newSmartAlbum" => {
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() });
app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), parent: parent_param(p) });
Ok(Value::Null)
}
"dialog.captureTime" => {

View File

@ -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<serde_json::Value, String> {
let r = app.run(command, params)?;
app.ui.reveal_album = r.get("id").and_then(serde_json::Value::as_u64);
Ok(r)
}
pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> {
match dlg {
Dialog::SamModel { then, .. } => {
@ -1010,7 +1028,9 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
r
}
Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})),
Dialog::NewAlbum { name, folder, parent } => {
created_in(app, "album.create", json!({"name": name, "folder": folder, "addSelected": !folder, "parent": parent}))
}
Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})),
Dialog::TextPrompt { value, command, params, key, .. } => {
let mut p = params.clone();
@ -1049,7 +1069,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
app.run("photo.analyze", p)
}
Dialog::SmartRules { id, name, rules } => {
Dialog::SmartRules { id, name, rules, parent } => {
let name = if name.trim().is_empty() { "Smart Album".to_string() } else { name.trim().to_string() };
match id {
Some(id) => {
@ -1058,10 +1078,12 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
app.run("album.setRules", json!({"id": id, "replace": true, "rules": {"ruleSet": rules}}))
}
None => app.run("album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}})),
None => created_in(app, "album.createSmart", json!({"name": name, "rules": {"ruleSet": rules}, "parent": parent})),
}
}
Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})),
Dialog::NewSmartAlbum { name, parent } => {
created_in(app, "album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }, "parent": parent}))
}
Dialog::CreatePreset { name, group, groups } => app.run(
"preset.create",
json!({

View File

@ -52,10 +52,27 @@ fn row_named(
count: Option<usize>,
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<usize>,
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<Album> = 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<AlbumId>, 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<Option<AlbumId>, Vec<Album>>;
/// `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<AlbumId>, 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<AlbumId>) -> 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<serde_json::Value, String>) {
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<u64> = 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::<f64>(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<AlbumId>) {
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<AlbumId>, before: Option<AlbumId>) {
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<AlbumId>, before: Option<AlbumId>, 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<AlbumDrop> {
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::<f64>(hover_id)).is_some() {
ui.data_mut(|d| d.remove_temp::<f64>(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::<f64>(hover_id)).is_some() {
ui.data_mut(|d| d.remove_temp::<f64>(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));

View File

@ -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<Vec<u64>>,
/// An album just made: the Albums tree opens the folders down to it, once.
#[serde(skip)]
pub reveal_album: Option<u64>,
/// An album or folder row being dragged in the sidebar (dropped on a folder to move it there).
#[serde(skip)]
pub dragging_album: Option<u64>,
/// 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<u64>,
},
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<u64>,
},
/// Help ▸ What's New.
WhatsNew,
@ -591,6 +602,9 @@ pub enum Dialog {
id: Option<u64>,
name: String,
rules: lightcraft_catalog::RuleSet,
/// The folder a new smart album is created in (ignored when editing).
#[serde(default)]
parent: Option<u64>,
},
/// `groups`: the settings groups the preset includes (`SettingsGroup` ids).
CreatePreset {
@ -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(),

View File

@ -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:<id>`), 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", &params).unwrap()["id"].as_u64().unwrap();
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips}));
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", &params).unwrap()["id"].as_u64().unwrap();
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips}));
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>| -> u64 {
let before: Vec<u64> = h.app.session.catalog.albums().map(|a| a.id.0).collect();
let mut params = json!({"name": name});
if let Some(p) = parent {
params["parent"] = json!(p);
}
let r = h.request("engine.execute", json!({"command": command, "params": params}), T);
assert_eq!(r["ok"], true, "{r}");
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
let new: Vec<u64> = h.app.session.catalog.albums().map(|a| a.id.0).filter(|i| !before.contains(i)).collect();
assert_eq!(new.len(), 1, "{command} made exactly one album: {new:?}");
new[0]
};
let parent_of = |h: &Headless, id: u64| h.app.session.catalog.album(lightcraft_catalog::AlbumId(id)).unwrap().parent.map(|p| p.0);
// fold both folders: the new rows must still be visible afterwards
for f in [europe, trips] {
let r = h.request("ui.clickWidget", json!({"id": format!("albumToggle:{f}")}), T);
assert_eq!(r["ok"], true, "{r}");
h.step();
h.step();
}
assert!(!has(&h, &format!("source:folder:{europe}")), "Trips is folded");
let album = create(&mut h, "dialog.newAlbum", "Day 1", Some(europe));
assert_eq!(parent_of(&h, album), Some(europe));
assert!(has(&h, &format!("source:album:{album}")), "the new album is shown, its folders opened to it");
let folder = create(&mut h, "dialog.newFolder", "Italy", Some(europe));
assert_eq!(parent_of(&h, folder), Some(europe));
assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(folder)).unwrap().folder);
let smart = create(&mut h, "dialog.smartAlbum", "Rated", Some(folder));
assert_eq!(parent_of(&h, smart), Some(folder));
assert!(h.app.session.catalog.album(lightcraft_catalog::AlbumId(smart)).unwrap().is_smart());
let from_view = create(&mut h, "dialog.newSmartAlbum", "From view", Some(trips));
assert_eq!(parent_of(&h, from_view), Some(trips));
// without a folder they are still made at the top level
let top = create(&mut h, "dialog.newAlbum", "Loose", None);
assert_eq!(parent_of(&h, top), None);
}
/// Saved or scripted dialogs from before the folder parameter still load.
#[test]
fn album_dialogs_without_a_parent_still_deserialize() {
use crate::state::Dialog;
let d: Dialog = serde_json::from_value(json!({"kind": "newAlbum", "name": "x", "folder": false})).unwrap();
assert!(matches!(d, Dialog::NewAlbum { parent: None, .. }));
let d: Dialog = serde_json::from_value(json!({"kind": "newSmartAlbum", "name": "x"})).unwrap();
assert!(matches!(d, Dialog::NewSmartAlbum { parent: None, .. }));
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", &params).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<u64> {
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<egui::Event>) {
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<u64>, folders: bool) -> Vec<String> {
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])

View File

@ -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:<id>`; 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:<albums\|local\|byDate\|keywords>`) 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 |

View File

@ -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.