diff --git a/ROADMAP.md b/ROADMAP.md index c3b699ce..869db662 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -29,6 +29,7 @@ _Last updated: 2026-10-02._ - Medium Dark theme, categorized and Advanced toolbars, 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. + - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). - Four brightness themes, persistent preferences. - **Tools:** - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. @@ -61,7 +62,7 @@ _Last updated: 2026-10-02._ | M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `` in/out + PDF soft mask); mask-editing mode (click the mask thumbnail; live update) done; freeform gradients pending | 18–27 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); Save / Don't Save / Cancel before closing modified documents; PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 52–73 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 | diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 6d978236..1ce16d9e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -219,11 +219,9 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { x += w; } if let Some(i) = close { - app.session.close_document(i); - if i < app.views.len() { - app.views.remove(i); + if let Err(e) = crate::unsaved::close(app, i) { + app.status(e); } - app.sync_views(); } else if let Some(i) = activate { app.session.set_active(i); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ee7bdedb..15d8fdc3 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -16,6 +16,8 @@ //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?}` //! - `ui.render {path?, scale?}`: render the active artboard headlessly (PNG) //! - `app.open {path}` / `app.save {path?}` / `app.export {format, path, scale?}` / `app.quit` +//! (`file.close`, `file.closeAll` and `app.quit` first open a `saveChanges` dialog for each +//! modified document: `ui.dialog.confirm` saves, set `discard: true` then confirm to discard) use std::sync::mpsc::Sender; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4eafff12..8c50b682 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -138,6 +138,7 @@ fn title(kind: &str) -> &'static str { "allTools" => "All Tools", "exportForScreens" => "Export for Screens", "recolor" => "Recolor Artwork", + crate::unsaved::KIND => "Save Changes", _ => "Dialog", } } @@ -150,6 +151,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { "shortcuts" => return crate::shortcut_editor::confirm(app), "newWorkspace" | "manageWorkspaces" => return crate::workspaces::confirm(app), "findFont" => return crate::find_font::confirm(app), + crate::unsaved::KIND => return crate::unsaved::confirm(app), _ => {} } let copy = d.bool("copy"); @@ -308,6 +310,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut ok = false; let mut cancel = false; + let mut discard = false; egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| { // Illustrator's dialogs are modal but don't dim the canvas (previews stay readable). ui.allocate_rect(ctx.content_rect(), egui::Sense::click()); @@ -322,10 +325,21 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { .frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22))) .show(ctx, |ui| { ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 }); - let heading = if d.kind == "effect" || d.kind == "command" { d.str("__label") } else { title(&d.kind).to_string() }; + if d.kind == crate::unsaved::KIND { + // Long document names wrap instead of widening the dialog. + ui.set_max_width(420.0); + } + let heading = match d.kind.as_str() { + "effect" | "command" => d.str("__label"), + crate::unsaved::KIND => format!("Do you want to save the changes you made to “{}”?", d.str("name")), + k => title(k).to_string(), + }; ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text)); ui.add_space(12.0); match d.kind.as_str() { + crate::unsaved::KIND => { + ui.label(egui::RichText::new("Your changes will be lost if you don't save them.").color(t.text_dim)); + } "exportForScreens" => export_for_screens_ui(app, ui, &mut d), "recolor" => { if recolor_ui(ui, &mut d) || !app.session.in_interaction() { @@ -387,6 +401,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let label = match d.kind.as_str() { "newDocument" => "Create", "exportForScreens" => "Export Artboard", + crate::unsaved::KIND => "Save", _ => "OK", }; if d.kind != "allTools" && widgets::primary_button(ui, label).clicked() { @@ -396,11 +411,19 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { if widgets::secondary_button(ui, if d.kind == "allTools" { "Close" } else { "Cancel" }).clicked() { cancel = true; } + if d.kind == crate::unsaved::KIND { + ui.add_space(28.0); + discard = widgets::secondary_button(ui, "Don't Save").clicked(); + } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" { ok = true; } + if discard { + d.fields.insert("discard".into(), json!(true)); + ok = true; + } let is_effect = d.kind == "effect" || d.kind == "recolor"; app.ui.dialog = Some(d); if cancel { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d96ef8f..6495d65d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod shortcuts; pub mod state; pub mod theme; pub mod toolbar; +pub mod unsaved; pub mod widgets; pub mod workspaces; @@ -420,6 +421,13 @@ impl VectorcraftApp { } self.last_time = now; self.sync_views(); + // The window's close button (or the system quitting the app) asks about unsaved documents. + if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + if let Err(e) = unsaved::close_all(self, "quit") { + self.status(e); + } + } shortcut_editor::sync(&self.ui); prefs_dialog::apply_runtime(self, ctx); self.drain_control(ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8bb7a43..e1fc19d7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -467,10 +467,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - app.ui.status = "quit".into(); - Ok(Value::Null) - } + // Closing asks Save / Don't Save / Cancel for modified documents first (`unsaved`). + "file.close" => match p.get("index").and_then(Value::as_u64).map(|i| i as usize).or(app.session.active_index()) { + Some(i) => crate::unsaved::close(app, i), + None => return None, + }, + "file.closeAll" => crate::unsaved::close_all(app, "closeAll"), + "app.quit" => crate::unsaved::close_all(app, "quit"), _ => return None, }; Some(r) diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs new file mode 100644 index 00000000..57434bda --- /dev/null +++ b/crates/ui-egui/src/unsaved.rs @@ -0,0 +1,192 @@ +//! Closing documents with unsaved changes. File → Close / Close All, a tab's ×, Quit and the +//! window's close button first ask Save / Don't Save / Cancel for each modified document (one +//! dialog per document); Cancel stops the whole operation and a cancelled Save As keeps the +//! document open. +//! +//! The question is an ordinary dialog (`UiState::dialog`, kind [`KIND`]), so agents answer it +//! like any other: `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then +//! confirm discards, `ui.dialog.cancel` cancels. + +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). +pub const KIND: &str = "saveChanges"; + +/// File → Close (or a tab's ×) for document `i`. +pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { + let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty(); + if dirty { ask(app, i, "close") } else { close_now(app, i) } +} + +/// 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 { + if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) { + return ask(app, i, then); + } + if then == "quit" { + // The host closes the window. + app.ui.status = "quit".into(); + return Ok(Value::Null); + } + if app.session.documents().is_empty() { + return Ok(json!({ "closed": 0 })); + } + let r = app.session.execute("file.closeAll", &json!({})).map_err(|e| e.to_string()); + app.sync_views(); + r +} + +/// Is any open document modified (the window's close button must ask first)? +pub fn any_dirty(app: &VectorcraftApp) -> bool { + app.session.documents().iter().any(|d| d.is_dirty()) +} + +/// 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 }))); + Ok(json!({ "pending": KIND })) +} + +/// Close document `i` through the engine command, keeping `views` aligned with the documents. +fn close_now(app: &mut VectorcraftApp, i: usize) -> Result { + let r = app.session.execute("file.close", &json!({ "index": i })).map_err(|e| e.to_string())?; + if i < app.views.len() { + app.views.remove(i); + } + app.sync_views(); + Ok(r) +} + +/// 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 i = i.ok_or("no such document")?; + if !d.bool("discard") { + app.session.set_active(i); + io::save(app, None, false)?; + } + close_now(app, i)?; + match d.str("then").as_str() { + "close" => Ok(Value::Null), + then => close_all(app, then), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use vectorcraft_engine::Session; + + /// An app whose Save As picks `out.vectorcraft` and records written paths. + fn app() -> (VectorcraftApp, Arc>>) { + let written = Arc::new(Mutex::new(vec![])); + let w = written.clone(); + let services = crate::Services { + pick_save: Some(Box::new(|_: &str| Some("out.vectorcraft".to_string()))), + write: Some(Box::new(move |p: &str, _: &[u8]| { + w.lock().unwrap().push(p.to_string()); + Ok(()) + })), + ..Default::default() + }; + (VectorcraftApp::new(Session::new(), services), written) + } + + fn new_doc(app: &mut VectorcraftApp, dirty: bool) { + app.run("file.new", json!({ "width": 100, "height": 100 })).unwrap(); + if dirty { + app.run("shape.rectangle", json!({ "x": 0, "y": 0, "width": 10, "height": 10 })).unwrap(); + } + } + + fn docs(app: &VectorcraftApp) -> usize { + app.session.documents().len() + } + + fn pending(app: &VectorcraftApp) -> Option { + app.ui.dialog.as_ref().filter(|d| d.kind == KIND).and_then(|d| d.fields["index"].as_u64()).map(|i| i as usize) + } + + #[test] + fn clean_documents_close_at_once_keeping_views_aligned() { + let (mut app, _) = app(); + for _ in 0..3 { + new_doc(&mut app, false); + } + assert!(!any_dirty(&app)); + app.views[0].zoom = 2.0; + app.views[2].zoom = 3.0; + close(&mut app, 1).unwrap(); + assert_eq!(docs(&app), 2); + assert_eq!(app.views.iter().map(|v| v.zoom).collect::>(), vec![2.0, 3.0]); + assert_eq!(app.run("file.close", json!({})).unwrap(), Value::Null); + assert_eq!(docs(&app), 1); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn closing_a_modified_document_asks_and_cancel_keeps_it() { + let (mut app, written) = app(); + new_doc(&mut app, true); + new_doc(&mut app, false); + assert!(any_dirty(&app)); + assert_eq!(app.run("file.close", json!({ "index": 0 })).unwrap(), json!({ "pending": KIND })); + assert_eq!((docs(&app), pending(&app), app.session.active_index()), (2, Some(0), Some(0))); + // Cancel (Esc / Cancel button / ui.dialog.cancel) closes nothing. + app.ui.dialog = None; + assert_eq!(docs(&app), 2); + // Save writes the file (Save As for an untitled document), then closes it. + app.run("file.close", json!({ "index": 0 })).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(*written.lock().unwrap(), vec!["out.vectorcraft".to_string()]); + assert_eq!((docs(&app), app.views.len()), (1, 1)); + } + + #[test] + fn a_cancelled_save_as_keeps_the_document_open() { + let (mut app, _) = app(); + app.services.pick_save = Some(Box::new(|_: &str| None)); + new_doc(&mut app, true); + close(&mut app, 0).unwrap(); + assert!(confirm(&mut app).is_err()); + assert_eq!(docs(&app), 1); + assert!(app.ui.dialog.is_none()); + } + + #[test] + fn close_all_and_quit_ask_once_per_modified_document() { + let (mut app, written) = app(); + new_doc(&mut app, false); + new_doc(&mut app, true); + new_doc(&mut app, true); + // Close All: asks about each modified document in turn, then closes the rest. + app.run("file.closeAll", json!({})).unwrap(); + assert_eq!(pending(&app), Some(1)); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + assert_eq!((docs(&app), pending(&app)), (2, Some(1))); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + assert_eq!((docs(&app), app.views.len(), app.ui.dialog.is_none()), (0, 0, true)); + assert!(written.lock().unwrap().is_empty()); + // Quit: nothing modified quits at once; otherwise only after every answer. + new_doc(&mut app, true); + app.run("app.quit", json!({})).unwrap(); + assert_ne!(app.ui.status, "quit"); + assert_eq!(pending(&app), Some(0)); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.status, "quit"); + app.ui.status.clear(); + app.run("app.quit", json!({})).unwrap(); + assert_eq!(app.ui.status, "quit"); + } +}