diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 6b3344744..7a86fb5cf 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -10,6 +10,7 @@ pub mod journal; pub mod model; pub mod query; +pub mod stacks; pub mod store; use std::collections::BTreeMap; @@ -28,6 +29,8 @@ pub enum CatalogError { NoPhoto(PhotoId), #[error("no such album {0:?}")] NoAlbum(AlbumId), + #[error("no such stack {0:?}")] + NoStack(StackId), #[error("invalid: {0}")] Invalid(String), #[error("corrupt catalog data: {0}")] @@ -118,6 +121,18 @@ pub enum Op { id: AlbumId, rules: Box, }, + AddStack { + stack: Stack, + }, + RemoveStack { + id: StackId, + }, + /// Replace a stack's members (first = top) and collapsed state. + SetStack { + id: StackId, + photos: Vec, + collapsed: bool, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -130,6 +145,10 @@ pub struct Catalog { albums: BTreeMap, next_photo: u64, next_album: u64, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + stacks: BTreeMap, + #[serde(default)] + next_stack: u64, /// Increments on every applied op. #[serde(skip)] pub revision: u64, @@ -137,7 +156,7 @@ pub struct Catalog { impl Catalog { pub fn new() -> Catalog { - Catalog { next_photo: 1, next_album: 1, ..Default::default() } + Catalog { next_photo: 1, next_album: 1, next_stack: 1, ..Default::default() } } // ---- ids @@ -152,6 +171,11 @@ impl Catalog { self.next_album = id.0 + 1; id } + pub fn alloc_stack_id(&mut self) -> StackId { + let id = StackId(self.next_stack.max(1)); + self.next_stack = id.0 + 1; + id + } // ---- reads @@ -369,6 +393,30 @@ impl Catalog { }; Op::SetAlbumRules { id, rules: std::mem::replace(old, rules) } } + Op::AddStack { stack } => { + if self.stacks.contains_key(&stack.id) { + return Err(CatalogError::Invalid(format!("stack {:?} exists", stack.id))); + } + self.validate_stack(stack.id, &stack.photos)?; + let id = stack.id; + self.next_stack = self.next_stack.max(id.0 + 1); + self.stacks.insert(id, stack); + Op::RemoveStack { id } + } + Op::RemoveStack { id } => { + let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + Op::AddStack { stack: s } + } + Op::SetStack { id, photos, collapsed } => { + if !self.stacks.contains_key(&id) { + return Err(CatalogError::NoStack(id)); + } + self.validate_stack(id, &photos)?; + let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; + let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + s.collapsed = collapsed; + old + } Op::Batch { ops } => { let mut inverses = Vec::with_capacity(ops.len()); for op in ops { @@ -389,7 +437,8 @@ impl Catalog { }) } - /// Ops that remove a photo permanently including album memberships (one undoable batch). + /// Ops that remove a photo permanently including album and stack memberships (one undoable + /// batch). pub fn delete_permanently_ops(&self, id: PhotoId) -> Op { let mut ops: Vec = self .albums @@ -397,6 +446,7 @@ impl Catalog { .filter(|a| a.photos.contains(&id)) .map(|a| Op::SetAlbumPhotos { id: a.id, photos: a.photos.iter().copied().filter(|p| *p != id).collect() }) .collect(); + ops.extend(self.remove_from_stacks_ops(&[id])); ops.push(Op::RemovePhoto { id }); Op::Batch { ops } } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index f0fa839ed..c27eefa97 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -13,6 +13,30 @@ pub struct PhotoId(pub u64); #[serde(transparent)] pub struct AlbumId(pub u64); +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct StackId(pub u64); + +/// A stack: photos grouped under a top photo (bursts, brackets, merge results with their sources). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Stack { + pub id: StackId, + /// Members in stack order; `photos[0]` is the top of the stack. At least two. + pub photos: Vec, + /// Collapsed stacks show only their top photo in the grid. + #[serde(default)] + pub collapsed: bool, +} + +impl Stack { + pub fn top(&self) -> PhotoId { + self.photos[0] + } + pub fn position(&self, id: PhotoId) -> Option { + self.photos.iter().position(|p| *p == id) + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum MediaKind { diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs new file mode 100644 index 000000000..ba97e0f80 --- /dev/null +++ b/crates/catalog/src/stacks.rs @@ -0,0 +1,316 @@ +//! Stacks: photos grouped under a top photo. +//! +//! A stack is catalog data ([`Stack`], changed by `AddStack` / `RemoveStack` / `SetStack` ops), so +//! grouping is undoable and journaled like everything else. The helpers here build the ops for the +//! usual gestures (group, ungroup, remove from stack, set top, collapse) and for programmatic +//! grouping such as merge results stacked with their sources ([`Catalog::stack_with_ops`]). +//! [`Catalog::arrange_stacks`] turns a query result into display order (members together, +//! collapsed stacks reduced to one photo). + +use std::collections::{HashMap, HashSet}; + +use crate::{Catalog, CatalogError, Op, PhotoId, Result, Stack, StackId}; + +impl Catalog { + pub fn stack(&self, id: StackId) -> Option<&Stack> { + self.stacks.get(&id) + } + pub fn stacks(&self) -> impl Iterator { + self.stacks.values() + } + /// The stack a photo belongs to. + pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { + self.stacks.values().find(|s| s.photos.contains(&photo)) + } + /// Photo → (stack, position in the stack), for every stacked photo. + pub fn stack_index(&self) -> HashMap { + self.stacks.values().flat_map(|s| s.photos.iter().enumerate().map(move |(i, p)| (*p, (s.id, i)))).collect() + } + + pub(crate) fn validate_stack(&self, id: StackId, photos: &[PhotoId]) -> Result<()> { + if photos.len() < 2 { + return Err(CatalogError::Invalid("a stack needs at least two photos".into())); + } + let mut seen = HashSet::new(); + for p in photos { + if !seen.insert(*p) { + return Err(CatalogError::Invalid(format!("photo {p:?} twice in a stack"))); + } + if !self.photos.contains_key(p) { + return Err(CatalogError::NoPhoto(*p)); + } + } + if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + return Err(CatalogError::Invalid("a photo can be in only one stack".into())); + } + Ok(()) + } + + /// Ops that put `top` and `members` into one stack with `top` on top. Stacks they already + /// belong to are merged into it (their other members follow, in order). `None` if fewer than + /// two photos would be in the stack. + pub fn group_ops(&mut self, top: PhotoId, members: &[PhotoId], collapsed: bool) -> Option { + let mut order: Vec = Vec::new(); + let push = |order: &mut Vec, p: PhotoId| { + if !order.contains(&p) && self.photos.contains_key(&p) { + order.push(p); + } + }; + push(&mut order, top); + for m in members { + push(&mut order, *m); + } + let mut touched: Vec = Vec::new(); + for p in order.clone() { + if let Some(s) = self.stack_of(p) + && !touched.contains(&s.id) + { + touched.push(s.id); + } + } + for sid in &touched { + for p in self.stacks[sid].photos.clone() { + push(&mut order, p); + } + } + if order.len() < 2 || order.first() != Some(&top) { + return None; + } + let mut ops: Vec = touched.iter().skip(1).map(|id| Op::RemoveStack { id: *id }).collect(); + match touched.first() { + Some(keep) => ops.push(Op::SetStack { id: *keep, photos: order, collapsed }), + None => { + let id = self.alloc_stack_id(); + ops.push(Op::AddStack { stack: Stack { id, photos: order, collapsed } }); + } + } + Some(Op::Batch { ops }) + } + + /// Stack `sources` under `result` (e.g. an HDR or panorama merge with the photos it was made + /// from), collapsed so the grid shows the result. + pub fn stack_with_ops(&mut self, result: PhotoId, sources: &[PhotoId]) -> Option { + self.group_ops(result, sources, true) + } + + /// Ops that dissolve every stack containing one of `photos`. + pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks.values().filter(|s| s.photos.iter().any(|p| photos.contains(p))).map(|s| Op::RemoveStack { id: s.id }).collect() + } + + /// Ops that take `photos` out of their stacks (a stack left with one photo is dissolved). + pub fn remove_from_stacks_ops(&self, photos: &[PhotoId]) -> Vec { + self.stacks + .values() + .filter(|s| s.photos.iter().any(|p| photos.contains(p))) + .map(|s| { + let rest: Vec = s.photos.iter().copied().filter(|p| !photos.contains(p)).collect(); + if rest.len() < 2 { Op::RemoveStack { id: s.id } } else { Op::SetStack { id: s.id, photos: rest, collapsed: s.collapsed } } + }) + .collect() + } + + /// The op that moves `photo` to the top of its stack (`None` if unstacked or already on top). + pub fn set_top_ops(&self, photo: PhotoId) -> Option { + let s = self.stack_of(photo)?; + if s.top() == photo { + return None; + } + let mut photos = vec![photo]; + photos.extend(s.photos.iter().copied().filter(|p| *p != photo)); + Some(Op::SetStack { id: s.id, photos, collapsed: s.collapsed }) + } + + /// Ops that collapse or expand stacks. + pub fn set_collapsed_ops(&self, stacks: &[StackId], collapsed: bool) -> Vec { + stacks + .iter() + .filter_map(|id| self.stacks.get(id)) + .filter(|s| s.collapsed != collapsed) + .map(|s| Op::SetStack { id: s.id, photos: s.photos.clone(), collapsed }) + .collect() + } + + /// Display order for a query result: a stack's members appear together (in stack order) where + /// its first member appears; a collapsed stack shows only its highest-ranked member in `ids` (the top, + /// unless the filter hides it). + pub fn arrange_stacks(&self, ids: &[PhotoId]) -> Vec { + if self.stacks.is_empty() { + return ids.to_vec(); + } + let index = self.stack_index(); + let present: HashSet = ids.iter().copied().collect(); + let mut done: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(ids.len()); + for id in ids { + match index.get(id) { + None => out.push(*id), + Some((sid, _)) => { + if !done.insert(*sid) { + continue; + } + let s = &self.stacks[sid]; + let members = s.photos.iter().copied().filter(|p| present.contains(p)); + if s.collapsed { + out.extend(members.take(1)); + } else { + out.extend(members); + } + } + } + } + out + } + + /// Auto-stack by capture time: runs of `ids` (not deleted, not already stacked, with a + /// capture time) whose consecutive captures are at most `gap_secs` apart, in time order. + /// Only runs of two or more are returned. + pub fn auto_stack_groups(&self, ids: &[PhotoId], gap_secs: f64) -> Vec> { + let index = self.stack_index(); + let mut timed: Vec<(i64, PhotoId)> = ids + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !index.contains_key(&p.id)) + .filter_map(|p| Some((iso_seconds(p.captured.as_deref()?)?, p.id))) + .collect(); + timed.sort(); + timed.dedup_by_key(|(_, id)| *id); + let mut groups: Vec> = Vec::new(); + let mut last: Option = None; + for (t, id) in timed { + match (last, groups.last_mut()) { + (Some(l), Some(g)) if (t - l) as f64 <= gap_secs => g.push(id), + _ => groups.push(vec![id]), + } + last = Some(t); + } + groups.retain(|g| g.len() >= 2); + groups + } +} + +/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, +/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. +pub fn iso_seconds(s: &str) -> Option { + let s = s.trim(); + let num = |a: usize, b: usize| -> Option { s.get(a..b).filter(|x| x.bytes().all(|c| c.is_ascii_digit())).and_then(|x| x.parse().ok()) }; + let (y, m, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if !(1..=12).contains(&m) || !(1..=31).contains(&d) { + return None; + } + let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) }; + // days from civil (proleptic Gregorian) + let y2 = if m <= 2 { y - 1 } else { y }; + let era = y2.div_euclid(400); + let yoe = y2 - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(days * 86_400 + hh * 3600 + mm * 60 + ss) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Photo, Source}; + + fn cat(n: usize, times: &[&str]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for i in 0..n { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + p.captured = times.get(i).map(|t| t.to_string()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + + #[test] + fn iso_seconds_parses() { + assert_eq!(iso_seconds("1970-01-01T00:00:00"), Some(0)); + assert_eq!(iso_seconds("1970-01-02"), Some(86_400)); + assert_eq!(iso_seconds("2026-04-01T10:00:05.123+02:00").unwrap() - iso_seconds("2026-04-01T10:00:00").unwrap(), 5); + assert_eq!(iso_seconds("2024-03-01").unwrap() - iso_seconds("2024-02-28").unwrap(), 2 * 86_400, "leap year"); + assert_eq!(iso_seconds("2000-01-01T00:00:00"), Some(946_684_800)); + assert_eq!(iso_seconds("garbage"), None); + } + + #[test] + fn group_merge_set_top_remove_and_arrange() { + let (mut c, ids) = cat(6, &[]); + let [a, b, x, d, e, f] = ids[..] else { unreachable!() }; + let op = c.group_ops(b, &[a, b, x], false).unwrap(); + let inv = c.apply(op).unwrap(); + let s = c.stack_of(a).unwrap().clone(); + assert_eq!(s.photos, vec![b, a, x]); + // query order a b x d e f → stack shown where its first member appears, in stack order + assert_eq!(c.arrange_stacks(&ids), vec![b, a, x, d, e, f]); + // collapse: only the top + let ops = c.set_collapsed_ops(&[s.id], true); + c.apply(Op::Batch { ops }).unwrap(); + assert_eq!(c.arrange_stacks(&ids), vec![b, d, e, f]); + // filtered-out top: first visible member stands in + assert_eq!(c.arrange_stacks(&[x, d, a]), vec![a, d]); + // grouping with another stack merges both + let op = c.group_ops(d, &[e], false).unwrap(); + c.apply(op).unwrap(); + let op = c.group_ops(f, &[a, e], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.stacks().count(), 1); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, a, e, b, x, d]); + // set top, remove from stack, ungroup + c.apply(c.set_top_ops(x).unwrap()).unwrap(); + assert_eq!(c.stack_of(f).unwrap().top(), x); + assert!(c.set_top_ops(x).is_none()); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[x, a, e, b]) }).unwrap(); + assert_eq!(c.stack_of(f).unwrap().photos, vec![f, d]); + c.apply(Op::Batch { ops: c.remove_from_stacks_ops(&[d]) }).unwrap(); + assert_eq!(c.stacks().count(), 0, "a one-photo stack dissolves"); + // undo everything back to the first group's inverse state + let (mut c2, _) = cat(6, &[]); + let op = c2.group_ops(a, &[b], false).unwrap(); + c2.apply(op).unwrap(); + c2.apply(Op::Batch { ops: c2.ungroup_ops(&[b]) }).unwrap(); + assert_eq!(c2.stacks().count(), 0); + let _ = inv; + } + + #[test] + fn stack_validation_and_permanent_delete() { + let (mut c, ids) = cat(3, &[]); + let id = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0]], collapsed: false } }).is_err(), "one photo"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[0]], collapsed: false } }).is_err(), "duplicate"); + assert!(c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], PhotoId(99)], collapsed: false } }).is_err(), "missing"); + c.apply(Op::AddStack { stack: Stack { id, photos: vec![ids[0], ids[1]], collapsed: true } }).unwrap(); + let id2 = c.alloc_stack_id(); + assert!(c.apply(Op::AddStack { stack: Stack { id: id2, photos: vec![ids[1], ids[2]], collapsed: false } }).is_err(), "one stack per photo"); + // deleting a member permanently dissolves the 2-photo stack; undo restores it + let before = c.to_snapshot(); + let inv = c.apply(c.delete_permanently_ops(ids[1])).unwrap(); + assert!(c.stack_of(ids[0]).is_none()); + c.apply(inv).unwrap(); + assert_eq!(c.to_snapshot(), before); + // snapshot round trip keeps stacks and the id allocator + let back = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + assert_eq!(back.stack(id), c.stack(id)); + assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); + } + + #[test] + fn auto_stack_by_capture_time() { + let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; + let (mut c, ids) = cat(6, ×); + let g = c.auto_stack_groups(&ids, 5.0); + assert_eq!(g, vec![vec![ids[0], ids[1]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[0], ids[1], ids[2]], vec![ids[3], ids[4]]]); + assert_eq!(c.auto_stack_groups(&ids, 3600.0).len(), 1); + // already stacked photos are skipped + let op = c.group_ops(ids[0], &[ids[1]], true).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.auto_stack_groups(&ids, 10.0), vec![vec![ids[3], ids[4]]]); + } +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 1cd6e6c47..d04427bae 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -26,7 +26,7 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { text: if b.is_multiple_of(5) { format!("{}", b % 10) } else { String::new() }, ..Default::default() }; - match kind % 12 { + match kind % 15 { 0 | 1 => { let id = c.alloc_photo_id(); let mut p = Photo::new(id, Source::File { path: format!("/p/{}.jpg", id.0) }, &format!("{}.jpg", id.0), "JPEG", 60, 40, "2026-01-01"); @@ -61,6 +61,12 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { Op::AddAlbum { album: Album { smart: Some(Box::new(rules(b))), ..Album::new(id, format!("Smart {b}")) } } } 11 => Op::SetAlbumRules { id: aid, rules: Box::new(rules(b)) }, + 12 => { + let members: Vec = photos.iter().copied().filter(|p| (p.0 + b as u64).is_multiple_of(4)).collect(); + c.group_ops(pid, &members, b.is_multiple_of(2)).unwrap_or(Op::Batch { ops: vec![] }) + } + 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, + 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), _ => c.delete_permanently_ops(pid), } } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3e1b8c540..cce167ed7 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ mod develop; mod edit; mod library; mod masks; +mod organize; mod preset_files; mod query; mod xmp; @@ -102,6 +103,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(library::specs()); v.extend(develop::specs()); v.extend(masks::specs()); + v.extend(organize::specs()); v.extend(query::specs()); v.extend(xmp::specs()); v.extend(preset_files::specs()); diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs new file mode 100644 index 000000000..8b4b6f73c --- /dev/null +++ b/crates/engine/src/cmd/organize.rs @@ -0,0 +1,142 @@ +//! Organizing commands: stacks (group, ungroup, set top, expand/collapse, auto-stack by capture +//! time). + +use lightcraft_catalog::{Op, PhotoId, StackId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, has_selection}; +use crate::{Result, Selection, Session}; + +/// Stacks containing any of `targets`, in first-seen order. +fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec { + let mut v = Vec::new(); + for t in targets { + if let Some(st) = s.catalog.stack_of(*t) + && !v.contains(&st.id) + { + v.push(st.id); + } + } + v +} + +fn in_stack(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } +} + +fn has_stacks(s: &Session) -> std::result::Result<(), String> { + if s.catalog.stacks().next().is_none() { Err("no stacks".into()) } else { Ok(()) } +} + +fn commit_ops(s: &mut Session, label: &str, ops: Vec) -> Result { + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + Ok(n) +} + +/// After collapsing, keep the active photo visible (move it to its stack's top). +fn fix_active(s: &mut Session) { + let vis = s.visible_cloned(); + if let Some(a) = s.selection.active + && !vis.contains(&a) + && let Some(st) = s.catalog.stack_of(a) + { + let top = vis.iter().copied().find(|p| st.photos.contains(p)).unwrap_or(st.top()); + s.selection = Selection::single(top); + } +} + +fn set_collapsed(s: &mut Session, stacks: Vec, collapsed: bool, label: &str) -> Result { + let ops = s.catalog.set_collapsed_ops(&stacks, collapsed); + let n = commit_ops(s, label, ops)?; + if collapsed { + fix_active(s); + } + Ok(json!({"changed": n})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "stack.group", + "Group into Stack", + ["Photo", "Stack"], + Some("Cmd+G"), + "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", + has_selection, + |s, p| { + let targets = s.targets(p); + let top = p + .get("top") + .and_then(Value::as_u64) + .map(PhotoId) + .or(s.selection.active.filter(|a| targets.contains(a))) + .or(targets.first().copied()) + .ok_or_else(|| bad("stack.group", "no photos"))?; + let op = s + .catalog + .group_ops(top, &targets, bool_or(p, "collapsed", true)) + .ok_or_else(|| bad("stack.group", "select at least two photos"))?; + s.commit("Group into Stack", op)?; + let st = s.catalog.stack_of(top).map(|st| (st.id.0, st.photos.len())).unwrap_or_default(); + s.selection = Selection::single(top); + Ok(json!({"stack": st.0, "count": st.1})) + } + ), + cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { + let ops = s.catalog.ungroup_ops(&s.targets(p)); + Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) + }), + cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) + }), + cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { + let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?; + let op = s.catalog.set_top_ops(id); + Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?})) + }), + cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { + let stacks = stacks_of(s, &s.targets(p)); + let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); + let collapsed = bool_or(p, "collapsed", !first); + set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) + }), + cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, false, "Expand All Stacks") + }), + cmd!("stack.collapseAll", "Collapse All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| { + let all: Vec = s.catalog.stacks().map(|st| st.id).collect(); + set_collapsed(s, all, true, "Collapse All Stacks") + }), + cmd!( + "stack.auto", + "Auto-Stack by Capture Time", + [], + None, + "{gap?: seconds between consecutive captures (default 60), ids? (default: the selection if several photos, else the current view), preview?: bool} → {stacks, photos}", + always, + |s, p| { + let gap = f64_or(p, "gap", 60.0).max(0.0); + let explicit = p.get("ids").is_some(); + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let groups = s.catalog.auto_stack_groups(&pool, gap); + let photos: usize = groups.iter().map(Vec::len).sum(); + if bool_or(p, "preview", false) { + return Ok(json!({"stacks": groups.len(), "photos": photos})); + } + let mut ops = Vec::new(); + for g in &groups { + ops.extend(s.catalog.group_ops(g[0], g, true)); + } + commit_ops(s, "Auto-Stack", ops)?; + fix_active(s); + Ok(json!({"stacks": groups.len(), "photos": photos})) + } + ), + ] +} diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 676773191..db4be842d 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -102,6 +102,14 @@ pub fn specs() -> Vec { let mut v = serde_json::to_value(ph.as_ref()).unwrap_or_default(); v["albums"] = json!(s.catalog.albums_of(id).iter().map(|a| a.0).collect::>()); v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::>()); + if let Some(st) = s.catalog.stack_of(id) { + v["stack"] = json!({ + "id": st.id.0, + "photos": st.photos.iter().map(|p| p.0).collect::>(), + "position": st.position(id), + "collapsed": st.collapsed, + }); + } Ok(v) }), cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 18b405495..39b1dc744 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -294,6 +294,9 @@ impl Session { self.visible.reverse(); } } + if self.source != LibrarySource::RecentlyDeleted { + self.visible = self.catalog.arrange_stacks(&self.visible); + } self.visible_key = Some(key); } &self.visible diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index b088e718a..2930ec689 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -78,3 +78,55 @@ fn smart_album_from_smart_album_view_keeps_its_rules() { assert_eq!((r.flag, r.rating, r.album), (Some(Flag::Pick), 3, None)); assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(id))); } + +#[test] +fn stacks_group_collapse_and_replay() { + let dir = temp_dir("stacks"); + let mut s = open(&dir); + let vis = s.visible_cloned(); + let n = vis.len(); + let (a, b, c) = (vis[2], vis[3], vis[4]); + s.execute("library.select", &json!({"ids": [a.0, b.0, c.0], "active": b.0})).unwrap(); + let r = s.execute("stack.group", &json!({})).unwrap(); + assert_eq!(r["count"], 3); + // collapsed by default: the top (the active photo) stands for the stack + let v = s.visible_cloned(); + assert_eq!(v.len(), n - 2); + assert!(v.contains(&b) && !v.contains(&a) && !v.contains(&c)); + assert_eq!(s.selection.active, Some(b)); + let info = s.execute("photo.inspect", &json!({"id": a.0})).unwrap(); + assert_eq!(info["stack"]["photos"], json!([b.0, a.0, c.0])); + // expand: members follow the top + s.execute("stack.toggle", &json!({})).unwrap(); + let v = s.visible_cloned(); + let i = v.iter().position(|x| *x == b).unwrap(); + assert_eq!(&v[i..i + 3], &[b, a, c]); + // set top, collapse with a member active → active moves to the visible top + s.execute("library.select", &json!({"ids": [c.0]})).unwrap(); + s.execute("stack.setTop", &json!({})).unwrap(); + s.execute("library.select", &json!({"ids": [a.0]})).unwrap(); + s.execute("stack.toggle", &json!({})).unwrap(); + assert_eq!(s.selection.active, Some(c)); + // remove from stack; undo + s.execute("stack.remove", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos, vec![c, b]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(c).unwrap().photos.len(), 3); + // auto-stack the rest of the view: a huge gap groups everything unstacked + let pre = s.execute("stack.auto", &json!({"gap": 1e9, "preview": true})).unwrap(); + assert_eq!(pre["stacks"], 1); + assert_eq!(s.catalog.stacks().count(), 1, "preview changes nothing"); + s.execute("stack.auto", &json!({"gap": 1e9})).unwrap(); + assert_eq!(s.catalog.stacks().count(), 2); + assert_eq!(s.visible_cloned().len(), 2); + s.execute("stack.expandAll", &json!({})).unwrap(); + assert_eq!(s.visible_cloned().len(), n); + let expect = s.catalog.to_snapshot(); + drop(s); + let mut s2 = open(&dir); + assert_eq!(s2.catalog.to_snapshot(), expect); + s2.execute("library.select", &json!({"ids": [b.0]})).unwrap(); + s2.execute("stack.ungroup", &json!({})).unwrap(); + assert_eq!(s2.catalog.stacks().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 439f15ad4..a871b0d17 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -46,6 +46,7 @@ pub enum Icon { Minus, Album, SmartAlbum, + Stack, Folder, Photos, Clock, @@ -303,6 +304,11 @@ pub fn paint(p: &Painter, rect: Rect, icon: Icon, color: Color32) { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); } + Stack => { + pen.rect(2.5, 7.0, 13.5, 17.5, 1.0); + pen.line(&[(5.0, 4.5), (16.0, 4.5), (16.0, 14.5)]); + pen.line(&[(7.5, 2.0), (18.5, 2.0), (18.5, 11.5)]); + } SmartAlbum => { pen.rect(3.0, 5.0, 15.0, 17.0, 1.0); pen.line(&[(6.0, 2.5), (17.5, 2.5), (17.5, 14.0)]); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6916f80f7..8048fa06b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -54,6 +54,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.newAlbum", "New Album…", Some("Cmd+N"), "File"), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), + ("dialog.autoStack", "Auto-Stack by Capture Time…", None, "Photo>Stack"), ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), @@ -267,6 +268,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { + app.ui.dialog = Some(Dialog::AutoStack { gap: p.get("gap").and_then(Value::as_f64).unwrap_or(60.0) as f32 }); + Ok(Value::Null) + } "dialog.newSmartAlbum" => { app.ui.dialog = Some(Dialog::NewSmartAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into() }); Ok(Value::Null) @@ -389,7 +394,7 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec { .map(|(id, label, sc, m)| MenuEntry { id: id.to_string(), label: label.to_string(), - menu: vec![m.to_string()], + menu: m.split('>').map(str::to_string).collect(), shortcut: sc.map(str::to_string), enabled: ui_enabled(app, id), }) diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 32cc8ea13..222ad21de 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -702,6 +702,15 @@ fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { if sel { p.rect_stroke(fr, 0.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); } + if let Some(st) = app.session.catalog.stack_of(*id) { + let text = + if st.collapsed { st.photos.len().to_string() } else { format!("{}/{}", st.position(*id).unwrap_or(0) + 1, st.photos.len()) }; + let g = p.layout_no_wrap(text, t.semibold(9.5), Color32::WHITE); + let br = Rect::from_min_size(fr.min + vec2(3.0, 3.0), vec2(g.size().x + 22.0, 15.0)); + p.rect_filled(br, 7.5, Color32::from_black_alpha(170)); + crate::icons::paint(p, Rect::from_min_size(br.min + vec2(4.0, 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 17.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + } } if resp.clicked() { let m = ui.input(|i| i.modifiers); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 340556a59..bf0536cbd 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -23,6 +23,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::NewAlbum { .. } => "Create Album", Dialog::RenameAlbum { .. } => "Rename Album", Dialog::NewSmartAlbum { .. } => "Create Smart Album", + Dialog::AutoStack { .. } => "Auto-Stack by Capture Time", Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", @@ -39,6 +40,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; match &mut dlg { + Dialog::AutoStack { gap } => { + ui.label(egui::RichText::new("Stack photos taken within this time of each other:").color(t.text_label)); + ui.add( + egui::Slider::new(gap, 0.0..=86400.0) + .logarithmic(true) + .smallest_positive(1.0) + .custom_formatter(|v, _| crate::panels::dialogs::fmt_gap(v)) + .custom_parser(|s| s.trim().trim_end_matches('s').parse().ok()), + ); + let preview = app.session.execute("stack.auto", &json!({"gap": *gap, "preview": true})).unwrap_or_default(); + let scope = if app.session.selection.ids.len() > 1 { "the selected photos" } else { "the photos in view" }; + ui.label( + egui::RichText::new(format!("Creates {} stacks from {} of {scope}", preview["stacks"], preview["photos"])).color(t.text_dim), + ); + } Dialog::NewSmartAlbum { name } => { let r = ui.add(egui::TextEdit::singleline(name).hint_text("Name").desired_width(f32::INFINITY)); r.request_focus(); @@ -228,10 +244,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } /// Apply a dialog's action (also used by `ui.dialog.confirm`). +/// `90` → `1 min 30 s`. +pub fn fmt_gap(v: f64) -> String { + let v = v.round() as u64; + match v { + 0..60 => format!("{v} s"), + 60..3600 if v.is_multiple_of(60) => format!("{} min", v / 60), + 60..3600 => format!("{} min {} s", v / 60, v % 60), + 3600..86400 if v.is_multiple_of(3600) => format!("{} h", v / 3600), + 3600..86400 => format!("{} h {} min", v / 3600, v % 3600 / 60), + _ => "1 day".into(), + } +} + pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { match dlg { Dialog::NewAlbum { name, folder } => app.run("album.create", json!({"name": name, "folder": folder, "addSelected": !folder})), Dialog::RenameAlbum { id, name } => app.run("album.rename", json!({"id": id, "name": name})), + Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::NewSmartAlbum { name } => app.run("album.createSmart", json!({"name": if name.trim().is_empty() { "Smart Album" } else { name }})), Dialog::CreatePreset { name, group } => { app.run("preset.create", json!({"name": if name.is_empty() { "My Preset" } else { name }, "group": group})) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index dc8b83daf..8bad73947 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -106,6 +106,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.data_mut(|d| d.insert_temp(key, active)); if last != active { active.and_then(|a| cells.iter().find(|c| c.id == a).map(|c| c.rect)) } else { None } }; + let stacks = app.session.catalog.stack_index(); let mut visible_ids = HashSet::new(); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); @@ -122,6 +123,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let r = c.rect.translate(origin.to_vec2()); let onscreen = c.rect.intersects(viewport); cell(app, ui, c.id, r, square, onscreen, ppp); + if let Some((sid, pos)) = stacks.get(&c.id) { + stack_badge(app, ui, c.id, *sid, *pos, r, square); + } } }); app.renderer.evict_thumbs(&visible_ids, 600); @@ -216,6 +220,36 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square resp.context_menu(|ui| context_menu(app, ui, id)); } +/// Stack badge at the cell's top-left: the photo count on a collapsed stack's top, `i/n` on the +/// members of an expanded stack. Clicking it expands/collapses the stack. +pub fn stack_badge(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, sid: lightcraft_catalog::StackId, pos: usize, r: Rect, square: bool) { + let t = Tokens::get(ui.ctx()); + let Some(st) = app.session.catalog.stack(sid) else { return }; + let n = st.photos.len(); + let collapsed = st.collapsed; + let text = if collapsed { n.to_string() } else { format!("{}/{n}", pos + 1) }; + let p = ui.painter(); + let g = p.layout_no_wrap(text, t.semibold(11.0), Color32::WHITE); + let origin = if square { r.min + vec2(10.0, 26.0) } else { r.min + vec2(5.0, 5.0) }; + let br = Rect::from_min_size(origin, vec2(g.size().x + 30.0, 20.0)); + let resp = ui.interact(br, egui::Id::new(("stack-badge", id.0)), Sense::click()).on_hover_text(if collapsed { + "Stack — click to expand (S)" + } else { + "Stack — click to collapse (S)" + }); + register(ui.ctx(), format!("stack:{}", id.0), br); + let fill = if resp.hovered() { Color32::from_black_alpha(220) } else { Color32::from_black_alpha(165) }; + p.rect_filled(br, 10.0, fill); + if pos == 0 && !collapsed { + p.rect_stroke(br, 10.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } + paint(p, Rect::from_min_size(br.min + vec2(6.0, 3.0), vec2(14.0, 14.0)), Icon::Stack, Color32::WHITE); + p.galley(pos2(br.min.x + 24.0, br.center().y - g.size().y / 2.0), g, Color32::WHITE); + if resp.clicked() { + let _ = app.run("stack.toggle", json!({"ids": [id.0]})); + } +} + pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if !app.session.selection.contains(id) { let _ = app.run("library.select", json!({"ids": [id.0]})); @@ -246,6 +280,25 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } } }); + ui.menu_button("Stack", |ui| { + let stacked = app.session.catalog.stack_of(id).is_some(); + let several = app.session.selection.ids.len() > 1; + for (label, cmd, on) in [ + ("Group into Stack", "stack.group", several), + ("Ungroup Stack", "stack.ungroup", stacked), + ("Remove from Stack", "stack.remove", stacked), + ("Set as Top of Stack", "stack.setTop", stacked), + ("Expand/Collapse Stack", "stack.toggle", stacked), + ] { + if ui.add_enabled(on, egui::Button::new(label)).clicked() { + let _ = app.run(cmd, json!({})); + } + } + ui.separator(); + if ui.button("Auto-Stack by Capture Time…").clicked() { + let _ = app.run("dialog.autoStack", json!({})); + } + }); ui.separator(); if ui.button("Copy Edit Settings").clicked() { let _ = app.run("develop.copy", json!({})); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8e3dfc4ee..c2cf3c715 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -127,6 +127,10 @@ pub enum Dialog { id: u64, name: String, }, + /// Auto-stack by capture time: the largest gap between consecutive shots, in seconds. + AutoStack { + gap: f32, + }, /// Save the current view (source + filter) as a smart album. NewSmartAlbum { name: String,