mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge PR #407
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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});
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user