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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 12:52:07 -04:00
parent cec012d032
commit 89b8aa3f77
13 changed files with 570 additions and 65 deletions

232
crates/catalog/src/dates.rs Normal file
View File

@ -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<String> {
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<String> {
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<GroupBy> {
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::<usize>().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::<u32>().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<DateRun> {
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<DateRun> = Vec::new();
let mut stack_key: std::collections::HashMap<crate::StackId, String> = 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::<Vec<_>>();
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)]
);
}
}

View File

@ -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::*;

View File

@ -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 }
}
}

View File

@ -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]);

View File

@ -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<CommandSpec> {
"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();

View File

@ -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 {

View File

@ -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;

View File

@ -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());
}

View File

@ -373,6 +373,32 @@ mod tests {
h.settle(SETTLE);
}
/// The photo grid shows date headers (registered as `group:<date>` 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]);

View File

@ -288,6 +288,16 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
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" => {

View File

@ -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}));
}
}
}

View File

@ -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<Cell> = 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<f32> = if square {
vec![1.0; ids.len()]
} else {
// justified rows: accumulate aspect ratios until the row is full
let aspects: Vec<f32> = 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<Cell> = 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<Rect> = {
@ -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<usize> = 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<Rect>,
pub headers: Vec<Rect>,
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<u64> = 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);
}
}

View File

@ -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 | ⬜ | | |