diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index e808cf2463..ff290952fb 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -862,6 +862,28 @@ fn save_changes_answers_for_its_own_document() { assert_eq!((d.kind.as_str(), &d.fields["uid"]), (crate::unsaved::KIND, &json!(uids[2]))); } +/// Closing a modified background tab whose Save opens a Save dialog: once the dialog answers, +/// the document closes and the document that was in front before the question is in front again. +#[test] +fn save_changes_answered_by_a_save_dialog_keeps_the_front_document() { + let (mut app, shown, written) = with_dialogs(false); + // A clean and in front, B modified and untitled, C clean. + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.session.set_active(0); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + app.run("file.close", json!({"index": 1})).unwrap(); + assert!(crate::dialogs::confirm(&mut app).is_err()); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); + shown.answer(&["/art/b.vectorcraft"]); + poll(&mut app); + assert_eq!(*written.borrow(), ["/art/b.vectorcraft"], "{}", app.ui.status); + assert_eq!(app.session.documents().iter().map(|d| d.uid).collect::>(), [uids[0], uids[2]]); + assert_eq!(app.session.active().map(|d| d.uid), Some(uids[0]), "A is in front again"); +} + /// A dialog asked for while another is open is refused, and the status bar says so rather than /// "cancelled". #[test] diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 7b068bc7e4..04bdc1e56e 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -14,9 +14,15 @@ use crate::{VectorcraftApp, io}; /// 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). +/// what continues after this document) and `discard` (Don't Save). With `then` = `close`, the +/// protected field [`FRONT`] holds the uid of the document that was in front before the question. pub const KIND: &str = "saveChanges"; +/// Field of a [`KIND`] dialog asked by File → Close or a tab's ×: the uid of the document in front +/// before the question showed the asked one. Once the asked document closes, that document is in +/// front again while it is still open. +const FRONT: &str = "__front"; + /// 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.) pub(crate) fn refuse_while_picking(app: &VectorcraftApp) -> Result<(), String> { @@ -37,7 +43,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result { refuse_while_picking(app)?; finish_pending(app); 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) } + if dirty { ask(app, i, "close") } else { close_now(app, i, None) } } /// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document, @@ -68,18 +74,28 @@ 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. +/// Show document `i` and ask whether to save it. For a single close (`then` = `close`), the +/// dialog also remembers the document in front before it ([`FRONT`]). fn ask(app: &mut VectorcraftApp, i: usize, then: &str) -> Result { + let front = app.session.active().map(|d| d.uid); app.session.set_active(i); 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 }))); + let mut fields = json!({ "index": i, "uid": uid, "name": name, "then": then }); + if let Some(front) = front.filter(|f| then == "close" && *f != uid) { + fields[FRONT] = json!(front); + } + app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(json!({ "pending": KIND })) } /// Close document `i` through the engine command; [`VectorcraftApp::sync_views`] drops its view. -fn close_now(app: &mut VectorcraftApp, i: usize) -> Result { +/// When `front` names a document that is still open, that document is in front afterwards. +fn close_now(app: &mut VectorcraftApp, i: usize, front: Option) -> Result { let r = app.session.execute("file.close", &json!({ "index": i })).map_err(|e| e.to_string())?; + if let Some(j) = front.and_then(|uid| app.session.documents().iter().position(|d| d.uid == uid)) { + app.session.set_active(j); + } app.sync_views(); if app.session.tool_id() == "artboard" && let Some(n) = app.session.active().map(|d| d.doc.artboards.len()) @@ -117,7 +133,7 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { } } } - close_now(app, i)?; + close_now(app, i, d.fields.get(FRONT).and_then(Value::as_u64))?; match d.str("then").as_str() { "close" => Ok(Value::Null), then => close_all(app, then), @@ -195,6 +211,98 @@ mod tests { assert_eq!((docs(&app), app.views.len()), (1, 1)); } + /// Three documents, A clean, B modified and C clean, with A in front: their uids. + fn front_and_modified_background_tab(app: &mut VectorcraftApp) -> [u64; 3] { + new_doc(app, false); + new_doc(app, true); + new_doc(app, false); + app.session.set_active(0); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + [uids[0], uids[1], uids[2]] + } + + fn front_uid(app: &VectorcraftApp) -> Option { + app.session.active().map(|d| d.uid) + } + + #[test] + fn closing_a_modified_background_tab_keeps_the_front_document_after_dont_save() { + let (mut app, written) = app(); + let [a, _, c] = front_and_modified_background_tab(&mut app); + assert_eq!(app.run("file.close", json!({ "index": 1 })).unwrap(), json!({ "pending": KIND })); + // The question shows the document it asks about. + assert_eq!((pending(&app), app.session.active_index()), (Some(1), Some(1))); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + assert_eq!((uids, front_uid(&app), app.views.len()), (vec![a, c], Some(a), 2)); + assert!(written.lock().unwrap().is_empty()); + } + + #[test] + fn closing_a_modified_background_tab_keeps_the_front_document_after_save() { + let (mut app, written) = app(); + let [a, _, c] = front_and_modified_background_tab(&mut app); + close(&mut app, 1).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(*written.lock().unwrap(), vec!["out.vectorcraft".to_string()]); + let uids: Vec = app.session.documents().iter().map(|d| d.uid).collect(); + assert_eq!((uids, front_uid(&app)), (vec![a, c], Some(a))); + } + + #[test] + fn cancel_keeps_the_asked_background_tab_in_front() { + let (mut app, _) = app(); + let [_, b, _] = front_and_modified_background_tab(&mut app); + close(&mut app, 1).unwrap(); + app.ui.dialog = None; + assert_eq!((docs(&app), front_uid(&app)), (3, Some(b))); + // A later close of the tab in front brings its neighbor to the front. + close(&mut app, 1).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + assert_eq!((docs(&app), app.session.active_index()), (2, Some(1))); + } + + #[test] + fn closing_the_modified_front_document_brings_its_neighbor_to_the_front() { + let (mut app, _) = app(); + new_doc(&mut app, false); + new_doc(&mut app, true); + new_doc(&mut app, false); + let c = app.session.documents()[2].uid; + app.session.set_active(1); + close(&mut app, 1).unwrap(); + assert!(app.ui.dialog.as_ref().is_some_and(|d| !d.fields.contains_key(FRONT))); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + assert_eq!((docs(&app), front_uid(&app)), (2, Some(c))); + } + + /// The document in front before the question is remembered by its uid: when it closed while + /// the question was open, the closed document's neighbor comes to the front as before. + #[test] + fn a_front_document_closed_meanwhile_is_not_brought_back() { + let (mut app, _) = app(); + let [a, _, c] = front_and_modified_background_tab(&mut app); + close(&mut app, 1).unwrap(); + let d = app.ui.dialog.take().unwrap(); + assert_eq!(d.fields.get(FRONT), Some(&json!(a))); + close(&mut app, 0).unwrap(); + app.ui.dialog = Some(d); + app.ui.dialog.as_mut().unwrap().fields.insert("discard".into(), json!(true)); + confirm(&mut app).unwrap(); + assert_eq!((docs(&app), front_uid(&app)), (1, Some(c))); + } + + #[test] + fn close_all_does_not_remember_a_front_document() { + let (mut app, _) = app(); + front_and_modified_background_tab(&mut app); + app.run("file.closeAll", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.kind == KIND && !d.fields.contains_key(FRONT))); + } + #[test] fn a_cancelled_save_as_keeps_the_document_open() { let (mut app, _) = app(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a5403caea6..31baa2969d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -927,6 +927,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The canvas context menu lists Object › Path › Smooth… while the selection holds a path, in a group or a compound path too. It opens the Smooth slider, as the Object menu does (`right_click_on_a_path_opens_the_smooth_slider`, `smooth_follows_the_selections_paths`). - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. - Save / Don't Save / Cancel before closing or quitting with unsaved documents (tabs, Close, Close All, Quit, window close). + - Closing a modified tab that is not in front shows it while asking to save; after Save or Don't Save the document that was in front before the question is in front again, and Cancel keeps the asked document in front. Tests: `closing_a_modified_background_tab_keeps_the_front_document_after_dont_save`, `closing_a_modified_background_tab_keeps_the_front_document_after_save`, `cancel_keeps_the_asked_background_tab_in_front` and `save_changes_answered_by_a_save_dialog_keeps_the_front_document`. - Four brightness themes, persistent preferences. - **Snapping quick access (#6164):** Control bar › Snapping includes Snap to Glyph (`view.snapToGlyph`) and Alignment Guides (`prefs.set`, `alignmentGuides`). The headless pointer/accessibility test `the_control_bar_snapping_menu_shares_glyph_and_alignment_settings` covers both commands, independent state, checked states after reopening, and all eight menu rows within a 1200 × 400-point window. Floating-panel parity remains unverified; scores and estimates unchanged. - **Pixel Snapping Options:** Control bar › Snapping › Pixel Snapping Options… sets whether View › Snap to Pixel snaps drawing, moving and scaling, each on by default (`view.snapToPixel.options`, the `pixelSnap` preference group; [the help page](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/draw-pixel-perfect-art.html)). Tests: `pixel_snapping_options_read_set_and_reset`, `pixel_snapping_options_choose_the_edits_snap_to_pixel_rounds`, `handle_drag_follows_the_while_scaling_option`, `move_follows_the_while_moving_option`, `an_anchor_drag_follows_the_while_moving_option`, `new_ruler_guide_follows_the_while_moving_option` and `the_control_bar_snapping_menu_opens_pixel_snapping_options`. Scores and estimates unchanged.