diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index f4aeb63d..4cfacad5 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use figcraft_engine::{Prefs, Session}; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{DialogFuture, FigApp, Services}; use serde_json::{Value, json}; pub struct Desktop { @@ -195,14 +195,29 @@ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { } impl Services for Desktop { - fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file() + fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { + let title = title.to_owned(); + let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); + Box::pin(async move { + rfd::AsyncFileDialog::new().set_title(title).add_filter("Supported files", &exts).pick_file().await.map(|file| file.path().to_path_buf()) + }) } - fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> Option { - rfd::FileDialog::new().set_title(title).set_file_name(name).add_filter("File", exts).save_file() + fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { + let (title, name) = (title.to_owned(), name.to_owned()); + let exts: Vec = exts.iter().map(|ext| (*ext).to_owned()).collect(); + Box::pin(async move { + rfd::AsyncFileDialog::new() + .set_title(title) + .set_file_name(name) + .add_filter("File", &exts) + .save_file() + .await + .map(|file| file.path().to_path_buf()) + }) } - fn pick_folder(&mut self, title: &str) -> Option { - rfd::FileDialog::new().set_title(title).pick_folder() + fn pick_folder(&mut self, title: &str) -> DialogFuture { + let title = title.to_owned(); + Box::pin(async move { rfd::AsyncFileDialog::new().set_title(title).pick_folder().await.map(|file| file.path().to_path_buf()) }) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/contributors/people.toml b/contributors/people.toml index e61cfd31..fcdf0cff 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -16,3 +16,6 @@ # # To remove your names, delete your entry (or set fields empty) in a PR. The next # contributors.py run, and the next build, drop them. + +[people.justinjohn0306] +real_name = "Justin John" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 37485568..96e15312 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -1088,11 +1088,12 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add - && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) - && let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) - { - added(app, &r); + if add { + app.pick_open(tr("Add library"), &["figcraft"], |app, p| { + if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { + added(app, &r); + } + }); } } diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs new file mode 100644 index 00000000..ec2c20f0 --- /dev/null +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -0,0 +1,34 @@ +use std::path::PathBuf; + +use figcraft_engine::Session; + +use crate::{DialogFuture, FigApp, Services}; + +struct OpenFile(PathBuf); + +impl Services for OpenFile { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(Some(self.0.clone()))) + } +} + +#[test] +fn open_dialog_finishes_after_the_async_picker_returns() { + let dir = std::env::temp_dir().join(format!("figcraft-async-open-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("opened.figcraft"); + let doc = figcraft_doc::Document::new("Opened"); + std::fs::write(&path, figcraft_format::save_bytes(&doc, None).unwrap()).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(OpenFile(path.clone()))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(!app.ui.home); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9a112367..78274507 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -460,23 +460,21 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.add_space(12.0); if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) { let base = paint.preview_color(); + let mut image_pending = false; paint.kind = match i { 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, 5 => { - // Pick an image file. - match app.services.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { - Some(p) => match std::fs::read(&p) { - Ok(bytes) => { - let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)); - match hash { - Some(h) => Paint::image(h, ImageScaleMode::Fill).kind, - None => paint.kind.clone(), - } - } - Err(_) => paint.kind.clone(), - }, - None => paint.kind.clone(), - } + image_pending = true; + let target = target.clone(); + app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { + let Ok(bytes) = std::fs::read(&p) else { return }; + let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; + if let Some(mut paint) = current_paint(app, &target) { + paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; + write(app, &target, &paint, false); + } + }); + paint.kind.clone() } 6 => match pattern_sources(app, &target).first() { Some((id, _)) => Paint::pattern(id.0).kind, @@ -495,7 +493,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } }; - write(app, &target, &paint, false); + if !image_pending { + write(app, &target, &paint, false); + } } }); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 221ee636..24fc12a4 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -464,15 +464,17 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { - let Some(p) = app.services.pick_save(tr("Export"), &format!("{name}.fig"), &["fig"]) else { return }; - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { - let left_out = r["warnings"].as_array().map_or(0, Vec::len); - app.toast(match left_out { - 0 => trf("Exported {name}", &[("name", &file)]), - n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), - }); - } + let filename = format!("{name}.fig"); + app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { + let left_out = r["warnings"].as_array().map_or(0, Vec::len); + app.toast(match left_out { + 0 => trf("Exported {name}", &[("name", &file)]), + n => trn("Exported {name}; {n} kinds of content were left out", n, &[("name", &file)]), + }); + } + }); } /// What the Export dialog was asked to do. diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 1c0c6a37..ade5df29 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -318,11 +318,13 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), _ => { - let Some(dir) = app.services.pick_folder(tr("Export")) else { return }; - if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { - let n = r["files"].as_array().map_or(0, Vec::len); - app.toast(trn("Exported {n} files", n, &[])); - } + let ids = ids.to_vec(); + app.pick_folder(tr("Export"), move |app, dir| { + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy(), "assets": assets})) { + let n = r["files"].as_array().map_or(0, Vec::len); + app.toast(trn("Exported {n} files", n, &[])); + } + }); } } } @@ -330,14 +332,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { /// Export `ids` together as one file with `setting`, through a save dialog suggesting `file`. pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting, file: &str) { let ext = setting.format.ext(); - let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return }; - let path = p.to_string_lossy().to_string(); let mut params = setting_params(setting); params["ids"] = json!(ids); - params["path"] = json!(path); - if app.run("file.export", params).is_some() { - app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); - } + app.pick_save(tr("Export"), file, &[ext], move |app, p| { + params["path"] = json!(p.to_string_lossy()); + if app.run("file.export", params).is_some() { + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + } + }); } /// The file name a one-off export of `ids` suggests: the first layer's name (or `fallback`) with diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..44b94ae2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -66,6 +66,8 @@ pub mod windows; #[cfg(test)] mod a11y_tests; #[cfg(test)] +mod async_dialog_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_drag_tests; @@ -155,7 +157,11 @@ mod windows_qa_tests; mod windows_tests; use std::collections::BTreeSet; +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Wake, Waker}; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -165,15 +171,15 @@ pub use control::{ControlReply, ControlRequest}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { - fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option { - None + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) } - fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { - None + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { + Box::pin(std::future::ready(None)) } /// A folder to write several files into. - fn pick_folder(&mut self, _title: &str) -> Option { - None + fn pick_folder(&mut self, _title: &str) -> DialogFuture { + Box::pin(std::future::ready(None)) } fn clipboard_set_text(&mut self, _s: &str) {} fn clipboard_get_text(&mut self) -> Option { @@ -209,6 +215,20 @@ pub trait Services { } } +pub type DialogFuture = Pin>>>; +type DialogDone = Box; + +struct RepaintWaker(egui::Context); +impl Wake for RepaintWaker { + fn wake(self: Arc) { + self.0.request_repaint(); + } + + fn wake_by_ref(self: &Arc) { + self.0.request_repaint(); + } +} + /// No-op services (tests, headless screenshots). pub struct NoServices; impl Services for NoServices {} @@ -503,6 +523,10 @@ pub struct FigApp { window_requests: Vec, /// Commands waiting for a server ([`FigApp::run_background`]) and what to do with their results. waiting: Vec<(String, Done)>, + /// Native dialogs complete asynchronously so their modal event loop never re-enters winit. + pending_dialog: Option<(DialogFuture, DialogDone)>, + /// The latest UI context schedules the first poll and wakes the app when a dialog completes. + ui_context: Option, } /// What to do with the result of a command run in the background. @@ -530,6 +554,8 @@ impl FigApp { windows: windows::Windows::default(), window_requests: Vec::new(), waiting: Vec::new(), + pending_dialog: None, + ui_context: None, } } @@ -790,12 +816,12 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) { + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"], |app, p| { let ps = p.to_string_lossy().to_string(); - if self.run("file.saveAs", json!({"path": ps})).is_some() { - self.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); + if app.run("file.saveAs", json!({"path": ps})).is_some() { + app.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); } - } + }); } pub fn save(&mut self) { @@ -811,14 +837,12 @@ impl FigApp { } pub fn open_dialog(&mut self) { - if let Some(p) = - self.services.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) - { + self.pick_open(i18n::tr("Open"), &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], |app, p| { let ps = p.to_string_lossy().to_string(); - if self.run("file.open", json!({"path": ps})).is_some() { - self.ui.home = false; + if app.run("file.open", json!({"path": ps})).is_some() { + app.ui.home = false; } - } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -827,9 +851,44 @@ impl FigApp { } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; - if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) { + let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; + self.pick_open(title, exts, move |app, p| { let ps = p.to_string_lossy().to_string(); - self.run("file.import", json!({"path": ps})); + app.run("file.import", json!({"path": ps})); + }); + } + + pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_open(title, exts), done); + } + + pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_save(title, name, exts), done); + } + + pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_folder(title), done); + } + + fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + if self.pending_dialog.is_some() { + return; + } + self.pending_dialog = Some((start(self.services.as_mut()), Box::new(done))); + if let Some(ctx) = &self.ui_context { + ctx.request_repaint(); + } + } + + fn poll_dialog(&mut self, ctx: &egui::Context) { + let Some((future, _)) = &mut self.pending_dialog else { return }; + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut task = TaskContext::from_waker(&waker); + let Poll::Ready(path) = future.as_mut().poll(&mut task) else { return }; + if let Some((_, done)) = self.pending_dialog.take() + && let Some(path) = path + { + done(self, path); } } @@ -926,8 +985,10 @@ impl FigApp { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); + self.ui_context = Some(ctx.clone()); self.frame_stats.record(&ctx); let first = !self.fonts_installed; + self.poll_dialog(&ctx); self.logic(&ctx); self.autosave_tick(&ctx); if first { diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 0d4da40c..481b446c 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -124,10 +124,11 @@ fn run(app: &mut FigApp, cmd: &str, params: Value) { /// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`. pub fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import plugin from manifest"), &["json"]) else { return }; - if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { - app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); - } + app.pick_open(tr("Import plugin from manifest"), &["json"], |app, p| { + if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { + app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); + } + }); } /// The installed plugins at the top of the Plugins menu: a plugin with one command is an item, one diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a5..e7711e12 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -205,24 +205,28 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { /// Export this file's collections through a save dialog. fn export_dialog(app: &mut FigApp, format: &str) { let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}")); - let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return }; - if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { - let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); - app.toast(trf("Exported {file}", &[("file", &file)])); - } + let format = format.to_owned(); + let ext = format.clone(); + app.pick_save(tr("Export variables"), &name, &[&ext], move |app, p| { + if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() { + let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default(); + app.toast(trf("Exported {file}", &[("file", &file)])); + } + }); } /// Import a variables JSON picked in an open dialog. fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open(tr("Import variables"), &["json"]) else { return }; - let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; - let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); - let skipped = r["warnings"].as_array().map_or(0, Vec::len); - let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); - if skipped > 0 { - msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); - } - app.toast(msg); + app.pick_open(tr("Import variables"), &["json"], |app, p| { + let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return }; + let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0)); + let skipped = r["warnings"].as_array().map_or(0, Vec::len); + let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]); + if skipped > 0 { + msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[])); + } + app.toast(msg); + }); } /// Extend library `lib`'s collection `c` and show the new collection.