From d3ceaf0e794b7f80a485d9b538feaad4c955b26d Mon Sep 17 00:00:00 2001 From: ragtux Date: Thu, 8 Oct 2026 18:42:19 -0500 Subject: [PATCH] Fix #191: commands' file dialogs run off the UI thread The desktop showed rfd's synchronous dialogs on the UI thread, so the window stopped answering the compositor while one was up: GNOME marked LightCraft "not responding", the File menu stayed drawn and the control channel went quiet. Services.picker lets the host start a dialog elsewhere and answer through a channel; a command that needs a path asks for one and returns, and runs again with the answer when the dialog closes (pick.rs). Import Photos, Import from Folder, Open Library, Import Lightroom Catalog, Find Missing Photos, the preset and curve-preset imports and exports, Auto-Tag from Tracklog and Locate Missing File use it; hosts without a picker (the web, tests) keep the synchronous dialogs. ui.inspect lists the waiting commands as fileDialogs. Same cause as storytold/photocraft#574. Co-Authored-By: Claude Fable 5.1 --- apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/src/main.rs | 38 +++- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/menus.rs | 112 ++++++++-- crates/ui-egui/src/panels/settings.rs | 18 +- crates/ui-egui/src/pick.rs | 289 ++++++++++++++++++++++++++ docs/control-protocol.md | 2 +- 8 files changed, 439 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/pick.rs diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index ede1de0df..16543fd54 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen let (backup_backend, backup_files) = (backend.clone(), files); let restore_backend = backend.clone(); Services { + picker: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 149c98ab0..3f86b7767 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -281,8 +281,42 @@ fn windows_open_url_command(url: &str) -> std::process::Command { c } -fn services() -> Services { +/// The platform services; `ctx` repaints when a file dialog closes. +fn services(ctx: egui::Context) -> Services { Services { + // Commands' file dialogs run on their own thread (#191): a dialog on the UI thread + // stopped the window answering the compositor, which then reported the app as hung. + picker: Some(Box::new(move |req: lightcraft_ui_egui::pick::PickRequest| { + use lightcraft_ui_egui::pick::PickKind; + let (tx, rx) = std::sync::mpsc::channel(); + let repaint = ctx.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + let mut d = rfd::FileDialog::new(); + if let Some(t) = &req.title { + d = d.set_title(t); + } + if let Some((name, exts)) = &req.filter { + d = d.add_filter_nocase(name.clone(), exts); + } + if let Some(n) = &req.file_name { + d = d.set_file_name(n); + } + let s = |p: std::path::PathBuf| p.to_string_lossy().to_string(); + let paths: Vec = match req.kind { + PickKind::Files => d.pick_files().unwrap_or_default().into_iter().map(s).collect(), + PickKind::File => d.pick_file().map(s).into_iter().collect(), + PickKind::Folder => d.pick_folder().map(s).into_iter().collect(), + PickKind::Save => d.save_file().map(s).into_iter().collect(), + }; + // the app may have quit meanwhile: nobody to tell + let _ = tx.send(paths); + repaint.request_repaint(); + }) + .map_err(|e| format!("could not start the file dialog: {e}"))?; + Ok(rx) + })), pick_lightroom_catalog: Some(Box::new(|| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Import Lightroom Catalog")) @@ -614,7 +648,7 @@ fn main() -> eframe::Result { std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3"))); // the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too) session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt")); - let mut app = LightcraftApp::new(session, services()); + let mut app = LightcraftApp::new(session, services(cc.egui_ctx.clone())); if let Some(ui) = prefs { app.ui = ui; } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7456a7240..9f1c0df1b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -103,6 +103,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "import": app.import.as_ref().map(crate::import::ImportTask::status), "tasks": app.tasks.labels(), + // commands waiting on a native file dialog shown off the UI thread (`pick`) + "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::>(), "memory": memory(app), }) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 502e31ffe..2293a32be 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -19,6 +19,7 @@ pub mod menubar; pub mod menus; pub mod merge; pub mod panels; +pub mod pick; pub mod render; pub mod shortcuts; pub mod softpaint; @@ -88,6 +89,10 @@ pub type HostAction = Box Result>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { + /// Native file dialogs for commands, shown off the UI thread and answered later + /// (`pick`; #191). With it, the synchronous pickers below serve only the buttons inside + /// dialogs; without it (the web, tests) they answer the commands too. + pub picker: Option, /// Show an open dialog for photos; returns paths. pub pick_files: Option, /// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`). @@ -171,6 +176,8 @@ pub struct LightcraftApp { shadow: Option, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, + /// Native file dialogs up for commands (`pick`): each command runs again when its closes. + pub(crate) pending_picks: Vec, /// Modifiers announced for synthetic input (held from a button down to its release). synthetic_mods: egui::Modifiers, /// Clear `synthetic_mods` on the next frame. @@ -249,6 +256,7 @@ impl LightcraftApp { screenshot_token: 0, shadow: None, synthetic: vec![], + pending_picks: vec![], synthetic_mods: egui::Modifiers::NONE, synthetic_mods_release: false, styled: false, @@ -873,6 +881,7 @@ impl LightcraftApp { import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); + pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a4eb6f31c..85218fb6d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -5,6 +5,7 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::LightcraftApp; +use crate::pick::{PickRequest, Picked}; use crate::state::{BeforeAfter, Dialog, RightPanel, ViewMode, Zoom}; /// (id, label, shortcut, menu path) @@ -1004,7 +1005,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => app.services.pick_files.as_mut().map(|f| f()).unwrap_or_default(), + None => { + let req = PickRequest::files(None, crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later | Picked::Unavailable => return Some(Ok(Value::Null)), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1013,8 +1020,17 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { - let path = - p.get("path").and_then(Value::as_str).map(str::to_string).or_else(|| app.services.pick_lightroom_catalog.as_mut().and_then(|f| f())); + let path = match p.get("path").and_then(Value::as_str).map(str::to_string) { + Some(path) => Some(path), + None => { + let req = + PickRequest::file(crate::i18n::tr("Import Lightroom Catalog"), crate::i18n::tr("Lightroom Classic Catalog"), &["lrcat"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_lightroom_catalog.as_mut().and_then(|f| f()).map(|x| vec![x])) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } + }; match path { Some(path) => app.run("library.importLightroom", json!({"path":path})), None => Ok(Value::Null), @@ -1032,7 +1048,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let folder = match p.get("folder").and_then(Value::as_str) { Some(f) => Some(f.to_string()), - None => app.services.pick_folder.as_mut().and_then(|f| f()), + None => match crate::pick::ask(app, id, p, "folder", PickRequest::folder(crate::i18n::tr("Find Missing Photos…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, }; let Some(folder) = folder else { return Some(Ok(Value::Null)) }; // the search (checking every photo's file, walking the folder) runs on a worker thread; @@ -1087,7 +1108,13 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_tracklog.as_mut().and_then(|f| f().into_iter().next()), + None => { + let req = PickRequest::file(crate::i18n::tr("Auto-Tag from Tracklog"), crate::i18n::tr("GPS Track Log"), &["gpx"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_tracklog.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let mut params = p.as_object().cloned().unwrap_or_default(); @@ -1128,7 +1155,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_files.as_mut().and_then(|f| f().into_iter().next()), + None => { + let req = + PickRequest::file(crate::i18n::tr("Locate Missing File…"), crate::i18n::tr("Photos"), lightcraft_engine::import::EXTENSIONS); + match crate::pick::ask(app, "photo.locate", p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + } + } }; match path { Some(path) => app.run("photo.relink", json!({"id": id.0, "path": path})), @@ -1157,7 +1191,12 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), - None => app.services.pick_folder.as_mut().and_then(|f| f()), + None => match crate::pick::ask(app, id, p, "path", PickRequest::folder(crate::i18n::tr("Import from Folder…")), |s| { + s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x]) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later | Picked::Unavailable => None, + }, }; match path { Some(path) => crate::import::open(app, vec![path]), @@ -1167,10 +1206,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let paths = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => match app.services.pick_preset_files.as_mut() { - Some(f) => f(), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Presets").into()), + crate::i18n::tr("Presets & Profiles"), + crate::pick::PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1249,9 +1296,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option Some(x.to_string()), None => { let name = format!("{}.lcpreset", group.as_deref().unwrap_or("LightCraft Presets")); - match app.services.save_preset_file.as_mut() { - Some(f) => f(&name), - None => return Some(Err("no file dialog on this platform".into())), + let req = PickRequest::save(crate::i18n::tr("Export Presets"), crate::i18n::tr("LightCraft Preset"), &["lcpreset"], name.clone()); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), } } }; @@ -1272,10 +1321,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let paths: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), - None => match app.services.pick_curve_preset_files.as_mut() { - Some(f) => f(), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::files( + Some(crate::i18n::tr("Import Point Curve Presets").into()), + crate::i18n::tr("Point Curve Presets"), + crate::pick::CURVE_PRESET_EXTENSIONS, + ); + match crate::pick::ask(app, id, p, "paths", req, |s| s.pick_curve_preset_files.as_mut().map(|f| f())) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; if paths.is_empty() { return Some(Ok(Value::Null)); @@ -1295,10 +1352,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option { let path = match p.get("path").and_then(Value::as_str) { Some(x) => Some(x.to_string()), - None => match app.services.save_curve_preset_file.as_mut() { - Some(f) => f("Point Curves.lccurve"), - None => return Some(Err("no file dialog on this platform".into())), - }, + None => { + let req = PickRequest::save( + crate::i18n::tr("Export Point Curve Presets"), + crate::i18n::tr("Point Curve Presets"), + &["lccurve"], + "Point Curves.lccurve", + ); + match crate::pick::ask(app, id, p, "path", req, |s| { + s.save_curve_preset_file.as_mut().map(|f| f("Point Curves.lccurve").into_iter().collect()) + }) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } }; let Some(path) = path else { return Some(Ok(Value::Null)) }; let mut params = json!({"path": path}); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index e16d88154..d87d64ba2 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -548,13 +548,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result } let path = match p.get("path").and_then(Value::as_str) { Some(x) => x.to_string(), - None => match app.services.pick_folder.as_mut() { - Some(pick) => match pick() { - Some(x) => x, - None => return Ok(Value::Null), - }, - None => return Err("no folder dialog on this platform".into()), - }, + None => { + let req = crate::pick::PickRequest::folder(crate::i18n::tr("Open Library")); + match crate::pick::ask(app, "app.openLibrary", p, "path", req, |s| s.pick_folder.as_mut().and_then(|f| f()).map(|x| vec![x])) { + crate::pick::Picked::Now(v) => match v.into_iter().next() { + Some(x) => x, + None => return Ok(Value::Null), + }, + crate::pick::Picked::Later => return Ok(Value::Null), + crate::pick::Picked::Unavailable => return Err("no folder dialog on this platform".into()), + } + } }; app.session.close_library().map_err(|e| e.to_string())?; app.session.open_library(&path, false).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs new file mode 100644 index 000000000..1032a5cd9 --- /dev/null +++ b/crates/ui-egui/src/pick.rs @@ -0,0 +1,289 @@ +//! Native file dialogs for commands, off the UI thread (#191). A synchronous dialog blocks the +//! thread that shows it, and on the desktop that was the UI thread: the window stopped answering +//! the compositor (GNOME marked LightCraft "not responding" and offered to kill it), the menu the +//! command came from stayed drawn, and the control channel went quiet until the dialog closed. +//! +//! A host that can run a dialog elsewhere installs a [`Picker`] in `Services`: it starts the +//! dialog and answers through a channel. A command that needs a path the caller didn't give +//! [`ask`]s for one and returns at once; when the dialog closes, [`poll`] runs the command again +//! with the answer filled in under the parameter it was missing. Hosts without a `Picker` (the +//! web, where browser pickers are asynchronous already; tests) answer through the synchronous +//! services as before. + +use crate::{LightcraftApp, Services}; +use serde_json::{Value, json}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +/// What a dialog chooses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PickKind { + /// Any number of files. + Files, + /// One file. + File, + /// One folder. + Folder, + /// Where to save one file. + Save, +} + +/// A dialog to show. Texts are translated already. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickRequest { + pub kind: PickKind, + /// The dialog's title; the host's default when `None`. + pub title: Option, + /// One file-type filter: its name and the extensions it admits (without dots). + pub filter: Option<(String, &'static [&'static str])>, + /// [`PickKind::Save`]: the suggested file name. + pub file_name: Option, +} + +impl PickRequest { + pub fn files(title: Option, filter: impl Into, extensions: &'static [&'static str]) -> Self { + PickRequest { kind: PickKind::Files, title, filter: Some((filter.into(), extensions)), file_name: None } + } + + pub fn file(title: impl Into, filter: impl Into, extensions: &'static [&'static str]) -> Self { + PickRequest { kind: PickKind::File, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: None } + } + + pub fn folder(title: impl Into) -> Self { + PickRequest { kind: PickKind::Folder, title: Some(title.into()), filter: None, file_name: None } + } + + pub fn save(title: impl Into, filter: impl Into, extensions: &'static [&'static str], file_name: impl Into) -> Self { + PickRequest { kind: PickKind::Save, title: Some(title.into()), filter: Some((filter.into(), extensions)), file_name: Some(file_name.into()) } + } +} + +/// What File ▸ Import Profiles & Presets… opens. +pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]; +/// What Import Point Curve Presets… opens. +pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"]; + +/// The host's dialog starter: shows the request without blocking and answers through the +/// channel with the chosen paths (none = cancelled). `Err` when no dialog could be started. +pub type Picker = Box Result>, String>>; + +/// A dialog that is up for a command. When it closes, the command runs again with the answer +/// under `key`. +pub struct Pending { + pub command: String, + params: Value, + key: &'static str, + kind: PickKind, + rx: Receiver>, +} + +/// How a command's request for a path was answered. +pub enum Picked { + /// A synchronous dialog answered (an empty list: cancelled). + Now(Vec), + /// The dialog is up (or was already); the command runs again when it closes. + Later, + /// This host has no such dialog. + Unavailable, +} + +/// Ask for a path for `command`, which was run with `params` but without `key`. With a [`Picker`] +/// the dialog runs off the UI thread and the command comes back through [`poll`] with `key` filled +/// in; a dialog already up for the command is not opened twice. Without one, `now` asks the +/// synchronous service. +pub fn ask( + app: &mut LightcraftApp, + command: &str, + params: &Value, + key: &'static str, + request: PickRequest, + now: impl FnOnce(&mut Services) -> Option>, +) -> Picked { + if app.pending_picks.iter().any(|p| p.command == command) { + return Picked::Later; + } + if let Some(start) = app.services.picker.as_mut() { + let kind = request.kind; + match start(request) { + Ok(rx) => { + app.pending_picks.push(Pending { command: command.to_string(), params: params.clone(), key, kind, rx }); + return Picked::Later; + } + Err(e) => log::warn!("{command}: no background file dialog ({e}); asking on the UI thread"), + } + } + match now(&mut app.services) { + Some(paths) => Picked::Now(paths), + None => Picked::Unavailable, + } +} + +/// `params` with the dialog's answer under `key`: the whole list for a `paths` key, the first +/// path otherwise. +pub fn with_answer(params: &Value, key: &str, kind: PickKind, paths: &[String]) -> Value { + let mut p = params.as_object().cloned().unwrap_or_default(); + let answer = match kind { + PickKind::Files => json!(paths), + _ => json!(paths.first().cloned().unwrap_or_default()), + }; + p.insert(key.to_string(), answer); + Value::Object(p) +} + +/// Each frame: run the commands whose dialogs have closed with something chosen; forget the +/// cancelled ones and those whose dialog went away without an answer. +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.pending_picks.is_empty() { + return; + } + let mut answered = Vec::new(); + let mut waiting = Vec::new(); + for p in std::mem::take(&mut app.pending_picks) { + match p.rx.try_recv() { + Ok(paths) if paths.is_empty() => {} + Ok(paths) => answered.push((p, paths)), + Err(TryRecvError::Empty) => waiting.push(p), + Err(TryRecvError::Disconnected) => log::warn!("{}: the file dialog ended without an answer", p.command), + } + } + if !waiting.is_empty() { + // the host repaints when a dialog closes; this is the net under it + ctx.request_repaint_after(std::time::Duration::from_millis(250)); + } + app.pending_picks = waiting; + for (p, paths) in answered { + let params = with_answer(&p.params, p.key, p.kind, &paths); + if let Err(e) = app.run(&p.command, params) { + app.toast_error(ctx, e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::headless::Headless; + use crate::state::Dialog; + use std::sync::mpsc::{Sender, channel}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + const SETTLE: Duration = Duration::from_secs(20); + + type Asked = Arc>)>>>; + + /// A windowless app whose host shows dialogs "elsewhere": each request is recorded with the + /// sender that answers it. Frames run through the headless harness, so [`poll`] and the import + /// scan happen as in the app. + fn app_with_picker() -> (Headless, Asked) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + let asked: Asked = Default::default(); + let a = asked.clone(); + app.services.picker = Some(Box::new(move |req| { + let (tx, rx) = channel(); + a.lock().unwrap().push((req, tx)); + Ok(rx) + })); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(SETTLE); + (h, asked) + } + + fn photo_in(dir: &std::path::Path) -> String { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let path = dir.join("one.png"); + std::fs::write(&path, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + path.to_string_lossy().to_string() + } + + /// The import review's sources once frames have run (the review opens after a background + /// scan of the chosen paths). + fn import_sources(h: &mut Headless) -> Option> { + h.settle(SETTLE); + match &h.app.ui.dialog { + Some(Dialog::Import { opts }) => Some(opts.sources.clone()), + _ => None, + } + } + + #[test] + fn import_photos_waits_for_its_dialog_and_runs_with_the_answer() { + let dir = std::env::temp_dir().join(format!("lc-pick-{}", std::process::id())); + let photo = photo_in(&dir); + let (mut h, asked) = app_with_picker(); + // the command returns at once: no review of its own yet, the native dialog is up + assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null); + assert!(import_sources(&mut h).is_none()); + assert_eq!(h.app.pending_picks.len(), 1); + { + let asked = asked.lock().unwrap(); + assert_eq!(asked.len(), 1); + assert_eq!(asked[0].0.kind, PickKind::Files); + assert_eq!(asked[0].0.filter.as_ref().map(|f| f.1), Some(lightcraft_engine::import::EXTENSIONS)); + } + // asking again while it is up opens no second dialog + assert_eq!(h.app.run("file.addPhotos", json!({})).unwrap(), Value::Null); + h.settle(SETTLE); + assert_eq!(asked.lock().unwrap().len(), 1); + assert_eq!(h.app.pending_picks.len(), 1, "still waiting"); + // the user chooses a photo: the command runs again with it and opens the import review + asked.lock().unwrap()[0].1.send(vec![photo.clone()]).unwrap(); + assert_eq!(import_sources(&mut h), Some(vec![photo])); + assert!(h.app.pending_picks.is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_cancelled_or_vanished_dialog_runs_nothing() { + let (mut h, asked) = app_with_picker(); + h.app.run("file.addPhotos", json!({})).unwrap(); + asked.lock().unwrap()[0].1.send(vec![]).unwrap(); + assert!(import_sources(&mut h).is_none(), "cancelled: no import review"); + assert!(h.app.pending_picks.is_empty()); + // the dialog thread ends without answering (the host dropped the sender) + h.app.run("file.addFolder", json!({})).unwrap(); + assert_eq!(h.app.pending_picks.len(), 1); + asked.lock().unwrap().pop(); + assert!(import_sources(&mut h).is_none()); + assert!(h.app.pending_picks.is_empty()); + } + + #[test] + fn a_folder_dialog_fills_in_one_path_and_a_save_dialog_names_the_file() { + let dir = std::env::temp_dir().join(format!("lc-pick-folder-{}", std::process::id())); + let _photo = photo_in(&dir); + let (mut h, asked) = app_with_picker(); + h.app.run("file.addFolder", json!({})).unwrap(); + assert_eq!(asked.lock().unwrap()[0].0.kind, PickKind::Folder); + asked.lock().unwrap()[0].1.send(vec![dir.to_string_lossy().to_string()]).unwrap(); + assert_eq!(import_sources(&mut h), Some(vec![dir.to_string_lossy().to_string()])); + // a save dialog carries the suggested name; the command's other parameters survive + h.app.run("file.exportPresets", json!({"group": "Mine"})).unwrap(); + let req = asked.lock().unwrap().last().map(|(r, _)| r.clone()).unwrap(); + assert_eq!(req.kind, PickKind::Save); + assert_eq!(req.file_name.as_deref(), Some("Mine.lcpreset")); + let p = with_answer(&json!({"group": "Mine"}), "path", PickKind::Save, &["/tmp/Mine.lcpreset".to_string(), "/tmp/other".to_string()]); + assert_eq!(p, json!({"group": "Mine", "path": "/tmp/Mine.lcpreset"})); + assert_eq!(with_answer(&Value::Null, "paths", PickKind::Files, &["a".to_string(), "b".to_string()]), json!({"paths": ["a", "b"]})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn without_a_picker_the_synchronous_dialog_answers_at_once() { + let dir = std::env::temp_dir().join(format!("lc-pick-sync-{}", std::process::id())); + let photo = photo_in(&dir); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + let p = photo.clone(); + app.services.pick_files = Some(Box::new(move || vec![p.clone()])); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.run("file.addPhotos", json!({})).unwrap(); + assert!(h.app.pending_picks.is_empty()); + assert_eq!(import_sources(&mut h), Some(vec![photo])); + // and a host with neither shows nothing, as before + let mut bare = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + assert_eq!(bare.run("file.addPhotos", json!({})).unwrap(), Value::Null); + assert!(bare.run("file.importPresets", json!({})).is_err(), "no dialog on this platform"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4f02dee89..80fd01a55 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`) | +| `ui.inspect` | — | UI state, window, canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |