mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
refactor(lightcraft-ui-egui): split app.rs into app/
This commit is contained in:
+11
-1567
File diff suppressed because it is too large
Load Diff
@@ -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)}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user