Compare commits

...
Author SHA1 Message Date
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
27 changed files with 536 additions and 75 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob
This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands.
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
_Last updated: 2026-10-09 (after placed documents (File › Place of a `.vectorcraft` file, linked), M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414), two batches of Preferences options that were stored but unused (#394), skeleton reads with `document.find` for large documents over MCP (#416) and anchors dragged with Direct Selection snapping to points, segments and smart guides, clipped SVG layers opening as clipping layers (#420), PDF/`.ai` layers read on pages of tens of millions of operators (#419), Document Color Mode putting RGB greys on the black plate on request and converting pattern tiles (#421) and movable dialogs with Offset Path's Joins dropdown (#423) and numeric fields scrubbed by dragging their label (#400), stroke weight presets in the stroke unit (#426), the Appearance panel's Opacity popup (#430), type on a path brackets, flip and Type on a Path Options that keep the type in place (#429), Layers rows dragged with a ghost and dropped on the trash or New Layer (#435), tool flyouts opening on a right or corner press (#436), tool flyouts torn off into floating toolbars (#437), the Shape Builder's erase mode deleting edges too (#439), installed fonts registered outside the font folders and fonts installed while the app runs listed in the font menus (#443), alike circular corners on rectangles that aren't square (#442), tool cursors as OS cursors so the pointer no longer trails over the canvas (#444), artboards copied and pasted with their art (#446), guides dragged out of the rulers snapping to the art's side midpoints and the artboards, artboard guides (#451), the toolbar's double arrow and Window › Toolbars › Double Column switching between one and two columns in any window height (#455), the Smart Guides display options and Snapping Tolerance (#394), Font Size and Leading combo boxes with preset lists in the Character and Properties panels and the Control bar (#458), screenshots pasted from the clipboard on macOS, Windows and the web (#457), the Properties panel's Fill and Stroke matching the Control bar's, Illustrator's Window menu panel keys (Shift+F7 for Align…) and Alt-wheel zoom whatever the wheel preference (Discord feedback), the point/area type widget (Discord feedback), groups in legacy Illustrator .ai files (Discord feedback), a rotating desktop log file (#465), image controls in the Control bar and Properties panel: Image Trace with its presets, Mask, Crop Image, Embed/Unembed, and an Image Trace object's preset, Expand and Release (Discord feedback), the Pen working as the Anchor Point tool with Alt held over a selected path's handles and anchors, and anchor buttons that convert, connect and cut paths at the selected anchors (Discord feedback), Japanese hanging punctuation (burasagari, #463), Escape leaving Presentation Mode with no screen mode saved across restarts, documents dropped on the desktop app opening as tabs of their own and a note that an `.ai` file's art off its artboards isn't in the part read (#472), Add/Delete Anchor Point tool icons drawn from the Pen tool's (#479), EPS import running more of PostScript Level 3 (patterns, Type 3 fonts, mesh shadings, masked images, VM and resource probes), and a preview that stands in saying why (Discord feedback, #466, #469, #474), chords with modifiers recorded in Edit › Keyboard Shortcuts (#487), Type Object Selection by Path Only (#394), Anti-aliased Artwork (#394), Show Pixel Grid (#394), pen and touch taps pressed and lifted within one frame clicking where they tapped and the Linux/Wayland pen-tablet workaround (#491), bounding-box resizes landing on the other objects' anchors and centres with labels, and the Rotate, Scale, Shear and Reflect tools snapping their drags (#482), Shift-drag marquees that toggle the objects or anchors they reach and a Lasso whose Alt subtraction keeps the rest of the selection (#483), the mouse wheel stepping the focused numeric field, the dialogs' and preferences' drag values made such fields (#485), Show The Home Screen When No Documents Are Open (#394), Numbers Without Units Are Points (#394), Auto-Collapse Iconic Panels (#394), the Contextual Task Bar moved by its handle and its More Options menu pinning and resetting its position, a designed macOS DMG windoLine truncated
## Where we are
+65 -23
View File
@@ -34,6 +34,7 @@ mod window;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::graphics::GraphicsLoss;
use vectorcraft_ui_egui::picks::PickRequest;
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
struct App {
@@ -185,9 +186,22 @@ fn save_prefs(app: &VectorcraftApp) {
}
}
/// A native file dialog showing `pick`'s file types, folder and suggested name.
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
/// The window file dialogs belong to.
type Parent = Option<std::sync::Arc<winit::window::Window>>;
/// A native file dialog for `request` over `parent` (Windows and Linux, where the portal then
/// shows it over the window and keeps it in front; macOS shows them as before).
fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog {
let d = match parent {
Some(w) if !cfg!(target_os = "macos") => rfd::FileDialog::new().set_parent(&**w),
_ => rfd::FileDialog::new(),
};
let pick = match request {
PickRequest::Open(pick) | PickRequest::Save(pick) => pick,
PickRequest::OpenMany => return fileio::place_filters().fold(d.set_title("Place"), |d, (name, exts)| d.add_filter(name, exts)),
PickRequest::Folder => return d,
};
let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
None => d,
@@ -195,6 +209,28 @@ fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
}
/// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked.
fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec<String> {
let paths = match request {
PickRequest::Open(_) => dialog.pick_file().into_iter().collect(),
PickRequest::OpenMany => dialog.pick_files().unwrap_or_default(),
PickRequest::Save(pick) => {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
dialog.save_file().into_iter().collect()
}
PickRequest::Folder => dialog.pick_folder().into_iter().collect(),
};
paths.into_iter().map(|p| p.to_string_lossy().to_string()).collect()
}
/// Show the dialog for `request` over `parent` now → the paths picked.
fn pick_now(request: PickRequest, parent: &Parent) -> Vec<String> {
show_dialog(file_dialog(&request, parent), &request)
}
/// File → Show in Folder: select `path` in Finder / Explorer, or open its folder elsewhere.
fn reveal(path: &str) -> Result<(), String> {
reveal_command(path).spawn().map(|_| ()).map_err(|e| format!("can't show {path}: {e}"))
@@ -230,25 +266,31 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
fn services() -> Services {
/// Linux: show the dialog for `request` over `parent` on a thread of its own, answering on the
/// receiver. Shown on the UI thread, nothing would answer the compositor meanwhile, which then
/// offers to kill the window as not responding (#592).
fn start_pick(request: PickRequest, parent: &Parent) -> Option<std::sync::mpsc::Receiver<Vec<String>>> {
let dialog = file_dialog(&request, parent);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("file-dialog".into())
.spawn(move || {
// The app gone meanwhile has no use for the answer.
let _ = tx.send(show_dialog(dialog, &request));
})
.ok()?;
Some(rx)
}
fn services(parent: Parent) -> Services {
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
Services {
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_open_multi: Some(Box::new(|| {
fileio::place_filters()
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|pick: &FilePick| {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
})),
pick_open: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Open(pick.clone()), &p1).into_iter().next())),
pick_open_multi: Some(Box::new(move || pick_now(PickRequest::OpenMany, &p2))),
pick_save: Some(Box::new(move |pick: &FilePick| pick_now(PickRequest::Save(pick.clone()), &p3).into_iter().next())),
// Windows and macOS dialogs run the window's events while they are open; Linux's don't.
start_pick: cfg!(all(unix, not(target_os = "macos")))
.then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
@@ -266,7 +308,7 @@ fn services() -> Services {
reveal: Some(Box::new(reveal)),
// Links panel: Edit Original; Package: Show Package. Relink to Folder and Package pick folders.
open_file: Some(Box::new(open_file)),
pick_folder: Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))),
pick_folder: Some(Box::new(move || pick_now(PickRequest::Folder, &p4).into_iter().next())),
// File → Print: the system's printers and print queue.
print: Some(Box::new(printing::SystemPrint)),
..Default::default()
@@ -397,7 +439,7 @@ fn main() -> std::process::ExitCode {
"VectorCraft",
options,
Box::new(move |cc| {
let mut app = VectorcraftApp::new(Session::new(), services());
let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned()));
load_prefs(&mut app, saved);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
@@ -424,11 +424,8 @@ fn destination(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
let pick = app.services.pick_folder.is_some();
ui.horizontal(|ui| {
form::text(ui, d, "folder", if pick { 300.0 } else { 334.0 });
if pick
&& widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked()
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|pick| pick())
{
d.fields.insert("folder".into(), json!(f));
if pick && widgets::icon_button(ui, "folder-open", tl!("Choose a folder"), false, 26.0).clicked() {
crate::picks::folder_field(app, d, "folder");
}
});
form::check(ui, d, "openLocation", tl!("Open Location after Export"));
@@ -145,10 +145,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
}
}
if let Some(a) = act
&& let Err(e) = run(app, d, a, &preset.name)
{
app.status(e);
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = preset.name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &preset.name)
};
if let Err(e) = r {
app.status(e);
}
}
false
}
@@ -181,7 +188,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
return Ok(());
}
let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
let r = app.run("flattener.presets.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
+1 -1
View File
@@ -122,7 +122,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
p if !p.is_empty() => p,
_ => {
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?
crate::picks::open(app, &pick).ok_or("cancelled")?
}
};
out = app.run("links.relink", json!({ "ids": current["ids"], "path": path }))?;
+2 -1
View File
@@ -315,7 +315,8 @@ pub fn cancel(app: &mut VectorcraftApp) {
/// Apply the open dialog (OK).
pub fn confirm(app: &mut VectorcraftApp) -> DialogResult {
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
(spec(&d.kind).confirm)(app, &d)
// A file dialog it shows off the UI thread confirms the dialog as it is again.
crate::picks::as_entry(app, || crate::picks::Entry::Confirm(Box::new(d.clone())), |app| (spec(&d.kind).confirm)(app, &d))
}
pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
+2 -5
View File
@@ -70,11 +70,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if !downloads(app) {
widgets::label_row(ui, tl!("Location:"), LABEL, |ui| {
form::text(ui, d, "folder", 230.0);
if let Some(pick) = app.services.pick_folder.as_mut()
&& ui.button(tl!("Choose…")).clicked()
&& let Some(f) = pick()
{
d.fields.insert("folder".into(), json!(f));
if app.services.pick_folder.is_some() && ui.button(tl!("Choose…")).clicked() {
crate::picks::folder_field(app, d, "folder");
}
});
ui.add_space(4.0);
+12 -5
View File
@@ -102,10 +102,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
details(ui, &t, preset, builtin);
});
});
if let Some(a) = act
&& let Err(e) = run(app, d, a, &preset.name)
{
app.status(e);
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = preset.name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &preset.name)
};
if let Err(e) = r {
app.status(e);
}
}
false
}
@@ -169,7 +176,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) ->
return Ok(());
}
let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
let r = app.run("pdf.preset.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
@@ -150,10 +150,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
});
});
let name = preset.name.clone();
if let Some(a) = act
&& let Err(e) = run(app, d, a, &name)
{
app.status(e);
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &name)
};
if let Err(e) = r {
app.status(e);
}
}
false
}
@@ -187,7 +194,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
return Ok(());
}
let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
let r = app.run("perspective.presets.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
+12 -5
View File
@@ -111,10 +111,17 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
});
if let Some(a) = act
&& let Err(e) = run(app, d, a, &name)
{
app.status(e);
if let Some(a) = act {
let r = if matches!(a, Action::Import) {
// Its file dialog, shown off the UI thread, imports into the dialog as it is then.
let current = name.clone();
crate::picks::in_dialog(app, d, move |app, d| run(app, d, Action::Import, &current))
} else {
run(app, d, a, &name)
};
if let Err(e) = r {
app.status(e);
}
}
false
}
@@ -149,7 +156,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current
return Ok(());
}
let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() };
let path = app.services.pick_open.as_mut().and_then(|f| f(&pick)).ok_or("cancelled")?;
let path = crate::picks::open(app, &pick).ok_or("cancelled")?;
let r = app.run("print.presets.import", json!({ "path": path }))?;
if let Some(first) = r["imported"].get(0).and_then(Value::as_str) {
select(d, first);
+1
View File
@@ -634,3 +634,4 @@
Clipboard Handling Práce se schránkou
Appearance of Black Vzhled černé
Devices Zařízení
A file dialog is open: choose there first Je otevřené dialogové okno souboru: nejprve vyberte v něm
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3598,3 +3598,4 @@ axis Both Ambos
Off: an editable copy of its art, embedded in this document. Desactivado: una copia editable de su ilustración, incrustada en este documento.
Images: link to the file instead of only embedding it. Imágenes: vincular al archivo en lugar de solo incrustarlo.
VectorCraft documents: one locked object, updated when the file changes. Documentos de VectorCraft: un objeto bloqueado que se actualiza cuando cambia el archivo.
A file dialog is open: choose there first Hay un cuadro de diálogo de archivos abierto: elija primero en él
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3598,3 +3598,4 @@ axis Both Entrambi
Off: an editable copy of its art, embedded in this document. Disattivato: una copia modificabile della sua grafica, incorporata in questo documento.
Images: link to the file instead of only embedding it. Immagini: collega il file invece di incorporarlo soltanto.
VectorCraft documents: one locked object, updated when the file changes. Documenti VectorCraft: un unico oggetto bloccato, aggiornato quando il file cambia.
A file dialog is open: choose there first È aperta una finestra di dialogo dei file: scegli prima lì
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3060,3 +3060,4 @@ theme Appearance 外観
Off: an editable copy of its art, embedded in this document. オフ:アートの編集可能なコピーをこのドキュメントに埋め込みます。
Images: link to the file instead of only embedding it. 画像:埋め込むだけでなく、ファイルにリンクします。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft ドキュメント:ロックされた 1 つのオブジェクト。ファイルの変更時に更新されます。
A file dialog is open: choose there first ファイルダイアログが開いています。先にそちらで選択してください
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -2648,3 +2648,4 @@
Off: an editable copy of its art, embedded in this document. Desativado: uma cópia editável da arte, incorporada neste documento.
Images: link to the file instead of only embedding it. Imagens: vincular ao arquivo em vez de apenas incorporá-lo.
VectorCraft documents: one locked object, updated when the file changes. Documentos do VectorCraft: um objeto bloqueado, atualizado quando o arquivo muda.
A file dialog is open: choose there first Há uma caixa de diálogo de arquivos aberta: escolha nela primeiro
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -3596,3 +3596,4 @@ axis Both Оба
Off: an editable copy of its art, embedded in this document. Выкл.: редактируемая копия его рисунка, внедрённая в этот документ.
Images: link to the file instead of only embedding it. Изображения: связывать с файлом вместо простого внедрения.
VectorCraft documents: one locked object, updated when the file changes. Документы VectorCraft: один заблокированный объект, обновляемый при изменении файла.
A file dialog is open: choose there first Открыто окно выбора файла: сначала сделайте выбор в нём
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3315,3 +3315,4 @@ axis Both 两者
Off: an editable copy of its art, embedded in this document. 关闭:将其图稿的可编辑副本嵌入此文档。
Images: link to the file instead of only embedding it. 图像:链接到文件,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文档:一个锁定对象,文件更改时随之更新。
A file dialog is open: choose there first 文件对话框已打开:请先在其中选择
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -3090,3 +3090,4 @@ axis Both 兩者
Off: an editable copy of its art, embedded in this document. 關閉:將其圖稿的可編輯副本內嵌在此文件中。
Images: link to the file instead of only embedding it. 影像:連結至檔案,而不只是嵌入。
VectorCraft documents: one locked object, updated when the file changes. VectorCraft 文件:單一鎖定物件,檔案變更時隨之更新。
A file dialog is open: choose there first 檔案對話方塊已開啟:請先在其中選擇
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2 -2
View File
@@ -72,7 +72,7 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
/// A path from the open dialog, or "cancelled".
fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String> {
app.services.pick_open.as_mut().and_then(|f| f(pick)).ok_or_else(|| "cancelled".into())
crate::picks::open(app, pick).ok_or_else(|| "cancelled".into())
}
/// Object › Plug-ins › Install Plug-in…: installs the `.wasm` at `path`, else a picked one (the
@@ -164,7 +164,7 @@ fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result<String, String
if is_web(app) {
return Ok(pick.name.clone());
}
let picked = app.services.pick_save.as_mut().and_then(|f| f(pick)).ok_or("cancelled")?;
let picked = crate::picks::save(app, pick).ok_or("cancelled")?;
Ok(with_extension(&picked, &fileio::extension(&pick.name), |_| true))
}
+21
View File
@@ -36,6 +36,7 @@ pub mod menus;
pub mod native_menu;
pub mod palette;
pub mod panels;
pub mod picks;
pub mod place;
pub mod prefs_dialog;
pub mod print;
@@ -95,6 +96,8 @@ mod tests_pathtype;
#[cfg(test)]
mod tests_pdfoutput;
#[cfg(test)]
mod tests_picks;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_plugins;
@@ -233,6 +236,10 @@ pub struct Services {
pub print: Option<Box<dyn print::PrintService>>,
/// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then.
pub native_menu: Option<native_menu::NativeMenu>,
/// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window
/// and the compositor finds it not answering): what asked runs again with the answer
/// ([`picks`]). Without it they are shown in line.
pub start_pick: Option<picks::StartPick>,
}
/// Cached canvas raster.
@@ -347,6 +354,8 @@ pub struct VectorcraftApp {
/// The thread that checks the system clipboard for `system_paste` instead
/// ([`Services::clipboard_probe`]).
clipboard_probe: Option<clipboard_probe::Probe>,
/// File dialogs shown off the UI thread and what runs again with their answers.
pub(crate) picks: picks::Picks,
/// The look for fonts installed or removed while the app was in the background, running
/// ([`Self::refresh_installed_fonts`]): whether they were.
font_check: Option<std::sync::mpsc::Receiver<bool>>,
@@ -426,6 +435,7 @@ impl VectorcraftApp {
system_paste: false,
system_paste_at: f64::NEG_INFINITY,
clipboard_probe: None,
picks: picks::Picks::default(),
font_check: None,
paste_chord: Default::default(),
background: Default::default(),
@@ -489,6 +499,16 @@ impl VectorcraftApp {
/// Run a UI or engine command by id. The single entry point for every frontend path.
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
// A file dialog it shows off the UI thread runs it again with the path picked.
if self.picks.is_entry_free() {
let entry = picks::Entry::Command(id.to_string(), params.clone());
return picks::as_entry(self, move || entry, |app| app.run_now(id, params));
}
self.run_now(id, params)
}
/// [`Self::run`] it, inside what asks for file dialogs.
fn run_now(&mut self, id: &str, params: Value) -> Result<Value, String> {
self.run_count = self.run_count.wrapping_add(1);
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
@@ -790,6 +810,7 @@ impl VectorcraftApp {
self.system_paste = wanted && self.system_clipboard_pasteable();
}
self.poll_font_check(ctx);
picks::poll(self, ctx);
background::poll(self);
if !self.background.jobs.is_empty() {
// Keep the status bar's progress moving and pick the result up when it arrives.
+11 -5
View File
@@ -185,17 +185,23 @@ fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option<Vec<NodeId
/// folder picker: the last folder Export for Screens used, else the Desktop); the web downloads
/// them, several as one zip.
fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
// The folder picked off the UI thread exports them then.
crate::picks::button(app, move |app| export_now(app, ids.clone(), files_each));
}
fn export_now(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
let mut p = json!({ "assets": ids });
if io::is_web(app) {
p["zip"] = json!(ids.len() * files_each > 1);
} else {
let saved = app.session.active().map(|st| st.doc.export_settings.clone()).unwrap_or_default();
let folder = match app.services.pick_folder.as_mut() {
Some(pick) => match pick() {
let folder = if crate::picks::can(app, &crate::picks::PickRequest::Folder) {
match crate::picks::folder(app) {
Some(f) => f,
None => return,
},
None => match saved
}
} else {
match saved
.get("folder")
.and_then(Value::as_str)
.filter(|f| !f.is_empty())
@@ -204,7 +210,7 @@ fn export(app: &mut VectorcraftApp, ids: Vec<u64>, files_each: usize) {
{
Some(f) => f,
None => return app.status("choose a folder to export to".to_string()),
},
}
};
p["folder"] = json!(folder);
p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true));
+1 -1
View File
@@ -154,7 +154,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option<String>)
}
path.or_else(|| {
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() };
app.services.pick_open.as_mut().and_then(|f| f(&pick))
crate::picks::open(app, &pick)
})
.map(Some)
.ok_or_else(|| "cancelled".into())
+16 -5
View File
@@ -116,15 +116,23 @@ fn report(app: &mut VectorcraftApp, r: Result<Value, String>, done: impl FnOnce(
/// Relink images `ids` to a picked file.
pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec<u64>) {
crate::picks::button(app, move |app| relink_picked(app, ids.clone()));
}
fn relink_picked(app: &mut VectorcraftApp, ids: Vec<u64>) {
let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() };
let Some(path) = app.services.pick_open.as_mut().and_then(|f| f(&pick)) else { return };
let Some(path) = crate::picks::open(app, &pick) else { return };
let r = app.run("links.relink", json!({ "ids": ids, "path": path }));
report(app, r, |v| format!("Relinked {} image(s)", v["relinked"].as_array().map_or(0, Vec::len)));
}
/// Relink images `ids` (none: every missing one) to the files of their names in a picked folder.
fn relink_to_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
let Some(folder) = app.services.pick_folder.as_mut().and_then(|f| f()) else { return };
crate::picks::button(app, move |app| relink_to_picked_folder(app, ids.clone()));
}
fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec<u64>) {
let Some(folder) = crate::picks::folder(app) else { return };
let ids = Some(ids).filter(|i| !i.is_empty());
let r = app.run("links.relink", json!({ "ids": ids, "folder": folder }));
report(app, r, |v| {
@@ -154,9 +162,12 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) {
}
return;
}
let Some(path) = app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named(name))) else { return };
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
let name = name.to_string();
crate::picks::button(app, move |app| {
let Some(path) = crate::picks::save(app, &crate::FilePick::named(&name)) else { return };
let r = app.run("links.unembed", json!({ "id": id, "path": path }));
report(app, r, |v| format!("Unembedded to {}", v["path"].as_str().unwrap_or_default()));
});
}
/// The UI commands `links.editOriginal` (open the linked file in its app) and `links.reveal`
+237
View File
@@ -0,0 +1,237 @@
//! File dialogs that leave the window running (#592).
//!
//! The app asks for a path where it needs one (Open, Save As, Export, Place, Relink, presets…)
//! and goes on with it at once, so a dialog shown on the UI thread holds the window until it
//! closes. On Windows and macOS the dialog runs the window's events meanwhile; on Linux nothing
//! does, so a Wayland compositor finds the window not answering and offers to kill it. There the
//! host shows dialogs on another thread ([`Services::start_pick`]): asking returns no path for now,
//! and when the dialog answers, what asked (the command, the dialog's OK, a panel's button: the
//! *entry*) runs again and gets the picked path at once.
use std::rc::Rc;
use std::sync::mpsc::{Receiver, TryRecvError};
use serde_json::Value;
use crate::state::Dialog;
use crate::{FilePick, VectorcraftApp};
/// A file dialog to show.
#[derive(Clone, Debug, PartialEq)]
pub enum PickRequest {
/// An open dialog for one file.
Open(FilePick),
/// An open dialog for files to place.
OpenMany,
/// A save dialog.
Save(FilePick),
/// A folder picker.
Folder,
}
/// The paths a dialog shown off the UI thread picked (none: cancelled).
pub type PickAnswer = Vec<String>;
/// Shows a dialog off the UI thread; its answer arrives on the receiver. `None` when it can't, and
/// the dialog is then shown in line.
pub type StartPick = Box<dyn FnMut(PickRequest) -> Option<Receiver<PickAnswer>>>;
/// What runs again with the picked path.
#[derive(Clone)]
pub(crate) enum Entry {
/// A command (`app.run`).
Command(String, Value),
/// A dialog's OK (`dialogs::confirm`), on the dialog as it was.
Confirm(Box<Dialog>),
/// A panel's or a dialog's button.
Call(Rc<dyn Fn(&mut VectorcraftApp)>),
}
/// A dialog shown off the UI thread, waiting for its answer.
struct Waiting {
request: PickRequest,
answer: Receiver<PickAnswer>,
entry: Entry,
}
/// The app's file dialogs ([`VectorcraftApp`]'s `picks`).
#[derive(Default)]
pub(crate) struct Picks {
/// What is running now and asks for a path, the outermost one.
entry: Option<Entry>,
waiting: Option<Waiting>,
/// A picked answer for the entry running again: the dialog it asks for gets it.
answer: Option<(PickRequest, PickAnswer)>,
}
impl Picks {
/// Nothing asking for file dialogs is running.
pub(crate) fn is_entry_free(&self) -> bool {
self.entry.is_none()
}
}
/// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it
/// answers. Inside another entry, that one runs again instead.
pub(crate) fn as_entry<R>(app: &mut VectorcraftApp, entry: impl FnOnce() -> Entry, f: impl FnOnce(&mut VectorcraftApp) -> R) -> R {
if app.picks.entry.is_some() {
return f(app);
}
app.picks.entry = Some(entry());
let r = f(app);
app.picks.entry = None;
r
}
/// [`as_entry`] for a panel's or a dialog's button that `f` handles.
pub(crate) fn button(app: &mut VectorcraftApp, f: impl Fn(&mut VectorcraftApp) + 'static) {
let f = Rc::new(f);
let again = f.clone();
as_entry(app, move || Entry::Call(again), |app| f(app));
}
/// A dialog body's action `f` on `d` (the open dialog's copy) that asks for a file dialog: shown
/// off the UI thread, its answer runs `f` again on the open dialog, when it is still of that kind.
pub(crate) fn in_dialog(
app: &mut VectorcraftApp,
d: &mut Dialog,
f: impl Fn(&mut VectorcraftApp, &mut Dialog) -> Result<(), String> + 'static,
) -> Result<(), String> {
let f = Rc::new(f);
let (again, kind) = (f.clone(), d.kind.clone());
let entry = move || {
Entry::Call(Rc::new(move |app: &mut VectorcraftApp| {
let Some(mut d) = app.ui.dialog.take() else { return };
let r = if d.kind == kind { again(app, &mut d) } else { Ok(()) };
app.ui.dialog = Some(d);
if let Err(e) = r {
app.status(e);
}
}))
};
as_entry(app, entry, |app| f(app, d))
}
/// A dialog body's folder button: the folder picked into field `key` of `d` ([`in_dialog`]).
pub(crate) fn folder_field(app: &mut VectorcraftApp, d: &mut Dialog, key: &'static str) {
let r = in_dialog(app, d, move |app, d| {
if let Some(f) = folder(app) {
d.fields.insert(key.into(), serde_json::json!(f));
}
Ok(())
});
// Picking a folder doesn't fail.
let _ = r;
}
/// A path from an open dialog for `pick` (none: cancelled, or shown off the UI thread for now).
pub(crate) fn open(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
ask(app, PickRequest::Open(pick.clone()))?.into_iter().next()
}
/// A path from a save dialog for `pick`.
pub(crate) fn save(app: &mut VectorcraftApp, pick: &FilePick) -> Option<String> {
ask(app, PickRequest::Save(pick.clone()))?.into_iter().next()
}
/// A folder from the folder picker.
pub(crate) fn folder(app: &mut VectorcraftApp) -> Option<String> {
ask(app, PickRequest::Folder)?.into_iter().next()
}
/// Paths from the open dialog for files to place (none: cancelled).
pub(crate) fn open_many(app: &mut VectorcraftApp) -> Vec<String> {
ask(app, PickRequest::OpenMany).unwrap_or_default()
}
/// Whether the host shows `request`'s kind of dialog at all.
pub(crate) fn can(app: &VectorcraftApp, request: &PickRequest) -> bool {
let s = &app.services;
match request {
PickRequest::Open(_) => s.pick_open.is_some(),
PickRequest::OpenMany => s.pick_open_multi.is_some() || s.pick_open.is_some(),
PickRequest::Save(_) => s.pick_save.is_some(),
PickRequest::Folder => s.pick_folder.is_some(),
}
}
/// The paths `request`'s dialog picked: the answer kept for it, else shown off the UI thread (no
/// paths for now), else shown in line.
fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option<PickAnswer> {
// One for another dialog is dropped: the entry took another way this time.
if let Some((asked, answer)) = app.picks.answer.take()
&& asked == request
{
return Some(answer);
}
if !can(app, &request) {
return None;
}
if let (Some(entry), Some(start)) = (app.picks.entry.clone(), app.services.start_pick.as_mut()) {
if app.picks.waiting.is_some() {
app.status(tl!("A file dialog is open: choose there first"));
return None;
}
if let Some(answer) = start(request.clone()) {
app.picks.waiting = Some(Waiting { request, answer, entry });
return None;
}
}
let s = &mut app.services;
let one = |p: Option<String>| Some(p.into_iter().collect());
match &request {
PickRequest::Open(pick) => one(s.pick_open.as_mut().and_then(|f| f(pick))),
PickRequest::Save(pick) => one(s.pick_save.as_mut().and_then(|f| f(pick))),
PickRequest::Folder => one(s.pick_folder.as_mut().and_then(|f| f())),
PickRequest::OpenMany => match (s.pick_open_multi.as_mut(), s.pick_open.as_mut()) {
(Some(many), _) => Some(many()),
(None, Some(f)) => one(f(&FilePick::default())),
(None, None) => None,
},
}
}
/// Each frame: when the dialog shown off the UI thread answers with paths, run its entry again
/// with them. While it is open, look again shortly.
pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) {
let Some(w) = &app.picks.waiting else { return };
let answer = match w.answer.try_recv() {
Ok(answer) => answer,
Err(TryRecvError::Empty) => {
// What asked gave up for now: no "cancelled" while the dialog is open.
if app.ui.status == "cancelled" {
app.ui.status.clear();
}
ctx.request_repaint_after(std::time::Duration::from_millis(100));
return;
}
// The dialog's thread is gone: as cancelled.
Err(TryRecvError::Disconnected) => vec![],
};
let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return };
if answer.is_empty() {
return;
}
app.picks.answer = Some((request, answer));
rerun(app, entry);
// An entry that took another way this time leaves the answer unused.
app.picks.answer = None;
}
/// Run `entry` again.
fn rerun(app: &mut VectorcraftApp, entry: Entry) {
let r = match entry {
Entry::Command(id, params) => app.run(&id, params).map(|_| ()),
Entry::Confirm(d) => {
app.ui.dialog = Some(*d);
crate::dialogs::confirm(app).map(|_| ())
}
Entry::Call(f) => {
f(app);
Ok(())
}
};
if let Err(e) = r {
app.status(e);
}
}
+4 -4
View File
@@ -135,10 +135,10 @@ fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
f();
return Ok(());
}
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
(Some(f), _) => f(),
(None, Some(f)) => f(&crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect(),
(None, None) => vec![],
let paths = if app.services.pick_open_multi.is_some() {
crate::picks::open_many(app)
} else {
crate::picks::open(app, &crate::FilePick { filters: fileio::place_filters().collect(), ..Default::default() }).into_iter().collect()
};
if paths.is_empty() {
return Err("cancelled".into());
+2 -2
View File
@@ -458,7 +458,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
"shortcuts.export" => {
let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides);
let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default();
let path = s("path").or_else(|| app.services.pick_save.as_mut().and_then(|f| f(&crate::FilePick::named("VectorCraft Shortcuts.json"))));
let path = s("path").or_else(|| crate::picks::save(app, &crate::FilePick::named("VectorCraft Shortcuts.json")));
match path {
Some(path) => match app.services.write.as_mut() {
Some(w) => w(&path, &bytes).map(|_| json!({"path": path})),
@@ -477,7 +477,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Resu
let data = if let Some(v) = p.get("data") {
Ok(v.clone())
} else {
let path = s("path").or_else(|| app.services.pick_open.as_mut().and_then(|f| f(&crate::FilePick::default())));
let path = s("path").or_else(|| crate::picks::open(app, &crate::FilePick::default()));
match (path, app.services.read.as_ref()) {
(Some(path), Some(r)) => r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())),
(None, _) => Err("cancelled".into()),
+113
View File
@@ -0,0 +1,113 @@
//! 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"));
}