From 27ad04dbc112460f733257273d2af595dfad99dd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 10:19:30 -0800 Subject: [PATCH] Documents: closing a modified background tab keeps the document in front Closing a modified tab that is not in front shows it while the Save Changes dialog asks about it. After Save or Don't Save, the closed tab's neighbor became the current document instead of the document that was in front before the question. Closing a background tab with its close button, or `file.close` with its `index`, now remembers the uid of the document in front in the dialog's protected `__front` field, and once the asked document closes, that document is made current again while it is still open. The field survives a Save dialog shown off the UI thread. Cancel keeps the asked document in front, closing the document in front still brings its neighbor forward, and Close All and Quit are unchanged. --- crates/ui-egui/src/tests/picks.rs | 22 ++++++ crates/ui-egui/src/unsaved.rs | 120 ++++++++++++++++++++++++++++-- docs/target-app-parity.md | 1 + 3 files changed, 137 insertions(+), 6 deletions(-) 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 c50a45b639..39e591f9ae 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -918,6 +918,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.