From 89b8aa3f778be3731bfa0684f2dcee26ba5a135a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:52:07 -0400 Subject: [PATCH] LR-VIEW-PHOTOGRID: group the photo/square grid by date with day/month/year headers Catalog date runs (stacks never split), Sort.group (auto/day/month/year/none) in the sort menus, library.groups query, sticky headers, header click selects the group; layout unit tests and a headless test. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/dates.rs | 232 ++++++++++++++++++++++ crates/catalog/src/lib.rs | 2 + crates/catalog/src/query.rs | 4 +- crates/catalog/src/tests.rs | 2 +- crates/engine/src/cmd/library.rs | 26 ++- crates/engine/src/import.rs | 15 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_libops.rs | 34 ++++ crates/ui-egui/src/headless.rs | 26 +++ crates/ui-egui/src/menubar.rs | 10 + crates/ui-egui/src/panels/bottombar.rs | 14 ++ crates/ui-egui/src/panels/grid.rs | 263 ++++++++++++++++++++----- docs/parity.md | 5 +- 13 files changed, 570 insertions(+), 65 deletions(-) create mode 100644 crates/catalog/src/dates.rs create mode 100644 crates/engine/src/tests_libops.rs diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs new file mode 100644 index 00000000..53e1019e --- /dev/null +++ b/crates/catalog/src/dates.rs @@ -0,0 +1,232 @@ +//! Calendar helpers for ISO 8601 local times (`2026-09-30T14:05:00`): seconds ↔ civil date, +//! weekday and month names, shifting a time by an offset, and grouping a photo list into runs of +//! the same day / month / year (the date headers of the photo grid). + +use serde::{Deserialize, Serialize}; + +pub use crate::stacks::iso_seconds; +use crate::{Catalog, PhotoId, SortKey}; + +pub const WEEKDAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +pub const MONTHS: [&str; 12] = + ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (proleptic Gregorian; H. Hinnant's algorithm). +pub fn civil(secs: i64) -> String { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60) +} + +/// Days since 1970-01-01 → (year, month 1..=12, day 1..=31). +pub fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + i64::from(m <= 2), m, d) +} + +/// Weekday name of an ISO date (`None` if it doesn't parse). +pub fn weekday(iso: &str) -> Option<&'static str> { + let days = iso_seconds(iso.get(..10)?)?.div_euclid(86_400); + // 1970-01-01 was a Thursday + Some(WEEKDAYS[(days + 3).rem_euclid(7) as usize]) +} + +/// Shift an ISO time by `secs`, keeping anything after the seconds (fractions, zone) as it was. +/// A date without a time gets one. `None` if it doesn't parse. +pub fn shift_iso(iso: &str, secs: i64) -> Option { + let t = iso_seconds(iso)?; + let tail = iso.trim().get(19..).unwrap_or(""); + Some(format!("{}{tail}", civil(t + secs))) +} + +/// Normalize user input to `YYYY-MM-DDTHH:MM:SS` (accepts a space instead of `T`, missing +/// seconds or time). `None` if it isn't a valid date. +pub fn normalize_iso(s: &str) -> Option { + let t = iso_seconds(s)?; + let out = civil(t); + // reject impossible dates that the arithmetic silently rolls over (2026-02-30) + if out.get(..10) != s.trim().get(..10) { + return None; + } + Some(out) +} + +/// Granularity of the grid's date headers. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum GroupBy { + /// Day headers, or months/years when the thumbnails are small (decided by the UI). + #[default] + Auto, + None, + Day, + Month, + Year, +} + +impl GroupBy { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "auto" => GroupBy::Auto, + "none" | "off" => GroupBy::None, + "day" => GroupBy::Day, + "month" => GroupBy::Month, + "year" => GroupBy::Year, + _ => return None, + }) + } + /// Length of the ISO prefix that identifies a group. + fn prefix(self) -> usize { + match self { + GroupBy::Year => 4, + GroupBy::Month => 7, + _ => 10, + } + } +} + +/// A run of consecutive photos with the same date (one header in the grid). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DateRun { + /// `2026-09-30` / `2026-09` / `2026`, or empty for photos without a usable date. + pub key: String, + /// "Wednesday, 30 September 2026" / "September 2026" / "2026" / "Unknown Date". + pub label: String, + /// Index of the first photo in the list. + pub start: usize, + pub count: usize, +} + +/// Header text for a group key. +pub fn group_label(key: &str) -> String { + let month = |k: &str| k.get(5..7).and_then(|m| m.parse::().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]); + match key.len() { + 10 => match (weekday(key), month(key), key[8..10].parse::().ok()) { + (Some(w), Some(m), Some(d)) => format!("{w}, {d} {m} {}", &key[..4]), + _ => key.to_string(), + }, + 7 => month(key).map(|m| format!("{m} {}", &key[..4])).unwrap_or_else(|| key.to_string()), + 4 => key.to_string(), + _ => "Unknown Date".into(), + } +} + +impl Catalog { + /// Split `ids` (the grid order) into runs of the same day / month / year for `key`'s date. + /// Members of a stack share the group of the stack's first photo in the list, so a stack is + /// never split by a header. Empty for sort keys without a date and for [`GroupBy::None`] + /// ([`GroupBy::Auto`] means days here). + pub fn date_runs(&self, ids: &[PhotoId], key: SortKey, by: GroupBy) -> Vec { + if by == GroupBy::None || ids.is_empty() { + return Vec::new(); + } + let n = by.prefix(); + let date_of = |id: &PhotoId| -> String { + let Some(p) = self.photo(*id) else { return String::new() }; + let d = match key { + SortKey::CaptureDate => Some(p.date()), + SortKey::ImportDate => Some(p.imported.as_str()), + SortKey::EditDate => p.edited.as_deref(), + _ => None, + }; + d.filter(|d| iso_seconds(d.get(..10).unwrap_or("")).is_some()).and_then(|d| d.get(..n)).unwrap_or("").to_string() + }; + if !matches!(key, SortKey::CaptureDate | SortKey::ImportDate | SortKey::EditDate) { + return Vec::new(); + } + let stacks = self.stack_index(); + let mut runs: Vec = Vec::new(); + let mut stack_key: std::collections::HashMap = Default::default(); + for (i, id) in ids.iter().enumerate() { + let k = match stacks.get(id) { + Some((sid, _)) => stack_key.entry(*sid).or_insert_with(|| date_of(id)).clone(), + None => date_of(id), + }; + match runs.last_mut() { + Some(r) if r.key == k => r.count += 1, + _ => runs.push(DateRun { label: group_label(&k), key: k, start: i, count: 1 }), + } + } + runs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Op, Photo, Source}; + + #[test] + fn calendar() { + assert_eq!(civil(0), "1970-01-01T00:00:00"); + assert_eq!(civil(951_782_400), "2000-02-29T00:00:00"); + assert_eq!(weekday("2026-09-30"), Some("Wednesday")); + assert_eq!(weekday("2026-10-01T09:00:00"), Some("Thursday")); + assert_eq!(weekday("2000-02-29"), Some("Tuesday")); + assert_eq!(group_label("2026-09-29"), "Tuesday, 29 September 2026"); + assert_eq!(group_label("2026-02"), "February 2026"); + assert_eq!(group_label("2026"), "2026"); + assert_eq!(group_label(""), "Unknown Date"); + assert_eq!(shift_iso("2026-12-31T23:30:00", 3600).as_deref(), Some("2027-01-01T00:30:00")); + assert_eq!(shift_iso("2026-03-01T00:00:00.25+02:00", -1).as_deref(), Some("2026-02-28T23:59:59.25+02:00")); + assert_eq!(shift_iso("nope", 5), None); + assert_eq!(normalize_iso("2026-04-01 10:05").as_deref(), Some("2026-04-01T10:05:00")); + assert_eq!(normalize_iso("2026-02-30"), None); + } + + #[test] + fn runs_by_day_month_year_keep_stacks_together() { + let mut c = Catalog::new(); + let times = ["2026-09-30T18:00:00", "2026-09-30T09:00:00", "2026-09-29T23:59:00", "2026-08-01T10:00:00", "2025-12-31T10:00:00", ""]; + let mut ids = Vec::new(); + for t in times { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-10-01T00:00:00"); + p.captured = (!t.is_empty()).then(|| t.to_string()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let shape = |r: &[DateRun]| r.iter().map(|r| (r.key.clone(), r.start, r.count)).collect::>(); + let day = c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Day); + // the undated photo falls back to its import date + assert_eq!( + shape(&day), + vec![ + ("2026-09-30".into(), 0, 2), + ("2026-09-29".into(), 2, 1), + ("2026-08-01".into(), 3, 1), + ("2025-12-31".into(), 4, 1), + ("2026-10-01".into(), 5, 1) + ] + ); + assert_eq!(day[0].label, "Wednesday, 30 September 2026"); + assert_eq!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::Auto), day); + assert_eq!( + shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Month)), + vec![("2026-09".into(), 0, 3), ("2026-08".into(), 3, 1), ("2025-12".into(), 4, 1)] + ); + assert_eq!(shape(&c.date_runs(&ids[..5], SortKey::CaptureDate, GroupBy::Year)), vec![("2026".into(), 0, 4), ("2025".into(), 4, 1)]); + assert!(c.date_runs(&ids, SortKey::FileName, GroupBy::Day).is_empty()); + assert!(c.date_runs(&ids, SortKey::CaptureDate, GroupBy::None).is_empty()); + // edit date: unedited photos have no date + let r = c.date_runs(&ids, SortKey::EditDate, GroupBy::Day); + assert_eq!(shape(&r), vec![(String::new(), 0, 6)]); + assert_eq!(r[0].label, "Unknown Date"); + // a stack spanning midnight stays in its first member's group + let op = c.group_ops(ids[1], &[ids[1], ids[2]], false).unwrap(); + c.apply(op).unwrap(); + let arranged = c.arrange_stacks(&ids[..5]); + assert_eq!( + shape(&c.date_runs(&arranged, SortKey::CaptureDate, GroupBy::Day))[..2], + [("2026-09-30".into(), 0, 3), ("2026-08-01".into(), 3, 1)] + ); + } +} diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 7a86fb5c..40d78b56 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -7,6 +7,7 @@ //! - **determinism**: replaying the log reproduces the state exactly (property-tested). #![forbid(unsafe_code)] +pub mod dates; pub mod journal; pub mod model; pub mod query; @@ -16,6 +17,7 @@ pub mod store; use std::collections::BTreeMap; use std::sync::Arc; +pub use dates::{DateRun, GroupBy}; pub use journal::{Journal, LoadReport, SnapshotPolicy}; use lightcraft_develop::DevelopSettings; pub use model::*; diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index 389c01e3..fc280c03 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -60,11 +60,13 @@ pub enum SortKey { pub struct Sort { pub key: SortKey, pub ascending: bool, + /// Date headers in the grid (date sort keys only). + pub group: crate::GroupBy, } impl Default for Sort { fn default() -> Self { - Sort { key: SortKey::CaptureDate, ascending: false } + Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto } } } diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index a18f9479..10830758 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -88,7 +88,7 @@ fn filter_search_sort() { let all = c.query(&Filter::default(), &Sort::default()); assert_eq!(all, vec![a, b, d], "newest first"); - let asc = c.query(&Filter::default(), &Sort { key: SortKey::FileName, ascending: true }); + let asc = c.query(&Filter::default(), &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }); assert_eq!(asc, vec![b, a, d]); assert_eq!(c.query(&Filter { rating: 4, ..Default::default() }, &Sort::default()), vec![b]); assert_eq!(c.query(&Filter { flag: Some(Flag::Reject), ..Default::default() }, &Sort::default()), vec![d]); diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 4cbfc22d..bbc843c1 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1,7 +1,7 @@ //! Library commands: view source, filter/sort, selection, ratings/flags/labels, rotate, delete, //! metadata, albums, import. -use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, Op, PhotoId, Sort, SortKey}; +use lightcraft_catalog::{Album, AlbumId, ColorLabel, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; @@ -130,17 +130,37 @@ pub fn specs() -> Vec { "Sort", ["View", "Sort"], None, - "{key: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool}", + "{key?: captureDate|importDate|editDate|fileName|rating|fileSize, ascending?: bool, group?: auto|none|day|month|year}", always, |s, p| { let key: SortKey = match p.get("key") { Some(k) => serde_json::from_value(k.clone()).map_err(|e| bad("library.sort", e.to_string()))?, None => s.sort.key, }; - s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending) }; + let group = match str_param(p, "group") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.sort", "group must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + s.sort = Sort { key, ascending: bool_or(p, "ascending", s.sort.ascending), group }; ok() } ), + cmd!( + query "library.groups", + "Date Groups", + [], + None, + "{by?: day|month|year (default: the sort's grouping; auto = day)} → [{key, label, start, count}] date headers of the grid", + always, + |s, p| { + let by = match str_param(p, "by") { + Some(g) => GroupBy::parse(g).ok_or_else(|| bad("library.groups", "by must be auto|none|day|month|year"))?, + None => s.sort.group, + }; + let vis = s.visible_cloned(); + Ok(serde_json::to_value(s.catalog.date_runs(&vis, s.sort.key, by)).unwrap_or_default()) + } + ), // ---- selection cmd!("library.select", "Select Photos", [], None, "{ids: [id], active?: id, mode?: replace|add|toggle|range}", always, |s, p| { let ids = ids_param(p).unwrap_or_default(); diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index e07199d6..ccd24c60 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -274,20 +274,9 @@ pub fn system_clock() -> String { } } -/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian (H. Hinnant's algorithm). +/// Unix seconds → `YYYY-MM-DDTHH:MM:SS` (UTC), proleptic Gregorian. pub fn civil(secs: i64) -> String { - let days = secs.div_euclid(86_400); - let rem = secs.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = yoe + era * 400 + i64::from(m <= 2); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}", rem / 3600, rem % 3600 / 60, rem % 60) + lightcraft_catalog::dates::civil(secs) } impl Session { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f022268e..fe3527e8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -340,6 +340,8 @@ mod tests_color; #[cfg(test)] mod tests_import; #[cfg(test)] +mod tests_libops; +#[cfg(test)] mod tests_library; #[cfg(test)] mod tests_merge; diff --git a/crates/engine/src/tests_libops.rs b/crates/engine/src/tests_libops.rs new file mode 100644 index 00000000..d4e79dbe --- /dev/null +++ b/crates/engine/src/tests_libops.rs @@ -0,0 +1,34 @@ +//! Library management commands: date groups, keyword rename/delete/merge, batch rename, capture +//! time, label names, import review. + +use serde_json::json; + +use crate::Session; + +/// Date headers follow the grid order and the sort's grouping. +#[test] +fn date_groups_follow_sort_and_grouping() { + let mut s = Session::with_demo(); + let vis = s.visible_cloned(); + let g = s.execute("library.groups", &json!({})).unwrap(); + let groups = g.as_array().unwrap(); + assert!(groups.len() > 3, "{g}"); + let total: u64 = groups.iter().map(|x| x["count"].as_u64().unwrap()).sum(); + assert_eq!(total as usize, vis.len()); + // contiguous runs in grid order, labelled with weekday and date + let mut next = 0; + for x in groups { + assert_eq!(x["start"].as_u64().unwrap(), next); + next += x["count"].as_u64().unwrap(); + assert!(x["label"].as_str().unwrap().contains(", "), "{x}"); + } + let months = s.execute("library.groups", &json!({"by": "month"})).unwrap(); + assert!(months.as_array().unwrap().len() < groups.len()); + assert_eq!(months[0]["key"].as_str().unwrap().len(), 7); + s.execute("library.sort", &json!({"group": "none"})).unwrap(); + assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([])); + s.execute("library.sort", &json!({"group": "year", "key": "fileName"})).unwrap(); + assert_eq!(s.execute("library.groups", &json!({})).unwrap(), json!([]), "no date headers when sorting by name"); + assert_eq!(s.sort.group, lightcraft_catalog::GroupBy::Year); + assert!(s.execute("library.sort", &json!({"group": "week"})).is_err()); +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f4c70cec..b4ce1951 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -373,6 +373,32 @@ mod tests { h.settle(SETTLE); } + /// The photo grid shows date headers (registered as `group:` widgets); clicking one + /// selects that day's photos; month headers when zoomed out; none when grouping is off. + #[test] + fn grid_groups_by_capture_date() { + let mut h = demo([1300.0, 800.0]); + let t = Duration::from_secs(10); + h.settle(SETTLE); + let groups = h.app.session.execute("library.groups", &json!({})).unwrap(); + let first = groups[0].clone(); + let key = first["key"].as_str().unwrap().to_string(); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{key}")}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.session.selection.ids.len() as u64, first["count"].as_u64().unwrap()); + // zoomed out: month headers + h.app.ui.thumb_size = 120.0; + h.settle(SETTLE); + let vis = h.app.session.visible_cloned(); + let month = h.app.session.catalog.date_runs(&vis, h.app.session.sort.key, lightcraft_catalog::GroupBy::Month); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], true, "{r}"); + h.request("engine.execute", json!({"command": "library.sort", "params": {"group": "none"}}), t); + let r = h.request("ui.clickWidget", json!({"id": format!("group:{}", month[0].key)}), t); + assert_eq!(r["ok"], false, "no headers: {r}"); + h.settle(SETTLE); + } + #[test] fn screenshot_request_is_answered_without_a_window() { let mut h = demo([800.0, 500.0]); diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 1b56db04..8e4efd5d 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -288,6 +288,16 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(MenuNode::Separator); v.push(item("library.sort", json!({"ascending": true}), "Ascending", None, true, Some(cur.ascending))); v.push(item("library.sort", json!({"ascending": false}), "Descending", None, true, Some(!cur.ascending))); + v.push(MenuNode::Separator); + use lightcraft_catalog::GroupBy; + let groups = [ + ("Group by Date: Automatic", GroupBy::Auto, "auto"), + ("Group by Day", GroupBy::Day, "day"), + ("Group by Month", GroupBy::Month, "month"), + ("Group by Year", GroupBy::Year, "year"), + ("No Date Groups", GroupBy::None, "none"), + ]; + v.extend(groups.into_iter().map(|(label, g, k)| item("library.sort", json!({"group": k}), label, None, true, Some(cur.group == g)))); v } "Add to Album" => { diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index 3c09bf8d..6e3a1b02 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -183,6 +183,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use lightcraft_catalog::GroupBy; use lightcraft_catalog::SortKey::*; let cur = app.session.sort; ui.label(egui::RichText::new("Sort by").weak()); @@ -205,4 +206,17 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.selectable_label(!cur.ascending, "Descending").clicked() { let _ = app.run("library.sort", json!({"ascending": false})); } + ui.separator(); + ui.label(egui::RichText::new("Group by date").weak()); + for (label, g, k) in [ + ("Automatic", GroupBy::Auto, "auto"), + ("Day", GroupBy::Day, "day"), + ("Month", GroupBy::Month, "month"), + ("Year", GroupBy::Year, "year"), + ("None", GroupBy::None, "none"), + ] { + if ui.selectable_label(cur.group == g, label).clicked() { + let _ = app.run("library.sort", json!({"group": k})); + } + } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 9886c8f1..4dbacf6b 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -3,7 +3,7 @@ use std::collections::HashSet; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use lightcraft_catalog::{Flag, PhotoId}; +use lightcraft_catalog::{DateRun, Flag, GroupBy, PhotoId}; use serde_json::json; use crate::LightcraftApp; @@ -52,25 +52,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let square = app.ui.view == ViewMode::SquareGrid; let target = app.ui.thumb_size; let avail_w = ui.available_width() - 8.0; - // layout rows - struct Cell { - id: PhotoId, - rect: Rect, - } - let mut cells: Vec = Vec::with_capacity(ids.len()); - let gap = if square { 1.0 } else { 6.0 }; - let mut y = 4.0f32; - if square { - let cols = ((avail_w + gap) / (target + gap)).floor().max(1.0); - let cw = (avail_w - gap * (cols - 1.0)) / cols; - for (i, id) in ids.iter().enumerate() { - let (c, r) = ((i as f32) % cols, (i as f32 / cols).floor()); - cells.push(Cell { id: *id, rect: Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw)) }); - } + let aspects: Vec = if square { + vec![1.0; ids.len()] } else { - // justified rows: accumulate aspect ratios until the row is full - let aspects: Vec = ids - .iter() + ids.iter() .map(|id| { let p = app.session.catalog.photo(*id); let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0)); @@ -79,32 +64,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (w, h) = if swap { (h, w) } else { (w, h) }; (w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3) }) - .collect(); - let mut i = 0; - while i < ids.len() { - let mut sum = 0.0; - let mut j = i; - while j < ids.len() { - sum += aspects[j]; - let h = (avail_w - gap * (j - i) as f32) / sum; - j += 1; - if h <= target { - break; - } - } - let full = j < ids.len() || (avail_w - gap * (j - i - 1) as f32) / sum <= target; - let h = if full { (avail_w - gap * (j - i - 1) as f32) / sum } else { target }; - let mut x = 4.0; - for k in i..j { - let w = aspects[k] * h; - cells.push(Cell { id: ids[k], rect: Rect::from_min_size(pos2(x, y), vec2(w, h)) }); - x += w + gap; - } - y += h + gap; - i = j; - } + .collect() + }; + let by = resolve_group(app.session.sort.group, target); + let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted { + Vec::new() + } else { + app.session.catalog.date_runs(&ids, app.session.sort.key, by) + }; + let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect(); + let lay = layout(&aspects, &spans, avail_w, target, square); + struct Cell { + id: PhotoId, + rect: Rect, } - let total_h = cells.last().map(|c| c.rect.bottom()).unwrap_or(0.0) + 12.0; + let cells: Vec = ids.iter().zip(&lay.cells).map(|(id, r)| Cell { id: *id, rect: *r }).collect(); + let total_h = lay.height + 12.0; let active = app.session.selection.active; // keep the active photo in view when it changes by keyboard let scroll_to: Option = { @@ -134,11 +109,145 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { stack_badge(app, ui, c.id, *sid, *pos, r, square); } } + // date headers; the current group's header sticks to the top while its photos scroll by + let mut sticky: Option = None; + for (g, hr) in lay.headers.iter().enumerate() { + if hr.top() <= viewport.top() { + sticky = Some(g); + } + if hr.intersects(viewport) { + group_header(app, ui, &runs[g], &ids, hr.translate(origin.to_vec2()), false); + } + } + if let Some(g) = sticky + && lay.headers[g].top() < viewport.top() + { + // pushed up by the next header as it arrives + let next_top = lay.headers.get(g + 1).map(|h| h.top()).unwrap_or(f32::INFINITY); + let y = viewport.top().min(next_top - HEADER_H); + let hr = Rect::from_min_size(pos2(0.0, y), vec2(area.width(), HEADER_H)).translate(origin.to_vec2()); + group_header(app, ui, &runs[g], &ids, hr, true); + } }); app.renderer.evict_thumbs(&visible_ids, 600); let _ = (Color32::BLACK, StrokeKind::Inside, Stroke::NONE); } +/// Height of a date header row (points). +pub const HEADER_H: f32 = 40.0; + +/// Cell rectangles (one per photo, in order), date-header rectangles (one per group) and the total +/// height of the grid content. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GridLayout { + pub cells: Vec, + pub headers: Vec, + pub height: f32, +} + +/// The grouping actually shown: `Auto` is days with large thumbnails, months with medium ones and +/// years when zoomed far out. +pub fn resolve_group(by: GroupBy, thumb_size: f32) -> GroupBy { + match by { + GroupBy::Auto if thumb_size >= 150.0 => GroupBy::Day, + GroupBy::Auto if thumb_size >= 110.0 => GroupBy::Month, + GroupBy::Auto => GroupBy::Year, + other => other, + } +} + +/// Lay out the grid: `aspects` (width / height per photo; ignored for the square grid) in +/// `groups` (`(start, count)` runs; empty = one group without a header), `avail_w` wide, rows +/// about `target` high (square cells about `target` wide). Every group starts on a new row +/// below its header. Justified rows fill the width exactly except a group's last row, which keeps +/// the target height. +pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: f32, square: bool) -> GridLayout { + let n = aspects.len(); + let whole = [(0, n)]; + let (groups, headed) = if groups.is_empty() { (&whole[..], false) } else { (groups, true) }; + let gap = if square { 1.0 } else { 6.0 }; + let mut out = GridLayout { cells: vec![Rect::NOTHING; n], headers: Vec::with_capacity(groups.len()), height: 0.0 }; + let mut y = 4.0f32; + for &(start, count) in groups { + let end = (start + count).min(n); + if headed { + out.headers.push(Rect::from_min_size(pos2(0.0, y), vec2(avail_w + 8.0, HEADER_H))); + y += HEADER_H; + } + if start >= end { + continue; + } + if square { + let cols = ((avail_w + gap) / (target + gap)).floor().max(1.0); + let cw = (avail_w - gap * (cols - 1.0)) / cols; + for i in start..end { + let k = (i - start) as f32; + let (c, r) = (k % cols, (k / cols).floor()); + out.cells[i] = Rect::from_min_size(pos2(4.0 + c * (cw + gap), y + r * (cw + gap)), vec2(cw, cw)); + } + let rows = ((end - start) as f32 / cols).ceil(); + y += rows * (cw + gap); + } else { + // justified rows: accumulate aspect ratios until the row is full + let mut i = start; + while i < end { + let mut sum = 0.0; + let mut j = i; + while j < end { + sum += aspects[j].max(0.05); + let h = (avail_w - gap * (j - i) as f32) / sum; + j += 1; + if h <= target { + break; + } + } + let row_h = (avail_w - gap * (j - i - 1) as f32) / sum; + let h = if j < end || row_h <= target { row_h } else { target }; + let mut x = 4.0; + for k in i..j { + let w = aspects[k].max(0.05) * h; + out.cells[k] = Rect::from_min_size(pos2(x, y), vec2(w, h)); + x += w + gap; + } + y += h + gap; + i = j; + } + } + if headed { + y += 8.0; + } + } + out.height = y; + out +} + +/// A date header: "Wednesday, 30 September 2026 · 12 photos". Clicking it selects the group +/// (Cmd/Shift: adds to the selection). +fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) { + let t = Tokens::get(ui.ctx()); + let resp = ui.interact(r, egui::Id::new(("group-header", run.start, pinned)), Sense::click()); + if !pinned { + register(ui.ctx(), format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); + } + let p = ui.painter(); + if pinned { + p.rect_filled(r, 0.0, t.canvas); + p.hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.divider)); + } + let g = p.layout_no_wrap(run.label.clone(), t.semibold(15.0), if resp.hovered() { t.text } else { t.text_label }); + let x = r.left() + 8.0; + let gw = g.size().x; + p.galley(pos2(x, r.center().y - g.size().y / 2.0), g, t.text); + let n = run.count; + p.text(pos2(x + gw + 10.0, r.center().y), Align2::LEFT_CENTER, format!("· {n} photo{}", if n == 1 { "" } else { "s" }), t.font(12.5), t.text_dim); + if resp.clicked() { + let group: Vec = ids[run.start..(run.start + run.count).min(ids.len())].iter().map(|p| p.0).collect(); + let m = ui.input(|i| i.modifiers); + let mode = if m.command || m.shift { "add" } else { "replace" }; + let _ = app.run("library.select", json!({"ids": group, "mode": mode})); + } +} + /// Request a grid/filmstrip thumbnail at `priority`. An unedited raw without a thumbnail texture /// first gets a stand-in (its cached thumbnail, else its embedded camera preview), and its real /// render then follows in the background. @@ -374,3 +483,69 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.delete", json!({})); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ungrouped_justified_rows_fill_the_width() { + let aspects = [1.5f32, 1.5, 0.66, 1.0, 1.5, 1.5, 1.5]; + let l = layout(&aspects, &[], 600.0, 150.0, false); + assert!(l.headers.is_empty()); + assert_eq!(l.cells.len(), aspects.len()); + // full rows end at the right edge; rows are at most the target height + let first_row: Vec<&Rect> = l.cells.iter().filter(|c| (c.top() - l.cells[0].top()).abs() < 0.01).collect(); + assert!(first_row.len() >= 2); + assert!((first_row.last().unwrap().right() - 604.0).abs() < 0.5, "{first_row:?}"); + assert!(first_row.iter().all(|c| c.height() <= 150.0 + 0.01)); + let last = l.cells.last().unwrap(); + assert!(last.right() <= 604.0 + 0.5); + assert!((l.height - (last.bottom() + 6.0)).abs() < 0.01); + } + + #[test] + fn groups_start_new_rows_below_their_headers() { + let aspects = [1.5f32; 7]; + let l = layout(&aspects, &[(0, 2), (2, 5)], 1000.0, 200.0, false); + assert_eq!(l.headers.len(), 2); + assert_eq!(l.headers[0].top(), 4.0); + assert_eq!(l.headers[0].height(), HEADER_H); + // group 1: two photos in a short row below its header (not stretched) + assert_eq!(l.cells[0].top(), l.headers[0].bottom()); + assert!((l.cells[0].height() - 200.0).abs() < 0.01); + assert_eq!(l.cells[1].top(), l.cells[0].top()); + // group 2 starts on a new row below its own header, after group 1's photos + assert!(l.headers[1].top() >= l.cells[1].bottom()); + assert_eq!(l.cells[2].top(), l.headers[1].bottom()); + assert!((l.cells[2].left() - 4.0).abs() < 0.01, "a group's first photo starts the row"); + for c in &l.cells[2..] { + assert!(c.top() >= l.headers[1].bottom()); + } + // every photo has a cell; nothing overlaps + for (i, a) in l.cells.iter().enumerate() { + assert!(a.width() > 0.0); + for b in &l.cells[i + 1..] { + assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} {b:?}"); + } + for h in &l.headers { + assert!(!a.shrink(0.5).intersects(*h)); + } + } + } + + #[test] + fn square_grid_groups_and_auto_levels() { + let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); + // 4 columns: group 1 fills 3 cells of a row, group 2 starts a new row + assert_eq!(l.cells[0].top(), l.cells[2].top()); + assert_eq!(l.cells[3].left(), 4.0); + assert!(l.cells[3].top() > l.cells[0].bottom() + HEADER_H - 1.0); + assert!((l.cells[0].width() - l.cells[0].height()).abs() < 0.01); + assert_eq!(resolve_group(GroupBy::Auto, 220.0), GroupBy::Day); + assert_eq!(resolve_group(GroupBy::Auto, 120.0), GroupBy::Month); + assert_eq!(resolve_group(GroupBy::Auto, 90.0), GroupBy::Year); + assert_eq!(resolve_group(GroupBy::None, 220.0), GroupBy::None); + assert_eq!(resolve_group(GroupBy::Year, 400.0), GroupBy::Year); + } +} diff --git a/docs/parity.md b/docs/parity.md index 97cada6d..8136d9b1 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -63,7 +63,6 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. 4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, feather/opacity sliders, `[`/`]` size keys. Medium effort. -5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. 6. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. 7. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). @@ -171,8 +170,8 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | 🟡 | `cmd:view.photoGrid`, `crates/ui-egui/src/panels/grid.rs` | no grouping by date | -| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split | +| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ⬜ | | |