//! File dialogs shown off the UI thread (Linux, #592) or as sheets on the window (macOS, #867):
//! what asked runs again with the answer, on the document it asked for; while a sheet is open
//! nothing closes or quits; and nothing outside `picks` shows a dialog in line.
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::rc::Rc;
use std::sync::mpsc::{Sender, channel};
use egui::{Pos2, Rect, vec2};
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use crate::panels::graphic_styles::GraphicStyleLibraries;
use crate::panels::library_panel::library_menu;
use crate::panels::swatches::SwatchLibraries;
use crate::picks::{self, PickRequest};
use crate::state::Dialog;
use crate::{FilePick, Services, VectorcraftApp};
const SVG: &[u8] = br#""#;
/// What the status bar says when an answer comes for a document no longer open.
const CLOSED: &str = "The document was closed while its file dialog was open";
/// A dialog asked for, and where its answer goes.
type Asked = (PickRequest, Sender>);
/// The dialogs showing.
#[derive(Clone, Default)]
pub(crate) struct Shown(Rc>>);
impl Shown {
pub(crate) fn requests(&self) -> Vec {
self.0.borrow().iter().map(|(r, _)| r.clone()).collect()
}
/// The last dialog shown answers `paths`.
pub(crate) 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();
}
}
/// The paths files were written to.
pub(crate) type Written = Rc>>;
/// Give `services` dialogs shown off the UI thread (none in line: those fail the test), a reader of
/// [`SVG`] at any path and a writer that lists the paths written.
pub(crate) fn off_the_ui_thread(services: &mut Services) -> (Shown, Written) {
let (shown, written) = (Shown::default(), Written::default());
let (s, w) = (shown.clone(), written.clone());
let in_line = |_: &FilePick| -> Option { panic!("no dialog on the UI thread") };
services.pick_open = Some(Box::new(in_line));
services.pick_save = Some(Box::new(in_line));
services.pick_open_multi = Some(Box::new(|| panic!("no dialog on the UI thread")));
services.pick_folder = Some(Box::new(|| panic!("no dialog on the UI thread")));
services.read = Some(Box::new(|_: &str| Ok(SVG.to_vec())));
services.write = Some(Box::new(move |path: &str, _: &[u8]| {
w.borrow_mut().push(path.to_string());
Ok(())
}));
services.start_pick = Some(Box::new(move |request: PickRequest| {
let (tx, rx) = channel();
s.0.borrow_mut().push((request, tx));
Some(rx)
}));
(shown, written)
}
/// A desktop app whose dialogs show off the UI thread, as sheets on the window when `sheets`
/// (macOS) or as windows of their own (Linux).
fn with_dialogs(sheets: bool) -> (VectorcraftApp, Shown, Written) {
let mut services = Services { dialogs_are_sheets: sheets, ..Default::default() };
let (shown, written) = off_the_ui_thread(&mut services);
(VectorcraftApp::new(Session::new(), services), shown, written)
}
/// The macOS app: its dialogs are sheets.
fn app() -> (VectorcraftApp, Shown, Written) {
with_dialogs(true)
}
fn poll(app: &mut VectorcraftApp) {
picks::poll(app, &egui::Context::default());
}
fn dialog_kind(app: &VectorcraftApp) -> Option<&str> {
app.ui.dialog.as_ref().map(|d| d.kind.as_str())
}
// ------------------------------------------------------------------------- driven like a person
/// Room for a dialog, a panel or a menu (the libraries menu lists every library).
const SCREEN: (f32, f32) = (1600.0, 6000.0);
/// One frame of `draw` with `events` → its texts and where they were drawn.
fn frame(
ctx: &egui::Context,
app: &mut VectorcraftApp,
events: Vec,
draw: &impl Fn(&mut VectorcraftApp, &mut egui::Ui),
) -> Vec<(String, Rect)> {
fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) {
match s {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(SCREEN.0, SCREEN.1))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| draw(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
/// Where text `label` was drawn (menu items pad theirs).
fn at(texts: &[(String, Rect)], label: &str) -> Pos2 {
texts
.iter()
.find(|(t, _)| t.trim() == label)
.unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::>()))
.1
.center()
}
/// Draw `draw` until it settles, then click its text `label`.
fn click(app: &mut VectorcraftApp, label: &str, draw: impl Fn(&mut VectorcraftApp, &mut egui::Ui)) {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
frame(&ctx, app, vec![], &draw);
frame(&ctx, app, vec![], &draw);
let pos = at(&frame(&ctx, app, vec![], &draw), label);
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&ctx, app, vec![egui::Event::PointerMoved(pos), button(true), button(false)], &draw);
}
/// The open dialog, drawn as the app draws it.
fn dialogs(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
crate::dialogs::show(app, ui.ctx());
}
/// The active document written to `Poster.vectorcraft` in a folder of its own named after `tag`
/// → (the folder, the file).
fn saved_document(app: &mut VectorcraftApp, tag: &str) -> (std::path::PathBuf, String) {
let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let dir = std::env::temp_dir().join(format!("vc-picks-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Poster.vectorcraft");
std::fs::write(&file, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap();
(dir, file.to_string_lossy().into_owned())
}
// ------------------------------------------------------------------------------------- the tests
#[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!(dialog_kind(&app), 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"));
}
/// Separations Preview › Load Profile… asks off the UI thread, and the profile picked is loaded
/// when the dialog answers (here a missing file, whose error names it).
#[test]
fn load_profile_asks_off_the_ui_thread() {
let (mut app, shown, _) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
click(&mut app, "Load Profile…", crate::panels::separations::show);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(p)] if p.filters.iter().any(|(_, e)| e.contains(&"icc"))));
poll(&mut app);
assert!(app.ui.status.is_empty(), "{}", app.ui.status);
shown.answer(&["/no/such/profile.icc"]);
poll(&mut app);
assert!(app.ui.status.contains("/no/such/profile.icc"), "color.loadProfile ran on the path: {}", app.ui.status);
}
/// The Swatches libraries menu's Other Library… asks off the UI thread; the file picked (another
/// document) loads as a library and opens in the library panel.
#[test]
fn other_swatch_library_asks_off_the_ui_thread() {
let (mut app, shown, _) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("swatch.new", json!({"name": "Signal", "color": "#ff3300"})).unwrap();
let (dir, file) = saved_document(&mut app, "swatches");
click(&mut app, "Other Library…", library_menu::);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
poll(&mut app);
assert!(app.ui.library_panel.is_none() && app.ui.status.is_empty(), "{}", app.ui.status);
shown.answer(&[&file]);
poll(&mut app);
let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status));
assert_eq!(open.kind, "swatches");
let (_, lib) = vectorcraft_engine::cmd::swatchlib::library(&app.session, &open.id).unwrap();
assert!(lib.swatch("Signal").is_some(), "{}", open.id);
let _ = std::fs::remove_dir_all(dir);
}
/// The Graphic Styles libraries menu's Other Library… asks off the UI thread; the file picked
/// (another document) loads as a library and opens in the library panel.
#[test]
fn other_graphic_style_library_asks_off_the_ui_thread() {
let (mut app, shown, _) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone();
app.run("graphicStyle.new", json!({"name": "Poster Look", "id": id})).unwrap();
let (dir, file) = saved_document(&mut app, "styles");
click(&mut app, "Other Library…", library_menu::);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
shown.answer(&[&file]);
poll(&mut app);
let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status));
assert_eq!(open.kind, "graphicStyles");
let (_, lib) = vectorcraft_engine::cmd::stylelib::library(&app.session, &open.id).unwrap();
assert!(lib.style("Poster Look").is_some(), "{}", open.id);
let _ = std::fs::remove_dir_all(dir);
}
/// The Keyboard Shortcuts dialog's Import… and Export… ask off the UI thread: the set imported
/// replaces the dialog's working set (kept on OK), Export… writes it where the dialog answers, and
/// an answer once the dialog is closed does nothing.
#[test]
fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() {
let (mut app, shown, written) = app();
let set = crate::shortcut_editor::export_json("Custom", &BTreeMap::from([("view.grid".to_string(), "Ctrl+Alt+G".to_string())]));
let (_, want) = crate::shortcut_editor::import_json(&set).unwrap();
let bytes = serde_json::to_vec(&set).unwrap();
app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone())));
app.run("edit.keyboardShortcuts", json!({})).unwrap();
click(&mut app, "Import…", dialogs);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)]));
shown.answer(&["/keys/mine.json"]);
poll(&mut app);
let d = app.ui.dialog.as_ref().expect("the dialog stays open");
assert_eq!((d.kind.as_str(), &d.fields["overrides"]), ("shortcuts", &serde_json::to_value(&want).unwrap()));
assert!(d.str("__message").starts_with("Imported"), "{}", d.str("__message"));
assert!(app.ui.shortcut_overrides.is_empty(), "kept only on OK");
click(&mut app, "Export…", dialogs);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]));
shown.answer(&["/keys/out.json"]);
poll(&mut app);
assert_eq!(*written.borrow(), ["/keys/out.json"]);
// Closed before the dialog answers: nothing is written, and the status says why.
click(&mut app, "Export…", dialogs);
app.ui.dialog = None;
shown.answer(&["/keys/late.json"]);
poll(&mut app);
assert_eq!(*written.borrow(), ["/keys/out.json"]);
assert_eq!(app.ui.status, picks::dialog_gone());
// The command waits for its dialog too: no data in place of the file.
assert_eq!(app.run("shortcuts.export", json!({})), Err("cancelled".to_string()));
shown.answer(&["/keys/command.json"]);
poll(&mut app);
assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]);
}
/// A dialog's folder button answered after the dialog closed fills nothing, and the status says
/// why.
#[test]
fn a_folder_for_a_closed_dialog_is_dropped() {
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");
app.ui.dialog = None;
shown.answer(&["/out"]);
poll(&mut app);
assert_eq!(dialog_kind(&app), None);
assert_eq!(app.ui.status, picks::dialog_gone());
}
/// Export… in the PDF, Print, Transparency Flattener and Perspective Grid Presets dialogs asks off
/// the UI thread and writes the selected preset where the dialog answers; the dialog stays open.
#[test]
fn preset_dialogs_export_asks_off_the_ui_thread() {
use vectorcraft_engine::cmd::{flatten, pdfcmds, perspgrid, printpresets};
let cases = [
("pdf.preset.save", json!({"name": "Mine"}), "ui.pdfPresetsDialog", pdfcmds::PRESET_FORMAT),
("print.presets.save", json!({"name": "Mine", "settings": {"copies": 2}}), "ui.printPresetsDialog", printpresets::PRESET_FORMAT),
("flattener.presets.save", json!({"name": "Mine", "balance": 20}), "ui.flattenerPresetsDialog", flatten::PRESET_FORMAT),
("perspective.presets.save", json!({"name": "Mine"}), "ui.perspectivePresetsDialog", perspgrid::PRESET_FORMAT),
];
for (save, params, open, ext) in cases {
let (mut app, shown, written) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run(save, params).unwrap();
app.run(open, json!({"selected": "Mine"})).unwrap();
let kind = dialog_kind(&app).unwrap().to_string();
click(&mut app, "Export…", dialogs);
assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]), "{open}: {:?}", shown.requests());
assert!(written.borrow().is_empty(), "{open}");
let path = format!("/presets/mine.{ext}");
shown.answer(&[&path]);
poll(&mut app);
assert_eq!(*written.borrow(), [path], "{open}: {}", app.ui.status);
assert_eq!(dialog_kind(&app), Some(kind.as_str()), "{open}");
}
}
/// Save As answers on the document that asked for it: the one made active meanwhile isn't saved,
/// and the one that asked is active again.
#[test]
fn save_as_saves_the_document_that_asked() {
let (mut app, shown, written) = app();
app.run("file.new", json!({"width": 100, "height": 80})).unwrap();
app.run("file.new", json!({"width": 200, "height": 160})).unwrap();
let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect();
app.run("document.activate", json!({"index": 0})).unwrap();
assert!(app.run("file.saveAs", json!({})).is_err());
app.run("document.activate", json!({"index": 1})).unwrap();
shown.answer(&["/art/a.svg"]);
poll(&mut app);
assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0]));
assert_eq!(dialog_kind(&app), Some("svgOptions"));
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(*written.borrow(), ["/art/a.svg"]);
let paths: Vec