Files
lightcraft/crates/ui-egui/src/lib.rs
T
Dylan M. Taylor 445dd3106e Update prompt shows the app icon and accent color instead of a letter (#3362)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#f2a516, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
Clippy fixes for Rust 1.99 in untouched code (same logic):
- crates/engine/src/cmd/export.rs
- crates/ui-egui/src/dialogs/rules_editor/rules_move.rs
- crates/ui-egui/src/panels/detail.rs
2026-10-30 01:27:49 -04:00

632 lines
33 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)]
/// LightCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png")),
accent: Some([0xf2, 0xa5, 0x16]),
..craft_update::suite::LIGHTCRAFT
};
pub mod brush_live;
pub mod canvas_keys;
mod command_error;
pub mod control;
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_import;
pub mod links;
pub mod menus;
mod model_setup;
pub mod panels;
pub mod pick;
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 theme;
pub mod widgets;
pub mod window_place;
#[cfg(test)]
mod tests;
#[cfg(test)]
use tests::panels as tests_panels;
#[cfg(test)]
use tests::{fixtures as tests_fixtures, render_fixtures as tests_render_fixtures};
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::credits;
pub use dialogs::import;
pub use dialogs::lightroom_destination;
pub use dialogs::merge;
pub use dialogs::print_view;
pub use dialogs::slideshow_export;
pub use dialogs::updates_ui;
pub use menus::menu_level;
pub use menus::menubar;
pub use state::UiState;
pub use widgets::album_picker;
pub use widgets::button;
pub use widgets::date_picker;
pub use widgets::progress;
pub use widgets::rating;
pub use widgets::text_field;
mod app;
mod caches;
mod clock;
mod services;
#[cfg(test)]
use app::{import_tick_allowed, sam_download_ended, shot_ready};
pub(crate) use caches::key_of;
pub use caches::{FilterValues, LibraryCounts, RulesView};
use clock::AppClock;
pub use clock::{monotonic_ms, now_ms};
#[cfg(target_os = "linux")]
pub use services::OpenModelFolderFn;
pub use services::{
CheckEditorFn, HostAction, OpenAllWithFn, OpenUrlFn, OpenWithFn, PickFiles, PickFolder, PngEncode, RevealFn, SaveFile, ScreenProfileFn, Services,
SharedWrite, SystemThemeFn, WriteFn,
};
use state::{GestureFrame, InjectedPress};
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";
#[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>,
/// Prefix of `dropped_files` that arrived behind a prompt, library window or its picker.
/// Preserve only this prefix through subsequent dialogs/imports; later arrivals still take
/// the ordinary refusal path unless they too arrive while the original barrier is up.
deferred_dropped_file_count: usize,
/// 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,
}
/// 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"),
}
}
}
/// 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 `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,
}
/// 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.")
}
/// 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)
}
/// 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,
}