M4.9: Save / Don't Save / Cancel before closing modified documents — File → Close, Close All, a tab's ×, Quit and the window's close button ask once per modified document (shown first); Cancel stops the whole close/quit, a cancelled Save As keeps the document open; closing keeps per-document views aligned; agents answer the saveChanges dialog with ui.dialog.confirm (discard: true for Don't Save)
This commit is contained in:
parent
3f1f3383d3
commit
b8701977d4
|
|
@ -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 `<mask>` 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 |
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
|||
"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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -467,10 +467,13 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
|||
app.ui.palette_query.clear();
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.quit" => {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Mutex<Vec<String>>>) {
|
||||
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<usize> {
|
||||
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<_>>(), 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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue