This commit is contained in:
Claude
2026-10-08 23:47:24 +00:00
8 changed files with 439 additions and 32 deletions
+1
View File
@@ -168,6 +168,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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
+36 -2
View File
@@ -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<String> = 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;
}
+2
View File
@@ -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::<Vec<_>>(),
"memory": memory(app),
})
}
+9
View File
@@ -20,6 +20,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;
@@ -91,6 +92,10 @@ pub type HostAction = Box<dyn FnMut(&mut Session) -> Result<Value, String>>;
/// Platform services injected by the host app (desktop or web).
#[derive(Default)]
pub struct Services {
/// 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<pick::Picker>,
/// Show an open dialog for photos; returns paths.
pub pick_files: Option<PickFiles>,
/// Open dialog for preset files (`.lcpreset`, `.xmp`, `.lrtemplate`, `.zip`, `.dng`, Luminar `.lmp` / `.mplumpack`).
@@ -174,6 +179,8 @@ pub struct LightcraftApp {
shadow: Option<headless::HeadlessView>,
/// Synthetic input events (from the control channel) injected one step per frame.
pub synthetic: Vec<egui::Event>,
/// Native file dialogs up for commands (`pick`): each command runs again when its closes.
pub(crate) pending_picks: Vec<pick::Pending>,
/// Modifiers announced for synthetic input (held from a button down to its release).
synthetic_mods: egui::Modifiers,
/// Clear `synthetic_mods` on the next frame.
@@ -252,6 +259,7 @@ impl LightcraftApp {
screenshot_token: 0,
shadow: None,
synthetic: vec![],
pending_picks: vec![],
synthetic_mods: egui::Modifiers::NONE,
synthetic_mods_release: false,
styled: false,
@@ -876,6 +884,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);
+90 -22
View File
@@ -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<Re
}
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 => 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<Re
crate::import::open(app, paths)
}
"file.importLightroom" => {
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<Re
"file.findMissing" => {
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<Re
// a GPX file → GPS for the selected photos by capture time (one undo step)
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => 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<Re
let Some(id) = app.session.active() else { return Some(Err("no photo selected".into())) };
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => 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<Re
// a folder (searched recursively) into the import review
let path = match p.get("path").and_then(Value::as_str) {
Some(x) => 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<Re
"file.importPresets" => {
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<Re
Some(x) => 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<Re
"file.importCurvePresets" => {
let paths: Vec<String> = 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<Re
"file.exportCurvePresets" => {
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});
+11 -7
View File
@@ -548,13 +548,17 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
}
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())?;
+289
View File
@@ -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<String>,
/// 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<String>,
}
impl PickRequest {
pub fn files(title: Option<String>, filter: impl Into<String>, extensions: &'static [&'static str]) -> Self {
PickRequest { kind: PickKind::Files, title, filter: Some((filter.into(), extensions)), file_name: None }
}
pub fn file(title: impl Into<String>, filter: impl Into<String>, 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<String>) -> Self {
PickRequest { kind: PickKind::Folder, title: Some(title.into()), filter: None, file_name: None }
}
pub fn save(title: impl Into<String>, filter: impl Into<String>, extensions: &'static [&'static str], file_name: impl Into<String>) -> 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<dyn FnMut(PickRequest) -> Result<Receiver<Vec<String>>, 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<Vec<String>>,
}
/// How a command's request for a path was answered.
pub enum Picked {
/// A synchronous dialog answered (an empty list: cancelled).
Now(Vec<String>),
/// 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<Vec<String>>,
) -> 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<Mutex<Vec<(PickRequest, Sender<Vec<String>>)>>>;
/// 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<Vec<String>> {
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);
}
}
+1 -1
View File
@@ -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 |