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:
1 parent
f425089983
commit
37547f8eba
18 files changed
+564
-137
No files matched your search
@@ -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
@@ -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;
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user