From 37d4477362c6dab397b906fe2894fc67a40d3bc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 04:59:05 +0700 Subject: [PATCH] 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 --- ROADMAP.md | 2 +- apps/vectorcraft/src/main.rs | 88 +++++-- .../ui-egui/src/dialogs/export_for_screens.rs | 7 +- .../ui-egui/src/dialogs/flattener_presets.rs | 17 +- crates/ui-egui/src/dialogs/missing_links.rs | 2 +- crates/ui-egui/src/dialogs/mod.rs | 3 +- crates/ui-egui/src/dialogs/package.rs | 7 +- crates/ui-egui/src/dialogs/pdf_presets.rs | 17 +- .../src/dialogs/perspective_presets.rs | 17 +- crates/ui-egui/src/dialogs/print_presets.rs | 17 +- crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/io.rs | 4 +- crates/ui-egui/src/lib.rs | 21 ++ crates/ui-egui/src/panels/asset_export.rs | 16 +- crates/ui-egui/src/panels/library_panel.rs | 2 +- crates/ui-egui/src/panels/links.rs | 21 +- crates/ui-egui/src/picks.rs | 237 ++++++++++++++++++ crates/ui-egui/src/place.rs | 8 +- crates/ui-egui/src/shortcut_editor.rs | 4 +- crates/ui-egui/src/tests_picks.rs | 113 +++++++++ 27 files changed, 536 insertions(+), 75 deletions(-) create mode 100644 crates/ui-egui/src/picks.rs create mode 100644 crates/ui-egui/src/tests_picks.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6e7067c8..71cf20a0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579); see [Honest assessment](#honest-assessment-2026-10-05))._ +_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 window (#493), panels, panel groups and the Tools panel undocking into floating panels that move, stack and dock again (#495), the 0.5.0 startup crash on dual-GPU Linux desktops under Wayland fixed: the desktop's own GPU by default and a restart on the next GPU when one can't show the window (#502), path editing while drawing: Cmd/Ctrl with the Pen borrowing Direct Selection and drawing on afterwards, Pen+Alt reshaping segments, handles of a whole selected path dragging, and handle drags no longer re-gathering snap targets every move (#494), the Control bar's and Properties panel's Convert buttons for a path selected as a whole with Direct Selection, the Pen and the anchor tools (#504), the Pen closing a path dragged out of its first anchor every time, without flicker (#501), the Paste menu checking the system clipboard on a background thread on Linux so an X11 clipboard owner that never answers no longer freezes the window (#510), PDF/`.ai` import keeping a type object's outline stroke on the type, wrapped lines as area type, barcode digits in place and layers in the file's stacking order (#508), the Italian interface (#514), EPS images with several data sources read in turn (#498), `restore` undoing dictionary changes in EPS import (part of #505), the macOS menu bar laid out as a Mac app's (the VectorCraft menu with Settings, Language and Appearance, the system's Hide, Services and Window items; owner's request), Object › Image Trace › Make tracing with the Default preset instead of asking for a preset's name (#543), the rulers' unit menu (#528), Live Corners on stars, polygons and pen paths (#511), the drawing tools snapping to Smart Guides as the Selection tool does, with Construction Guides (#506), the in-window menus' Select All, Cut, Copy and Paste acting on the text field they were opened from (#516), Windows rendering through DX12 before Vulkan (#545), Image Trace tracing colour layers in parallel and refusing traces too large for memory, with the canvas outlining huge selections by their bounds (#525), SVGs of chosen artboards holding only the art over each artboard (#550), the font menu scrolling only its list, under a search field and filters that stay put (#555), a click on an object of a selection making it the key object that Align aligns to and Distribute Spacing spaces from, drawn with a thicker outline (#541), Shape Builder merges taken from the planar map itself, so merged regions leave no inner edge, hairline gap or spur in their outline (#569), `.ai` written by the exports and the CLI (`--export`, `convert`) as Save As writes it (#526), SVG round trips keeping hidden objects and `data-*` attributes (#522), the gradient stop popover staying open after the click that opens it from the Gradient panel (#577), standard ligatures kept under small tracking and dropped only past about −8% / +22% of a word space or when the character asks for them (#583), the Color panel keeping its height as warnings come and go so a drag on its spectrum no longer flickers near white (#578), fonts that font services such as Adobe Fonts load on Windows, listed through DirectWrite (#579), file dialogs on Linux shown off the UI thread so the compositor no longer finds the window not responding, and parented to the window on Linux and Windows (#592); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 549a3ac7..e6a3b9a1 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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>; + +/// 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 { + 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 { + 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>> { + 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() { diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index f37d05c5..89435854 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -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")); diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index 909f3bf7..c50bedf3 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -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, ¤t)) + } 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); diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index c436e3be..2d7b3c7e 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -122,7 +122,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { 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 }))?; diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 2d82a591..019f6eed 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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) { diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 2859a2eb..234141c9 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -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); diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index 0d63fdd1..d0c6afaf 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -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, ¤t)) + } 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); diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index be4d2109..3dd13855 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -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, ¤t)) + } 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); diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index 1d1da489..36b95e4b 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -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, ¤t)) + } 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); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index c44ada02..82c42bc8 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index e4ceed41..6196c3d1 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index decd91fd..6cf4879c 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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ì diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b479ea62..f9102846 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 ファイルダイアログが開いています。先にそちらで選択してください diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 9500ba89..6753ee1d 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 53cafffa..ae4256a5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Открыто окно выбора файла: сначала сделайте выбор в нём diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ddc8134f..677ca8f4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 文件对话框已打开:请先在其中选择 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a9d704ba..0312ff1a 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 檔案對話方塊已開啟:請先在其中選擇 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index cf7c08a6..375a271f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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 { - 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>, /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. pub native_menu: Option, + /// 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, } /// 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, + /// 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>, @@ -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 { + // 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 { self.run_count = self.run_count.wrapping_add(1); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { 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. diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 5a38619c..ced2d907 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -185,17 +185,23 @@ fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option, 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, 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, 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)); diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index bd68193d..ca934825 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -154,7 +154,7 @@ pub(crate) fn pick_library_file(app: &mut VectorcraftApp, path: Option) } 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()) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index fef4221f..3250b16b 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -116,15 +116,23 @@ fn report(app: &mut VectorcraftApp, r: Result, done: impl FnOnce( /// Relink images `ids` to a picked file. pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { + crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +} + +fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { 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) { - 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) { + 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` diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs new file mode 100644 index 00000000..463a47fc --- /dev/null +++ b/crates/ui-egui/src/picks.rs @@ -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; + +/// 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 Option>>; + +/// 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), + /// A panel's or a dialog's button. + Call(Rc), +} + +/// A dialog shown off the UI thread, waiting for its answer. +struct Waiting { + request: PickRequest, + answer: Receiver, + 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, + waiting: Option, + /// 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(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 { + 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 { + ask(app, PickRequest::Save(pick.clone()))?.into_iter().next() +} + +/// A folder from the folder picker. +pub(crate) fn folder(app: &mut VectorcraftApp) -> Option { + 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 { + 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 { + // 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| 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); + } +} diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index c3a6f75f..56cb9222 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -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()); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index d327b916..6e9cdd72 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -458,7 +458,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { 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 r(&path).and_then(|b| serde_json::from_slice(&b).map_err(|e| e.to_string())), (None, _) => Err("cancelled".into()), diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs new file mode 100644 index 00000000..28d123ec --- /dev/null +++ b/crates/ui-egui/src/tests_picks.rs @@ -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#""#; + +/// A dialog asked for, and where its answer goes. +type Asked = (PickRequest, Sender>); + +/// The dialogs showing. +#[derive(Clone, Default)] +struct Shown(Rc>>); + +impl Shown { + fn requests(&self) -> Vec { + 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>>) { + let (shown, written) = (Shown::default(), Rc::new(RefCell::new(vec![]))); + let (s, w) = (shown.clone(), written.clone()); + let in_line = |_: &FilePick| -> Option { 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")); +}