refactor(lightcraft-ui-egui): split app.rs into app/

This commit is contained in:
aiongg
2026-10-26 21:39:56 +00:00
parent cb2a922383
commit 26760e44d2
6 changed files with 1595 additions and 1567 deletions
+11 -1567
View File
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
use super::*;
use crate::control;
impl LightcraftApp {
/// 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.
pub(super) 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`]).
pub(crate) 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.
pub(crate) 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`]).
pub(crate) 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.
pub(super) 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.
pub(super) 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.
pub(super) 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.
pub(crate) 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.
pub(super) fn queue_pointer_cleanup(&mut self) {
self.pointer_cleanup = true;
}
/// Apply a queued pointer cancellation before egui processes the next input pass.
pub(super) 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.
pub(super) 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)}));
}
}
}
+448
View File
@@ -0,0 +1,448 @@
use super::*;
impl LightcraftApp {
/// Install/refresh fonts once and watch the device and availability caches. Setup, not input.
pub(super) 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.
pub(super) 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`.
pub(crate) 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.
pub(crate) 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.
pub(super) 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);
}
}
/// 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);
}
/// 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()),
}
}
/// 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.
pub(crate) 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.
pub(super) 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);
}
}
/// 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).
pub(crate) fn import_tick_allowed(ctx: &egui::Context) -> bool {
ctx.current_pass_index() == 0
}
+261
View File
@@ -0,0 +1,261 @@
use super::*;
impl LightcraftApp {
/// 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.
pub(super) 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);
}
}
/// 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;
}
}
#[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}");
}
}
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
impl LightcraftApp {
/// 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);
}
}
/// 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.
pub(super) 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.
pub(super) 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;
}
}
+362
View File
@@ -0,0 +1,362 @@
use super::*;
use crate::control;
impl LightcraftApp {
/// 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.
pub(crate) fn shot_document_generation(&self) -> u64 {
self.session.library_generation()
}
/// 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.
pub(super) 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.
pub(super) 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);
}
/// 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.
pub(crate) 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.
pub(crate) 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));
}
}
/// 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.
pub(crate) 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;
}
}
/// 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.
pub(crate) 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)
}