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

1178 lines
53 KiB
Rust

//! 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 control;
pub mod credits;
pub mod export_task;
pub mod headless;
pub mod i18n;
pub mod icons;
pub mod import;
pub mod lightroom_import;
pub mod links;
pub mod menubar;
pub mod menus;
pub mod merge;
pub mod panels;
pub mod render;
pub mod shortcuts;
pub mod softpaint;
pub mod state;
pub mod tasks;
pub mod theme;
pub mod widgets;
#[cfg(test)]
mod tests_curve;
#[cfg(test)]
mod tests_filmstrip;
#[cfg(test)]
mod tests_grid;
#[cfg(test)]
mod tests_keymap;
#[cfg(test)]
mod tests_labels;
#[cfg(test)]
mod tests_library_problem;
#[cfg(test)]
mod tests_masking;
#[cfg(test)]
mod tests_masking_layout;
#[cfg(test)]
mod tests_offline;
#[cfg(test)]
mod tests_panels;
#[cfg(test)]
mod tests_quit_unsaved;
#[cfg(test)]
mod tests_scroll;
#[cfg(test)]
mod tests_switch_library;
#[cfg(test)]
mod tests_unsaved;
use std::sync::mpsc::{Receiver, Sender};
use lightcraft_engine::Session;
use serde_json::Value;
pub use control::{ControlRequest, ControlResponse};
pub use state::UiState;
const TOAST_SECONDS: f64 = 1.4;
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>>;
/// 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>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// Show an open dialog for photos; returns paths.
pub pick_files: 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>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: 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>,
pub write: 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>,
/// 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>,
/// 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>,
}
#[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,
pub fps: f64,
}
pub struct LightcraftApp {
/// 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 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>,
/// 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>,
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
pub quit_prompt: Option<String>,
/// Quit Anyway was chosen: the window may close with unsaved changes.
pub quit_confirmed: bool,
control_rx: Option<Receiver<ControlRequest>>,
pending_screenshots: Vec<PendingShot>,
screenshot_token: u64,
/// 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>,
/// Modifiers announced for synthetic input (held from a button down to its release).
synthetic_mods: egui::Modifiers,
/// Clear `synthetic_mods` on the next frame.
synthetic_mods_release: bool,
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,
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 frame (automation ids → rects).
pub widgets: Vec<(String, egui::Rect)>,
/// In-progress on-canvas gesture (brush stroke points, gradient drag…).
pub gesture: Option<panels::detail::Gesture>,
/// What the loupe drew last frame: photo and source ("render", "cached", "embedded", "small",
/// "thumb", "none").
pub loupe_shown: Option<(lightcraft_catalog::PhotoId, &'static str)>,
/// 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 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 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 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>,
/// The memory budget setting last applied (MB, 0 = automatic).
memory_applied: Option<u32>,
/// 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>,
}
impl LightcraftApp {
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,
native_menu: false,
native_shortcuts: Default::default(),
recording_shortcut: None,
headless_host: false,
notices: vec![],
quit_prompt: None,
quit_confirmed: false,
control_rx: None,
pending_screenshots: vec![],
screenshot_token: 0,
shadow: None,
synthetic: vec![],
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
styled: false,
font_language: i18n::Locale::En,
fonts_ready: false,
last_time: 0.0,
canvas_rect: None,
image_rect: None,
grid_scroll: None,
film_scroll: None,
widgets: vec![],
gesture: None,
loupe_shown: None,
merge: merge::MergeState::default(),
import: None,
scan: None,
lightroom: None,
lightroom_last: None,
export: None,
tasks: Default::default(),
last_export_result: None,
hover_preview: None,
window_is_fullscreen: false,
gpu_applied: None,
memory_applied: None,
library_problem: None,
}
}
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
}
/// 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> {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
let r = self.session.execute(id, &params).map_err(|e| e.to_string());
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(),
};
// Native menu clicks can arrive before logic() updates last_time after an idle gap.
let now = self.tasks.repaint.as_ref().map(|ctx| ctx.input(|i| i.time)).unwrap_or(self.last_time);
self.ui.toast = Some((text, now + TOAST_SECONDS, label));
}
if let Err(e) = &r {
log::warn!("{id}: {e}");
self.ui.status = e.clone();
}
r
}
/// Show a transient toast at the bottom of the canvas (like the reference app's HUD).
/// Advance a running slideshow (wrapping around at the end).
fn slideshow_tick(&mut self, ctx: &egui::Context) {
let Some((interval, due, paused)) = self.ui.slideshow else { return };
if !self.ui.fullscreen {
self.ui.slideshow = None;
return;
}
if paused {
return;
}
let now = ctx.input(|i| i.time);
if now >= due {
let vis = self.session.visible_cloned();
if let Some(cur) = self.session.active()
&& !vis.is_empty()
{
let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len());
let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]}));
}
self.ui.slideshow = Some((interval, now + interval, false));
}
ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval)));
}
/// Announce the start and 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 {
if self.ui.preview_build_seen != Some((key, false)) {
self.ui.preview_build_seen = Some((key, false));
let msg = match b.what {
"" => crate::i18n::tr_format!("Building previews for {} photos…", b.total),
what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)),
};
self.toast(ctx, msg);
}
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 = ctx.input(|i| i.time);
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, "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);
}
/// 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 = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + secs, 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) {
let polled = self.session.segment_poll();
let now = ctx.input(|i| i.time);
if polled.changed {
ctx.request_repaint();
}
if let Some(mask) = polled.refine {
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 (&download.error, open) {
(_, true) => {}
(Some(e), false) if e.contains("cancelled") => {
self.toast(ctx, crate::i18n::tr("SAM 3 download stopped: it resumes where it left off next time."))
}
(Some(e), false) => self.toast_error(ctx, crate::i18n::tr_format!("The SAM 3 download failed: {e}", e = e)),
(None, false) if download.finished => {
self.toast_error(ctx, crate::i18n::tr("The SAM 3 model is installed: Object and Describe masks are ready."))
}
(None, false) => {}
}
}
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.
pub fn ai_error(&mut self, ctx: &egui::Context, e: impl Into<String>, then: Option<(&str, &str)>) {
let e = e.into();
if lightcraft_engine::segment::Segmenter::AVAILABLE && e.starts_with(lightcraft_engine::segment::NOT_INSTALLED) {
self.offer_sam_download(then);
} else {
self.toast_error(ctx, e);
}
}
/// 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 = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 6.0, None));
}
fn drain_control(&mut self, ctx: &egui::Context) {
let Some(rx) = self.control_rx.take() else { return };
while let Ok(req) = rx.try_recv() {
let reply = req.reply.clone();
match control::handle(self, ctx, &req) {
control::Outcome::Done(v) => {
let _ = reply.send(v);
}
control::Outcome::Screenshot { path, headless } => {
self.screenshot_token += 1;
let now = now_ms();
self.pending_screenshots.push(PendingShot {
token: self.screenshot_token,
path,
reply,
headless: headless && !self.headless_host,
not_before: now + 120.0,
deadline: now + SCREENSHOT_SETTLE_MS,
frames: 0,
sent_at: None,
});
}
}
}
self.control_rx = Some(rx);
}
/// Advance pending screenshots: wait (≥ 3 frames, ≥ 120 ms) until no renders are in flight
/// (or a timeout), then capture — via the host's compositor, 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 = now_ms();
let busy = self.renderer.in_flight() > 0 || self.merge.busy();
let mut shots = std::mem::take(&mut self.pending_screenshots);
let mut shadow_ticked = false;
shots.retain_mut(|s| {
if let Some(sent) = s.sent_at {
if now - sent < SCREENSHOT_FALLBACK_MS || self.headless_host {
return true;
}
// 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");
s.sent_at = None;
s.headless = true;
s.deadline = now + SCREENSHOT_SETTLE_MS;
}
s.frames += 1;
let ready = now >= s.not_before && s.frames >= 3 && (!busy || now > s.deadline);
if s.headless {
// The shadow frame requests the renders the UI needs, even while the window shows nothing.
let img = if ready || !shadow_ticked { self.headless_screenshot(ctx, ready) } else { None };
shadow_ticked = true;
if let Some(img) = img {
let _ = s.reply.send(control::save_screenshot(self, &img, s.path.as_deref()));
return false;
}
true
} else {
if ready {
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(s.token)));
s.sent_at = Some(now);
}
true
}
});
shots.append(&mut self.pending_screenshots);
self.pending_screenshots = shots;
if !self.pending_screenshots.is_empty() {
ctx.request_repaint_after(std::time::Duration::from_millis(16));
}
}
/// 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) };
let ppp = main.pixels_per_point();
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.ui(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
}
fn collect_screenshots(&mut self, ctx: &egui::Context) {
if self.pending_screenshots.is_empty() {
return;
}
let events: Vec<_> = ctx.input(|i| {
i.raw
.events
.iter()
.filter_map(|e| match e {
egui::Event::Screenshot { user_data, image, .. } => {
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
Some((token, image.clone()))
}
_ => None,
})
.collect()
});
for (token, image) in events {
if let Some(i) = self.pending_screenshots.iter().position(|s| s.token == token) {
let s = self.pending_screenshots.remove(i);
let _ = s.reply.send(control::save_screenshot(self, &image, s.path.as_deref()));
}
}
}
/// Per-frame logic before layout (control channel, renders, shortcuts, drops).
pub fn logic(&mut self, ctx: &egui::Context) {
i18n::set_language(self.ui.language);
let t0 = now_ms();
panels::library_problem::logic(self);
self.logic_inner(ctx);
self.perf.logic_ms = now_ms() - t0;
}
fn logic_inner(&mut self, ctx: &egui::Context) {
if !self.styled {
theme::install_fonts(ctx);
theme::apply(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(ctx);
self.font_language = self.ui.language;
} else {
self.fonts_ready = true;
}
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 = ctx.input(|i| i.time);
let dt = now - 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 = now;
self.drain_control(ctx);
self.apply_settings(ctx);
if !self.synthetic.is_empty() {
ctx.request_repaint();
}
self.renderer.poll(ctx, &mut self.session);
merge::poll(self, ctx);
import::poll_scan(self, ctx);
import::tick(self, ctx);
lightroom_import::tick(self, ctx);
tasks::poll(self, ctx);
self.preview_build_status(ctx);
self.save_status(ctx);
self.slideshow_tick(ctx);
// back from an external editor: pick up the files it saved
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, "Updated the edits saved in the external editor");
}
}
self.ui.was_focused = focused;
// 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";
let now = ctx.input(|i| i.time);
if now - self.ui.auto_import_at >= 3.0 && self.import.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());
};
if let Err(e) = tasks::spawn(self, LABEL, work, done) {
log::warn!("{e}");
}
}
ctx.request_repaint_after(std::time::Duration::from_secs(3));
}
self.session.persist_if_dirty();
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
#[cfg(not(target_arch = "wasm32"))]
{
let dropped: Vec<String> =
ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().to_string()).filter(|p| !p.is_empty()).collect());
// preset files import as presets, everything else as photos
let (presets, photos): (Vec<String>, Vec<String>) = dropped.into_iter().partition(|p| is_preset_file(p));
if !presets.is_empty() {
let _ = self.run("file.importPresets", serde_json::json!({"paths": presets}));
}
// read and added on a worker thread (dropped folders can be large, or on a slow drive)
if !photos.is_empty() {
if self.lightroom.is_some() {
self.toast(ctx, "Finish Lightroom catalog import before adding photos");
} else if let Err(e) = import::start_paths(self, photos) {
self.toast(ctx, e);
}
}
}
}
/// 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);
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;
}
}
/// Inject synthetic events (pointer events one per frame; key sequences up to the release).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
// the frame after a synthetic release / key: modifiers back up
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(egui::Modifiers::NONE));
}
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),
};
if let Some(egui::Event::PointerMoved(p) | 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;
}
raw.events.extend(self.synthetic.drain(..n));
}
/// Frame timings once layout is done (`t0`: when layout started).
fn end_frame(&mut self, t0: f64) {
self.perf.frame_ms = now_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);
}
/// Lay out the whole window.
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;
}
let t0 = now_ms();
if std::mem::take(&mut self.ui.quit) {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
// panels set it again this frame while the pointer rests on a preset or profile
self.hover_preview = None;
self.ai_mask_detail(&ctx);
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| panels::detail::show(self, ui));
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.end_frame(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);
if self.ui.right != state::RightPanel::None {
panels::right::show(self, ui);
}
if self.ui.presets {
panels::presets::show(self, ui);
}
if self.ui.left_panel {
panels::left::show(self, ui);
}
panels::bottombar::show(self, ui);
let t = theme::Tokens::get(&ctx);
let bg = if matches!(self.ui.view, state::ViewMode::Detail | state::ViewMode::Compare | state::ViewMode::Survey | state::ViewMode::Reference)
{
t.canvas
} else {
t.grid_bg
};
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| match self.ui.view {
state::ViewMode::PhotoGrid | state::ViewMode::SquareGrid => panels::grid::show(self, ui),
state::ViewMode::Detail => panels::detail::show(self, ui),
state::ViewMode::Compare => panels::compare::show_compare(self, ui),
state::ViewMode::Survey => panels::compare::show_survey(self, ui),
state::ViewMode::Reference => panels::compare::show_reference(self, ui),
state::ViewMode::People => panels::people::show(self, ui),
});
panels::second::show(self, &ctx);
panels::notices::show(self, &ctx);
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
import::progress(self, &ctx);
import::scan_progress(self, &ctx);
lightroom_import::progress(self, &ctx);
export_task::poll(self, &ctx);
panels::grid::drag_feedback(self, &ctx);
panels::toast(self, &ctx);
self.widgets = widgets::take_registry(&ctx);
self.end_frame(t0);
}
}
/// How long a screenshot waits for in-flight renders.
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;
/// A `ui.screenshot` request in progress.
struct PendingShot {
token: u64,
path: Option<String>,
reply: Sender<ControlResponse>,
headless: bool,
not_before: f64,
deadline: f64,
frames: u32,
/// When the viewport screenshot command went out.
sent_at: Option<f64>,
}
/// Wall-clock milliseconds since the Unix epoch (`web-time` maps to `Date.now()` on the web).
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.")
}
/// 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"].contains(&ext.as_str())
}
#[cfg(test)]
mod drop_tests {
#[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>>)>,
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>>)>,
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>,
/// How often the album counts were recomputed (tests check that unchanged frames don't).
pub album_count_scans: usize,
/// 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>,
}
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}
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>> {
match &self.folder_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.folder_tree());
self.folder_tree = Some((cat.revision, t.clone()));
t
}
}
}
/// The library's keyword tree.
pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> {
match &self.keyword_tree {
Some((r, t)) if *r == cat.revision => t.clone(),
_ => {
let t = std::sync::Arc::new(cat.keyword_tree());
self.keyword_tree = Some((cat.revision, 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
&& r == cat.revision
{
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
}
/// The By Date tree.
pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> {
match &self.date_groups {
Some((r, g)) if *r == cat.revision => g.clone(),
_ => {
let g = 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
}
}
}
/// 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.
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 { "" };
let k = key_of((cat.revision, minute));
match &self.album_counts {
Some((h, v)) if *h == k => v.clone(),
_ => {
if relative {
// "in the last N days" counts back from the session clock, as in the grid
lightcraft_catalog::rules::set_now(Some(now.to_string()));
}
let v: std::sync::Arc<std::collections::HashMap<_, _>> =
std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect());
self.album_count_scans += 1;
self.album_counts = Some((k, v.clone()));
v
}
}
}
}
#[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");
// metadata change
cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap();
assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2);
// 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, 4);
lightcraft_catalog::rules::set_now(None);
}
/// 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);
}
}