Files
vectorcraft/crates/ui-egui/src/tests_picks.rs
bflatasticandClaude Opus 5.5 37d4477362 Fix #592: file dialogs on Linux no longer stop the window answering
VectorCraft asks for a path where it needs one and goes on with it at
once, so its file dialogs ran on the UI thread. Windows and macOS dialogs
run the window's events meanwhile; on Linux nothing answered the Wayland
compositor's pings while a dialog was open, and Hyprland offered to kill
the window as not responding.

- ui-egui `picks`: every file dialog (open, save, folder, place) goes
  through one place. With the host's `start_pick` (desktop Linux) the
  dialog shows on a thread of its own; what asked (the command, the
  dialog's OK, a panel's or dialog's button) gives up for now and runs
  again with the picked path when the dialog answers. Another dialog
  waits for the open one.
- The desktop app passes its window as the dialogs' parent on Linux (the
  portal can then associate its window, "Unhandled parent window type"
  before) and Windows; macOS shows them as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 04:59:05 +07:00

114 lines
4.5 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer.
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use serde_json::json;
use vectorcraft_engine::Session;
use crate::picks::{self, PickRequest};
use crate::state::Dialog;
use crate::{FilePick, Services, VectorcraftApp};
const SVG: &[u8] = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="10" height="10"/></svg>"#;
/// A dialog asked for, and where its answer goes.
type Asked = (PickRequest, Sender<Vec<String>>);
/// The dialogs showing.
#[derive(Clone, Default)]
struct Shown(Rc<RefCell<Vec<Asked>>>);
impl Shown {
fn requests(&self) -> Vec<PickRequest> {
self.0.borrow().iter().map(|(r, _)| r.clone()).collect()
}
/// The last dialog shown answers `paths`.
fn answer(&self, paths: &[&str]) {
let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing");
tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap();
}
}
/// A desktop app whose dialogs show off the UI thread (none in line), reading [`SVG`] from any path
/// and writing files to `written`.
fn app() -> (VectorcraftApp, Shown, Rc<RefCell<Vec<String>>>) {
let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![])));
let (s, w) = (shown.clone(), written.clone());
let in_line = |_: &FilePick| -> Option<String> { panic!("no dialog on the UI thread") };
let services = Services {
pick_open: Some(Box::new(in_line)),
pick_save: Some(Box::new(in_line)),
pick_folder: Some(Box::new(|| panic!("no dialog on the UI thread"))),
read: Some(Box::new(|_: &str| Ok(SVG.to_vec()))),
write: Some(Box::new(move |path: &str, _: &[u8]| {
w.borrow_mut().push(path.to_string());
Ok(())
})),
start_pick: Some(Box::new(move |request: PickRequest| {
let (tx, rx) = channel();
s.0.borrow_mut().push((request, tx));
Some(rx)
})),
..Default::default()
};
(VectorcraftApp::new(Session::new(), services), shown, written)
}
fn poll(app: &mut VectorcraftApp) {
picks::poll(app, &egui::Context::default());
}
#[test]
fn file_open_opens_what_its_dialog_picks_when_it_answers() {
let (mut app, shown, _) = app();
// The dialog shows; File › Open gives up for now, without a "cancelled".
assert!(app.run("file.open", json!({})).is_err());
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
poll(&mut app);
assert!(app.session.documents().is_empty() && app.ui.status.is_empty(), "{}", app.ui.status);
// Another dialog waits for this one.
assert!(app.run("file.open", json!({})).is_err());
assert_eq!(shown.requests().len(), 1);
assert!(app.ui.status.contains("file dialog is open"), "{}", app.ui.status);
shown.answer(&["/art/a.svg"]);
poll(&mut app);
assert_eq!(app.session.documents().len(), 1);
assert_eq!(app.session.active().and_then(|d| d.path.clone()).as_deref(), Some("/art/a.svg"));
// Cancelled: nothing happens, and dialogs show again.
assert!(app.run("file.open", json!({})).is_err());
shown.answer(&[]);
poll(&mut app);
assert_eq!(app.session.documents().len(), 1);
assert!(app.run("file.open", json!({})).is_err());
assert_eq!(shown.requests().len(), 1);
}
#[test]
fn save_as_goes_on_with_the_path_picked() {
let (mut app, shown, written) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
assert!(app.run("file.saveAs", json!({})).is_err());
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
shown.answer(&["/art/poster.svg"]);
poll(&mut app);
// SVG Options asks next, and its OK writes the file there.
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("svgOptions"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(*written.borrow(), ["/art/poster.svg"]);
}
#[test]
fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() {
let (mut app, shown, _) = app();
let mut d = Dialog::new("exportForScreens", json!({"folder": ""}));
app.ui.dialog = Some(d.clone());
picks::folder_field(&mut app, &mut d, "folder");
assert_eq!(shown.requests(), [PickRequest::Folder]);
shown.answer(&["/out"]);
poll(&mut app);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out"));
}