M1.14: stacks — group, expand/collapse, top of stack, auto-stack by time

Stack entity in the catalog (AddStack/RemoveStack/SetStack ops with
inverses, validation, snapshot + journal replay property test). Helpers
build ops for group (merging stacks), ungroup, remove, set top, collapse,
and stack_with_ops() for future merge results. Views arrange stack members
together and collapse to the top. Commands stack.* (Cmd+G, Cmd+Shift+G, S,
Shift+S, expand/collapse all, stack.auto with preview); grid/filmstrip
count badges (click to toggle), context menu, Auto-Stack dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 09:52:11 -04:00
co-authored by Claude Opus 5.5
parent 50741eceb4
commit 5434c5d172
15 changed files with 714 additions and 4 deletions
+52 -2
View File
@@ -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<Filter>,
},
AddStack {
stack: Stack,
},
RemoveStack {
id: StackId,
},
/// Replace a stack's members (first = top) and collapsed state.
SetStack {
id: StackId,
photos: Vec<PhotoId>,
collapsed: bool,
},
/// Several ops as one step (undo applies the inverses in reverse).
Batch {
ops: Vec<Op>,
@@ -130,6 +145,10 @@ pub struct Catalog {
albums: BTreeMap<AlbumId, Album>,
next_photo: u64,
next_album: u64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
stacks: BTreeMap<StackId, Stack>,
#[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<Op> = 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 }
}
+24
View File
@@ -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<PhotoId>,
/// 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<usize> {
self.photos.iter().position(|p| *p == id)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MediaKind {
+316
View File
@@ -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<Item = &Stack> {
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<PhotoId, (StackId, usize)> {
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<Op> {
let mut order: Vec<PhotoId> = Vec::new();
let push = |order: &mut Vec<PhotoId>, 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<StackId> = 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<Op> = 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<Op> {
self.group_ops(result, sources, true)
}
/// Ops that dissolve every stack containing one of `photos`.
pub fn ungroup_ops(&self, photos: &[PhotoId]) -> Vec<Op> {
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<Op> {
self.stacks
.values()
.filter(|s| s.photos.iter().any(|p| photos.contains(p)))
.map(|s| {
let rest: Vec<PhotoId> = 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<Op> {
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<Op> {
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<PhotoId> {
if self.stacks.is_empty() {
return ids.to_vec();
}
let index = self.stack_index();
let present: HashSet<PhotoId> = ids.iter().copied().collect();
let mut done: HashSet<StackId> = 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<Vec<PhotoId>> {
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<PhotoId>> = Vec::new();
let mut last: Option<i64> = 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<i64> {
let s = s.trim();
let num = |a: usize, b: usize| -> Option<i64> { 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<PhotoId>) {
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, &times);
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]]]);
}
}
+7 -1
View File
@@ -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<PhotoId> = 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),
}
}
+2
View File
@@ -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());
+142
View File
@@ -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<StackId> {
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<Op>) -> Result<usize> {
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<StackId>, collapsed: bool, label: &str) -> Result<Value> {
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<CommandSpec> {
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<StackId> = 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<StackId> = 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}))
}
),
]
}
+8
View File
@@ -102,6 +102,14 @@ pub fn specs() -> Vec<CommandSpec> {
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::<Vec<_>>());
v["history"] = json!(ph.history.iter().map(|h| h.label.clone()).collect::<Vec<_>>());
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::<Vec<_>>(),
"position": st.position(id),
"collapsed": st.collapsed,
});
}
Ok(v)
}),
cmd!(query "develop.get", "Get Develop Settings", [], None, "{id?}", always, |s, p| {
+3
View File
@@ -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
+52
View File
@@ -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);
}
+6
View File
@@ -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)]);
+6 -1
View File
@@ -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<Re
app.ui.dialog = Some(Dialog::NewAlbum { name: p.get("name").and_then(Value::as_str).unwrap_or("").into(), folder: false });
Ok(Value::Null)
}
"dialog.autoStack" => {
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<MenuEntry> {
.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),
})
+9
View File
@@ -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);
+30
View File
@@ -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<serde_json::Value, String> {
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}))
+53
View File
@@ -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!({}));
+4
View File
@@ -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,