mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 13:46:47 -06:00
G2: windows on workbooks in the engine; New Window shares the workbook
Session keeps a window list (stable ids, per-window selection, active sheet and per-sheet selections swapped into the workbook without dirtying it) in place of the index-based hidden list and the copying New Window. Titles read Name:1 / Name:2 for a workbook with several windows. File > Close closes one window of several; window.close, window.list and window ids for window.activate, view.switchWindows, view.hideWindow and view.unhideWindow. Control channel and MCP tools take window/doc ids; scroll positions and cell drafts belong to their window.
This commit is contained in:
+3
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (no lost workbook after New: drafts stay in their workbook, Switch Windows, macOS Quit asks to save; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
@@ -221,6 +222,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -195,13 +195,13 @@ pub struct NativeMenu {
|
||||
events: Receiver<MenuEvent>,
|
||||
theme_items: Vec<(&'static str, CheckMenuItem)>,
|
||||
window_menu: Option<Submenu>,
|
||||
/// 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<CheckMenuItem>,
|
||||
windows: Vec<OpenWindow>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -279,15 +279,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());
|
||||
}
|
||||
@@ -296,7 +296,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);
|
||||
}
|
||||
@@ -318,8 +318,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::<u64>().ok()) {
|
||||
app.run_or_alert("view.switchWindows", json!({"doc": doc}));
|
||||
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
|
||||
app.run_or_alert("view.switchWindows", json!({"window": window}));
|
||||
continue;
|
||||
}
|
||||
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
|
||||
|
||||
@@ -141,16 +141,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
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
|
||||
),
|
||||
@@ -790,54 +790,46 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
copy.selection = d.selection.clone();
|
||||
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<Json> {
|
||||
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<u64> {
|
||||
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<Json> {
|
||||
let id = window_param(s, "view.hideWindow", p)?;
|
||||
s.hide_window(id);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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<Json> {
|
||||
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<Json> {
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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())),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -159,7 +161,16 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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<Json> {
|
||||
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 {
|
||||
@@ -220,10 +231,35 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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<Json> {
|
||||
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}))
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ pub mod pivot_calc;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
pub mod windows;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -38,6 +39,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 {
|
||||
@@ -286,7 +288,15 @@ pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
/// 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<WindowState>,
|
||||
active_window: Option<u64>,
|
||||
/// Last window id handed out.
|
||||
next_window: u64,
|
||||
/// Window activation counter (most recently used order).
|
||||
activation: u64,
|
||||
pub clipboard: Option<Clipboard>,
|
||||
pub prefs: Prefs,
|
||||
pub mode: Mode,
|
||||
@@ -304,8 +314,6 @@ pub struct Session {
|
||||
pub hide_ruler: bool,
|
||||
/// Journal position where action recording started.
|
||||
pub recording: Option<usize>,
|
||||
/// Windows hidden with View › Hide.
|
||||
pub hidden_windows: Vec<usize>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
pub draw_tool: String,
|
||||
pub draw_color: String,
|
||||
@@ -330,9 +338,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> {
|
||||
@@ -351,16 +365,23 @@ 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() {
|
||||
self.docs.remove(i);
|
||||
if self.active >= self.docs.len() {
|
||||
self.active = self.docs.len().saturating_sub(1);
|
||||
let d = self.docs.remove(i);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+113
-1
@@ -2501,7 +2501,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!({}));
|
||||
@@ -4490,3 +4493,112 @@ fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() {
|
||||
assert!(s.execute("view.switchWindows", json!({"doc": "Book1"})).is_err());
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Selection>,
|
||||
}
|
||||
|
||||
/// 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<WindowView>,
|
||||
/// 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<Target> {
|
||||
let id = |k: &str| -> Result<Option<u64>> {
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
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<u64> {
|
||||
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<Item = &WindowState> {
|
||||
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<String> {
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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<u64> {
|
||||
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<T>(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result<T> {
|
||||
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<Option<TargetGuard>> {
|
||||
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<TargetGuard>) {
|
||||
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<Json> {
|
||||
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<Json> {
|
||||
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")
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<Value, String> {
|
||||
fn execute(&mut self, target: Target, id: &str, params: Value) -> Result<Value, String> {
|
||||
// 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}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ fn tools_list_has_schemas() {
|
||||
"redo",
|
||||
"screenshot",
|
||||
"list_functions",
|
||||
"list_windows",
|
||||
] {
|
||||
assert!(names.contains(&want), "missing tool {want}");
|
||||
}
|
||||
@@ -548,3 +549,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<String> =
|
||||
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"]);
|
||||
}
|
||||
|
||||
+62
-2
@@ -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<Value> {
|
||||
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<Value> {
|
||||
let empty = || obj(json!({}), &[]);
|
||||
vec![
|
||||
// ----- generic -----
|
||||
@@ -125,6 +143,14 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
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<Value, String>
|
||||
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<Value, String>
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<String, Value>,
|
||||
}
|
||||
|
||||
impl Backend for Targeted<'_> {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
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<String, Value> =
|
||||
["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}")),
|
||||
}
|
||||
|
||||
@@ -104,6 +104,38 @@ fn run(app: &mut SheetApp, id: &str, params: Json) -> Result<Json, String> {
|
||||
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<Json, String> {
|
||||
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<Json, String> {
|
||||
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();
|
||||
@@ -120,6 +152,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::<Vec<_>>(),
|
||||
"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},
|
||||
})
|
||||
}
|
||||
@@ -222,7 +256,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<Json> = app.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
||||
@@ -256,7 +294,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::<Vec<_>>())),
|
||||
"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();
|
||||
@@ -440,7 +478,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();
|
||||
|
||||
@@ -1327,7 +1327,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());
|
||||
}
|
||||
@@ -1340,7 +1340,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());
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ pub const REF_COLORS: [Color32; 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<u64>,
|
||||
pub sheet: usize,
|
||||
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
|
||||
pub locale: gridcraft_engine::formula::FormulaLocale,
|
||||
@@ -56,6 +59,7 @@ impl EditState {
|
||||
let original = (!enter_mode).then(|| text.clone());
|
||||
EditState {
|
||||
doc,
|
||||
window: None,
|
||||
sheet,
|
||||
locale: Default::default(),
|
||||
cell,
|
||||
|
||||
+54
-30
@@ -166,10 +166,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,
|
||||
@@ -177,7 +179,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() }
|
||||
}
|
||||
@@ -256,6 +259,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<editor::EditState>,
|
||||
pub text_box_editor: Option<text_box::EditState>,
|
||||
@@ -410,7 +414,7 @@ impl SheetApp {
|
||||
fn run_command(&mut self, id: &str, params: Json) -> Result<Json, String> {
|
||||
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()?;
|
||||
}
|
||||
@@ -567,9 +571,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;
|
||||
@@ -914,27 +918,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<OpenWindow> {
|
||||
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))
|
||||
}
|
||||
@@ -956,10 +969,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));
|
||||
@@ -1005,6 +1019,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);
|
||||
@@ -1015,29 +1030,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.
|
||||
@@ -1360,10 +1384,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`).
|
||||
|
||||
@@ -2403,7 +2403,7 @@ fn switch_windows(app: &mut SheetApp, ui: &mut Ui) {
|
||||
for w in app.open_windows() {
|
||||
let label = 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,3 +484,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<String> = 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}");
|
||||
}
|
||||
|
||||
@@ -23,13 +23,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 pointer events queued |
|
||||
| `ui.cell.click` / `ui.cell.drag` | `{cell}` / `{from, to}` | Pointer input at a cell's centre (works in point mode while editing); same 10,000-event queue limit |
|
||||
| `ui.key` / `ui.text` | `{key, shift?, cmd?, alt?, ctrl?}` / `{text}` | Keyboard input (typing in the grid starts editing) |
|
||||
@@ -42,7 +43,20 @@ connection; nothing sent after it runs. Blank lines are skipped.
|
||||
| `ui.resize` / `ui.focus` | `{width, height}` | Window control |
|
||||
| `app.open` / `app.save` / `app.quit` | `{path, password?}` / `{path?}` | Files and lifetime. `password` opens a password-protected workbook; without it, such a workbook returns an error (never a dialog) |
|
||||
|
||||
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"`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Switch Windows lists the open workbooks; a cell being typed stays in its own workbook through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (New Window opens a second window on the same workbook, titled Name:1 / Name:2; Switch Windows lists the open windows; a cell being typed stays in its own window through New, Open and window switches; macOS Quit asks about unsaved workbooks) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
|
||||
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
|
||||
@@ -34,7 +34,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 | margins drag on the rulers (#792, with header and footer margins); header/footer pictures, Different First Page / Odd & Even missing (1–3 h) |
|
||||
| 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
|
||||
|
||||
@@ -177,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user