Merge PR #423
This commit is contained in:
commit
ab73d3d2e0
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -295,6 +295,7 @@ impl Op {
|
|||
Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumOrder { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -118,6 +118,29 @@ fn albums_crud() {
|
|||
s.execute("album.delete", &json!({"id": f})).unwrap();
|
||||
}
|
||||
|
||||
/// Given an album and a folder, when something is created inside each, then only the folder
|
||||
/// accepts it: the sidebar only lists children of folders, so a child of a plain album would
|
||||
/// be invisible.
|
||||
#[test]
|
||||
fn albums_and_smart_albums_are_created_only_inside_folders() {
|
||||
let mut s = demo();
|
||||
let folder = s.execute("album.create", &json!({"name": "Trips", "folder": true})).unwrap()["id"].as_u64().unwrap();
|
||||
let album = s.execute("album.create", &json!({"name": "Best", "parent": folder})).unwrap()["id"].as_u64().unwrap();
|
||||
for (cmd, params) in [
|
||||
("album.create", json!({"name": "Nested", "parent": album})),
|
||||
("album.create", json!({"name": "Nested", "folder": true, "parent": album})),
|
||||
("album.createSmart", json!({"name": "Nested", "parent": album})),
|
||||
("album.create", json!({"name": "Nested", "parent": 987_654})),
|
||||
] {
|
||||
assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {params} must be refused");
|
||||
}
|
||||
let n = s.catalog.albums().count();
|
||||
let sub = s.execute("album.create", &json!({"name": "Sub", "folder": true, "parent": folder})).unwrap()["id"].as_u64().unwrap();
|
||||
let smart = s.execute("album.createSmart", &json!({"name": "Rated", "parent": sub})).unwrap()["id"].as_u64().unwrap();
|
||||
assert_eq!(s.catalog.albums().count(), n + 2);
|
||||
assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(smart)).unwrap().parent, Some(lightcraft_catalog::AlbumId(sub)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_and_delete_restore() {
|
||||
let mut s = demo();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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", ¶ms).unwrap()["id"].as_u64().unwrap();
|
||||
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
|
||||
let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips}));
|
||||
let best = make(&mut h, json!({"name": "Best", "parent": europe}));
|
||||
let loose = make(&mut h, json!({"name": "Loose"}));
|
||||
h.step();
|
||||
h.step();
|
||||
let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id);
|
||||
let toggle = |h: &mut Headless, id: u64| {
|
||||
let r = h.request("ui.clickWidget", json!({"id": format!("albumToggle:{id}")}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
};
|
||||
// folders start open and show their albums; only folders get a triangle
|
||||
assert!(has(&h, &format!("source:album:{best}")), "open by default");
|
||||
assert!(has(&h, &format!("albumToggle:{trips}")) && has(&h, &format!("albumToggle:{europe}")));
|
||||
assert!(!has(&h, &format!("albumToggle:{best}")) && !has(&h, &format!("albumToggle:{loose}")), "albums have no triangle");
|
||||
// folding the outer folder hides everything inside it, the inner triangle included
|
||||
toggle(&mut h, trips);
|
||||
assert!(!has(&h, &format!("source:folder:{europe}")) && !has(&h, &format!("source:album:{best}")));
|
||||
assert!(has(&h, &format!("source:album:{loose}")), "siblings stay");
|
||||
// opening it again leaves the inner folder as it was
|
||||
toggle(&mut h, trips);
|
||||
assert!(has(&h, &format!("source:album:{best}")));
|
||||
toggle(&mut h, europe);
|
||||
assert!(!has(&h, &format!("source:album:{best}")) && has(&h, &format!("source:folder:{europe}")));
|
||||
// an empty folder has nothing to open: no triangle until it holds something
|
||||
let empty = make(&mut h, json!({"name": "Empty", "folder": true}));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, &format!("source:folder:{empty}")) && !has(&h, &format!("albumToggle:{empty}")));
|
||||
make(&mut h, json!({"name": "First", "parent": empty}));
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(has(&h, &format!("albumToggle:{empty}")), "it gets one with its first child");
|
||||
}
|
||||
|
||||
/// A dialog that cannot do its job (here: the chosen parent is a plain album, as when the folder
|
||||
/// was deleted or turned out not to be one) stays open with the error shown, the typed name kept.
|
||||
#[test]
|
||||
fn creating_in_a_non_folder_keeps_the_dialog_open() {
|
||||
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let plain = h.app.session.execute("album.create", &json!({"name": "Plain"})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = h.app.session.catalog.albums().count();
|
||||
let r = h.request("engine.execute", json!({"command": "dialog.newAlbum", "params": {"name": "Kept", "parent": plain}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
let r = h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.step();
|
||||
h.step();
|
||||
assert_eq!(h.app.session.catalog.albums().count(), before, "nothing was made");
|
||||
assert!(
|
||||
matches!(&h.app.ui.dialog, Some(crate::state::Dialog::NewAlbum { name, .. }) if name == "Kept"),
|
||||
"the dialog stays, with its text: {:?}",
|
||||
h.app.ui.dialog
|
||||
);
|
||||
}
|
||||
|
||||
/// Creating from a folder's menu: every `dialog.new*` command takes the folder to create in, the
|
||||
/// dialog remembers it, and the new row shows up even when that folder (and the one holding it)
|
||||
/// was folded shut.
|
||||
#[test]
|
||||
fn albums_are_created_inside_the_chosen_folder() {
|
||||
let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true}));
|
||||
let make = |h: &mut Headless, params: serde_json::Value| h.app.session.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap();
|
||||
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
|
||||
let europe = make(&mut h, json!({"name": "Europe", "folder": true, "parent": trips}));
|
||||
make(&mut h, json!({"name": "Seed", "parent": europe}));
|
||||
h.step();
|
||||
h.step();
|
||||
let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id);
|
||||
let create = |h: &mut Headless, command: &str, name: &str, parent: Option<u64>| -> 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", ¶ms).unwrap()["id"].as_u64().unwrap();
|
||||
let archive = make(&mut h, json!({"name": "Archive", "folder": true}));
|
||||
let trips = make(&mut h, json!({"name": "Trips", "folder": true}));
|
||||
let sub = make(&mut h, json!({"name": "Sub", "folder": true, "parent": trips}));
|
||||
let best = make(&mut h, json!({"name": "Best", "parent": trips}));
|
||||
let loose = make(&mut h, json!({"name": "Loose"}));
|
||||
h.step();
|
||||
h.step();
|
||||
AlbumTree { h, archive, trips, sub, best, loose }
|
||||
}
|
||||
|
||||
impl AlbumTree {
|
||||
fn row(&self, id: u64) -> egui::Rect {
|
||||
let h = &self.h;
|
||||
let name = ["source:folder:", "source:album:"].iter().map(|p| format!("{p}{id}")).find(|n| h.app.widgets.iter().any(|(w, _)| w == n));
|
||||
widget(h, &name.unwrap_or_else(|| panic!("no row for album {id}")))
|
||||
}
|
||||
/// The middle of the "Albums" header, where an album is dropped to take it to the top level.
|
||||
fn albums_header(&self) -> egui::Pos2 {
|
||||
widget(&self.h, "sidebarSection:albums").center()
|
||||
}
|
||||
fn parent(&self, id: u64) -> Option<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])
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue