250 lines
9.7 KiB
Rust
250 lines
9.7 KiB
Rust
//! 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()), 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);
|
||
}
|
||
}
|