Files
lightcraft/crates/ui-egui/src/lib.rs
T

3433 lines
173 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.
//! LightCraft's egui frontend: a Lightroom-style UI over `lightcraft-engine`.
//!
//! The UI is thin: every action goes through [`LightcraftApp::run`], which handles UI commands
//! (views, panels, zoom — see [`menus::ui_commands`]) and forwards everything else to the engine.
//! The same entry point serves menus, shortcuts, buttons and the control channel ([`control`]).
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod album_picker;
pub mod brush_live;
pub mod button;
pub mod canvas_keys;
mod command_error;
pub mod control;
pub mod credits;
pub mod date_picker;
pub mod dialogs;
pub mod docking;
mod drop_overlay;
pub mod export_task;
mod focus_scan;
mod frame_rate;
pub mod headless;
pub mod i18n;
pub mod icons;
pub mod lightroom_destination;
pub mod lightroom_import;
pub mod links;
pub mod menu_level;
pub mod menubar;
pub mod menus;
mod model_setup;
pub mod panels;
pub mod pick;
pub mod progress;
pub mod rating;
pub mod recheck;
pub mod region;
pub mod reload;
pub mod render;
pub mod shortcuts;
pub mod sidebar;
pub mod softpaint;
pub mod state;
pub mod sync;
pub mod tasks;
pub mod text_field;
pub mod theme;
pub mod updates_ui;
pub mod widgets;
pub mod window_place;
#[cfg(test)]
mod tests;
#[cfg(test)]
use tests::panels as tests_panels;
use std::sync::mpsc::{Receiver, Sender};
use lightcraft_engine::{AiModel, ErrorKind, Session};
use serde_json::Value;
pub use command_error::CommandError;
pub use control::{ControlRequest, ControlResponse};
pub use dialogs::import;
pub use dialogs::merge;
pub use dialogs::print_view;
pub use dialogs::slideshow_export;
pub use state::UiState;
const TOAST_SECONDS: f64 = 1.4;
/// The `path` of a display profile read from the screen rather than chosen as a file.
pub const SCREEN_PROFILE: &str = "screen";
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>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Perf {
/// Layout of the last frame ([`LightcraftApp::ui`], including commands run from widgets).
pub frame_ms: f64,
/// Per-frame logic before layout ([`LightcraftApp::logic`]: control channel, shortcuts,
/// render polling, pending catalog persistence).
pub logic_ms: f64,
/// The whole update of the last frame: logic + layout.
pub update_ms: f64,
/// The slowest whole update since start.
pub max_update_ms: f64,
/// The previous frame's whole time on this thread, painting (texture uploads) included, as
/// the host reports it (eframe's `cpu_usage`); `None` without a host report (headless).
pub host_frame_ms: Option<f64>,
pub fps: f64,
}
/// Why quitting stopped to ask first (`panels::notices`).
#[derive(Clone, Debug, PartialEq)]
pub enum QuitPrompt {
/// Changes couldn't be written to disk (issue #103).
Unsaved(String),
/// Tasks that quitting would cut short are running: an import, an export… (issue #345).
Tasks(String),
}
impl QuitPrompt {
/// The prompt's message.
pub fn text(&self) -> &str {
match self {
QuitPrompt::Unsaved(text) | QuitPrompt::Tasks(text) => text,
}
}
}
pub struct LightcraftApp {
pub(crate) model_setup: model_setup::Pending,
/// Per-catalog-revision caches of library-wide results the panels show every frame
/// (expensive on big libraries).
pub caches: Caches,
pub session: Session,
pub ui: UiState,
pub services: Services,
pub renderer: render::Renderer,
pub perf: Perf,
/// macOS: the host draws the traffic lights over our top bar.
pub integrated_titlebar: bool,
/// The `movable` flag last given to the window (macOS, `craft_window::title_bar::sync_movable`).
pub window_movable: Option<bool>,
/// The host installed a native menu bar (no in-window menus then).
pub native_menu: bool,
/// Shortcuts the native menu bar currently handles (`Cmd+Z`, `G`…): the egui shortcut handler
/// leaves them alone so nothing fires twice.
pub native_shortcuts: std::collections::HashSet<String>,
/// The keyboard shortcuts editor is waiting for a key press for this command: no shortcut
/// fires (the native menu bar drops its accelerators too) until it gets one or is cancelled.
pub recording_shortcut: Option<String>,
/// A text field had the keyboard at the end of the last frame. egui drops the focus as Esc
/// arrives, before the shortcuts are read, so this is how Esc there stays the field's own.
pub text_focus: bool,
/// The host is [`headless::Headless`] (it answers viewport screenshot commands itself).
pub headless_host: bool,
/// Warnings to show one at a time (damaged settings files…, issue #103).
pub notices: Vec<String>,
pub(crate) updates_rt: updates_ui::Runtime,
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
pub quit_prompt: Option<QuitPrompt>,
/// Keyboard/focus/input state for the prompt above, including its one-frame creation barrier.
pub(crate) notice_input: panels::notices::PromptInput,
/// Quit Anyway was chosen: the window may close with unsaved changes.
pub quit_confirmed: bool,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<PendingShot>,
/// Synthetic-input control requests waiting for a fresh layout and delivery (plan §4.5).
pending_gestures: Vec<PendingGesture>,
/// Completed, nondiscarded UI layouts. A gesture admitted before this advanced may resolve.
layout_epoch: u64,
screenshot_token: u64,
/// Bytes reserved by admitted, not-yet-drained screenshots (in-flight and completed alike).
shot_bytes: usize,
/// A geometry change since the last logic call: a completed image from an older epoch is
/// stale even when the window has meanwhile been resized back to the issued size.
shot_geometry_epoch: u64,
last_shot_geometry: Option<ShotGeometry>,
/// The texture limit the host last reported (`RawInput::max_texture_side`), retained across
/// passes where the host reports it only once.
max_texture_side: usize,
/// Monotonic app clock (`web_time::Instant`), not the wall clock and not egui's per-pass time:
/// timers must keep advancing while the window is hidden.
clock: AppClock,
/// The language already sent to `Context::set_language`, so a logic pass does not allocate a
/// `String` just to compare it.
accesskit_language: Option<String>,
/// When playback was auto-paused because the window became hidden (`None`: it is running, or
/// the user paused it). On unhide the pause is lifted and the slide keeps its remaining time.
slideshow_hidden_at: Option<f64>,
/// The app (not the user) paused the slideshow because the window was hidden.
slideshow_auto_paused: bool,
/// Files dropped since the last UI pass, consumed exactly once by the UI phase.
dropped_files: Vec<egui::DroppedFileHandle>,
/// A synthetic batch was injected into a pass and has not been consumed by a completed
/// (nondiscarded) UI pass yet: a hidden logic tick must not inject another one.
synthetic_in_flight: bool,
/// Completed, nondiscarded root UI passes; host frames, not layout passes.
ui_passes: u64,
/// Test-only: the library barrier's per-pass decisions, so a first-return test can observe the
/// dedicated shield/discard pass and the following background pass rather than only its input
/// effects (which the global shortcut guard also hides).
#[cfg(test)]
pub(crate) library_passes: Vec<panels::library_problem::LibraryPass>,
/// Offscreen context for headless screenshots of the windowed app.
shadow: Option<headless::HeadlessView>,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
/// A gesture was canceled after its synthetic pointer press reached egui. The next
/// [`Self::raw_input_hook`] cancels that press before egui's next input pass, without a
/// `PointerMoved` (which can start a drag) or a `PointerButton` release (which can become a
/// click).
pointer_cleanup: bool,
/// Files the control channel (`ui.dropFiles`) holds over the window: every frame reports them
/// as hovered, as the OS does while a drag is over the window.
drop_hover: Vec<String>,
/// Files the control channel dropped: delivered as `dropped_files` on the next frame.
drop_pending: Vec<String>,
/// The text the UI last put on the clipboard (`ui.inspect` → `copied`).
pub copied: Option<String>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
pub(crate) pending_picks: Vec<pick::Pending>,
/// Modifiers announced for synthetic input (held from a button down to its release). This is
/// only the app's synthetic ownership; the real host state is tracked separately in
/// `host_mods`, so a canceled synthetic gesture never clears a physically held key.
synthetic_mods: egui::Modifiers,
/// Clear `synthetic_mods` on the next frame.
synthetic_mods_release: bool,
/// The host's last reported physical modifier state (`ModifiersChanged`), restored after a
/// synthetic gesture releases instead of unconditionally announcing `NONE`.
host_mods: egui::Modifiers,
styled: bool,
/// The language the installed fonts were built for: the CJK fallback order follows the UI
/// language's script, so switching language reinstalls them.
font_language: i18n::Locale,
/// Browser font loaded separately from WASM so the module stays below its host's size limit.
chinese_font: Option<std::sync::Arc<egui::FontData>>,
fonts_ready: bool,
last_time: f64,
/// Rect of the photo canvas and the displayed image (screen points) from the last frame.
pub canvas_rect: Option<egui::Rect>,
pub image_rect: Option<egui::Rect>,
/// Scroll offsets (points) of the photo grid (vertical) and the filmstrip (horizontal) as
/// drawn last (`ui.inspect` → `scroll`).
pub grid_scroll: Option<f32>,
pub film_scroll: Option<f32>,
/// Widget registry from the last completed, nondiscarded frame (automation ids → rects).
pub widgets: Vec<(String, egui::Rect)>,
/// Egui hit owners for [`Self::widgets`] from the same completed pass. A press is delivered
/// only when this id still owns egui's pointer hit (see `control::widget_target_visible`).
pub widget_hits: Vec<(String, widgets::WidgetHit)>,
/// Registry collected by the pass being laid out, published only if that pass completes.
next_widgets: Vec<(String, egui::Rect)>,
next_widget_hits: Vec<(String, widgets::WidgetHit)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
pub gesture: Option<panels::detail::Gesture>,
/// The brush stroke being painted, as drawn on the photo until it's committed (issue #517).
pub brush_live: Option<brush_live::LiveStroke>,
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
/// The window render the loupe asked for last, by job key (see [`region`]); kept for the few
/// windows whose textures can be on screen, and for the inspector.
pub region_view: Option<region::RegionView>,
/// The same for the Before side of a Before/After view.
pub region_before_view: Option<region::RegionView>,
/// The loupe's render sizes while a pinch or two-finger scroll runs.
pub(crate) size_hold: region::SizeHold,
/// The size of a slider drag's whole-frame drafts (full size where they keep up, issue #330).
pub(crate) drag_scale: region::DragScale,
/// (photo, look, window frame size) a window was refused for: it reads more than one render holds.
pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>,
pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>,
/// The window render each pane of Compare / Reference asked for last (left, right), as
/// [`Self::region_view`] is for the loupe.
pub compare_windows: [Option<region::RegionView>; 2],
/// Compare / Reference window renders by (pane, job key): which texture shows what.
pub(crate) compare_tiles: std::collections::HashMap<(u8, u64), region::RegionView>,
/// Until when (s) a pinch or scroll in Compare / Reference holds the windows there are.
pub(crate) compare_hold_until: f64,
/// Photo Merge dialog previews and background merges.
pub merge: merge::MergeState,
/// An import in progress (the import review dialog's batches).
pub import: Option<import::ImportTask>,
/// A folder scan in progress (feeds the import review).
pub scan: Option<import::ScanTask>,
/// A Synchronize Folder scan in progress (feeds its dialog).
pub sync: Option<sync::SyncTask>,
/// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it).
pub sync_owns_changes: bool,
/// A Synchronize Folder at work in the background.
pub sync_run: Option<sync::SyncRun>,
/// A Lightroom catalog inspect/import in progress.
pub lightroom: Option<lightroom_import::LightroomTask>,
/// Last terminal Lightroom result, exposed by the command's status/wait response.
pub lightroom_last: Option<Value>,
/// A completed import result waiting for another modal dialog to close.
pub(crate) lightroom_result: Option<state::Dialog>,
/// The import review of a finished scan, waiting for another dialog to close.
pub(crate) review_pending: Option<state::Dialog>,
/// The library generation the window title and the recent list were last updated for.
pub shown_library: Option<u64>,
/// A background export in progress.
pub export: Option<export_task::ExportTask>,
/// Background file-system work of other commands (Find Missing Photos, auto import…).
pub tasks: tasks::Tasks,
/// The files of the last finished background export (`ui.inspect` → `export.last`).
pub last_export_result: Option<Value>,
/// The last slideshow export's end has been told (a toast).
pub slideshow_export_reported: bool,
/// The look the loupe shows while the pointer rests on a preset or profile (set by the
/// panels each frame; nothing is committed, no history entry).
pub hover_preview: Option<HoverPreview>,
/// The window is in full screen (as last reported by the host, or as last requested).
pub window_is_fullscreen: bool,
/// The GPU preference last applied (`app.gpu`), to apply Settings changes once.
gpu_applied: Option<bool>,
/// Say once, when the GPU device has been tried, that no graphics processor was found
/// ([`panels::notices::gpu_notice`]). The desktop app sets it; headless sessions and tests don't.
pub gpu_notice: bool,
/// The memory budget setting last applied (MB, 0 = automatic).
memory_applied: Option<u32>,
/// The display profile setting last applied (`app.displayProfile`).
pub(crate) display_applied: Option<String>,
/// Why the display profile setting could not be applied (shown in Settings ▸ Display).
pub display_error: Option<String>,
/// The screen profile in use while no display profile is chosen (`services.screen_profile`),
/// and when it was last read (egui time, s).
pub(crate) screen_profile: Option<Vec<u8>>,
pub(crate) screen_profile_read: Option<f64>,
/// The library failed to open at launch: the blocking window, then the temporary-session
/// banner (issue #100). Cleared once a library opens.
pub library_problem: Option<panels::library_problem::LibraryProblem>,
/// First-frame focus/input state for a newly shown blocking library window.
pub(crate) library_window_input: panels::library_problem::WindowBarrier,
/// Alt/Option held as the window first appears offers the recent libraries once (issue
/// #1290); the desktop host arms it when nothing chose the library already.
pub alt_asks_for_library: bool,
/// Whether each recent library can be opened, answered by a worker (issue #1290): the launch
/// list and File ▸ Open Recent Library never look at a folder on the UI thread.
pub recent_checks: panels::library_problem::RecentChecks,
/// The activity stack shows every task, not just the first few ("+N more" was clicked).
pub activity_expanded: bool,
}
impl LightcraftApp {
/// Build the app with default UI, renderer, cache and control state, and move AI segmentation
/// to a worker so frames apply results instead of waiting.
pub fn new(mut session: Session, services: Services) -> Self {
// AI mask requests run on the model's worker; frames apply their results (never wait)
session.segmenter.background = true;
Self {
session,
ui: UiState::default(),
services,
renderer: render::Renderer::default(),
perf: Perf::default(),
caches: Caches::default(),
integrated_titlebar: false,
window_movable: None,
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
text_focus: false,
headless_host: false,
notices: vec![],
updates_rt: Default::default(),
quit_prompt: None,
notice_input: Default::default(),
quit_confirmed: false,
control_rx: None,
pending_screenshots: vec![],
pending_gestures: vec![],
layout_epoch: 0,
screenshot_token: 0,
shot_bytes: 0,
shot_geometry_epoch: 0,
last_shot_geometry: None,
max_texture_side: MAX_TEXTURE_SIDE,
clock: AppClock::new(),
accesskit_language: None,
slideshow_hidden_at: None,
slideshow_auto_paused: false,
dropped_files: vec![],
synthetic_in_flight: false,
ui_passes: 0,
#[cfg(test)]
library_passes: vec![],
shadow: None,
synthetic: vec![],
pointer_cleanup: false,
drop_hover: vec![],
drop_pending: vec![],
copied: None,
pending_picks: vec![],
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
host_mods: egui::Modifiers::NONE,
styled: false,
font_language: i18n::Locale::En,
chinese_font: None,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
image_rect: None,
grid_scroll: None,
film_scroll: None,
widgets: vec![],
widget_hits: vec![],
next_widgets: vec![],
next_widget_hits: vec![],
gesture: None,
brush_live: None,
loupe_shown: None,
region_view: None,
region_before_view: None,
size_hold: Default::default(),
drag_scale: Default::default(),
window_refused: None,
region_tiles: Default::default(),
compare_windows: [None; 2],
compare_tiles: Default::default(),
compare_hold_until: f64::NEG_INFINITY,
merge: merge::MergeState::default(),
import: None,
scan: None,
sync: None,
sync_owns_changes: false,
sync_run: None,
lightroom: None,
lightroom_last: None,
lightroom_result: None,
review_pending: None,
shown_library: None,
export: None,
tasks: Default::default(),
last_export_result: None,
slideshow_export_reported: true,
hover_preview: None,
window_is_fullscreen: false,
gpu_applied: None,
gpu_notice: false,
memory_applied: None,
display_applied: None,
display_error: None,
screen_profile: None,
screen_profile_read: None,
library_problem: None,
library_window_input: Default::default(),
alt_asks_for_library: false,
recent_checks: Default::default(),
activity_expanded: false,
model_setup: Default::default(),
}
}
/// Supply the browser's separately downloaded Simplified Chinese font before the first frame.
pub fn with_chinese_font(mut self, bytes: Vec<u8>) -> Self {
if !bytes.is_empty() {
self.chinese_font = Some(std::sync::Arc::new(egui::FontData::from_owned(bytes)));
}
self
}
/// Attach the control-channel receiver and keep CPU copies of photo textures, which headless
/// screenshots need; returns the app for chaining.
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
// keep CPU copies of photo textures so `ui.screenshot {"headless": true}` can draw them
self.renderer.keep_pixels = true;
self
}
/// Seconds on the app's monotonic clock. Every timer that must advance while the window is
/// hidden (imports, autosave, playback, toasts, captures) uses this, never `i.time`.
pub fn tick_now(&self) -> f64 {
self.clock.now()
}
/// The app clock in milliseconds, for control deadlines and timers.
fn tick_now_ms(&self) -> f64 {
self.clock.now() * 1000.0
}
/// The immutable identity a capture is bound to: the library generation. It changes when a
/// different library is opened, not on every edit, import batch or keyword op (`catalog.revision`
/// bumps on all of those), so a screenshot during interactive editing still belongs to the
/// document the user asked for.
fn shot_document_generation(&self) -> u64 {
self.session.library_generation()
}
/// Give a headless driver its simulated time (`None` returns to the monotonic host clock).
pub fn set_simulated_clock(&mut self, seconds: Option<f64>) {
self.clock.simulated = seconds.filter(|s| s.is_finite() && *s >= 0.0);
}
/// The window's current surface geometry as native points plus native pixels per point.
/// `run_logic` refreshes `input.viewports`, so this is fresh even while the UI is not laid out;
/// without one, the last laid-out content rect is used.
fn shot_geometry(&self, ctx: &egui::Context) -> ShotGeometry {
// Both `viewport.inner_rect` and the content rect are in egui points, and `pixels_per_point`
// already includes the UI zoom (egui-winit divides by the zoomed ppp). Multiplying by the
// zoom again would reserve a surface that is too small, so the zoom is only tracked as a
// change signal.
let zoom = ctx.zoom_factor();
let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 };
let pixels_per_point = ctx.input(|i| i.pixels_per_point());
let viewport = ctx.input(|i| {
i.viewport().inner_rect.filter(|r| r.width() > 0.0 && r.height() > 0.0).map_or_else(|| i.content_rect().size(), |inner| inner.size())
});
ShotGeometry { viewport, pixels_per_point, zoom, epoch: self.shot_geometry_epoch }
}
/// Track geometry changes as a generation. Called once per logic tick from the refreshed
/// viewport information, so a hidden window still notices a resize.
fn note_shot_geometry(&mut self, ctx: &egui::Context) {
let mut now = self.shot_geometry(ctx);
if let Some(last) = self.last_shot_geometry
&& (last.viewport != now.viewport || last.pixels_per_point != now.pixels_per_point || last.zoom != now.zoom)
{
self.shot_geometry_epoch = self.shot_geometry_epoch.wrapping_add(1);
now.epoch = self.shot_geometry_epoch;
}
self.last_shot_geometry = Some(now);
}
/// The texture limit the host reported (16 384 unless it said otherwise).
fn max_texture_side(&self) -> usize {
self.max_texture_side
}
/// Retain the host's `RawInput::max_texture_side`. Hosts (egui-winit, `Headless`) report it
/// only when it changes, so keep the last value instead of assuming 16 384.
fn note_max_texture_side(&mut self, ctx: &egui::Context) {
if let Some(limit) = ctx.input(|i| i.raw.max_texture_side)
&& limit > 0
{
self.max_texture_side = limit;
}
}
/// Reserve a new image's `bytes` against the aggregate screenshot budget, or report why not.
fn reserve_shot_bytes(&mut self, bytes: usize) -> Result<(), String> {
if bytes > MAX_SHOT_BYTES {
return Err(format!("ui.screenshot: {bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit"));
}
let total = self.shot_bytes.checked_add(bytes).ok_or("ui.screenshot: byte reservation overflow")?;
if total > MAX_SHOT_TOTAL_BYTES {
return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit"));
}
self.shot_bytes = total;
Ok(())
}
/// A surface that grew after admission (a stale `content_rect` on Wayland, a resize between
/// issue and completion): bound the *new image* against the per-image limit, then reserve only
/// the growth against the aggregate. The old per-image check would compare the delta to the
/// per-image cap and name the delta as if it were the image.
fn grow_shot_reservation(&mut self, current_reserved: usize, image_bytes: usize) -> Result<(), String> {
if image_bytes > MAX_SHOT_BYTES {
return Err(format!("ui.screenshot: {image_bytes} bytes is over the {MAX_SHOT_BYTES}-byte per-image limit"));
}
let extra = image_bytes.saturating_sub(current_reserved);
let total = self.shot_bytes.checked_add(extra).ok_or("ui.screenshot: byte reservation overflow")?;
if total > MAX_SHOT_TOTAL_BYTES {
return Err(format!("ui.screenshot: {total} bytes reserved is over the {MAX_SHOT_TOTAL_BYTES}-byte limit"));
}
self.shot_bytes = total;
Ok(())
}
/// Release a reservation; never let an accounting bug underflow `usize`.
fn release_shot_bytes(&mut self, bytes: usize) {
self.shot_bytes = self.shot_bytes.saturating_sub(bytes);
}
/// The open person page, the way back to it, and the photo it was opened from follow a rename.
fn follow_renamed_person(&mut self, from: &str, to: &str) {
let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase();
if self.ui.person_page.as_deref().is_some_and(same) {
self.ui.person_page = Some(to.to_string());
}
if self.ui.last_person.as_deref().is_some_and(same) {
self.ui.last_person = Some(to.to_string());
}
if let Some((Some(name), photo)) = self.ui.person_from.clone()
&& same(&name)
{
self.ui.person_from = Some((Some(to.to_string()), photo));
}
}
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
self.run_typed(id, params).map_err(|e| e.message)
}
/// [`LightcraftApp::run`], keeping what kind of error a refusal is: for code that decides on it
/// (offer a model's download, treat a cancel as one) instead of reading the message.
pub fn run_typed(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
if let Some(result) = model_setup::intercept(self, id, &params) {
return result.map_err(CommandError::from);
}
if let Some(r) = menus::run_ui_command(self, id, &params) {
if r.is_ok() {
docking::reveal_command(self, id)?;
}
return r.map_err(CommandError::from);
}
let params = if id == "crop.aspect"
&& params.get("aspect").and_then(Value::as_str) == Some("toggle")
&& self.crop_tool_aspect().is_some()
&& self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default())
{
serde_json::json!({"aspect": "free"})
} else {
params
};
let r = self.session.execute(id, &params).map_err(CommandError::from);
if r.is_ok() && matches!(id, "crop.aspect" | "crop.rotateAspect") {
self.ui.crop_default_aspect = if id == "crop.aspect" && params.get("aspect").and_then(Value::as_str) == Some("original") {
Some(serde_json::json!("original"))
} else {
self.session
.active()
.and_then(|photo| self.session.develop_of(photo))
.map(|d| d.crop.aspect.map_or(serde_json::json!("free"), |(w, h)| serde_json::json!([w as f64 / 100.0, h as f64 / 100.0])))
};
}
if r.is_ok() && id == "library.toggleFilters" {
// the search field shows the text filter: empty while the filters are off, back with them
self.ui.search = self.session.filter.text.clone();
}
if r.is_ok() && id == "library.clearFilter" {
// the search field shows the text filter: cleared from anywhere (the loupe's pill, the
// menu), it must not keep a search that no longer applies (issue #1648)
self.ui.search.clear();
}
if r.is_ok() && id == "mask.adjust" {
// Judge local adjustments on the photo, without the selection overlay obscuring them.
// Keep it hidden after release; O / the overlay eye can show it again.
self.ui.mask_overlay = false;
}
if r.is_ok() && matches!(id, "mask.add" | "mask.addComponent" | "mask.duplicate" | "mask.select") {
// A new or newly picked mask shows its overlay again, as in Lightroom: hidden by a slider
// drag above, it stayed hidden while the Overlay menu still named a mode (issue #1800).
self.ui.mask_overlay = true;
}
if r.is_ok() {
// These commands finish synchronously, so their completion would otherwise
// be invisible once the activity stack is gone. Never show success on error.
let completed = match id {
"library.compact" => Some(format!("✓ {}", i18n::tr("Optimize Library"))),
"library.clearPreviews" => Some(format!("✓ {}", i18n::tr("Discard Preview Cache"))),
"develop.autoSync" => Some(format!("{}: {}", i18n::tr("Auto Sync"), i18n::tr(if self.session.auto_sync { "On" } else { "Off" }))),
_ => None,
};
if let Some(message) = completed {
let now = self.tick_now();
self.ui.toast = Some((message, now + TOAST_SECONDS, None));
}
}
if r.is_ok() && id == "photo.label" {
let label = params.get("label").and_then(Value::as_str).and_then(lightcraft_catalog::ColorLabel::parse);
let text = match label {
Some(label) => {
let name =
self.session.catalog.custom_label_name(label).map(str::to_owned).unwrap_or_else(|| i18n::tr(&format!("{label:?}")).into());
i18n::tr_format!("{name} Label", name = name)
}
None => i18n::tr("Color label cleared").into(),
};
let now = self.tick_now();
self.ui.toast = Some((text, now + TOAST_SECONDS, label));
}
match &r {
Err(e) => {
log::warn!("{id}: {e}");
self.ui.status = e.message.clone();
// no detector yet: offer it (its terms first) instead of only saying so
if id == "faces.detect" && e.kind == ErrorKind::ModelMissing(AiModel::FaceDetector) {
panels::faces::offer_detector(self);
}
}
Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v),
Ok(v) if id == "faces.renamePerson" => {
let to = v["to"].as_str().unwrap_or("").to_string();
let from = params.get("from").and_then(Value::as_str).unwrap_or("");
if !to.is_empty() {
self.follow_renamed_person(from, &to);
}
}
Ok(_) => {}
}
r
}
/// The remembered ratio belongs to the tool until the user starts editing the crop.
pub(crate) fn crop_tool_aspect(&self) -> Option<(u32, u32)> {
let photo = self.session.active()?;
let d = self.session.develop_of(photo)?;
if d.crop != lightcraft_develop::Crop::default() {
return d.crop.aspect;
}
// nothing chosen yet: locked to the photo's own shape, as Lightroom starts (#991); one
// Lock toggle (remembered as "free") unlocks it for good
let remembered = self.ui.crop_default_aspect.clone().unwrap_or_else(|| Value::String("original".into()));
match &remembered {
Value::String(s) if s == "original" => {
let p = self.session.catalog.photo(photo)?;
let (w, h) = (p.width.max(1).saturating_mul(100), p.height.max(1).saturating_mul(100));
Some(if d.orientation.swaps_axes() { (h, w) } else { (w, h) })
}
Value::Array(a) => {
let number = |i: usize| {
let v = a.get(i)?.as_f64()? * 100.0;
(v.is_finite() && v >= 1.0 && v <= f64::from(u32::MAX)).then_some(v.round() as u32)
};
Some((number(0)?, number(1)?))
}
_ => None,
}
}
/// Called only after a crop gesture begins, inside its undo transaction.
pub(crate) fn begin_crop(&mut self) -> Result<(), String> {
// nothing chosen yet: the photo's own shape, as `crop_tool_aspect` shows it
let aspect = self.ui.crop_default_aspect.clone().unwrap_or_else(|| serde_json::json!("original"));
if self.session.interaction.is_some()
&& self.session.active().and_then(|id| self.session.develop_of(id)).is_some_and(|d| d.crop == lightcraft_develop::Crop::default())
&& aspect != serde_json::json!("free")
// the remembered ratio, turned like the new photo's crop (as a named ratio is)
&& let Err(e) = self.session.execute("crop.aspect", &serde_json::json!({"aspect": aspect, "orientation": "crop"}))
{
// A remembered value the command refuses (a hand-edited settings file) would refuse
// every crop drag on an untouched photo: forget it and let this drag go ahead.
self.ui.crop_default_aspect = None;
self.ui.status = e.to_string();
}
Ok(())
}
/// Advance a running slideshow, but not while the window is hidden: in 0.36 `i.time` froze
/// there, and a minimized show must not keep waking the event loop or requesting full-size
/// slide renders overnight. Playback is auto-paused on hide and resumed on unhide with the
/// slide's remaining time preserved.
fn slideshow_tick(&mut self, ctx: &egui::Context, now: f64) {
let hidden = ctx.input(|i| i.viewport().visible() == Some(false));
let Some(run) = self.ui.slideshow.as_mut() else {
self.slideshow_hidden_at = None;
self.slideshow_auto_paused = false;
return;
};
if hidden {
if self.slideshow_hidden_at.is_none() {
self.slideshow_hidden_at = Some(now);
if !run.play.is_paused() {
run.play.toggle_pause(now);
self.slideshow_auto_paused = true;
}
}
return;
}
if self.slideshow_hidden_at.take().is_some() && self.slideshow_auto_paused {
self.slideshow_auto_paused = false;
if run.play.is_paused() {
run.play.toggle_pause(now);
}
}
panels::slideshow::tick(self, ctx, now);
}
/// Announce the end of a Build Previews run.
fn preview_build_status(&mut self, ctx: &egui::Context) {
use std::sync::atomic::Ordering;
let Some(b) = self.session.preview_build.clone() else { return };
let key = std::sync::Arc::as_ptr(&b) as usize;
if b.finished.load(Ordering::Relaxed) {
if self.ui.preview_build_seen != Some((key, true)) {
self.ui.preview_build_seen = Some((key, true));
let (done, failed) = (b.done.load(Ordering::Relaxed), b.failed.load(Ordering::Relaxed));
let plural = if done == 1 { "" } else { "s" };
let mut msg = match (b.error(), b.what) {
(Some(e), what) => format!("{}: {e}", if what.is_empty() { "previews" } else { what }),
(None, "") => crate::i18n::tr_format!("Previews ready for {done} photo{plural}", done = done, plural = plural),
(None, what) => {
crate::i18n::tr_format!("Done ({what}): {done} photo{plural}", done = done, plural = plural, what = crate::i18n::tr(what))
}
};
if failed > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {failed} couldn't be rendered", failed = failed));
}
self.toast(ctx, msg);
}
} else {
// The activity stack shows the run while it works; poll for the end.
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
}
/// Announce when saving the library starts failing (changes then live only in memory and are
/// retried) and when it works again; the top bar's cloud icon shows the state meanwhile.
fn save_status(&mut self, ctx: &egui::Context) {
let unsaved = self.session.unsaved().map(|(n, e)| (n, e.to_string()));
match (unsaved, self.ui.unsaved_seen) {
(Some((n, e)), false) => {
self.ui.unsaved_seen = true;
let t = self.tick_now();
self.ui.toast = Some((
crate::i18n::tr_format!(
"{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry",
if n == 1 { "" } else { "s" },
n = n,
e = e
),
t + 6.0,
None,
));
}
(None, true) => {
self.ui.unsaved_seen = false;
self.toast(ctx, crate::i18n::tr("Library saved"));
}
(Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry
(None, false) => {}
}
}
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
self.toast_for(ctx, text, TOAST_SECONDS);
}
/// Auto straighten (the crop panel's Auto, a double-click with the Straighten tool), saying so
/// when it finds nothing to level by instead of leaving the photo as it is without a word.
pub fn auto_straighten(&mut self, ctx: &egui::Context) {
match self.run("crop.autoStraighten", serde_json::json!({})) {
Ok(r) if r.get("changed").and_then(Value::as_bool) == Some(false) => {
self.toast(ctx, crate::i18n::tr("No horizon or vertical lines to level by"));
}
Ok(_) => {}
Err(e) => self.toast(ctx, e),
}
}
/// A toast that stays `secs` seconds (messages that say where to look or what to do next).
pub fn toast_for(&mut self, _ctx: &egui::Context, text: impl Into<String>, secs: f64) {
let t = self.tick_now();
self.ui.toast = Some((text.into(), t + secs, None));
}
/// Reverse geocoding: say how a place-names download ended (a failure stays readable, with what to do).
fn place_names_outcome(&mut self, ctx: &egui::Context) {
use lightcraft_engine::cmd::geocode::DownloadOutcome;
match self.session.place_names.take_outcome() {
Some(DownloadOutcome::Done(_)) => self.toast(ctx, crate::i18n::tr("Place names downloaded")),
Some(DownloadOutcome::Cancelled) => self.toast(ctx, crate::i18n::tr("Place names download cancelled")),
Some(DownloadOutcome::Failed(e)) => self.toast_error(ctx, e),
None if self.session.place_names.downloading() => ctx.request_repaint_after(std::time::Duration::from_millis(500)),
None => {}
}
}
/// AI masks: apply finished background requests (clicks, descriptions, detail passes) and
/// show their errors; start a zoomed-in detail pass once the clicking stops
/// (`ui.detail_due`); watch the model download. Never waits for the model.
fn ai_mask_detail(&mut self, ctx: &egui::Context, now: f64) {
let polled = self.session.segment_poll();
if polled.changed {
ctx.request_repaint();
}
if let Some(mask) = polled.refine {
// a new selection from a click or a description: show it, as `run` does for new masks
self.ui.mask_overlay = true;
self.ui.detail_due = Some((now + 0.3, mask));
}
if let Some(e) = polled.messages.into_iter().last() {
self.ai_error(ctx, e, None);
}
let seg = &self.session.segmenter;
if seg.busy() || seg.pending_clicks().is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
let download = seg.download_status();
if download.running {
ctx.request_repaint_after(std::time::Duration::from_millis(250));
}
// a download finishing while its dialog is closed: say so once
if self.ui.sam_downloading && !download.running {
self.ui.sam_downloading = false;
let open = matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. }));
match sam_download_ended(&download) {
_ if open => {}
Some((text, true)) => self.toast_error(ctx, text),
Some((text, false)) => self.toast(ctx, text),
None => {}
}
}
if let Some((due, mask)) = self.ui.detail_due {
if now >= due && !self.session.segmenter.busy() && !self.session.segmenter.detail_busy() {
self.ui.detail_due = None;
if let Err(e) = self.run("mask.refineDetail", serde_json::json!({"id": mask})) {
log::warn!("detail pass: {e}");
}
} else {
ctx.request_repaint_after(std::time::Duration::from_millis(150));
}
}
if self.session.segmenter.detail_busy() {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
/// An AI mask error: when the model isn't installed, the dialog that offers to download it
/// (`then`: the AI mask to start afterwards); otherwise a toast. Decided on the error's kind
/// ([`ErrorKind::ModelMissing`]), not its words.
pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into<CommandError>, then: Option<(&str, &str)>) {
let e = e.into();
if lightcraft_engine::segment::Segmenter::AVAILABLE && e.kind == ErrorKind::ModelMissing(AiModel::Sam3) {
self.offer_sam_download(then);
} else {
self.toast_error(ctx, e.message);
}
}
/// Open the dialog offering the SAM 3 download (never downloads by itself).
pub fn offer_sam_download(&mut self, then: Option<(&str, &str)>) {
if self.ui.dialog.is_none() || matches!(self.ui.dialog, Some(state::Dialog::SamModel { .. })) {
self.ui.dialog = Some(state::Dialog::SamModel { then: then.map(|(k, o)| (k.to_string(), o.to_string())), error: None });
}
}
/// A toast for an error the user has to read and act on (stays 6 s).
pub fn toast_error(&mut self, _ctx: &egui::Context, text: impl Into<String>) {
let t = self.tick_now();
self.ui.toast = Some((text.into(), t + 6.0, None));
}
/// Take the control receiver for this frame, run every queued request whose sender still
/// wants it, and reply directly or admit its screenshot/gesture to a later completed pass.
/// Requests abandoned by their sender are skipped rather than executed late.
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
// a request its sender gave up on (the window drew nothing for a while) is not run
// late: it would happen twice once the sender retries (issue #718)
if !req.take() {
continue;
}
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { target, headless } => {
if let Err(error) = self.admit_shot(ctx, target, headless, reply.clone()) {
let _ = reply.send(serde_json::json!({"ok": false, "error": error}));
}
}
control::Outcome::Gesture { method, params } => {
if let Err(error) = self.admit_gesture(ctx, method, params, reply.clone()) {
let _ = reply.send(serde_json::json!({"ok": false, "error": error}));
}
}
}
}
self.control_rx = Some(rx);
}
/// The navigation/document/modal/panel state that decides what an automation id means.
fn gesture_frame(&self) -> GestureFrame {
GestureFrame {
view: self.ui.view,
active: self.session.active(),
library: self.session.library_generation(),
dialog: self.ui.dialog.as_ref().map(std::mem::discriminant),
left_panel: self.ui.left_panel,
right: self.ui.right,
presets: self.ui.presets,
filmstrip: self.ui.filmstrip,
}
}
/// Queue a synthetic-input request. Nothing is answered and no event is queued until a
/// completed layout newer than this admission exists (see [`Self::resolve_pending_gestures`]).
fn admit_gesture(&mut self, ctx: &egui::Context, method: String, params: Value, reply: Sender<ControlResponse>) -> Result<(), String> {
if self.pending_gestures.len() >= MAX_PENDING_GESTURES {
return Err(format!("{}: {MAX_PENDING_GESTURES} control gestures are already pending", method));
}
let now = self.tick_now_ms();
self.pending_gestures.push(PendingGesture {
method,
params,
reply,
admitted_epoch: self.layout_epoch,
frame: self.gesture_frame(),
deadline_ms: now + GESTURE_TIMEOUT_MS,
resolved_pass: None,
remaining_events: 0,
started: false,
target: None,
pressed: None,
result: Value::Null,
});
ctx.request_repaint();
Ok(())
}
/// Resolve one queued gesture against the fresh widgets/image rect of the pass that just
/// completed. A changed view/document/dialog/panel state cancels instead of retargeting.
fn resolve_pending_gestures(&mut self, ctx: &egui::Context) {
if self.pending_gestures.is_empty() {
return;
}
let now = self.tick_now_ms();
let frame = self.gesture_frame();
let epoch = self.layout_epoch;
let mut gestures = std::mem::take(&mut self.pending_gestures);
let mut keep = Vec::with_capacity(gestures.len());
for mut gesture in gestures.drain(..) {
if gesture.resolved_pass.is_some() {
// Already handed to the delivery queue; `reply_delivered_gestures` (or
// `expire_gestures`) owns its deadline and must remove its unsent events too.
keep.push(gesture);
continue;
}
if now > gesture.deadline_ms {
// Only this request failed to get a fresh layout. A later gesture has its own
// deadline and must not be canceled with it.
let _ =
gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)}));
continue;
}
if epoch <= gesture.admitted_epoch {
keep.push(gesture);
continue;
}
if gesture.frame != frame {
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: the view, document, dialog or panel layout changed before a fresh layout", gesture.method),
}));
continue;
}
let before = self.synthetic.len();
match control::gesture_events(self, ctx, &gesture.method, &gesture.params) {
Ok(outcome) => {
gesture.target = outcome.target;
gesture.result = outcome.result;
gesture.resolved_pass = Some(self.ui_passes);
gesture.remaining_events = self.synthetic.len().saturating_sub(before);
gesture.started = gesture.remaining_events == 0;
keep.push(gesture);
}
Err(error) => {
let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": error}));
}
}
}
self.pending_gestures = keep;
}
/// Answer gestures whose last synthetic event a completed, nondiscarded UI pass consumed.
/// A zero-event action waits for one completed pass after resolution.
///
/// Delivery does not compare the frame again: the gesture's own event may legitimately change
/// the view, document, dialog or panel (a click that opens a dialog, a key that starts Compare),
/// so a second snapshot would cancel exactly the input that did its job. Freshness is checked
/// before resolution ([`Self::resolve_pending_gestures`]) and again immediately before the
/// batch is injected ([`Self::drop_gestures_stale_before_injection`]).
fn reply_delivered_gestures(&mut self) {
if self.pending_gestures.is_empty() {
return;
}
let now = self.tick_now_ms();
// Cancel only the gestures that actually stalled: their unsent events are at the front of
// the queue, and gestures queued behind them are independent input that must survive.
let mut i = 0;
while i < self.pending_gestures.len() {
if self.pending_gestures[i].resolved_pass.is_none() || now <= self.pending_gestures[i].deadline_ms {
i += 1;
continue;
}
let at_head = self.head_gesture() == Some(i);
let gesture = self.pending_gestures.remove(i);
self.cancel_gesture_events(&gesture, at_head);
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} timed out before the UI consumed its last event", gesture.method),
}));
}
if !self.synthetic.is_empty() || self.synthetic_in_flight {
return;
}
let mut gestures = std::mem::take(&mut self.pending_gestures);
let mut keep = Vec::with_capacity(gestures.len());
for mut gesture in gestures.drain(..) {
let Some(resolved) = gesture.resolved_pass else {
keep.push(gesture);
continue;
};
if self.ui_passes <= resolved {
keep.push(gesture);
continue;
}
if gesture.remaining_events > 0 {
// A defensive queue reset (a non-head gesture owned unsent events) dropped this
// batch. It must not become an `ok: true` for input no panel ever saw.
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: its queued input was dropped before delivery", gesture.method),
}));
continue;
}
let result = std::mem::take(&mut gesture.result);
let _ = gesture.reply.send(serde_json::json!({"ok": true, "result": result}));
}
self.pending_gestures = keep;
}
/// The gesture whose events sit at the head of the synthetic queue.
fn head_gesture(&self) -> Option<usize> {
self.pending_gestures.iter().position(|gesture| gesture.resolved_pass.is_some() && gesture.remaining_events > 0)
}
/// Fail resolved gestures whose view/document/dialog/panel snapshot changed before their
/// first synthetic batch reached the next completed pass. A hidden logic tick runs between
/// passes and may change what an automation id or a photo click means; fail closed instead of
/// delivering it against the new state.
///
/// A widget gesture also has to keep aiming at the requested widget. egui attributes a click
/// to the widget under the press point in the *previous completed pass*, while the control
/// batch spans several host frames (a move, then press, then release). Recheck the target
/// before the first event and before every press; if it moved or disappeared, cancel this
/// gesture with an error instead of clicking whatever is now at those coordinates.
///
/// Only the gesture at the head of the queue is checked, and later batches of the same gesture
/// may see its own partial effect (the first click of a double click selects the photo the
/// second one opens). A second, concurrently queued gesture is checked when its events reach
/// the head.
fn drop_gestures_stale_before_injection(&mut self, ctx: &egui::Context) -> bool {
let Some(head) = self.head_gesture() else { return false };
let first_batch = !self.pending_gestures[head].started;
if first_batch && self.pending_gestures[head].frame != self.gesture_frame() {
let gestures = std::mem::take(&mut self.pending_gestures);
for gesture in gestures {
if gesture.resolved_pass.is_none() {
self.pending_gestures.push(gesture);
} else {
if gesture.pressed.is_some() {
self.queue_pointer_cleanup();
}
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: the view, document, dialog or panel layout changed before delivery", gesture.method),
}));
}
}
self.synthetic.clear();
return true;
}
let Some(target) = self.pending_gestures[head].target.clone() else { return false };
let press_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: true, .. }));
let release_next = matches!(self.synthetic.first(), Some(egui::Event::PointerButton { pressed: false, .. }));
// A drag legitimately ends outside the widget it started on; only a click must still find
// its target at the release that egui attributes the click to. A hover must still find a
// visible widget for its single pointer move.
let check_release = release_next && target.check_release;
let check_visible = first_batch && target.check_visible;
if !first_batch && !press_next && !check_release && !check_visible {
return false;
}
let latest = control::widget_rect(self, &target.id);
let latest_hit = control::widget_hit(self, &target.id);
// A press must go to the widget that owns the point. A click's release must too (a drag
// may legitimately end outside the widget it started on), so an overlay that appears
// between press and release cancels the click before the release rather than activating it.
// A hover's single move must also still find the widget visible: the documented contract
// rejects a clipped or newly covered target instead of moving the pointer at stale coordinates.
let must_own = press_next || (release_next && target.check_release) || check_visible;
// A quit prompt or settings notice owns input even before its Foreground areas have
// settled (egui's first layout pass is non-interactable), and even when a control request
// is resolved against the registry of that first pass. Only the prompt's own buttons pass.
let notice_ok = !must_own || crate::panels::notices::allows_target(self, latest_hit);
let still_aimed = latest.is_some_and(|rect| {
// A changed widget cancels any gesture before its first event. Owned presses, click
// releases and hovers must also be inside egui's clipped, visible part and be the
// topmost widget hit: a target scrolled out of its panel, or one under a non-modal
// overlay, must not be pressed or hovered at stale coordinates.
let geometry_ok = rect == target.rect || rect.contains(target.at);
geometry_ok && notice_ok && (!must_own || control::widget_target_visible(ctx, latest_hit, target.at))
});
if still_aimed {
return false;
}
let outside_registered = !target.rect.contains(target.at);
let reason = match latest {
None => "disappeared",
Some(rect) if !(rect == target.rect || rect.contains(target.at)) => "moved",
Some(_) if outside_registered => "has no visible interaction area at the requested point",
Some(_) => "is no longer visible",
};
let phase = if first_batch {
"its first event"
} else if check_release {
"its release"
} else {
"its press"
};
// Only this gesture's unsent events are removed: they are at the front of the queue, and
// gestures resolved after it are checked in turn when they reach the head. Failing the
// whole click would also discard unrelated input.
let gesture = self.pending_gestures.remove(head);
self.cancel_gesture_events(&gesture, true);
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: widget `{}` {reason} before {phase}; nothing was retargeted", gesture.method, target.id),
}));
true
}
/// Remove a canceled gesture's unsent events from the front of the queue and release a pointer
/// button it left down. Callers cancel in queue order, so the events removed are exactly this
/// gesture's unsent prefix; a later gesture's events stay queued.
fn cancel_gesture_events(&mut self, gesture: &PendingGesture, at_head: bool) {
if at_head {
let unsent = gesture.remaining_events.min(self.synthetic.len());
self.synthetic.drain(..unsent);
} else if gesture.remaining_events > 0 {
// The queue order makes only the head own unsent events. If a caller ever breaks that
// invariant, fail safe: stale synthetic input must not reach an unrelated widget, even
// at the cost of dropping the rest of this batch instead of panicking or guessing a
// prefix. `reply_delivered_gestures` fails any gesture whose events disappeared this
// way instead of reporting a false success.
log::warn!("canceled non-head gesture still owned {} unsent synthetic events; dropping the queue", gesture.remaining_events);
self.synthetic.clear();
self.synthetic_in_flight = false;
}
if gesture.pressed.is_some() {
self.queue_pointer_cleanup();
}
}
/// Cancel every synthetic gesture that a newly shown prompt or blocking library window makes
/// unsafe.
///
/// A gesture whose input is still queued must not be delivered under the barrier or later when
/// it is gone. A gesture whose press already reached egui must be released without a click. A
/// gesture that already delivered all its events may still complete its reply: the barrier may
/// well be its own result (e.g. a key that opened the quit prompt). `all` is for a batch already
/// injected this host frame before the barrier was discovered after `raw_input_hook`: the
/// barrier filter drops it before any panel sees it, so even a fully injected gesture must fail
/// rather than report a successful delivery.
fn cancel_gestures_for_barrier(&mut self, all: bool, reason: &str) {
// All not-yet-injected events belong to the queue's head, one gesture at a time. Cancel
// them in order so only that gesture's unsent prefix is removed.
while let Some(head) = self.head_gesture() {
let gesture = self.pending_gestures.remove(head);
self.cancel_gesture_events(&gesture, true);
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: {reason} before its input was delivered", gesture.method),
}));
}
let mut i = 0;
while i < self.pending_gestures.len() {
if all || self.pending_gestures[i].resolved_pass.is_none() || self.pending_gestures[i].pressed.is_some() {
let gesture = self.pending_gestures.remove(i);
self.cancel_gesture_events(&gesture, false);
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: {reason} before its input was delivered", gesture.method),
}));
} else {
i += 1;
}
}
}
/// Cancel a press that reached egui without making it a click or starting a drag. The reset
/// itself happens before egui's next input pass in [`Self::raw_input_hook`], where replacing
/// the pointer state and stopping the drag is safe (the same approach as the sibling
/// SoundCraft/VectorCraft apps). No pointer movement is injected: moving beyond
/// `max_click_dist` suppresses a click but can also start a drag on a slider or curve.
fn queue_pointer_cleanup(&mut self) {
self.pointer_cleanup = true;
}
/// Apply a queued pointer cancellation before egui processes the next input pass.
fn apply_pointer_cleanup(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
if !std::mem::take(&mut self.pointer_cleanup) {
return;
}
ctx.input_mut(|input| input.pointer = egui::PointerState::default());
ctx.stop_dragging();
if self.synthetic_mods != egui::Modifiers::NONE {
// The cleanup removes only the app's synthetic modifiers, never a real host change.
// A host `ModifiersChanged` in this same raw batch is already authoritative and was
// recorded before this call; otherwise restore the host's last known state (which may
// be a physically held Shift), rather than clearing it unconditionally with NONE.
let host_changed = raw.events.iter().any(|event| matches!(event, egui::Event::ModifiersChanged(_)));
self.synthetic_mods = egui::Modifiers::NONE;
if !host_changed {
raw.events.push(egui::Event::ModifiersChanged(self.host_mods));
}
}
self.synthetic_mods_release = false;
}
/// Fail pending gestures at the monotonic deadline even when no UI pass runs (a hidden window
/// whose paint request the host ignored). `logic` calls this every tick.
fn expire_gestures(&mut self) {
if self.pending_gestures.is_empty() {
return;
}
let now = self.tick_now_ms();
if !self.pending_gestures.iter().any(|gesture| now > gesture.deadline_ms) {
return;
}
// Cancel exactly the stalled requests, in queue order, so a later gesture's input survives.
let mut i = 0;
while i < self.pending_gestures.len() {
if now <= self.pending_gestures[i].deadline_ms {
i += 1;
continue;
}
let at_head = self.head_gesture() == Some(i);
let gesture = self.pending_gestures.remove(i);
self.cancel_gesture_events(&gesture, at_head);
let _ = gesture.reply.send(serde_json::json!({"ok": false, "error": format!("{} timed out before the UI consumed it", gesture.method)}));
}
}
/// Admit a `ui.screenshot` request against the bounded capture budget. Nothing is written and
/// no capture is issued here: [`Self::issue_screenshots`] waits for a settled, nondiscarded UI
/// pass and a fresh geometry generation.
fn admit_shot(
&mut self,
ctx: &egui::Context,
target: Option<lightcraft_engine::originals::WriteTarget>,
headless: bool,
reply: Sender<ControlResponse>,
) -> Result<(), String> {
if self.pending_screenshots.len() >= MAX_PENDING_SHOTS {
return Err(format!("ui.screenshot: {MAX_PENDING_SHOTS} requests are already pending; wait for a reply first"));
}
let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx));
let bytes = geometry.bytes(self.max_texture_side()).ok_or_else(|| geometry.rejection(self.max_texture_side()))?;
self.reserve_shot_bytes(bytes)?;
self.screenshot_token = self.screenshot_token.wrapping_add(1);
let now = self.tick_now_ms();
self.pending_screenshots.push(PendingShot {
token: self.screenshot_token,
target,
reply,
headless: headless && !self.headless_host,
admitted_passes: self.ui_passes,
not_before_ms: now + SHOT_SETTLE_MS,
// Capture the current settled frame at this instant even if renders are still in flight
// (0.36 did this at its three-second deadline, so automation never got an error instead
// of an image while a slider/import kept the renderer busy).
force_ms: now + SHOT_FORCE_BUSY_MS,
// Settle + forced capture + compositor fallback + the headless settle, all from admission.
deadline_ms: now + SHOT_SETTLE_MS + SCREENSHOT_SETTLE_MS + SCREENSHOT_FALLBACK_MS + SCREENSHOT_SETTLE_MS,
sent_at_ms: None,
reserved: bytes,
slot: None,
issued: None,
issued_library: self.shot_document_generation(),
});
ctx.request_repaint();
Ok(())
}
/// Advance pending screenshots: wait (≥ 3 completed UI frames, ≥ 120 ms) until no renders are
/// in flight — or until the forced-capture instant, so sustained rendering cannot turn a
/// request into a timeout — then capture through the compositor's one-shot callback, or
/// headlessly ([`Self::headless_screenshot`]) when asked or when the compositor delivers nothing.
fn issue_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_screenshots.is_empty() {
return;
}
let now = self.tick_now_ms();
let busy = self.renderer.in_flight() > 0 || self.merge.busy();
let max_side = self.max_texture_side();
let library = self.shot_document_generation();
let geometry = self.last_shot_geometry.unwrap_or_else(|| self.shot_geometry(ctx));
let mut shots = std::mem::take(&mut self.pending_screenshots);
let mut shadow_ticked = false;
let mut keep = Vec::with_capacity(shots.len());
for mut shot in shots.drain(..) {
// Hard deadline from admission: a compositor fallback does not extend it.
if now > shot.deadline_ms {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)}));
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
self.release_shot_bytes(shot.reserved);
continue;
}
if let Some(sent) = shot.sent_at_ms {
if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host {
keep.push(shot);
continue;
}
// The compositor delivered nothing (display asleep, window occluded): go headless.
log::warn!("ui.screenshot: no frame from the compositor after {SCREENSHOT_FALLBACK_MS} ms; rendering headlessly");
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
shot.sent_at_ms = None;
shot.issued = None;
shot.headless = true;
}
// A busy renderer only defers the capture until the forced instant; after that the
// settled frame that exists now is captured (0.36's deadline behavior), never an error.
let ready = shot_ready(now, &shot, busy, self.ui_passes);
if !ready {
keep.push(shot);
continue;
}
if shot.headless {
// The shadow frame requests the renders the UI needs, even while the window shows nothing.
let img = if shadow_ticked { None } else { self.headless_screenshot(ctx, true) };
shadow_ticked = true;
if let Some(img) = img {
let bytes = img.size[0].checked_mul(img.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::<egui::Color32>()));
match bytes {
Some(bytes) if bytes > shot.reserved => {
// A stale/undersized reservation is not a user error: grow it to the
// actual image and encode it when the per-image and aggregate caps allow.
if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error}));
self.release_shot_bytes(shot.reserved);
continue;
}
shot.reserved = bytes;
}
Some(_) => {}
None => {
let reply = serde_json::json!({
"ok": false,
"error": "ui.screenshot: the rendered image has invalid dimensions",
});
let _ = shot.reply.send(reply);
self.release_shot_bytes(shot.reserved);
continue;
}
}
let _ = shot.reply.send(control::save_screenshot(self, &img, shot.target.as_ref()));
self.release_shot_bytes(shot.reserved);
continue;
}
keep.push(shot);
continue;
}
// The compositor path needs a real, laid-out UI and fresh fonts; a queued request on a
// hidden window is kept alive by RequestPaintWhileHidden from `logic`.
if !self.fonts_ready || self.ui_passes == 0 {
keep.push(shot);
continue;
}
// Revalidate the reservation against the geometry the command is actually bound to.
let Some(bytes) = geometry.bytes(max_side) else {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": geometry.rejection(max_side)}));
self.release_shot_bytes(shot.reserved);
continue;
};
if bytes > shot.reserved {
if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error}));
self.release_shot_bytes(shot.reserved);
continue;
}
shot.reserved = bytes;
}
// One slot and one callback per issue; the callback only holds a weak slot and wakes
// the viewport. File writes and replies happen in `collect_screenshots`.
let (slot, callback) = craft_ui::capture::CaptureSlot::new(ctx, egui::ViewportId::ROOT, MAX_SHOT_BYTES);
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(callback));
log::debug!("ui.screenshot {}: issued at {:?}, {} bytes reserved", shot.token, geometry.viewport, shot.reserved);
shot.slot = Some(slot);
shot.sent_at_ms = Some(now);
shot.issued = Some(geometry);
shot.issued_library = library;
keep.push(shot);
}
self.pending_screenshots = keep;
if !self.pending_screenshots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// The system's appearance: the host's answer first, then egui's (none: unknown).
pub fn system_theme(&self, ctx: &egui::Context) -> Option<egui::Theme> {
self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme())
}
/// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light
/// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise.
pub fn sync_theme(&mut self, ctx: &egui::Context) {
let wanted = self.ui.settings.theme(self.system_theme(ctx));
if theme::current(ctx) != Some(wanted) {
theme::apply_kind(ctx, wanted);
}
}
/// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it
/// on the CPU (photo textures come from the renderer's CPU copies).
pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option<egui::ColorImage> {
let mut view = self.shadow.take().unwrap_or_default();
let size = main.input(|i| i.content_rect()).size();
let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) };
// Feed native viewport points; the shadow context applies the copied UI zoom.
let zoom = main.zoom_factor();
let size = size * zoom;
let ppp = main.pixels_per_point() / zoom;
let time = main.input(|i| i.time);
if view.frames() == 0 {
// warm-up pass: activates our fonts (pending font definitions live in `Memory`, which
// is replaced below)
view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |_| {});
}
// same scroll offsets, open sections, style… as the window
let memory = main.memory(|m| m.clone());
view.ctx.memory_mut(|m| *m = memory);
view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.draw_window(ui));
let img = capture.then(|| {
let mut tex = self.renderer.cpu_textures();
if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) {
tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone()));
}
view.paint(&tex)
});
self.shadow = Some(view);
img
}
/// Take each completed capture once, reject stale generations before writing anything, and
/// reply exactly once. A late callback after cancel/drop finds no slot and is inert.
fn collect_screenshots(&mut self, _ctx: &egui::Context) {
if self.pending_screenshots.is_empty() {
return;
}
let now = self.tick_now_ms();
let current = self.last_shot_geometry;
let library = self.shot_document_generation();
let mut shots = std::mem::take(&mut self.pending_screenshots);
let mut keep = Vec::with_capacity(shots.len());
for mut shot in shots.drain(..) {
let completed = shot.slot.as_ref().and_then(craft_ui::capture::CaptureSlot::take);
match completed {
Some(Ok(image)) => {
let stale =
shot.issued.is_none_or(|issued| current.is_none_or(|now| now.epoch != issued.epoch)) || shot.issued_library != library;
if stale {
// A resize/DPI/zoom or library change between issue and completion: never
// combine old pixels with new geometry. Retry with a fresh slot.
shot.slot = None;
shot.sent_at_ms = None;
shot.issued = None;
shot.not_before_ms = now + SHOT_SETTLE_MS;
keep.push(shot);
continue;
}
let pixels = image.size[0].checked_mul(image.size[1]).and_then(|p| p.checked_mul(std::mem::size_of::<egui::Color32>()));
let Some(bytes) = pixels else {
let reply = serde_json::json!({
"ok": false,
"error": "ui.screenshot: the rendered image has invalid dimensions",
});
let _ = shot.reply.send(reply);
self.release_shot_bytes(shot.reserved);
continue;
};
if bytes > shot.reserved {
// Wayland reports no `inner_rect`, so the reservation can be based on a stale
// content rect while the compositor returns the new, larger surface. Treat a
// larger-but-legal image as a stale reservation and grow it; never mix it
// with geometry of another epoch (checked above).
if let Err(error) = self.grow_shot_reservation(shot.reserved, bytes) {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error}));
self.release_shot_bytes(shot.reserved);
continue;
}
shot.reserved = bytes;
}
self.release_shot_bytes(shot.reserved);
let _ = shot.reply.send(control::save_screenshot(self, &image, shot.target.as_ref()));
}
Some(Err(error)) => {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": error.to_string()}));
self.release_shot_bytes(shot.reserved);
}
None => {
if now > shot.deadline_ms {
let _ = shot.reply.send(serde_json::json!({"ok": false, "error": format!("screenshot {} timed out", shot.token)}));
if let Some(slot) = shot.slot.take() {
slot.cancel();
}
self.release_shot_bytes(shot.reserved);
} else {
keep.push(shot);
}
}
}
}
self.pending_screenshots = keep;
}
/// The process is about to end (settings and library are saved by then): make sure nothing is
/// inside the GPU driver when it does (issue #620: a render running on a worker thread while
/// the process exits crashes in the driver). No GPU work starts from here on; the render
/// workers get up to `timeout` to finish their jobs, and what is left of it goes to GPU work
/// on other threads (export, denoise, the device warm-up). `false`: the deadline passed first
/// (the host exits anyway). The app renders nothing afterwards.
pub fn shutdown(&mut self, timeout: std::time::Duration) -> bool {
lightcraft_engine::gpu::begin_shutdown();
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
let stopped = self.renderer.shutdown(timeout);
#[cfg(not(target_arch = "wasm32"))]
let timeout = timeout.saturating_sub(t0.elapsed());
let idle = lightcraft_engine::gpu::wait_idle(timeout);
stopped && idle
}
/// Background and queue work. Called before layout on every host pass and, while the window is
/// hidden, from `Context::run_logic`. It may read the refreshed viewport fields only: never
/// `i.time`, events, keys or dropped files.
pub fn logic(&mut self, ctx: &egui::Context) {
i18n::set_language(self.ui.language);
// The fork's AccessKit root language and bidi runs follow the locale; presentation only.
// Compare against the cached string: `Context::language()` allocates on every call, and
// this runs on every UI pass and every hidden logic tick.
let language = self.ui.language.code();
if self.accesskit_language.as_deref() != Some(language) {
ctx.set_language(Some(language.to_owned()));
self.accesskit_language = Some(language.to_owned());
}
let t0 = monotonic_ms();
self.note_max_texture_side(ctx);
self.note_shot_geometry(ctx);
self.ensure_fonts(ctx);
// Applying the theme belongs to logic: a host that only calls `logic` (and every hidden
// tick) must see an Appearance Mode / system-theme change, and the UI pass that follows
// then draws with the new visuals. `ui` keeps its own call for a settings change made by
// a command during the same pass; it is a no-op when the theme already matches.
self.sync_theme(ctx);
self.logic_background(ctx);
self.perf.logic_ms = monotonic_ms() - t0;
}
/// Install/refresh fonts once and watch the device and availability caches. Setup, not input.
fn ensure_fonts(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
theme::apply(ctx);
Self::drop_legacy_panel_state(ctx);
// File → Add from Device lists cards scanned in the background: show hot-plugs
let repaint = ctx.clone();
lightcraft_engine::devices::on_change(move || repaint.request_repaint());
// "is the original there?" (grid, Info panel, Missing Photos) answers from a cache a
// worker fills: a sleeping NAS or a dropped share never stalls a frame
let repaint = ctx.clone();
self.session.media.availability.run_in_background(std::sync::Arc::new(move || repaint.request_repaint()));
self.styled = true;
self.font_language = self.ui.language;
} else if self.font_language != self.ui.language {
// Shared Han characters take the active language's forms (Japanese faces for 日本語,
// the Simplified Chinese face for 简体中文): rebuild the fallback order.
theme::install_fonts_with_chinese(ctx, self.chinese_font.as_ref());
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;
}
}
/// 0.36 stored each `PanelState` under the panel's exact `Id`; 0.37 stores it under
/// `parent_ui.scope_id().with_salt(id_salt)`, so the old entries are unreachable and the
/// panels reset once by simply not finding them. Remove the old keys anyway so egui memory
/// does not retain dead entries (no document, workspace JSON, catalog or XMP data is
/// migrated; nothing above a user's photo edits depends on them).
fn drop_legacy_panel_state(ctx: &egui::Context) {
for salt in ["top_bar", "bottom_bar", "tool_strip", "library-problem-banner"] {
ctx.data_mut(|d| d.remove::<egui::containers::panel::PanelState>(egui::Id::unique(salt)));
}
}
/// Queues and timers. Each consumed queue is drained once per call; time-gated work uses the
/// monotonic app clock, so a hidden `run_logic` keeps it advancing and a second layout pass is
/// a no-op rather than a duplicate.
fn logic_background(&mut self, ctx: &egui::Context) {
panels::notices::logic(self);
// closing the window (or Quit) with changes only in memory: retry, else ask first
if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
let now = self.tick_now();
self.drain_control(ctx);
self.apply_settings(ctx);
if self.queued_input_needs_repaint() {
ctx.request_repaint();
}
self.renderer.poll(ctx, &mut self.session);
merge::poll(self, ctx);
import::poll_scan(self, ctx);
sync::poll(self, ctx);
sync::poll_run(self, ctx);
// Import commits batches and must not run twice in one host frame. The first layout pass
// and a hidden logic tick both report pass index 0; a second pass reports 1. A wall-clock
// gate cannot express that (a slow discarded first pass would re-run it).
if import_tick_allowed(ctx) {
import::tick(self, ctx);
}
lightroom_import::tick(self, ctx);
recheck::tick(self);
tasks::poll(self, ctx);
updates_ui::tick(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
self.ai_mask_detail(ctx, now);
self.place_names_outcome(ctx);
self.slideshow_tick(ctx, now);
panels::denoise::pump_background(self, ctx, now);
panels::faces::pump_background(self, ctx, now);
model_setup::pump(self, ctx, now);
focus_scan::pump(self, ctx, now);
// auto import: list the watched folder every few seconds (on a worker thread: it may be on
// a network share), then import what's new like any import (also on a worker thread)
#[cfg(not(target_arch = "wasm32"))]
if let Some(folder) = self.session.import_defaults.auto_folder.clone() {
const LABEL: &str = "Auto Import";
if now - self.ui.auto_import_at >= 3.0
&& self.import.is_none()
&& self.sync_run.is_none()
&& self.lightroom.is_none()
&& !self.tasks.is_running(LABEL)
{
self.ui.auto_import_at = now;
let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);
let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {
let listing = match listing {
Ok(l) => l,
Err(e) => return log::debug!("auto import: {e}"),
};
let p = serde_json::json!({"listing": listing, "start": false});
let Ok(r) = app.session.execute("library.autoImportScan", &p) else { return };
let Some(mut params) = r.get("import").cloned().filter(|_| app.import.is_none()) else { return };
let paths: Vec<String> =
params["paths"].as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect();
if let Some(o) = params.as_object_mut() {
o.remove("paths");
}
let undo0 = app.session.undo.len();
app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());
};
// listed every few seconds: quiet, no row in the activity stack
if let Err(e) = tasks::spawn(self, LABEL, None, work, done) {
log::warn!("{e}");
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
// automatic XMP writes that failed (a network share offline): tried again every few seconds once their files
// are reachable, and said once when they start waiting and once when they're written
#[cfg(not(target_arch = "wasm32"))]
{
if self.session.unsaved_sidecars() > 0 {
if now - self.ui.sidecar_retry_at >= 10.0 {
self.ui.sidecar_retry_at = now;
self.session.retry_unsaved_sidecars();
}
ctx.request_repaint_after(std::time::Duration::from_secs(10));
}
let waiting = self.session.unsaved_sidecars();
if waiting > 0 && self.ui.sidecars_waiting == 0 {
self.toast_for(
ctx,
crate::i18n::tr_format!(
"The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.",
n = waiting
),
8.0,
);
} else if waiting == 0 && self.ui.sidecars_waiting > 0 {
self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting"));
}
self.ui.sidecars_waiting = waiting;
}
// Settings → Import: open the import review when a camera or card is connected
#[cfg(not(target_arch = "wasm32"))]
if self.session.import_defaults.show_on_card {
if let Some(now) = lightcraft_engine::devices::devices_scanned() {
import::cards_seen(self, now);
}
ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE);
} else {
self.ui.known_cards = None;
}
self.session.persist_if_dirty();
self.expire_gestures();
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
self.request_paint_while_hidden(ctx);
}
/// While hidden, queued captures and an injected gesture step need a real UI pass; a pending
/// screenshot command paints by itself once issued.
fn request_paint_while_hidden(&self, ctx: &egui::Context) {
// `logic` runs per layout pass; one command per host frame is enough.
if ctx.current_pass_index() != 0 {
return;
}
// `ViewportInfo::visible()`: minimized or fully occluded is `Some(false)`; `None`
// (headless, `RawInput::default()`) is normal and must not be treated as hidden.
let hidden = ctx.input(|i| i.viewport().visible() == Some(false));
if hidden && self.needs_ui_pass_while_hidden() {
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
}
}
/// A hidden window needs a real UI pass for queued/settling captures, for control gestures
/// that still have to resolve or deliver, and for an injected synthetic step. An already
/// issued screenshot has its own compositor-driven paint and must not request another
/// `RequestPaintWhileHidden`; its fallback and timeout keep running from `logic`.
fn needs_ui_pass_while_hidden(&self) -> bool {
!self.pending_gestures.is_empty()
|| self.pointer_cleanup
|| self.pending_screenshots.iter().any(|shot| shot.sent_at_ms.is_none())
|| !self.synthetic.is_empty()
|| self.synthetic_in_flight
}
/// Queued synthetic input, including a queued pointer cancellation that is not part of
/// `synthetic`, needs the next visible frame even when an earlier stall left the synthetic
/// queue empty.
fn queued_input_needs_repaint(&self) -> bool {
!self.synthetic.is_empty() || self.pointer_cleanup
}
/// Input, layout and setup for a known UI phase: at most once per host frame, and never from a
/// hidden logic tick. `current_pass_index() == 0` is the first pass of this `run_ui`; a second
/// pass has empty events.
fn ui_pass(&mut self, ctx: &egui::Context) {
if ctx.current_pass_index() != 0 {
return;
}
// A prompt owns the keyboard and focus. A notice may have been added by this frame's logic
// after `raw_input_hook`; capture its Tab/Enter/Space/Escape and drop everything else
// before any panel, focused control or shortcut reads the input. A new prompt gets a
// shielding pass whose background is not drawn at all (see `draw_window`).
let batch_in_flight = self.synthetic_in_flight;
if panels::notices::prepare_ui_pass(self, ctx) {
self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared");
}
panels::library_problem::prepare_ui_pass(self, ctx, batch_in_flight);
let now = self.tick_now();
// the last pass's time is fine here: this is a UI phase, not a hidden tick
let t = ctx.input(|i| i.time);
let dt = t - self.last_time;
if dt > 0.0 {
self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1;
}
self.last_time = t;
panels::denoise::pump_ui_input(self, ctx, now);
panels::faces::pump_ui_input(self, ctx, now);
// A prompt owns the library too: files dropped while it is up wait in the queue and are
// imported on the first UI pass after it goes away, never behind it.
if !panels::notices::blocks_input(self) {
self.import_dropped_files(ctx);
}
self.check_external_editor(ctx);
// Recent-library checks and launch-list keys read focus/time/keys: they belong to the UI
// phase, not the hidden-capable `logic` tick (plan §4.5 / migration input contract).
panels::library_problem::logic(self, ctx);
if self.fonts_ready && !updates_ui::blocks_input(self) {
shortcuts::handle(self, ctx);
}
}
/// Take the host's dropped files, exactly once. The queue is filled by `raw_input_hook`, so a
/// hidden logic tick can never replay the last pass's drop. Web hosts call this in their UI
/// phase (they need `bytes_async`, which is not available on a native file path).
pub fn take_dropped_files(&mut self) -> Vec<egui::DroppedFileHandle> {
std::mem::take(&mut self.dropped_files)
}
/// Import native drops as presets, face models or photos.
fn import_dropped_files(&mut self, ctx: &egui::Context) {
let dropped = self.take_dropped_files();
if dropped.is_empty() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
self.handle_dropped_files(ctx, dropped);
#[cfg(target_arch = "wasm32")]
let _ = (dropped, ctx);
}
/// Files dropped on the window: face models open their licence dialog, presets import as
/// presets, photos and folders go to the import review (or straight in, Settings ▸ Import).
#[cfg(not(target_arch = "wasm32"))]
fn handle_dropped_files(&mut self, ctx: &egui::Context, dropped: Vec<egui::DroppedFileHandle>) {
let dropped: Vec<String> =
dropped.iter().map(|f| egui::DroppedFile::path(f.as_ref()).to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect();
if dropped.is_empty() {
return;
}
// a dialog is on screen: the review would replace it, losing what it holds
if self.ui.dialog.is_some() {
self.toast(ctx, crate::i18n::tr("Close the open dialog, then drop the files again"));
return;
}
let drop = drop_overlay::classify(dropped);
if let Some(model) = drop.models.first()
&& let Err(e) = panels::faces::open_dialog(self, model)
{
self.toast(ctx, e);
}
if !drop.presets.is_empty() {
// presets live in a library: with none open they would go with the session it replaces
if panels::library_problem::holds_drops(self) {
self.toast(ctx, crate::i18n::tr("Open a library first, then drop the presets again"));
} else {
let _ = self.run("file.importPresets", serde_json::json!({"paths": drop.presets}));
}
}
if drop.photos.is_empty() {
return;
}
// no library open yet (the launch list, a library that failed to open): they wait for one
let Some(photos) = panels::library_problem::hold_dropped(self, ctx, drop.photos) else { return };
if self.lightroom.is_some() {
self.toast(ctx, crate::i18n::tr("Finish Lightroom catalog import before adding photos"));
return;
}
if import::running(self) {
self.toast(ctx, crate::i18n::tr("An import is running"));
return;
}
let started = if self.ui.settings.import_dropped_directly {
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
import::start_paths(self, photos).map(|_| ())
} else {
import::open(self, photos).map(|_| ())
};
if let Err(e) = started {
self.toast(ctx, e);
}
}
/// A file dropped by the host, kept for the next UI pass (web hosts consume their own queue).
fn queue_dropped_files(&mut self, raw: &mut egui::RawInput) {
if !raw.dropped_files.is_empty() {
self.dropped_files.append(&mut raw.dropped_files);
}
}
/// Back from an external editor: pick up the files it saved. Reads `i.focused`, which
/// `run_logic` does not refresh, so it belongs to the UI phase.
fn check_external_editor(&mut self, ctx: &egui::Context) {
let focused = ctx.input(|i| i.focused);
if focused && !self.ui.was_focused && !self.ui.external_edits.is_empty() {
let ids = self.ui.external_edits.clone();
if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids}))
&& r["reloaded"].as_array().is_some_and(|a| !a.is_empty())
{
self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor"));
}
}
self.ui.was_focused = focused;
}
/// Apply settings that act outside the UI state: GPU rendering and window full screen.
fn apply_settings(&mut self, ctx: &egui::Context) {
if self.gpu_applied != Some(self.ui.settings.gpu) {
self.gpu_applied = Some(self.ui.settings.gpu);
let _ = self.session.execute("app.gpu", &serde_json::json!({"enabled": self.ui.settings.gpu}));
// GPU device + kernels off the UI thread, once the window is up and only when GPU
// rendering is on: a broken driver must not keep the window from appearing (issue #136)
if self.ui.settings.gpu {
lightcraft_engine::gpu::warm_up();
}
}
let mb = self.ui.settings.memory_mb;
// automatic at startup: leave the engine's default alone
if self.memory_applied != Some(mb) && (mb > 0 || self.memory_applied.is_some()) {
let mb = if mb == 0 { (lightcraft_engine::memory::default_budget() >> 20) as u32 } else { mb };
let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb}));
}
self.memory_applied = Some(mb);
// the monitor profile (Settings ▸ Display): previews follow it from the next frame
if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) {
let path = self.ui.settings.display_profile.clone();
match lightcraft_engine::display::Display::load_opt(&path) {
Ok(d) => {
self.renderer.set_display(d);
self.display_error = None;
}
Err(msg) => {
// a profile that can't be used: previews are sRGB, the setting is kept
self.renderer.set_display(None);
log::warn!("display profile: {msg}");
self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used")));
self.display_error = Some(msg);
}
}
self.display_applied = Some(path);
// none chosen again: the screen's profile is read afresh
self.screen_profile = None;
self.screen_profile_read = None;
}
if self.ui.settings.display_profile.is_empty() {
self.follow_screen_profile(ctx);
}
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
self.window_is_fullscreen = fs;
}
if let Some(on) = self.ui.window_fullscreen.take() {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(on));
self.window_is_fullscreen = on;
}
}
/// While no display profile is chosen, show previews through the profile of the screen the
/// window is on (`services.screen_profile`), read about once a second so moving the window to
/// another screen is followed. Without the service (or a profile) the display is sRGB.
fn follow_screen_profile(&mut self, _ctx: &egui::Context) {
let now = self.tick_now();
if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) {
return;
}
self.screen_profile_read = Some(now);
self.apply_screen_profile();
}
/// Read the screen's profile now and show previews through it (sRGB without one).
pub(crate) fn apply_screen_profile(&mut self) {
let Some(read) = self.services.screen_profile.as_mut() else { return };
let icc = read();
if icc == self.screen_profile {
return;
}
let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) {
Ok(d) => Some(std::sync::Arc::new(d)),
Err(msg) => {
// a profile we can't use: previews stay sRGB, as before #787
log::warn!("screen profile: {msg}");
None
}
});
self.renderer.set_display(display);
self.screen_profile = icc;
}
/// Take the host's dropped files once and inject synthetic events (pointer events one per
/// frame; key sequences up to the release).
///
/// The hook also runs before a hidden logic-only tick, so it injects at most one batch and
/// keeps that batch in flight until a completed UI pass consumed it. It never injects while
/// the fonts are not installed (the pass would draw nothing and the events would be lost).
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
let file = |p: &String| (!p.is_empty()).then(|| std::path::PathBuf::from(p));
if !self.drop_pending.is_empty() {
raw.dropped_files = std::mem::take(&mut self.drop_pending)
.into_iter()
.map(|p| std::sync::Arc::new(InjectedDrop(p.into())) as egui::DroppedFileHandle)
.collect();
}
if !self.drop_hover.is_empty() {
raw.hovered_files = self.drop_hover.iter().map(|p| egui::HoveredFile { path: file(p), ..Default::default() }).collect();
}
// Queue after injecting, so a `ui.dropFiles` request is consumed exactly once by the UI phase.
self.queue_dropped_files(raw);
// Track the real host modifier state separately from the app's synthetic ownership. A host
// change means the host now owns the modifier state: relinquish any synthetic claim and do
// not later synthesise a release for a key that is physically held.
if let Some(host_mods) = raw.events.iter().rev().find_map(|event| match event {
egui::Event::ModifiersChanged(modifiers) => Some(*modifiers),
_ => None,
}) {
self.host_mods = host_mods;
self.synthetic_mods = egui::Modifiers::NONE;
self.synthetic_mods_release = false;
}
// A prompt owns all input. On its first host frame this also removes pointer/touch input
// before egui computes its hit test; later frames keep only key releases, the prompt's own
// keys, and AccessKit actions targeted at a prompt button. See `notices::filter_raw_input`.
let batch_in_flight = self.synthetic_in_flight;
if crate::panels::notices::prepare_raw_input(self, ctx, raw) {
// A prompt appearing during a synthetic drag must abort the drag, not let its remaining
// move/release events act under the prompt or after it is dismissed. If a whole batch
// was already injected, it is unsafe as well: this frame's prompt filter drops it
// before a panel sees it, so it must not report a successful delivery.
self.cancel_gestures_for_barrier(batch_in_flight, "a modal prompt appeared");
self.queue_pointer_cleanup();
}
// Enforce the stall deadline before injecting another event: a request that expired during
// a long frame gap must not get one more event on the frame that resumes.
self.expire_gestures();
// A canceled press is released by resetting egui's pointer state before its next input
// pass, even while another batch is in flight or the fonts are not ready, so the pointer
// cannot stay down. No move/release event is injected: either could start a drag or
// become a click on the widget being canceled.
self.apply_pointer_cleanup(ctx, raw);
if !self.fonts_ready || self.synthetic_in_flight {
return;
}
if self.drop_gestures_stale_before_injection(ctx) {
// The stale check can cancel the head gesture while its press is already down.
self.apply_pointer_cleanup(ctx, raw);
return;
}
// the frame after a synthetic release / key: restore the host's physical modifiers, not
// necessarily NONE (Shift may still be held while a synthetic Alt gesture is released)
if std::mem::take(&mut self.synthetic_mods_release) && self.synthetic_mods != egui::Modifiers::NONE {
self.synthetic_mods = egui::Modifiers::NONE;
raw.events.push(egui::Event::ModifiersChanged(self.host_mods));
}
if self.synthetic.is_empty() {
return;
}
let n = match self.synthetic[0] {
egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1,
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
};
// A press needs the pointer at its position before the button event; the move that is
// itself the first event is delivered by the drain below, so it must not be sent twice.
if let Some(egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
raw.events.push(egui::Event::PointerMoved(*p));
}
// egui's `input.modifiers` follow `ModifiersChanged` events, not the modifiers carried by
// pointer/key events: announce the injected events' modifiers (held from button down to
// the frame after up), so ⌥-drag, ⇧-click … work through the control channel
let carried = self.synthetic[..n].iter().find_map(|e| match e {
egui::Event::PointerButton { pressed, modifiers, .. } => Some((*modifiers, !pressed)),
egui::Event::Key { modifiers, .. } => Some((*modifiers, true)),
_ => None,
});
if let Some((m, ends)) = carried {
if m != self.synthetic_mods {
self.synthetic_mods = m;
raw.events.push(egui::Event::ModifiersChanged(m));
}
self.synthetic_mods_release = ends;
}
let blocked_by_prompt = self.synthetic[..n].iter().any(|event| !crate::panels::notices::prompt_allows_event(self, event));
let head = self.head_gesture();
if let Some(head) = head {
let gesture = &mut self.pending_gestures[head];
for event in &self.synthetic[..n] {
match event {
egui::Event::PointerButton { button, pressed: true, .. } => {
gesture.pressed = Some(InjectedPress { button: *button });
}
egui::Event::PointerButton { button, pressed: false, .. }
if gesture.pressed.as_ref().is_some_and(|press| press.button == *button) =>
{
gesture.pressed = None;
}
_ => {}
}
}
gesture.started = true;
gesture.remaining_events = gesture.remaining_events.saturating_sub(n);
}
raw.events.extend(self.synthetic.drain(..n));
// The same barrier applies after injection: only the prompt's own keys and targeted
// AccessKit actions survive; host input and `ui.text`/clipboard never reach the background.
crate::panels::notices::filter_raw_input(self, raw);
if blocked_by_prompt && let Some(index) = head {
// A settled prompt is already laid out, so this is not the prompt-creation-cancels-all
// path: only the gesture whose batch the barrier removed fails. Allowed prompt keys,
// pointer input and targeted AccessKit actions still report a successful delivery.
let gesture = self.pending_gestures.remove(index);
self.cancel_gesture_events(&gesture, true);
let _ = gesture.reply.send(serde_json::json!({
"ok": false,
"error": format!("{} canceled: the open modal prompt blocked this input", gesture.method),
}));
}
// The 5 s deadline is a delivery-stall limit, not a limit on a gesture's frame count: a
// drag feeds one event per host frame, so every batch that the app consumes proves the UI
// is alive and restarts the clock for the whole queue.
let now = self.tick_now_ms();
for gesture in &mut self.pending_gestures {
gesture.deadline_ms = now + GESTURE_TIMEOUT_MS;
}
self.synthetic_in_flight = true;
}
/// Lights Out: veil everything but the photo (the loupe's image, or the grid / Compare /
/// Survey area) in black. Painted above the panels, below dialogs and notices.
fn lights_out(&self, ctx: &egui::Context) {
let a = self.ui.lights_out.veil();
if a <= 0.0 {
return;
}
let screen = ctx.content_rect();
let keep = if self.ui.view == state::ViewMode::Detail { self.image_rect.or(self.canvas_rect) } else { self.canvas_rect };
let veil = egui::Color32::from_black_alpha((a.clamp(0.0, 1.0) * 255.0).round() as u8);
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::unique("lights-out")));
let k = keep.unwrap_or(egui::Rect::NOTHING).intersect(screen);
if !k.is_positive() {
p.rect_filled(screen, 0.0, veil);
return;
}
// four bands around the photo
use egui::{Rect, pos2};
p.rect_filled(Rect::from_min_max(screen.min, pos2(screen.right(), k.top())), 0.0, veil);
p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.bottom()), screen.max), 0.0, veil);
p.rect_filled(Rect::from_min_max(pos2(screen.left(), k.top()), pos2(k.left(), k.bottom())), 0.0, veil);
p.rect_filled(Rect::from_min_max(pos2(k.right(), k.top()), pos2(screen.right(), k.bottom())), 0.0, veil);
}
/// Frame timings once layout is done (`t0`: when layout started).
fn end_frame(&mut self, ctx: &egui::Context, t0: f64) {
if self.ui.frame_rate {
frame_rate::show(ctx, self);
} else {
frame_rate::forget(ctx);
}
panels::edit::wheel_pointer_frame_end(ctx);
ctx.output(|o| {
for c in &o.commands {
if let egui::OutputCommand::CopyText(text) = c {
self.copied = Some(text.clone());
}
}
});
self.perf.frame_ms = monotonic_ms() - t0;
self.perf.update_ms = self.perf.logic_ms + self.perf.frame_ms;
self.perf.max_update_ms = self.perf.max_update_ms.max(self.perf.update_ms);
}
/// Once per library opened (at launch too): name it in the window title, so nobody edits in
/// the wrong one, and put it first in File ▸ Open Recent Library.
pub(crate) fn library_changed(&mut self, ctx: &egui::Context) {
let generation = self.session.library_generation();
if self.shown_library == Some(generation) {
return;
}
self.shown_library = Some(generation);
if let Some(l) = self.session.library.as_ref().filter(|l| l.on_disk) {
let path = l.dir.to_string_lossy().into_owned();
panels::settings::remember_library(&mut self.ui.settings.recent_libraries, &path);
// it is open: whatever an earlier check said about it is over
self.recent_checks.opened(&path);
// when, for the launch window (issue #1290); only the listed libraries keep a time
let settings = &mut self.ui.settings;
settings.recent_opened.insert(path, (self.session.clock)());
settings.recent_opened.retain(|p, _| settings.recent_libraries.contains(p));
}
ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title()));
}
/// "LightCraft — <library name>" (just "LightCraft" for a library that isn't saved).
pub fn window_title(&self) -> String {
match panels::settings::open_library_name(self) {
Some(name) => format!("LightCraft — {name}"),
None => "LightCraft".to_string(),
}
}
/// The browser page's title: "<file name>[ •] — LightCraft" for the active photo (• while
/// `unsaved`: changes not yet written to storage), else the window title. The web build has no
/// window for `ViewportCommand::Title`; a host embedding it reads this title to tell which
/// document a drop opened and whether it is saved (issue #2397).
pub fn page_title(&self, unsaved: bool) -> String {
let dot = if unsaved { " •" } else { "" };
match self.session.active().and_then(|id| self.session.catalog.photo(id)) {
Some(p) => format!("{}{dot} — LightCraft", p.file_name),
None => format!("{}{dot}", self.window_title()),
}
}
/// Lay out the whole window: input/setup for the known UI phase, then presentation, then the
/// frame bookkeeping. A hidden `run_logic` never reaches this.
pub fn ui(&mut self, ui: &mut egui::Ui) {
i18n::set_language(self.ui.language);
let ctx = ui.ctx().clone();
if !self.fonts_ready {
ctx.request_repaint();
return;
}
self.sync_theme(&ctx);
self.ui_pass(&ctx);
self.draw_window(ui);
self.finish_ui_pass(&ctx);
}
/// A completed, nondiscarded pass is a settled frame; a discarded pass threw its widgets away
/// and must not advance the geometry generation a capture binds to.
fn finish_ui_pass(&mut self, ctx: &egui::Context) {
// Any pass consumed the injected batch; a discarded pass still read its events and the
// next pass has none. Only a nondiscarded pass, however, is a settled frame a capture may
// bind to and a control gesture may resolve against.
self.synthetic_in_flight = false;
if ctx.will_discard() {
self.next_widgets.clear();
self.next_widget_hits.clear();
return;
}
self.widgets = std::mem::take(&mut self.next_widgets);
self.widget_hits = std::mem::take(&mut self.next_widget_hits);
panels::notices::finish_ui_pass(self, ctx);
panels::library_problem::finish_ui_pass(self, ctx);
self.ui_passes = self.ui_passes.wrapping_add(1);
self.layout_epoch = self.layout_epoch.wrapping_add(1);
self.resolve_pending_gestures(ctx);
self.reply_delivered_gestures();
if self.pointer_cleanup {
// A canceled press may be discovered by a timeout after the synthetic queue emptied;
// nothing else may ask for the frame that applies its cancellation.
ctx.request_repaint();
}
}
/// Presentation only: the window's panels, dialogs and overlays.
fn draw_window(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let t0 = monotonic_ms();
panels::library_problem::begin_draw(self);
// A command or shortcut drawn before this can create the window after `ui_pass`; catch it
// here before any panel draws with the still-focused background text field.
let batch_in_flight = self.synthetic_in_flight;
panels::library_problem::prepare_ui_pass(self, &ctx, batch_in_flight);
// A newly shown blocking library window gets the same barrier as a prompt: its first Area
// layout is non-interactable and hit testing still uses the previous pass, so background
// panels (and a focused text field) must not draw in that pass.
if panels::library_problem::shield_pass(self, &ctx) {
#[cfg(test)]
self.library_passes.push(panels::library_problem::LibraryPass::Shield);
panels::library_problem::show(self, &ctx, false);
(self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx);
ctx.request_discard("library problem: shield the window's creation frame");
self.end_frame(&ctx, t0);
return;
}
// A newly shown prompt gets one pass with no background. egui's first Area layout is
// non-interactable and its hit test uses the previous pass, so drawing panels here would
// let a pointer press/release in the prompt's creation frame act underneath it. `ui()` then
// reruns this pass with empty raw input, where the settled prompt and the full window draw
// together. This is a state/input-routing barrier, not a wholesale input disable: once
// settled, raw pointer input still reaches the prompt backdrop (a no-op) or its buttons.
if panels::notices::shield_pass(self, &ctx) {
panels::notices::show(self, &ctx);
(self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx);
ctx.request_discard("notice: shield the prompt's creation frame");
self.end_frame(&ctx, t0);
return;
}
#[cfg(test)]
self.library_passes.push(panels::library_problem::LibraryPass::Background);
panels::dialogs::prepare(self, ui);
// Freeze this frame's notice barrier before `notices::show` can dismiss a prompt: the same
// click or key must never be acted on again by the dialog drawn after it.
let notice_blocks = panels::notices::blocks_input(self);
if std::mem::take(&mut self.ui.quit) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
self.library_changed(&ctx);
// panels set it again this frame while the pointer rests on a preset or profile
self.hover_preview = None;
if self.ui.fullscreen {
// full-screen preview: the photo alone on black
egui::CentralPanel::default()
.frame(egui::Frame::NONE.fill(egui::Color32::BLACK))
.show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) });
panels::second::show(self, &ctx);
if notice_blocks {
panels::notices::restore_prompt_pointer(self, &ctx);
}
panels::notices::show(self, &ctx);
if notice_blocks {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
if notice_blocks {
// Keep the real press/release state for the next pass: an ordinary click spans
// host frames, and resetting it above must only hide it from this pass's dialogs.
panels::notices::restore_prompt_pointer(self, &ctx);
}
(self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx);
self.end_frame(&ctx, t0);
return;
}
// Order matters: earlier panels take the full edge (top bar spans the window; the tool strip,
// right panels and left panel run to the bottom; the bottom bar sits between them).
panels::topbar::show(self, ui);
panels::library_problem::banner(self, ui);
panels::strip::show(self, ui);
// filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour
let chrome = theme::Tokens::get(&ctx).chrome;
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui));
self.lights_out(&ctx);
panels::second::show(self, &ctx);
if notice_blocks {
panels::notices::restore_prompt_pointer(self, &ctx);
}
panels::notices::show(self, &ctx);
if notice_blocks {
panels::notices::hide_background_pointer(&ctx);
}
panels::dialogs::show(self, &ctx, notice_blocks);
updates_ui::show(self, &ctx);
panels::library_problem::show(self, &ctx, notice_blocks);
export_task::poll(self, &ctx);
slideshow_export::poll(self, &ctx);
panels::activity::show(self, &ctx);
export_task::poll_photos(self, &ctx);
pick::poll(self, &ctx);
panels::grid::drag_feedback(self, &ctx);
panels::left::album_drag_feedback(self, &ctx);
panels::left::section_drag_feedback(self, &ctx);
panels::keyword_list::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
drop_overlay::show(self, &ctx);
// AppKit moves the window on a drag anywhere in the title-bar band unless the window isn't
// movable: it stops being movable while the pointer is on a bar control or a panel over it
#[cfg(target_os = "macos")]
craft_window::title_bar::sync_movable(&ctx, &mut self.window_movable);
if notice_blocks {
// Keep the real press/release state for the next pass: an ordinary click spans host
// frames, and resetting it above must only hide it from this pass's dialogs.
panels::notices::restore_prompt_pointer(self, &ctx);
}
(self.next_widgets, self.next_widget_hits) = widgets::take_registries(&ctx);
self.text_focus = ctx.egui_wants_keyboard_input();
self.end_frame(&ctx, t0);
}
}
/// How long a screenshot waits for in-flight renders before it is captured anyway (and the
/// headless fallback's settle budget).
const SCREENSHOT_SETTLE_MS: f64 = 3000.0;
/// How long a windowed screenshot waits for the compositor before falling back to headless.
const SCREENSHOT_FALLBACK_MS: f64 = 2000.0;
/// Screenshots admitted at once (queued, issued and completed-but-undrained).
const MAX_PENDING_SHOTS: usize = 4;
/// Bytes one screenshot image may use.
const MAX_SHOT_BYTES: usize = 128 * 1024 * 1024;
/// Largest RGBA surface that fits [`MAX_SHOT_BYTES`], checked with the viewport dimensions before
/// layout. The CPU rasterizer's own `softpaint::MAX_IMAGE_PIXELS` may be larger; a snapshot that
/// cannot be captured must be rejected up front rather than after admission.
pub(crate) const MAX_SHOT_PIXELS: usize = MAX_SHOT_BYTES / std::mem::size_of::<egui::Color32>();
/// Bytes all pending/completed screenshots may reserve together.
const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024;
/// Slack for PNG framing and deflate overhead while encoding one screenshot. zlib can exceed the
/// raw bytes slightly for incompressible input; this bounds that transient honestly.
const SHOT_ENCODE_SLACK_BYTES: usize = 64 * 1024;
/// Peak working set of capture encoding: every reserved image (≤ [`MAX_SHOT_TOTAL_BYTES`]) plus,
/// for the image being written, its `Rgba8` copy and encoded PNG (each ≤ [`MAX_SHOT_BYTES`] plus
/// [`SHOT_ENCODE_SLACK_BYTES`]). Encoding runs on the UI thread, one image at a time.
const MAX_SHOT_WORKING_SET_BYTES: usize = MAX_SHOT_TOTAL_BYTES + 2 * MAX_SHOT_BYTES + SHOT_ENCODE_SLACK_BYTES;
const _: () = {
assert!(MAX_SHOT_WORKING_SET_BYTES > MAX_SHOT_TOTAL_BYTES);
assert!(SHOT_ENCODE_SLACK_BYTES > 0);
assert!(MAX_SHOT_BYTES.is_multiple_of(std::mem::size_of::<egui::Color32>()));
assert!(MAX_SHOT_PIXELS * std::mem::size_of::<egui::Color32>() == MAX_SHOT_BYTES);
};
/// Synthetic-input control requests waiting for a fresh layout/delivery at once.
const MAX_PENDING_GESTURES: usize = 32;
/// A control gesture is answered with an error after this long without a completed UI pass and
/// delivery (milliseconds).
/// How long synthetic input may wait without the UI consuming a batch. Each consumed batch
/// restarts this clock, so it is a stall limit, not a cap on a many-step drag's total time.
const GESTURE_TIMEOUT_MS: f64 = 5000.0;
/// Completed UI frames a request waits for before it is issued (a settled, laid-out frame).
const SHOT_SETTLE_FRAMES: u64 = 3;
/// Time a request waits before it is issued, so a control command that triggered a layout change
/// is not captured mid-change.
const SHOT_SETTLE_MS: f64 = 120.0;
/// A settled capture is issued at this instant even while renders are still in flight. The hard
/// admission deadline below still leaves the compositor fallback and the headless settle after it.
const SHOT_FORCE_BUSY_MS: f64 = SCREENSHOT_SETTLE_MS;
/// The texture limit when the host has not reported one.
const MAX_TEXTURE_SIDE: usize = 16_384;
/// The viewport geometry a screenshot was issued for. A completion from an older generation is
/// rejected even if the window has since returned to the same size.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ShotGeometry {
/// Viewport size in egui points (the same units as `content_rect`).
viewport: egui::Vec2,
/// Egui pixels per point, including UI zoom.
pixels_per_point: f32,
/// The UI zoom factor.
zoom: f32,
/// [`LightcraftApp::shot_geometry_epoch`] at issue.
epoch: u64,
}
impl ShotGeometry {
/// The surface pixels this geometry would produce, before the host's texture limit.
fn surface_pixels(self) -> Option<(usize, usize)> {
let w = (self.viewport.x * self.pixels_per_point).round();
let h = (self.viewport.y * self.pixels_per_point).round();
if !w.is_finite() || !h.is_finite() || w < 1.0 || h < 1.0 {
return None;
}
Some((w as usize, h as usize))
}
/// The surface pixels this geometry can produce, or `None` when it is not a usable surface or
/// exceeds one side of the host's texture limit.
fn pixels(self, max_texture_side: usize) -> Option<(usize, usize)> {
let (w, h) = self.surface_pixels()?;
(w <= max_texture_side && h <= max_texture_side).then_some((w, h))
}
/// Checked `width * height * 4` bytes for this geometry.
fn bytes(self, max_texture_side: usize) -> Option<usize> {
let (w, h) = self.pixels(max_texture_side)?;
w.checked_mul(h)?.checked_mul(std::mem::size_of::<egui::Color32>())
}
/// Why [`Self::bytes`] returned `None`: no laid-out surface, or one side over the host's limit.
/// The two are not the same user action (a driver/GL limit is not "the window has not drawn").
fn rejection(self, max_texture_side: usize) -> String {
match self.surface_pixels() {
None => "ui.screenshot: the window has no usable surface size yet (no frame has been laid out)".to_string(),
Some((w, h)) => format!("ui.screenshot: the {w}×{h} pixel surface is over the host's {max_texture_side}-pixel per-side texture limit"),
}
}
}
/// Import commits batches at most once per host frame. The first layout pass and a hidden
/// `run_logic` tick both report pass index 0; a discarded frame's later passes report 1+. A
/// wall-clock gate cannot express that (a slow first pass could re-run it within one frame).
fn import_tick_allowed(ctx: &egui::Context) -> bool {
ctx.current_pass_index() == 0
}
/// Whether a pending request may be issued now: it has waited for the settle time and completed
/// UI frames, and either no render is in flight or the forced-capture instant has passed.
fn shot_ready(now_ms: f64, shot: &PendingShot, busy: bool, ui_passes: u64) -> bool {
let settled = ui_passes.saturating_sub(shot.admitted_passes) >= SHOT_SETTLE_FRAMES && now_ms >= shot.not_before_ms;
settled && (!busy || now_ms >= shot.force_ms)
}
/// 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)]
struct GestureFrame {
view: crate::state::ViewMode,
active: Option<lightcraft_catalog::PhotoId>,
library: u64,
dialog: Option<std::mem::Discriminant<crate::state::Dialog>>,
left_panel: bool,
right: crate::state::RightPanel,
presets: bool,
filmstrip: bool,
}
/// A synthetic-input control request waiting for a fresh layout and for its last event to be
/// consumed by a completed UI pass.
struct PendingGesture {
method: String,
params: Value,
reply: Sender<ControlResponse>,
/// [`LightcraftApp::layout_epoch`] at admission; resolution needs a later one.
admitted_epoch: u64,
frame: GestureFrame,
/// Monotonic stall deadline (milliseconds): delivery refreshes it whenever the UI consumes a
/// batch, so a long but progressing drag is not a timeout.
deadline_ms: f64,
/// [`LightcraftApp::ui_passes`] when the events were queued (`None`: not resolved yet).
resolved_pass: Option<u64>,
/// Synthetic events queued for this gesture that the host has not injected yet.
remaining_events: usize,
/// At least one batch of this gesture's events has reached a pass. Later batches may see the
/// gesture's own effect (a first click selects, the second opens), so the semantic frame
/// snapshot is only checked before the first one.
started: bool,
/// For a widget gesture, the id and point resolved against the completed layout. Delivery
/// rechecks the widget's fresh rect before the first event, before each press and (for a click)
/// before its release.
target: Option<control::GestureTarget>,
/// A pointer button this gesture has pressed but not yet released. Cancellation resets the
/// pointer state before egui's next input pass, so a refused click neither leaves the pointer
/// down nor turns the cleanup into a click or drag.
pressed: Option<InjectedPress>,
result: Value,
}
/// A pointer press delivered to the UI, kept so a canceled gesture knows that a button is down.
#[derive(Clone, Copy, Debug)]
struct InjectedPress {
button: egui::PointerButton,
}
/// A `ui.screenshot` request in progress.
struct PendingShot {
token: u64,
/// Destination identity pinned at admission (revalidated immediately before the write).
target: Option<lightcraft_engine::originals::WriteTarget>,
reply: Sender<ControlResponse>,
headless: bool,
/// Admitted UI pass count and monotonic deadlines (milliseconds).
admitted_passes: u64,
not_before_ms: f64,
/// At this instant a busy renderer no longer holds the capture back.
force_ms: f64,
deadline_ms: f64,
/// When the viewport screenshot command went out.
sent_at_ms: Option<f64>,
/// Bytes this request reserves until it is drained or cancelled.
reserved: usize,
/// The one-shot completion slot; only the app owns the strong reference.
slot: Option<craft_ui::capture::CaptureSlot>,
/// The geometry and document generation the issued command was bound to.
issued: Option<ShotGeometry>,
/// Library generation at issue (a different library changed mid-capture).
issued_library: u64,
}
/// 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)]
struct AppClock {
start: web_time::Instant,
simulated: Option<f64>,
}
impl AppClock {
/// Start the clock at the current monotonic instant with no simulated time.
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`.
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)
}
/// Whether the settings render in black & white.
/// A temporary look for the loupe (hovering a preset or profile).
#[derive(Clone, Debug, PartialEq)]
pub struct HoverPreview {
/// What is previewed (e.g. "Preset: Warm Glow").
pub label: String,
/// The photo's settings with the look applied.
pub settings: lightcraft_develop::DevelopSettings,
}
pub fn is_bw(d: &lightcraft_develop::DevelopSettings) -> bool {
d.treatment == lightcraft_develop::Treatment::Bw || d.profile.id == "lc.mono" || d.profile.id.starts_with("lc.bw.")
}
/// "Found 3 faces in 2 photos" for a `faces.detect` result.
fn detect_summary(v: &Value) -> String {
let photos = v["photos"].as_array().map(Vec::as_slice).unwrap_or_default();
let counts: Vec<usize> = photos.iter().map(|p| p["faces"].as_array().map_or(0, Vec::len)).collect();
let (faces, with) = (counts.iter().sum::<usize>(), counts.iter().filter(|n| **n > 0).count());
let plural = |n: usize, w: &str| format!("{n} {w}{}", if n == 1 { "" } else { "s" });
match (faces, photos.len()) {
(0, 1) => "No faces found".into(),
(0, n) => format!("No faces found in {}", plural(n, "photo")),
(f, 1) => format!("Found {}", plural(f, "face")),
(f, _) => format!("Found {} in {}", plural(f, "face"), plural(with, "photo")),
}
}
/// What to say when a SAM 3 download ended while its dialog was closed: the text, and whether it is
/// shown as an error toast. A cancel is told by `cancelled`, not by the error's words.
fn sam_download_ended(download: &lightcraft_engine::segment::DownloadStatus) -> Option<(String, bool)> {
match &download.error {
Some(_) if download.cancelled => {
Some((crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time.").to_string(), false))
}
Some(e) => Some((crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e), true)),
None if download.finished => Some((crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready.").to_string(), true)),
None => None,
}
}
/// A file `ui.dropFiles` delivers as dropped, as a real OS drop arrives in `raw.dropped_files`.
#[derive(Debug)]
struct InjectedDrop(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()) })
}
}
/// A dropped face model file (`.onnx`).
pub fn is_model_file(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("onnx"))
}
/// Files dropped on the window that are presets rather than photos.
pub fn is_preset_file(path: &str) -> bool {
let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack", "lceffect"].contains(&ext.as_str())
}
/// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same
/// name (`IMG_1.xmp` or `IMG_1.CR2.xmp` beside `IMG_1.CR2`) is that photo's sidecar, not a preset, and is left out.
pub fn split_presets(dropped: Vec<String>) -> (Vec<String>, Vec<String>) {
let dir_of = |p: &str| std::path::Path::new(p).parent().map(|d| d.to_string_lossy().to_lowercase()).unwrap_or_default();
let stem_of = |p: &str| std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let name_of = |p: &str| std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
let is_xmp = |p: &str| std::path::Path::new(p).extension().is_some_and(|e| e.eq_ignore_ascii_case("xmp"));
let photo_names: Vec<(String, String)> =
dropped.iter().filter(|p| !is_preset_file(p)).flat_map(|p| [(dir_of(p), stem_of(p)), (dir_of(p), name_of(p))]).collect();
let (mut presets, mut photos) = (Vec::new(), Vec::new());
for p in dropped {
if !is_preset_file(&p) {
photos.push(p);
} else if is_xmp(&p) && photo_names.contains(&(dir_of(&p), stem_of(&p))) {
// a photo's sidecar travels with the photo
} else {
presets.push(p);
}
}
(presets, photos)
}
#[cfg(test)]
mod drop_tests {
#[test]
fn photo_sidecars_are_not_presets() {
let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let dropped = v(&["/a/IMG_1.CR2", "/a/IMG_1.xmp", "/a/IMG_2.jpg", "/a/IMG_2.jpg.XMP", "/a/Look.xmp", "/b/IMG_1.xmp"]);
let (presets, photos) = super::split_presets(dropped);
assert_eq!(photos, v(&["/a/IMG_1.CR2", "/a/IMG_2.jpg"]));
assert_eq!(presets, v(&["/a/Look.xmp", "/b/IMG_1.xmp"]));
}
#[test]
fn detection_results_are_summarised_in_words() {
let r =
|counts: &[usize]| serde_json::json!({"photos": counts.iter().map(|n| serde_json::json!({"faces": vec![0; *n]})).collect::<Vec<_>>()});
assert_eq!(super::detect_summary(&r(&[0])), "No faces found");
assert_eq!(super::detect_summary(&r(&[1])), "Found 1 face");
assert_eq!(super::detect_summary(&r(&[3])), "Found 3 faces");
assert_eq!(super::detect_summary(&r(&[0, 0])), "No faces found in 2 photos");
assert_eq!(super::detect_summary(&r(&[2, 0, 1])), "Found 3 faces in 2 photos");
assert_eq!(super::detect_summary(&serde_json::json!({})), "No faces found in 0 photos");
}
#[test]
fn dropped_face_models_are_told_apart() {
for p in ["/a/model.onnx", "/a/dir/M.ONNX"] {
assert!(super::is_model_file(p), "{p}");
}
for p in ["/a/model.onnx.jpg", "/a/onnx", "/a/b.png"] {
assert!(!super::is_model_file(p), "{p}");
}
}
#[test]
fn dropped_presets_are_told_apart_from_photos() {
for p in ["/a/Look.lrtemplate", "/a/b.XMP", "/a/pack.zip", "/a/x.lcpreset", "/a/Magic Hour.mplumpack", "/a/Pop.lmp", "/a/Bundle.LMP"] {
assert!(super::is_preset_file(p), "{p}");
}
for p in ["/a/IMG_1.CR2", "/a/b.dng", "/a/c.jpg", "/a/folder"] {
assert!(!super::is_preset_file(p), "{p}");
}
}
}
/// Results recomputed only when the catalog (or their inputs) change.
#[derive(Default)]
pub struct Caches {
keyword_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>>)>,
/// The Keywording box's read-only names (with containing keywords, or Will Export), by library
/// revision, selection and view.
keyword_names: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>,
/// The Keywording box's chips, by library revision and selection.
keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>,
/// How many photos of the library the selection is, by library revision and selection.
selected_photos: Option<(u64, u64, usize)>,
/// The Keywording box's chips grouped by the keyword they are inside, by library revision and
/// selection.
keyword_groups: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Group>>)>,
/// The Keyword List's tick boxes, by library revision and selection.
keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>,
folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>,
people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>,
suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>,
/// What the Add keyword box offers for the text typed in it, by library revision, the
/// selection's keywords and that text.
completions: Option<(u64, std::sync::Arc<panels::keywording::Offers>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
/// The albums' counts: the minute they are of (while a smart album asks about "the last N days"), the tally
/// that follows edits, and the counts as the panels take them.
album_counts:
Option<(String, lightcraft_catalog::tally::AlbumTally, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// How often the album counts were made from every photo (tests check that unchanged frames, and edits to a
/// photo or a few, don't).
pub album_count_scans: usize,
/// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]).
rules_view: Option<(u64, std::sync::Arc<RulesView>)>,
pub rules_view_scans: usize,
/// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts.
dialog_count: Option<(u64, usize)>,
pub dialog_count_scans: usize,
/// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]).
smart_problems: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>,
pub smart_problem_scans: usize,
/// AI denoise: what the pump last saw, the model list and the downloads being watched.
pub denoise: panels::denoise::Ui,
/// Bumped when a face model is installed, removed or chosen, so Settings re-reads the list at once.
pub faces_epoch: u64,
/// Face model downloads the user started, followed until installed (see `panels::faces::pump`).
pub faces_dl_watch: Vec<String>,
/// Whether the background face indexer is running, how many faces it has embedded, how many photos are left, and
/// when to ask it again.
pub faces_active: bool,
pub faces_indexed: u64,
pub faces_pending: u64,
pub faces_next_pump: f64,
/// When to ask the focus-point reader again (`focus_scan::pump`).
pub focus_next_pump: f64,
/// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time).
pub last_input: f64,
/// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`).
pub faces_pace: &'static str,
pub faces_in_front: bool,
pub last_move: f64,
/// Name suggestions for the photo in the loupe: (photo, catalog revision, faces indexed, suggestions by region).
pub face_hints: Option<(u64, u64, u64, std::sync::Arc<panels::faces::Hints>)>,
/// The open person page: (name, catalog revision, faces indexed, when it was asked for, the page).
pub person_page: Option<(String, u64, u64, f64, std::sync::Arc<panels::person::PersonPage>)>,
/// The unnamed faces: (catalog revision, faces indexed, when it was asked for, the list).
pub unnamed: Option<(u64, u64, f64, std::sync::Arc<panels::unnamed::Unnamed>)>,
/// The most photos the face scan has had left at once since it last finished (the progress bar's whole).
pub faces_peak: u64,
person_names: Option<(u64, std::sync::Arc<Vec<String>>)>,
/// The grid's date runs, layout and indexes (by the visible list's generation).
pub grid: panels::grid::GridCache,
/// What the grid did on its frames (benchmarks and tests check unchanged frames stay cheap).
pub grid_stats: panels::grid::GridStats,
}
/// 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);
}
}