Files
lightcraft/crates/engine/src/view.rs
T
Nathaniel LedfordandClaude Opus 5.5 f40c7fb204 LRC-LIB-SMARTCOLL: rule summaries name albums, not ids
Summaries showed an Album rule's id ("album isn't 9"): the sidebar
tooltip, the "Rules:" filter chip and albums.list's rulesText for
agents.

RuleSet::describe_with(&Catalog) and Filter::describe_with name the
album ("album isn't “Excluded Photos”"). One that is gone shows as #9
(rules::album_name). The engine's rulesText and Rules chip use them, and
i18n::rules_label takes the catalog for the translated summaries.
Tests at all three levels: catalog (nested groups, a missing album),
engine (rulesText), UI (German summary).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:54:57 -04:00

250 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Library view state: what the grid shows and what is selected.
use lightcraft_catalog::{AlbumId, Catalog, Filter, PhotoId};
use serde::{Deserialize, Serialize};
/// The "My Photos" source in the left panel.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "id", rename_all = "camelCase")]
pub enum LibrarySource {
#[default]
All,
/// Imported in the last import session (we treat "recent" as the latest import date).
RecentlyAdded,
Album(AlbumId),
RecentlyDeleted,
/// Photos with picks.
Picks,
/// A folder on disk ([`crate::Session::browse`]): its files, added to the library or not.
Folder,
/// Photos whose original file can't be found (`library.missing`).
Missing,
/// A folder the library's photos were imported from ([`crate::Session::library_folder`]),
/// and the folders inside it (`library.folders`).
LibraryFolder,
}
/// The folder a [`LibrarySource::Folder`] view shows.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Browse {
pub path: String,
pub subfolders: bool,
}
/// How far back Recently Added reaches from the latest import.
pub const RECENT_DAYS: i64 = 30;
impl LibrarySource {
pub fn to_filter(&self, f: &Filter, cat: &Catalog) -> Filter {
let mut f = f.clone();
match self {
LibrarySource::All => {}
// everything imported in the 30 days up to the latest import (by import, newest first)
LibrarySource::RecentlyAdded => {
let latest = cat.photos().filter(|p| p.in_library()).map(|p| p.imported.clone()).max().unwrap_or_default();
let from = lightcraft_catalog::stacks::iso_seconds(&latest)
.map(|s| lightcraft_catalog::dates::civil(s - RECENT_DAYS * 86_400))
.unwrap_or_else(|| latest.get(..10).unwrap_or("").to_string());
f.imported_from = Some(from);
}
LibrarySource::Album(a) => f.album = Some(*a),
LibrarySource::RecentlyDeleted => f.deleted = true,
LibrarySource::Picks => f.flag = Some(lightcraft_catalog::Flag::Pick),
// the folder itself is filled in by the session (it holds the path)
LibrarySource::Folder | LibrarySource::Missing | LibrarySource::LibraryFolder => {}
}
f
}
pub fn label(&self, cat: &Catalog) -> String {
match self {
LibrarySource::All => "All Photos".into(),
LibrarySource::RecentlyAdded => "Recently Added".into(),
LibrarySource::Album(a) => cat.album(*a).map(|a| a.name.clone()).unwrap_or_else(|| "Album".into()),
LibrarySource::RecentlyDeleted => "Recently Deleted".into(),
LibrarySource::Picks => "Picks".into(),
LibrarySource::Folder => "Folder".into(),
LibrarySource::LibraryFolder => "Folder".into(),
LibrarySource::Missing => "Missing Photos".into(),
}
}
}
/// Selected photos (ordered) and the active (most-selected) one.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
pub ids: Vec<PhotoId>,
pub active: Option<PhotoId>,
}
/// How a photo relates to the selection: what every view draws (grid cell, filmstrip cell…).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SelectionState {
NotSelected,
/// Selected, but not the photo the loupe and panels act on.
Selected,
/// The most-selected photo.
Active,
}
impl Selection {
/// What views draw for `id`: active, selected or neither (one answer for grid, filmstrip, …).
pub fn state_of(&self, id: PhotoId) -> SelectionState {
if self.active == Some(id) {
SelectionState::Active
} else if self.contains(id) {
SelectionState::Selected
} else {
SelectionState::NotSelected
}
}
pub fn single(id: PhotoId) -> Selection {
Selection { ids: vec![id], active: Some(id) }
}
pub fn contains(&self, id: PhotoId) -> bool {
self.ids.contains(&id)
}
pub fn toggle(&mut self, id: PhotoId) {
if let Some(i) = self.ids.iter().position(|x| *x == id) {
self.ids.remove(i);
if self.active == Some(id) {
self.active = self.ids.last().copied();
}
} else {
self.ids.push(id);
self.active = Some(id);
}
}
/// Shift-click: select the range between the active photo and `id` in `order`.
pub fn extend_to(&mut self, id: PhotoId, order: &[PhotoId]) {
let anchor = self.active.unwrap_or(id);
let (Some(a), Some(b)) = (order.iter().position(|x| *x == anchor), order.iter().position(|x| *x == id)) else {
*self = Selection::single(id);
return;
};
let (lo, hi) = (a.min(b), a.max(b));
for x in &order[lo..=hi] {
if !self.ids.contains(x) {
self.ids.push(*x);
}
}
self.active = Some(id);
}
}
/// One active filter, shown as a removable chip above the grid.
#[derive(Clone, Debug, PartialEq)]
pub struct FilterChip {
pub label: String,
/// `library.filter` patch that clears just this filter.
pub clear: serde_json::Value,
}
/// The chips for every constraint in the filter bar / search / sidebar (not the source itself).
pub fn filter_chips(f: &Filter, cat: &Catalog) -> Vec<FilterChip> {
use lightcraft_catalog::{MediaKind, RatingOp};
use serde_json::{Value::Null, json};
let mut v = Vec::new();
let mut add = |label: String, clear: serde_json::Value| v.push(FilterChip { label, clear });
if !f.text.trim().is_empty() {
add(format!("Search: “{}”", f.text.trim()), json!({"text": ""}));
}
if f.rating > 0 {
let op = match f.rating_op {
RatingOp::AtLeast => "≥",
RatingOp::Exactly => "=",
RatingOp::AtMost => "≤",
};
add(format!("Rating {op} {}★", f.rating), json!({"rating": 0}));
}
if let Some(fl) = f.flag {
add(format!("Flag: {}", format!("{fl:?}").to_lowercase()), json!({"flag": Null}));
}
let mut labels = f.labels.clone();
labels.extend(f.label.filter(|l| !labels.contains(l)));
if !labels.is_empty() {
let names: Vec<String> = labels.iter().map(|l| cat.label_name(*l)).collect();
add(format!("Label: {}", names.join(" or ")), json!({"label": Null, "labels": []}));
}
if let Some(k) = f.kind {
let n = match k {
MediaKind::Image => "Photos",
MediaKind::Raw => "Raw",
MediaKind::Video => "Videos",
};
add(format!("Type: {n}"), json!({"kind": Null}));
}
if let Some(m) = &f.merged {
let n = match m.as_str() {
"hdr" => "HDR",
"panorama" => "Panoramas",
"hdrPanorama" => "HDR panoramas",
_ => "Merged",
};
add(format!("Type: {n}"), json!({"merged": Null}));
}
if let Some(e) = f.edited {
add(if e { "Edited".into() } else { "Unedited".into() }, json!({"edited": Null}));
}
for (name, key, val) in
[("Camera", "camera", &f.camera), ("Lens", "lens", &f.lens), ("Keyword", "keyword", &f.keyword), ("Person", "person", &f.person)]
{
if let Some(x) = val {
add(format!("{name}: {x}"), json!({key: Null}));
}
}
if let Some(d) = &f.date {
add(format!("Date: {}", date_label(d)), json!({"date": Null}));
}
if let Some(d) = f.library_folder.as_deref().filter(|d| !d.trim().is_empty()) {
// the last two names, so two folders called "Pictures" are told apart
let name = lightcraft_catalog::folders::folder_label(d);
add(format!("Folder: {name}"), json!({"libraryFolder": Null}));
}
if let Some(d) = &f.imported {
add(format!("Imported: {}", date_label(d)), json!({"imported": Null}));
}
match (&f.date_from, &f.date_to) {
(Some(a), Some(b)) => add(format!("Captured {a} – {b}"), json!({"dateFrom": Null, "dateTo": Null})),
(Some(a), None) => add(format!("Captured from {a}"), json!({"dateFrom": Null})),
(None, Some(b)) => add(format!("Captured until {b}"), json!({"dateTo": Null})),
_ => {}
}
if let Some(rs) = f.rule_set.as_ref().filter(|r| !r.rules.is_empty()) {
add(format!("Rules: {}", rs.describe_with(cat)), json!({"ruleSet": Null}));
}
if !f.only.is_empty() {
add(format!("Only {} photos", f.only.len()), json!({"only": []}));
}
v
}
/// `2026-01-16` → "January 16, 2026"; `2026-01` → "January 2026"; anything else as is.
fn date_label(d: &str) -> String {
const MONTHS: [&str; 12] =
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
let parts: Vec<&str> = d.split('-').collect();
let month = parts.get(1).and_then(|m| m.parse::<usize>().ok()).filter(|m| (1..=12).contains(m)).map(|m| MONTHS[m - 1]);
match (parts.as_slice(), month) {
([y, _, day], Some(m)) => format!("{m} {}, {y}", day.trim_start_matches('0')),
([y, _], Some(m)) => format!("{m} {y}"),
_ => d.to_string(),
}
}
#[cfg(test)]
mod selection_state_tests {
use super::*;
use lightcraft_catalog::PhotoId;
#[test]
fn select_all_marks_the_others_selected_and_one_active() {
let ids: Vec<PhotoId> = (1..=3).map(PhotoId).collect();
let s = Selection { ids: ids.clone(), active: Some(ids[0]) };
assert_eq!(s.state_of(ids[0]), SelectionState::Active);
assert_eq!(s.state_of(ids[1]), SelectionState::Selected);
assert_eq!(s.state_of(PhotoId(9)), SelectionState::NotSelected);
}
}