diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index ede1de0d..16543fd5 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 149c98ab..3f86b776 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 7456a724..9f1c0df1 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 502e31ff..2293a32b 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 a4eb6f31..85218fb6 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 e16d8815..d87d64ba 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 00000000..1032a5cd --- /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 4f02dee8..80fd01a5 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 |