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:
parent
cec012d032
commit
89b8aa3f77
|
|
@ -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)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 | ⬜ | | |
|
||||
|
|
|
|||
Loading…
Reference in New Issue