diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 96deafb4..2d0f83e6 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -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() }; diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 1d15a9ba..8609ddbe 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -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; diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index d8211c0b..2ef0c2ab 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -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" => { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c619da6e..e6f36e56 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs_library.rs b/crates/ui-egui/src/dialogs_library.rs index ab16f203..e5e49477 100644 --- a/crates/ui-egui/src/dialogs_library.rs +++ b/crates/ui-egui/src/dialogs_library.rs @@ -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::(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 { 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::(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()); diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 6118bfab..144113bd 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -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 ---- diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 5f8284e7..6de6beaf 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -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::>())); - 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); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index ebdec4d2..fb45f4a8 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -50,6 +50,8 @@ pub struct Documents { pub quitting: Option>, /// 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); } diff --git a/crates/ui-egui/src/file_picker.rs b/crates/ui-egui/src/file_picker.rs new file mode 100644 index 00000000..f57579e5 --- /dev/null +++ b/crates/ui-egui/src/file_picker.rs @@ -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>>>; +type Completion = Box)>; + +pub(crate) struct Pending { + future: FilePickerFuture, + complete: Completion, +} + +struct Repaint(egui::Context); + +impl Wake for Repaint { + fn wake(self: Arc) { + 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) + '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) + '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, 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>>, + polls: Cell, + waker: RefCell>, + } + + struct Delayed(Rc); + impl Future for Delayed { + type Output = Option; + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + 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 { + 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) { + *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| { + 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"); + } +} diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index fa9ffd48..e3858ea9 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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); + } + }); })); } }); diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 95fc8087..614cd00d 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -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 { - 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; diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 88b65ac9..ef81b5ad 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -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::(&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') { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 87bd8cab..5b18f1ab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 Option>>, - pub pick_save: Option Option>>, + pub pick_open: Option FilePickerFuture>>, + pub pick_save: Option FilePickerFuture>>, pub show_in_folder: Option>, /// Where the host reads the anti-aliasing preference at start (a small JSON file). pub graphics_path: Option, @@ -236,6 +238,7 @@ pub struct SolveApp { /// restores it. pub pre_sketch_marker: Option>, pub services: Services, + file_picker: Option, pub viewport: viewport::ViewportState, pub tool: Option, pub dialog: Option, @@ -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 { - 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 { diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index 69389624..ef1c8867 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -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})); } } }); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 6af116b3..927ee040 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -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). diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 40d2b148..8ba7f464 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -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 { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index f48251a1..5a65900b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -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(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 305e9b5c..20a5b95d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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