diff --git a/ROADMAP.md b/ROADMAP.md index dd0255190..33f6ee25c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -147,6 +147,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | +| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows | | 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks | | 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here | | 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step | @@ -243,6 +244,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | 2026-10-23 | minor | Progress log: accounting negatives in number text | | 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) | +| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP | | 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save | | 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips | | 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row | diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 4c41f05c3..b4a06fc9e 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -196,13 +196,13 @@ pub struct NativeMenu { events: Receiver, theme_items: Vec<(&'static str, CheckMenuItem)>, window_menu: Option, - /// The Window menu's workbook items and what they last showed. + /// The Window menu's window items and what they last showed. window_items: Vec, windows: Vec, } /// Menu id of the Window menu item that brings workbook `doc` forward. -const WINDOW_ITEM: &str = "window:doc:"; +const WINDOW_ITEM: &str = "window:id:"; /// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own /// Quit (terminate:) would skip. @@ -280,15 +280,15 @@ impl NativeMenu { NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() } } - /// Keeps the Window menu's list of open workbooks (title, unsaved bullet, active check) in + /// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in /// step with the session. fn sync_windows(&mut self, app: &SheetApp) { let windows = app.open_windows(); if windows != self.windows && let Some(menu) = &self.window_menu { - let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); - if same_docs { + let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window); + if same_windows { for (item, w) in self.window_items.iter().zip(&windows) { item.set_text(w.label()); } @@ -297,7 +297,7 @@ impl NativeMenu { let _ = menu.remove(&item); } for w in &windows { - let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.doc), w.label(), true, w.active, None); + let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None); let _ = menu.append(&item); self.window_items.push(item); } @@ -319,8 +319,8 @@ impl NativeMenu { ctx.send_viewport_cmd(egui::ViewportCommand::Close); continue; } - if let Some(doc) = id.strip_prefix(WINDOW_ITEM).and_then(|d| d.parse::().ok()) { - app.run_or_alert("view.switchWindows", json!({"doc": doc})); + if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::().ok()) { + app.run_or_alert("view.switchWindows", json!({"window": window})); continue; } // Text selection belongs to the inline editor, not the worksheet clipboard/undo. diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 954583a99..637993cbd 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -141,16 +141,16 @@ pub fn specs() -> Vec { ), cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views), cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split), - cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window), + cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window), cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()), - cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window), - cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window), + cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window), + cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window), cmd!( noundo "view.switchWindows", "Switch Windows", ["View", "Window"], None, - "{doc?} → open workbooks [{index, doc, title, dirty, active, hidden}]; with `doc` (a workbook's `doc` id from the list) makes that workbook active first", + "{window?, doc?} → open windows [{window, doc, index, title, dirty, active, hidden}]; with `window` (an id from the list), or `doc` (a workbook id: its most recent window), shows and activates that window first", always, switch_windows ), @@ -816,55 +816,46 @@ fn split(s: &mut Session, _: &Json) -> Result { } fn new_window(s: &mut Session, _: &Json) -> Result { - let d = s.doc()?.clone(); - let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust); - copy.selection = d.selection.clone(); - copy.scripts_trusted = d.scripts_trusted; - let i = s.add_document(copy); - Ok(json!({"index": i})) + let id = s.new_window()?; + let doc = s.window(id).map(|w| w.doc_uid); + Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)})) } -fn hide_window(s: &mut Session, _: &Json) -> Result { - s.hidden_windows.push(s.active_index()); - let n = s.documents().len(); - if n > 1 { - let next = (0..n).find(|i| !s.hidden_windows.contains(i)).unwrap_or(0); - s.set_active(next); - } +/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most +/// recent window), else the active window. +pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { + let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?; + s.target_window(t).map_err(|e| bad(cmd, e.to_string())) +} + +fn hide_window(s: &mut Session, p: &Json) -> Result { + let id = window_param(s, "view.hideWindow", p)?; + s.hide_window(id); ok() } fn unhide_window(s: &mut Session, p: &Json) -> Result { - let i = u32_param(p, "index").map(|v| v as usize).or_else(|| s.hidden_windows.last().copied()); - if let Some(i) = i { - s.hidden_windows.retain(|x| *x != i); - s.set_active(i); + let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + Some(window_param(s, "view.unhideWindow", p)?) + } else if let Some(i) = u32_param(p, "index") { + // A workbook's position in the document list. + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?; + s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid)) + } else { + s.last_hidden_window() + }; + if let Some(id) = id { + s.unhide_window(id); } ok() } fn switch_windows(s: &mut Session, p: &Json) -> Result { - if let Some(doc) = p.get("doc").filter(|v| !v.is_null()) { - let i = doc.as_u64().and_then(|uid| s.document_index(uid)).ok_or_else(|| bad("view.switchWindows", "no open workbook has that `doc` id"))?; - s.hidden_windows.retain(|x| *x != i); - s.set_active(i); + if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) { + let id = window_param(s, "view.switchWindows", p)?; + s.unhide_window(id); } - Ok(Json::Array( - s.documents() - .iter() - .enumerate() - .map(|(i, d)| { - json!({ - "index": i, - "doc": d.uid, - "title": d.display_title(), - "dirty": d.is_dirty(), - "active": i == s.active_index(), - "hidden": s.hidden_windows.contains(&i), - }) - }) - .collect(), - )) + Ok(s.window_list()) } fn table_resize(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f261ea238..94b013f55 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save), cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as), cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes), - cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close), + cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close), cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")), cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")), cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties), @@ -25,7 +25,9 @@ pub fn specs() -> Vec { always, options ), - cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window), + cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window), + cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window), + cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())), ] } @@ -160,7 +162,16 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result { Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()})) } +/// File › Close closes the active window; the workbook closes with its last window. fn close(s: &mut Session, p: &Json) -> Result { + if !bool_param(p, "document").unwrap_or(false) + && let Some(id) = s.active_window() + && let Some(doc) = s.window(id).map(|w| w.doc_uid) + && s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 + { + s.close_window(id); + return Ok(json!({"closed": true, "document": false})); + } let force = bool_param(p, "force").unwrap_or(false); let d = s.doc()?; if d.is_dirty() && !force { @@ -221,10 +232,35 @@ fn options(s: &mut Session, _: &Json) -> Result { } fn activate_window(s: &mut Session, p: &Json) -> Result { - let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize; - if i >= s.documents().len() { - return Err(bad("window.activate", "no such window")); - } - s.set_active(i); + let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) { + Some(i) => { + let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?; + s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))? + } + None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => { + return Err(bad("window.activate", "missing `window`")); + } + None => super::extra::window_param(s, "window.activate", p)?, + }; + s.unhide_window(id); ok() } + +fn close_window(s: &mut Session, p: &Json) -> Result { + let id = super::extra::window_param(s, "window.close", p)?; + let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default(); + if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 { + s.close_window(id); + return Ok(json!({"closed": true, "document": false})); + } + let force = bool_param(p, "force").unwrap_or(false); + let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?; + if d.is_dirty() && !force { + let title = d.display_title(); + s.activate_window(id); + s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title}))); + return Ok(json!({"closed": false, "dirty": true})); + } + s.close_window(id); + Ok(json!({"closed": true, "document": true})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c1b9fa13b..b1d12bfe5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod record; pub mod sample; pub mod selection; pub mod tables; +pub mod windows; use std::sync::Arc; @@ -39,6 +40,7 @@ pub use gridcraft_formula as formula; pub use gridcraft_model as model; pub use gridcraft_numfmt as numfmt; pub use selection::Selection; +pub use windows::{Target, TargetGuard, WindowState, WindowView}; #[derive(Debug, thiserror::Error)] pub enum EngineError { @@ -427,7 +429,15 @@ pub type TextWidth = Arc f32 + Send + Sync>; #[derive(Default)] pub struct Session { docs: Vec, + /// Index in `docs` of the active window's workbook. active: usize, + /// Windows on the open workbooks ([`windows`]); every workbook has at least one. + windows: Vec, + active_window: Option, + /// Last window id handed out. + next_window: u64, + /// Window activation counter (most recently used order). + activation: u64, pub clipboard: Option, pub prefs: Prefs, pub mode: Mode, @@ -450,8 +460,6 @@ pub struct Session { pub recorder: Option, /// How many `execute` calls are running (0 between top-level commands). depth: u32, - /// Windows hidden with View › Hide. - pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. pub draw_tool: String, pub draw_color: String, @@ -487,9 +495,15 @@ impl Session { pub fn active_index(&self) -> usize { self.active } + /// Makes the workbook at `i` active, in its most recently active window (unless the active + /// window already shows it). pub fn set_active(&mut self, i: usize) { - if i < self.docs.len() { - self.active = i; + let Some(uid) = self.docs.get(i).map(|d| d.uid) else { return }; + if self.active == i && self.active_window.and_then(|w| self.window(w)).is_some_and(|w| w.doc_uid == uid) { + return; + } + if let Some(w) = self.doc_window(uid) { + self.activate_window(w); } } pub fn active(&self) -> Option<&DocState> { @@ -508,17 +522,24 @@ impl Session { pub fn doc_mut(&mut self) -> Result<&mut DocState> { self.active_mut().ok_or(EngineError::NoDocument) } + /// Opens a workbook in a new window, which becomes the active one. pub fn add_document(&mut self, d: DocState) -> usize { + let uid = d.uid; self.docs.push(d); - self.active = self.docs.len() - 1; - self.active + let w = self.push_window(uid); + self.activate_window(w); + self.docs.len() - 1 } + /// Closes a workbook and its windows; the most recently active remaining window comes forward. pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { let d = self.docs.remove(i); self.groups.retain(|g| g.doc != d.uid); - if self.active >= self.docs.len() { - self.active = self.docs.len().saturating_sub(1); + self.windows.retain(|w| w.doc_uid != d.uid); + if self.active_window.is_some_and(|w| self.window(w).is_none()) { + self.activate_recent(); + } else if let Some(di) = self.active_window.and_then(|w| self.window(w)).and_then(|w| self.document_index(w.doc_uid)) { + self.active = di; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6ca6db43a..f2586fe54 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2774,7 +2774,10 @@ fn every_command_survives_empty_params() { let mut s = Session::new(); s.execute("file.new", json!({"sample": "sales"})).unwrap(); for spec in crate::command_specs() { - if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") { + if matches!( + spec.id, + "file.close" | "window.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture" + ) { continue; } let _ = s.execute(spec.id, json!({})); @@ -4857,6 +4860,115 @@ fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() { assert_eq!(s.doc().unwrap().uid, book1, "a bad id leaves the active workbook alone"); } +#[test] +fn new_window_shares_the_workbook_and_closes_window_by_window() { + let mut s = s(); + let book = s.doc().unwrap().uid; + assert_eq!(s.windows().len(), 1, "a new workbook opens in one window"); + let first = s.active_window().unwrap(); + assert_eq!(s.window_title(first).as_deref(), Some("Book1")); + + let r = s.execute("view.newWindow", json!({})).unwrap(); + let second = r["window"].as_u64().unwrap(); + assert_eq!(s.documents().len(), 1, "no copy of the workbook"); + assert_eq!((s.windows().len(), s.active_window()), (2, Some(second))); + assert_eq!(s.window_title(first).as_deref(), Some("Book1:1")); + assert_eq!(s.window_title(second).as_deref(), Some("Book1:2")); + assert!(!s.doc().unwrap().is_dirty()); + + // An edit in window 2 is in window 1's workbook, and saving from window 2 saves it. + s.execute("cell.set", json!({"cell": "A1", "input": "shared"})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-windows-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + s.execute("window.activate", json!({"window": first})).unwrap(); + assert_eq!((s.doc().unwrap().uid, v(&s, "A1")), (book, Value::text("shared"))); + assert!(!s.doc().unwrap().is_dirty(), "saved from the other window"); + assert_eq!(s.doc().unwrap().path.as_deref(), path.to_str()); + let _ = std::fs::remove_file(&path); + + // Closing one window keeps the workbook and renumbers the titles; the last one closes it. + s.execute("cell.set", json!({"cell": "A2", "input": "unsaved"})).unwrap(); + let r = s.execute("file.close", json!({})).unwrap(); + assert_eq!((r["closed"].as_bool(), r["document"].as_bool()), (Some(true), Some(false)), "no prompt for one of two windows"); + assert_eq!((s.windows().len(), s.active_window()), (1, Some(second))); + assert_eq!(s.window_title(second).as_deref(), Some(s.doc().unwrap().display_title().as_str())); + let r = s.execute("window.close", json!({})).unwrap(); + assert_eq!(r["closed"].as_bool(), Some(false), "the last window asks about unsaved changes"); + assert_eq!(s.documents().len(), 1); + s.execute("window.close", json!({"force": true})).unwrap(); + assert!(s.documents().is_empty() && s.windows().is_empty() && s.active_window().is_none()); +} + +#[test] +fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-window-views-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B2:C3"})).unwrap(); + let first = s.active_window().unwrap(); + let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + s.execute("selection.set", json!({"range": "D4"})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + + s.execute("window.activate", json!({"window": first})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.a1()), (0, "B2:C3".to_string())); + assert!(!d.is_dirty(), "a window's sheet and selection are not document changes"); + s.execute("view.switchWindows", json!({"window": second})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!((d.wb.active_sheet, d.selection.a1()), (1, "D4".to_string())); + assert!(!d.is_dirty()); + + // A routed command acts in its window and leaves the active one showing. + s.execute_in(crate::Target::Window(first), "cell.set", json!({"cell": "A1", "input": "in window 1"})).unwrap(); + assert_eq!(s.active_window(), Some(second)); + assert_eq!(s.doc().unwrap().wb.sheets[0].value(CellRef::parse("A1").unwrap()), Value::text("in window 1")); + assert_eq!(s.doc().unwrap().selection.a1(), "D4"); +} + +#[test] +fn hide_unhide_and_switch_by_window_id() { + let mut s = s(); + let book1 = s.doc().unwrap().uid; + let w1 = s.active_window().unwrap(); + let w2 = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("file.new", json!({})).unwrap(); + let w3 = s.active_window().unwrap(); + s.execute("view.hideWindow", json!({})).unwrap(); + assert_eq!(s.active_window(), Some(w2), "the most recent visible window comes forward"); + let list = s.execute("window.list", json!({})).unwrap(); + let rows = list.as_array().unwrap(); + assert_eq!(rows.len(), 3); + assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["doc"].as_u64()), (Some(w1), Some("Book1:1"), Some(book1))); + assert_eq!((rows[2]["title"].as_str(), rows[2]["hidden"].as_bool()), (Some("Book2"), Some(true))); + assert_eq!(rows[1]["active"].as_bool(), Some(true)); + s.execute("view.unhideWindow", json!({})).unwrap(); + assert_eq!(s.active_window(), Some(w3)); + assert!(!s.window(w3).unwrap().hidden); + // A workbook id goes to that workbook's most recent window. + s.execute("view.switchWindows", json!({"doc": book1})).unwrap(); + assert_eq!(s.active_window(), Some(w2)); + assert!(s.execute("window.activate", json!({"window": 999})).is_err()); + assert_eq!(s.active_window(), Some(w2)); +} + +#[test] +fn open_replaces_an_untouched_blank_workbook_and_its_windows() { + let mut s = s(); + s.execute("view.newWindow", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-open-blank-{}.csv", std::process::id())); + std::fs::write(&path, "a,b\n1,2\n").unwrap(); + s.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(s.documents().len(), 1); + assert_eq!(s.windows().len(), 1, "the blank workbook's windows closed with it"); + assert_eq!(s.windows()[0].doc_uid, s.doc().unwrap().uid); +} + #[test] fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() { // Formatting may be allowed on a protected sheet; the permission can't serve as a key to diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs new file mode 100644 index 000000000..8dcb92011 --- /dev/null +++ b/crates/engine/src/windows.rs @@ -0,0 +1,342 @@ +//! Windows on open workbooks. +//! +//! Every open workbook has one or more windows (View › New Window adds another on the same +//! workbook). A window has its own selection, active sheet and per-sheet selections. The active +//! window's view lives in its [`DocState`] (`selection`, `wb.active_sheet`, `sheet_selections`), +//! where every command reads it; each other window keeps its view in [`WindowState`] until it is +//! activated. Swapping a view in or out never makes the workbook dirty: per-window view state is +//! not a document change. + +use serde_json::{Value as Json, json}; + +use crate::{DocState, EngineError, Result, Selection, Session}; + +/// The view state one window keeps while another window on its workbook is showing. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WindowView { + pub selection: Selection, + pub active_sheet: usize, + /// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another + /// window still finds it. + pub sheet_name: String, + pub sheet_selections: Vec, +} + +/// One window on an open workbook. +#[derive(Clone, Debug)] +pub struct WindowState { + /// Identifies the window for the rest of the session; never reused. + pub id: u64, + /// The [`DocState::uid`] of the workbook it shows. + pub doc_uid: u64, + /// View › Hide. + pub hidden: bool, + /// `None` while this window's view is the one in the document (the active window, or the + /// workbook's most recently active window); exactly one window per workbook has `None`. + view: Option, + /// Activation order: higher was active more recently. + last_active: u64, +} + +impl WindowView { + fn capture(d: &DocState) -> WindowView { + WindowView { + selection: d.selection.clone(), + active_sheet: d.wb.active_sheet, + sheet_name: d.wb.sheets.get(d.wb.active_sheet).map(|s| s.name.clone()).unwrap_or_default(), + sheet_selections: d.sheet_selections.clone(), + } + } + + fn apply(self, d: &mut DocState) { + let sheets = &d.wb.sheets; + let at = if sheets.get(self.active_sheet).is_some_and(|s| s.name == self.sheet_name) { + self.active_sheet + } else { + sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.active_sheet) + }; + let at = at.min(sheets.len().saturating_sub(1)); + if d.wb.active_sheet != at { + let dirty = d.is_dirty(); + std::sync::Arc::make_mut(&mut d.wb).active_sheet = at; + if !dirty { + d.saved = d.wb.clone(); + } + } + d.selection = self.selection; + d.sheet_selections = self.sheet_selections; + } +} + +/// Which window a command from a script or agent acts on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Target { + /// The active window. + #[default] + Active, + /// A window by [`WindowState::id`]. + Window(u64), + /// A workbook by [`DocState::uid`]: its most recently active window. + Doc(u64), +} + +impl Target { + /// Reads `window` or `doc` from a JSON object (control channel envelopes, MCP arguments). + /// Neither present is [`Target::Active`]; a value that isn't an id is an error. + pub fn from_json(p: &Json) -> Result { + let id = |k: &str| -> Result> { + match p.get(k) { + None | Some(Json::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| EngineError::Other(format!("`{k}` must be a window or workbook id number"))), + } + }; + Ok(match (id("window")?, id("doc")?) { + (Some(w), _) => Target::Window(w), + (None, Some(d)) => Target::Doc(d), + (None, None) => Target::Active, + }) + } +} + +/// What [`Session::leave_target`] restores. +#[derive(Debug)] +#[must_use] +pub struct TargetGuard { + id: u64, + back: Option, +} + +impl Session { + /// The open windows, in the order they were opened. + pub fn windows(&self) -> &[WindowState] { + &self.windows + } + + /// The active window's id (`None` with no workbook open). + pub fn active_window(&self) -> Option { + self.active_window + } + + pub fn window(&self, id: u64) -> Option<&WindowState> { + self.windows.iter().find(|w| w.id == id) + } + + /// A workbook's windows in opening order. + fn windows_of(&self, doc: u64) -> impl Iterator { + self.windows.iter().filter(move |w| w.doc_uid == doc) + } + + /// The window title: the workbook's name when it has one window, "Name:1", "Name:2"… in + /// opening order when it has several. + pub fn window_title(&self, id: u64) -> Option { + let w = self.window(id)?; + let d = self.docs.iter().find(|d| d.uid == w.doc_uid)?; + let name = d.display_title(); + let mut n = 0; + let mut at = 0; + for x in self.windows_of(w.doc_uid) { + n += 1; + if x.id == id { + at = n; + } + } + Some(if n > 1 { format!("{name}:{at}") } else { name }) + } + + /// The most recently active window of a workbook, preferring one that isn't hidden. + pub fn doc_window(&self, doc: u64) -> Option { + self.windows_of(doc).max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id) + } + + /// Resolves a [`Target`] to a window id. + pub fn target_window(&self, t: Target) -> Result { + match t { + Target::Active => self.active_window.ok_or(EngineError::NoDocument), + Target::Window(id) => self.window(id).map(|w| w.id).ok_or_else(|| EngineError::Other(format!("no open window has id {id}"))), + Target::Doc(uid) => self.doc_window(uid).ok_or_else(|| EngineError::Other(format!("no open workbook has id {uid}"))), + } + } + + pub(crate) fn push_window(&mut self, doc_uid: u64) -> u64 { + self.next_window = self.next_window.saturating_add(1); + let id = self.next_window; + self.windows.push(WindowState { id, doc_uid, hidden: false, view: None, last_active: 0 }); + id + } + + /// Makes a window active, swapping its view into its workbook. Unknown ids are ignored. + pub fn activate_window(&mut self, id: u64) { + let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return }; + let Some(w) = self.windows.get(wi) else { return }; + let doc = w.doc_uid; + if w.view.is_some() + && let Some(di) = self.document_index(doc) + { + let live = self.windows.iter().position(|x| x.doc_uid == doc && x.view.is_none()); + let incoming = self.windows.get_mut(wi).and_then(|w| w.view.take()); + if let Some(d) = self.docs.get_mut(di) { + if let Some(li) = live + && let Some(lw) = self.windows.get_mut(li) + { + lw.view = Some(WindowView::capture(d)); + } + if let Some(v) = incoming { + v.apply(d); + } + } + } + self.activation = self.activation.saturating_add(1); + let stamp = self.activation; + if let Some(w) = self.windows.get_mut(wi) { + w.last_active = stamp; + } + self.active_window = Some(id); + if let Some(di) = self.document_index(doc) { + self.active = di; + } + } + + /// Activates the most recently active window that isn't hidden, else any window, else none. + pub(crate) fn activate_recent(&mut self) { + let next = self.windows.iter().max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id); + match next { + Some(id) => self.activate_window(id), + None => { + self.active_window = None; + self.active = 0; + } + } + } + + /// View › New Window: another window on the active window's workbook, showing what it shows. + pub fn new_window(&mut self) -> Result { + let current = self.active_window.ok_or(EngineError::NoDocument)?; + let doc = self.window(current).map(|w| w.doc_uid).ok_or(EngineError::NoDocument)?; + let view = self.doc().map(WindowView::capture)?; + // The new window takes over the live view (identical at this point); the old one keeps a copy. + if let Some(w) = self.windows.iter_mut().find(|w| w.doc_uid == doc && w.view.is_none()) { + w.view = Some(view); + } + let id = self.push_window(doc); + self.activate_window(id); + Ok(id) + } + + /// Closes one window. The workbook stays open while it has another window; closing its last + /// window closes the workbook (unsaved changes are the caller's to ask about). + pub fn close_window(&mut self, id: u64) { + let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return }; + let doc = self.windows.get(wi).map(|w| w.doc_uid).unwrap_or_default(); + if self.windows_of(doc).count() <= 1 { + if let Some(di) = self.document_index(doc) { + self.close_document(di); + } + return; + } + let closed = self.windows.remove(wi); + if closed.view.is_none() { + // Its view was the one in the document: the workbook's next window takes it over. + if let Some(next) = self.windows.iter().filter(|w| w.doc_uid == doc).max_by_key(|w| w.last_active).map(|w| w.id) { + let incoming = self.windows.iter_mut().find(|w| w.id == next).and_then(|w| w.view.take()); + if let Some(v) = incoming + && let Some(d) = self.docs.iter_mut().find(|d| d.uid == doc) + { + v.apply(d); + } + } + } + if self.active_window == Some(id) { + self.activate_recent(); + } + } + + /// View › Hide on a window; the most recent visible window comes forward. + pub fn hide_window(&mut self, id: u64) { + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + w.hidden = true; + if self.active_window == Some(id) && self.windows.iter().any(|w| !w.hidden) { + self.activate_recent(); + } + } + + /// View › Unhide: shows a hidden window and activates it. + pub fn unhide_window(&mut self, id: u64) { + let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return }; + w.hidden = false; + self.activate_window(id); + } + + /// The most recently hidden window. + pub fn last_hidden_window(&self) -> Option { + self.windows.iter().filter(|w| w.hidden).max_by_key(|w| w.last_active).map(|w| w.id) + } + + /// The windows as scripts and the Switch Windows lists see them: + /// `[{window, doc, index, title, dirty, active, hidden}]`, `index` being the workbook's + /// position in [`Session::documents`]. + pub fn window_list(&self) -> Json { + Json::Array( + self.windows + .iter() + .filter_map(|w| { + let index = self.document_index(w.doc_uid)?; + let d = self.docs.get(index)?; + Some(json!({ + "window": w.id, + "doc": w.doc_uid, + "index": index, + "title": self.window_title(w.id).unwrap_or_default(), + "dirty": d.is_dirty(), + "active": self.active_window == Some(w.id), + "hidden": w.hidden, + })) + }) + .collect(), + ) + } + + /// Runs `f` with the target window active, then brings back the window that was active, so a + /// script acting on another window doesn't change what the user is looking at. When `f` + /// itself switched windows (`window.activate`, `file.new`, a close), its choice stands. + pub fn with_target(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result { + let guard = self.enter_target(t)?; + let r = f(self); + self.leave_target(guard); + Ok(r) + } + + /// The first half of [`Session::with_target`], for callers whose closure can't borrow the + /// session alone (the UI runs commands through its app): activates the target window and + /// returns what [`Session::leave_target`] needs to bring the previous one back. + pub fn enter_target(&mut self, t: Target) -> Result> { + if t == Target::Active { + return Ok(None); + } + let id = self.target_window(t)?; + let back = self.active_window; + self.activate_window(id); + Ok(Some(TargetGuard { id, back })) + } + + pub fn leave_target(&mut self, guard: Option) { + let Some(TargetGuard { id, back: Some(back) }) = guard else { return }; + if self.active_window == Some(id) && back != id && self.window(back).is_some() { + self.activate_window(back); + } + } + + /// [`Session::execute`] on a target window. + pub fn execute_in(&mut self, t: Target, id: &str, params: Json) -> Result { + self.with_target(t, |s| s.execute(id, params))? + } + + /// [`Session::execute_headless`] on a target window. + pub fn execute_headless_in(&mut self, t: Target, id: &str, params: Json) -> Result { + self.with_target(t, |s| s.execute_headless(id, params))? + } +} + +/// Commands that take `window`/`doc` as their own parameters rather than as a target. +pub fn is_window_command(id: &str) -> bool { + id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "view.switchWindows") +} diff --git a/crates/engine/tests/it/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs index 73ae1edbe..caa4e9682 100644 --- a/crates/engine/tests/it/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -59,7 +59,8 @@ const EXCLUDED: &[(&str, &str)] = &[ ("file.exportPdf", "renders a PDF: slow, and may write files"), ("file.print", "renders a PDF: slow"), ("window.activate", "switches documents"), - ("view.newWindow", "opens another document"), + ("window.close", "closes the document under test"), + ("view.newWindow", "opens another window, whose view swaps with the first"), ("insert.picture", "reads files"), ("pageLayout.background", "reads files"), ("formulas.calculationOptions", "manual and iterative calculation differ from a full recalc by design"), diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 5f96fbd25..28a5cecce 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -1,6 +1,6 @@ //! An in-process engine session that answers the engine-level control-channel methods itself. -use gridcraft_engine::Session; +use gridcraft_engine::{Session, Target}; use serde_json::{Value, json}; use crate::backend::Backend; @@ -33,9 +33,9 @@ impl Headless { Self { session } } - fn execute(&mut self, id: &str, params: Value) -> Result { + fn execute(&mut self, target: Target, id: &str, params: Value) -> Result { // Programmatic calls never open dialogs; anything a command queued for a UI is dropped. - self.session.execute_headless(id, params).map_err(|e| e.to_string()) + self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string()) } fn commands(&self, params: &Value) -> Value { @@ -68,14 +68,28 @@ impl Backend for Headless { if !(p.is_object() || p.is_null()) { return Err(format!("engine.execute: `params` of `{id}` must be an object")); } - self.execute(&id, p) + let target = Target::from_json(¶ms).map_err(|e| e.to_string())?; + self.execute(target, &id, p) } "engine.commands" => Ok(self.commands(¶ms)), "engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())), m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")), // Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are // commands with the same id. - m if gridcraft_engine::find_command(m).is_some() => self.execute(m, params), + // Their params may name a `window` or `doc` to act in, except for the window commands, + // whose own params those are. + m if gridcraft_engine::find_command(m).is_some() => { + if gridcraft_engine::windows::is_window_command(m) { + return self.execute(Target::Active, m, params); + } + let target = Target::from_json(¶ms).map_err(|e| e.to_string())?; + let mut params = params; + if let Some(o) = params.as_object_mut() { + o.remove("window"); + o.remove("doc"); + } + self.execute(target, m, params) + } other => Err(format!("unknown method `{other}`")), } } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 78a42521f..61cfc8dfd 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -106,6 +106,7 @@ fn tools_list_has_schemas() { "redo", "screenshot", "list_functions", + "list_windows", ] { assert!(names.contains(&want), "missing tool {want}"); } @@ -595,3 +596,31 @@ fn invalid_sort_keys_are_invalid_params() { } ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]})); } + +#[test] +fn a_window_argument_routes_tools_to_that_window() { + let mut s = server(); + ok(&mut s, "set_cell", json!({"cell": "A1", "input": "book one"})); + ok(&mut s, "new_workbook", json!({})); + let windows = ok(&mut s, "list_windows", json!({})); + let rows = windows.as_array().unwrap(); + assert_eq!(rows.len(), 2, "{windows}"); + let (w1, book1) = (rows[0]["window"].as_u64().unwrap(), rows[0]["doc"].as_u64().unwrap()); + assert_eq!((rows[0]["title"].as_str(), rows[1]["active"].as_bool()), (Some("Book1"), Some(true))); + + // Book2 stays active; reads and writes go to Book1's window. + ok(&mut s, "set_cell", json!({"cell": "B1", "input": "=A1", "window": w1})); + let got = ok(&mut s, "get_cell", json!({"cell": "B1", "doc": book1})); + assert!(got.to_string().contains("book one"), "{got}"); + let book2 = ok(&mut s, "read_range", json!({"range": "A1:B1"})); + assert!(!book2.to_string().contains("book one"), "{book2}"); + let info = ok(&mut s, "inspect_workbook", json!({"window": w1})); + assert!(info.to_string().contains("Book1"), "{info}"); + assert!(fails(&mut s, "get_cell", json!({"cell": "A1", "window": 999})).contains("no open window")); + + // A second window on Book1 gets a numbered title. + ok(&mut s, "execute_command", json!({"command": "view.newWindow", "window": w1})); + let titles: Vec = + ok(&mut s, "list_windows", json!({})).as_array().unwrap().iter().map(|r| r["title"].as_str().unwrap().to_string()).collect(); + assert_eq!(titles, ["Book1:1", "Book2", "Book1:2"]); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index a6879b3ff..b422b5f34 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -106,8 +106,26 @@ const BORDER_PRESETS: &[&str] = &[ "diagonalUp", ]; -/// All tools, in `tools/list` order. +/// Tools that don't act on one workbook, so they take no `window`/`doc` target. +const UNTARGETED: &[&str] = &["list_commands", "list_functions", "list_windows", "new_workbook", "open_workbook", "screenshot"]; + +/// All tools, in `tools/list` order. Tools that act on a workbook take an optional `window` or +/// `doc` id (from list_windows) and default to the active window. pub fn tool_definitions() -> Vec { + let mut tools = base_tool_definitions(); + for t in &mut tools { + if UNTARGETED.iter().any(|n| t["name"] == *n) { + continue; + } + if let Some(props) = t["inputSchema"]["properties"].as_object_mut() { + props.insert("window".into(), int("Window id from list_windows (default: the active window)")); + props.insert("doc".into(), int("Workbook id from list_windows: its most recent window (default: the active window)")); + } + } + tools +} + +fn base_tool_definitions() -> Vec { let empty = || obj(json!({}), &[]); vec![ // ----- generic ----- @@ -125,6 +143,14 @@ pub fn tool_definitions() -> Vec { ), false, ), + tool( + "list_windows", + "List windows", + "List the open windows: window id, workbook (`doc`) id, title (\"Book1\", or \"Book1:2\" for a workbook's second \ + window), unsaved changes, active and hidden. Pass a `window` or `doc` id to other tools to act there.", + empty(), + true, + ), tool( "list_commands", "List commands", @@ -608,6 +634,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result exec(b, cmd, p) } "list_commands" => b.call("engine.commands", pick(a, &["search"])), + "list_windows" => exec(b, "window.list", json!({})), "inspect_workbook" => b.call("document.inspect", json!({})), "read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])), "write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])), @@ -638,6 +665,35 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result } } +/// Sends every engine call of one tool to the window its `window`/`doc` argument names. +struct Targeted<'a> { + inner: &'a mut dyn Backend, + target: Map, +} + +impl Backend for Targeted<'_> { + fn call(&mut self, method: &str, params: Value) -> Result { + let envelope = if method == "engine.execute" { + params + } else if gridcraft_engine::find_command(method).is_some() { + json!({"command": method, "params": params}) + } else { + return self.inner.call(method, params); + }; + let mut envelope = envelope; + if let Some(o) = envelope.as_object_mut() { + o.extend(self.target.clone()); + } + self.inner.call("engine.execute", envelope) + } + fn has_ui(&self) -> bool { + self.inner.has_ui() + } + fn describe(&self) -> String { + self.inner.describe() + } +} + /// Runs one tool. Failures become `isError` results with a readable message. pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { let empty = Map::new(); @@ -646,7 +702,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { Value::Null => &empty, _ => return ToolResult::error("tool arguments must be a JSON object"), }; - match dispatch(b, name, a) { + let target: Map = + ["window", "doc"].iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect(); + let r = + if target.is_empty() || UNTARGETED.contains(&name) { dispatch(b, name, a) } else { dispatch(&mut Targeted { inner: b, target }, name, a) }; + match r { Ok(v) => ToolResult::json(&v), Err(e) => ToolResult::error(format!("{name}: {e}")), } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 9b969d34d..ad48c8631 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -407,6 +407,38 @@ fn run(app: &mut SheetApp, id: &str, params: Json) -> Result { r } +/// [`run`] in another window (`window` or `doc` from the request), bringing back the active +/// window afterwards unless the command itself switched windows. +fn run_in(app: &mut SheetApp, target: gridcraft_engine::Target, id: &str, params: Json) -> Result { + let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; + let r = run(app, id, params); + app.session.leave_target(guard); + r +} + +/// A command called by its id as the method. Its params may name a `window` or `doc` to act in, +/// except for the window commands, whose own params those are. +fn direct(app: &mut SheetApp, id: &str, p: &Json, via_app: bool) -> Result { + let mut params = p.clone(); + let target = if gridcraft_engine::windows::is_window_command(id) { + gridcraft_engine::Target::Active + } else { + let t = gridcraft_engine::Target::from_json(p).map_err(|e| e.to_string())?; + if let Some(o) = params.as_object_mut() { + o.remove("window"); + o.remove("doc"); + } + t + }; + if via_app { + return run_in(app, target, id, params); + } + let guard = app.session.enter_target(target).map_err(|e| e.to_string())?; + let r = app.session.run(id, params); + app.session.leave_target(guard); + r +} + pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { let r = ctx.content_rect(); let v = app.view(); @@ -423,6 +455,8 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json { "mode": format!("{:?}", app.session.mode), "documents": app.session.documents().iter().map(|d| json!({"title": d.display_title(), "dirty": d.is_dirty()})).collect::>(), "activeDocument": app.session.active_index(), + "windows": app.session.window_list(), + "activeWindow": app.session.active_window(), "perf": {"frameMs": app.perf.frame_ms, "gridMs": app.perf.grid_ms, "fps": app.perf.fps}, }) } @@ -588,7 +622,11 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - "engine.execute" | "command" | "ui.menu.invoke" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().unwrap_or(json!({})); - wrap(run(app, id, if params.is_null() { json!({}) } else { params })) + let target = match gridcraft_engine::Target::from_json(p) { + Ok(t) => t, + Err(e) => return err(e), + }; + wrap(run_in(app, target, id, if params.is_null() { json!({}) } else { params })) } "engine.commands" => { let mut v: Vec = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); @@ -624,7 +662,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ok(Json::Array(filter_commands(v, s("search")))) } "engine.journal" => ok(json!(app.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect::>())), - "document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(app.session.run(method, p.clone())), + "document.inspect" | "sheet.read" | "cell.get" | "selection.stats" => wrap(direct(app, method, p, false)), "ui.inspect" => ok(inspect(app, ctx)), "ui.ribbon" => { app.ui.ribbon_tab = s("tab").unwrap_or("Home").to_string(); @@ -814,7 +852,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - ok(Json::Null) } // Anything else that names a command runs it. - other if gridcraft_engine::find_command(other).is_some() => wrap(run(app, other, p.clone())), + other if gridcraft_engine::find_command(other).is_some() => wrap(direct(app, other, p, true)), other => err(format!("unknown method `{other}`")), }; ctx.request_repaint(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 71ef117b9..2896035df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1443,7 +1443,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let quitting = d.values.get("quit").and_then(Json::as_bool) == Some(true); ui.horizontal(|ui| { if ui.button("Don't Save").clicked() { - app.run_or_alert("file.close", json!({"force": true})); + app.run_or_alert("file.close", json!({"force": true, "document": true})); if quitting { app.continue_quit(ui.ctx()); } @@ -1456,7 +1456,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { if ui.button("Save").clicked() && app.commit_edit(0, 0, false, false) { app.run_or_alert("file.save", json!({})); if app.session.active().is_some_and(|x| !x.is_dirty()) { - app.run_or_alert("file.close", json!({"force": true})); + app.run_or_alert("file.close", json!({"force": true, "document": true})); if quitting { app.continue_quit(ui.ctx()); } diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index dff14e3d1..847afe3ae 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -10,6 +10,9 @@ pub const REF_COLOR_COUNT: usize = 8; pub struct EditState { /// The workbook (`DocState::uid`) the draft belongs to: it is drawn and committed only there. pub doc: u64, + /// The window it was typed in: switching to another window, even on the same workbook, + /// commits it there first (`None` until the app sets it). + pub window: Option, pub sheet: usize, /// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it). pub locale: gridcraft_engine::formula::FormulaLocale, @@ -52,6 +55,7 @@ impl EditState { let original = (!enter_mode).then(|| text.clone()); EditState { doc, + window: None, sheet, locale: Default::default(), cell, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ea1060f2c..988c695fd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -178,10 +178,12 @@ pub struct Notice { pub action: Option<(String, String)>, } -/// One open workbook, as View › Switch Windows and the macOS Window menu list it. +/// One open window, as View › Switch Windows and the macOS Window menu list it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenWindow { - /// The workbook's `DocState::uid`, the `doc` param of `view.switchWindows`. + /// The window's id, the `window` param of `view.switchWindows`. + pub window: u64, + /// Its workbook's `DocState::uid`. pub doc: u64, pub title: String, pub dirty: bool, @@ -189,7 +191,8 @@ pub struct OpenWindow { } impl OpenWindow { - /// The title, with a bullet after it while the workbook has unsaved changes. + /// The title ("Book1", or "Book1:2" for a workbook's second window), with a bullet after it + /// while the workbook has unsaved changes. pub fn label(&self) -> String { if self.dirty { format!("{} •", self.title) } else { self.title.clone() } } @@ -268,6 +271,7 @@ pub struct SheetApp { pub ui: UiState, /// The window chrome to draw (see [`Chrome`]). pub chrome: Chrome, + /// Scroll position per (window id, sheet index). pub views: HashMap<(u64, usize), SheetView>, pub editor: Option, pub text_box_editor: Option, @@ -469,7 +473,7 @@ impl SheetApp { fn run_command(&mut self, id: &str, params: Json) -> Result { let switches_window = id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") - || (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null())); + || (id == "view.switchWindows" && ["window", "doc"].iter().any(|k| params.get(*k).is_some_and(|d| !d.is_null()))); if id.starts_with("file.") || switches_window { self.settle_drafts()?; } @@ -626,9 +630,9 @@ impl SheetApp { if id == "insert.recommendedCharts" && params.get("type").is_none() { return self.open_dialog("recommendedCharts", json!({})); } - // Switch Windows with no workbook named (key tip, command search) opens the View tab's + // Switch Windows with no window named (key tip, command search) opens the View tab's // list to choose from; programmatic callers of `run` get the list back. - if id == "view.switchWindows" && params.get("doc").is_none_or(Json::is_null) { + if id == "view.switchWindows" && ["window", "doc"].iter().all(|k| params.get(*k).is_none_or(Json::is_null)) { self.ui.ribbon_tab = "View".into(); self.ui.ribbon_collapsed = false; self.open_switch_windows = true; @@ -982,27 +986,36 @@ impl SheetApp { } } - /// The open workbooks in order, for the Switch Windows lists. + /// The open windows in order, for the Switch Windows lists. pub fn open_windows(&self) -> Vec { - let active = self.session.active_index(); + let active = self.session.active_window(); self.session - .documents() + .windows() .iter() - .enumerate() - .map(|(i, d)| OpenWindow { doc: d.uid, title: d.display_title(), dirty: d.is_dirty(), active: i == active }) + .filter_map(|w| { + let d = self.session.documents().iter().find(|d| d.uid == w.doc_uid)?; + let title = self.session.window_title(w.id)?; + Some(OpenWindow { window: w.id, doc: d.uid, title, dirty: d.is_dirty(), active: active == Some(w.id) }) + }) .collect() } + /// The active workbook and sheet: what drafts, text boxes and picture targets belong to. pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } + /// The active window and sheet: each window scrolls on its own. + fn scroll_key(&self) -> Option<(u64, usize)> { + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet)) + } + pub fn view(&self) -> SheetView { - self.view_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) + self.scroll_key().and_then(|k| self.views.get(&k).copied()).unwrap_or_else(|| self.saved_view()) } pub fn view_mut(&mut self) -> Option<&mut SheetView> { - let k = self.view_key()?; + let k = self.scroll_key()?; let saved = self.saved_view(); Some(self.views.entry(k).or_insert(saved)) } @@ -1024,10 +1037,11 @@ impl SheetApp { /// cell to the engine so a save writes them (`sheetView topLeftCell`). Normal view only: /// Page Layout view scrolls over pages, not cells. pub fn store_view_state(&mut self) { + let window = self.session.active_window(); let Some(d) = self.session.active_mut() else { return }; let mut tops = Vec::new(); - for (&(uid, si), v) in &self.views { - let Some(sh) = d.wb.sheets.get(si).filter(|sh| uid == d.uid && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { + for (&(w, si), v) in &self.views { + let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else { continue; }; let (fr, fc) = sh.freeze.unwrap_or((0, 0)); @@ -1073,6 +1087,7 @@ impl SheetApp { None => (current.clone(), false), }; let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); + ed.window = self.session.active_window(); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); @@ -1083,29 +1098,38 @@ impl SheetApp { /// Commits the edit; `then` moves the selection (dr, dc) afterwards. /// - /// The draft goes into the workbook it was typed in. If another workbook is showing, that - /// workbook stays active afterwards; a draft its own workbook refuses (validation, a bad - /// formula) brings that workbook back to the front with the draft still open. + /// The draft goes into the window and workbook it was typed in. If another window is + /// showing, that window stays active afterwards; a draft its own workbook refuses + /// (validation, a bad formula) brings its window back to the front with the draft still open. pub fn commit_edit(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { - let Some(doc) = self.editor.as_ref().map(|ed| ed.doc) else { return true }; - let shown = self.session.active().map(|d| d.uid); - if shown == Some(doc) { + let Some((doc, window)) = self.editor.as_ref().map(|ed| (ed.doc, ed.window)) else { return true }; + if !self.draft_elsewhere() { return self.commit_edit_here(dr, dc, array, fill_selection); } - let Some(owner) = self.session.document_index(doc) else { + // Its window, or (when that window closed) its workbook's most recent one. + let Some(owner) = window.filter(|w| self.session.window(*w).is_some()).or_else(|| self.session.doc_window(doc)) else { self.cancel_edit(); return true; }; - let back = self.session.active_index(); - self.session.set_active(owner); + let back = self.session.active_window(); + self.session.activate_window(owner); if !self.commit_edit_here(dr, dc, array, fill_selection) { - // Refused: its workbook stays in front with the draft open and the reason shown. + // Refused: its window stays in front with the draft open and the reason shown. return false; } - self.session.set_active(back); + if let Some(back) = back { + self.session.activate_window(back); + } true } + /// The cell draft belongs to a window or workbook other than the one showing. + fn draft_elsewhere(&self) -> bool { + self.editor.as_ref().is_some_and(|ed| { + self.session.active().map(|d| d.uid) != Some(ed.doc) || ed.window.is_some_and(|w| self.session.active_window() != Some(w)) + }) + } + /// Commits the cell and text-box drafts, as Enter would, before a command that can show /// another workbook or write this one to a file, so a draft never lands in another workbook. /// A draft its workbook refuses stays open with the reason shown, and the command doesn't run. @@ -1585,10 +1609,10 @@ impl SheetApp { } let t0 = now_ms(); text_box::before_ui(self, &ctx); - // Another workbook came forward without going through a command (which settles drafts - // first): the draft goes into its own workbook instead of floating over this one. If that - // workbook refuses it, it comes back to the front with the draft open. - if self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) { + // Another window came forward without going through a command (which settles drafts + // first): the draft goes into its own window instead of floating over this one. If its + // workbook refuses it, its window comes back to the front with the draft open. + if self.draft_elsewhere() { self.commit_edit(0, 0, false, false); } // The language the widgets translate with this frame (see `i18n::current`). diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 673cb6b3b..87d719d1a 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2607,7 +2607,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { for w in app.open_windows() { let label = theme::available_text(ui, &format!("{}{}", if w.active { "✓ " } else { " " }, w.label())); if ui.add(egui::Button::selectable(w.active, label).min_size(vec2(220.0, 22.0))).clicked() { - act(app, "view.switchWindows", json!({"doc": w.doc})); + act(app, "view.switchWindows", json!({"window": w.window})); ui.close(); } } diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index f473233ff..fb0779631 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -585,3 +585,65 @@ fn save_a_copy_with_a_bare_name_asks_where_to_save_it() { assert_eq!(asked.lock().unwrap().len(), 1); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn new_window_shows_the_same_workbook_and_a_draft_settles_in_its_own_window() { + let mut h = harness(Services::default()); + h.state_mut().run("selection.set", serde_json::json!({"range": "B2"})).unwrap(); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("view.newWindow", serde_json::json!({})); + h.run_steps(2); + let second = h.state().session.active_window().unwrap(); + let book = h.state().session.doc().unwrap().uid; + assert_ne!(first, second); + assert_eq!(h.state().session.documents().len(), 1, "one workbook, two windows"); + let titles: Vec = h.state().open_windows().iter().map(|w| w.title.clone()).collect(); + assert_eq!(titles, ["Book1:1", "Book1:2"]); + + // A draft typed in window 2 is entered in window 2's cell before window 1 comes forward. + h.state_mut().run("selection.set", serde_json::json!({"range": "D4"})).unwrap(); + h.input_mut().events.push(egui::Event::Text("typed".into())); + h.run_steps(2); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"window": first})); + h.run_steps(2); + assert!(h.state().editor.is_none(), "the draft doesn't follow into window 1"); + assert_eq!(h.state().session.active_window(), Some(first)); + let d = h.state().session.doc().unwrap(); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("D4").unwrap()), Value::text("typed")); + assert_eq!(d.selection.active, CellRef::parse("B2").unwrap(), "window 1 keeps its own selection"); + + // Quitting asks once about the workbook, whichever window shows it. + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "{sent:?}"); + assert!(h.state().session.document_index(book).is_none(), "both windows closed with the workbook"); +} + +#[test] +fn control_requests_route_to_a_window_and_list_windows() { + use gridcraft_ui_egui::control::{Outcome, handle}; + let mut h = harness(Services::default()); + let first = h.state().session.active_window().unwrap(); + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(2); + let ctx = h.ctx.clone(); + let call = |h: &mut egui_kittest::Harness<'static, SheetApp>, method: &str, p: serde_json::Value| match handle(h.state_mut(), &ctx, method, &p) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => serde_json::Value::Null, + }; + let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.set", "params": {"cell": "A1", "input": "one"}, "window": first})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(a1(&h, 0), Value::text("one")); + assert_eq!(a1(&h, 1), Value::Empty); + assert_eq!(h.state().session.active_index(), 1, "Book2 stays in front"); + let r = call(&mut h, "cell.get", serde_json::json!({"cell": "A1", "window": first})); + assert!(r["result"].to_string().contains("one"), "{r}"); + + let r = call(&mut h, "window.list", serde_json::json!({})); + let rows = r["result"].as_array().unwrap(); + assert_eq!(rows.len(), 2, "{r}"); + assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(first), Some("Book1"), Some(true))); + assert_eq!(rows[1]["active"].as_bool(), Some(true)); + let r = call(&mut h, "engine.execute", serde_json::json!({"command": "cell.get", "params": {"cell": "A1"}, "window": 999})); + assert_eq!(r["ok"], false, "{r}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 70cc7a71a..883a6af3b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -50,13 +50,14 @@ connection; nothing sent after it runs. Blank lines are skipped. | Method | Params | What it does | |---|---|---| -| `engine.execute` | `{command, params}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`). `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | +| `engine.execute` | `{command, params, window?, doc?}` | Runs any engine command (see `engine.commands` or `gridcraft-cli commands`) in the active window, or in the window named by `window` (a window id) or `doc` (a workbook id: its most recent window). The active window stays in front afterwards unless the command itself switches windows. `edit.goTo`, `edit.goToSpecial`, `selection.set` and `selection.move` scroll the active cell into view, as Go To does in the window | +| `window.list` | | The open windows: `[{window, doc, index, title, dirty, active, hidden}]`. `title` is the workbook name, or "Book1:1", "Book1:2"… when a workbook has several windows; `index` is the workbook's position among the open workbooks | | `engine.commands` | `{search?}` | Every command: id, label, ribbon path, shortcut, params doc, enabled; `search` keeps those whose id, label or ribbon path contains it (case-insensitive) | | `engine.journal` | | Commands run so far (replayable). Passwords (`password`, `…Password` params) are left out, so a replayed Protect Sheet has no password. Capped at 10,000 entries and 64 MB (serialized); past either cap the oldest entries go | | `document.inspect` | | Workbook summary: sheets, used ranges, tables, charts, names, selection, undo labels | | `sheet.read` | `{range?, sheet?, formulas?, formatted?}` | Values (or formulas / displayed text) of a range | | `cell.get` | `{cell?}` | One cell: value, formula, displayed text, style, merge, comment, link | -| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf | +| `ui.inspect` | | UI state: ribbon tab, editor, open dialog, Backstage page, message, grid rect, scroll, perf, `windows` (as `window.list`) and `activeWindow` | | `ui.click` / `ui.drag` / `ui.move` | `{x, y, toX?, toY?, button?, count?, shift?, cmd?, alt?}` | Real pointer input in window points; refused with an error if it would leave more than 10,000 synthetic events or 8 MiB of synthetic input queued (pointer, key, text and move events share this budget) | | `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same shared queue limits | | `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing); same shared queue limits, and one `ui.text` string may be at most 1 MiB | @@ -83,7 +84,20 @@ fits. `path` writes the PNG to a file instead of returning base64 text and so av base64 costs, but it does not bypass the per-image, aggregate, encoding-working-set or peak limits. -Any other method name that is a command id (e.g. `home.bold`) runs that command. +Any other method name that is a command id (e.g. `home.bold`) runs that command. Its params may +include `window` or `doc` to run it in that window, as with `engine.execute`; the window commands +(`window.*`, `view.newWindow`, `view.hideWindow`, `view.unhideWindow`, `view.switchWindows`) read +them as their own parameters. + +### Windows + +Every open workbook has one or more windows; View › New Window (`view.newWindow`) adds another +on the same workbook, and saving from any of them saves that workbook. Each window has its own +selection, active sheet and scroll position; switching windows never marks the workbook as +changed. `window.activate {window}`, `view.switchWindows {window}` and `view.unhideWindow +{window}` bring a window forward; `window.close {window?, force?}` closes one (the workbook +closes with its last window, asking about unsaved changes unless `force`); `file.close` closes +the active window, or with `{document: true}` the workbook and all its windows. Use `engine.execute` with `{command: "view.theme", params: {mode: "system"}}` to choose the display theme; `mode` accepts `"system"`, `"light"`, or `"dark"`. diff --git a/docs/mcp.md b/docs/mcp.md index 89fa31e82..32ea6e0c1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -41,6 +41,7 @@ with a file open. |---|---|---| | `execute_command` | `command`, `params?` | Runs **any** engine command (`cell.set`, `home.bold`, `data.removeDuplicates`, …). | | `list_commands` | `search?` | Command ids, labels, ribbon paths, shortcuts and parameter docs. | +| `list_windows` | — | Open windows: window id, workbook (`doc`) id, title ("Book1", or "Book1:2" for a workbook's second window), unsaved changes, active, hidden. | | `inspect_workbook` | — | Sheets, used ranges, tables, charts, names, selection, history. | | `read_range` | `range?`, `sheet?`, `formulas?`, `formatted?` | Rows of values (default: the used range). | | `write_range` | `range`, `values` (2-D) | `range.setValues`; strings starting with `=` are formulas. | @@ -57,6 +58,10 @@ with a file open. | `screenshot` | `path` | PNG of the window (Remote only). | | `list_functions` | `search?`, `category?` | Worksheet functions with signatures. | +Every tool that acts on a workbook (all but `list_commands`, `list_functions`, `list_windows`, `open_workbook`, +`new_workbook` and `screenshot`) also takes `window` or `doc`, an id from `list_windows`, and acts in that window +(for `doc`, the workbook's most recent window) without bringing it forward. Without them it acts in the active window. + Results are one text block holding pretty JSON; failures set `isError: true` with a readable message. Unknown tools, missing required arguments and wrong argument types are JSON-RPC `-32602` errors; malformed JSON is `-32700`; unknown methods `-32601`. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e7c04235f..90453447d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -35,7 +35,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Dark mode, system appearance | yes | yes | done (10-09) | | Page Layout view (paper view with rulers and header editing) | yes | yes (#329, 10-11): per-sheet view saved in XLSX, pages with margins and gaps on the print pagination, "Click to add data" pages, rulers (inches or centimetres), headers and footers drawn and edited in place with a Header & Footer tab, whose Different First Page and Different Odd & Even Pages give the first page and the even pages their own header and footer (#344), and whose Scale with Document and Align with Page Margins set whether header and footer text follows the print scale and the side margins, on paper and in the view | margins drag on the rulers (#792, with header and footer margins); header/footer pictures missing (1–3 h). Unverified: with Align with Page Margins off, header and footer text sits 0.7 in from the paper edge, a value not yet measured in the target app | | Multiple windows per workbook | yes | no (#162) | see [hardware-parity.md](hardware-parity.md) | -| Switch Windows, unsaved workbooks on quit | yes | yes (10-22): View › Switch Windows and the macOS Window menu list the open workbooks (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {doc}`); a cell being typed is entered into its own workbook before New, Open, Close, Save, Print and window switches, and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | +| Switch Windows, New Window, unsaved workbooks on quit | yes | yes (10-22): View › New Window opens another window on the same workbook (no copy: saving from either saves it), each with its own selection, active sheet and scroll; a workbook with several windows titles them "Book1:1", "Book1:2"; File › Close closes one of them without asking and asks only for the last; View › Switch Windows and the macOS Window menu list the open windows (active one checked, unsaved ones marked •) and bring the chosen one forward (`view.switchWindows {window}`, or `{doc}` for a workbook's most recent window); a cell being typed is entered into its own window and workbook before New, Open, Close, Save, Print and window switches (also between two windows on one workbook), and a refused entry stays open and stops the command; quitting (window close, Cmd+Q, the macOS Quit item) asks Save / Don't Save / Cancel for each unsaved workbook | one OS window for all workbooks (#162) | ## Grid interaction @@ -202,6 +202,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | 2026-10-24 | minor | When the fill starts from a date, the right-drag fill menu and the Auto Fill Options button also offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years (`edit.autoFill` with `mode` set to `days`, `weekdays`, `months` or `years`). A lone date steps by one unit; two or more dates step by the difference between the first two in that unit, or one unit when they fall in the same one. Fill Months and Fill Years keep the day of the month, clamped to the month's last day. Every fill by months or years (these two, a drag of dates a month apart, and Series by Month or Year) keeps the time of day of a date with a time, which used to be dropped | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | +| 2026-10-22 | minor | New Window opens a second window on the same workbook instead of a copy that saved over the original; windows keep their own selection, sheet and scroll; titles Name:1 / Name:2; Switch Windows, Hide and Unhide work by window; File › Close closes one window of several without asking | | 2026-10-22 | minor | New workbook no longer loses the first one: the cell being typed is entered into its own workbook before any File or window command (an entry the workbook refuses stays open and the command doesn't run; an untouched F2 draft changes nothing), the editor is tied to its workbook, View › Switch Windows and the macOS Window menu list the open workbooks, and the macOS Quit item asks about unsaved workbooks like the close button | | 2026-10-21 | minor | Background error checking, first part (#1994): a constant whose text would be a number if typed (signs, thousands, currency, percent, exponent, parenthesised negative) gets Excel's green corner triangle; the active cell then shows a warning button whose menu names the problem and offers Convert to Number for the selection (`formulas.convertToNumber`). Dates, times and fractions kept as text and formula results are not flagged, as in Excel | | 2026-10-21 | minor | Paste Special laid out as Excel's: Paste and Operation options each in two columns inside a frame, Skip blanks and Transpose side by side, Paste Link as a button that pastes references at once; labels translated (the generic form didn't translate them) and the window no longer runs past the screen |