mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
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:
co-authored by
Claude Opus 5.5
parent
50741eceb4
commit
5434c5d172
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ×);
|
||||
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]]]);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}))
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user