Keep Open and Save asynchronous to prevent macOS resize crashes (#1127)

Open and Save use parented AsyncFileDialog sheets; their answers run on the UI thread in a later frame, so resizing the window while a picker is open no longer re-enters winit and aborts. Editors, tab changes, Close/Quit and model-changing control requests wait for the answer; the local library Open and Export in the Appearance dialog use the same flow. Fixes #1088.
This commit is contained in:
Karl Von Richter authored and GitHub committed 2026-10-14 13:41:29 -04:00
1 parent f425089983
commit 37547f8eba
18 files changed
+564 -137

No files matched your search

+8 -2
View File
@@ -73,6 +73,12 @@ mod web {
if self.files.ctx.borrow().is_none() {
*self.files.ctx.borrow_mut() = Some(ctx.clone());
}
// A ready picker answer is consumed in app.logic later in this frame. Keep
// incoming files queued until then so they cannot replace its target design.
if self.app.file_picker_busy() {
ctx.request_repaint();
return;
}
files::flush(&self.files);
for (text, error) in self.files.messages.borrow_mut().drain(..) {
self.app.set_status(text, error);
@@ -434,12 +440,12 @@ mod web {
// Open shows the browser's files (and the computer's, from there).
pick_open: Some(Box::new(move || {
panel.panel.set(true);
None
Box::pin(std::future::ready(None))
})),
// Designs are kept in the browser; everything else is downloaded.
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let design = exts.contains(&solidcraft_engine::io::DESIGN_EXT);
Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
Box::pin(std::future::ready(Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))))
})),
..Default::default()
};
+17 -11
View File
@@ -50,12 +50,14 @@ impl eframe::App for App {
solidcraft_ui_egui::documents::request_quit(&mut self.solve);
}
#[cfg(target_os = "macos")]
for f in open_documents::take() {
for f in if self.solve.file_picker_busy() { Vec::new() } else { open_documents::take() } {
self.solve.open_path_async(&f);
}
self.solve.logic(ctx);
#[cfg(any(target_os = "windows", target_os = "linux"))]
self.space_mouse.tick(&mut self.solve, ctx);
if !self.solve.file_picker_busy() {
self.space_mouse.tick(&mut self.solve, ctx);
}
if self.solve.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -81,10 +83,13 @@ impl eframe::App for App {
}
}
fn services() -> Services {
fn services(cc: &eframe::CreationContext<'_>) -> Services {
let parent = rfd::AsyncFileDialog::new().set_parent(cc);
let open_parent = parent.clone();
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
pick_open: Some(Box::new(move || {
let future = open_parent
.clone()
.add_filter(
"Designs, STEP, IGES and meshes",
&[
@@ -111,11 +116,11 @@ fn services() -> Services {
.add_filter("Mesh (3MF, STL)", &["3mf", "3MF", "stl", "STL"])
.add_filter("Sketch drawings (DXF, SVG)", &["dxf", "DXF", "svg", "SVG"])
.add_filter("All files", &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
.pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
let dialog = rfd::FileDialog::new().set_file_name(name);
pick_save: Some(Box::new(move |name: &str, exts: &[&str]| {
let dialog = parent.clone().set_file_name(name);
let dialog = if exts == [io::DESIGN_EXT, "json"] {
if name.ends_with(".json") {
dialog.add_filter("Plain JSON design", &["json"]).add_filter("SolidCraft container", &[io::DESIGN_EXT])
@@ -125,7 +130,8 @@ fn services() -> Services {
} else {
dialog.add_filter("File", exts)
};
dialog.save_file().map(|p| p.to_string_lossy().to_string())
let future = dialog.save_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
graphics_path: graphics_path().map(|p| p.to_string_lossy().to_string()),
http_get: Some(std::sync::Arc::new(http_get)),
@@ -422,7 +428,7 @@ fn main() -> eframe::Result {
Box::new(move |cc| {
created_in_app.store(true, std::sync::atomic::Ordering::SeqCst);
solidcraft_ui_egui::fonts::setup_fonts(&cc.egui_ctx);
let mut app = SolveApp::new(Session::default(), services());
let mut app = SolveApp::new(Session::default(), services(cc));
app.custom_titlebar = CUSTOM_TITLEBAR;
app.integrated_titlebar = cfg!(target_os = "macos");
app.cpu_meshing = safe_mode;
+3 -10
View File
@@ -862,10 +862,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
"ui.export" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"]))
{
let _ = app.run("file.export", json!({ "path": path, "bodies": bodies }));
}
app.pick_save_command(&format!("{name}.step"), &["step", "stp", "igs", "stl", "3mf", "obj"], "file.export", json!({"bodies": bodies}));
}
"ui.findBrowser" => {
if let Some(b) = p.get("body").and_then(Value::as_str) {
@@ -1033,16 +1030,12 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) {
}
"ui.saveMesh" => {
let name = bodies.first().cloned().unwrap_or_else(|| app.session.doc.name.clone());
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.stl"), &["stl"])) {
let _ = app.run("file.save_mesh", json!({ "path": path, "bodies": bodies }));
}
app.pick_save_command(&format!("{name}.stl"), &["stl"], "file.save_mesh", json!({"bodies": bodies}));
}
"ui.exportDxf" => {
if let Some(id) = id_of(&p, "sketch") {
let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&format!("{name}.dxf"), &["dxf"])) {
let _ = app.run("sketch.export_dxf", json!({ "sketch": id, "path": path }));
}
app.pick_save_command(&format!("{name}.dxf"), &["dxf"], "sketch.export_dxf", json!({"sketch": id}));
}
}
"ui.extrudeSketch" => {
+1
View File
@@ -221,6 +221,7 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
"renderer": if app.viewport.gpu.is_some() { "gpu" } else { "cpu" },
"frame_ms": app.frame_ms,
"file_busy": app.file_load.busy(),
"file_picker_busy": app.file_picker_busy(),
"build_ms": app.viewport.build_ms,
"preview": {"active": app.preview.active, "busy": app.preview.busy, "error": app.preview.error, "ms": app.preview.ms, "replaced": app.preview.replaced},
"home": app.home.open,
+48 -22
View File
@@ -23,6 +23,33 @@ fn display_order(mut catalog: Catalog) -> Catalog {
}
#[cfg(not(target_arch = "wasm32"))]
/// Runs `f` on the open Appearance dialog's state, once a file picker has answered.
fn with_state(app: &mut SolveApp, f: impl FnOnce(&mut SolveApp, &mut Ap)) {
let Some(mut dialog) = app.dialog.take() else { return };
if let crate::dialogs::Kind::Appearance(k) = &mut dialog.kind {
f(app, k);
}
if app.dialog.is_none() {
app.dialog = Some(dialog);
}
}
/// Exports the design's library to `path` and shows it as the local library.
fn export(app: &mut SolveApp, k: &mut Ap, path: String, overwrite: bool) {
k.note = Some(match app.run("library.export", json!({"path":path,"overwrite":overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
format!("Saved {count} reusable definitions. The design is unchanged.")
}
Err(error) => error,
});
}
fn load(app: &mut SolveApp, library: &mut LocalLibrary) -> Result<String, String> {
let result = app.run("library.inspect", json!({"path":library.path}))?;
let catalog: Catalog = serde_json::from_value(result.get("catalog").cloned().ok_or("Library response is missing")?).map_err(|e| e.to_string())?;
@@ -47,23 +74,17 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelInpu
let save = ui.button(crate::i18n::tr_label("Export design library..."));
crate::scenario::publish_handle("library:export", save.rect.center());
if save.clicked() {
let chosen = match app.services.pick_save.as_ref() {
Some(pick) => pick("my-library.solidcraft-library.json", &["json"]),
None => (!k.local.path.is_empty()).then(|| k.local.path.clone()),
};
if let Some(path) = chosen {
k.note = Some(match app.run("library.export", json!({"path":path,"overwrite":k.local.overwrite})) {
Ok(result) => {
let parsed = result.get("catalog").cloned().and_then(|v| serde_json::from_value::<Catalog>(v).ok()).map(display_order);
let count = parsed.as_ref().map_or(0, |c| c.entries.len());
k.local.path = path;
k.local.catalog = parsed;
k.local.selected = None;
k.local.search.clear();
format!("Saved {count} reusable definitions. The design is unchanged.")
let overwrite = k.local.overwrite;
if app.services.pick_save.is_some() {
// The picker answers in a later frame (#1088); the export runs then.
app.pick_save("my-library.solidcraft-library.json", &["json"], move |app, path| {
if let Some(path) = path {
with_state(app, |app, k| export(app, k, path, overwrite));
}
Err(error) => error,
});
} else if !k.local.path.is_empty() {
let path = k.local.path.clone();
export(app, k, path, overwrite);
}
}
ui.label(
@@ -84,13 +105,18 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
k.local.selected = None;
}
ui.horizontal(|ui| {
if ui.button(crate::i18n::tr_label("Open library...")).clicked()
&& let Some(path) = app.services.pick_open.as_ref().and_then(|pick| pick())
{
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
if ui.button(crate::i18n::tr_label("Open library...")).clicked() {
// The picker answers in a later frame (#1088); the library loads then.
app.pick_open(|app, path| {
if let Some(path) = path {
with_state(app, |app, k| {
k.local.path = path;
k.local.catalog = None;
k.local.selected = None;
k.note = Some(load(app, &mut k.local).unwrap_or_else(|error| error));
});
}
});
}
let load_button = ui.add_enabled(!k.local.path.is_empty(), egui::Button::new(crate::i18n::tr_label("Load library")));
crate::scenario::publish_handle("library:load", load_button.rect.center());
+19 -9
View File
@@ -435,17 +435,27 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
fn export_flat(app: &mut SolveApp, body: &str, extension: &str) -> String {
let name = format!("{}-{body}.{extension}", app.session.doc.name);
let Some(path) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[extension])) else {
return "Export cancelled".into();
};
let body = body.to_string();
let command = if extension == "csv" { "sheet.export_bend_table" } else { "sheet.export_dxf" };
match app.run(command, json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
app.pick_save(&name, &[extension], move |app, path| {
let message = if let Some(path) = path {
match app.run(command, json!({"body": body, "path": path})) {
Ok(_) => {
app.set_status(format!("Exported {path}"), false);
format!("Saved {path}")
}
Err(e) => e,
}
} else {
"Export cancelled".into()
};
if let Some(d) = app.dialog.as_mut()
&& let Kind::Sheet(Sm::FlatPattern { status, .. }) = &mut d.kind
{
*status = Some(message);
}
Err(e) => e,
}
});
"Choose an export file…".into()
}
// ---- commands ----
+10 -6
View File
@@ -116,10 +116,12 @@ fn flat_exports_use_the_save_picker() {
let captured = picks.clone();
app.services.pick_save = Some(Box::new(move |name, extensions| {
captured.lock().unwrap().push((name.to_string(), extensions.iter().map(|x| x.to_string()).collect::<Vec<_>>()));
None
Box::pin(std::future::ready(None))
}));
assert_eq!(export_flat(&mut app, "Base", "csv"), "Export cancelled");
assert_eq!(export_flat(&mut app, "Base", "dxf"), "Export cancelled");
assert_eq!(export_flat(&mut app, "Base", "csv"), "Choose an export file…");
crate::file_picker::tick(&mut app, &egui::Context::default());
assert_eq!(export_flat(&mut app, "Base", "dxf"), "Choose an export file…");
crate::file_picker::tick(&mut app, &egui::Context::default());
let picks = picks.lock().unwrap();
assert!(picks[0].0.ends_with("-Base.csv"));
assert_eq!(picks[0].1, vec!["csv"]);
@@ -275,7 +277,7 @@ fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
assert!(name.ends_with("-Body1.dxf"));
assert_eq!(extensions, &["dxf"]);
Some(chosen.clone())
Box::pin(std::future::ready(Some(chosen.clone())))
}));
h.app.start("sheet.flat_pattern");
h.settle();
@@ -300,10 +302,12 @@ fn bend_table_picker_writes_the_current_body() {
let chosen = output.clone();
app.services.pick_save = Some(Box::new(move |_, extensions| {
assert_eq!(extensions, &["csv"]);
Some(chosen.clone())
Box::pin(std::future::ready(Some(chosen.clone())))
}));
let undo = app.session.undo.len();
assert_eq!(export_flat(&mut app, &body, "csv"), format!("Saved {output}"));
assert_eq!(export_flat(&mut app, &body, "csv"), "Choose an export file…");
crate::file_picker::tick(&mut app, &egui::Context::default());
assert_eq!(app.status.as_ref().map(|s| &s.0), Some(&format!("Exported {output}")));
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(contents.lines().count(), 2);
+40 -10
View File
@@ -50,6 +50,8 @@ pub struct Documents {
pub quitting: Option<Vec<usize>>,
/// The "save changes?" button the arrow keys have picked; Enter gives it.
pub choice: Answer,
/// Save As is answering a Close/Quit prompt.
saving: bool,
}
impl Documents {
@@ -111,6 +113,9 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
/// Make tab `i` the active design.
pub fn switch(app: &mut SolveApp, i: usize) {
if app.file_picker_busy() {
return;
}
app.home.open = false;
if i == app.docs.active || i >= app.docs.slots.len() {
return;
@@ -167,6 +172,9 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
/// A new, empty design in its own tab.
pub fn new_design(app: &mut SolveApp) {
if app.file_picker_busy() {
return;
}
app.home.open = false;
if active_is_blank(app) {
return;
@@ -188,6 +196,9 @@ pub fn prepare_open(app: &mut SolveApp) {
/// Close tab `i`: asks first when it has unsaved changes (`force`: close anyway).
pub fn close(app: &mut SolveApp, i: usize, force: bool) {
if app.file_picker_busy() {
return;
}
let n = app.docs.count();
if i >= n {
return;
@@ -227,6 +238,9 @@ pub fn close(app: &mut SolveApp, i: usize, force: bool) {
/// design with unsaved changes, then sets `app.quit_requested` for the host to close the window.
/// Cancel, or a Save that didn't save, stays open.
pub fn request_quit(app: &mut SolveApp) {
if app.file_picker_busy() {
return;
}
if app.docs.quitting.is_none() {
app.docs.quitting = Some(Vec::new());
}
@@ -536,21 +550,18 @@ pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) {
/// Act on the "save changes?" answer (also the control channel's `ui.documents {answer}`).
pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
if app.file_picker_busy() {
return;
}
let Some(i) = app.docs.closing else { return };
app.docs.choice = Answer::Save;
match answer {
Answer::Save => {
switch(app, i);
app.docs.saving = true;
crate::toolbar::save(app);
let saved = !app.session.is_dirty();
app.docs.closing = None;
if app.docs.quitting.is_some() {
// Quit only once it is saved; a cancelled picker or a failed save stays open.
if !saved {
app.docs.quitting = None;
}
} else if saved {
close(app, app.docs.active, true);
if !app.file_picker_busy() {
saved_for_close(app);
}
}
Answer::DontSave => {
@@ -569,6 +580,23 @@ pub fn answer_prompt(app: &mut SolveApp, answer: Answer) {
quit_step(app);
}
/// Resume Close/Quit only after Save As has answered and the write succeeded.
pub(crate) fn saved_for_close(app: &mut SolveApp) {
if !std::mem::take(&mut app.docs.saving) || app.docs.closing.is_none() {
return;
}
let saved = !app.session.is_dirty();
app.docs.closing = None;
if app.docs.quitting.is_some() {
if !saved {
app.docs.quitting = None;
}
} else if saved {
close(app, app.docs.active, true);
}
quit_step(app);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -723,9 +751,11 @@ mod tests {
let file = std::env::temp_dir().join(format!("solidcraft-quit-saveas-{}.solidcraft", std::process::id()));
let _ = std::fs::remove_file(&file);
let to = file.to_string_lossy().to_string();
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Some(to.clone())));
a.services.pick_save = Some(Box::new(move |_: &str, _: &[&str]| Box::pin(std::future::ready(Some(to.clone())))));
request_quit(&mut a);
answer_prompt(&mut a, Answer::Save);
assert!(!a.quit_requested && a.file_picker_busy());
crate::file_picker::tick(&mut a, &egui::Context::default());
assert!(a.quit_requested && file.exists());
let _ = std::fs::remove_file(&file);
}
+311
View File
@@ -0,0 +1,311 @@
//! Native pickers must return to the event loop while the user chooses a file (#1088).
//! The host opens an async sheet; its continuation runs on the UI thread once it answers.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll, Wake, Waker};
use serde_json::{Value, json};
use crate::SolveApp;
/// A host picker answer: a selected path, or `None` when cancelled.
pub type FilePickerFuture = Pin<Box<dyn Future<Output = Option<String>>>>;
type Completion = Box<dyn FnOnce(&mut SolveApp, Option<String>)>;
pub(crate) struct Pending {
future: FilePickerFuture,
complete: Completion,
}
struct Repaint(egui::Context);
impl Wake for Repaint {
fn wake(self: Arc<Self>) {
self.0.request_repaint();
}
}
impl SolveApp {
/// Keep the target design and editors unchanged until the picker answers.
pub fn file_picker_busy(&self) -> bool {
self.file_picker.is_some()
}
pub(crate) fn pick_open(&mut self, complete: impl FnOnce(&mut SolveApp, Option<String>) + 'static) {
if self.file_picker_busy() {
return;
}
let future = self.services.pick_open.as_ref().map(|pick| pick());
self.begin_picker(future, Box::new(complete));
}
pub(crate) fn pick_save(&mut self, name: &str, extensions: &[&str], complete: impl FnOnce(&mut SolveApp, Option<String>) + 'static) {
if self.file_picker_busy() {
return;
}
let future = self.services.pick_save.as_ref().map(|pick| pick(name, extensions));
self.begin_picker(future, Box::new(complete));
}
fn begin_picker(&mut self, future: Option<FilePickerFuture>, complete: Completion) {
let Some(future) = future else {
complete(self, None);
return;
};
self.file_picker = Some(Pending { future, complete });
}
pub(crate) fn pick_open_command(&mut self, command: &str, params: Value) {
let command = command.to_string();
self.pick_open(move |app, path| {
if let Some(path) = path {
let mut params = params;
params["path"] = json!(path);
let result = app.run(&command, params);
if command == "parameters.import"
&& let Some(dialog) = app.params.as_mut()
{
dialog.error = result.err();
}
}
});
}
pub(crate) fn pick_save_command(&mut self, name: &str, extensions: &[&str], command: &str, params: Value) {
let command = command.to_string();
self.pick_save(name, extensions, move |app, path| {
if let Some(path) = path {
let mut params = params;
params["path"] = json!(path);
let result = app.run(&command, params);
if command == "parameters.export"
&& let Some(dialog) = app.params.as_mut()
{
dialog.error = result.err();
}
}
});
}
}
pub(crate) fn tick(app: &mut SolveApp, ctx: &egui::Context) {
let Some(pending) = app.file_picker.as_mut() else { return };
let waker = Waker::from(Arc::new(Repaint(ctx.clone())));
let Poll::Ready(path) = pending.future.as_mut().poll(&mut Context::from_waker(&waker)) else {
// A fallback for native backends that do not wake reliably. Resizing still draws
// the ordinary UI in every frame.
ctx.request_repaint_after(std::time::Duration::from_millis(50));
return;
};
if let Some(pending) = app.file_picker.take() {
(pending.complete)(app, path);
// Hosts can flush writes or restore native input on the following frame.
ctx.request_repaint();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[derive(Default)]
struct Answer {
path: RefCell<Option<Option<String>>>,
polls: Cell<usize>,
waker: RefCell<Option<Waker>>,
}
struct Delayed(Rc<Answer>);
impl Future for Delayed {
type Output = Option<String>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.0.polls.set(self.0.polls.get() + 1);
*self.0.waker.borrow_mut() = Some(cx.waker().clone());
self.0.path.borrow_mut().take().map_or(Poll::Pending, Poll::Ready)
}
}
fn picker(app: &mut SolveApp, save: bool) -> Rc<Answer> {
let answer = Rc::new(Answer::default());
let captured = answer.clone();
if save {
app.services.pick_save = Some(Box::new(move |_, _| Box::pin(Delayed(captured.clone()))));
} else {
app.services.pick_open = Some(Box::new(move || Box::pin(Delayed(captured.clone()))));
}
answer
}
fn finish(app: &mut SolveApp, answer: &Answer, path: Option<String>) {
*answer.path.borrow_mut() = Some(path);
if let Some(waker) = answer.waker.borrow_mut().take() {
waker.wake();
}
tick(app, &egui::Context::default());
assert!(!app.file_picker_busy());
}
/// #1088: Open and Save stay pending through real UI frames at changing window sizes.
/// Model input and tab changes wait, while the event loop keeps painting.
#[test]
fn pending_open_and_save_allow_resize_frames_without_changing_the_design() {
for save in [false, true] {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
let before = h.app.session.doc.to_json();
let answer = picker(&mut h.app, save);
let completed = Rc::new(Cell::new(0));
let count = completed.clone();
let callback = move |_: &mut SolveApp, path: Option<String>| {
assert!(path.is_none());
count.set(count.get() + 1);
};
if save {
h.app.pick_save("part.solidcraft", &["solidcraft"], callback);
} else {
h.app.pick_open(callback);
}
assert_eq!(answer.polls.get(), 0, "the UI action returns before polling");
for (width, height) in [(1200, 800), (1600, 1000), (960, 600), (1400, 900)] {
h.call("ui.resize", json!({"width":width,"height":height}));
h.frames(2);
assert!(h.app.file_picker_busy());
assert_eq!(completed.get(), 0);
assert_eq!(h.ctx.content_rect().width(), width as f32);
}
assert!(answer.polls.get() >= 8);
crate::documents::new_design(&mut h.app);
crate::documents::close(&mut h.app, 0, true);
crate::documents::request_quit(&mut h.app);
h.app.start("solid.box");
assert!(h.app.run("solid.box", json!({})).is_err());
h.step(&json!({"key":"N", "cmd":true})).unwrap();
assert_eq!(h.app.docs.count(), 1);
assert!(!h.app.quit_requested);
assert_eq!(h.app.session.doc.to_json(), before);
// Duplicate requests cannot replace the pending operation.
h.app.pick_open(|_, _| panic!("duplicate picker"));
finish(&mut h.app, &answer, None);
tick(&mut h.app, &h.ctx);
assert_eq!(completed.get(), 1);
assert_eq!(h.app.session.doc.to_json(), before);
}
}
#[test]
fn save_as_waits_for_its_path_and_saves_only_on_completion() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
let answer = picker(&mut h.app, true);
let path = h.dir.join("chosen.solidcraft");
crate::toolbar::save(&mut h.app);
h.frames(3);
assert!(h.app.session.is_dirty() && !path.exists());
finish(&mut h.app, &answer, Some(path.to_string_lossy().into_owned()));
assert!(path.is_file() && !h.app.session.is_dirty());
assert_eq!(h.app.session.path.as_deref(), path.to_str());
}
#[test]
fn close_and_quit_wait_for_save_as_and_cancel_or_failure_keeps_changes() {
for quit in [false, true] {
for outcome in ["save", "cancel", "fail"] {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
let before = h.app.session.doc.to_json();
let answer = picker(&mut h.app, true);
if quit {
crate::documents::request_quit(&mut h.app);
} else {
crate::documents::close(&mut h.app, 0, false);
}
crate::documents::answer_prompt(&mut h.app, crate::documents::Answer::Save);
h.frames(3);
assert!(h.app.file_picker_busy() && !h.app.quit_requested);
assert_eq!(h.app.docs.closing, Some(0));
let path = match outcome {
"save" => Some(h.dir.join("chosen.solidcraft")),
"fail" => Some(h.dir.join("missing/chosen.solidcraft")),
_ => None,
};
finish(&mut h.app, &answer, path.map(|p| p.to_string_lossy().into_owned()));
if outcome == "save" {
assert_eq!(h.app.quit_requested, quit);
assert!(!h.app.session.is_dirty());
if !quit {
assert!(h.app.home.open && h.app.session.doc.features.is_empty());
}
} else {
assert!(!h.app.quit_requested && h.app.session.is_dirty());
assert!(h.app.docs.quitting.is_none() && h.app.docs.closing.is_none());
assert_eq!(h.app.session.doc.to_json(), before);
}
}
}
}
#[test]
fn quit_saves_each_untitled_tab_after_its_own_dialog_answers() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
crate::documents::new_design(&mut h.app);
h.app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap();
crate::documents::request_quit(&mut h.app);
for i in 0..2 {
let answer = picker(&mut h.app, true);
crate::documents::answer_prompt(&mut h.app, crate::documents::Answer::Save);
h.frames(2);
assert!(!h.app.quit_requested);
finish(&mut h.app, &answer, Some(h.dir.join(format!("tab{i}.solidcraft")).to_string_lossy().into_owned()));
assert_eq!(h.app.quit_requested, i == 1);
}
assert!(!crate::documents::any_dirty(&h.app));
}
#[test]
fn picker_holds_engine_requests_but_answers_window_resize_requests() {
let mut h = crate::scenario::Harness::new();
let answer = picker(&mut h.app, false);
h.app.pick_open(|_, _| {});
let (tx, rx) = std::sync::mpsc::channel();
h.app.control_rx = Some(rx);
let (command, reply) =
crate::control::ControlRequest::new("engine.execute", json!({"command":"solid.box","params":{"length":10,"width":10,"height":10}}));
let (resize, resized) = crate::control::ControlRequest::new("ui.resize", json!({"width":1200,"height":800}));
let (home, shown) = crate::control::ControlRequest::new("ui.home", json!({"open":true}));
tx.send(command).unwrap();
tx.send(resize).unwrap();
tx.send(home).unwrap();
h.frames(2);
assert!(reply.try_recv().is_err());
assert_eq!(resized.try_recv().unwrap()["ok"], true);
assert!(shown.try_recv().is_err());
assert_eq!(h.call("ui.inspect", json!({}))["result"]["file_picker_busy"], true);
assert!(h.app.session.doc.features.is_empty());
finish(&mut h.app, &answer, None);
h.frames(2);
let result = reply.try_recv().unwrap();
assert_eq!(result["ok"], true, "{result}");
assert_eq!(shown.try_recv().unwrap()["ok"], true);
assert_eq!(h.app.session.doc.features.len(), 1);
}
#[test]
fn canvas_picker_starts_placement_only_after_a_file_is_selected() {
let mut h = crate::scenario::Harness::new();
let answer = picker(&mut h.app, false);
h.app.start("canvas.insert");
h.frames(2);
assert!(h.app.tool.is_none());
finish(&mut h.app, &answer, Some("selected-image.png".into()));
assert_eq!(h.app.tool.as_ref().map(|tool| tool.cmd), Some("canvas.insert"));
let answer = picker(&mut h.app, false);
h.app.start("canvas.insert");
finish(&mut h.app, &answer, None);
assert!(h.app.tool.is_none(), "cancelling does not resume the old placement");
}
}
+6 -4
View File
@@ -395,10 +395,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
ui.add_space(10.0);
if big(ui, "open", "Open…", false) {
action = Some(Box::new(|app: &mut SolveApp| {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.home.open = false;
app.open_path_async(&p);
}
app.pick_open(|app, path| {
if let Some(path) = path {
app.home.open = false;
app.open_path_async(&path);
}
});
}));
}
});
+16 -11
View File
@@ -20,7 +20,7 @@ pub(crate) fn cad_input_blocked() -> bool {
CAD_INPUT_BLOCKED.with(std::cell::Cell::get)
}
/// Draw CAD behind a report without delivering its input or focus to any model editor.
/// Draw CAD behind a report or native picker without delivering input or focus to editors.
/// Fresh focus history also prevents inline editors from committing on report focus loss.
pub(crate) struct CadInput {
ctx: egui::Context,
@@ -41,7 +41,9 @@ impl CadInput {
}
pub(crate) fn begin(app: &SolveApp, ctx: &egui::Context) -> Option<Self> {
app.issue_report.draft.as_ref()?;
if app.issue_report.draft.is_none() && app.file_picker.is_none() {
return None;
}
let mut raw = ctx.input(|i| i.raw.clone());
raw.events.clear();
raw.dropped_files.clear();
@@ -553,16 +555,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
draft.note = "Paste the image into the GitHub draft, or save the PNG if image copying is unavailable.".into();
}
if ui.add_enabled(draft.image.is_some() && app.services.pick_save.is_some(), egui::Button::new("Save screenshot…")).clicked()
&& let Some(image) = draft.image.as_ref()
&& let Some(path) =
app.services.pick_save.as_ref().and_then(|pick| pick(&format!("solidcraft-issue-{}.png", app.shot_token), &["png"]))
&& let Some(image) = draft.image.clone()
{
let result = crate::control::save_screenshot(image, Some(&path));
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
"Screenshot saved. Attach the PNG in the GitHub draft.".into()
} else {
format!("Could not save screenshot: {}", debug(&result.get("error"), 1000))
};
app.pick_save(&format!("solidcraft-issue-{}.png", app.shot_token), &["png"], move |app, path| {
let Some(path) = path else { return };
let result = crate::control::save_screenshot(&image, Some(&path));
if let Some(draft) = app.issue_report.draft.as_mut() {
draft.note = if result.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
"Screenshot saved. Attach the PNG in the GitHub draft.".into()
} else {
format!("Could not save screenshot: {}", debug(&result.get("error"), 1000))
};
}
});
}
if ui.add_enabled(draft.image.is_some(), egui::Button::new(crate::i18n::tr_label("Remove screenshot"))).clicked() {
draft.image = None;
+8 -5
View File
@@ -256,18 +256,21 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
Some((_, "reset_all")) => reset(app, None),
Some((_, "export")) => {
let text = crate::toolbar_custom::export(app).to_string();
if let Some(path) = app.services.pick_save.as_ref().and_then(|f| f("SolidCraft shortcuts.json", &["json"])) {
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
}
app.pick_save("SolidCraft shortcuts.json", &["json"], move |app, path| {
if let Some(path) = path {
app.keymap.message = std::fs::write(&path, text).err().map(|e| format!("export: {e}"));
}
});
}
Some((_, "import")) => {
if let Some(path) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.pick_open(|app, path| {
let Some(path) = path else { return };
let r = std::fs::read_to_string(&path)
.map_err(|e| e.to_string())
.and_then(|t| serde_json::from_str::<Value>(&t).map_err(|e| e.to_string()))
.and_then(|v| crate::toolbar_custom::import(app, &v));
app.keymap.message = r.err().map(|e| format!("import: {e}"));
}
});
}
Some((pair, "replace")) => {
if let Some((id, key)) = pair.split_once('\n') {
+28 -15
View File
@@ -75,6 +75,8 @@ mod expression_complete;
#[cfg(test)]
mod extrude_edit_tests;
mod file_load;
mod file_picker;
pub use file_picker::FilePickerFuture;
#[cfg(test)]
mod file_load_ui_tests;
#[cfg(test)]
@@ -211,8 +213,8 @@ impl Default for UiState {
/// Host services (file pickers) injected by the app so this crate stays portable.
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_open: Option<Box<dyn Fn() -> FilePickerFuture>>,
pub pick_save: Option<Box<dyn Fn(&str, &[&str]) -> FilePickerFuture>>,
pub show_in_folder: Option<Box<dyn Fn(&str)>>,
/// Where the host reads the anti-aliasing preference at start (a small JSON file).
pub graphics_path: Option<String>,
@@ -236,6 +238,7 @@ pub struct SolveApp {
/// restores it.
pub pre_sketch_marker: Option<Option<usize>>,
pub services: Services,
file_picker: Option<file_picker::Pending>,
pub viewport: viewport::ViewportState,
pub tool: Option<tools::Tool>,
pub dialog: Option<dialogs::Dialog>,
@@ -329,6 +332,7 @@ impl SolveApp {
pre_sketch_cam: None,
pre_sketch_marker: None,
services,
file_picker: None,
viewport: viewport::ViewportState::default(),
tool: None,
dialog: None,
@@ -497,7 +501,7 @@ impl SolveApp {
/// Run a command programmatically (JSON parameters, no dialogs).
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
if self.file_load.busy() {
if self.file_load.busy() || self.file_picker.is_some() {
return Err("a file operation is in progress".into());
}
let t0 = now_ms();
@@ -567,7 +571,7 @@ impl SolveApp {
/// that have them, otherwise run it with defaults.
pub fn start(&mut self, id: &str) {
// A file is loading: the design waits for it.
if self.file_load.busy() {
if self.file_load.busy() || self.file_picker.is_some() {
return;
}
// A disabled command starts nothing, however it was reached. The toolbar greys its
@@ -600,15 +604,15 @@ impl SolveApp {
self.tool = None;
self.home.open = false;
if matches!(id, "sketch.insert_dxf" | "sketch.insert_svg") {
if let Some(path) = self.services.pick_open.as_ref().and_then(|f| f()) {
let _ = self.run(id, json!({"path": path}));
}
self.pick_open_command(id, json!({}));
return;
}
if matches!(id, "file.insert_step" | "file.insert_mesh") {
if let Some(p) = self.services.pick_open.as_ref().and_then(|f| f()) {
self.insert_path_async(&p);
}
self.pick_open(|app, path| {
if let Some(path) = path {
app.insert_path_async(&path);
}
});
return;
}
if !sketch_tools::start_hook(self, id) {
@@ -1014,6 +1018,7 @@ impl SolveApp {
/// Per-frame logic before layout.
pub fn logic(&mut self, ctx: &egui::Context) {
file_picker::tick(self, ctx);
file_load::tick(self, ctx);
self.esc_handled = false;
prefs::apply(self, ctx);
@@ -1040,12 +1045,12 @@ impl SolveApp {
issue_report::tick(self, ctx);
self.collect_screenshots(ctx);
self.issue_screenshots(ctx);
if self.issue_report.draft.is_none() {
if self.issue_report.draft.is_none() && self.file_picker.is_none() {
toolbar::shortcuts(self, ctx);
}
recovery_ui::tick(self);
update::tick(self, ctx);
if self.issue_report.draft.is_none() {
if self.issue_report.draft.is_none() && self.file_picker.is_none() {
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let p = f.path().to_string_lossy().to_string();
if !p.is_empty() {
@@ -1095,10 +1100,15 @@ impl SolveApp {
issue_report::CadInput::block(self, ui, &mut report_input);
self.cad_ui(ui, &mut report_input);
drop(report_input);
issue_report::show(self, ui.ctx());
if self.file_picker.is_none() {
issue_report::show(self, ui.ctx());
}
if !self.home.open {
agent_cursor::paint(self, ui.ctx());
}
if self.file_picker_busy() {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(50));
}
self.frame_ms = now_ms() - t0;
}
@@ -1169,11 +1179,14 @@ impl SolveApp {
break;
}
// Held requests go first, in order, once the file is in.
let held = if self.file_load.busy() { None } else { self.control_held.pop_front() };
let held = if self.file_load.busy() || self.file_picker.is_some() { None } else { self.control_held.pop_front() };
let Some(req) = held.or_else(|| rx.try_recv().ok()) else { break };
// A file is loading: commands that change the design wait for it (the window,
// inspection and screenshots still answer).
if self.file_load.busy() && matches!(req.method.as_str(), "engine.execute" | "command" | "engine.script" | "engine.batch") {
if ((self.file_load.busy() || self.file_picker.is_some())
&& matches!(req.method.as_str(), "engine.execute" | "command" | "engine.script" | "engine.batch"))
|| (self.file_picker_busy() && matches!(req.method.as_str(), "ui.file" | "ui.home"))
{
if self.control_held.len() < MAX_HELD {
self.control_held.push_back(req);
} else {
+4 -8
View File
@@ -136,16 +136,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
ui.add(egui::TextEdit::singleline(&mut d.filter).hint_text(crate::i18n::tr_label("Filter")).desired_width(180.0));
ui.separator();
if ui.button("Import…").on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones")).clicked()
&& let Some(p) = app.services.pick_open.as_ref().and_then(|f| f())
{
d.error = app.run("parameters.import", json!({ "path": p })).err();
if ui.button("Import…").on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones")).clicked() {
app.pick_open_command("parameters.import", json!({}));
}
for (label, ext) in [("Export CSV…", "csv"), ("Export JSON…", "json")] {
if ui.button(label).clicked()
&& let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&format!("parameters.{ext}"), &[ext]))
{
d.error = app.run("parameters.export", json!({ "path": p, "format": ext })).err();
if ui.button(label).clicked() {
app.pick_save_command(&format!("parameters.{ext}"), &[ext], "parameters.export", json!({"format": ext}));
}
}
});
+3 -2
View File
@@ -181,9 +181,10 @@ impl Harness {
// Frames are laid out, never painted.
self.app.viewport.no_pixels = true;
let dir = self.dir.clone();
self.app.services.pick_save = Some(Box::new(move |name, _| Some(dir.join(name).to_string_lossy().into_owned())));
self.app.services.pick_save =
Some(Box::new(move |name, _| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned())))));
let next = self.next_file.clone();
self.app.services.pick_open = Some(Box::new(move || next.borrow_mut().take()));
self.app.services.pick_open = Some(Box::new(move || Box::pin(std::future::ready(next.borrow_mut().take()))));
}
/// Autosave into the scenario's recovery folder (as the desktop app does into the user's).
+9 -4
View File
@@ -165,10 +165,15 @@ pub fn start_hook(app: &mut SolveApp, id: &str) -> bool {
if !matches!(id, "canvas.insert" | "canvas.decal") {
return true;
}
let path = app.services.pick_open.as_ref().and_then(|f| f());
let ok = path.is_some();
IMAGE.with(|i| *i.borrow_mut() = path);
ok
let command = id.to_string();
app.pick_open(move |app, path| {
if let Some(path) = path {
IMAGE.with(|i| *i.borrow_mut() = Some(path));
app.last_command = solidcraft_engine::find_command(&command).map(|spec| (spec.id.into(), spec.label.into()));
app.tool = crate::tools::Tool::for_command(&command);
}
});
false
}
fn v3(v: &Value) -> Option<Vec3> {
+29 -18
View File
@@ -337,9 +337,11 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
close = true;
}
if item(ui, &format!("{}…", app.tr("ui.open")), "Ctrl+O") {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path_async(&p);
}
app.pick_open(|app, path| {
if let Some(path) = path {
app.open_path_async(&path);
}
});
close = true;
}
ui.menu_button(app.tr("ui.open_recent"), |ui| {
@@ -455,9 +457,12 @@ fn save_as(app: &mut SolveApp) {
fn save_as_with_extension(app: &mut SolveApp, extension: &str) {
let name = format!("{}.{extension}", app.session.doc.name);
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[solidcraft_engine::io::DESIGN_EXT, "json"])) {
let _ = app.run("file.save_as", json!({"path": p}));
}
app.pick_save(&name, &[solidcraft_engine::io::DESIGN_EXT, "json"], |app, path| {
if let Some(path) = path {
let _ = app.run("file.save_as", json!({"path": path}));
}
crate::documents::saved_for_close(app);
});
}
fn export(app: &mut SolveApp, ext: &str) {
@@ -467,14 +472,17 @@ fn export(app: &mut SolveApp, ext: &str) {
/// Export to a file with extension `ext`, in `format` when the extension alone doesn't name it.
fn export_as(app: &mut SolveApp, ext: &str, format: Option<&str>) {
let name = format!("{}.{ext}", app.session.doc.name);
let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name, &[ext])) else { return };
let params = match format {
Some(f) => json!({"path": p, "format": f}),
None => json!({"path": p}),
};
if app.run("file.export", params).is_ok() {
app.set_status(format!("Exported {p}"), false);
}
let format = format.map(str::to_string);
app.pick_save(&name, &[ext], move |app, path| {
let Some(path) = path else { return };
let mut params = json!({"path": path});
if let Some(format) = format {
params["format"] = json!(format);
}
if app.run("file.export", params).is_ok() {
app.set_status(format!("Exported {path}"), false);
}
});
}
pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) {
@@ -1152,9 +1160,11 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) {
Key::N => crate::documents::new_design(app),
Key::Comma => crate::prefs::open_at(app, 0),
Key::O => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
app.open_path_async(&p);
}
app.pick_open(|app, path| {
if let Some(path) = path {
app.open_path_async(&path);
}
});
}
_ if !shown => {}
Key::Z => drop(app.run("edit.undo", json!({}))),
@@ -1215,7 +1225,8 @@ mod header_report_tests {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":2,"width":3,"height":4})).unwrap();
let dir = h.dir.clone();
h.app.services.pick_save = Some(Box::new(move |name, _extensions| Some(dir.join(name).to_string_lossy().into_owned())));
h.app.services.pick_save =
Some(Box::new(move |name, _extensions| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned())))));
h.frames(3);
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
h.step(&json!({"widget":"Save As JSON…"})).unwrap();
+4
View File
@@ -71,6 +71,10 @@ MCP server sends its folders this way on every request ([mcp.md](mcp.md)). Witho
Commands never open dialogs when run this way. `solidcraft-cli commands` prints the same registry.
While an interactive native file picker is open, `ui.inspect` reports `file_picker_busy: true`.
Engine commands, `ui.file` and `ui.home` requests wait for its answer; window resizing,
inspection and screenshots remain available.
Every `sketch.*` command that changes a sketch adds `created: {points, curves, constraints,
dimensions}` to its result (and `wires` for 3D curves): the ids of what it added, in creation
order, next to whatever else the command returns (`curves`, `sides`, `corners`…). Sketch ids are