refactor(lightcraft-ui-egui): split lib.rs into crate-root modules

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent 20c26b83c8
commit cb2a922383
6 changed files with 2876 additions and 2835 deletions
File diff suppressed because it is too large Load Diff
+469
View File
@@ -0,0 +1,469 @@
use super::*;
/// The left panel's counts.
#[derive(Clone, Copy, Debug, Default)]
pub struct LibraryCounts {
pub total: usize,
pub picks: usize,
pub deleted: usize,
}
/// The values the filter bar's pickers offer.
#[derive(Clone, Debug, Default)]
pub struct FilterValues {
pub cameras: Vec<String>,
pub lenses: Vec<String>,
pub keywords: Vec<String>,
}
/// What the smart-album rule dialog shows besides the rules themselves: their problems (each row
/// marks its own), how many photos they match and the albums an Album rule picks from.
pub struct RulesView {
pub problems: Vec<lightcraft_catalog::rules::Problem>,
pub count: usize,
pub albums: Vec<album_picker::AlbumEntry>,
}
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}
/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only
/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's
/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the
/// library saved on every edit.
fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool {
revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads))
}
/// What the tallies below read of a photo, besides whether it is in the library.
const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE);
const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META);
const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS);
const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED);
const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG);
impl Caches {
/// The folders the library's photos were imported from.
pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> {
let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t);
let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree()));
self.folder_tree = Some((cat.revision, t.clone()));
t
}
/// The folder tree as last built, when it still holds (a read-only check cannot rebuild it).
pub(crate) fn folder_tree_if_fresh(&self, cat: &lightcraft_catalog::Catalog) -> Option<std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>> {
self.folder_tree.as_ref().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t.clone())
}
/// The library's keyword tree.
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t);
let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree()));
self.keyword_tree = Some((cat.revision, t.clone()));
t
}
/// The Keywording box's read-only names for the selection: what exported files carry
/// (`export`), or the keywords with those containing them.
pub(crate) fn keyword_names(
&mut self,
cat: &lightcraft_catalog::Catalog,
selection: &[lightcraft_catalog::PhotoId],
export: bool,
) -> std::sync::Arc<Vec<panels::keywording::Chip>> {
let key = key_of((selection, export));
match &self.keyword_names {
Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(),
_ => {
let c = std::sync::Arc::new(if export {
panels::keywording::will_export(cat, selection)
} else {
panels::keywording::with_containing(cat, selection)
});
self.keyword_names = Some((cat.revision, key, c.clone()));
c
}
}
}
/// The selection's keywords (the Keywording box's chips).
pub(crate) fn keyword_chips(
&mut self,
cat: &lightcraft_catalog::Catalog,
selection: &[lightcraft_catalog::PhotoId],
) -> std::sync::Arc<Vec<panels::keywording::Chip>> {
let key = key_of(selection);
match &self.keyword_chips {
Some((r, k, c)) if *r == cat.revision && *k == key => c.clone(),
_ => {
let c = std::sync::Arc::new(panels::keywording::chips(cat, selection));
self.keyword_chips = Some((cat.revision, key, c.clone()));
c
}
}
}
/// How many photos the selection is: each once, those the library has (what the Keywording
/// box's "N of M" counts).
pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize {
let key = key_of(selection);
match self.selected_photos {
Some((r, k, n)) if r == cat.revision && k == key => n,
_ => {
let n = panels::keywording::distinct(cat, selection).len();
self.selected_photos = Some((cat.revision, key, n));
n
}
}
}
/// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box).
pub(crate) fn keyword_groups(
&mut self,
cat: &lightcraft_catalog::Catalog,
selection: &[lightcraft_catalog::PhotoId],
) -> std::sync::Arc<Vec<panels::keywording::Group>> {
let key = key_of(selection);
match &self.keyword_groups {
Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(),
_ => {
let chips = self.keyword_chips(cat, selection);
let g = std::sync::Arc::new(panels::keywording::grouped(&chips));
self.keyword_groups = Some((cat.revision, key, g.clone()));
g
}
}
}
/// How many selected photos have each keyword (the Keyword List's tick boxes).
pub(crate) fn keyword_ticks(
&mut self,
cat: &lightcraft_catalog::Catalog,
selection: &[lightcraft_catalog::PhotoId],
) -> std::sync::Arc<panels::keyword_list::Ticks> {
let key = key_of(selection);
match &self.keyword_ticks {
Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(),
_ => {
let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection));
self.keyword_ticks = Some((cat.revision, key, t.clone()));
t
}
}
}
/// The people named on faces among the photos the filter lets through (its own `person` aside),
/// with photo counts.
pub fn people(
&mut self,
cat: &lightcraft_catalog::Catalog,
filter: &lightcraft_catalog::Filter,
) -> std::sync::Arc<Vec<lightcraft_catalog::Person>> {
let key = lightcraft_catalog::Filter { person: None, ..filter.clone() };
match &self.people {
Some((r, f, t)) if *r == cat.revision && *f == key => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.people_in(&key));
self.people = Some((cat.revision, key, t.clone()));
t
}
}
}
/// All Photos / Picks / Recently Deleted counts.
pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts {
if let Some((r, c)) = self.counts
&& still_right(cat, r, READS_FLAG)
{
self.counts = Some((cat.revision, c));
return c;
}
let mut c = LibraryCounts::default();
for p in cat.photos() {
if p.in_library() {
c.total += 1;
if p.flag == lightcraft_catalog::Flag::Pick {
c.picks += 1;
}
} else if p.deleted && !p.local {
c.deleted += 1;
}
}
self.counts = Some((cat.revision, c));
c
}
/// Everyone named on a face in the library (for completing a name as it is typed).
pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<String>> {
let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v);
let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>()));
self.person_names = Some((cat.revision, v.clone()));
v
}
/// The By Date tree.
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g);
let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups()));
self.date_groups = Some((cat.revision, g.clone()));
g
}
/// Cameras, lenses and keywords in the library (filter bar pickers).
pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> {
match &self.filter_values {
Some((r, v)) if *r == cat.revision => v.clone(),
_ => {
let distinct = |mut v: Vec<String>| {
v.retain(|s| !s.trim().is_empty());
v.sort_by_key(|s| s.to_lowercase());
v.dedup();
v
};
let lib = || cat.photos().filter(|p| p.in_library());
let v = std::sync::Arc::new(FilterValues {
cameras: distinct(lib().map(|p| p.meta.camera.clone()).collect()),
lenses: distinct(lib().map(|p| p.meta.lens.clone()).collect()),
keywords: cat.keywords().into_iter().map(|(k, _)| k).collect(),
});
self.filter_values = Some((cat.revision, v.clone()));
v
}
}
}
/// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart
/// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks
/// through the library's keywords.
pub(crate) fn completions(
&mut self,
cat: &lightcraft_catalog::Catalog,
current: &[String],
typed: &str,
) -> std::sync::Arc<panels::keywording::Offers> {
let k = key_of((cat.revision, current, typed));
match &self.completions {
Some((h, v)) if *h == k => v.clone(),
_ => {
let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed));
self.completions = Some((k, v.clone()));
v
}
}
}
/// Keyword suggestions for a photo with `current` keywords and the typed `prefix`.
pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> {
let k = key_of((cat.revision, current, prefix, n));
match &self.suggestions {
Some((h, v)) if *h == k => v.clone(),
_ => {
let v = std::sync::Arc::new(cat.keyword_suggestions(current, prefix, n));
self.suggestions = Some((k, v.clone()));
v
}
}
}
/// Every album's photo count for the sidebar (a smart album evaluates its rules, which
/// scans the catalog). Recomputed when the catalog changes (photos, metadata, album rules
/// all bump its revision) and — only while some smart album has an "in the last…" rule —
/// when `now` (the session clock, ISO) enters a new minute, so such counts follow the clock
/// within a minute without rescanning every frame.
/// [`RulesView`] for `rules` edited in smart album `editing` (`None`: a new album), within
/// `folder` when it was made from a folder view: worked out again only when the catalog, the
/// rules, the language or (for "in the last…" rules) the minute changes, not every frame.
pub fn rules_view(
&mut self,
cat: &lightcraft_catalog::Catalog,
rules: &lightcraft_catalog::RuleSet,
editing: Option<u64>,
folder: Option<String>,
now: &str,
) -> std::sync::Arc<RulesView> {
let relative = rules.depends_on_now();
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
let k = key_of((cat.revision, serde_json::to_string(rules).unwrap_or_default(), editing, &folder, minute, i18n::language().code()));
if let Some((key, v)) = &self.rules_view
&& *key == k
{
return v.clone();
}
if relative {
lightcraft_catalog::rules::set_now(Some(now.to_string()));
}
let problems = rules.check_for(cat, editing.map(lightcraft_catalog::AlbumId));
let filter = lightcraft_catalog::Filter { rule_set: Some(rules.clone()), library_folder: folder, ..Default::default() };
let count = if problems.is_empty() { cat.query(&filter, &Default::default()).len() } else { 0 };
let v = std::sync::Arc::new(RulesView { problems, count, albums: panels::dialogs::album_entries(cat, editing) });
self.rules_view_scans += 1;
self.rules_view = Some((k, v.clone()));
v
}
/// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs
/// again only when the catalog or `what` (what is counted) changes, not every frame.
pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize {
let k = key_of((cat.revision, what));
if let Some((key, n)) = self.dialog_count
&& key == k
{
return n;
}
let n = count();
self.dialog_count_scans += 1;
self.dialog_count = Some((k, n));
n
}
/// The problems of every saved smart album that has some (`Catalog::smart_album_problems`),
/// for the sidebar's ⚠ marks: worked out again only when the catalog changes.
pub fn smart_album_problems(
&mut self,
cat: &lightcraft_catalog::Catalog,
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>> {
if let Some((rev, v)) = &self.smart_problems
&& *rev == cat.revision
{
return v.clone();
}
let v: std::sync::Arc<std::collections::HashMap<_, _>> = std::sync::Arc::new(
cat.albums().filter(|a| a.is_smart()).map(|a| (a.id, cat.smart_album_problems(a.id))).filter(|(_, p)| !p.is_empty()).collect(),
);
self.smart_problem_scans += 1;
self.smart_problems = Some((cat.revision, v.clone()));
v
}
pub fn album_counts(
&mut self,
cat: &lightcraft_catalog::Catalog,
now: &str,
) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> {
let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now()));
let minute = if relative { now.get(..16).unwrap_or(now) } else { "" };
// the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart
// album asking only those photos again; anything more, or a new minute, is counted from every photo
let kept = self.album_counts.take().filter(|(of, _, _)| of == minute);
if let Some((_, tally, shown)) = &kept
&& tally.revision() == cat.revision
{
let shown = shown.clone();
self.album_counts = kept;
return shown;
}
if relative {
// "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks
// only the photos it touched, at this second; the others were asked earlier in the minute. That is as
// far out as the counts already were between edits, and the next minute counts them all again.)
lightcraft_catalog::rules::set_now(Some(now.to_string()));
}
let tally = match kept {
Some((_, mut tally, _)) => {
if !tally.update(cat) {
self.album_count_scans += 1;
}
tally
}
None => {
self.album_count_scans += 1;
lightcraft_catalog::tally::AlbumTally::of(cat)
}
};
let shown = std::sync::Arc::new(tally.counts().clone());
self.album_counts = Some((minute.to_string(), tally, shown.clone()));
shown
}
}
#[cfg(test)]
mod cache_tests {
use lightcraft_catalog::{Album, AlbumId, Catalog, Filter, Op, Photo, PhotoId, RuleSet, Source};
fn photo(id: u64, captured: &str, rating: u8) -> Op {
let mut p = Photo::new(PhotoId(id), Source::Demo { scene: 0 }, &format!("p{id}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
p.captured = Some(captured.into());
p.rating = rating;
Op::AddPhoto { photo: Box::new(p) }
}
fn smart(id: u64, rules: serde_json::Value) -> Op {
let mut a = Album::new(AlbumId(id), format!("smart {id}"));
let rules: RuleSet = serde_json::from_value(rules).unwrap();
a.smart = Some(Box::new(Filter { rule_set: Some(rules), ..Default::default() }));
Op::AddAlbum { album: a }
}
/// Smart-album counts: unchanged frames reuse them; metadata and rule changes and (for
/// "in the last…" rules) the clock crossing into a new minute recompute them.
#[test]
fn smart_album_counts_are_cached_until_something_changes() {
let mut cat = Catalog::default();
let mut c = super::Caches::default();
for (i, d) in ["2026-09-30T11:59:30", "2026-09-29T08:00:00", "2026-01-01T08:00:00"].iter().enumerate() {
cat.apply(photo(i as u64 + 1, d, if i == 0 { 5 } else { 1 })).unwrap();
}
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
cat.apply(smart(11, serde_json::json!({"rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 1, "unit": "hours"}}]}))).unwrap();
let now = "2026-09-30T12:00:00";
let n = c.album_counts(&cat, now);
assert_eq!((n[&AlbumId(10)], n[&AlbumId(11)]), (1, 1));
assert_eq!(c.album_count_scans, 1);
for _ in 0..10 {
c.album_counts(&cat, "2026-09-30T12:00:40");
}
assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan");
// a photo rated: followed from that photo, not counted from every one
cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap();
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2);
assert_eq!(c.album_count_scans, 1);
// rule change
let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap();
cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap();
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1);
// an hour later the 11:59:30 photo has left "in the last hour", with no catalog change
assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0);
assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not");
lightcraft_catalog::rules::set_now(None);
}
/// The rule dialog's problems, live count and album list are worked out once per change to the
/// catalog or the rules, not every frame; so are the sidebar's smart-album problems.
#[test]
fn rule_dialog_and_problems_are_cached_until_something_changes() {
let mut cat = Catalog::default();
let mut c = super::Caches::default();
for i in 0..3u64 {
cat.apply(photo(i + 1, "2026-09-30T11:00:00", if i == 0 { 5 } else { 1 })).unwrap();
}
let rules =
|v: u64| -> RuleSet { serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": v}]})).unwrap() };
let now = "2026-09-30T12:00:00";
let view = c.rules_view(&cat, &rules(3), None, None, now);
assert_eq!((view.count, view.problems.len()), (1, 0));
for _ in 0..10 {
c.rules_view(&cat, &rules(3), None, None, now);
}
assert_eq!(c.rules_view_scans, 1, "unchanged frames reuse it");
assert_eq!(c.rules_view(&cat, &rules(1), None, None, now).count, 3, "a rule change");
assert_eq!(c.rules_view(&cat, &rules(9), None, None, now).problems.len(), 1);
cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap();
assert_eq!(c.rules_view(&cat, &rules(3), None, None, now).count, 2, "a catalog change");
assert_eq!(c.rules_view_scans, 4);
// the sidebar's problems
cat.apply(Op::AddAlbum { album: lightcraft_catalog::Album::new(AlbumId(20), "Trip") }).unwrap();
cat.apply(smart(21, serde_json::json!({"rules": [{"field": "album", "op": "is", "value": 20}]}))).unwrap();
assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_none_or(Vec::is_empty));
for _ in 0..10 {
c.smart_album_problems(&cat);
}
assert_eq!(c.smart_problem_scans, 1);
cat.apply(Op::RemoveAlbum { id: AlbumId(20) }).unwrap();
assert!(c.smart_album_problems(&cat).get(&AlbumId(21)).is_some_and(|p| !p.is_empty()), "its album is gone");
assert_eq!(c.smart_problem_scans, 2);
}
/// Without "in the last…" rules the clock never causes a rescan.
#[test]
fn absolute_rules_ignore_the_clock() {
let mut cat = Catalog::default();
let mut c = super::Caches::default();
cat.apply(photo(1, "2026-09-30T11:59:30", 5)).unwrap();
cat.apply(smart(10, serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 3}]}))).unwrap();
c.album_counts(&cat, "2026-09-30T12:00:00");
c.album_counts(&cat, "2027-01-01T00:00:00");
assert_eq!(c.album_count_scans, 1);
}
}
+35
View File
@@ -0,0 +1,35 @@
/// A monotonic clock for the app's own timers.
///
/// `web_time::Instant` is backed by the browser's `performance.now()` on WASM and by
/// `std::time::Instant` natively: it never jumps with the wall clock and has no `Date.now()`
/// zero-origin problem. A headless host may supply its simulated time instead.
#[derive(Clone, Copy, Debug)]
pub(crate) struct AppClock {
start: web_time::Instant,
pub(crate) simulated: Option<f64>,
}
impl AppClock {
/// Start the clock at the current monotonic instant with no simulated time.
pub(crate) fn new() -> Self {
Self { start: web_time::Instant::now(), simulated: None }
}
/// Seconds on the clock: the supplied simulated time when set, otherwise time since `new`.
pub(crate) fn now(&self) -> f64 {
self.simulated.unwrap_or_else(|| self.start.elapsed().as_secs_f64())
}
}
/// Monotonic milliseconds since the first call, for durations and deadlines.
pub fn monotonic_ms() -> f64 {
static START: std::sync::OnceLock<web_time::Instant> = std::sync::OnceLock::new();
START.get_or_init(web_time::Instant::now).elapsed().as_secs_f64() * 1000.0
}
/// Wall-clock milliseconds since the Unix epoch, only for names that must be unique in the world
/// (`library-restored-<ms>`), never for timing or deadlines.
pub fn now_ms() -> f64 {
use web_time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
}
+19 -2835
View File
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
use super::*;
pub type PickFiles = Box<dyn FnMut() -> Vec<String>>;
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
pub type SaveFile = Box<dyn FnMut(&str) -> Option<String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
/// A writer other threads can use (background export).
pub type SharedWrite = std::sync::Arc<dyn Fn(&str, &[u8]) -> Result<(), String> + Send + Sync>;
pub type PngEncode = Box<dyn Fn(&lightcraft_raster::Rgba8) -> Vec<u8>>;
/// A folder chooser (`None` = cancelled).
pub type PickFolder = Box<dyn FnMut() -> Option<String>>;
/// Reveal a file in the system file manager (Finder / Explorer / the folder on Linux).
pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a URL in the user's browser.
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a file in an application (`app` = "" for the system's default one).
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
/// Open several files with one start of an application (`app` as for [`OpenWithFn`]).
pub type OpenAllWithFn = Box<dyn FnMut(&[String], &str) -> Result<(), String>>;
/// Linux model-folder work: wait for the default opener on a background task.
#[cfg(target_os = "linux")]
pub type OpenModelFolderFn = std::sync::Arc<dyn Fn(&str) -> Result<(), String> + Send + Sync>;
/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`:
/// not known).
pub type ScreenProfileFn = Box<dyn FnMut() -> Option<Vec<u8>>>;
/// Whether an external editor can be started (its name as in [`Services::open_with`]); the
/// error says why not and what to do (e.g. PhotoCraft isn't installed).
pub type CheckEditorFn = Box<dyn Fn(&str) -> Result<(), String>>;
/// Something the host does with the session (web: back up or restore the library in browser
/// storage); the work may finish asynchronously.
pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose
/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it
/// last heard and wakes the UI itself when that changes; it is never polled for changes.
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Native file dialogs for commands, shown off the UI thread and answered later
/// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside
/// dialogs; without it (the web, tests) they answer the commands too.
pub picker: Option<pick::Picker>,
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Local ONNX model; a manifest must accompany it.
pub pick_denoise_model: Option<PickFiles>,
/// Open dialog for a face model (`.onnx`; Settings ▸ Faces ▸ Add a model file…).
pub pick_model_file: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
pub pick_preset_files: Option<PickFiles>,
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).
pub pick_tracklog: Option<PickFiles>,
/// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only).
pub pick_display_profile: Option<PickFiles>,
/// The profile of the screen showing the window, used while none is chosen in Settings ▸
/// Display (macOS, whose windows aren't colour-managed: issue #787).
pub screen_profile: Option<ScreenProfileFn>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…).
pub pick_keyword_list: Option<PickFiles>,
/// Save dialog for an exported keyword list (File ▸ Export Keywords…).
pub save_keyword_list: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
pub pick_curve_preset_files: Option<PickFiles>,
/// Save dialog for an exported `.lccurve` file.
pub save_curve_preset_file: Option<SaveFile>,
/// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…).
pub pick_slideshow_templates: Option<PickFiles>,
/// Save dialog for an exported slideshow template (`.json`).
pub save_slideshow_template: Option<SaveFile>,
/// Open dialog for print template files (`.lcprint`; File ▸ Print… ▸ Templates ▸ Import…).
pub pick_print_templates: Option<PickFiles>,
/// Save dialog for an exported print template (`.lcprint`).
pub save_print_template: Option<SaveFile>,
pub write: Option<WriteFn>,
/// Create-new writer (never replaces an existing path), for screenshots of destinations that
/// did not exist when the request was admitted. Required for such captures: falling back to
/// [`Self::write`] would reopen the admission-to-write race.
pub write_new: Option<WriteFn>,
/// Thread-safe writer: with it, UI-started exports run in the background (desktop only).
pub write_shared: Option<SharedWrite>,
/// PNG encoder (the host links an image encoder; the UI crate stays codec-free).
pub png: Option<PngEncode>,
/// Show a file in the system file manager (desktop only).
pub reveal: Option<RevealFn>,
/// The host's current log file (`<settings>/logs/lightcraft.log`), which Help ▸ Open Log
/// Folder reveals (#260). None where no log is kept: the web, `--memory`, `LIGHTCRAFT_NO_PREFS`.
pub log_file: Option<String>,
/// Choose a folder (Settings → General → Open Library…; desktop only).
pub pick_folder: Option<PickFolder>,
/// Open a Lightroom Classic `.lrcat` catalog for read-only import.
pub pick_lightroom_catalog: Option<PickFolder>,
/// Open a web link in the browser (Help menu, About, Discord button).
pub open_url: Option<OpenUrlFn>,
/// Open a file in an external editor (Edit in External Editor; desktop only).
pub open_with: Option<OpenWithFn>,
/// Open several files with one start of the editor (Open Original; desktop only): an editor
/// that keeps one window takes them all. None = [`Services::open_with`] for each.
pub open_all_with: Option<OpenAllWithFn>,
/// Optional worker-safe model-folder opener alongside `open_with` (Linux desktop only).
#[cfg(target_os = "linux")]
pub open_model_folder: Option<OpenModelFolderFn>,
/// Checked before Edit In renders a copy, so a missing editor leaves no copy behind (none =
/// assume it can).
pub check_editor: Option<CheckEditorFn>,
/// File ▸ Back Up Library…: save the whole library (catalog and originals) as one file the
/// user keeps (web only: there the library lives in browser storage, which the browser may
/// clear; on the desktop it is a folder backed up like any other).
pub backup_library: Option<HostAction>,
/// File ▸ Restore Library from Backup… (web only; keeps the current library).
pub restore_library: Option<HostAction>,
/// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report.
pub system_theme: Option<SystemThemeFn>,
}
+41
View File
@@ -592,3 +592,44 @@ fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D:
let value = serde_json::Value::deserialize(d)?;
Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect())
}
/// The navigation/document/modal/panel state a gesture was admitted under. The same automation
/// id can mean a different control after any of these changes; the request is canceled rather
/// than retargeted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct GestureFrame {
pub(crate) view: crate::state::ViewMode,
pub(crate) active: Option<lightcraft_catalog::PhotoId>,
pub(crate) library: u64,
pub(crate) dialog: Option<std::mem::Discriminant<crate::state::Dialog>>,
pub(crate) left_panel: bool,
pub(crate) right: crate::state::RightPanel,
pub(crate) presets: bool,
pub(crate) filmstrip: bool,
}
/// A pointer press delivered to the UI, kept so a canceled gesture knows that a button is down.
#[derive(Clone, Copy, Debug)]
pub(crate) struct InjectedPress {
pub(crate) button: egui::PointerButton,
}
/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`.
#[derive(Debug)]
pub(crate) struct InjectedDrop(pub(crate) std::path::PathBuf);
impl egui::DroppedFile for InjectedDrop {
fn path(&self) -> &std::path::Path {
&self.0
}
#[cfg(not(target_arch = "wasm32"))]
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
#[cfg(target_arch = "wasm32")]
fn bytes_async(&self) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<u8>, String>> + '_>> {
Box::pin(async { Err("the web build has no files to read".to_string()) })
}
}