diff --git a/Cargo.lock b/Cargo.lock index c8257cdf..707eb27e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4448,6 +4448,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-text", "objc2-foundation 0.3.2", + "pollster 1.0.1", "raw-window-handle", "rfd", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index 6da99a17..7c8d28e5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -127,7 +127,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| -| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). | +| 2026-10-11 | Effect › Brush Strokes: Accented Edges, Angled Strokes, Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes, Sumi-e (G2). Command parameters from agents and files checked before anything changes (paint, place, cuts, dashes, crop, asset ids, anchors, paths, distort; #984–#999); command dialogs keep whole numbers whole, so Create Object Mosaic takes the tiles typed (#1000); stepper arrows take Shift (×10) and Ctrl/Cmd (÷10) like the arrow keys (#991). Exports and Rasterize supersample Art Optimized edges, so shapes meeting edge to edge leave no seam (#983). Character and paragraph styles take leading, kerning, mojikumi and the other attributes left out at their defaults (#1001). Drawing, typing and pasting with no layer shown and unlocked is refused instead of adding art to a hidden or locked layer (#1002). MCP `add_text` gives type on or in a path the colour asked for (#1003). MCP `screenshot` of a connected app renders the artboard asked for (#1004). `vectorcraft-cli run` ends quietly when its reader closes early (#1005). Select › All on Active Artboard selects on the artboard made active, not always the first (#1006). Reverse Gradient keeps off-centre midpoints, mirrored (#1007). A polar grid with no radial dividers draws none (#1008). Use Precise Cursors also covers the Pen's join pointer (#1009). SVGs with one absolute and one unitless root side import at their size, unstretched (#1010). DXF US survey inches, yards and miles open at their size and name (#1011). A file placed into a document with no other links follows its first change even while the app sits idle (#1031). PDF import keeps the style a font name says when the file doesn't embed the font (#1023). Shift selects ruler guides and art together, and Delete removes both (#1022). Open filled paths are hit by their fill on every side, the rotate cursor bows around the corner it is at, the flip icons match their flips, the Color Picker previews on the selection, and Properties › Transform reaches Scale Corners and Scale Strokes & Effects (#1036). Offset Path and the other booleans finish on outlines where the sweep used to loop until memory ran out (linesweeper 0.5, #1028). Floating panel groups stack into sets that move together, collapse to their tabs and dock as extra dock columns that scroll (#1020). Ruler guides live on layers: listed in the Layers panel, shown, hidden, locked, duplicated, merged and deleted with their layer, and moved between layers (#1021). A `.ai` whose editing data uses global process colours (`Xk`/`XK`) or pattern fills keeps its sublayers: the colours come in as global swatches at their tint, the patterns as pattern swatches (#1025). A `.ai` in PostScript form opens through its layers, read from the program itself, also when it names the prolog defining its operators without including it, as Rhino and other CAD apps write it (#1027). A fill or stroke chosen while the Type tool has characters selected colours just those characters (#1063). Scaled type shows its scaled font size, leading, baseline shift and horizontal scale in the Character panel, and a size typed there is the size you get (#1034). Character panel menu › Proportional Metrics sets Japanese and Chinese full-width glyphs on the font's proportional widths in horizontal type (#966). Align, Distribute, Move, Arrange, Lock, Hide, Ungroup and other Object commands that would change nothing no longer record an undo step or mark the document changed (#1060). Spot colours defined in Lab open with their Lab values from `.ai` and PDF files, instead of as wrong RGB colours or their CMYK equivalents (#1032). Build scripts leave a generated source they wouldn't change untouched, so the next build doesn't compile most of the workspace again where file times lose their sub-second part (#1051). A fill colour recolours the gradient mesh point last clicked with the Mesh tool or Direct Selection, and a Mesh tool click on a mesh's point selects that mesh (#1052). The All Tools drawer stays two tools wide instead of filling a wide window (#1054). Text Wrap's Make, Release and Options refuse a list of object ids with a malformed or unknown id instead of skipping it (#1055). A freeform gradient line with a malformed point index is refused instead of being shortened to the indices that are valid (#1056). The Corners dialog (`ui.corners`) refuses a malformed object id or a listed anchor that isn't a corner instead of editing the selection or the other corners (#1057). New View refuses a centre that isn't exactly [x, y] and a rotation that isn't a number, instead of trimming the one and saving the other as 0° (#1058). The command sweeps try junk scale, spacing and artboard values too, which they had skipped since before those bugs were fixed (#1059). EPS and PostScript `arc` and `arcn` with huge angles no longer hang the import, and an arc whose angles are whole turns apart is empty, as the PostScript reference defines it (#1062). Align and Distribute Spacing keep the key object still also when it is inside a selected group or compound path (#1074). A `.ai` whose layers can't be read yet still opens with its artboards where they were (in a grid, say), not in a row (#1068). Layer Options (`ui.layerOptions`) refuses a malformed or unknown row id instead of skipping it or opening on the highlighted rows (#1077). Shaper scribbles given a list of object ids refuse a malformed or unknown id instead of skipping it and editing the shapes that remain (#1078). Delete Width Point (`stroke.widthPoint.remove`) refuses a list of indices with a malformed entry instead of deleting the points that remain, and no longer wraps a huge index round on the web (#1079). Preferences' checkboxes have an outline when the pointer isn't over them, in every interface brightness, as the app's other dialogs' checkboxes do (#1080). The last plain checkboxes (Properties panel snapping and Scale Strokes & Effects, Revolve, Save options, Export for Screens, Variables, plug-in forms) keep a visible box at rest in every theme too (#885). Setting or copying a width point with a malformed index is refused instead of adding a new point or wrapping a huge index round (follow-up to #1079). An Illustrator EPS opened through its layers keeps its own page size and the red outline of text stacked over a dark one, as 0.8.0 did (#1102). The macOS DMG is built so the app in it passes `codesign --verify --strict --deep`, and the release checks that it does (#1085). Swatch and graphic style libraries, Libraries panel files, presets and plug-ins dropped on the window open as File › Open opens them instead of being placed on the canvas (#1083). A key object needs another selected object outside it: a change that leaves one object selected lets the key go, and one selected object aligns to the artboard whatever Align To says (#1088). Align to Artboard aligns to the active artboard, and actions play their Align to Artboard, All on Active Artboard and Paste in Place, in Front and in Back steps on the artboard active when they play (#1105). Align refuses a call with neither direction, and Align, Distribute, Reflect and Shear refuse a direction, axis, target or bounds they don't take instead of falling back to a default (#1106). `object.setProps` deselects the objects it hides or locks, the key object included, as the Layers panel's eye and lock do (#1107). Align, Distribute and Distribute Spacing move pattern tiles with the art when General › Transform Pattern Tiles is on (#1108). Object › Lock › All Artwork Above and Object › Hide › All Artwork Above deselect the selected objects they lock or hide, the key object included (#1109). Save Swatch Library's `count` is the number of swatches the file holds: `.gpl` and `.ase` hold solid colors only, and CSS leaves freeform gradients out (#1110). In a rotated view (Rotate View), an artboard's name stays at its own top-left corner (#1095). Ctrl+Shift+L, C, R, J and F align or justify the paragraphs of the selected type, or those the Type tool's selection touches (#1111). On macOS, file dialogs are sheets on the document window, so resizing one no longer crashes the app; while one is open the menus, Quit and Close wait, and its answer goes to the document that asked (#867). | | 2026-10-10 | Initial live Revolve (Effect › 3D and Materials, #846, #605), in its own `three-d` crate. Progress docs re-measured and restructured to the craftrules standard (this page, `docs/target-app-parity.md`, `gaps.md`, `ui-parity.md`, the format, hardware, localization, effects and type checklists). Same day: v0.8.0; Effect › Distort, Pixelate and Texture filters (13); German interface; Variables (data merge); Graph Type value axes and tick marks; restart when the first frame never reaches the screen (#964) | | 2026-10-09 | Radial Blur, Smart Blur, Unsharp Mask (gap 2 begins); 340 commits, the busiest day | | 2026-10-08 | v0.5.0–v0.7.0; community contributions: Pen and shape-tool modifiers, Layers, Artboards, Japanese composition, Hebrew/Arabic type, interface languages, saved selections | diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 63bd6fd0..fea62dd2 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -63,6 +63,9 @@ raw-window-handle = "0.6" # objc2-foundation. objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] } objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] } +# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0, +# already in the build through eframe. +pollster = "1" [build-dependencies] # Icon + VERSIONINFO for vectorcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index e277f0e3..33c39bee 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -80,8 +80,9 @@ impl eframe::App for App { self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET); } file_access::unconfined(|| self.app.ui(ui)); + // Not while a file dialog's sheet has the keyboard: its name field's composition would go. #[cfg(target_os = "macos")] - if self.app.take_ime_discard() { + if !self.app.file_dialog_open() && self.app.take_ime_discard() { discard_marked_text(); } } @@ -114,8 +115,16 @@ impl App { vectorcraft_ui_egui::background::wait_all(&mut self.app); return; } + // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs + // again on its document when it answers. #[cfg(target_os = "macos")] - open_files(&mut self.app, open_documents::take()); + if !self.app.file_dialog_open() { + // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs + // again on its document when it answers. + if !self.app.file_dialog_open() { + open_files(&mut self.app, open_documents::take()); + } + } self.app.logic(ctx); window::track(ctx, &mut self.app.ui.window); if self.app.ui.status == "quit" { @@ -255,48 +264,99 @@ fn save_prefs(app: &VectorcraftApp) { /// 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(), - }; +/// What rfd's blocking and asynchronous file dialogs are both set up with. +trait DialogSettings: Sized { + fn parent(self, window: &winit::window::Window) -> Self; + fn title(self, title: &str) -> Self; + fn filter(self, name: &str, exts: &[&str]) -> Self; + fn folder(self, folder: &str) -> Self; + fn file_name(self, name: &str) -> Self; +} + +macro_rules! dialog_settings { + ($($dialog:ty),*) => {$( + impl DialogSettings for $dialog { + fn parent(self, window: &winit::window::Window) -> Self { + self.set_parent(window) + } + fn title(self, title: &str) -> Self { + self.set_title(title) + } + fn filter(self, name: &str, exts: &[&str]) -> Self { + self.add_filter(name, exts) + } + fn folder(self, folder: &str) -> Self { + self.set_directory(folder) + } + fn file_name(self, name: &str) -> Self { + self.set_file_name(name) + } + } + )*}; +} + +dialog_settings!(rfd::FileDialog, rfd::AsyncFileDialog); + +/// `d` set up for `request`: its title, file types, folder and suggested name. +fn dialog_for(d: D, request: &PickRequest) -> D { 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::OpenMany => return fileio::place_filters().fold(d.title("Place"), |d, (name, exts)| d.filter(name, exts)), PickRequest::Folder => return d, }; - let d = pick.filters.iter().fold(d, |d, (name, exts)| d.add_filter(*name, exts)); + let d = pick.filters.iter().fold(d, |d, (name, exts)| d.filter(name, exts)); let d = match &pick.folder { - Some(folder) => d.set_directory(folder), + Some(folder) => d.folder(folder), None => d, }; - if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) } + if pick.name.is_empty() { d } else { d.file_name(&pick.name) } +} + +/// A native file dialog for `request` over `parent` (on Linux the portal then shows it over the +/// window and keeps it in front). +#[cfg(not(target_os = "macos"))] +fn file_dialog(request: &PickRequest, parent: &Parent) -> rfd::FileDialog { + match parent { + Some(w) => dialog_for(rfd::FileDialog::new().parent(w), request), + None => dialog_for(rfd::FileDialog::new(), request), + } +} + +/// Before `request`'s dialog shows: a save dialog's folder (the Templates folder) may not exist yet. +fn make_folder(request: &PickRequest) { + if let PickRequest::Save(FilePick { folder: Some(folder), .. }) = request { + let _ = std::fs::create_dir_all(folder); + } } /// Show `dialog` for `request` (on the calling thread, until it closes) → the paths picked. +#[cfg(not(target_os = "macos"))] fn show_dialog(dialog: rfd::FileDialog, request: &PickRequest) -> Vec { + make_folder(request); 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::Save(_) => 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. +#[cfg(not(target_os = "macos"))] fn pick_now(request: PickRequest, parent: &Parent) -> Vec { show_dialog(file_dialog(&request, parent), &request) } +/// macOS shows no dialog in line: its modal loop would run inside winit's event handler, and an +/// event winit held back meanwhile aborts the app (#867). Dialogs are sheets there ([`start_pick`]); +/// one asked for any other way (a bug: outside a command, a dialog's OK or a button) picks nothing. +#[cfg(target_os = "macos")] +fn pick_now(request: PickRequest, _parent: &Parent) -> Vec { + log::error!("no file dialog shown for {request:?}: macOS shows them as sheets, from commands, dialogs' OK and buttons only"); + Vec::new() +} + /// 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}")) @@ -332,34 +392,97 @@ fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> { fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string()) } -/// Linux and macOS: 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 on Linux, -/// which then offers to kill the window as not responding (#592); on macOS the panel ran modally -/// inside the window's event handler, and resizing it delivered window events to that handler -/// again, which crashed the app (#867). From another thread, rfd runs the panel on the main thread -/// from the run loop, outside the handler. -fn start_pick(request: PickRequest, parent: &Parent) -> Option>> { +/// 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). +#[cfg(not(target_os = "macos"))] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { let dialog = file_dialog(&request, parent); let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); 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)); + ui.request_repaint(); }) .ok()?; Some(rx) } -fn services(parent: Parent) -> Services { +/// The paths a dialog shown as a sheet picks, once it closes. +#[cfg(target_os = "macos")] +type SheetAnswer = std::pin::Pin> + Send>>; + +/// macOS: show the dialog for `request` as a sheet on `parent`'s window, answering on the receiver +/// (#867). Shown in line, the panel ran AppKit's modal event loop inside winit's event handler: an +/// event winit had held back meanwhile (one came as a resize of the panel ended) then re-entered +/// the handler, and winit's panic there, inside an AppKit callback, aborted the app. The sheet +/// returns at once and the window keeps running; a thread of its own waits for the answer. +#[cfg(target_os = "macos")] +fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Option>> { + use winit::raw_window_handle::HasWindowHandle as _; + // rfd runs the panel modally after all unless it has the running app's window to hold the + // sheet (and from another thread it can't reach the window): no dialog then (`pick_now`). + let mtm = objc2::MainThreadMarker::new(); + let running = mtm.is_some_and(|mtm| objc2_app_kit::NSApplication::sharedApplication(mtm).isRunning()); + let Some(window) = parent.as_ref().filter(|w| running && w.window_handle().is_ok()) else { + log::error!("no window to show the file dialog on (main thread: {}, running: {running})", mtm.is_some()); + return None; + }; + // The thread first, so the answer of a sheet once shown always has somewhere to go. + let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::(); + let (tx, rx) = std::sync::mpsc::channel(); + let ui = ui.clone(); + std::thread::Builder::new() + .name("file-dialog".into()) + .spawn(move || { + if let Ok(answer) = sheet_rx.recv() { + // The app gone meanwhile has no use for the answer. + let _ = tx.send(pollster::block_on(answer)); + ui.request_repaint(); + } + }) + .ok()?; + make_folder(&request); + // rfd builds the panel and begins the sheet here, on the main thread (as it must). + let dialog = dialog_for(rfd::AsyncFileDialog::new().parent(window), &request); + let path = |f: &rfd::FileHandle| f.path().to_string_lossy().to_string(); + let answer: SheetAnswer = match request { + PickRequest::Open(_) => { + let picked = dialog.pick_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + PickRequest::OpenMany => { + let picked = dialog.pick_files(); + Box::pin(async move { picked.await.unwrap_or_default().iter().map(path).collect() }) + } + PickRequest::Save(_) => { + let picked = dialog.save_file(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + PickRequest::Folder => { + let picked = dialog.pick_folder(); + Box::pin(async move { picked.await.iter().map(path).collect() }) + } + }; + // The thread can't be gone: it waits for this. If it were, its receiver answers as cancelled. + let _ = sheet_tx.send(answer); + Some(rx) +} + +/// The host's services; `ui` is woken when a file dialog answers. +fn services(parent: Parent, ui: egui::Context) -> Services { let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); Services { 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 dialogs run the window's events while they are open; Linux's don't, and macOS - // ones re-enter the window's event handler (#867). - start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5)) as vectorcraft_ui_egui::picks::StartPick), + // Linux shows dialogs on a thread of their own (#592), macOS as sheets (#867); Windows + // dialogs run the window's events while they are open. + start_pick: cfg!(unix).then(|| Box::new(move |request: PickRequest| start_pick(request, &p5, &ui)) as vectorcraft_ui_egui::picks::StartPick), + dialogs_are_sheets: cfg!(target_os = "macos"), 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. @@ -584,7 +707,7 @@ fn main() -> std::process::ExitCode { "VectorCraft", options, Box::new(move |cc| { - let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned())); + let mut app = VectorcraftApp::new(Session::new(), services(cc.winit_window().cloned(), cc.egui_ctx.clone())); 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/control.rs b/crates/ui-egui/src/control.rs index 6ca73099..8bbc647f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -104,6 +104,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "nativeMenuBar": app.services.native_menu.is_some(), // Saves and exports still being written in the background (Background Save / Export). "background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::>(), + // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting + // for the person at the computer: what asked for it runs again when it answers. + "fileDialog": app.picks.waiting_kind(), }) } diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index c50bedf3..4fbac19a 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -146,12 +146,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } } 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) + let r = match a { + // Their file dialogs, shown off the UI thread, import into or export from the dialog + // as it is then. + Action::Import | Action::Export => { + let (current, export) = (preset.name.clone(), matches!(a, Action::Export)); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t)) + } + a => run(app, d, a, &preset.name), }; if let Err(e) = r { app.status(e); diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index d0c6afaf..c8c442ac 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -103,12 +103,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); }); 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) + let r = match a { + // Their file dialogs, shown off the UI thread, import into or export from the dialog + // as it is then. + Action::Import | Action::Export => { + let (current, export) = (preset.name.clone(), matches!(a, Action::Export)); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t)) + } + a => run(app, d, a, &preset.name), }; if let Err(e) = r { app.status(e); diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index 3dd13855..a182e66f 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -151,12 +151,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); let name = preset.name.clone(); 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) + let r = match a { + // Their file dialogs, shown off the UI thread, import into or export from the dialog + // as it is then. + Action::Import | Action::Export => { + let (current, export) = (name.clone(), matches!(a, Action::Export)); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t)) + } + a => run(app, d, a, &name), }; if let Err(e) = r { app.status(e); diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index 36b95e4b..747269d1 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -112,12 +112,14 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); }); 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) + let r = match a { + // Their file dialogs, shown off the UI thread, import into or export from the dialog + // as it is then. + Action::Import | Action::Export => { + let (current, export) = (name.clone(), matches!(a, Action::Export)); + crate::picks::in_dialog(app, d, move |app, d| run(app, d, if export { Action::Export } else { Action::Import }, ¤t)) + } + a => run(app, d, a, &name), }; if let Err(e) = r { app.status(e); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 345eb587..fd8d1021 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -656,6 +656,8 @@ 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 + The document was closed while its file dialog was open Dokument byl zavřen, zatímco bylo otevřené jeho dialogové okno souboru + The dialog was closed before its file dialog answered Dialogové okno se zavřelo dřív, než odpovědělo jeho dialogové okno souboru Shaper Shaper Expand Shaper Group Rozšířit skupinu Shaper Release Shaper Group Uvolnit skupinu Shaper diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 0240988b..76e8c87e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -3833,6 +3833,8 @@ theme Appearance Erscheinungsbild Images: link to the file instead of only embedding it. Bilder: mit der Datei verknüpfen, statt sie nur einzubetten. VectorCraft documents: one locked object, updated when the file changes. VectorCraft-Dokumente: ein gesperrtes Objekt, das aktualisiert wird, wenn sich die Datei ändert. A file dialog is open: choose there first Ein Dateidialog ist geöffnet: Treffen Sie zuerst dort Ihre Auswahl + The document was closed while its file dialog was open Das Dokument wurde geschlossen, während sein Dateidialog geöffnet war + The dialog was closed before its file dialog answered Das Dialogfeld wurde geschlossen, bevor sein Dateidialog geantwortet hat Shaper Shaper Expand Shaper Group Shaper-Gruppe umwandeln Release Shaper Group Shaper-Gruppe zurückwandeln diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 367bf363..860895aa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3934,6 +3934,8 @@ axis Both Ambos 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 + The document was closed while its file dialog was open Se cerró el documento mientras su cuadro de diálogo de archivos estaba abierto + The dialog was closed before its file dialog answered El cuadro de diálogo se cerró antes de que respondiera su cuadro de diálogo de archivos Shaper Shaper Expand Shaper Group Expandir grupo Shaper Release Shaper Group Soltar grupo Shaper diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 22921e24..f2d4bf1d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3931,6 +3931,8 @@ axis Both Les deux Images: link to the file instead of only embedding it. Images : lier le fichier au lieu de seulement l’incorporer. VectorCraft documents: one locked object, updated when the file changes. Documents VectorCraft : un objet verrouillé, mis à jour quand le fichier change. A file dialog is open: choose there first Une boîte de dialogue de fichiers est ouverte : faites d’abord votre choix dans celle-ci + The document was closed while its file dialog was open Le document a été fermé pendant que sa boîte de dialogue de fichiers était ouverte + The dialog was closed before its file dialog answered La boîte de dialogue a été fermée avant la réponse de sa boîte de dialogue de fichiers Shaper Shaper Expand Shaper Group Décomposer le groupe Shaper Release Shaper Group Libérer le groupe Shaper diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce2fd9c6..762e22b6 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -3934,6 +3934,8 @@ axis Both Entrambi 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ì + The document was closed while its file dialog was open Il documento è stato chiuso mentre la sua finestra di dialogo dei file era aperta + The dialog was closed before its file dialog answered La finestra di dialogo è stata chiusa prima che rispondesse la sua finestra di dialogo dei file Shaper Shaper Expand Shaper Group Espandi gruppo Shaper Release Shaper Group Rilascia gruppo Shaper diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 5f0bd7bd..d5902eb7 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3290,6 +3290,8 @@ theme Appearance 外観 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 ファイルダイアログが開いています。先にそちらで選択してください + The document was closed while its file dialog was open ファイルダイアログが開いている間にドキュメントが閉じられました + The dialog was closed before its file dialog answered ファイルダイアログが応答する前にダイアログが閉じられました Shaper Shaper Expand Shaper Group Shaper グループを拡張 Release Shaper Group Shaper グループを解除 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e4befb68..59469824 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2718,6 +2718,8 @@ 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 + The document was closed while its file dialog was open O documento foi fechado enquanto sua caixa de diálogo de arquivos estava aberta + The dialog was closed before its file dialog answered O diálogo foi fechado antes que sua caixa de diálogo de arquivos respondesse Shaper Formador Expand Shaper Group Expandir Grupo Formador Release Shaper Group Liberar Grupo Formador diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c50e420d..e336a260 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -3930,6 +3930,8 @@ axis Both Оба 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 Открыто окно выбора файла: сначала сделайте выбор в нём + The document was closed while its file dialog was open Документ был закрыт, пока было открыто его окно выбора файла + The dialog was closed before its file dialog answered Диалоговое окно было закрыто до того, как ответило его окно выбора файла Shaper Shaper Expand Shaper Group Разобрать группу Shaper Release Shaper Group Освободить группу Shaper diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 03c2bd9e..47e7a373 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3915,6 +3915,8 @@ axis Both Обидві 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 Відкрито вікно вибору файлу: спочатку зробіть вибір у ньому + The document was closed while its file dialog was open Документ було закрито, поки було відкрито його вікно вибору файлу + The dialog was closed before its file dialog answered Діалогове вікно було закрито раніше, ніж відповіло його вікно вибору файлу Shaper Формоутворення Expand Shaper Group Розібрати групу формоутворення Release Shaper Group Звільнити групу формоутворення diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 576ea511..62e14437 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -3540,6 +3540,8 @@ axis Both 两者 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 文件对话框已打开:请先在其中选择 + The document was closed while its file dialog was open 文件对话框打开期间,文档已关闭 + The dialog was closed before its file dialog answered 文件对话框给出结果之前,对话框已关闭 Expand Shaper Group 扩展 Shaper 组 Release Shaper Group 释放 Shaper 组 Select Shaper Face 选择 Shaper 区域 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 45e53634..b68f0f28 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3319,6 +3319,8 @@ axis Both 兩者 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 檔案對話方塊已開啟:請先在其中選擇 + The document was closed while its file dialog was open 檔案對話方塊開啟期間,文件已關閉 + The dialog was closed before its file dialog answered 檔案對話方塊回應之前,對話方塊已關閉 Shaper Shaper Expand Shaper Group 展開 Shaper 群組 Release Shaper Group 釋放 Shaper 群組 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a659d0a8..8c93463a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -243,9 +243,13 @@ pub struct Services { /// 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. + /// and the compositor finds it not answering, #592) or as sheets on the window (macOS, where a + /// dialog in line aborts the app, #867): what asked runs again with the answer ([`picks`]). + /// Without it they are shown in line. pub start_pick: Option, + /// [`Self::start_pick`]'s dialogs are sheets on the window (macOS): while one is open the app + /// neither quits nor closes documents, as under the modal dialog before. + pub dialogs_are_sheets: bool, } /// Cached canvas raster. @@ -679,8 +683,13 @@ impl VectorcraftApp { let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). let roots = self.automation_roots.clone(); - let outcome = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) - .unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)"))); + let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) { + Ok(outcome) => outcome, + Err(msg) => { + self.picks.recover(); + control::err(format!("internal error: {msg} (please report this bug)")) + } + }; match outcome { control::Outcome::Done(v) => { let _ = reply.send(v); @@ -816,6 +825,18 @@ impl VectorcraftApp { } } + /// A file dialog is open off the UI thread (a sheet on macOS, [`picks`]): what asked for it runs + /// again when it answers. + pub fn file_dialog_open(&self) -> bool { + self.picks.is_waiting() + } + + /// A file dialog is open as a sheet on the window ([`Services::dialogs_are_sheets`]): nothing + /// closes or quits until it answers. + pub fn file_sheet_open(&self) -> bool { + self.services.dialogs_are_sheets && self.file_dialog_open() + } + /// Show a transient status message. pub fn status(&mut self, s: impl Into) { self.ui.status = s.into(); @@ -904,6 +925,7 @@ impl VectorcraftApp { pub fn logic(&mut self, ctx: &egui::Context) { let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx))) { + self.picks.recover(); self.status(format!("Internal error: {msg} (please report this bug)")); } } @@ -957,10 +979,14 @@ impl VectorcraftApp { ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // The window's close button (or the system quitting the app) asks about unsaved documents, - // once the saves running in the background are done. + // once the saves running in the background are done. Not while a file dialog's sheet is on + // the window: what asked for it runs again when it answers. if ctx.input(|i| i.viewport().close_requested()) { background::wait_all(self); - if unsaved::any_dirty(self) { + if self.file_sheet_open() { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + self.status(picks::busy()); + } else if unsaved::any_dirty(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); if let Err(e) = unsaved::close_all(self, "quit") { self.status(e); @@ -1078,7 +1104,9 @@ impl VectorcraftApp { // The native menu's key equivalents become the input their keys make, ahead of what came // after them (see `native_menu`). let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); - if !keys.is_empty() { + // The menu bar does nothing while a file dialog is open (`native_menu`): a key that raced it + // going inert is dropped. + if !keys.is_empty() && !self.file_dialog_open() { let events: Vec = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect(); raw.events.splice(0..0, events); } @@ -1135,6 +1163,7 @@ impl VectorcraftApp { pub fn ui(&mut self, ui: &mut egui::Ui) { let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten(); if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui))) { + self.picks.recover(); self.status(format!("Internal error: {msg} (please report this bug)")); } } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index cb2c1330..8126539b 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -458,7 +458,31 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option { /// VectorCraft's menus as a Mac menu bar, as they show now. pub fn layout(app: &VectorcraftApp) -> Layout { - mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current()) + let mut layout = mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current()); + if app.file_dialog_open() { + for menu in &mut layout.bar.menus { + inert(&mut menu.children); + } + } + layout +} + +/// While a file dialog is open (a sheet on the window, [`crate::picks`]) the menu bar does +/// nothing, as under the modal dialog before: what asked for the dialog runs again when it +/// answers, on the document as it was. Hide and Minimize still work. +fn modal_allows(item: &Item) -> bool { + matches!(item.command, Some(HIDE | HIDE_OTHERS | MINIMIZE)) +} + +/// Disable every item of `nodes` (and their submenus) [`modal_allows`] doesn't keep. +fn inert(nodes: &mut [Node]) { + for node in nodes { + match node { + Node::Item(item) => item.enabled &= modal_allows(item), + Node::Submenu { children, .. } => inert(children), + Node::Separator | Node::Header(_) | Node::Standard(_) => {} + } + } } // ----------------------------------------------------------------------------- the app's side @@ -516,8 +540,13 @@ impl NativeMenu { /// Run the items clicked in the native menu since the last frame, like in-window menu clicks. pub fn run(app: &mut VectorcraftApp, ctx: &egui::Context) { + let picking = app.file_dialog_open(); let Some(menu) = app.services.native_menu.as_mut() else { return }; for it in std::mem::take(&mut menu.clicks) { + // A click that raced the menu going inert under a file dialog. + if picking && !modal_allows(&it) { + continue; + } match it.command { Some(MINIMIZE) => ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)), Some(command) => { @@ -555,8 +584,8 @@ pub fn sync(app: &mut VectorcraftApp, ctx: &egui::Context) { /// What the menus' rows depend on, cheaply (no allocation): commands run, the active document, /// its revision (edits, selection and view changes), history and clipboard, the language, -/// shortcuts and plug-ins. `input` forces a change on frames with a click or a key press, which -/// covers what the hash doesn't list (panels shown, a tool picked). +/// shortcuts, plug-ins and an open file dialog. `input` forces a change on frames with a click or +/// a key press, which covers what the hash doesn't list (panels shown, a tool picked). fn state_hash(app: &VectorcraftApp, input: Option) -> u64 { let mut h = DefaultHasher::new(); input.hash(&mut h); @@ -567,6 +596,7 @@ fn state_hash(app: &VectorcraftApp, input: Option) -> u64 { (d.uid, d.revision, d.history.undo.len(), d.history.redo.len(), d.transparency_grid, d.print_tiling).hash(&mut h); } (app.system_paste, app.last_effect.is_some(), app.ui.recent_files.len(), app.ui.recent_files.first(), app.recent_fonts().len()).hash(&mut h); + app.file_dialog_open().hash(&mut h); (crate::i18n::current().code(), menus::plugin_revision()).hash(&mut h); crate::shortcut_editor::GENERATION.load(std::sync::atomic::Ordering::Relaxed).hash(&mut h); h.finish() diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index d14e8417..f2f848a6 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -730,8 +730,10 @@ impl LibraryKind for GraphicStyleLibraries { } fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); + // As a command, so its file dialog, shown off the UI thread, loads the library when it + // answers. if menu_item(ui, tl!("Other Library…"), true, false) - && let Err(e) = other_library(app, None) + && let Err(e) = app.run("window.graphicStyleLibrary.other", json!({})) { app.status(e); } diff --git a/crates/ui-egui/src/panels/separations.rs b/crates/ui-egui/src/panels/separations.rs index b1a9c918..ec06dc39 100644 --- a/crates/ui-egui/src/panels/separations.rs +++ b/crates/ui-egui/src/panels/separations.rs @@ -108,14 +108,14 @@ fn settings_section(app: &mut VectorcraftApp, ui: &mut Ui) { run(app, "edit.colorSettings", json!({"bpc": !st.bpc})); } ui.horizontal(|ui| { - if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() - && let Some(path) = app - .services - .pick_open - .as_mut() - .and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() })) - { - run(app, "color.loadProfile", json!({ "path": path })); + if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() { + // Its file dialog, shown off the UI thread, loads the profile when it answers. + crate::picks::button(app, |app| { + let pick = crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() }; + if let Some(path) = crate::picks::open(app, &pick) { + run(app, "color.loadProfile", json!({ "path": path })); + } + }); } if widgets::flat_button(ui, tl!("Gamut Check"), 100.0).clicked() { match app.run("color.gamutCheck", json!({})) { diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index cdf38e4f..d4465dae 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -1092,8 +1092,10 @@ impl LibraryKind for SwatchLibraries { } fn menu_tail(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); + // As a command, so its file dialog, shown off the UI thread, loads the library when it + // answers. if menu_item(ui, tl!("Other Library…"), true, false) - && let Err(e) = other_library(app, None) + && let Err(e) = app.run("window.swatchLibrary.other", json!({})) { app.status(e); } diff --git a/crates/ui-egui/src/picks.rs b/crates/ui-egui/src/picks.rs index 8ee50853..fb6d8854 100644 --- a/crates/ui-egui/src/picks.rs +++ b/crates/ui-egui/src/picks.rs @@ -1,13 +1,20 @@ -//! File dialogs that leave the window running (#592). +//! File dialogs that leave the window running (#592, #867). //! //! 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 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, and on macOS they -//! re-enter the window's event handler, which crashed the app when the panel was resized (#867). -//! 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. +//! closes. On Windows 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 (#592); on macOS the +//! panel's modal loop runs inside winit's event handler, and an event winit had held back meanwhile +//! re-enters it and aborts the app (#867). There the host shows dialogs off the UI thread (Linux) +//! or as sheets (macOS) ([`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, on the document it asked for, and gets the picked path at once. Everything that asks +//! must run as an entry ([`as_entry`]): outside one the dialog is shown in line, which macOS +//! refuses. +//! +//! While a dialog is open ([`VectorcraftApp::file_dialog_open`]) another is refused; on macOS, +//! where it is a sheet on the window, the menu bar does nothing and the app neither quits nor +//! closes documents, as under the modal dialog before. use std::rc::Rc; use std::sync::mpsc::{Receiver, TryRecvError}; @@ -48,11 +55,35 @@ pub(crate) enum Entry { Call(Rc), } +/// Commands that ask for a file but act on no document: their answer goes on even when the +/// document active at the time has closed meanwhile. +const DOCUMENT_FREE: &[&str] = &[ + "file.open", + "file.newFromTemplate", + "ui.installPlugin", + "window.swatchLibrary.other", + "window.graphicStyleLibrary.other", + "shortcuts.import", + "shortcuts.export", +]; + +impl Entry { + /// The document it acts on when it runs again: the active one, unless it acts on none. + fn document(&self, app: &VectorcraftApp) -> Option { + match self { + Entry::Command(id, _) if DOCUMENT_FREE.contains(&id.as_str()) => None, + _ => app.session.active().map(|d| d.uid), + } + } +} + /// A dialog shown off the UI thread, waiting for its answer. struct Waiting { request: PickRequest, answer: Receiver, entry: Entry, + /// The active document when it was asked for ([`vectorcraft_engine::DocState::uid`]). + doc: Option, } /// The app's file dialogs ([`VectorcraftApp`]'s `picks`). @@ -63,6 +94,8 @@ pub(crate) struct Picks { waiting: Option, /// A picked answer for the entry running again: the dialog it asks for gets it. answer: Option<(PickRequest, PickAnswer)>, + /// Another dialog was refused while this one is open ([`busy`]). + refused: bool, } impl Picks { @@ -70,6 +103,39 @@ impl Picks { pub(crate) fn is_entry_free(&self) -> bool { self.entry.is_none() } + + /// A dialog shown off the UI thread is open. + pub(crate) fn is_waiting(&self) -> bool { + self.waiting.is_some() + } + + /// After a panic the guard caught (a bug): no entry is running any more, and an answer kept for + /// one is stale. The open dialog's answer still arrives. + pub(crate) fn recover(&mut self) { + self.entry = None; + self.answer = None; + } + + /// The kind of dialog open off the UI thread (`ui.inspect`'s `fileDialog`). + pub(crate) fn waiting_kind(&self) -> Option<&'static str> { + self.waiting.as_ref().map(|w| match w.request { + PickRequest::Open(_) => "open", + PickRequest::OpenMany => "place", + PickRequest::Save(_) => "save", + PickRequest::Folder => "folder", + }) + } +} + +/// What the status bar says when a dialog is asked for while another is open. +pub(crate) fn busy() -> &'static str { + tl!("A file dialog is open: choose there first") +} + +/// What the status bar says when the dialog whose button asked closed before the file dialog +/// answered. +pub(crate) fn dialog_gone() -> &'static str { + tl!("The dialog was closed before its file dialog answered") } /// Run `f` as `entry`: a dialog it asks for off the UI thread runs `entry` again when it @@ -102,8 +168,11 @@ pub(crate) fn in_dialog( 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(()) }; + let Some(mut d) = app.ui.dialog.take_if(|d| d.kind == kind) else { + app.status(dialog_gone()); + return; + }; + let r = again(app, &mut d); app.ui.dialog = Some(d); if let Err(e) = r { app.status(e); @@ -168,15 +237,23 @@ fn ask(app: &mut VectorcraftApp, request: PickRequest) -> Option { 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; + if app.services.start_pick.is_some() && app.picks.waiting.is_some() { + app.status(busy()); + app.picks.refused = true; + return None; + } + match (app.picks.entry.clone(), app.services.start_pick.as_mut()) { + (Some(entry), Some(start)) => { + if let Some(answer) = start(request.clone()) { + let doc = entry.document(app); + app.picks.waiting = Some(Waiting { request, answer, entry, doc }); + return None; + } } + // Shown in line it holds the window (Linux) or is refused (macOS): what asks must run as + // an entry. + (None, Some(_)) => log::warn!("a file dialog asked for outside a command, a dialog's OK or a button: {request:?}"), + _ => {} } let s = &mut app.services; let one = |p: Option| Some(p.into_iter().collect()); @@ -199,20 +276,39 @@ pub(crate) fn poll(app: &mut VectorcraftApp, ctx: &egui::Context) { 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. + // What asked gave up for now: no "cancelled" while the dialog is open, unless it was + // another dialog, refused. if app.ui.status == "cancelled" { - app.ui.status.clear(); + app.ui.status = if std::mem::take(&mut app.picks.refused) { busy().into() } else { String::new() }; } - ctx.request_repaint_after(std::time::Duration::from_millis(100)); + // The host wakes the window when the dialog answers; this is in case it can't. + ctx.request_repaint_after(std::time::Duration::from_secs(1)); return; } // The dialog's thread is gone: as cancelled. Err(TryRecvError::Disconnected) => vec![], }; - let Some(Waiting { request, entry, .. }) = app.picks.waiting.take() else { return }; + let Some(Waiting { request, entry, doc, .. }) = app.picks.waiting.take() else { return }; + app.picks.refused = false; + // Once more: what waited for the dialog (files from the Finder, the menu bar) goes on. + ctx.request_repaint(); if answer.is_empty() { return; } + // On the document it was asked for: another may be active by now (opened from the Finder, by + // an agent), or it may be gone. + if let Some(uid) = doc { + let Some(i) = app.session.documents().iter().position(|d| d.uid == uid) else { + app.status(tl!("The document was closed while its file dialog was open")); + return; + }; + if app.session.active_index() != Some(i) + && let Err(e) = app.run("document.activate", serde_json::json!({ "index": i })) + { + app.status(e); + return; + } + } app.picks.answer = Some((request, answer)); rerun(app, entry); // An entry that took another way this time leaves the answer unused. @@ -227,8 +323,10 @@ fn rerun(app: &mut VectorcraftApp, entry: Entry) { app.ui.dialog = Some(*d); crate::dialogs::confirm(app).map(|_| ()) } + // An entry again, so a dialog it asks for next is shown off the UI thread too. Entry::Call(f) => { - f(app); + let again = f.clone(); + as_entry(app, move || Entry::Call(again), |app| f(app)); Ok(()) } }; diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 6e9cdd72..605644e7 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -464,6 +464,8 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option w(&path, &bytes).map(|_| json!({"path": path})), None => Err("no file writer".into()), }, + // Its save dialog is open: the file is written when it answers. + None if app.file_dialog_open() => Err("cancelled".into()), None => match app.services.download.as_mut() { Some(dl) => { dl("VectorCraft Shortcuts.json", &bytes); @@ -698,33 +700,13 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let import = d.fields.remove("__import").is_some(); let export = d.fields.remove("__export").is_some(); app.ui.dialog = Some(d); + // Their file dialogs, shown off the UI thread, import into or export from the dialog as it is + // when they answer. if import { - // Import replaces the working set (committed on OK like other edits). - let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone()); - match run_command(app, "shortcuts.import", &json!({})) { - Some(Ok(_)) => { - let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone()); - (app.ui.shortcut_overrides, app.ui.shortcut_set) = saved; - sync(&app.ui); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({}))); - d.fields.insert("set".into(), json!(s)); - d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set."))); - } - } - Some(Err(e)) if e != "cancelled" => app.status(e), - _ => {} - } + crate::picks::button(app, import_into_dialog); } if export { - let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default(); - let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov); - let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set); - if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) { - app.status(e); - } - app.ui.shortcut_overrides = saved; - app.ui.shortcut_set = saved_set; + crate::picks::button(app, export_from_dialog); } if cancel { app.ui.dialog = None; @@ -733,6 +715,51 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { } } +/// The Keyboard Shortcuts dialog, while it is open. +fn shortcuts_dialog(app: &VectorcraftApp) -> bool { + app.ui.dialog.as_ref().is_some_and(|d| d.kind == "shortcuts") +} + +/// The dialog's Import…: the imported set replaces its working set (committed on OK like other +/// edits). +fn import_into_dialog(app: &mut VectorcraftApp) { + if !shortcuts_dialog(app) { + app.status(crate::picks::dialog_gone()); + return; + } + let saved = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone()); + match run_command(app, "shortcuts.import", &json!({})) { + Some(Ok(_)) => { + let (o, s) = (app.ui.shortcut_overrides.clone(), app.ui.shortcut_set.clone()); + (app.ui.shortcut_overrides, app.ui.shortcut_set) = saved; + sync(&app.ui); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("overrides".into(), serde_json::to_value(&o).unwrap_or(json!({}))); + d.fields.insert("set".into(), json!(s)); + d.fields.insert("__message".into(), json!(tl!("Imported. Press OK to keep the imported set."))); + } + } + Some(Err(e)) if e != "cancelled" => app.status(e), + _ => {} + } +} + +/// The dialog's Export…: its working set, as edited so far. +fn export_from_dialog(app: &mut VectorcraftApp) { + if !shortcuts_dialog(app) { + app.status(crate::picks::dialog_gone()); + return; + } + let (ov, set) = app.ui.dialog.as_ref().map(|d| (dialog_overrides(d), d.str("set"))).unwrap_or_default(); + let saved = std::mem::replace(&mut app.ui.shortcut_overrides, ov); + let saved_set = std::mem::replace(&mut app.ui.shortcut_set, set); + if let Some(Err(e)) = run_command(app, "shortcuts.export", &json!({})) { + app.status(e); + } + app.ui.shortcut_overrides = saved; + app.ui.shortcut_set = saved_set; +} + /// Apply a recorded chord to `key` in the working set, warning about (and resolving) conflicts. fn record(d: &mut Dialog, ov: &mut BTreeMap, key: &str, chord: &str) { d.fields.insert("__recording".into(), json!("")); diff --git a/crates/ui-egui/src/tests_nativemenu.rs b/crates/ui-egui/src/tests_nativemenu.rs index d2e6546c..9f17829c 100644 --- a/crates/ui-egui/src/tests_nativemenu.rs +++ b/crates/ui-egui/src/tests_nativemenu.rs @@ -429,3 +429,58 @@ fn the_app_bar_hides_its_menus_with_the_mac_menu_bar() { assert_eq!(n(&native) + 1, n(&in_window), "{title}"); } } + +/// While a file dialog's sheet is on the window the menu bar does nothing, as under the modal +/// dialog before: every item is disabled but Hide, Hide Others and Minimize, a click that raced it +/// isn't run (Minimize still is), and a key equivalent doesn't reach the app. Once the dialog +/// answers, the menus are as before and work again. +#[test] +fn the_menu_bar_is_inert_while_a_file_dialog_is_open() { + let mut m = Mac::new(true); + m.app.services.dialogs_are_sheets = true; + let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut m.app.services); + m.app.run("shape.rectangle", json!({"x": 200, "y": 150, "width": 20, "height": 20})).unwrap(); + m.app.run("select.none", json!({})).unwrap(); + m.frame(vec![]); + let rows = |bar: &MenuBar| bar.items().iter().map(|it| (it.command, it.params.clone(), it.enabled)).collect::>(); + let before = m.fake.last(); + let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE]; + assert!(system.iter().all(|c| find(&before, c).enabled)); + assert!(m.app.run("file.open", json!({})).is_err()); + assert!(m.app.file_dialog_open()); + m.frame(vec![]); + let inert = m.fake.last(); + for it in inert.items() { + let kept = it.command.is_some_and(|c| system.contains(&c)); + assert_eq!(it.enabled, kept, "{:?} {} while the dialog is open", it.command, it.label); + } + // A click that raced the menu going inert isn't run; Minimize is. + let rulers = (m.app.ui.view.rulers, find(&before, "view.rulers").clone()); + m.fake.report(native_menu::Event::Click(rulers.1.clone())); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, rulers.0); + m.fake.report(native_menu::Event::Click(find(&inert, native_menu::MINIMIZE).clone())); + assert!(m.frame(vec![]).viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Minimized(true))); + // A key equivalent that raced it doesn't reach egui. + let mut k = crate::shortcuts::parse("Cmd+A").unwrap(); + k.modifiers.mac_cmd = k.modifiers.command; + m.fake.report(native_menu::Event::Key(k)); + let mut raw = egui::RawInput::default(); + m.app.raw_input_hook(&mut raw); + assert!(!raw.events.iter().any(|e| matches!(e, Event::Key { .. })), "{:?}", raw.events); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 0, "⌘A selected nothing"); + // Cancelled: the menus are as before, and work again. + shown.answer(&[]); + m.frame(vec![]); + m.frame(vec![]); + assert!(!m.app.file_dialog_open()); + // Every row from before is back as it was (tests running alongside may add plug-in rows). + let after = rows(&m.fake.last()); + assert!(rows(&before).iter().all(|r| after.contains(r)), "the menus are as before"); + m.fake.report(native_menu::Event::Click(rulers.1)); + m.frame(vec![]); + assert_eq!(m.app.ui.view.rulers, !rulers.0); + m.key_equivalent("Cmd+A"); + assert_eq!(m.selected(), 2, "⌘A selected every object"); +} diff --git a/crates/ui-egui/src/tests_picks.rs b/crates/ui-egui/src/tests_picks.rs index 28d123ec..8cfbd311 100644 --- a/crates/ui-egui/src/tests_picks.rs +++ b/crates/ui-egui/src/tests_picks.rs @@ -1,65 +1,159 @@ -//! File dialogs shown off the UI thread (Linux, #592): what asked runs again with the answer. +//! File dialogs shown off the UI thread (Linux, #592) or as sheets on the window (macOS, #867): +//! what asked runs again with the answer, on the document it asked for; while a sheet is open +//! nothing closes or quits; and nothing outside `picks` shows a dialog in line. use std::cell::RefCell; +use std::collections::BTreeMap; use std::rc::Rc; use std::sync::mpsc::{Sender, channel}; -use serde_json::json; +use egui::{Pos2, Rect, vec2}; +use serde_json::{Value, json}; use vectorcraft_engine::Session; +use crate::panels::graphic_styles::GraphicStyleLibraries; +use crate::panels::library_panel::library_menu; +use crate::panels::swatches::SwatchLibraries; use crate::picks::{self, PickRequest}; use crate::state::Dialog; use crate::{FilePick, Services, VectorcraftApp}; const SVG: &[u8] = br#""#; +/// What the status bar says when an answer comes for a document no longer open. +const CLOSED: &str = "The document was closed while its file dialog was open"; + /// A dialog asked for, and where its answer goes. type Asked = (PickRequest, Sender>); /// The dialogs showing. #[derive(Clone, Default)] -struct Shown(Rc>>); +pub(crate) struct Shown(Rc>>); impl Shown { - fn requests(&self) -> Vec { + pub(crate) fn requests(&self) -> Vec { self.0.borrow().iter().map(|(r, _)| r.clone()).collect() } /// The last dialog shown answers `paths`. - fn answer(&self, paths: &[&str]) { + pub(crate) fn answer(&self, paths: &[&str]) { let (_, tx) = self.0.borrow_mut().pop().expect("a dialog is showing"); tx.send(paths.iter().map(|p| p.to_string()).collect()).unwrap(); } } -/// 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![]))); +/// The paths files were written to. +pub(crate) type Written = Rc>>; + +/// Give `services` dialogs shown off the UI thread (none in line: those fail the test), a reader of +/// [`SVG`] at any path and a writer that lists the paths written. +pub(crate) fn off_the_ui_thread(services: &mut Services) -> (Shown, Written) { + let (shown, written) = (Shown::default(), Written::default()); let (s, w) = (shown.clone(), written.clone()); let in_line = |_: &FilePick| -> Option { panic!("no dialog on the UI thread") }; - 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() - }; + services.pick_open = Some(Box::new(in_line)); + services.pick_save = Some(Box::new(in_line)); + services.pick_open_multi = Some(Box::new(|| panic!("no dialog on the UI thread"))); + services.pick_folder = Some(Box::new(|| panic!("no dialog on the UI thread"))); + services.read = Some(Box::new(|_: &str| Ok(SVG.to_vec()))); + services.write = Some(Box::new(move |path: &str, _: &[u8]| { + w.borrow_mut().push(path.to_string()); + Ok(()) + })); + services.start_pick = Some(Box::new(move |request: PickRequest| { + let (tx, rx) = channel(); + s.0.borrow_mut().push((request, tx)); + Some(rx) + })); + (shown, written) +} + +/// A desktop app whose dialogs show off the UI thread, as sheets on the window when `sheets` +/// (macOS) or as windows of their own (Linux). +fn with_dialogs(sheets: bool) -> (VectorcraftApp, Shown, Written) { + let mut services = Services { dialogs_are_sheets: sheets, ..Default::default() }; + let (shown, written) = off_the_ui_thread(&mut services); (VectorcraftApp::new(Session::new(), services), shown, written) } +/// The macOS app: its dialogs are sheets. +fn app() -> (VectorcraftApp, Shown, Written) { + with_dialogs(true) +} + fn poll(app: &mut VectorcraftApp) { picks::poll(app, &egui::Context::default()); } +fn dialog_kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +// ------------------------------------------------------------------------- driven like a person + +/// Room for a dialog, a panel or a menu (the libraries menu lists every library). +const SCREEN: (f32, f32) = (1600.0, 6000.0); + +/// One frame of `draw` with `events` → its texts and where they were drawn. +fn frame( + ctx: &egui::Context, + app: &mut VectorcraftApp, + events: Vec, + draw: &impl Fn(&mut VectorcraftApp, &mut egui::Ui), +) -> Vec<(String, Rect)> { + fn texts(s: &egui::Shape, out: &mut Vec<(String, Rect)>) { + match s { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(SCREEN.0, SCREEN.1))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| draw(app, ui)); + out.textures_delta.clear(); + let mut v = vec![]; + out.shapes.iter().for_each(|c| texts(&c.shape, &mut v)); + v +} + +/// Where text `label` was drawn (menu items pad theirs). +fn at(texts: &[(String, Rect)], label: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t.trim() == label) + .unwrap_or_else(|| panic!("no `{label}` in {:?}", texts.iter().map(|t| &t.0).collect::>())) + .1 + .center() +} + +/// Draw `draw` until it settles, then click its text `label`. +fn click(app: &mut VectorcraftApp, label: &str, draw: impl Fn(&mut VectorcraftApp, &mut egui::Ui)) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, app, vec![], &draw); + frame(&ctx, app, vec![], &draw); + let pos = at(&frame(&ctx, app, vec![], &draw), label); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&ctx, app, vec![egui::Event::PointerMoved(pos), button(true), button(false)], &draw); +} + +/// The open dialog, drawn as the app draws it. +fn dialogs(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + crate::dialogs::show(app, ui.ctx()); +} + +/// The active document written to `Poster.vectorcraft` in a folder of its own named after `tag` +/// → (the folder, the file). +fn saved_document(app: &mut VectorcraftApp, tag: &str) -> (std::path::PathBuf, String) { + let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + let dir = std::env::temp_dir().join(format!("vc-picks-{tag}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Poster.vectorcraft"); + std::fs::write(&file, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + (dir, file.to_string_lossy().into_owned()) +} + +// ------------------------------------------------------------------------------------- the tests + #[test] fn file_open_opens_what_its_dialog_picks_when_it_answers() { let (mut app, shown, _) = app(); @@ -95,7 +189,7 @@ fn save_as_goes_on_with_the_path_picked() { 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")); + assert_eq!(dialog_kind(&app), Some("svgOptions")); crate::dialogs::confirm(&mut app).unwrap(); assert_eq!(*written.borrow(), ["/art/poster.svg"]); } @@ -111,3 +205,613 @@ fn a_dialogs_folder_button_fills_its_field_when_the_picker_answers() { poll(&mut app); assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("folder")).as_deref(), Some("/out")); } + +/// Separations Preview › Load Profile… asks off the UI thread, and the profile picked is loaded +/// when the dialog answers (here a missing file, whose error names it). +#[test] +fn load_profile_asks_off_the_ui_thread() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + click(&mut app, "Load Profile…", crate::panels::separations::show); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(p)] if p.filters.iter().any(|(_, e)| e.contains(&"icc")))); + poll(&mut app); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + shown.answer(&["/no/such/profile.icc"]); + poll(&mut app); + assert!(app.ui.status.contains("/no/such/profile.icc"), "color.loadProfile ran on the path: {}", app.ui.status); +} + +/// The Swatches libraries menu's Other Library… asks off the UI thread; the file picked (another +/// document) loads as a library and opens in the library panel. +#[test] +fn other_swatch_library_asks_off_the_ui_thread() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("swatch.new", json!({"name": "Signal", "color": "#ff3300"})).unwrap(); + let (dir, file) = saved_document(&mut app, "swatches"); + click(&mut app, "Other Library…", library_menu::); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + poll(&mut app); + assert!(app.ui.library_panel.is_none() && app.ui.status.is_empty(), "{}", app.ui.status); + shown.answer(&[&file]); + poll(&mut app); + let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); + assert_eq!(open.kind, "swatches"); + let (_, lib) = vectorcraft_engine::cmd::swatchlib::library(&app.session, &open.id).unwrap(); + assert!(lib.swatch("Signal").is_some(), "{}", open.id); + let _ = std::fs::remove_dir_all(dir); +} + +/// The Graphic Styles libraries menu's Other Library… asks off the UI thread; the file picked +/// (another document) loads as a library and opens in the library panel. +#[test] +fn other_graphic_style_library_asks_off_the_ui_thread() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap()["id"].clone(); + app.run("graphicStyle.new", json!({"name": "Poster Look", "id": id})).unwrap(); + let (dir, file) = saved_document(&mut app, "styles"); + click(&mut app, "Other Library…", library_menu::); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + shown.answer(&[&file]); + poll(&mut app); + let open = app.ui.library_panel.clone().unwrap_or_else(|| panic!("no library open: {}", app.ui.status)); + assert_eq!(open.kind, "graphicStyles"); + let (_, lib) = vectorcraft_engine::cmd::stylelib::library(&app.session, &open.id).unwrap(); + assert!(lib.style("Poster Look").is_some(), "{}", open.id); + let _ = std::fs::remove_dir_all(dir); +} + +/// The Keyboard Shortcuts dialog's Import… and Export… ask off the UI thread: the set imported +/// replaces the dialog's working set (kept on OK), Export… writes it where the dialog answers, and +/// an answer once the dialog is closed does nothing. +#[test] +fn keyboard_shortcuts_import_and_export_ask_off_the_ui_thread() { + let (mut app, shown, written) = app(); + let set = crate::shortcut_editor::export_json("Custom", &BTreeMap::from([("view.grid".to_string(), "Ctrl+Alt+G".to_string())])); + let (_, want) = crate::shortcut_editor::import_json(&set).unwrap(); + let bytes = serde_json::to_vec(&set).unwrap(); + app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone()))); + app.run("edit.keyboardShortcuts", json!({})).unwrap(); + click(&mut app, "Import…", dialogs); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + shown.answer(&["/keys/mine.json"]); + poll(&mut app); + let d = app.ui.dialog.as_ref().expect("the dialog stays open"); + assert_eq!((d.kind.as_str(), &d.fields["overrides"]), ("shortcuts", &serde_json::to_value(&want).unwrap())); + assert!(d.str("__message").starts_with("Imported"), "{}", d.str("__message")); + assert!(app.ui.shortcut_overrides.is_empty(), "kept only on OK"); + click(&mut app, "Export…", dialogs); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); + shown.answer(&["/keys/out.json"]); + poll(&mut app); + assert_eq!(*written.borrow(), ["/keys/out.json"]); + // Closed before the dialog answers: nothing is written, and the status says why. + click(&mut app, "Export…", dialogs); + app.ui.dialog = None; + shown.answer(&["/keys/late.json"]); + poll(&mut app); + assert_eq!(*written.borrow(), ["/keys/out.json"]); + assert_eq!(app.ui.status, picks::dialog_gone()); + // The command waits for its dialog too: no data in place of the file. + assert_eq!(app.run("shortcuts.export", json!({})), Err("cancelled".to_string())); + shown.answer(&["/keys/command.json"]); + poll(&mut app); + assert_eq!(*written.borrow(), ["/keys/out.json", "/keys/command.json"]); +} + +/// A dialog's folder button answered after the dialog closed fills nothing, and the status says +/// why. +#[test] +fn a_folder_for_a_closed_dialog_is_dropped() { + let (mut app, shown, _) = app(); + let mut d = Dialog::new("exportForScreens", json!({"folder": ""})); + app.ui.dialog = Some(d.clone()); + picks::folder_field(&mut app, &mut d, "folder"); + app.ui.dialog = None; + shown.answer(&["/out"]); + poll(&mut app); + assert_eq!(dialog_kind(&app), None); + assert_eq!(app.ui.status, picks::dialog_gone()); +} + +/// Export… in the PDF, Print, Transparency Flattener and Perspective Grid Presets dialogs asks off +/// the UI thread and writes the selected preset where the dialog answers; the dialog stays open. +#[test] +fn preset_dialogs_export_asks_off_the_ui_thread() { + use vectorcraft_engine::cmd::{flatten, pdfcmds, perspgrid, printpresets}; + let cases = [ + ("pdf.preset.save", json!({"name": "Mine"}), "ui.pdfPresetsDialog", pdfcmds::PRESET_FORMAT), + ("print.presets.save", json!({"name": "Mine", "settings": {"copies": 2}}), "ui.printPresetsDialog", printpresets::PRESET_FORMAT), + ("flattener.presets.save", json!({"name": "Mine", "balance": 20}), "ui.flattenerPresetsDialog", flatten::PRESET_FORMAT), + ("perspective.presets.save", json!({"name": "Mine"}), "ui.perspectivePresetsDialog", perspgrid::PRESET_FORMAT), + ]; + for (save, params, open, ext) in cases { + let (mut app, shown, written) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run(save, params).unwrap(); + app.run(open, json!({"selected": "Mine"})).unwrap(); + let kind = dialog_kind(&app).unwrap().to_string(); + click(&mut app, "Export…", dialogs); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)]), "{open}: {:?}", shown.requests()); + assert!(written.borrow().is_empty(), "{open}"); + let path = format!("/presets/mine.{ext}"); + shown.answer(&[&path]); + poll(&mut app); + assert_eq!(*written.borrow(), [path], "{open}: {}", app.ui.status); + assert_eq!(dialog_kind(&app), Some(kind.as_str()), "{open}"); + } +} + +/// Save As answers on the document that asked for it: the one made active meanwhile isn't saved, +/// and the one that asked is active again. +#[test] +fn save_as_saves_the_document_that_asked() { + let (mut app, shown, written) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!(app.run("file.saveAs", json!({})).is_err()); + app.run("document.activate", json!({"index": 1})).unwrap(); + shown.answer(&["/art/a.svg"]); + poll(&mut app); + assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0])); + assert_eq!(dialog_kind(&app), Some("svgOptions")); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(*written.borrow(), ["/art/a.svg"]); + let paths: Vec> = app.session.documents().iter().map(|d| d.path.clone()).collect(); + assert_eq!(paths, [Some("/art/a.svg".to_string()), None]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0])); +} + +/// An answer for a document closed meanwhile (here through the engine, as an agent can) is dropped: +/// nothing is written, no other document is saved, and the status says why. +#[test] +fn an_answer_for_a_closed_document_is_dropped() { + let (mut app, shown, written) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!(app.run("file.saveAs", json!({})).is_err()); + app.session.execute("file.close", &json!({"index": 0})).unwrap(); + app.sync_views(); + shown.answer(&["/art/a.svg"]); + poll(&mut app); + assert_eq!(app.ui.status, CLOSED); + assert!(written.borrow().is_empty()); + assert_eq!(dialog_kind(&app), None, "no SVG Options"); + assert_eq!(app.session.documents().iter().map(|d| d.path.clone()).collect::>(), [None]); + assert!(!app.file_dialog_open()); +} + +/// A button whose action asks for an open dialog and, once that answers, a save dialog shows both +/// off the UI thread, then finishes with both paths. +#[test] +fn a_buttons_second_dialog_shows_off_the_ui_thread_too() { + let (mut app, shown, _) = app(); + let (from, done) = (Rc::new(RefCell::new(None::)), Rc::new(RefCell::new(vec![]))); + let (f, d) = (from.clone(), done.clone()); + picks::button(&mut app, move |app| { + if f.borrow().is_none() { + let picked = picks::open(app, &FilePick::default()); + *f.borrow_mut() = picked; + } + let Some(source) = f.borrow().clone() else { return }; + if let Some(to) = picks::save(app, &FilePick::named("copy.svg")) { + d.borrow_mut().push((source, to)); + } + }); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Open(_)])); + shown.answer(&["/art/in.svg"]); + poll(&mut app); + assert_eq!(from.borrow().as_deref(), Some("/art/in.svg")); + assert_eq!(shown.requests(), [PickRequest::Save(FilePick::named("copy.svg"))]); + assert!(app.file_dialog_open() && done.borrow().is_empty()); + shown.answer(&["/art/out.svg"]); + poll(&mut app); + assert_eq!(*done.borrow(), [("/art/in.svg".to_string(), "/art/out.svg".to_string())]); + assert!(!app.file_dialog_open()); +} + +/// While a dialog's sheet is on the window (macOS) nothing quits or closes: Quit, Close and Close +/// All say a file dialog is open and close nothing; once it answers or is cancelled they work +/// again. +#[test] +fn nothing_closes_or_quits_while_a_sheet_is_open() { + let (mut app, shown, _) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + assert!(!app.file_dialog_open() && !app.file_sheet_open()); + assert!(app.run("file.open", json!({})).is_err()); + assert!(app.file_dialog_open() && app.file_sheet_open()); + let refused = |app: &mut VectorcraftApp| { + for (id, p) in [("app.quit", json!({})), ("file.close", json!({})), ("file.close", json!({"index": 0})), ("file.closeAll", json!({}))] { + assert_eq!(app.run(id, p.clone()), Err(picks::busy().to_string()), "{id} {p}"); + } + assert_eq!(app.session.documents().len(), 2); + assert_ne!(app.ui.status, "quit"); + assert_eq!(dialog_kind(app), None, "no Save Changes either"); + }; + refused(&mut app); + // Answered: the file opens, and documents close again. + shown.answer(&["/art/c.svg"]); + poll(&mut app); + assert!(!app.file_dialog_open()); + assert_eq!(app.session.documents().len(), 3); + app.run("file.close", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 2); + // Cancelled: the same. + assert!(app.run("file.saveAs", json!({})).is_err()); + refused(&mut app); + shown.answer(&[]); + poll(&mut app); + assert!(!app.file_dialog_open()); + app.run("file.closeAll", json!({})).unwrap(); + assert!(app.session.documents().is_empty()); + assert!(app.run("file.open", json!({})).is_err()); + assert!(app.run("app.quit", json!({})).is_err()); + shown.answer(&[]); + poll(&mut app); + app.run("app.quit", json!({})).unwrap(); + assert_eq!(app.ui.status, "quit"); +} + +/// On Linux the dialog is a window of its own, not a sheet: Close and Quit go on while it is open, +/// and its answer for the document closed meanwhile is dropped, nothing written. +#[test] +fn on_linux_documents_close_while_a_dialog_is_open() { + let (mut app, shown, written) = with_dialogs(false); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 200, "height": 160})).unwrap(); + app.run("document.activate", json!({"index": 0})).unwrap(); + assert!(app.run("file.saveAs", json!({})).is_err()); + assert!(app.file_dialog_open() && !app.file_sheet_open()); + app.run("file.close", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 1); + shown.answer(&["/art/a.svg"]); + poll(&mut app); + assert_eq!(app.ui.status, CLOSED); + assert!(written.borrow().is_empty()); + assert_eq!(dialog_kind(&app), None); + // Quit goes on too. + assert!(app.run("file.open", json!({})).is_err()); + assert!(app.file_dialog_open()); + app.run("app.quit", json!({})).unwrap(); + assert_eq!(app.ui.status, "quit"); +} + +/// File › Open acts on no document: its answer opens the file even when the document active at +/// the time closed meanwhile (Linux). +#[test] +fn an_open_answer_goes_on_after_the_active_document_closes() { + let (mut app, shown, _) = with_dialogs(false); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + assert!(app.run("file.open", json!({})).is_err()); + app.run("file.close", json!({})).unwrap(); + assert!(app.session.documents().is_empty()); + shown.answer(&["/art/b.svg"]); + poll(&mut app); + assert_eq!(app.session.documents().iter().map(|d| d.path.clone()).collect::>(), [Some("/art/b.svg".to_string())]); + assert_ne!(app.ui.status, CLOSED); +} + +/// Save Changes' Save, answered by its Save dialog, saves and closes the document it asked about, +/// even when an earlier document closed meanwhile (Linux), then asks about the next one. +#[test] +fn save_changes_answers_for_its_own_document() { + let (mut app, shown, written) = with_dialogs(false); + let modified = |app: &mut VectorcraftApp| app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + // X clean, A modified and untitled, C modified with a file. + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + modified(&mut app); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + modified(&mut app); + app.session.active_mut().unwrap().path = Some("/art/c.vectorcraft".into()); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + app.run("file.closeAll", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["uid"].clone()), Some(json!(uids[1])), "asks about A"); + // Save: A's Save dialog shows, and X closes meanwhile. + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); + app.run("file.close", json!({"index": 0})).unwrap(); + shown.answer(&["/art/a.vectorcraft"]); + poll(&mut app); + assert_eq!(*written.borrow(), ["/art/a.vectorcraft"], "{}", app.ui.status); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), [uids[2]], "A closed, C untouched"); + let d = app.ui.dialog.as_ref().expect("Save Changes asks about C next"); + assert_eq!((d.kind.as_str(), &d.fields["uid"]), (crate::unsaved::KIND, &json!(uids[2]))); +} + +/// A dialog asked for while another is open is refused, and the status bar says so rather than +/// "cancelled". +#[test] +fn a_refused_dialog_says_why() { + let (mut app, shown, _) = app(); + assert!(app.run("file.open", json!({})).is_err()); + poll(&mut app); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); + crate::menus::invoke(&mut app, "file.open", json!({})); + assert_eq!(shown.requests().len(), 1); + poll(&mut app); + assert_eq!(app.ui.status, picks::busy()); + shown.answer(&[]); + poll(&mut app); + // The next dialog's own "cancelled" is cleared as before. + crate::menus::invoke(&mut app, "file.open", json!({})); + poll(&mut app); + assert!(app.ui.status.is_empty(), "{}", app.ui.status); +} + +/// After a panic inside what asks (a bug the frame's guard catches and recovers from), no entry is +/// left behind: the next command's answer runs that command, not the one that panicked. +#[test] +fn a_panic_inside_an_entry_leaves_none_behind() { + let (mut app, shown, written) = app(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + let r = vectorcraft_engine::guard::catch_panic(|| picks::button(&mut app, |_| panic!("a bug"))); + assert!(r.is_err()); + app.picks.recover(); + assert!(app.picks.is_entry_free()); + assert!(app.run("file.open", json!({})).is_err()); + shown.answer(&["/art/b.svg"]); + poll(&mut app); + assert_eq!(app.session.documents().len(), 2, "File › Open ran again"); + assert!(written.borrow().is_empty()); +} + +/// One whole frame of the app (its logic and its window); `close` presses the window's close +/// button. +fn app_frame(ctx: &egui::Context, app: &mut VectorcraftApp, close: bool) -> Vec { + let mut raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 900.0))), ..Default::default() }; + if close { + raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + } + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default() +} + +/// The window's close button doesn't close the window while a dialog's sheet is on it: the status +/// says a file dialog is open. On Linux, and once the sheet answers, it closes. +#[test] +fn the_window_stays_open_while_a_sheet_is_on_it() { + for sheets in [true, false] { + let (mut app, shown, _) = with_dialogs(sheets); + let ctx = egui::Context::default(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app_frame(&ctx, &mut app, false); + assert!(app.run("file.open", json!({})).is_err()); + let commands = app_frame(&ctx, &mut app, true); + assert_eq!(commands.contains(&egui::ViewportCommand::CancelClose), sheets, "sheets: {sheets}"); + assert_eq!(app.ui.status == picks::busy(), sheets, "sheets: {sheets}: {}", app.ui.status); + assert_eq!(app.session.documents().len(), 1); + shown.answer(&[]); + app_frame(&ctx, &mut app, false); + assert!(!app_frame(&ctx, &mut app, true).contains(&egui::ViewportCommand::CancelClose), "sheets: {sheets}"); + } +} + +/// `ui.inspect`'s `fileDialog` is the kind of dialog open (open, save, place, folder), null once it +/// answers. +#[test] +fn inspect_tells_the_file_dialog_open() { + let (mut app, shown, _) = app(); + let ctx = egui::Context::default(); + let shows = |app: &VectorcraftApp| crate::control::inspect(app, &ctx)["fileDialog"].clone(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + assert_eq!(shows(&app), Value::Null); + /// What asks for a kind of dialog. + type Ask = fn(&mut VectorcraftApp); + let asks: [(&str, Ask); 4] = [ + ("open", |app| assert!(app.run("file.open", json!({})).is_err())), + ("save", |app| assert!(app.run("file.saveAs", json!({})).is_err())), + ("place", |app| assert!(app.run("file.place", json!({})).is_err())), + ("folder", |app| { + let mut d = Dialog::new("exportForScreens", json!({"folder": ""})); + app.ui.dialog = Some(d.clone()); + picks::folder_field(app, &mut d, "folder"); + }), + ]; + for (kind, ask) in asks { + ask(&mut app); + assert!(app.file_dialog_open(), "{kind}"); + assert_eq!(shows(&app), json!(kind)); + shown.answer(&[]); + poll(&mut app); + assert!(!app.file_dialog_open(), "{kind}"); + assert_eq!(shows(&app), Value::Null, "{kind}"); + } +} + +// ------------------------------------------------------------------------ nothing asks in line + +/// `src` with comments and the insides of string and char literals blanked: only code is left. +fn code_only(src: &str) -> String { + let b: Vec = src.chars().collect(); + let ident = |i: usize| i < b.len() && (b[i].is_alphanumeric() || b[i] == '_'); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + while let Some(&c) = b.get(i) { + let next = b.get(i + 1).copied(); + if c == '/' && next == Some('/') { + while i < b.len() && b[i] != '\n' { + i += 1; + } + } else if c == '/' && next == Some('*') { + let mut depth = 0; + while i < b.len() { + if b[i] == '/' && b.get(i + 1) == Some(&'*') { + depth += 1; + i += 2; + } else if b[i] == '*' && b.get(i + 1) == Some(&'/') { + depth -= 1; + i += 2; + if depth == 0 { + break; + } + } else { + i += 1; + } + } + out.push(' '); + } else if c == 'r' && (i == 0 || !ident(i - 1) || (b[i - 1] == 'b' && (i < 2 || !ident(i - 2)))) && matches!(next, Some('"' | '#')) { + // A raw string r"…", r#"…"# (or a raw identifier r#name, kept). + let hashes = b[i + 1..].iter().take_while(|&&c| c == '#').count(); + if b.get(i + 1 + hashes) != Some(&'"') { + out.push(c); + i += 1; + continue; + } + let close: Vec = std::iter::once('"').chain(std::iter::repeat_n('#', hashes)).collect(); + i += 2 + hashes; + while i < b.len() && !b[i..].starts_with(&close) { + i += 1; + } + i += close.len(); + out.push_str("\"\""); + } else if c == '"' { + i += 1; + while i < b.len() && b[i] != '"' { + i += if b[i] == '\\' { 2 } else { 1 }; + } + i += 1; + out.push_str("\"\""); + } else if c == '\'' && next == Some('\\') { + // An escaped char literal. + i += 2; + while i < b.len() && b[i] != '\'' { + i += 1; + } + i += 1; + out.push_str("' '"); + } else if c == '\'' && b.get(i + 2) == Some(&'\'') { + i += 3; + out.push_str("' '"); + } else { + // A lifetime's quote, or code. + out.push(c); + i += 1; + } + } + out +} + +/// `code` (from [`code_only`]) without its `#[cfg(test)]` items: test modules and their +/// declarations, test helpers. +fn without_tests(code: &str) -> String { + let mut out = String::new(); + let mut rest = code; + while let Some(at) = rest.find("#[cfg(test)]") { + out.push_str(&rest[..at]); + let item = &rest[at..]; + // The item ends at its first `;` outside braces, or with the braces it opens. + let mut depth = 0usize; + let mut end = item.len(); + for (k, ch) in item.char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth = depth.saturating_sub(1); + if depth == 0 { + end = k + 1; + break; + } + } + ';' if depth == 0 => { + end = k + 1; + break; + } + _ => {} + } + } + rest = &item[end..]; + } + out.push_str(rest); + out +} + +/// The calls in `code` (from [`code_only`]) of the host's in-line dialogs: [`Services`]' +/// `pick_open`, `pick_save`, `pick_folder` and `pick_open_multi` used other than to ask whether +/// the host has them (`.is_some()`, `.is_none()`). +fn in_line_dialogs(code: &str) -> Vec { + let mut found = vec![]; + for name in ["pick_open", "pick_save", "pick_folder", "pick_open_multi"] { + let field = format!(".{name}"); + let mut rest = code; + while let Some(at) = rest.find(&field) { + rest = &rest[at + field.len()..]; + // Another field whose name starts with this one's (`pick_open_multi`). + if rest.starts_with(|c: char| c.is_alphanumeric() || c == '_') { + continue; + } + // What follows, over line breaks. + let next: String = rest.chars().filter(|c| !c.is_whitespace()).take(40).collect(); + if !next.starts_with(".is_some()") && !next.starts_with(".is_none()") { + found.push(format!("{field}{next}")); + } + } + } + found +} + +/// Nothing but `picks` shows the host's file dialogs: everything else asks through it, which shows +/// them off the UI thread when what asks runs as an entry (#867). Asking whether the host has one +/// is fine. +#[test] +fn only_picks_shows_the_hosts_dialogs() { + // The scan finds the in-line call Load Profile… made, split over lines, and not presence checks + // or comments. + let before = r#"if widgets::flat_button(ui, tl!("Load Profile…"), 110.0).clicked() + && let Some(path) = app + .services + .pick_open + .as_mut() + .and_then(|f| f(&crate::FilePick { filters: vec![("ICC Profiles", &["icc", "icm"])], ..Default::default() })) + {"#; + assert_eq!(in_line_dialogs(&code_only(before)).len(), 1); + assert_eq!(in_line_dialogs(&code_only("let s = &mut app.services; let p = (s.pick_save.as_mut()?)(&pick);")).len(), 1); + assert_eq!(in_line_dialogs(&code_only("let Some(f) = &mut s.pick_open else { return };")).len(), 1); + let fine = "let paths = if app.services.pick_open_multi.is_some() { 1 } else { 2 }; // app.services.pick_open.as_mut()\n\ + let t = \"services.pick_folder.take()\"; let c = '\"'; let u = s.pick_folder.is_none();"; + assert_eq!(in_line_dialogs(&code_only(fine)), Vec::::new()); + assert_eq!( + without_tests(&code_only("fn a() {}\n#[cfg(test)]\nmod tests;\n#[cfg(test)]\nmod t { fn b() { x.pick_open.take(); } }\nfn c() {}")), + "fn a() {}\n\n\nfn c() {}" + ); + + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + // Each source file's code without its tests, by its path under `src`. + let mut scanned = BTreeMap::new(); + let mut stack = vec![dir.clone()]; + while let Some(d) = stack.pop() { + for entry in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let path = entry.path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string(); + if path.is_dir() { + stack.push(path); + } else if name.ends_with(".rs") && !name.starts_with("tests") && name != "picks.rs" { + let key = path.strip_prefix(&dir).unwrap().to_string_lossy().replace('\\', "/"); + scanned.insert(key, without_tests(&code_only(&std::fs::read_to_string(&path).unwrap()))); + } + } + } + // Code after a test item is still read: lib.rs declares its test modules first, and the + // shortcut editor's mirror macro has a test-only block. + for (file, code) in [ + ("lib.rs", "fnfile_dialog_open"), + ("shortcut_editor.rs", "fnimport_into_dialog"), + ("ui_fonts.rs", "fnpainted_text"), + ("place.rs", ".pick_open_multi.is_some()"), + ("panels/separations.rs", "picks::open("), + ] { + let flat: String = scanned.get(file).unwrap_or_else(|| panic!("{file} not read")).chars().filter(|c| !c.is_whitespace()).collect(); + assert!(flat.contains(code), "{file} lost `{code}`"); + } + let found: Vec = scanned.iter().flat_map(|(file, code)| in_line_dialogs(code).into_iter().map(move |c| format!("{file}: {c}"))).collect(); + assert!(found.is_empty(), "file dialogs shown in line, not through `picks`: {found:#?}"); +} diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 829d426f..c98bf96a 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -12,12 +12,20 @@ use serde_json::{Value, json}; use crate::state::Dialog; use crate::{VectorcraftApp, io}; -/// Dialog kind. Fields: `index` (document), `name` (its title), `then` (`close`, `closeAll` or -/// `quit`: what continues after this document) and `discard` (Don't Save). +/// Dialog kind. Fields: `index` (document), `uid` (the same document, which `index` may no longer +/// point at once a file dialog answers), `name` (its title), `then` (`close`, `closeAll` or `quit`: +/// what continues after this document) and `discard` (Don't Save). pub const KIND: &str = "saveChanges"; +/// Nothing closes while a file dialog's sheet is on the window (macOS): what asked for it runs +/// again when it answers. (Elsewhere the dialog is a window of its own, and closing goes on.) +fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { + if app.file_sheet_open() { Err(crate::picks::busy().into()) } else { Ok(()) } +} + /// File → Close (or a tab's ×) for document `i`. pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { + refuse_while_picking(app)?; // A save still running decides whether there is anything left to save. crate::background::wait_all(app); let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty(); @@ -27,6 +35,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { /// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document, /// or finish once none is left. pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result { + refuse_while_picking(app)?; crate::background::wait_all(app); if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) { return ask(app, i, then); @@ -54,8 +63,9 @@ pub fn any_dirty(app: &VectorcraftApp) -> bool { /// Show document `i` and ask whether to save it. fn ask(app: &mut VectorcraftApp, i: usize, then: &str) -> Result { app.session.set_active(i); - let name = app.session.documents()[i].title(); - app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "name": name, "then": then }))); + let doc = &app.session.documents()[i]; + let (name, uid) = (doc.title(), doc.uid); + app.ui.dialog = Some(Dialog::new(KIND, json!({ "index": i, "uid": uid, "name": name, "then": then }))); Ok(json!({ "pending": KIND })) } @@ -72,7 +82,12 @@ fn close_now(app: &mut VectorcraftApp, i: usize) -> Result { /// Answer the open dialog: save (or, with `discard`, don't), close the document and carry on. pub fn confirm(app: &mut VectorcraftApp) -> Result { let d = app.ui.dialog.take().ok_or("no dialog open")?; - let i = d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < app.session.documents().len()); + let docs = app.session.documents(); + // By its uid: documents may have closed while its Save dialog was open. + let i = match d.fields.get("uid").and_then(Value::as_u64) { + Some(uid) => docs.iter().position(|doc| doc.uid == uid), + None => d.fields.get("index").and_then(Value::as_u64).map(|i| i as usize).filter(|i| *i < docs.len()), + }; let i = i.ok_or("no such document")?; if !d.bool("discard") { app.session.set_active(i); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5e51beb9..599cf223 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,7 @@ the two can still win that race. | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | | `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | -| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way) | +| `ui.inspect` | | tool, UI state, `screenMode` (0 normal, 1 full screen with menu bar, 2 full screen, 3 Presentation Mode), `taskBar` (`pinned`, and `rect` `[x, y, w, h]` while the Contextual Task Bar shows), `freeTransformWidget` (`[x, y, w, h]` while the Free Transform widget shows), view, canvas rect, window size, perf, background saves and exports still running, `nativeMenuBar` (true when the menus are in the macOS menu bar rather than the window; `ui.menu.list` lists the in-window menus either way), `fileDialog` (`"open"`, `"place"`, `"save"` or `"folder"` while a native file dialog is open, `null` otherwise; see below) | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | | `ui.tool.select` / `ui.tool.list` | `{tool}` | | @@ -75,6 +75,7 @@ the two can still win that race. | `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields | | `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning | | `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` | +| Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | | Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). Fields: `tab` (`artboards` \| `assets`), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) |