* claim: #1801 * Insert SVG: the file picker opens on SVG files (#1801) Every Open went through one host picker whose filters start with designs, CAD files and meshes, so Insert SVG (and Insert DXF) in a sketch opened on STEP, IGES and meshes, and the SVG file had to be found under another filter. The host's pick_open now takes the extensions the command reads and offers them as the picker's first, default filter. Insert SVG and Insert DXF pass their type; every other Open passes none and keeps the list it had. The browser host ignores them (it shows its own file panel). --------- Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
345 lines
14 KiB
Rust
345 lines
14 KiB
Rust
//! 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) {
|
|
self.pick_open_for(&[], complete);
|
|
}
|
|
|
|
/// Open a file of this type: the picker offers these extensions first (#1801).
|
|
pub(crate) fn pick_open_for(&mut self, extensions: &[&str], 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(extensions));
|
|
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));
|
|
}
|
|
|
|
/// Ask for a program (a slicer). Without a host dialog, the answer is a cancel.
|
|
pub(crate) fn pick_program(&mut self, complete: impl FnOnce(&mut SolveApp, Option<String>) + 'static) {
|
|
if self.file_picker_busy() {
|
|
return;
|
|
}
|
|
let future = self.services.pick_program.as_ref().map(|pick| pick());
|
|
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, extensions: &[&str]) {
|
|
let command = command.to_string();
|
|
self.pick_open_for(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.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));
|
|
}
|
|
|
|
/// #1801: Insert SVG and Insert DXF ask the host for their own file type first; other
|
|
/// opens keep the default list.
|
|
#[test]
|
|
fn sketch_inserts_ask_for_their_file_type() {
|
|
let mut h = crate::scenario::Harness::new();
|
|
let asked: Rc<RefCell<Vec<Vec<String>>>> = Rc::default();
|
|
let log = asked.clone();
|
|
h.app.services.pick_open = Some(Box::new(move |extensions| {
|
|
log.borrow_mut().push(extensions.iter().map(|e| e.to_string()).collect());
|
|
Box::pin(std::future::ready(None))
|
|
}));
|
|
h.app.run("sketch.create", json!({"plane": "XY"})).unwrap();
|
|
for (command, want) in [("sketch.insert_svg", vec!["svg"]), ("sketch.insert_dxf", vec!["dxf"]), ("file.insert_step", vec![])] {
|
|
h.app.start(command);
|
|
tick(&mut h.app, &h.ctx);
|
|
assert_eq!(asked.borrow().last().cloned(), Some(want.iter().map(|e| e.to_string()).collect::<Vec<_>>()), "{command}");
|
|
}
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|