From cbde1630a1f5212cf91ab45e8e9614c28c1ac6b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 09:05:44 -0800 Subject: [PATCH] Documents: closing a background tab keeps the document in front file.close {index} on a tab other than the current one made the document now at the closed index current and reset the tool, so the document the user was working in went to the back. The current document now stays current, found by its id as document.move does, and its tool keeps its state. Closing the current tab still makes its neighbor current. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/session/documents.rs | 13 +++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/document_close.rs | 91 +++++++++++++++++++++++ crates/ui-egui/src/tests/tab_reorder.rs | 4 +- docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 6 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/tests/document_close.rs diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 0d472ef81d..297322fda0 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -404,11 +404,18 @@ impl Session { true } + /// Close the document in tab `index` (`file.close`). Closing a tab other than the current one + /// keeps the current document current, and its tool keeps its state: no document is switched + /// to. Closing the current document makes the tab now at `index` current, or the last tab when + /// it was the last one. False, changing nothing, when `index` is past the last document. pub fn close_document(&mut self, index: usize) -> bool { if index >= self.docs.len() { return false; } - self.reset_tool_for_doc_switch(); + let front = self.active().map(|d| d.uid).filter(|_| self.active != Some(index)); + if front.is_none() { + self.reset_tool_for_doc_switch(); + } // Closed (saved or discarded): nothing left to recover. let uid = self.docs[index].uid; cmd::recovery::forget(self, uid); @@ -416,6 +423,10 @@ impl Session { if let Some(stash) = &mut self.batch_stash { stash.push(old); } + if let Some(uid) = front { + self.active = self.docs.iter().position(|d| d.uid == uid); + return true; + } self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) }; self.sync_front_composite_fonts(); self.sync_front_profiles(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6c231dc7e5..c260b42d90 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -47,6 +47,7 @@ mod dashalign; mod dcs; mod distort; mod docsetup; +mod document_close; mod document_move; mod draw2; mod dynamic_symbols; diff --git a/crates/engine/src/tests/document_close.rs b/crates/engine/src/tests/document_close.rs new file mode 100644 index 0000000000..a1b106d365 --- /dev/null +++ b/crates/engine/src/tests/document_close.rs @@ -0,0 +1,91 @@ +//! `file.close {index}`: closing a document tab other than the current one keeps the current +//! document current, and closing the current one makes its neighbor current. + +use serde_json::json; + +use crate::*; + +/// A session with documents A, B, C and D, in that order, D current. +fn four() -> Session { + let mut s = Session::new(); + for name in ["A", "B", "C", "D"] { + s.execute("file.new", &json!({"name": name, "created": null})).unwrap(); + } + s +} + +fn order(s: &Session) -> String { + s.documents().iter().map(DocState::title).collect::>().join("") +} + +fn current(s: &Session) -> String { + s.active().map(DocState::title).unwrap_or_default() +} + +fn close(s: &mut Session, index: usize) { + s.execute("file.close", &json!({"index": index})).unwrap_or_else(|e| panic!("file.close {index}: {e}")); +} + +#[test] +fn closing_a_tab_before_the_current_one_keeps_the_current_document() { + let mut s = four(); + close(&mut s, 0); + assert_eq!((order(&s), current(&s)), ("BCD".into(), "D".into())); + assert_eq!(s.active_index(), Some(2)); + assert!(s.set_active(1)); + close(&mut s, 0); + assert_eq!((order(&s), current(&s)), ("CD".into(), "C".into())); + assert_eq!(s.active_index(), Some(0)); +} + +#[test] +fn closing_a_tab_after_the_current_one_keeps_the_current_document() { + let mut s = four(); + assert!(s.set_active(1)); + close(&mut s, 3); + assert_eq!((order(&s), current(&s)), ("ABC".into(), "B".into())); + close(&mut s, 2); + assert_eq!((order(&s), current(&s)), ("AB".into(), "B".into())); + assert_eq!(s.active_index(), Some(1)); +} + +#[test] +fn closing_the_current_tab_makes_its_neighbor_current() { + let mut s = four(); + assert!(s.set_active(1)); + close(&mut s, 1); + assert_eq!((order(&s), current(&s)), ("ACD".into(), "C".into()), "the tab that took its place"); + assert!(s.set_active(2)); + close(&mut s, 2); + assert_eq!((order(&s), current(&s)), ("AC".into(), "C".into()), "the last tab: the one before it"); + s.execute("file.close", &json!({})).unwrap(); + assert_eq!((order(&s), current(&s)), ("A".into(), "A".into()), "without an index, the current one closes"); + close(&mut s, 0); + assert!(s.active().is_none()); + assert!(s.execute("file.close", &json!({"index": 0})).is_err(), "no document left"); +} + +#[test] +fn closing_another_tab_keeps_the_tool_and_the_documents_edits() { + let mut s = four(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let resets = s.tool_resets; + let nodes = s.doc().unwrap().doc.node_count(); + let history = super::history(&s); + close(&mut s, 1); + assert_eq!(s.tool_resets, resets, "no document was switched to, so the tool is not reset"); + assert_eq!(super::history(&s), history); + assert_eq!(s.doc().unwrap().doc.node_count(), nodes); + assert!(s.doc().unwrap().is_dirty(), "the document keeps its unsaved edit"); + close(&mut s, 2); + assert_ne!(s.tool_resets, resets, "closing the current document resets the tool"); +} + +#[test] +fn a_bad_index_closes_nothing() { + let mut s = four(); + assert!(s.set_active(2)); + assert!(s.execute("file.close", &json!({"index": 4})).is_err()); + assert!(!s.close_document(4)); + assert_eq!((order(&s), current(&s)), ("ABCD".into(), "C".into())); +} diff --git a/crates/ui-egui/src/tests/tab_reorder.rs b/crates/ui-egui/src/tests/tab_reorder.rs index c1070fbc70..d658a178c1 100644 --- a/crates/ui-egui/src/tests/tab_reorder.rs +++ b/crates/ui-egui/src/tests/tab_reorder.rs @@ -160,8 +160,8 @@ fn views_follow_closes_and_moves_made_in_one_batch() { .unwrap(); assert_eq!(order(&app), ["Delta", "Charlie"]); assert_eq!(zooms(&app), [4.0, 3.0], "Delta does not take the closed Alpha's view"); - // Closing by index made the document in the closed one's place current (Charlie); it shows its own view. - assert_eq!((current(&app), app.view().map(|v| v.zoom)), ("Charlie".into(), Some(3.0))); + // Closing other documents by index leaves the current one (Delta) current; it shows its own view. + assert_eq!((current(&app), app.view().map(|v| v.zoom)), ("Delta".into(), Some(4.0))); // A document opened afterwards starts with a view of its own. app.run("file.new", json!({"width": 100, "height": 100, "name": "Echo"})).unwrap(); app.sync_views(); diff --git a/docs/mcp.md b/docs/mcp.md index 59bb3c78ad..b8857b5d59 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6064,7 +6064,10 @@ document. With no document open they fail with "no document open". `document.act the current one. `document.move {from?, to}` reorders open documents the way dragging a document tab along the tab strip does: `from` defaults to the current document, `to` is the destination index, documents between them shift by one, the current document stays current, and each document keeps its own view (#5663). It is an engine command, so headless and -remote `command_list` both list it. +remote `command_list` both list it. `file.close {index}` on a document other than the current one leaves the current +document current, and its tool keeps its state; closing the current document makes the document in the next tab +current, or the one before it when it was the last tab. In the desktop app a modified document comes to the front first, +to ask whether to save it. `window.arrange.tile` (Window › Arrange › Tile) shows every open document in its own pane, edge to edge in the document area, in a grid of as many columns as the square root of the number of documents, rounded up, and fits each document to its pane; with fewer than two documents open it leaves the view and `tiled` as they are. It returns `{tiled, panes}`. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7044797d5e..6247f23460 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -702,6 +702,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Window › Arrange › Float in Window, Float All in Windows and Cascade (G7):** documents float in windows of their own, out of the tab strip ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). Float in Window (`window.arrange.floatInWindow`) floats the current document, Float All in Windows (`window.arrange.floatAllInWindows`) every tabbed one, and Cascade (`window.arrange.cascade`) floats every document and stacks the windows from the upper left to the lower right ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)). On the desktop each window is an OS window; on the web and on Wayland it is a window inside the app's window. The window of the current document holds the canvas; the others draw their documents as Tile's panes do, and a press in one makes its document the current one. Closing a window closes its document, asking to save unsaved changes. Consolidate All Windows brings every document back under the tabs, and Tile brings them back into its grid. The cascade's 30 pt step, the windows' size and when the items are enabled (Cascade with two documents or more, Float in Window for a tabbed current document, Float All in Windows while a document is tabbed, Consolidate All Windows while documents are tiled or floating) are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. +- **Closing a background document tab:** closing a tab other than the current one (`file.close {index}`) keeps the current document current, and its tool keeps its state; closing the current tab still makes its neighbor current. `crates/engine/src/tests/document_close.rs`. - **Export and Import Preferences (#6435):** an optional VectorCraft feature. The Preferences dialog's Export Preferences… saves the applied preferences to a JSON file (`prefs.export`; the web build downloads it), and Import Preferences… applies such a file (`prefs.import`). The file leaves out the folder preferences. The import checks every value first and changes nothing on an error; it keeps the open document's units and records no undo step. The file format is VectorCraft's own. Tests: `crates/engine/src/tests/prefs_file.rs`, `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`.