From fef1f4ddb1dd546f94de045318932d47fbc934d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:25:14 +0200 Subject: [PATCH 01/54] Security: the --control server requires an auth token and closes bad connections (#777) Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.lock | 2 + ROADMAP.md | 2 + apps/gridcraft/Cargo.toml | 3 + apps/gridcraft/src/control_server.rs | 139 +++++++++++++++++++++++++-- apps/gridcraft/src/main.rs | 10 +- crates/mcp/src/backend.rs | 35 ++++++- crates/mcp/src/control_auth.rs | 135 ++++++++++++++++++++++++++ crates/mcp/src/lib.rs | 1 + crates/mcp/src/tests.rs | 51 +++++++++- docs/control-protocol.md | 26 ++++- docs/mcp.md | 2 +- 13 files changed, 393 insertions(+), 17 deletions(-) create mode 100644 crates/mcp/src/control_auth.rs diff --git a/AGENTS.md b/AGENTS.md index 963fc6d74..ccead14c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). -- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. - **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..2a7ee363e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr ## Running and looking at the app - `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel). -- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. +- Drive it: `gridcraft-cli send 7979 ''` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. - **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`. - Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`. - **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`. diff --git a/Cargo.lock b/Cargo.lock index ade3a5adb..c3fa06650 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1503,7 +1503,9 @@ dependencies = [ "eframe", "egui", "fmv-macos-events", + "getrandom 0.4.3", "gridcraft-engine", + "gridcraft-mcp", "gridcraft-ui-egui", "image", "log", diff --git a/ROADMAP.md b/ROADMAP.md index e8e1c06b3..930ded70d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) | | 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available | | 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found | | 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) | @@ -178,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row | | 2026-10-11 | minor | Forecast Sheet (#322); progress log row | diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index c867d24a2..14bc66b69 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -27,6 +27,9 @@ winresource = "0.1" craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } arboard = { workspace = true } +gridcraft-mcp = { workspace = true } +# The control channel's token (OS random numbers); already in the tree. +getrandom = "0.4" [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] arboard = { workspace = true, features = ["wayland-data-control"] } diff --git a/apps/gridcraft/src/control_server.rs b/apps/gridcraft/src/control_server.rs index e9e05a2e6..ab2046e3c 100644 --- a/apps/gridcraft/src/control_server.rs +++ b/apps/gridcraft/src/control_server.rs @@ -1,34 +1,82 @@ //! Localhost JSON-lines control server. Each connection may send many requests; each line //! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`. +//! +//! The first line of a connection must authenticate with the app's token (#777; see +//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't +//! JSON, is answered once and closed. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; +use gridcraft_mcp::control_auth; use gridcraft_ui_egui::ControlRequest; use serde_json::{Value as Json, json}; const MAX_LINE: usize = 64 * 1024 * 1024; +/// How long a new connection may take to send its token. +const AUTH_TIMEOUT: Duration = Duration::from_secs(10); -pub fn start(port: u16, ctx: egui::Context) -> std::io::Result> { +/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`), +/// else 256 random bits from the operating system, written to a file only the user can read. +fn make_token(port: u16, chosen: Option) -> std::io::Result { + if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) { + if t.len() < control_auth::MIN_TOKEN_LEN { + return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN))); + } + eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)"); + return Ok(t); + } + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?; + let token = control_auth::hex(&bytes); + let path = control_auth::write_token_file(port, &token)?; + eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display()); + Ok(token) +} + +pub fn start(port: u16, chosen_token: Option, ctx: egui::Context) -> std::io::Result> { let listener = TcpListener::bind(("127.0.0.1", port))?; + let token: Arc = make_token(port, chosen_token)?.into(); let (tx, rx) = channel(); std::thread::Builder::new().name("control".into()).spawn(move || { for stream in listener.incoming().flatten() { let tx = tx.clone(); let ctx = ctx.clone(); - let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx)); + let token = Arc::clone(&token); + let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx)); } })?; log::info!("control channel listening on 127.0.0.1:{port}"); Ok(rx) } -fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { +/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within +/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on. +fn authenticate(reader: &mut BufReader, out: &mut TcpStream, token: &str) -> bool { + let _ = out.set_read_timeout(Some(AUTH_TIMEOUT)); + let mut first = String::new(); + let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first); + let verdict = match read { + Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token), + _ => Err(json!({"ok": false, "error": "authentication required"})), + }; + let allowed = verdict.is_ok(); + let reply = verdict.unwrap_or_else(|e| e); + let _ = writeln!(out, "{reply}"); + let _ = out.set_read_timeout(None); + allowed +} + +fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - let reader = BufReader::new(read); + let mut reader = BufReader::new(read); + if !authenticate(&mut reader, &mut out, token) { + return; + } for line in reader.lines() { let Ok(line) = line else { break }; if line.trim().is_empty() { @@ -42,7 +90,7 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { Ok(v) => v, Err(e) => { let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")})); - continue; + break; // Not a client of ours; don't keep reading. } }; let id = msg.get("id").cloned().unwrap_or(Json::Null); @@ -65,3 +113,82 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { } } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + const TOKEN: &str = "0123456789abcdef0123456789abcdef"; + + /// A server on a loopback port with a stand-in UI that echoes each method. + fn spawn() -> std::net::SocketAddr { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "result": req.method})); + } + }); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let tx = tx.clone(); + std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default())); + } + }); + addr + } + + fn lines(addr: std::net::SocketAddr, send: &str) -> Vec { + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(send.as_bytes()).unwrap(); + // Replies up to the server closing the connection. + BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect() + } + + #[test] + fn the_token_opens_the_connection() { + let addr = spawn(); + let auth = control_auth::auth_line(1, TOKEN); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap(); + let mut r = BufReader::new(c); + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + assert_eq!(serde_json::from_str::(&line).unwrap()["ok"], json!(true)); + line.clear(); + r.read_line(&mut line).unwrap(); + let v: Json = serde_json::from_str(&line).unwrap(); + assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping"))); + } + + #[test] + fn anything_but_the_token_first_closes_the_connection() { + let addr = spawn(); + let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff"); + for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] { + // The second request is never served: one refusal, then end of stream. + let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n")); + assert_eq!(got.len(), 1, "{first}: {got:?}"); + assert_eq!(got[0]["ok"], json!(false), "{first}"); + } + } + + #[test] + fn an_unparsable_line_after_auth_closes_the_connection() { + let addr = spawn(); + let auth = control_auth::auth_line(1, TOKEN); + let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n")); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[1]["ok"], json!(false)); + } + + #[test] + fn a_chosen_token_must_be_long_enough() { + assert!(make_token(1, Some("short".into())).is_err()); + assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN); + } +} diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8093205c4..18cff9844 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -1,9 +1,10 @@ //! GridCraft desktop app. //! -//! Usage: `gridcraft [--control ] [--sample ] [files…]` +//! Usage: `gridcraft [--control [--control-token ]] [--sample ] [files…]` //! //! `--control ` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control //! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`. +//! The first line of each connection must carry the token (`docs/control-protocol.md`). //! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -242,6 +243,7 @@ fn main() -> eframe::Result<()> { return Ok(()); } let mut control_port: Option = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok()); + let mut control_token: Option = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok(); let mut sample: Option = None; let mut files: Vec = Vec::new(); let mut i = 0; @@ -251,6 +253,10 @@ fn main() -> eframe::Result<()> { i += 1; control_port = args.get(i).and_then(|p| p.parse().ok()); } + "--control-token" => { + i += 1; + control_token = args.get(i).cloned(); + } "--sample" => { i += 1; sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into())); @@ -338,7 +344,7 @@ fn main() -> eframe::Result<()> { }; SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language); if let Some(port) = control_port { - match control_server::start(port, cc.egui_ctx.clone()) { + match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) { Ok(rx) => app.control_rx = Some(rx), Err(e) => log::error!("control channel on port {port}: {e}"), } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index adc866d2f..c77b612f7 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024; /// A running GridCraft app, reached through its loopback control port. pub struct Remote { addr: String, + token: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, } impl Remote { - /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app + /// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port. pub fn connect(addr: &str) -> std::io::Result { - let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + Self::connect_with_token(addr, token) + } + + /// Like [`Remote::connect`] with a token the caller already has. + pub fn connect_with_token(addr: &str, token: String) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 }; r.reconnect()?; Ok(r) } @@ -51,7 +59,9 @@ impl Remote { s.set_read_timeout(Some(IO_TIMEOUT)).ok(); s.set_write_timeout(Some(IO_TIMEOUT)).ok(); let read = s.try_clone()?; - self.conn = Some((BufReader::new(read), s)); + let mut conn = (BufReader::new(read), s); + Self::authenticate(&mut conn, &self.token)?; + self.conn = Some(conn); return Ok(()); } Err(e) => last = e, @@ -60,6 +70,25 @@ impl Remote { Err(last) } + /// The handshake every connection starts with: the app closes one that doesn't send the token first. + fn authenticate(conn: &mut (BufReader, TcpStream), token: &str) -> std::io::Result<()> { + let (reader, writer) = conn; + writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?; + let ok = n > 0 && serde_json::from_str::(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true); + if ok { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)", + )) + } + } + fn roundtrip(&mut self, line: &str) -> std::io::Result { if self.conn.is_none() { self.reconnect()?; diff --git a/crates/mcp/src/control_auth.rs b/crates/mcp/src/control_auth.rs new file mode 100644 index 000000000..7ff589353 --- /dev/null +++ b/crates/mcp/src/control_auth.rs @@ -0,0 +1,135 @@ +//! Authentication of the control channel (#777). +//! +//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:`, can +//! reach a loopback port. So the first line of every connection must be +//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The +//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it +//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +/// Environment variable holding a token chosen by the user. The app uses it instead of making +/// one (and writes no file); clients use it instead of reading the file. +pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN"; +/// The method of the handshake line. +pub const AUTH_METHOD: &str = "auth"; +/// Shortest token the app accepts from the user (a made one has 256 bits). +pub const MIN_TOKEN_LEN: usize = 16; +/// Longest first line the app reads from a peer that hasn't authenticated yet. +pub const MAX_AUTH_LINE: usize = 4096; + +/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal +/// at once (the length of a token isn't secret). +pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + // Keep the optimiser from turning the loop into an early exit. + std::hint::black_box(diff) == 0 +} + +/// A made token as lowercase hex (two characters per byte). +pub fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len().saturating_mul(2)); + for b in bytes { + s.push(char::from(DIGITS[usize::from(b >> 4)])); + s.push(char::from(DIGITS[usize::from(b & 15)])); + } + s +} + +/// The handshake line a client sends first. +pub fn auth_line(id: u64, token: &str) -> String { + json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string() +} + +/// Check a connection's first line. `Ok` is the reply to send before serving further requests; +/// `Err` is the reply for a refused connection, which the caller sends and then closes. The +/// refusal is the same whatever was wrong, so it tells a stranger nothing. +pub fn check_first_line(line: &str, token: &str) -> Result { + let refused = || json!({"ok": false, "error": "authentication required"}); + let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?; + if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) { + return Err(refused()); + } + let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or(""); + // Always compare, so a missing token costs the same as a wrong one. + if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() { + Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}})) + } else { + Err(refused()) + } +} + +/// Where the app writes the token it made for `port`, and where clients look for it: a folder +/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows +/// `%USERPROFILE%\.gridcraft`), file `control-.token`. `None` when no such folder is known. +pub fn token_path(port: u16) -> Option { + let dir = if cfg!(windows) { + PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft") + } else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) { + PathBuf::from(run).join("gridcraft") + } else { + PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft") + }; + Some(dir.join(format!("control-{port}.token"))) +} + +/// The port of a control address (`127.0.0.1:7979` → 7979). +fn port_of(addr: &str) -> Option { + addr.rsplit(':').next()?.parse().ok() +} + +/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote. +pub fn client_token(addr: &str) -> Result { + if let Ok(t) = std::env::var(TOKEN_ENV) + && !t.trim().is_empty() + { + return Ok(t.trim().to_string()); + } + let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?; + match std::fs::read_to_string(&path) { + Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()), + _ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())), + } +} + +/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix), +/// an older file is replaced, and the new one is created readable by the user only (mode 0600). +/// Returns the path. +pub fn write_token_file(port: u16, token: &str) -> std::io::Result { + use std::io::Write; + let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?; + let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?; + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is. + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + } + // Replace rather than open: a leftover file may have looser permissions, or be a link. + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts.open(&path)?; + f.write_all(token.as_bytes())?; + f.write_all(b"\n")?; + Ok(path) +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index de3e25044..7c66a26f9 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -15,6 +15,7 @@ #![forbid(unsafe_code)] mod backend; +pub mod control_auth; mod headless; mod server; mod tools; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index eb1f20503..df6c954e7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -363,6 +363,11 @@ fn remote_backend_over_tcp() { let stream = stream.unwrap(); let mut w = stream.try_clone().unwrap(); let mut r = BufReader::new(stream); + // Every connection starts with the token, as the app requires. + let mut first = String::new(); + r.read_line(&mut first).unwrap(); + let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap(); + w.write_all(format!("{hello}\n").as_bytes()).unwrap(); loop { let mut line = String::new(); if r.read_line(&mut line).unwrap_or(0) == 0 { @@ -382,7 +387,7 @@ fn remote_backend_over_tcp() { }); let addr = control_addr(&port.to_string()); assert_eq!(addr, format!("127.0.0.1:{port}")); - let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap())); assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0)); // Second call reconnects transparently. ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"})); @@ -391,7 +396,49 @@ fn remote_backend_over_tcp() { assert!(msg.contains("unknown command"), "{msg}"); drop(s); handle.join().unwrap(); - assert!(Remote::connect("127.0.0.1:1").is_err()); + assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err()); +} + +#[test] +fn control_auth_handshake_and_compare() { + use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex}; + let token = "0123456789abcdef0123456789abcdef"; + let ok = check_first_line(&auth_line(7, token), token).unwrap(); + assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true))); + // Wrong or missing token, another method, and non-JSON are all refused the same way. + let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err(); + for bad in [ + auth_line(1, ""), + r#"{"id":1,"method":"auth","params":{}}"#.into(), + r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(), + "POST / HTTP/1.1".into(), + String::new(), + ] { + assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}"); + } + // An empty configured token never matches an empty given one. + assert!(check_first_line(&auth_line(1, ""), "").is_err()); + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"abcd")); + assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff"); +} + +#[cfg(unix)] +#[test] +fn control_token_file_is_private() { + use std::os::unix::fs::PermissionsExt; + // Unique port per run so parallel tests don't share a file; the folder is the real one. + let port = 40_000 + (std::process::id() % 20_000) as u16; + let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700); + // A looser leftover is replaced, not reused. + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap(); + crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap(); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef"); + std::fs::remove_file(&path).unwrap(); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7cc6ec230..39a3e5d67 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -6,7 +6,31 @@ Start the desktop app with a control port: gridcraft --control 7979 # or GRIDCRAFT_CONTROL_PORT=7979 ``` -Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets one reply line: +## Authentication + +The control port is reachable by every program on the machine, and by a web page that POSTs +`text/plain` to `127.0.0.1:7979`, so every connection must prove it knows the app's token +(#777). **There is no way to turn this off.** + +- The app makes a token at start (256 random bits from the operating system, kept in memory) and + writes it to a file only you can read: `$XDG_RUNTIME_DIR/gridcraft/control-.token` (else + `~/.cache/gridcraft/…`; Windows: `%USERPROFILE%\.gridcraft\…`; the folder is mode 0700 and the + file 0600 on Unix). The path is printed on stderr. The token itself is never printed or logged. +- Or choose the token yourself: `GRIDCRAFT_CONTROL_TOKEN=…` (preferred) or `--control-token …` + (visible in the process list), at least 16 characters. The app then writes no file. +- The first line of a connection must be + `{"id":0,"method":"auth","params":{"token":"…"}}`; the reply is + `{"id":0,"ok":true,"result":{"authenticated":true}}`. Any other first line (a wrong or missing + token, another method, text that isn't JSON such as an HTTP request, no line within 10 s, or + one longer than 4 KiB) gets `{"ok":false,"error":"authentication required"}` and the + connection is closed. After that, a line that isn't JSON also closes the connection. +- `gridcraft-cli send`/`mcp --connect` and `gridcraft_mcp::Remote` do this themselves: they use + `GRIDCRAFT_CONTROL_TOKEN` if set, else the token file of that port (so they work for the same + user on the same machine without setup). Your own client reads the file or the variable. + +## Requests + +Then send newline-delimited JSON to `127.0.0.1:7979` (after the `auth` line). Every request line gets one reply line: ```json {"id": 1, "method": "engine.execute", "params": {"command": "cell.set", "params": {"cell": "B2", "input": "=SUM(A1:A9)"}}} diff --git a/docs/mcp.md b/docs/mcp.md index 3e6e7bc11..811fc9500 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -9,7 +9,7 @@ Two backends: | Mode | Command | What it drives | |---|---|---| | Headless (default) | `gridcraft-cli mcp [--in book.xlsx \| --sample budget]` | An in-process engine session — no window. | -| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`). | +| Remote | `gridcraft-cli mcp --connect 7979` | A running app started with `gridcraft --control 7979` (TCP JSON lines on `127.0.0.1`, authenticated with the app's token: it is read from the app's token file or `GRIDCRAFT_CONTROL_TOKEN`, see [`control-protocol.md`](control-protocol.md)). | ## Claude Code From 5bd6cc3d04701b1f70442f2a2eaae5d285a0f3e8 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 00:57:17 -0500 Subject: [PATCH 02/54] Options: Maximum Iterations and Maximum Change in File > Options > Formulas The Formulas section had the calculation-mode radios and Enable iterative calculation but not the two limits that formulas.calculationOptions already takes. They now sit under the checkbox, disabled while iteration is off, and commit when the field loses focus. Like Excel, out-of-range entries (Maximum Iterations outside 1-32767, a negative or non-numeric Maximum Change) are refused with a message instead of being clamped by the command; the Enter that committed the field no longer also dismisses that message. Labels and messages are translated for every interface language. A kittest covers the disabled state, valid and invalid entries, and undo refreshing the field. Drops the stale "no Options dialog yet" from gaps.md and function-parity.md. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/ui-egui/src/backstage.rs | 53 +++++++++++++++++++ crates/ui-egui/src/i18n.rs | 4 ++ crates/ui-egui/src/i18n/cs.rs | 4 ++ crates/ui-egui/src/i18n/de.rs | 4 ++ crates/ui-egui/src/i18n/es.rs | 4 ++ crates/ui-egui/src/i18n/fr.rs | 6 +++ crates/ui-egui/src/i18n/ko.rs | 4 ++ crates/ui-egui/src/i18n/pt.rs | 4 ++ crates/ui-egui/src/i18n/ru.rs | 4 ++ crates/ui-egui/src/i18n/sr.rs | 4 ++ crates/ui-egui/src/i18n/zh.rs | 4 ++ crates/ui-egui/tests/file_menu.rs | 88 ++++++++++++++++++++++++++++++- docs/function-parity.md | 5 +- docs/gaps.md | 6 ++- docs/ui-parity.md | 5 +- 16 files changed, 194 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2c06f07d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (Maximum Iterations and Maximum Change in File › Options) · **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. @@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | What landed | |---|---| +| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) | | 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) | | 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) | | 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) | diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 922baabc5..bc41222e1 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -992,10 +992,63 @@ fn options(app: &mut SheetApp, ui: &mut Ui) { if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() { app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative})); } + // Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel. + ui.add_enabled_ui(calc.iterative, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| { + ui.label(lang.tr("Maximum Iterations:")); + let id = ui.make_persistent_id("backstage_max_iterations"); + if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0)) { + match parse_max_iterations(&text) { + Some(n) if n != calc.max_iterations => { + app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."), + } + } + ui.end_row(); + ui.label(lang.tr("Maximum Change:")); + let id = ui.make_persistent_id("backstage_max_change"); + if let Some(text) = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0)) { + match parse_max_change(&text) { + Some(d) if d != calc.max_change => { + app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d})); + } + Some(_) => {} + None => refuse(app, ui, "Maximum Change must be a number of 0 or more."), + } + } + ui.end_row(); + }); + }); + }); + // TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes + // `precisionAsDisplayed` (PR #1366); the model already stores it. ui.add_space(6.0); dim(ui, lang.tr("Calculation settings are saved with the workbook.")); } +/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that +/// committed the field is consumed so it doesn't also dismiss the message box in this frame. +fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) { + ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); + app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into())); +} + +/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others). +fn parse_max_iterations(text: &str) -> Option { + let n = text.trim().parse::().ok()?; + (1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n) +} + +/// Maximum Change as typed: a finite number of 0 or more, or `None`. +fn parse_max_change(text: &str) -> Option { + let d = text.trim().parse::().ok()?; + (d.is_finite() && d >= 0.0).then_some(d) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 061698df9..17b098de2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -587,6 +587,10 @@ const JAPANESE: &[(&str, &str)] = &[ ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), + ("Maximum Iterations:", "最大反復回数:"), + ("Maximum Change:", "変化の最大値:"), + ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"), + ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), ]; diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index 7ed8372c7..deaff822c 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -323,6 +323,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně", "Workbook calculation" => "Výpočet sešitu", "Enable iterative calculation" => "Povolit iterativní výpočet", + "Maximum Iterations:" => "Maximální počet iterací:", + "Maximum Change:" => "Maximální změna:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Maximální počet iterací musí být celé číslo od 1 do 32767.", + "Maximum Change must be a number of 0 or more." => "Maximální změna musí být číslo 0 nebo větší.", "Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.", "100%" => "100 %", "Zoom to\nSelection" => "Přiblížit\nna výběr", diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index c20c7d1b3..efa20cac7 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -323,6 +323,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern", "Workbook calculation" => "Arbeitsmappenberechnung", "Enable iterative calculation" => "Iterative Berechnung aktivieren", + "Maximum Iterations:" => "Maximale Iterationszahl:", + "Maximum Change:" => "Maximale Änderung:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Die maximale Iterationszahl muss eine ganze Zahl von 1 bis 32767 sein.", + "Maximum Change must be a number of 0 or more." => "Die maximale Änderung muss eine Zahl größer oder gleich 0 sein.", "Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.", "Interface size" => "Größe der Benutzeroberfläche", "Auto" => "Automatisch", diff --git a/crates/ui-egui/src/i18n/es.rs b/crates/ui-egui/src/i18n/es.rs index 4ce231bca..508f04945 100644 --- a/crates/ui-egui/src/i18n/es.rs +++ b/crates/ui-egui/src/i18n/es.rs @@ -322,6 +322,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio", "Workbook calculation" => "Cálculo del libro", "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Maximum Iterations:" => "Iteraciones máximas:", + "Maximum Change:" => "Cambio máximo:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Las iteraciones máximas deben ser un número entero de 1 a 32767.", + "Maximum Change must be a number of 0 or more." => "El cambio máximo debe ser un número igual o mayor que 0.", "Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.", "Interface size" => "Tamaño de la interfaz", "Auto" => "Automático", diff --git a/crates/ui-egui/src/i18n/fr.rs b/crates/ui-egui/src/i18n/fr.rs index 5d20ab0ca..dec45571b 100644 --- a/crates/ui-egui/src/i18n/fr.rs +++ b/crates/ui-egui/src/i18n/fr.rs @@ -409,6 +409,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification", "Workbook calculation" => "Calcul du classeur", "Enable iterative calculation" => "Activer le calcul itératif", + "Maximum Iterations:" => "Nombre maximal d'itérations :", + "Maximum Change:" => "Écart maximal :", + "Maximum Iterations must be a whole number from 1 to 32767." => { + "Le nombre maximal d'itérations doit être un entier compris entre 1 et 32767." + } + "Maximum Change must be a number of 0 or more." => "L'écart maximal doit être un nombre supérieur ou égal à 0.", "Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.", _ => return None, }) diff --git a/crates/ui-egui/src/i18n/ko.rs b/crates/ui-egui/src/i18n/ko.rs index 1902ad301..566044d57 100644 --- a/crates/ui-egui/src/i18n/ko.rs +++ b/crates/ui-egui/src/i18n/ko.rs @@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장", "Workbook calculation" => "통합 문서 계산", "Enable iterative calculation" => "반복 계산 사용", + "Maximum Iterations:" => "최대 반복 횟수:", + "Maximum Change:" => "변화 한도:", + "Maximum Iterations must be a whole number from 1 to 32767." => "최대 반복 횟수는 1에서 32767 사이의 정수여야 합니다.", + "Maximum Change must be a number of 0 or more." => "변화 한도는 0 이상의 숫자여야 합니다.", "Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.", _ => return None, }) diff --git a/crates/ui-egui/src/i18n/pt.rs b/crates/ui-egui/src/i18n/pt.rs index fb9236cdb..74d4c6268 100644 --- a/crates/ui-egui/src/i18n/pt.rs +++ b/crates/ui-egui/src/i18n/pt.rs @@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração", "Workbook calculation" => "Cálculo da pasta de trabalho", "Enable iterative calculation" => "Habilitar cálculo iterativo", + "Maximum Iterations:" => "Número Máximo de Iterações:", + "Maximum Change:" => "Alteração Máxima:", + "Maximum Iterations must be a whole number from 1 to 32767." => "O número máximo de iterações deve ser um número inteiro de 1 a 32767.", + "Maximum Change must be a number of 0 or more." => "A alteração máxima deve ser um número maior ou igual a 0.", "Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.", "OK" => "OK", "Cancel" => "Cancelar", diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 765f6bb32..f53611517 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -409,6 +409,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения", "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", + "Maximum Iterations:" => "Предельное число итераций:", + "Maximum Change:" => "Относительная погрешность:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.", + "Maximum Change must be a number of 0 or more." => "Относительная погрешность должна быть числом не меньше 0.", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", _ => return None, }) diff --git a/crates/ui-egui/src/i18n/sr.rs b/crates/ui-egui/src/i18n/sr.rs index cbf18d4c4..8f724c22b 100644 --- a/crates/ui-egui/src/i18n/sr.rs +++ b/crates/ui-egui/src/i18n/sr.rs @@ -401,6 +401,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene", "Workbook calculation" => "Izračunavanje radne sveske", "Enable iterative calculation" => "Omogući iterativno izračunavanje", + "Maximum Iterations:" => "Maksimalan broj iteracija:", + "Maximum Change:" => "Maksimalna promena:", + "Maximum Iterations must be a whole number from 1 to 32767." => "Maksimalan broj iteracija mora biti ceo broj od 1 do 32767.", + "Maximum Change must be a number of 0 or more." => "Maksimalna promena mora biti broj veći ili jednak 0.", "Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.", "Left Margin" => "Leva margina", "Right Margin" => "Desna margina", diff --git a/crates/ui-egui/src/i18n/zh.rs b/crates/ui-egui/src/i18n/zh.rs index 00175925e..8f77aeb6d 100644 --- a/crates/ui-egui/src/i18n/zh.rs +++ b/crates/ui-egui/src/i18n/zh.rs @@ -401,6 +401,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿", "Workbook calculation" => "工作簿计算", "Enable iterative calculation" => "启用迭代计算", + "Maximum Iterations:" => "最多迭代次数:", + "Maximum Change:" => "最大误差:", + "Maximum Iterations must be a whole number from 1 to 32767." => "最多迭代次数必须是 1 到 32767 之间的整数。", + "Maximum Change must be a number of 0 or more." => "最大误差必须是大于或等于 0 的数字。", "Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。", _ => return None, }) diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 691eef367..33059c14e 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -2,7 +2,7 @@ use std::sync::{Arc, Mutex}; -use egui_kittest::kittest::Queryable as _; +use egui_kittest::kittest::{NodeT as _, Queryable as _}; use gridcraft_engine::{Session, core::CellRef, core::Value}; use gridcraft_ui_egui::{Services, SheetApp}; @@ -217,3 +217,89 @@ fn a_cancelled_export_pdf_picker_writes_nothing() { assert!(h.state().message.is_none()); assert!(h.state_mut().session.take_ui_requests().is_empty()); } + +/// Options › Formulas: Maximum Iterations and Maximum Change, with the checkbox that enables them. +#[test] +fn options_sets_maximum_iterations_and_change_only_while_iteration_is_on() { + use egui::accesskit::Role; + let mut session = Session::new(); + session.new_workbook(); + // Tall enough that the Formulas section is on screen without scrolling. + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1400.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Services::default()), + ); + h.run_steps(4); + h.state_mut().run("file.options", serde_json::json!({})).unwrap(); + h.run_steps(3); + let calc = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().session.active().unwrap().wb.calc.clone(); + // The text field on the same row as `label`. + let field = |h: &egui_kittest::Harness<'static, SheetApp>, label: &str| { + let l = h.get_by_label(label).rect(); + h.get_all_by_role(Role::TextInput).map(|n| n.rect()).find(|r| r.left() > l.left() && (r.center().y - l.center().y).abs() < 8.0).unwrap() + }; + let disabled = |h: &egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect| { + h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().accesskit_node().is_disabled() + }; + let retype = |h: &mut egui_kittest::Harness<'static, SheetApp>, rect: egui::Rect, text: &str| { + h.input_mut().events.push(egui::Event::PointerMoved(rect.center())); + h.run_steps(1); + h.get_all_by_role(Role::TextInput).find(|n| n.rect() == rect).unwrap().click(); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.run_steps(1); + for c in text.chars() { + h.input_mut().events.push(egui::Event::Text(c.to_string())); + h.run_steps(2); + } + h.key_press(egui::Key::Enter); + h.run_steps(2); + }; + + // Iteration is off: the two fields show the defaults, greyed out. + let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:")); + assert!(disabled(&h, iterations) && disabled(&h, change), "the fields are disabled while iteration is off"); + assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == iterations).unwrap().value().as_deref(), Some("100")); + assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.001")); + + h.get_by_role_and_label(Role::CheckBox, "Enable iterative calculation").click(); + h.run_steps(3); + assert!(calc(&h).iterative); + let (iterations, change) = (field(&h, "Maximum Iterations:"), field(&h, "Maximum Change:")); + assert!(!disabled(&h, iterations) && !disabled(&h, change)); + + retype(&mut h, iterations, "500"); + assert_eq!(calc(&h).max_iterations, 500); + retype(&mut h, change, "0.01"); + assert_eq!(calc(&h).max_change, 0.01); + assert!(h.state().message.is_none()); + + // Out-of-range entries are refused with a message and change nothing (the command alone would clamp them). + for bad in ["0", "32768", "12.5", "lots"] { + retype(&mut h, iterations, bad); + assert_eq!(calc(&h).max_iterations, 500, "{bad}"); + assert!(h.state_mut().message.take().is_some(), "{bad} is reported"); + h.run_steps(2); + } + retype(&mut h, iterations, "32767"); + assert_eq!(calc(&h).max_iterations, 32767); + for bad in ["-1", "x"] { + retype(&mut h, change, bad); + assert_eq!(calc(&h).max_change, 0.01, "{bad}"); + assert!(h.state_mut().message.take().is_some(), "{bad} is reported"); + h.run_steps(2); + } + retype(&mut h, change, "0"); + assert_eq!(calc(&h).max_change, 0.0); + + // Undo puts the old value back in the field. + h.state_mut().run("edit.undo", serde_json::json!({})).unwrap(); + h.run_steps(3); + assert_eq!(calc(&h).max_change, 0.01); + let change = field(&h, "Maximum Change:"); + assert_eq!(h.get_all_by_role(Role::TextInput).find(|n| n.rect() == change).unwrap().value().as_deref(), Some("0.01")); +} diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..7b2f5f03e 100644 --- a/docs/function-parity.md +++ b/docs/function-parity.md @@ -1,6 +1,6 @@ # Function parity: worksheet functions and formulas -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (iterative calculation settings in File › Options) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Excel's worksheet functions, category by category, against GridCraft's function library. Part of [target-app-parity.md](target-app-parity.md); gaps feed [gaps.md](gaps.md). @@ -79,7 +79,7 @@ PIVOTBY, REGEXTEST…) are all present. | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | | Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too | | LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done | -| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Differences: no Options dialog yet (command only); GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | +| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as ``), loops are evaluated again from their previous values (blank = 0) until no cell on a loop changes by Maximum Change or Maximum Iterations is reached; off, cycles give `#CIRC!`. Set in File › Options › Formulas (Enable iterative calculation, Maximum Iterations 1 to 32767, Maximum Change 0 or more; the two fields are disabled while iteration is off). Differences: GridCraft recalculates on open, so a loop that never settles (`=A1+1`) advances again each time the file is opened | | 15-significant-digit comparison semantics | **missing** (#57: `=0.1+0.2=0.3` is FALSE) | | External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update | | Localized function names and `;` separators | **missing** (#45); en-US only | @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Iterative calculation: Maximum Iterations and Maximum Change are set in File › Options › Formulas (dropped "no Options dialog yet") | | 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) | | 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) | | 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled | diff --git a/docs/gaps.md b/docs/gaps.md index 03e8672c5..61b5f7422 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (§6: Maximum Iterations and Maximum Change in File › Options) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -62,7 +62,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- - **Missing:** 15-significant-digit equality; spill updates after structural edits and cleared blockers; 3-D span deletion; qualified names after sheet rename. - **Evidence:** #57, #133, #139, #140, #134; PRs #148, #155, #156, #157. Iterative calculation - for circular references landed (no Options dialog yet). + for circular references landed, set in File › Options › Formulas (Maximum Iterations, Maximum + Change). - **Impact:** wrong numbers are worse than missing features. - **Estimate:** 10–15 h. [function-parity.md](function-parity.md#formula-language) @@ -168,6 +169,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | §6: Maximum Iterations and Maximum Change in File › Options › Formulas; dropped the stale "no Options dialog yet" | | 2026-10-12 | minor | rows auto-fit oversized fonts on open and whole-row/column formatting (#833) | | 2026-10-12 | minor | Page Layout view: dragging margins on the rulers (#792) removed from the §14 remainder | | 2026-10-12 | minor | §13: Forecast Sheet Forecast Start (hindcast) landed (#785) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..34b6ed1e4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (File › Options › Formulas: Maximum Iterations and Maximum Change) · **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 @@ -26,7 +26,7 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Task panes (Format Chart, Comments, Watch, Selection, PivotTable fields) | yes | yes | element-level Format pane for shapes and pictures missing | | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | partial | Options dialog and template gallery missing | | Dark mode, system appearance | yes | yes | done (10-09); follows the system on first run, title-bar switch and File › Display theme (#194, #239) | -| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | +| Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation mode and iterative calculation with Maximum Iterations and Maximum Change, 10-15) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | 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) | @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | File › Options › Formulas: Maximum Iterations (1 to 32767) and Maximum Change (0 or more) under Enable iterative calculation, disabled while it is off; invalid entries are refused with a message | | 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread | | 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) | | 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md | From 1794c389e58ce42e538a88468d7fe6a426dff387 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:01:47 +0000 Subject: [PATCH 03/54] Keep the cell draft in its own workbook; Switch Windows lists open workbooks A cell being typed when File > New ran (macOS menu, control channel) stayed open over the new workbook, and Enter wrote it there; the first workbook stayed clean and nothing in the UI reached it. - Every file.* and window command (window.activate, view.newWindow, view.hideWindow, view.unhideWindow, view.switchWindows with a doc) first commits the cell and text-box drafts as Enter would, or drops a draft its workbook refuses, still reporting why. Finder, argv and dropped opens go through file.open and get the same. - The cell editor records its workbook's uid; the grid draws a draft only over that workbook, and a commit always goes into it. - view.switchWindows takes a `doc` uid to bring that workbook forward and lists index, doc, title, dirty, active and hidden. Menu-style calls with no doc open the View tab's new Switch Windows dropdown. - macOS: the Window menu lists the open workbooks (unsaved bullet, active check), and Quit closes the window so unsaved workbooks are asked about. --- apps/gridcraft/src/main.rs | 2 +- apps/gridcraft/src/native_menu.rs | 68 ++++++++++++++-- crates/engine/src/cmd/extra.rs | 28 ++++++- crates/engine/src/lib.rs | 6 ++ crates/engine/src/tests.rs | 31 ++++++++ crates/ui-egui/src/editor.rs | 15 ++-- crates/ui-egui/src/grid.rs | 3 +- crates/ui-egui/src/lib.rs | 127 ++++++++++++++++++++++++------ crates/ui-egui/src/ribbon.rs | 19 +++++ crates/ui-egui/tests/file_menu.rs | 77 ++++++++++++++++++ 10 files changed, 337 insertions(+), 39 deletions(-) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..8aad9aeb8 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -34,7 +34,7 @@ impl eframe::App for App { if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { self.1 = Some(native_menu::NativeMenu::install(ctx)); } - if let Some(m) = &self.1 { + if let Some(m) = &mut self.1 { m.poll(&mut self.0, ctx); } apple_events::poll(&self.2, &mut self.0, ctx); diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..2c2866379 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -4,7 +4,7 @@ use std::str::FromStr; use std::sync::mpsc::{Receiver, channel}; -use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::{OpenWindow, SheetApp}; use muda::accelerator::Accelerator; use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; use serde_json::{Value, json}; @@ -168,7 +168,16 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Calculate Now", "formulas.calculateNow", None), ], ), - ("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]), + // The open workbooks follow the separator (`NativeMenu::sync_windows`). + ( + "Window", + vec![ + ("New Window", "view.newWindow", None), + ("Next Sheet", "sheet.next", None), + ("Previous Sheet", "sheet.previous", None), + ("-", "", None), + ], + ), ( "Help", vec![ @@ -185,8 +194,19 @@ pub struct NativeMenu { _menu: Menu, events: Receiver, theme_items: Vec<(&'static str, CheckMenuItem)>, + window_menu: Option, + /// The Window menu's workbook 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:"; + +/// The Quit item: closing the window asks about unsaved workbooks first, which AppKit's own +/// Quit (terminate:) would skip. +const QUIT: &str = "app.quit"; + fn select_theme(app: &mut SheetApp, id: &str) -> bool { let mode = match id { "theme:system" => "system", @@ -212,7 +232,7 @@ impl NativeMenu { &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), - &PredefinedMenuItem::quit(None), + &MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()), ]); let _ = menu.append(&app_menu); let mut theme_items = Vec::new(); @@ -247,7 +267,7 @@ impl NativeMenu { // NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the // system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks // the submenu up in the installed main menu. - if let Some(window) = window_menu { + if let Some(window) = &window_menu { window.set_as_windows_menu_for_nsapp(); } let (tx, rx) = channel(); @@ -256,12 +276,47 @@ impl NativeMenu { let _ = tx.send(e); ctx.request_repaint(); })); - NativeMenu { _menu: menu, events: rx, theme_items } + NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() } } - pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) { + /// Keeps the Window menu's list of open workbooks (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 { + return; + } + let Some(menu) = &self.window_menu else { return }; + let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); + if same_docs { + for (item, w) in self.window_items.iter().zip(&windows) { + item.set_text(w.label()); + item.set_checked(w.active); + } + } else { + for item in self.window_items.drain(..) { + 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 _ = menu.append(&item); + self.window_items.push(item); + } + } + self.windows = windows; + } + + pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); + if id == QUIT { + 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})); + continue; + } // Text selection belongs to the inline editor, not the worksheet clipboard/undo. if app.text_box_editor.is_some() { let clipboard = match id.as_str() { @@ -321,6 +376,7 @@ impl NativeMenu { item.set_checked(checked); } } + self.sync_windows(app); } } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index c44094cdd..debb62eeb 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -145,7 +145,15 @@ pub fn specs() -> Vec { 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!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows), + 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", + always, + switch_windows + ), cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler), cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts), // Table tools @@ -808,12 +816,26 @@ fn unhide_window(s: &mut Session, p: &Json) -> Result { ok() } -fn switch_windows(s: &mut Session, _: &Json) -> Result { +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); + } Ok(Json::Array( s.documents() .iter() .enumerate() - .map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)})) + .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(), )) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 10c6cb87c..aeeaa9bdb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -106,6 +106,8 @@ pub struct DocState { pub path: Option, pub saved: Arc, pub revision: u64, + /// Identifies this open workbook for the rest of the session: it never changes while the + /// workbook is open (saving, renaming, reordering) and is never reused for another one. pub uid: u64, pub title: String, /// Per-sheet selection memory. @@ -336,6 +338,10 @@ impl Session { pub fn active(&self) -> Option<&DocState> { self.docs.get(self.active) } + /// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`]. + pub fn document_index(&self, uid: u64) -> Option { + self.docs.iter().position(|d| d.uid == uid) + } pub fn active_mut(&mut self) -> Option<&mut DocState> { self.docs.get_mut(self.active) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6a99be91..07a32209f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4459,3 +4459,34 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() { let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap(); assert!(!html.contains("") && !html.contains("table-layout") && !html.contains(" EditState { + pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState { let caret = text.chars().count(); EditState { + doc, sheet, locale: Default::default(), cell, @@ -365,7 +368,7 @@ mod tests { #[test] fn point_mode() { - let mut e = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false); assert!(e.can_point()); e.insert_ref("A1"); assert_eq!(e.text, "=SUM(A1"); @@ -375,19 +378,19 @@ mod tests { e.caret += 1; e.point = None; assert!(e.can_point()); - let e2 = EditState::new(0, CellRef::new(0, 0), "hello".into(), true, false); + let e2 = EditState::new(1, 0, CellRef::new(0, 0), "hello".into(), true, false); assert!(!e2.can_point()); } #[test] fn autocomplete_and_hints() { let names: Vec = ["SUM", "SUMIF", "SUMPRODUCT", "AVERAGE"].iter().map(|s| s.to_string()).collect(); - let mut e = EditState::new(0, CellRef::new(0, 0), "=su".into(), true, false); + let mut e = EditState::new(1, 0, CellRef::new(0, 0), "=su".into(), true, false); e.update_autocomplete(&names); assert_eq!(e.autocomplete.len(), 3); assert!(e.accept_autocomplete()); assert_eq!(e.text, "=SUM("); - let e = EditState::new(0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false); + let e = EditState::new(1, 0, CellRef::new(0, 0), "=IF(A1>0,SUM(B1,".into(), true, false); assert_eq!(e.current_function(), Some(("SUM".into(), 1))); } @@ -423,7 +426,7 @@ mod tests { #[test] fn inserting_a_union_keeps_each_reference_separately_pointed() { - let mut edit = EditState::new(0, CellRef::new(0, 0), "=SUM(".into(), true, false); + let mut edit = EditState::new(1, 0, CellRef::new(0, 0), "=SUM(".into(), true, false); edit.insert_ref("C:C"); edit.append_ref("E:E"); assert_eq!(edit.text, "=SUM(C:C,E:E"); diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..145b9f583 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1744,6 +1744,7 @@ pub(crate) fn paint_selection( // Editing a formula: outline its references instead of the selection. if let Some(ed) = &app.editor && ed.is_formula() + && app.session.active().is_some_and(|d| d.uid == ed.doc) { let origin = app.session.active().and_then(|d| d.wb.sheet(ed.sheet)).map_or(sh.name.as_str(), |o| o.name.as_str()); for (_, _, r, sheet, idx) in crate::editor::formula_refs(&ed.text) { @@ -3045,7 +3046,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) { let Some(ed) = app.editor.as_ref() else { return }; - if ed.sheet != wb.active_sheet { + if app.view_key() != Some((ed.doc, ed.sheet)) { return; } let cell = ed.cell; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index faae9bfae..001abaad5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -166,6 +166,23 @@ pub struct Notice { pub action: Option<(String, String)>, } +/// One open workbook, 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`. + pub doc: u64, + pub title: String, + pub dirty: bool, + pub active: bool, +} + +impl OpenWindow { + /// The title, 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() } + } +} + /// Host services the platform shell provides (file pickers, opening URLs). #[derive(Default)] pub struct Services { @@ -296,6 +313,9 @@ pub struct SheetApp { /// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next /// window close goes through. pub allow_close: bool, + /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a + /// key tip, the command search or a menu, possibly while another tab was showing). + pub(crate) open_switch_windows: bool, } impl SheetApp { @@ -342,6 +362,7 @@ impl SheetApp { tab_run: None, started: now_ms(), allow_close: false, + open_switch_windows: false, } } @@ -387,30 +408,30 @@ 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())); + if id.starts_with("file.") || switches_window { + self.settle_drafts(); + } if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); } - if id.starts_with("file.") - || matches!( - id, - "window.activate" - | "sheet.activate" - | "sheet.next" - | "sheet.previous" - | "sheet.move" - | "sheet.hide" - | "sheet.unhide" - | "view.newWindow" - | "view.hideWindow" - | "view.unhideWindow" - | "home.insertSheet" - | "home.deleteSheet" - | "edit.undo" - | "edit.redo" - | "object.delete" - | "object.move" - ) - { + if matches!( + id, + "sheet.activate" + | "sheet.next" + | "sheet.previous" + | "sheet.move" + | "sheet.hide" + | "sheet.unhide" + | "home.insertSheet" + | "home.deleteSheet" + | "edit.undo" + | "edit.redo" + | "object.delete" + | "object.move" + ) { self.commit_text_box_edit()?; } if let Some(r) = self.run_ui_command(id, ¶ms) { @@ -546,6 +567,14 @@ 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 + // 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) { + self.ui.ribbon_tab = "View".into(); + self.ui.ribbon_collapsed = false; + self.open_switch_windows = true; + return; + } if let Err(e) = self.run(id, params) { self.alert(e); } @@ -882,6 +911,17 @@ impl SheetApp { } } + /// The open workbooks in order, for the Switch Windows lists. + pub fn open_windows(&self) -> Vec { + let active = self.session.active_index(); + self.session + .documents() + .iter() + .enumerate() + .map(|(i, d)| OpenWindow { doc: d.uid, title: d.display_title(), dirty: d.is_dirty(), active: i == active }) + .collect() + } + pub fn view_key(&self) -> Option<(u64, usize)> { self.session.active().map(|d| (d.uid, d.wb.active_sheet)) } @@ -961,7 +1001,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.wb.active_sheet, at, text, replace, from_formula_bar); + let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); ed.locale = locale; if replace && self.ui.autocomplete && !ed.is_formula() { ed.completion = editor::column_completion(sh, at, &ed.text); @@ -971,7 +1011,45 @@ 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, and a draft its own workbook refuses (validation, a bad + /// formula) is dropped rather than left open over the wrong grid. 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) { + return self.commit_edit_here(dr, dc, array, fill_selection); + } + let Some(owner) = self.session.document_index(doc) else { + self.cancel_edit(); + return true; + }; + let back = self.session.active_index(); + self.session.set_active(owner); + if !self.commit_edit_here(dr, dc, array, fill_selection) { + self.cancel_edit(); + } + self.session.set_active(back); + true + } + + /// Settles the cell and text-box drafts before a command that can show another workbook or + /// write this one to a file: each is committed as Enter would, or dropped when it can't be + /// (the refusal is still reported), so a draft never lands in another workbook. + fn settle_drafts(&mut self) { + if !self.commit_edit(0, 0, false, false) { + self.cancel_edit(); + } + if let Err(e) = self.commit_text_box_edit() { + self.cancel_text_box_edit(); + if self.message.is_none() { + self.message = Some(("Text Box".into(), clean_error(&e))); + } + } + } + + fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { let Some(ed) = self.editor.take() else { return true }; self.session.mode = gridcraft_engine::Mode::Ready; let text = match &ed.completion { @@ -1276,6 +1354,11 @@ 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 self.editor.as_ref().is_some_and(|ed| self.session.active().map(|d| d.uid) != Some(ed.doc)) { + self.commit_edit(0, 0, false, false); + } // The language the widgets translate with this frame (see `i18n::current`). i18n::set_current(&ctx, self.ui.language); let t = theme::Tokens::get(&ctx); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 356381d66..d90b642ea 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2336,6 +2336,7 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { ], ); }); + switch_windows(app, ui); ui.vertical(|ui| { ui.label(lang.tr("Display theme")); let mode = app.ui.theme_mode(); @@ -2391,6 +2392,24 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { }); } +/// View › Switch Windows: the open workbooks, the active one checked and unsaved ones marked; +/// choosing one brings it forward. +fn switch_windows(app: &mut SheetApp, ui: &mut Ui) { + let anchor = big_button(ui, Icon::Book, "Switch\nWindows", "Switch Windows", true); + anchor.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), "Switch Windows")); + control_keytip(app, ui, &anchor, "view.switchWindows"); + let forced = std::mem::take(&mut app.open_switch_windows); + egui::Popup::menu(&anchor).open_memory(popup_command(forced, anchor.clicked())).show(|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})); + ui.close(); + } + } + }); +} + fn automate(app: &mut SheetApp, ui: &mut Ui) { if big_button(ui, Icon::Script, "Macros", "Macros and scripts in this workbook (Alt+F8)", false).clicked() { app.open_dialog("macros", json!({})); diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 047efe1b1..415e09902 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -289,6 +289,83 @@ fn closing_the_window_can_save_first_and_a_saved_workbook_closes_at_once() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn quitting_with_one_of_two_workbooks_unsaved_asks_once() { + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap(); + h.state_mut().run("file.new", serde_json::json!({})).unwrap(); + h.run_steps(2); + + let sent = request_window_close(&mut h); + assert!(sent.contains(&egui::ViewportCommand::CancelClose), "{sent:?}"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(h.state().session.active_index(), 0, "the unsaved workbook comes forward"); + let sent = click_commands(&mut h, "Cancel"); + assert!(!sent.contains(&egui::ViewportCommand::Close), "Cancel stops the quit"); + assert_eq!(h.state().session.documents().len(), 2, "Cancel keeps both workbooks"); + + request_window_close(&mut h); + let sent = click_commands(&mut h, "Don't Save"); + assert!(sent.contains(&egui::ViewportCommand::Close), "the saved workbook isn't asked about: {sent:?}"); + assert!(h.state().dialog.is_none()); +} + +/// Book1 has a cell being typed when File › New runs (the macOS menu runs the command directly). +fn typing_in_book1_then_new(h: &mut egui_kittest::Harness<'static, SheetApp>) -> u64 { + h.input_mut().events.push(egui::Event::Text("hello".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + let book1 = h.state().session.doc().unwrap().uid; + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(3); + book1 +} + +fn a1(h: &egui_kittest::Harness<'static, SheetApp>, doc: usize) -> Value { + h.state().session.documents()[doc].wb.active().unwrap().value(CellRef::new(0, 0)) +} + +#[test] +fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_back() { + let mut h = harness(Services::default()); + let book1 = typing_in_book1_then_new(&mut h); + assert!(h.state().editor.is_none(), "the draft doesn't follow into Book2"); + assert_eq!(h.state().session.documents().len(), 2); + assert_eq!(h.state().session.active_index(), 1); + assert_eq!(a1(&h, 0), Value::text("hello"), "the draft is committed into Book1, as Enter would"); + assert!(h.state().session.documents()[0].is_dirty(), "so quitting asks about Book1"); + assert_eq!(a1(&h, 1), Value::Empty); + h.key_press(egui::Key::Enter); + h.run_steps(2); + assert_eq!(a1(&h, 1), Value::Empty, "Enter in Book2 writes nothing left over"); + + // The key tip or command search opens the View tab's list; Book1 shows its unsaved bullet. + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({})); + h.run_steps(3); + assert_eq!(h.state().ui.ribbon_tab, "View"); + h.get_by_label_contains("Book1 •").click(); + h.run_steps(3); + assert_eq!(h.state().session.doc().unwrap().uid, book1); + assert_eq!(h.state().session.documents().len(), 2); + assert!(h.state().message.is_none()); +} + +#[test] +fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() { + let mut h = harness(Services::default()); + let book1 = typing_in_book1_then_new(&mut h); + h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap(); + h.input_mut().events.push(egui::Event::Text("Purple".into())); + h.run_steps(2); + assert!(h.state().editor.is_some()); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert!(h.state().editor.is_none()); + assert_eq!(h.state().session.doc().unwrap().uid, book1, "the switch still happens"); + assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged"); + assert!(h.state().message.is_some(), "the refusal is reported"); +} + fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { let asked = Arc::new(Mutex::new(Vec::::new())); let (capture, target) = (asked.clone(), dir.to_path_buf()); From a0d54b49d47c081b33dc18c44b65da74a3916413 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 04/54] A refused cell draft stops the command; an untouched F2 draft changes nothing - File and window commands stop when the workbook refuses the cell or text-box draft (validation Stop, bad formula, protected text box): the draft stays open in its cell with the reason shown, to fix or Esc. The per-frame backstop brings a refused draft's workbook back to the front. - An F2 draft committed with its text unchanged writes nothing: no undo step, and the workbook stays clean through Save, Print or New. - macOS Window menu: the active workbook's check is put back every poll, since choosing a check item flips it. --- apps/gridcraft/src/native_menu.rs | 39 ++++++++++++++++------------ crates/ui-egui/src/editor.rs | 5 ++++ crates/ui-egui/src/lib.rs | 43 ++++++++++++++++++------------- crates/ui-egui/tests/file_menu.rs | 38 ++++++++++++++++++++++++--- 4 files changed, 86 insertions(+), 39 deletions(-) diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 2c2866379..877c82fd8 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -283,27 +283,32 @@ impl NativeMenu { /// step with the session. fn sync_windows(&mut self, app: &SheetApp) { let windows = app.open_windows(); - if windows == self.windows { - return; + 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 { + for (item, w) in self.window_items.iter().zip(&windows) { + item.set_text(w.label()); + } + } else { + for item in self.window_items.drain(..) { + 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 _ = menu.append(&item); + self.window_items.push(item); + } + } + self.windows = windows; } - let Some(menu) = &self.window_menu else { return }; - let same_docs = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.doc == b.doc); - if same_docs { - for (item, w) in self.window_items.iter().zip(&windows) { - item.set_text(w.label()); + // AppKit flips a check item when it is chosen, so the active check is put back every poll. + for (item, w) in self.window_items.iter().zip(&self.windows) { + if item.is_checked() != w.active { item.set_checked(w.active); } - } else { - for item in self.window_items.drain(..) { - 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 _ = menu.append(&item); - self.window_items.push(item); - } } - self.windows = windows; } pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index eeeaf12da..3f23e8bed 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -45,11 +45,15 @@ pub struct EditState { pub completion: Option, /// Move the caret to the end on the next frame. pub caret_to_end: bool, + /// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone + /// (no undo step, the workbook stays clean). `None` for an edit started by typing. + pub original: Option, } impl EditState { pub fn new(doc: u64, sheet: usize, cell: CellRef, text: String, enter_mode: bool, from_formula_bar: bool) -> EditState { let caret = text.chars().count(); + let original = (!enter_mode).then(|| text.clone()); EditState { doc, sheet, @@ -68,6 +72,7 @@ impl EditState { sync_caret: true, caret_to_end: true, completion: None, + original, } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 001abaad5..cc478168e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -412,7 +412,7 @@ impl SheetApp { || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow") || (id == "view.switchWindows" && params.get("doc").is_some_and(|d| !d.is_null())); if id.starts_with("file.") || switches_window { - self.settle_drafts(); + self.settle_drafts()?; } if matches!(id, "file.save" | "file.saveAs" | "file.saveBytes") { self.store_view_state(); @@ -575,8 +575,11 @@ impl SheetApp { self.open_switch_windows = true; return; } - if let Err(e) = self.run(id, params) { - self.alert(e); + match self.run(id, params) { + // A refused cell draft already shows its own message (with the rule's title). + Err(e) if self.message.as_ref().is_some_and(|(_, m)| *m == e) => {} + Err(e) => self.alert(e), + Ok(_) => {} } } @@ -1013,8 +1016,8 @@ 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, and a draft its own workbook refuses (validation, a bad - /// formula) is dropped rather than left open over the wrong grid. + /// 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. 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); @@ -1028,25 +1031,21 @@ impl SheetApp { let back = self.session.active_index(); self.session.set_active(owner); if !self.commit_edit_here(dr, dc, array, fill_selection) { - self.cancel_edit(); + // Refused: its workbook stays in front with the draft open and the reason shown. + return false; } self.session.set_active(back); true } - /// Settles the cell and text-box drafts before a command that can show another workbook or - /// write this one to a file: each is committed as Enter would, or dropped when it can't be - /// (the refusal is still reported), so a draft never lands in another workbook. - fn settle_drafts(&mut self) { + /// 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. + fn settle_drafts(&mut self) -> Result<(), String> { if !self.commit_edit(0, 0, false, false) { - self.cancel_edit(); - } - if let Err(e) = self.commit_text_box_edit() { - self.cancel_text_box_edit(); - if self.message.is_none() { - self.message = Some(("Text Box".into(), clean_error(&e))); - } + return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); } + self.commit_text_box_edit() } fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool { @@ -1056,6 +1055,13 @@ impl SheetApp { Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(), _ => ed.text.clone(), }; + // F2 then Enter (or a file command) on an untouched draft: nothing to write. + if !fill_selection && !array && ed.original.as_ref() == Some(&text) { + if dr != 0 || dc != 0 { + self.move_after_enter(dr, dc); + } + return true; + } let text = match self.session.active().map(|d| formula_locale::canonical(&text, ed.locale, &d.wb, ed.sheet, ed.cell)) { Some(Ok(text)) => text, Some(Err(error)) => { @@ -1355,7 +1361,8 @@ 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. + // 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)) { self.commit_edit(0, 0, false, false); } diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 415e09902..5c447cb63 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -351,19 +351,49 @@ fn new_workbook_keeps_the_typed_cell_in_the_first_and_switch_windows_brings_it_b } #[test] -fn a_draft_its_workbook_refuses_is_dropped_when_switching_away() { +fn a_draft_its_workbook_refuses_stops_the_switch_and_stays_open() { let mut h = harness(Services::default()); let book1 = typing_in_book1_then_new(&mut h); + let book2 = h.state().session.doc().unwrap().uid; h.state_mut().session.execute("data.validation", serde_json::json!({"range": "A1", "type": "list", "formula1": "\"Red,Green\""})).unwrap(); h.input_mut().events.push(egui::Event::Text("Purple".into())); h.run_steps(2); - assert!(h.state().editor.is_some()); h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); h.run_steps(2); - assert!(h.state().editor.is_none()); - assert_eq!(h.state().session.doc().unwrap().uid, book1, "the switch still happens"); + assert_eq!(h.state().session.doc().unwrap().uid, book2, "the switch doesn't happen"); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("Purple"), "the draft stays open to fix"); assert_eq!(a1(&h, 1), Value::Empty, "Book2 is unchanged"); assert!(h.state().message.is_some(), "the refusal is reported"); + + // Esc drops the draft; then the switch goes through. + h.state_mut().message = None; + h.state_mut().cancel_edit(); + h.state_mut().run_or_alert("view.switchWindows", serde_json::json!({"doc": book1})); + h.run_steps(2); + assert_eq!(h.state().session.doc().unwrap().uid, book1); + assert!(h.state().message.is_none()); +} + +#[test] +fn an_untouched_f2_draft_leaves_the_workbook_clean_on_a_file_command() { + let dir = std::env::temp_dir().join(format!("gridcraft-f2-draft-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Book.xlsx").to_string_lossy().into_owned(); + let mut h = harness(Services::default()); + h.state_mut().run("cell.set", serde_json::json!({"cell": "A1", "input": "=1+2"})).unwrap(); + h.state_mut().run("file.save", serde_json::json!({"path": &path})).unwrap(); + h.run_steps(2); + let undo = h.state().session.doc().unwrap().undo.len(); + h.key_press(egui::Key::F2); + h.run_steps(2); + assert_eq!(h.state().editor.as_ref().map(|ed| ed.text.as_str()), Some("=1+2")); + h.state_mut().run_or_alert("file.new", serde_json::json!({})); + h.run_steps(2); + assert!(h.state().editor.is_none()); + let book1 = &h.state().session.documents()[0]; + assert!(!book1.is_dirty(), "nothing was typed, so nothing changed"); + assert_eq!(book1.undo.len(), undo, "and no undo step was added"); + let _ = std::fs::remove_dir_all(&dir); } fn pdf_picker_harness(dir: &std::path::Path) -> (Arc>>, egui_kittest::Harness<'static, SheetApp>) { From 4bafd4e8ee5f38b068720156b9057eed5ed82a80 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 05:12:23 +0000 Subject: [PATCH 05/54] Docs: Switch Windows and drafts kept in their workbook (UI parity, progress log) --- ROADMAP.md | 4 +++- docs/ui-parity.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..9617ef1e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-21 · **Change:** minor (Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **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) · **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 | 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 | | 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch | @@ -220,6 +221,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: 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 | | 2026-10-19 | minor | Progress log: long flat operator chains (#916) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..b70682ac3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (Esc and Enter close a data validation alert over the cell editor; Ctrl+Enter fill gives each cell the automatic number format typing gives it; keytip letters typed with Alt held, #1846; Number Stored as Text indicator and Convert to Number) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **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 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,6 +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) | ## Grid interaction @@ -176,6 +177,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-21 | minor | Form dialogs (conditional-format quick rules, New Name, Remove Duplicates, Text to Columns and every other `Dialog::form`) fit their content: OK and Cancel sit in a row under the last field instead of a right-to-left layout that took the screen's whole height and left the buttons floating halfway down | From 45d1d1acc0dc87d0104d0634c42deae009721775 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:50:53 +0000 Subject: [PATCH 06/54] 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. --- ROADMAP.md | 4 +- apps/gridcraft/src/native_menu.rs | 16 +- crates/engine/src/cmd/extra.rs | 72 ++-- crates/engine/src/cmd/file.rs | 50 ++- crates/engine/src/lib.rs | 39 ++- crates/engine/src/tests.rs | 114 ++++++- crates/engine/src/windows.rs | 342 +++++++++++++++++++ crates/engine/tests/command_sequence_fuzz.rs | 3 +- crates/mcp/src/headless.rs | 24 +- crates/mcp/src/tests.rs | 29 ++ crates/mcp/src/tools.rs | 64 +++- crates/ui-egui/src/control.rs | 44 ++- crates/ui-egui/src/dialogs.rs | 4 +- crates/ui-egui/src/editor.rs | 4 + crates/ui-egui/src/lib.rs | 84 +++-- crates/ui-egui/src/ribbon.rs | 2 +- crates/ui-egui/tests/file_menu.rs | 62 ++++ docs/control-protocol.md | 20 +- docs/mcp.md | 5 + docs/ui-parity.md | 5 +- 20 files changed, 872 insertions(+), 115 deletions(-) create mode 100644 crates/engine/src/windows.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9617ef1e6..bf25cc3a9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 877c82fd8..826a12b31 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -195,13 +195,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. @@ -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::().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 debb62eeb..395345543 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 ), @@ -790,54 +790,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())); - 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 { - 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 a61c6298f..1f50b908b 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())), ] } @@ -159,7 +161,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 { @@ -220,10 +231,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 aeeaa9bdb..74924d542 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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 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, @@ -304,8 +314,6 @@ pub struct Session { pub hide_ruler: bool, /// Journal position where action recording started. pub recording: Option, - /// 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, @@ -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; } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 07a32209f..6f7f7f4bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); +} 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/command_sequence_fuzz.rs b/crates/engine/tests/command_sequence_fuzz.rs index f8cb603cb..1724b9b9d 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/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 c699ccf69..5ceeea0ce 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}"); } @@ -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 = + 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 11d535ac4..bf1dedac1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -104,6 +104,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(); @@ -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::>(), "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 = 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::>())), - "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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 612ca93b2..be474d37f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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()); } diff --git a/crates/ui-egui/src/editor.rs b/crates/ui-egui/src/editor.rs index 3f23e8bed..95e74d959 100644 --- a/crates/ui-egui/src/editor.rs +++ b/crates/ui-egui/src/editor.rs @@ -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, 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, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cc478168e..d330d1fcf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, pub text_box_editor: Option, @@ -410,7 +414,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()?; } @@ -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 { - 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`). diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d90b642ea..6bd45e18f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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(); } } diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/file_menu.rs index 5c447cb63..fa9350f7a 100644 --- a/crates/ui-egui/tests/file_menu.rs +++ b/crates/ui-egui/tests/file_menu.rs @@ -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 = 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 0c24e9252..00bbc8fc5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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"`. diff --git a/docs/mcp.md b/docs/mcp.md index 1877cf228..8c45182c9 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 b70682ac3..839984959 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | From 5bd5d241a0825a4af8551cc28c6c97b9b31f2d66 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:21:59 +0000 Subject: [PATCH 07/54] G3: a native window per workbook window Each engine window is drawn in its own native window through craft-window's immediate-viewport driver (desktop only; the web app keeps one window). The root viewport shows one window and adopts another when its window closes. - WindowUi: the cell draft, text-box draft, dialog, Backstage, message, toast, notice, grid state, name box, key tips, selected chart, Tab run and edit mode are per window. SheetApp's fields hold the active window's; the others are parked and swapped in when a window is drawn or activated, and after any command that switched windows. - Session::show_window makes a window current for drawing without counting as an activation. - The ribbon in each window acts on it; menu bar commands and scripts go to the window focused last. Leaving a window enters its draft as Enter would. - A window's close button runs window.close: one of several windows on a workbook closes without asking, a workbook's last asks when unsaved. SheetApp::request_quit (macOS Quit item, Dock quit) asks about each unsaved workbook in its window; Cancel stops. Save Changes answers moved to SheetApp::answer_save_changes. - The last window closed quits on Windows and Linux; macOS hides the root and keeps running until New or Open. - Fonts are bound for every open workbook, since each may be showing. craft-window is pinned to the aiongg/craft-libs window-list branch until it merges into storytold/craft-libs. --- Cargo.lock | 10 + Cargo.toml | 4 + ROADMAP.md | 4 +- apps/gridcraft/src/apple_events.rs | 3 +- apps/gridcraft/src/main.rs | 20 +- apps/gridcraft/src/native_menu.rs | 14 +- crates/engine/src/windows.rs | 20 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/dialogs.rs | 25 +-- crates/ui-egui/src/doc_windows.rs | 227 ++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 148 +++++++++++++-- crates/ui-egui/src/window_ui.rs | 133 +++++++++++++ crates/ui-egui/tests/doc_windows.rs | 284 ++++++++++++++++++++++++++++ docs/hardware-parity.md | 5 +- docs/ui-parity.md | 7 +- 15 files changed, 840 insertions(+), 65 deletions(-) create mode 100644 crates/ui-egui/src/doc_windows.rs create mode 100644 crates/ui-egui/src/window_ui.rs create mode 100644 crates/ui-egui/tests/doc_windows.rs diff --git a/Cargo.lock b/Cargo.lock index e041aafbc..77b3b801a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,15 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2#4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -1636,6 +1645,7 @@ name = "gridcraft-ui-egui" version = "0.10.0" dependencies = [ "craft-ui", + "craft-window", "egui", "egui_kittest", "epaint_default_fonts", diff --git a/Cargo.toml b/Cargo.toml index e8a6dc67e..b28d7eb33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,10 @@ sys-locale = "0.3" skrifa = { version = "0.44", default-features = false, features = ["std"] } # Real font bytes for tests (Ubuntu Light, compiled into epaint). epaint_default_fonts = "0.36" +# One native window per workbook: the window list, Window menu titles and the immediate-viewport +# driver (craft-libs). Pinned to the aiongg fork's `window-list` branch; repins to +# storytold/craft-libs once that branch merges there. +craft-window = { version = "0.1.0", git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", default-features = false, features = ["egui"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/ROADMAP.md b/ROADMAP.md index bf25cc3a9..38c394fb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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; New Window shares the workbook) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window; 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 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) | | 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 | @@ -222,6 +223,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: a native window per workbook window | | 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 | diff --git a/apps/gridcraft/src/apple_events.rs b/apps/gridcraft/src/apple_events.rs index 405ee89a0..4d596f957 100644 --- a/apps/gridcraft/src/apple_events.rs +++ b/apps/gridcraft/src/apple_events.rs @@ -43,7 +43,8 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) { app.open_path(p); } } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + // Quit from the Dock or the system: each unsaved workbook is asked about in its window. + Event::Quit => app.request_quit(ctx), } } } diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 8aad9aeb8..c0b50038d 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -22,24 +22,27 @@ use std::cell::RefCell; use std::rc::Rc; use gridcraft_engine::Session; +use gridcraft_ui_egui::doc_windows::DocWindows; use gridcraft_ui_egui::{Services, SheetApp}; use serde_json::json; -struct App(SheetApp, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); +/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events. +struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option, #[cfg(target_os = "macos")] fmv_macos_events::Inbox); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { #[cfg(target_os = "macos")] { - if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { - self.1 = Some(native_menu::NativeMenu::install(ctx)); + if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() { + self.2 = Some(native_menu::NativeMenu::install(ctx)); } - if let Some(m) = &mut self.1 { - m.poll(&mut self.0, ctx); + if let Some(m) = &mut self.2 { + m.poll(&mut self.0, ctx, self.1.focused_viewport()); } - apple_events::poll(&self.2, &mut self.0, ctx); + apple_events::poll(&self.3, &mut self.0, ctx); } self.0.logic(ctx); + self.1.logic(&mut self.0, ctx); self.0.poll_printing(ctx); if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); @@ -55,7 +58,7 @@ impl eframe::App for App { } } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.1.show(&mut self.0, ui); } fn on_exit(&mut self) { print::finish_pending_jobs(); @@ -320,6 +323,7 @@ fn main() -> eframe::Result<()> { if let Some(i) = icon() { viewport = viewport.with_icon(i); } + let window_builder = viewport.clone(); let options = eframe::NativeOptions { viewport, ..Default::default() }; // Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when // DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs). @@ -357,8 +361,10 @@ fn main() -> eframe::Result<()> { Err(e) => log::error!("control channel on port {port}: {e}"), } } + let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window()); Ok(Box::new(App( app, + windows, #[cfg(target_os = "macos")] None, #[cfg(target_os = "macos")] diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 826a12b31..9559c47d7 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -203,8 +203,8 @@ pub struct NativeMenu { /// Menu id of the Window menu item that brings workbook `doc` forward. 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. +/// The Quit item: asks about each unsaved workbook in its window first +/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip. const QUIT: &str = "app.quit"; fn select_theme(app: &mut SheetApp, id: &str) -> bool { @@ -311,11 +311,13 @@ impl NativeMenu { } } - pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + /// Runs the chosen menu items. Commands go to the active window, the most recently focused; + /// `viewport` is its egui viewport, for the items that act on its input. + pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) { while let Ok(ev) = self.events.try_recv() { let id = ev.id.as_ref().to_string(); if id == QUIT { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + app.request_quit(ctx); continue; } if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::().ok()) { @@ -331,11 +333,11 @@ impl NativeMenu { _ => None, }; if let Some(command) = clipboard { - ctx.send_viewport_cmd(command); + ctx.send_viewport_cmd_to(viewport, command); continue; } if id == "edit.undo" || id == "edit.redo" { - ctx.input_mut(|i| { + ctx.input_mut_for(viewport, |i| { i.events.push(egui::Event::Key { key: egui::Key::Z, physical_key: None, diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs index 8dcb92011..4441166db 100644 --- a/crates/engine/src/windows.rs +++ b/crates/engine/src/windows.rs @@ -166,6 +166,16 @@ impl Session { /// Makes a window active, swapping its view into its workbook. Unknown ids are ignored. pub fn activate_window(&mut self, id: u64) { + self.enter_window(id, true); + } + + /// Makes a window the one commands act on while the interface draws it, without counting as + /// an activation: the most recently active window of each workbook stays the same. + pub fn show_window(&mut self, id: u64) { + self.enter_window(id, false); + } + + fn enter_window(&mut self, id: u64, recent: bool) { 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; @@ -185,10 +195,12 @@ impl Session { } } } - self.activation = self.activation.saturating_add(1); - let stamp = self.activation; - if let Some(w) = self.windows.get_mut(wi) { - w.last_active = stamp; + if recent { + 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) { diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 85a24dda9..626d5e7fa 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,7 @@ gridcraft-xlsx = { workspace = true, features = ["test-support"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # The desktop's preferred language for the first-run interface language. sys-locale = { workspace = true } +craft-window = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] # Read the browser's preferred language for the first-run web interface language. diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index be474d37f..55c0819df 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1323,29 +1323,14 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } "saveChanges" => { ui.label(format!("Do you want to save the changes you made to {}?", d.values.get("title").and_then(Json::as_str).unwrap_or("this workbook"))); - // Asked while quitting: once answered, the next unsaved workbook is asked about. 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, "document": true})); - if quitting { - app.continue_quit(ui.ctx()); + for (label, answer) in + [("Don't Save", crate::SaveAnswer::DontSave), ("Cancel", crate::SaveAnswer::Cancel), ("Save", crate::SaveAnswer::Save)] + { + if ui.button(label).clicked() && app.answer_save_changes(answer, quitting, ui.ctx()) { + open = false; } - open = false; - } - if ui.button("Cancel").clicked() { - open = false; - } - // File operations must include the cell the user is still editing (as the File > Save menu item already does). - 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, "document": true})); - if quitting { - app.continue_quit(ui.ctx()); - } - } - open = false; } }); } diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs new file mode 100644 index 000000000..88ba3967a --- /dev/null +++ b/crates/ui-egui/src/doc_windows.rs @@ -0,0 +1,227 @@ +//! Desktop: each workbook window is a native window of its own. +//! +//! The engine owns the windows ([`Session::windows`]); [`DocWindows`] mirrors the visible ones in +//! a [`WindowList`] and draws each through [`Viewports`]: the root viewport shows one window and +//! every other window is an immediate viewport. Before a window draws, it becomes the engine's +//! window and its interface state is swapped in ([`SheetApp::show_window`]); after the frame the +//! focused window is active again, so menu commands, scripts and shortcuts outside a window's own +//! input go to the window the user worked in last. +//! +//! Close and quit: +//! - A window's close button closes that window (`window.close`): one of several windows on a +//! workbook closes without asking; a workbook's last window asks Save / Don't Save / Cancel when +//! it has unsaved changes. +//! - Quit ([`SheetApp::request_quit`]) asks about each unsaved workbook in its window; Cancel stops. +//! - When the root's window closes while others are open, another window moves into the root. +//! - With no window left, the app quits, or (macOS) keeps running with its root hidden and the +//! menu bar showing until a workbook is created or opened. +//! +//! [`Session::windows`]: gridcraft_engine::Session::windows + +use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList}; +use serde_json::json; + +use crate::SheetApp; + +/// The native windows (see the module docs). +pub struct DocWindows { + /// Engine window id per native window. + list: WindowList, + viewports: Viewports, + /// The builder new windows start from: the root window's size, icon and frame settings. + base: egui::ViewportBuilder, + /// Quit when the last window closes (Windows, Linux); macOS keeps running without a window. + quit_with_last_window: bool, + /// The root viewport is hidden because no window is open. + root_hidden: bool, +} + +impl DocWindows { + /// Takes over `app`'s windows. `base` is the root window's builder, used for new windows. + pub fn new(app: &mut SheetApp, base: egui::ViewportBuilder, quit_with_last_window: bool) -> DocWindows { + app.native_windows = true; + // The root window keeps the desktop's frame, so every window does. + DocWindows { list: WindowList::new(), viewports: Viewports::new(Frame::Desktop), base, quit_with_last_window, root_hidden: false } + } + + /// The platform default for the last window closing: macOS keeps running, others quit. + pub fn platform_quits_with_last_window() -> bool { + !cfg!(target_os = "macos") + } + + /// The native window showing engine window `window`. + fn find(&self, window: u64) -> Option { + self.list.find(|w| *w == window) + } + + /// The engine window drawn in the root viewport. + pub fn root_window(&self) -> Option { + self.viewports.root().and_then(|w| self.list.get(w).copied()) + } + + /// The engine windows that have a native window, in opening order. + pub fn shown_windows(&self) -> Vec { + self.list.iter().map(|(_, w)| *w).collect() + } + + /// The egui viewport showing engine window `window`. + pub fn viewport_of(&self, window: u64) -> Option { + self.find(window).map(|id| self.viewports.viewport_id(id)) + } + + /// The egui viewport of the focused window, where menu commands that act on input (copy, + /// paste, undo in a text box) go. + pub fn focused_viewport(&self) -> egui::ViewportId { + self.list.active().map(|w| self.viewports.viewport_id(w)).unwrap_or(egui::ViewportId::ROOT) + } + + /// Call each frame after [`SheetApp::logic`], also while the root is hidden (eframe runs only + /// the logic then): shows windows created or opened by menu commands, files and scripts. + pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + self.sync(app, ctx); + } + + /// Mirrors the engine's visible windows: closed or hidden windows lose their native window + /// (the root adopting another), new ones get one, and a window a command activated comes to + /// the front. With no window left, quits or hides the root. + fn sync(&mut self, app: &mut SheetApp, ctx: &egui::Context) { + let visible = visible_windows(app); + let gone: Vec = self.list.iter().filter(|(_, w)| !visible.contains(w)).map(|(id, _)| id).collect(); + let mut root_closed = false; + for id in gone { + root_closed |= self.viewports.root() == Some(id); + self.list.close(id); + } + if root_closed { + // The window the engine brought forward, else the most recently focused. + let next = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active()); + match next { + Some(id) => self.viewports.adopt(ctx, &self.list, id), + None => self.viewports.set_root(None), + } + } + for w in visible { + if self.find(w).is_none() { + self.list.open(w); + } + } + if self.viewports.root().is_none() { + let first = app.session.active_window().and_then(|w| self.find(w)).or_else(|| self.list.active()); + self.viewports.set_root(first); + } + if self.list.is_empty() { + if self.quit_with_last_window { + if !app.allow_close { + app.allow_close = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); + } + } else if !self.root_hidden { + self.root_hidden = true; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(false)); + } + return; + } + if self.root_hidden { + self.root_hidden = false; + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Visible(true)); + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus); + } + // A command or script activated another window: bring its native window forward. + if let Some(id) = app.session.active_window().and_then(|w| self.find(w)) + && self.list.active() != Some(id) + { + self.list.focus(id); + self.viewports.focus(ctx, id); + } + } + + /// Draws every window. Call from the root viewport's UI each frame, in place of + /// [`SheetApp::ui`]. + pub fn show(&mut self, app: &mut SheetApp, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.sync(app, &ctx); + let focused = self.list.active().and_then(|id| self.list.get(id).copied()); + app.focus_window = focused; + let titles: std::collections::HashMap = self.list.iter().map(|(_, w)| (*w, window_title(app, *w))).collect(); + let base = &self.base; + // The window a command run in some window's UI activated (File › New, Switch Windows, a close). + let mut requested: Option = None; + let events = self.viewports.show( + ui, + &mut self.list, + |_, w| base.clone().with_title(titles.get(w).cloned().unwrap_or_default()), + |ui, _, w| { + app.show_window(*w); + // Files dropped on a window open as workbooks (the root's arrive through the app's + // raw input hook; a child window's input reaches only its own pass). + let dropped: Vec = ui + .ctx() + .input(|i| i.raw.dropped_files.iter().filter_map(|f| f.path().to_str().map(str::to_string)).filter(|p| !p.is_empty()).collect()); + for path in dropped { + app.open_path(&path); + } + app.ui(ui); + if app.session.active_window() != Some(*w) { + requested = app.session.active_window(); + } + }, + ); + for event in events { + match event { + WindowEvent::Focused(_) => {} + WindowEvent::CloseRequested(id) => { + if let Some(w) = self.close_requested(app, &ctx, id) { + requested = Some(w); + } + } + } + } + let target = requested.or_else(|| self.list.active().and_then(|id| self.list.get(id).copied())); + if let Some(target) = target.filter(|w| app.session.window(*w).is_some()) { + if requested.is_none() + && let Some(previous) = focused.filter(|p| *p != target && app.session.window(*p).is_some()) + { + // The user moved to another window: the previous window's draft is entered there, + // as Enter would. One its workbook refuses stays open in that window. + app.enter_window(previous); + let _ = app.settle_drafts(); + } + app.enter_window(target); + } + self.sync(app, &ctx); + app.focus_window = self.list.active().and_then(|id| self.list.get(id).copied()); + } + + /// A window's close button. Returns the window to bring forward when the close needs an + /// answer (or moved focus); `None` when nothing changed. + fn close_requested(&mut self, app: &mut SheetApp, ctx: &egui::Context, id: WindowId) -> Option { + let is_root = self.viewports.root() == Some(id); + if app.allow_close { + // Quitting: every workbook has been answered for; the root's close ends the app. + return None; + } + if is_root { + // eframe would end the app with the root window; closing it is the app's decision. + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::CancelClose); + } + let window = *self.list.get(id)?; + app.enter_window(window); + app.run_or_alert("window.close", json!({"window": window})); + app.session.active_window() + } +} + +/// The windows that get a native window: every window not hidden (View › Hide), or the active one +/// when all are hidden, so the app still has a window to unhide them from. +fn visible_windows(app: &SheetApp) -> Vec { + let shown: Vec = app.session.windows().iter().filter(|w| !w.hidden).map(|w| w.id).collect(); + if shown.is_empty() { app.session.active_window().into_iter().collect() } else { shown } +} + +/// A native window's title: the window's ("Book1", "Book1:2"), marked while its workbook has +/// unsaved changes. +fn window_title(app: &SheetApp, window: u64) -> String { + let title = app.session.window_title(window).unwrap_or_default(); + let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty()); + if dirty { format!("{title} — Edited") } else { title } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d330d1fcf..f405a3d18 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -13,6 +13,8 @@ pub mod chartview; pub mod control; pub mod credits; pub mod dialogs; +#[cfg(not(target_arch = "wasm32"))] +pub mod doc_windows; pub mod editor; mod floating_pictures; pub mod fonts; @@ -34,6 +36,9 @@ pub mod tabs; pub mod text_box; pub mod theme; pub mod widgets; +mod window_ui; + +pub use window_ui::WindowUi; use std::collections::HashMap; @@ -166,6 +171,14 @@ pub struct Notice { pub action: Option<(String, String)>, } +/// An answer to the Save Changes question. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SaveAnswer { + Save, + DontSave, + Cancel, +} + /// One open window, as View › Switch Windows and the macOS Window menu list it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenWindow { @@ -282,8 +295,8 @@ pub struct SheetApp { fonts_set: bool, /// Workbook fonts bound into the current font set; a change rebinds (installed fonts only). fonts_bound: Vec, - /// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only - /// when the workbook changes. + /// The open workbooks' (uid, revision), folded, that `fonts_bound` was computed for, so the + /// style tables are walked only when a workbook changes. fonts_seen: (u64, u64), /// Set while a font-set change is pending activation: skip this frame's paint, like the /// startup `fonts_ready` gate (egui applies new definitions at the next pass). @@ -320,6 +333,17 @@ pub struct SheetApp { /// View › Switch Windows opens its list on the next frame the View tab draws (asked for by a /// key tip, the command search or a menu, possibly while another tab was showing). pub(crate) open_switch_windows: bool, + /// The window whose interface state the per-window fields above hold (see [`WindowUi`]). + ui_window: Option, + /// The other windows' interface state, by window id. + parked: HashMap, + /// Each window is a native window of its own ([`doc_windows`] on the desktop): a window's + /// close button closes that window, and quitting is asked for separately. Without it (web, + /// tests, embedding hosts) one window shows the active workbook window, and its close button + /// quits. + pub native_windows: bool, + /// The native window that has focus, which menu commands and screenshots go to. + pub(crate) focus_window: Option, } impl SheetApp { @@ -367,6 +391,10 @@ impl SheetApp { started: now_ms(), allow_close: false, open_switch_windows: false, + ui_window: None, + parked: HashMap::new(), + native_windows: false, + focus_window: None, } } @@ -385,11 +413,12 @@ impl SheetApp { theme::apply(ctx, dark); } - /// The font names the active workbook uses: every style's font plus the theme's heading/body - /// fonts. These are the families [`fonts`] binds an installed file for. + /// The font names the open workbooks use: every style's font plus the theme's heading/body + /// fonts. These are the families [`fonts`] binds an installed file for. Every workbook's, as + /// each may be showing in a window of its own. pub fn used_fonts(&self) -> Vec { let mut names: Vec = Vec::new(); - if let Some(d) = self.session.active() { + for d in self.session.documents() { names.extend(d.wb.styles.iter().map(|(_, s)| s.font.name.clone())); names.push(d.wb.theme.major_font.clone()); names.push(d.wb.theme.minor_font.clone()); @@ -441,7 +470,14 @@ impl SheetApp { if let Some(r) = self.run_ui_command(id, ¶ms) { return r; } + let before = self.session.active_window(); let r = self.session.run(id, params); + if id.starts_with("file.") && self.session.active_window() != before { + // File › New or Open from the Backstage view: the window it was in goes back to its sheet. + self.backstage = None; + } + // Requests the command made (a dialog, a draft) belong to the window it left active. + self.follow_active_window(); self.after_engine(); // The new chart is selected, as after inserting any object, so Delete removes it, not its data. if id == "insert.chart" @@ -1065,7 +1101,7 @@ impl SheetApp { /// 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. - fn settle_drafts(&mut self) -> Result<(), String> { + pub(crate) fn settle_drafts(&mut self) -> Result<(), String> { if !self.commit_edit(0, 0, false, false) { return Err(self.message.as_ref().map(|(_, m)| m.clone()).unwrap_or_else(|| "The cell being edited can't be entered.".into())); } @@ -1216,7 +1252,7 @@ impl SheetApp { /// Holds back a window close (the close button, Alt+F4, the OS quitting the app) while a /// workbook has unsaved changes, and asks about them first. fn guard_window_close(&mut self, ctx: &egui::Context) { - if self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { + if self.native_windows || self.allow_close || !ctx.input(|i| i.viewport().close_requested()) { return; } // A cell still being edited is part of the workbook, as for File › Close. @@ -1237,21 +1273,75 @@ impl SheetApp { } } - /// Asks about the next unsaved workbook before quitting, or closes the window once none is left. + /// Asks about the next unsaved workbook before quitting, in its window, or closes the app + /// once none is left. pub(crate) fn continue_quit(&mut self, ctx: &egui::Context) { - match self.session.documents().iter().position(|d| d.is_dirty()) { - Some(i) => { - self.session.set_active(i); + let dirty = self.session.documents().iter().find(|d| d.is_dirty()).map(|d| d.uid); + match dirty.and_then(|uid| self.session.doc_window(uid)) { + Some(window) => { + // Its window comes forward (the native window too: see `doc_windows`). + self.enter_window(window); let title = self.session.active().map(|d| d.display_title()).unwrap_or_default(); self.open_dialog("saveChanges", json!({"title": title, "quit": true})); } None => { self.allow_close = true; - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close); } } } + /// Answers the Save Changes question about the active workbook (its buttons). While quitting, + /// the next unsaved workbook is asked about once this one is answered; Cancel stops the quit. + /// Returns whether the question is settled: Save keeps it open when the cell being edited + /// can't be entered. + pub fn answer_save_changes(&mut self, answer: SaveAnswer, quitting: bool, ctx: &egui::Context) -> bool { + match answer { + SaveAnswer::Cancel => {} + SaveAnswer::DontSave => { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + SaveAnswer::Save => { + // File operations include the cell still being edited, as File › Save does. + if !self.commit_edit(0, 0, false, false) { + return false; + } + self.run_or_alert("file.save", json!({})); + if self.session.active().is_some_and(|x| !x.is_dirty()) { + self.run_or_alert("file.close", json!({"force": true, "document": true})); + if quitting { + self.continue_quit(ctx); + } + } + } + } + true + } + + /// Quit (the macOS app menu, ⌘Q): commits every window's cell draft as Enter would, then asks + /// about each unsaved workbook in its window; Cancel on any of them stops the quit. A draft its + /// workbook refuses stops the quit too, with its window in front and the reason shown. + pub fn request_quit(&mut self, ctx: &egui::Context) { + while let Some(window) = self.parked_window_with_draft() { + self.enter_window(window); + if self.settle_drafts().is_err() { + return; + } + } + if self.settle_drafts().is_err() { + return; + } + if let Some(d) = self.dialog.as_mut().filter(|d| d.name() == "saveChanges") { + // Already asking about a workbook (File › Close): answering it goes on to quit. + d.values.insert("quit".into(), json!(true)); + return; + } + self.continue_quit(ctx); + } + /// Per-frame logic (control channel, screenshots). Call before `ui`. pub fn logic(&mut self, ctx: &egui::Context) { if self.session.text_width.is_none() { @@ -1288,7 +1378,11 @@ impl SheetApp { // Bind the workbook's installed fonts once the background scan has finished. Adding // fonts re-applies the font set, which egui activates at the next pass, so skip this // frame's paint (same one-frame cost as startup). Only when the workbook changed. - let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0)); + let seen = self + .session + .documents() + .iter() + .fold((0u64, 0u64), |(uid, rev), d| (uid.wrapping_mul(31).wrapping_add(d.uid), rev.wrapping_mul(31).wrapping_add(d.revision))); if seen != self.fonts_seen { let used = self.used_fonts(); self.fonts_seen = seen; @@ -1322,9 +1416,10 @@ impl SheetApp { ctx.options_mut(|o| o.zoom_with_keyboard = false); } // egui closes the window on Ctrl+Q (and with Shift or Alt held too) without asking to save - // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis); macOS keeps Cmd+Q, - // the app's Quit there. - if ctx.os() != egui::os::OperatingSystem::Mac && ctx.options(|o| !o.quit_shortcuts.is_empty()) { + // (#1443). Excel for Windows has no such key (Ctrl+Q is Quick Analysis). With a native + // window per workbook egui's Cmd+Q would close only the window it was typed in: the menu + // bar's Quit item has the key instead (it asks about unsaved workbooks first). + if (ctx.os() != egui::os::OperatingSystem::Mac || self.native_windows) && ctx.options(|o| !o.quit_shortcuts.is_empty()) { ctx.options_mut(|o| o.quit_shortcuts.clear()); } let zoom = self.ui.zoom_factor(); @@ -1357,9 +1452,12 @@ impl SheetApp { } self.after_engine(); } - if self.session.active().is_none() { + // One window always shows a workbook, unless each workbook has a native window of its own + // (`doc_windows` decides what having none left means). + if self.session.active().is_none() && !self.native_windows { self.session.new_workbook(); } + self.follow_active_window(); } pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { @@ -1383,6 +1481,10 @@ impl SheetApp { return; } let t0 = now_ms(); + // A script may have switched windows since the last frame. + self.follow_active_window(); + // Native windows: menu commands and screenshots go to the focused one. + let focused = !self.native_windows || self.focus_window == self.ui_window; text_box::before_ui(self, &ctx); // 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 @@ -1428,8 +1530,10 @@ impl SheetApp { dialogs::show(self, &ctx); widgets::message_box(self, &ctx); widgets::toast(self, &ctx); - control::issue_screenshots(self, &ctx); - control::collect_screenshots(self, &ctx); + if focused { + control::issue_screenshots(self, &ctx); + control::collect_screenshots(self, &ctx); + } if !ctx.input(|i| i.focused) { // Nothing else to do. } @@ -1453,8 +1557,10 @@ impl SheetApp { self.store_view_state(); let _ = self.session.run("file.save", json!({})); } - // Window title. - if let Some(d) = self.session.active() { + // Window title (native windows get theirs from `doc_windows`). + if !self.native_windows + && let Some(d) = self.session.active() + { let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" }); if self.grid.last_title.as_deref() != Some(title.as_str()) { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 000000000..96aa83c79 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,133 @@ +//! The interface state each window keeps for itself. +//! +//! [`SheetApp`]'s per-window fields (the cell draft, dialog, Backstage view, grid gestures…) hold +//! the state of one window: the engine's active window. Every other window's state is parked in a +//! [`WindowUi`] and swapped in when that window is activated or drawn, the way the engine swaps a +//! window's selection and sheet into its workbook. So a draft, a dialog or a message stays in the +//! window it belongs to. + +use gridcraft_engine::core::CellRef; + +use crate::{Notice, SheetApp, backstage, dialogs, editor, grid, keytips, text_box}; + +/// One window's parked interface state (see the module docs). +#[derive(Default)] +pub struct WindowUi { + editor: Option, + text_box_editor: Option, + dialog: Option, + backstage: Option, + message: Option<(String, String)>, + toast: Option<(String, f64)>, + notice: Option, + grid: grid::GridState, + name_box: Option, + keytips: keytips::KeyTips, + selected_chart: Option, + tab_run: Option<(u64, usize, u32, CellRef)>, + open_switch_windows: bool, + /// The status bar's Ready/Enter/Edit, which follows the window's cell draft. + mode: gridcraft_engine::Mode, +} + +impl WindowUi { + /// A new window's state. The last border, fill and font colour picked carry over, as they are + /// the user's recent choices rather than the window's. + fn fresh(like: &grid::GridState) -> WindowUi { + WindowUi { + grid: grid::GridState { + last_border: like.last_border.clone(), + last_fill: like.last_fill.clone(), + last_font_color: like.last_font_color.clone(), + ..Default::default() + }, + ..Default::default() + } + } +} + +impl SheetApp { + /// Exchanges the per-window fields with `w`. Destructured in full, so a field added to + /// [`WindowUi`] can't be left out of the swap. + fn swap_window_ui(&mut self, w: &mut WindowUi) { + let WindowUi { + editor, + text_box_editor, + dialog, + backstage, + message, + toast, + notice, + grid, + name_box, + keytips, + selected_chart, + tab_run, + open_switch_windows, + mode, + } = w; + std::mem::swap(&mut self.editor, editor); + std::mem::swap(&mut self.text_box_editor, text_box_editor); + std::mem::swap(&mut self.dialog, dialog); + std::mem::swap(&mut self.backstage, backstage); + std::mem::swap(&mut self.message, message); + std::mem::swap(&mut self.toast, toast); + std::mem::swap(&mut self.notice, notice); + std::mem::swap(&mut self.grid, grid); + std::mem::swap(&mut self.name_box, name_box); + std::mem::swap(&mut self.keytips, keytips); + std::mem::swap(&mut self.selected_chart, selected_chart); + std::mem::swap(&mut self.tab_run, tab_run); + std::mem::swap(&mut self.open_switch_windows, open_switch_windows); + std::mem::swap(&mut self.session.mode, mode); + } + + /// The window whose interface state is in the per-window fields. + pub fn ui_window(&self) -> Option { + self.ui_window + } + + /// Brings the per-window fields in line with the engine's active window, after a command (or + /// a script) switched windows: the previous window's state is parked, the active one's swapped + /// in (a window never seen before starts fresh). A closed window's state is dropped. + pub fn follow_active_window(&mut self) { + let active = self.session.active_window(); + if active == self.ui_window { + return; + } + let Some(previous) = self.ui_window else { + // The first window: the fields are already its state. + self.ui_window = active; + return; + }; + let mut outgoing = WindowUi::fresh(&self.grid); + self.swap_window_ui(&mut outgoing); + if self.session.window(previous).is_some() { + self.parked.insert(previous, outgoing); + } + if let Some(mut incoming) = active.and_then(|w| self.parked.remove(&w)) { + self.swap_window_ui(&mut incoming); + } + self.ui_window = active; + let session = &self.session; + self.parked.retain(|w, _| session.window(*w).is_some()); + } + + /// Activates window `id` (it becomes the most recently active) with its interface state. + pub fn enter_window(&mut self, id: u64) { + self.session.activate_window(id); + self.follow_active_window(); + } + + /// Makes window `id` and its interface state current while it is drawn, without changing + /// which window counts as most recently active. + pub fn show_window(&mut self, id: u64) { + self.session.show_window(id); + self.follow_active_window(); + } + + /// Whether a window other than the current one has a cell or text-box draft open. + pub fn parked_window_with_draft(&self) -> Option { + self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() + } +} diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs new file mode 100644 index 000000000..ff8e2d631 --- /dev/null +++ b/crates/ui-egui/tests/doc_windows.rs @@ -0,0 +1,284 @@ +//! One native window per workbook window: each window keeps its own interface state, and closing +//! or quitting asks about unsaved workbooks only when a workbook would close. +//! +//! The windows are drawn through a test backend that, like eframe, runs each immediate viewport +//! as a nested pass with its own input, so a close button can be pressed on one window. +#![cfg(not(target_arch = "wasm32"))] + +use std::cell::RefCell; + +use egui::{Context, RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportInfo}; +use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::doc_windows::DocWindows; +use gridcraft_ui_egui::{SaveAnswer, SheetApp}; +use serde_json::json; + +thread_local! { + /// Each viewport's input for the next frame (focus, close button). + static INFOS: RefCell> = RefCell::new(egui::ViewportIdMap::default()); + /// The viewport commands the app sent this frame. + static SENT: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn record(mut out: egui::FullOutput) { + out.textures_delta.clear(); // not painted + SENT.with(|s| { + let mut s = s.borrow_mut(); + for (id, v) in &out.viewport_output { + s.extend(v.commands.iter().map(|c| (*id, c.clone()))); + } + }); +} + +struct Desk { + ctx: Context, + app: SheetApp, + windows: DocWindows, +} + +impl Desk { + fn new(quit_with_last_window: bool) -> Desk { + INFOS.with(|i| i.borrow_mut().clear()); + let ctx = Context::default(); + ctx.set_embed_viewports(false); + Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut input = RawInput { viewport_id: id, ..RawInput::default() }; + input.viewports = INFOS.with(|i| i.borrow().clone()); + input.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + record(ctx.run_ui(input, |ui| cb(ui))); + }); + let mut session = Session::new(); + session.new_workbook(); + let mut app = SheetApp::new(session, Default::default()); + let windows = DocWindows::new(&mut app, egui::ViewportBuilder::default(), quit_with_last_window); + let mut desk = Desk { ctx, app, windows }; + desk.frames(3); + desk + } + + /// One frame as eframe runs it; `act` runs first, inside the frame. Returns the viewport + /// commands sent. + fn frame_with(&mut self, act: impl FnOnce(&mut SheetApp, &Context)) -> Vec<(ViewportId, ViewportCommand)> { + SENT.with(|s| s.borrow_mut().clear()); + let mut input = RawInput { viewport_id: ViewportId::ROOT, ..RawInput::default() }; + input.viewports = INFOS.with(|i| i.borrow().clone()); + input.viewports.entry(ViewportId::ROOT).or_default(); + let Desk { ctx, app, windows } = self; + let mut act = Some(act); + let out = ctx.clone().run_ui(input, |ui| { + let ctx = ui.ctx().clone(); + if let Some(act) = act.take() { + act(app, &ctx); + } + app.logic(&ctx); + windows.logic(app, &ctx); + windows.show(app, ui); + }); + record(out); + // Close buttons are one-shot events. + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + SENT.with(|s| s.borrow().clone()) + } + + fn frames(&mut self, n: usize) -> Vec<(ViewportId, ViewportCommand)> { + (0..n).flat_map(|_| self.frame_with(|_, _| {})).collect() + } + + fn viewport(&self, window: u64) -> ViewportId { + self.windows.viewport_of(window).unwrap_or_else(|| panic!("window {window} has no native window")) + } + + /// The user presses window `window`'s close button. + fn close(&mut self, window: u64) -> Vec<(ViewportId, ViewportCommand)> { + let vp = self.viewport(window); + INFOS.with(|i| i.borrow_mut().entry(vp).or_default().events.push(ViewportEvent::Close)); + let mut sent = self.frame_with(|_, _| {}); + sent.extend(self.frames(2)); + sent + } + + /// Answers the Save Changes question showing in the active window, as its button does. + fn answer(&mut self, answer: SaveAnswer) -> Vec<(ViewportId, ViewportCommand)> { + let mut sent = self.frame_with(|app, ctx| { + let d = app.dialog.take().unwrap_or_else(|| panic!("no question is showing")); + assert_eq!(d.name(), "saveChanges"); + let quitting = d.values.get("quit").and_then(serde_json::Value::as_bool) == Some(true); + app.answer_save_changes(answer, quitting, ctx); + }); + sent.extend(self.frames(2)); + sent + } + + fn window_of(&self, doc: usize) -> u64 { + let uid = self.app.session.documents()[doc].uid; + self.app.session.doc_window(uid).unwrap_or_default() + } +} + +fn sent_to(sent: &[(ViewportId, ViewportCommand)], vp: ViewportId, cmd: &ViewportCommand) -> bool { + sent.iter().any(|(id, c)| *id == vp && c == cmd) +} + +#[test] +fn each_window_keeps_its_own_selection_and_draft() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.windows.shown_windows(), [w1, w2], "a native window per workbook"); + assert_ne!(desk.viewport(w1), desk.viewport(w2)); + + desk.app.enter_window(w1); + desk.app.run("selection.set", json!({"range": "B2"})).unwrap(); + desk.app.begin_edit(Some("one".into()), false); + desk.frames(2); + desk.app.enter_window(w2); + desk.app.run("selection.set", json!({"range": "D4"})).unwrap(); + desk.app.begin_edit(Some("two".into()), false); + desk.frames(3); + + for (w, cell, text) in [(w1, "B2", "one"), (w2, "D4", "two")] { + desk.app.enter_window(w); + assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some(text), "window {w} keeps its draft"); + assert!(desk.app.grid.rect.is_some(), "window {w} drew its own grid"); + assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse(cell).unwrap(), "window {w} keeps its selection"); + } + assert!(desk.app.session.documents().iter().all(|d| !d.is_dirty()), "drawing the other window commits no draft"); +} + +#[test] +fn closing_one_of_two_windows_on_a_workbook_does_not_ask() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + desk.app.run("view.newWindow", json!({})).unwrap(); + desk.frames(3); + let windows = desk.windows.shown_windows(); + assert_eq!(windows.len(), 2, "New Window opens a native window"); + let child = windows.iter().copied().find(|w| Some(*w) != desk.windows.root_window()).unwrap(); + + let sent = desk.close(child); + assert!(desk.app.dialog.is_none(), "the workbook stays open in its other window: nothing to ask"); + assert_eq!(desk.app.session.documents().len(), 1); + assert_eq!(desk.app.session.windows().len(), 1); + assert!(desk.app.session.documents()[0].is_dirty(), "its changes are kept"); + assert_eq!(desk.windows.shown_windows().len(), 1); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); +} + +#[test] +fn closing_the_last_window_of_an_unsaved_workbook_asks_and_the_app_quits_after() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "42"})).unwrap(); + desk.frames(2); + let w = desk.window_of(0); + let sent = desk.close(w); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "the window stays open to ask: {sent:?}"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + assert_eq!(desk.app.session.documents().len(), 1); + + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel keeps it open"); + assert_eq!(desk.app.session.documents().len(), 1); + + desk.close(w); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(desk.app.session.documents().is_empty()); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the last window closed: the app quits: {sent:?}"); + assert!(desk.app.allow_close); +} + +#[test] +fn without_quitting_on_the_last_window_the_app_hides_and_new_shows_it_again() { + let mut desk = Desk::new(false); + let w = desk.window_of(0); + let sent = desk.close(w); + assert!(desk.app.session.documents().is_empty(), "a clean workbook closes without asking"); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(false)), "{sent:?}"); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "the app keeps running: {sent:?}"); + assert!(desk.app.session.documents().is_empty(), "and doesn't make a workbook of its own"); + + let sent = desk.frame_with(|app, _| app.run_or_alert("file.new", json!({}))); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Visible(true)), "{sent:?}"); + assert_eq!(desk.windows.root_window(), Some(desk.window_of(0))); +} + +#[test] +fn quit_asks_about_each_unsaved_workbook_in_its_window_and_cancel_stops() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "1"})).unwrap(); + desk.app.run("file.new", json!({})).unwrap(); + desk.app.run("cell.set", json!({"cell": "A1", "input": "2"})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.app.session.active_window(), Some(w2)); + + let sent = desk.frame_with(|app, ctx| app.request_quit(ctx)); + assert_eq!(desk.app.session.active_window(), Some(w1), "Book1's window comes forward to ask"); + assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::Focus), "{sent:?}"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = desk.answer(SaveAnswer::Cancel); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Cancel stops the quit: {sent:?}"); + assert_eq!(desk.app.session.documents().len(), 2); + assert!(desk.app.dialog.is_none()); + + desk.frame_with(|app, ctx| app.request_quit(ctx)); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "Book2 is still unsaved: {sent:?}"); + assert_eq!(desk.app.session.active_window(), Some(w2), "then Book2's window asks"); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("saveChanges")); + let sent = desk.answer(SaveAnswer::DontSave); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "both answered: the app quits: {sent:?}"); +} + +#[test] +fn the_root_adopts_the_remaining_window_with_its_workbook_and_state() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + assert_eq!(desk.windows.root_window(), Some(w1)); + let book2 = desk.app.session.documents()[1].uid; + desk.app.enter_window(w2); + desk.app.run("selection.set", json!({"range": "C3"})).unwrap(); + desk.app.begin_edit(Some("kept".into()), false); + desk.frames(2); + + let sent = desk.close(w1); + assert!(sent_to(&sent, ViewportId::ROOT, &ViewportCommand::CancelClose), "closing the root's window doesn't end the app: {sent:?}"); + assert!(!sent_to(&sent, ViewportId::ROOT, &ViewportCommand::Close), "{sent:?}"); + assert_eq!(desk.windows.root_window(), Some(w2), "Book2's window moves into the root"); + assert_eq!(desk.windows.shown_windows(), [w2]); + assert_eq!(desk.app.session.documents().len(), 1); + assert_eq!(desk.app.session.doc().unwrap().uid, book2); + assert_eq!(desk.app.editor.as_ref().map(|ed| ed.text.as_str()), Some("kept"), "its draft is still open"); + assert_eq!(desk.app.session.doc().unwrap().selection.active, CellRef::parse("C3").unwrap()); + assert_eq!(desk.app.session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("C3").unwrap()), Value::Empty); +} + +#[test] +fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft() { + let mut desk = Desk::new(true); + desk.app.run("file.new", json!({})).unwrap(); + desk.frames(3); + let (w1, w2) = (desk.window_of(0), desk.window_of(1)); + desk.app.begin_edit(Some("typed".into()), false); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w2)); + + // The user clicks into Book1's window. + let (v1, v2) = (desk.viewport(w1), desk.viewport(w2)); + INFOS.with(|i| { + let mut i = i.borrow_mut(); + i.entry(v1).or_default().focused = Some(true); + i.entry(v2).or_default().focused = Some(false); + }); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w1), "commands from the menu bar go to the window focused last"); + assert!(desk.app.editor.is_none(), "Book1's window has no draft"); + let book2 = &desk.app.session.documents()[1]; + assert_eq!(book2.wb.active().unwrap().value(CellRef::new(0, 0)), Value::text("typed"), "the draft was entered in its own window"); + desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap(); + assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu")); +} diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f9135c49..f19b4a49d 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | -| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | One window; multiple workbooks switch inside it (#162; PR #171) | Native window per workbook, New Window, Arrange All across monitors: 6–8 h | +| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | | 2026-10-11 | trivial | Windows: OpenGL fallback when DirectX 12 has no GPU or fails to start; Vulkan never by default (#120) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 839984959..817b5b915 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (every workbook window is a native window of its own on the desktop, with its own draft, dialog and ribbon; closing one of several windows on a workbook doesn't ask; Quit asks about each unsaved workbook in its window; the last window closed quits on Windows and Linux, macOS keeps running) · **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 @@ -33,8 +33,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | 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, 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) | +| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | +| 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 (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | ## 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 | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running | | 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 | From d4db58cde467f7a412293e55861d390a7f77b4ad Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:29:24 +0000 Subject: [PATCH 08/54] G3: feedback from a command that switched windows shows in the window it left active after_engine, show_open_warnings and alert bring the per-window state in line with the engine's active window first, so a notice, message or toast from opening a workbook (also by a script on the session) lands in the opened workbook's window instead of being parked with the previous one. --- crates/ui-egui/src/lib.rs | 7 +++++++ crates/ui-egui/tests/doc_windows.rs | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f405a3d18..19f69a90f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -648,6 +648,8 @@ impl SheetApp { /// Shows a command error: a toast for an unavailable command, else a message box. fn alert(&mut self, e: String) { + // Shown in the window the user ends up in, also after a command that switched windows. + self.follow_active_window(); if e.contains("is not available right now") { self.toast = Some((e, now_ms())); } else { @@ -799,6 +801,8 @@ impl SheetApp { /// Picks up requests commands made (dialogs, edit mode…). pub fn after_engine(&mut self) { + // The requests belong to the window the command left active. + self.follow_active_window(); for req in self.session.take_ui_requests() { match req { UiRequest::Dialog(name, params) => self.open_dialog(&name, params), @@ -924,6 +928,9 @@ impl SheetApp { /// most important one (a preserved VBA project outranks, say, a skipped optional part). /// Kept deliberately generic: any open-time warning can reach the user this way. pub fn show_open_warnings(&mut self, r: &Json) { + // The opened workbook's window shows them, however it was opened (a script may have run + // `file.open` on the session directly). + self.follow_active_window(); let Some(list) = r.get("warnings").and_then(Json::as_array) else { self.notice = None; return; diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index ff8e2d631..b6d518ce2 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -282,3 +282,28 @@ fn menu_commands_follow_the_focused_window_and_leaving_a_window_enters_its_draft desk.app.run("cell.set", json!({"cell": "B1", "input": "menu"})).unwrap(); assert_eq!(desk.app.session.documents()[0].wb.active().unwrap().value(CellRef::new(0, 1)), Value::text("menu")); } + +#[test] +fn feedback_from_opening_a_workbook_shows_in_its_new_window() { + let mut desk = Desk::new(true); + desk.app.run("cell.set", json!({"cell": "A1", "input": "kept"})).unwrap(); + desk.frames(2); + let book1 = desk.window_of(0); + let mut wb = gridcraft_engine::model::Workbook::new(); + wb.vba_project = Some(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1 fake OLE2 vbaProject".to_vec().into()); + let b64 = gridcraft_engine::io::base64_encode(&gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()); + desk.frame_with(|app, _| { + // A script opening the file on the session, then the interface reporting on it. + let r = app.session.run("file.open", json!({"name": "macros.xlsm", "base64": b64})).unwrap(); + app.show_open_warnings(&r); + app.run_or_alert("no.such.command", json!({})); + }); + desk.frames(2); + let opened = desk.window_of(1); + assert_eq!(desk.windows.shown_windows(), [book1, opened]); + assert_eq!(desk.app.session.active_window(), Some(opened)); + assert_eq!(desk.app.notice.as_ref().map(|n| n.kind), Some(gridcraft_ui_egui::NoticeKind::Warning), "the macro notice shows in the opened window"); + assert!(desk.app.message.is_some(), "and so does the alert"); + desk.app.enter_window(book1); + assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither"); +} From 2ed3df2bd64d2022d28ce588e6275babd6d654ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:50:10 +0000 Subject: [PATCH 09/54] G4: two windows on one workbook without copying it; Arrange All - The workbook's active sheet is an atomic set in place (Workbook::active_sheet / set_active_sheet), so a window swap or sheet switch never clones the Arc or marks it dirty. Selections already live in DocState and the window views. Save records the sheet of the most recently active window. - A dialog taken out of its window while it runs goes back to that window when a command run from it switched windows (dialogs::show, ui.dialog.confirm). - view.arrangeAll {arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?} returns the visible windows, active first; the desktop moves and sizes the native windows over the main window's monitor. New Window and Arrange All buttons on the View ribbon; Arrange Windows dialog. - Docs: ui-parity, hardware-parity, gaps #20, target-app-parity, ROADMAP. --- ROADMAP.md | 4 +- crates/engine/src/cmd/analysis.rs | 2 +- crates/engine/src/cmd/data.rs | 18 +-- crates/engine/src/cmd/draw.rs | 4 +- crates/engine/src/cmd/edit.rs | 27 ++--- crates/engine/src/cmd/extra.rs | 51 ++++++-- crates/engine/src/cmd/file.rs | 4 +- crates/engine/src/cmd/forecast.rs | 10 +- crates/engine/src/cmd/format.rs | 2 +- crates/engine/src/cmd/formulas.rs | 16 +-- crates/engine/src/cmd/insert.rs | 22 ++-- crates/engine/src/cmd/inspect.rs | 2 +- crates/engine/src/cmd/mod.rs | 6 +- crates/engine/src/cmd/pivot.rs | 16 +-- crates/engine/src/cmd/print.rs | 6 +- crates/engine/src/cmd/review.rs | 6 +- crates/engine/src/cmd/sheet.rs | 20 ++-- crates/engine/src/cmd/slicer.rs | 6 +- crates/engine/src/cmd/solver.rs | 12 +- crates/engine/src/cmd/view.rs | 4 +- crates/engine/src/cmd/whatif.rs | 14 +-- crates/engine/src/io.rs | 2 +- crates/engine/src/lib.rs | 4 +- crates/engine/src/sample.rs | 2 +- crates/engine/src/tests.rs | 75 +++++++++++- crates/engine/src/windows.rs | 30 ++--- crates/engine/tests/command_sequence_fuzz.rs | 6 +- .../engine/tests/hidden_sheet_navigation.rs | 12 +- crates/model/src/workbook.rs | 68 ++++++++++- crates/ui-egui/examples/snapshot.rs | 2 +- crates/ui-egui/src/control.rs | 3 +- crates/ui-egui/src/dialogs.rs | 30 ++++- crates/ui-egui/src/doc_windows.rs | 74 +++++++++++- crates/ui-egui/src/formula_bar.rs | 6 +- crates/ui-egui/src/grid.rs | 33 ++--- crates/ui-egui/src/lib.rs | 27 +++-- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/paper_view.rs | 8 +- crates/ui-egui/src/ribbon.rs | 12 +- crates/ui-egui/src/tabs.rs | 10 +- crates/ui-egui/src/text_box.rs | 2 +- crates/ui-egui/src/window_ui.rs | 13 ++ crates/ui-egui/tests/commit_after_pointing.rs | 4 +- crates/ui-egui/tests/doc_windows.rs | 113 ++++++++++++++++++ crates/ui-egui/tests/popup_dismissal.rs | 2 +- crates/ui-egui/tests/sheet_navigator.rs | 4 +- crates/ui-egui/tests/sheet_tabs.rs | 6 +- crates/ui-egui/tests/trace_navigation.rs | 4 +- crates/xlsx/src/read.rs | 2 +- crates/xlsx/src/write.rs | 4 +- crates/xlsx/src/xls/mod.rs | 2 +- crates/xlsx/src/xlsb.rs | 2 +- docs/gaps.md | 7 +- docs/hardware-parity.md | 5 +- docs/target-app-parity.md | 9 +- docs/ui-parity.md | 7 +- 56 files changed, 620 insertions(+), 224 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 38c394fb0..782c00e3a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 (a native window per workbook window; 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) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All; a native window per workbook window; 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 and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in | | 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) | | 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 | @@ -223,6 +224,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: Arrange All; two windows on one workbook without copying it | | 2026-10-22 | minor | Progress log: a native window per workbook window | | 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 | diff --git a/crates/engine/src/cmd/analysis.rs b/crates/engine/src/cmd/analysis.rs index 8ff7e3582..6225b4934 100644 --- a/crates/engine/src/cmd/analysis.rs +++ b/crates/engine/src/cmd/analysis.rs @@ -1182,7 +1182,7 @@ mod tests { fn v(s: &Session, a: &str) -> Value { let d = s.doc().unwrap(); let (sh, body) = crate::cmd::split_sheet(a); - let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet); + let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) } fn n(s: &Session, a: &str) -> f64 { diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 52a716cff..8ffab2b51 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -158,7 +158,7 @@ fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> { { return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`"))); } - if sheet != d.wb.active_sheet && p.get("range").is_none() { + if sheet != d.wb.active_sheet() && p.get("range").is_none() { return Err(bad(cmd, "missing `range`: the selection is on the active sheet")); } Ok((sheet, data_range(s, p)?)) @@ -210,7 +210,7 @@ fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> { { return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`"))); } - if sheet != d.wb.active_sheet && p.get("range").is_none() { + if sheet != d.wb.active_sheet() && p.get("range").is_none() { return Err(bad("data.sort", "missing `range`: the selection is on the active sheet")); } Ok((sheet, data_range(s, p)?)) @@ -502,7 +502,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result { let (sheet, r) = data_target(s, p, "data.filter")?; super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; // The active cell belongs to the active sheet: on another sheet the table is the one at `range`. - let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start }; + let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start }; let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some()); let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone())); if let Some(name) = in_table { @@ -554,7 +554,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result { } fn filter_by(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?; let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let af = match &sh.autofilter { @@ -741,7 +741,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { } fn clear_filter(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; if let Some(a) = sh.autofilter.as_mut() { @@ -754,7 +754,7 @@ fn clear_filter(s: &mut Session, _: &Json) -> Result { } fn reapply(s: &mut Session, _: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { apply_filter(&mut cx.wb, sheet); cx.structural = true; @@ -1258,7 +1258,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let input = str_param(p, "input").unwrap_or(""); - match check_validation(&d.wb, d.wb.active_sheet, at, input) { + match check_validation(&d.wb, d.wb.active_sheet(), at, input) { None => Ok(json!({"ok": true})), Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})), } @@ -1266,7 +1266,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result { fn circle_invalid(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) }; let mut bad_cells = Vec::new(); for dv in &sh.validations { @@ -1305,7 +1305,7 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> { fn group(s: &mut Session, p: &Json, on: bool) -> Result { let si = target_sheet(s, p)?; // The selection belongs to the active sheet, so another sheet needs its lines. - if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() { + if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() { return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet")); } let (rows, a, b) = outline_lines(s, p)?; diff --git a/crates/engine/src/cmd/draw.rs b/crates/engine/src/cmd/draw.rs index af3e65e28..a86be3099 100644 --- a/crates/engine/src/cmd/draw.rs +++ b/crates/engine/src/cmd/draw.rs @@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result { str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() }); let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0); let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx)); let dx = (minx - sh.col_left(cell.col)) as f32; @@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> { } fn ink_to_shape(s: &mut Session, p: &Json) -> Result { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); let id = u32_param(p, "id"); edit(s, |cx| { let sh = cx.sheet_mut(si)?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e53740712..77c2fb8a0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -237,7 +237,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { { return Err(EngineError::Other(m)); } - let active_sheet = s.doc()?.wb.active_sheet; + let active_sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { if array { // Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds @@ -414,23 +414,18 @@ fn selection_set(s: &mut Session, p: &Json) -> Result { impl crate::DocState { /// Switches the active sheet, remembering each sheet's selection. pub fn wb_switch_sheet(&mut self, i: usize) { - if i >= self.wb.sheets.len() || i == self.wb.active_sheet { + if i >= self.wb.sheets.len() || i == self.wb.active_sheet() { return; } - let cur = self.wb.active_sheet; + let cur = self.wb.active_sheet(); if self.sheet_selections.len() < self.wb.sheets.len() { self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default()); } if let Some(slot) = self.sheet_selections.get_mut(cur) { *slot = self.selection.clone(); } - // The active sheet is view state: change it without making the workbook dirty. - let dirty = self.is_dirty(); - let wb = std::sync::Arc::make_mut(&mut self.wb); - wb.active_sheet = i; - if !dirty { - self.saved = self.wb.clone(); - } + // View state: set in place, so the workbook is neither copied nor made dirty. + self.wb.set_active_sheet(i); self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default(); } } @@ -558,7 +553,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result { // A defined name or table? let d = s.doc()?; let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() { - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); if let Some(n) = d.wb.name(reference, sheet) { n.formula.clone() } else if let Some((si, ti)) = d.wb.table(reference) { @@ -1535,7 +1530,7 @@ fn find(s: &mut Session, p: &Json) -> Result { let workbook = str_param(p, "within") == Some("workbook"); s.last_find = Some(p.clone()); let d = s.doc()?; - let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] }; + let sheets: Vec = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] }; let mut hits: Vec<(usize, CellRef, String)> = Vec::new(); for si in sheets { let Some(sh) = d.wb.sheet(si) else { continue }; @@ -1565,7 +1560,7 @@ fn find(s: &mut Session, p: &Json) -> Result { return Ok(json!({"count": list.len(), "results": list})); } // Next hit after the active cell. - let cur = (d.wb.active_sheet, d.selection.active); + let cur = (d.wb.active_sheet(), d.selection.active); let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) }; let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned(); let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) }; @@ -1587,7 +1582,7 @@ fn replace(s: &mut Session, p: &Json) -> Result { let workbook = str_param(p, "within") == Some("workbook"); let active = s.doc()?.selection.active; let n = edit(s, |cx| { - let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] }; + let sheets: Vec = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] }; let mut n = 0; for si in sheets { let cells: Vec<(CellRef, String)> = @@ -1659,7 +1654,7 @@ fn replace_ci(text: &str, what: &str, with: &str) -> String { fn format_painter(s: &mut Session, p: &Json) -> Result { if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) { let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) }; - let dest_sheet = s.doc()?.wb.active_sheet; + let dest_sheet = s.doc()?.wb.active_sheet(); let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles; edit(s, |cx| { let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) }; @@ -1739,7 +1734,7 @@ fn format_painter(s: &mut Session, p: &Json) -> Result { } let sticky = bool_param(p, "sticky").unwrap_or(false); let d = s.doc()?; - s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky)); + s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky)); ok() } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 395345543..506f56443 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -142,7 +142,15 @@ 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, "{} → {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.arrangeAll", + "Arrange All", + ["View", "Window"], + None, + "{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first", + has_doc, + arrange_all + ), 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!( @@ -276,7 +284,7 @@ pub fn specs() -> Vec { fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let idx = match u32_param(p, "chart") { Some(id) => sh.charts.iter().position(|c| c.id == id), @@ -442,7 +450,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result { let (sheet_name, body) = split_sheet(range); let src_si = match sheet_name { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?; if r.count() > 1_000_000 { @@ -464,7 +472,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result { let (sheet_name, body) = split_sheet(t); let si = match sheet_name { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?; let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?; @@ -546,7 +554,7 @@ fn shape_outline(s: &mut Session, p: &Json) -> Result { } fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); let id = u32_param(p, "id"); edit(s, |cx| { let shapes = &mut cx.sheet_mut(si)?.shapes; @@ -564,7 +572,7 @@ fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("shape").to_string(); let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false); - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); fn reorder(v: &mut Vec, i: usize, forward: bool, extreme: bool) { if i >= v.len() { return; @@ -630,7 +638,7 @@ fn align(s: &mut Session, p: &Json) -> Result { let ids: Vec = p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default(); let how = str_param(p, "align").unwrap_or("left").to_string(); - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sheet = cx.sheet_mut(si)?; let geo: Vec<(u32, f64, f64, f32, f32)> = { @@ -743,7 +751,7 @@ fn custom_views(s: &mut Session, p: &Json) -> Result { let mut views = stash_map(&d.wb, VIEWS_NAME); if let Some(name) = str_param(p, "save") { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); + views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze}); let v = views.clone(); edit(s, |cx| { stash_set(&mut cx.wb, VIEWS_NAME, &v); @@ -802,6 +810,25 @@ pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result { s.target_window(t).map_err(|e| bad(cmd, e.to_string())) } +/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout, +/// and the interface moves its native windows ([`Session::window_list`] order, active first). +fn arrange_all(s: &mut Session, p: &Json) -> Result { + let arrange = match str_param(p, "arrange").unwrap_or("tiled") { + a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a, + other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))), + }; + let active = s.active_window(); + let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid); + let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false); + let mut windows: Vec = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect(); + if let Some(at) = windows.iter().position(|w| Some(*w) == active) + && let Some(first) = windows.get_mut(..=at) + { + first.rotate_right(1); + } + Ok(json!({"arrange": arrange, "windows": windows})) +} + fn hide_window(s: &mut Session, p: &Json) -> Result { let id = window_param(s, "view.hideWindow", p)?; s.hide_window(id); @@ -835,7 +862,7 @@ fn switch_windows(s: &mut Session, p: &Json) -> Result { fn table_resize(s: &mut Session, p: &Json) -> Result { let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?; let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let ti = match str_param(p, "table") { Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)), @@ -944,7 +971,7 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r }; - let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r); + let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r); from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::()})) } @@ -1117,7 +1144,7 @@ fn theme_fonts(s: &mut Session, p: &Json) -> Result { fn background(s: &mut Session, p: &Json) -> Result { if bool_param(p, "clear").unwrap_or(false) { - let si = s.doc()?.wb.active_sheet; + let si = s.doc()?.wb.active_sheet(); return edit(s, |cx| { cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background"); Ok(Json::Null) @@ -1210,7 +1237,7 @@ fn watch_window(s: &mut Session, p: &Json) -> Result { .iter() .map(|w| { let (sheet, body) = split_sheet(w); - let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet); + let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet()); let c = CellRef::parse(body).unwrap_or_default(); let sh = d.wb.sheet(si); json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))}) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1f50b908b..f704714cc 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -185,9 +185,9 @@ fn close(s: &mut Session, p: &Json) -> Result { fn export(s: &mut Session, p: &Json, ext: &str) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string(); let d = s.doc()?; - let mut wb = (*d.wb).clone(); + let wb = (*d.wb).clone(); if p.get("sheet").is_some() { - wb.active_sheet = target_sheet(s, p)?; + wb.set_active_sheet(target_sheet(s, p)?); } let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") }; let bytes = crate::io::save_bytes(&wb, &target)?; diff --git a/crates/engine/src/cmd/forecast.rs b/crates/engine/src/cmd/forecast.rs index 139d6fc46..b7472fd22 100644 --- a/crates/engine/src/cmd/forecast.rs +++ b/crates/engine/src/cmd/forecast.rs @@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> { let (sh, body) = split_sheet(t); let si = match sh { Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?, - None => d.wb.active_sheet, + None => d.wb.active_sheet(), }; let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?; let r = if r.is_full_cols() || r.is_full_rows() { @@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec, Vec)> None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let sel = d.selection.current(); - (d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) + (d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel }) } }; if r.width() < 2 { @@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { let stats_col = width + 1; let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 }; - let at = s.doc()?.wb.active_sheet; + let at = s.doc()?.wb.active_sheet(); let index = edit(s, |cx| { let at = at.min(cx.wb.sheets.len()); let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true); @@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result { *sc += 1; } } - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); *cx.sel = crate::Selection::at(CellRef::new(0, 0)); cx.structural = true; Ok(at) @@ -502,7 +502,7 @@ mod tests { assert_eq!(r["periods"], 4); let wb = &s.doc().unwrap().wb; // Inserted before the data sheet and activated. - assert_eq!(wb.active_sheet, 0); + assert_eq!(wb.active_sheet(), 0); let sh = wb.sheet(0).unwrap(); assert_eq!(sh.name, r["sheet"].as_str().unwrap()); assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)")); diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index cd28cfbfa..84bc2f3cc 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -1011,7 +1011,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u return Ok((sheet, lines)); } let d = s.doc()?; - if sheet != d.wb.active_sheet { + if sheet != d.wb.active_sheet() { return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet"))); } Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())) diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..22db2abd0 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -102,7 +102,7 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result { (CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1())) }) .collect(); - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { for (c, f) in &writes { let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default(); @@ -400,7 +400,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result { .iter() .filter(|n| !n.hidden) .map(|n| { - let sheet = n.scope.unwrap_or(d.wb.active_sheet); + let sheet = n.scope.unwrap_or(d.wb.active_sheet()); // parse_name, not evaluate's parse: a name's top-level commas are unions. let v = gridcraft_formula::parse_name(&n.formula) .map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e)); @@ -477,7 +477,7 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { fn trace_precedents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at)); + let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at)); let v: Vec = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect(); Ok(Json::Array(v)) } @@ -486,7 +486,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let mut deps = Vec::new(); - d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps); + d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps); deps.sort(); deps.dedup(); let v: Vec = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect(); @@ -499,7 +499,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result = BTreeSet::new(); @@ -570,7 +570,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result { } fn show_formulas(s: &mut Session, p: &Json) -> Result { - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas); let on = bool_param(p, "on").unwrap_or(!cur); edit(s, |cx| { @@ -636,7 +636,7 @@ fn error_checking(s: &mut Session, _: &Json) -> Result { fn evaluate_formula(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let text = match str_param(p, "formula") { Some(f) => f.trim_start_matches('=').to_string(), None => { @@ -675,7 +675,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result { let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?; crate::cmd::edit::check_arg_counts(&expr)?; - let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr); + let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr); Ok(crate::cmd::inspect::value_json(&v)) } diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index aae5a9faa..08369a773 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -356,7 +356,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { if let Some(n) = str_param(p, "table") { return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`"))); } - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; let ti = sh .tables @@ -819,7 +819,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result { fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?; let idx = match u32_param(p, "chart") { Some(id) => sh.charts.iter().position(|c| c.id == id), @@ -968,7 +968,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result { _ => SparklineKind::Line, }; let markers = bool_param(p, "markers").unwrap_or(false); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; sh.sparklines.retain(|x| x.cell != loc); @@ -993,7 +993,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { if placement == "cell" { let mime = validate_cell_picture(&data)?; let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.active().ok_or(EngineError::NoDocument)?; let at = match str_param(p, "at") { Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?, @@ -1037,7 +1037,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result { mime, alt: str_param(p, "alt").unwrap_or("").into(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.images.push(img); Ok(json!({"image": id})) @@ -1107,7 +1107,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result { text: str_param(p, "text").unwrap_or("").into(), format: Default::default(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) @@ -1125,7 +1125,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { return Err(bad("shape.setText", "`text` is longer than 32,767 characters")); } let d = s.doc()?; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?; let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?; if shape.kind != ShapeKind::TextBox { @@ -1148,7 +1148,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result { fn delete_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; match kind.as_str() { @@ -1164,7 +1164,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result { fn move_object(s: &mut Session, p: &Json) -> Result { let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?; let kind = str_param(p, "kind").unwrap_or("").to_string(); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let at = cell_param(s, p, "at")?; edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; @@ -1205,7 +1205,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result { return Err(bad("object.setAnchorMode", "kind must be chart, image or shape")); } let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { let sh = cx.sheet_mut(sheet)?; let anchor = match kind.as_str() { @@ -1703,7 +1703,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result { text: name, format: Default::default(), }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); edit(s, |cx| { cx.sheet_mut(sheet)?.shapes.push(shape); Ok(json!({"shape": id})) diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f373de489..c322f4388 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -63,7 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), - "activeSheet": wb.active_sheet, + "activeSheet": wb.active_sheet(), "sheets": sheets, "selection": d.selection.a1(), "activeCell": d.selection.active.a1(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1f15ddfb8..3d1b404d0 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -271,7 +271,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result { { return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`"))); } - Ok(d.wb.active_sheet) + Ok(d.wb.active_sheet()) } pub(crate) use gridcraft_core::split_sheet; @@ -280,7 +280,7 @@ pub(crate) use gridcraft_core::split_sheet; /// a cell reference, or that names a sheet other than the active one, is an error rather than a /// silent fallback to some other cell. pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result> { - cell_param_on(s, p, key, s.doc()?.wb.active_sheet) + cell_param_on(s, p, key, s.doc()?.wb.active_sheet()) } /// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it). @@ -562,7 +562,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) { impl Ctx<'_> { pub fn sheet_index(&self) -> usize { - self.wb.active_sheet + self.wb.active_sheet() } pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> { self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into())) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 43c3aa4b5..3d3c28b44 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -206,7 +206,7 @@ fn other(msg: impl Into) -> EngineError { /// (sheet, index) of the pivot a call targets. fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; - let active = d.wb.active_sheet; + let active = d.wb.active_sheet(); if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) { let mut order: Vec = vec![active]; order.extend((0..d.wb.sheets.len()).filter(|i| *i != active)); @@ -616,7 +616,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { return Err(other("The workbook is protected and cannot be changed.")); } let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet; + let active = s.doc()?.wb.active_sheet(); // Validate the source now (header names, size). let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; let name = match str_param(p, "name") { @@ -654,7 +654,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { let (si, anchor) = match target { Some(t) => t, None => { - let at = cx.wb.active_sheet.min(cx.wb.sheets.len()); + let at = cx.wb.active_sheet().min(cx.wb.sheets.len()); let nm = cx.wb.next_sheet_name(); cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); for n in cx.wb.names.iter_mut() { @@ -704,7 +704,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { sh.pivots.push(pt); let pi = sh.pivots.len() - 1; let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?; - cx.wb.active_sheet = si; + cx.wb.set_active_sheet(si); *cx.sel = crate::Selection::at(anchor); cx.structural = true; let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(); @@ -1395,7 +1395,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> { let sh = gridcraft_core::unquote_sheet(sh); (d.wb.sheet_index(&sh), a) } - None => (Some(d.wb.active_sheet), t), + None => (Some(d.wb.active_sheet()), t), }; if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) { let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c)); @@ -1529,7 +1529,7 @@ fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec { fn recommended(s: &mut Session, p: &Json) -> Result { let source = source_param(s, p)?; - let active = s.doc()?.wb.active_sheet; + let active = s.doc()?.wb.active_sheet(); let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?; let list = suggestions(&s.doc()?.wb, &src); let Some(apply) = p.get("apply").and_then(Json::as_u64) else { @@ -1612,7 +1612,7 @@ mod tests { assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"])); let d = s.doc().unwrap(); assert_eq!(d.wb.sheets.len(), 2); - assert_eq!(d.wb.active_sheet, 0, "new sheet goes before the current one"); + assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one"); assert_eq!(d.wb.active().unwrap().name, "Sheet2"); assert!(text(&s, "A3").starts_with("PivotTable1")); assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13"); @@ -1993,7 +1993,7 @@ mod tests { } fn plotted(s: &Session) -> Vec>> { let d = s.doc().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s)); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s)); data.series.into_iter().map(|x| x.values).collect() } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 4c80f2fa9..35225cbe9 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -370,13 +370,13 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { let sheets: Vec = if opts.all_sheets { (0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect() } else { - vec![wb.active_sheet] + vec![wb.active_sheet()] }; for si in sheets { if pages >= MAX_PAGES { break; } - let ovr = if si == wb.active_sheet { opts.range } else { None }; + let ovr = if si == wb.active_sheet() { opts.range } else { None }; if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) { continue; } @@ -387,7 +387,7 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec { } } if out.is_empty() && opts.all_sheets { - out.extend(layout_sheet(wb, wb.active_sheet, opts, None)); + out.extend(layout_sheet(wb, wb.active_sheet(), opts, None)); } out } diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 5464abba5..9811fde9e 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -90,7 +90,7 @@ fn has_comments(s: &Session) -> std::result::Result<(), String> { fn step_comment(s: &mut Session, forward: bool) -> Result { let list = comment_cells(s); let d = s.doc()?; - let here = (d.wb.active_sheet, d.selection.active); + let here = (d.wb.active_sheet(), d.selection.active); let target = if forward { list.iter().find(|c| **c > here).or_else(|| list.first()) } else { @@ -374,7 +374,7 @@ mod tests { fn at(s: &Session) -> (usize, String) { let d = s.doc().unwrap(); - (d.wb.active_sheet, d.selection.active.a1()) + (d.wb.active_sheet(), d.selection.active.a1()) } #[test] @@ -443,7 +443,7 @@ mod tests { ); let prot: SheetProtection = serde_json::from_str(&old).unwrap(); assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true)); - let i = s.doc().unwrap().wb.active_sheet; + let i = s.doc().unwrap().wb.active_sheet(); let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot); assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err()); diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 555c2fb79..e68bf6070 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -687,7 +687,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result { let before = p.get("before").and_then(Json::as_u64).map(|v| v as usize); let idx = edit_doc(d, |cx| { // Excel inserts before the active sheet; the "+" button appends after it. - let at = before.unwrap_or(cx.wb.active_sheet + 1).min(cx.wb.sheets.len()); + let at = before.unwrap_or(cx.wb.active_sheet() + 1).min(cx.wb.sheets.len()); cx.wb.sheets.insert(at, Arc::new(Sheet::new(name.clone()))); // Names scoped to later sheets shift. for n in cx.wb.names.iter_mut() { @@ -697,7 +697,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result { *sc += 1; } } - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); *cx.sel = crate::Selection::default(); cx.structural = true; Ok(at) @@ -785,8 +785,8 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result { } } } - if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i { - cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)); + if cx.wb.active_sheet() >= cx.wb.sheets.len() || cx.wb.active_sheet() > i { + cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1))); } *cx.sel = crate::Selection::default(); cx.structural = true; @@ -900,12 +900,12 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result { } cx.wb.names.extend(copies); cx.wb.sheets.insert(at, Arc::new(sh)); - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); } else { let sh = cx.wb.sheets.remove(i); let at = to.min(n - 1); cx.wb.sheets.insert(at, sh); - cx.wb.active_sheet = at; + cx.wb.set_active_sheet(at); // Sheet-level names follow their sheet. for nm in cx.wb.names.iter_mut() { if let Some(sc) = nm.scope.as_mut() { @@ -942,8 +942,8 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result { } edit(s, |cx| { cx.sheet_mut(i)?.visibility = Visibility::Hidden; - if cx.wb.active_sheet == i { - cx.wb.active_sheet = cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + if cx.wb.active_sheet() == i { + cx.wb.set_active_sheet(cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); } Ok(Json::Null) }) @@ -964,7 +964,7 @@ fn unhide_sheet(s: &mut Session, p: &Json) -> Result { let i = target_sheet(s, p)?; edit(s, |cx| { cx.sheet_mut(i)?.visibility = Visibility::Visible; - cx.wb.active_sheet = i; + cx.wb.set_active_sheet(i); Ok(Json::Null) }) } @@ -978,7 +978,7 @@ fn activate(s: &mut Session, p: &Json) -> Result { fn step_sheet(s: &mut Session, dir: isize) -> Result { let d = s.doc()?; - let mut i = d.wb.active_sheet; + let mut i = d.wb.active_sheet(); while let Some(next) = i.checked_add_signed(dir) { let Some(sheet) = d.wb.sheet(next) else { break }; if sheet.visibility == Visibility::Visible { diff --git a/crates/engine/src/cmd/slicer.rs b/crates/engine/src/cmd/slicer.rs index cb72ded71..10e3cf13c 100644 --- a/crates/engine/src/cmd/slicer.rs +++ b/crates/engine/src/cmd/slicer.rs @@ -165,7 +165,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string(); let multi = bool_param(p, "multi").unwrap_or(false); - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); let (table, column, current) = { let d = s.doc()?; let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?; @@ -204,7 +204,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result { fn slicer_select_all(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); edit(s, |cx| { if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) { sl.selected = None; @@ -217,7 +217,7 @@ fn slicer_select_all(s: &mut Session, p: &Json) -> Result { fn slicer_delete(s: &mut Session, p: &Json) -> Result { let (idx, id) = find_slicer(s, p)?; - let sheet = s.doc()?.wb.active_sheet; + let sheet = s.doc()?.wb.active_sheet(); // Absolute column of the slicer, so its filter criterion can be removed too. let abs_col = s.doc().ok().and_then(|d| { let sh = d.wb.active()?; diff --git a/crates/engine/src/cmd/solver.rs b/crates/engine/src/cmd/solver.rs index ffd426f5f..bf78b4d4c 100644 --- a/crates/engine/src/cmd/solver.rs +++ b/crates/engine/src/cmd/solver.rs @@ -299,7 +299,7 @@ fn parse_model(s: &Session, p: &Json) -> Result { let d = s.doc()?; let wb = &d.wb; let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or(""); - let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; + let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?; let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?; let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?; if sh.cell(objective).is_none_or(|c| c.formula.is_none()) { @@ -1913,8 +1913,8 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec= at { - cx.wb.active_sheet += count; + if cx.wb.active_sheet() >= at { + cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count)); } cx.structural = true; names @@ -1939,7 +1939,7 @@ fn report(s: &mut Session, p: &Json) -> Result { let params = match p.get("model") { Some(m @ Json::Object(_)) => m.clone(), Some(Json::Null) | None => { - let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({})); + let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({})); if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) { for (k, v) in over { base.insert(k.clone(), v.clone()); @@ -1988,7 +1988,7 @@ fn report(s: &mut Session, p: &Json) -> Result { fn run(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; - let active = d.wb.active_sheet; + let active = d.wb.active_sheet(); let mut params = p.clone(); if bool_param(p, "saved").unwrap_or(false) { let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?; @@ -2145,7 +2145,7 @@ mod tests { /// The first saved-model name scoped to the active sheet. fn saved(s: &Session) -> String { let d = s.doc().unwrap(); - d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default() + d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default() } fn set(s: &mut Session, cells: &[(&str, &str)]) { for (c, v) in cells { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f0b2f77d3..e79d79630 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -139,7 +139,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result { } let at = match cell_param_on(s, p, "cell", si)? { Some(c) => c, - None if si == d.wb.active_sheet => d.selection.active, + None if si == d.wb.active_sheet() => d.selection.active, // The selection belongs to the active sheet. None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")), }; @@ -190,7 +190,7 @@ fn zoom(s: &mut Session, p: &Json) -> Result { let d = s.doc_mut()?; let dirty = d.is_dirty(); let wb = std::sync::Arc::make_mut(&mut d.wb); - let si = wb.active_sheet; + let si = wb.active_sheet(); if let Some(sh) = wb.sheet_mut(si) { sh.zoom = z; } diff --git a/crates/engine/src/cmd/whatif.rs b/crates/engine/src/cmd/whatif.rs index cec845b7a..53b0dc3cc 100644 --- a/crates/engine/src/cmd/whatif.rs +++ b/crates/engine/src/cmd/whatif.rs @@ -101,7 +101,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR let d = s.doc()?; let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?; let (sh, body) = split_sheet(t); - let si = sheet_named(&d.wb, sh, d.wb.active_sheet, cmd)?; + let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?; let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?; Ok((si, c)) } @@ -110,7 +110,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR /// are trimmed to the used range. pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> { let (sh, body) = split_sheet(t); - let si = sheet_named(wb, sh, wb.active_sheet, cmd)?; + let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?; let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?; Ok((si, trim_to_used(wb, si, r))) } @@ -152,7 +152,7 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve return Err(bad(cmd, format!("too many cells (at most {cap})"))); } } - Ok((sheet.unwrap_or(wb.active_sheet), out)) + Ok((sheet.unwrap_or(wb.active_sheet()), out)) } /// What a JSON value typed into a cell is, as a constant. @@ -525,7 +525,7 @@ fn scenario_delete(s: &mut Session, p: &Json) -> Result { fn scenario_summary(s: &mut Session, p: &Json) -> Result { const CMD: &str = "data.scenarioSummary"; let d = s.doc()?; - let si = d.wb.active_sheet; + let si = d.wb.active_sheet(); let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?; let scenarios: Vec = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect(); if scenarios.is_empty() { @@ -799,7 +799,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result { Some(t) => parse_range(&d.wb, t, CMD)?, None => { let sh = d.wb.active().ok_or(EngineError::NoDocument)?; - (d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active)) + (d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active)) } }; let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?; @@ -1024,7 +1024,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result { let (si, r) = parse_range(&d.wb, t, CMD)?; (si, r.start) } - None => (d.wb.active_sheet, d.selection.active), + None => (d.wb.active_sheet(), d.selection.active), }; let mut row_keys: Vec = Vec::new(); let mut col_keys: Vec = Vec::new(); @@ -1146,7 +1146,7 @@ mod tests { fn v(s: &Session, a: &str) -> Value { let d = s.doc().unwrap(); let (sh, body) = super::split_sheet(a); - let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet); + let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet()); d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap()) } fn n(s: &Session, a: &str) -> f64 { diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..ef1cc7bb5 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -234,7 +234,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result> { /// Encodes a workbook as `kind`, whatever `path`'s extension says. pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result> { - let sheet = wb.active_sheet; + let sheet = wb.active_sheet(); match kind { // The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry // the VBA project, so any other name saves without it, as Excel does. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 74924d542..f55fba080 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -194,14 +194,14 @@ impl DocState { !Arc::ptr_eq(&self.wb, &self.saved) } pub fn sheet_index(&self) -> usize { - self.wb.active_sheet + self.wb.active_sheet() } /// Records the view state that is saved with the file (`sheetView topLeftCell`, the active /// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the /// active sheet's active cell. View state never makes the workbook dirty. pub fn store_view(&mut self, tops: &[(usize, CellRef)]) { let active = self.selection.active; - let si = self.wb.active_sheet; + let si = self.wb.active_sheet(); let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c)) || self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active); if !changed { diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..d5f5b81d0 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -37,7 +37,7 @@ pub fn build(name: &str) -> Option { return None; } let mut wb = (*s.doc().ok()?.wb).clone(); - wb.active_sheet = 0; + wb.set_active_sheet(0); if let Some(sh) = wb.sheet_mut(0) { sh.view_active = gridcraft_core::CellRef::default(); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6f7f7f4bc..73ea96743 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -188,7 +188,7 @@ fn chart_category_axis_from_blank_corner_and_select_data() { assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]); let d = s.doc().unwrap(); let sh = d.wb.active().unwrap(); - let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap()); + let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), sh.charts.last().unwrap()); assert_eq!(data.categories, ["2020", "2021", "2022"]); // The last series can't be removed; "" clears the labels. s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap(); @@ -1608,7 +1608,7 @@ fn slicer_finds_a_named_table_on_another_sheet() { s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap(); s.execute("home.insertSheet", json!({"name": "Report"})).unwrap(); let report = s.doc().unwrap().wb.sheet_index("Report").unwrap(); - assert_eq!(s.doc().unwrap().wb.active_sheet, report); + assert_eq!(s.doc().unwrap().wb.active_sheet(), report); // With another sheet active, `table` still names the table, and the slicer goes on the table's sheet. let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap(); @@ -3843,7 +3843,7 @@ fn line_sizes_and_freeze_panes_take_a_sheet() { assert!(data.cols.get(&4).is_some_and(|c| c.hidden)); assert_eq!(data.default_col_width, 80.0); assert_eq!(sheet(&s, 0), before, "the active sheet is untouched"); - assert_eq!(s.doc().unwrap().wb.active_sheet, 0); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap(); assert_eq!(sheet(&s, 1).freeze, Some((2, 1))); @@ -4550,11 +4550,11 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { 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_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_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. @@ -4564,6 +4564,71 @@ fn switching_windows_swaps_their_views_without_dirtying_the_workbook() { assert_eq!(s.doc().unwrap().selection.a1(), "D4"); } +#[test] +fn window_swaps_share_the_workbook_and_save_records_the_latest_window_sheet() { + let mut s = s(); + s.execute("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-window-arc-{}.xlsx", std::process::id())); + s.execute("file.save", json!({"path": path})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("selection.set", json!({"range": "B2"})).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": "C3:D4"})).unwrap(); + + // Swapping windows, as the interface does before drawing each one, never copies the workbook. + let before = s.doc().unwrap().wb.clone(); + for _ in 0..3 { + s.show_window(first); + assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (0, "B2".to_string())); + s.activate_window(second); + assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (1, "C3:D4".to_string())); + s.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + s.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + } + let d = s.doc().unwrap(); + assert!(std::sync::Arc::ptr_eq(&before, &d.wb), "a window swap or sheet switch copied the workbook"); + assert!(!d.is_dirty()); + + // A command run in window 2 edits window 2's sheet at its selection. + s.execute("cell.set", json!({"input": "two"})).unwrap(); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets[1].value(CellRef::parse("C3").unwrap()), Value::text("two")); + assert_eq!(d.wb.sheets[0].value(CellRef::parse("B2").unwrap()), Value::Empty); + s.show_window(first); + assert_eq!(s.doc().unwrap().wb.active_sheet(), 0); + + // The most recently active window is window 2: the file records its sheet. + s.execute_in(crate::Target::Window(second), "file.save", json!({"path": path})).unwrap(); + let mut t = Session::new(); + t.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(t.doc().unwrap().wb.active_sheet(), 1); + s.activate_window(first); + s.execute("file.save", json!({"path": path})).unwrap(); + let mut t = Session::new(); + t.execute("file.open", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + assert_eq!(t.doc().unwrap().wb.active_sheet(), 0); +} + +#[test] +fn arrange_all_picks_the_visible_windows_active_first() { + let mut s = s(); + let book1 = s.active_window().unwrap(); + let book1b = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap(); + s.execute("file.new", json!({})).unwrap(); + let book2 = s.active_window().unwrap(); + let r = s.execute("view.arrangeAll", json!({})).unwrap(); + assert_eq!(r, json!({"arrange": "tiled", "windows": [book2, book1, book1b]})); + s.execute("window.activate", json!({"window": book1b})).unwrap(); + s.execute("view.hideWindow", json!({"window": book2})).unwrap(); + let r = s.execute("view.arrangeAll", json!({"arrange": "cascade", "activeWorkbook": true})).unwrap(); + assert_eq!(r, json!({"arrange": "cascade", "windows": [book1b, book1]})); + assert!(s.execute("view.arrangeAll", json!({"arrange": "diagonal"})).is_err()); +} + #[test] fn hide_unhide_and_switch_by_window_id() { let mut s = s(); diff --git a/crates/engine/src/windows.rs b/crates/engine/src/windows.rs index 4441166db..df43b7d32 100644 --- a/crates/engine/src/windows.rs +++ b/crates/engine/src/windows.rs @@ -2,10 +2,11 @@ //! //! 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. +//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's +//! active sheet ([`gridcraft_model::workbook::Workbook::active_sheet`]), where every command reads +//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view +//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through +//! the shared `Arc`, and selections live outside the workbook. use serde_json::{Value as Json, json}; @@ -15,7 +16,7 @@ use crate::{DocState, EngineError, Result, Selection, Session}; #[derive(Clone, Debug, Default, PartialEq)] pub struct WindowView { pub selection: Selection, - pub active_sheet: usize, + pub 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, @@ -42,27 +43,20 @@ 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: 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 + let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) { + self.sheet } else { - sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.active_sheet) + sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.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.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1))); d.selection = self.selection; d.sheet_selections = self.sheet_selections; } diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/command_sequence_fuzz.rs index 1724b9b9d..fd06c70b3 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/command_sequence_fuzz.rs @@ -373,8 +373,8 @@ fn structure_problem(wb: &Workbook, sel: &gridcraft_engine::Selection) -> Option if wb.sheets.is_empty() { return Some("no sheets left".into()); } - if wb.active_sheet >= wb.sheets.len() { - return Some(format!("active sheet {} of {}", wb.active_sheet, wb.sheets.len())); + if wb.active_sheet() >= wb.sheets.len() { + return Some(format!("active sheet {} of {}", wb.active_sheet(), wb.sheets.len())); } let mut tables = BTreeSet::new(); for (i, sh) in wb.sheets.iter().enumerate() { @@ -490,7 +490,7 @@ fn watch_list(wb: &Workbook) -> Option<&str> { /// reset: undo restores view state as it was, but switching sheets isn't an undoable edit. fn canonical(wb: &Workbook) -> Workbook { let mut wb = wb.clone(); - wb.active_sheet = 0; + wb.set_active_sheet(0); wb.names.retain(|n| n.name != WATCH_NAME); for sh in wb.sheets.iter_mut() { let sh = Arc::make_mut(sh); diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/hidden_sheet_navigation.rs index f457f4bdb..8869d52ba 100644 --- a/crates/engine/tests/hidden_sheet_navigation.rs +++ b/crates/engine/tests/hidden_sheet_navigation.rs @@ -25,7 +25,7 @@ fn next_stays_on_the_last_visible_sheet() { for _ in 0..2 { session.execute("sheet.next", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.wb.active_sheet(), 1); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -36,7 +36,7 @@ fn previous_stays_on_the_first_visible_sheet() { for _ in 0..2 { session.execute("sheet.previous", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 2); + assert_eq!(doc.wb.active_sheet(), 2); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -45,10 +45,10 @@ fn previous_stays_on_the_first_visible_sheet() { fn navigation_skips_hidden_sheets_in_both_directions() { let mut session = workbook(&[1, 2], 0); session.execute("sheet.next", json!({})).unwrap(); - assert_eq!(session.active().unwrap().wb.active_sheet, 3); + assert_eq!(session.active().unwrap().wb.active_sheet(), 3); session.execute("sheet.previous", json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 0); + assert_eq!(doc.wb.active_sheet(), 0); assert_eq!(doc.selection.active.a1(), "C7"); } @@ -58,7 +58,7 @@ fn navigation_with_one_visible_sheet_is_a_noop() { for command in ["sheet.previous", "sheet.next"] { session.execute(command, json!({})).unwrap(); let doc = session.active().unwrap(); - assert_eq!(doc.wb.active_sheet, 1); + assert_eq!(doc.wb.active_sheet(), 1); assert_eq!(doc.selection.active.a1(), "C7"); } } @@ -73,6 +73,6 @@ fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() { } for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] { session.execute(command, json!({})).unwrap(); - assert_eq!(session.active().unwrap().wb.active_sheet, expected); + assert_eq!(session.active().unwrap().wb.active_sheet(), expected); } } diff --git a/crates/model/src/workbook.rs b/crates/model/src/workbook.rs index 3b943d295..ac6b3131b 100644 --- a/crates/model/src/workbook.rs +++ b/crates/model/src/workbook.rs @@ -1,6 +1,7 @@ //! Workbooks. use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use gridcraft_core::DateSystem; use gridcraft_formula::Expr; @@ -147,7 +148,8 @@ pub struct Workbook { pub calc: CalcSettings, #[serde(default, skip_serializing_if = "is_all")] pub show_objects: ShowObjects, - pub active_sheet: usize, + /// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`]. + pub active_sheet: ActiveSheet, pub props: DocProps, /// Workbook structure protection. pub protected_structure: bool, @@ -205,6 +207,57 @@ impl Default for Workbook { } } +/// The active sheet index of a [`Workbook`]. +/// +/// Every other part of a workbook value is immutable once shared (the engine keeps versions as +/// `Arc` and tells unsaved changes by pointer). The active sheet is the exception: it +/// is the view state of the window showing the workbook, and several windows on one workbook +/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it +/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook. +/// Copies (undo versions, a cloned workbook) take the value at the time of the copy. +#[derive(Default, Serialize, Deserialize)] +#[serde(from = "usize", into = "usize")] +pub struct ActiveSheet(AtomicUsize); + +impl ActiveSheet { + pub fn get(&self) -> usize { + self.0.load(Ordering::Relaxed) + } + pub fn set(&self, i: usize) { + self.0.store(i, Ordering::Relaxed); + } +} + +impl From for ActiveSheet { + fn from(i: usize) -> ActiveSheet { + ActiveSheet(AtomicUsize::new(i)) + } +} + +impl From for usize { + fn from(a: ActiveSheet) -> usize { + a.get() + } +} + +impl Clone for ActiveSheet { + fn clone(&self) -> ActiveSheet { + ActiveSheet::from(self.get()) + } +} + +impl PartialEq for ActiveSheet { + fn eq(&self, other: &ActiveSheet) -> bool { + self.get() == other.get() + } +} + +impl std::fmt::Debug for ActiveSheet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.get().fmt(f) + } +} + impl Workbook { /// A new workbook with one sheet, "Sheet1". pub fn new() -> Workbook { @@ -216,7 +269,7 @@ impl Workbook { date_system: DateSystem::D1900, calc: CalcSettings::default(), show_objects: ShowObjects::All, - active_sheet: 0, + active_sheet: ActiveSheet::default(), props: DocProps::default(), protected_structure: false, structure_password: None, @@ -239,8 +292,17 @@ impl Workbook { pub fn sheet_index(&self, name: &str) -> Option { self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name)) } + /// The index of the sheet the workbook's window shows (the file's active tab). + pub fn active_sheet(&self) -> usize { + self.active_sheet.get() + } + /// Changes the active sheet in place, also through a shared `Arc`: it is view state, + /// so switching sheets or windows neither copies the workbook nor makes it a new version. + pub fn set_active_sheet(&self, i: usize) { + self.active_sheet.set(i); + } pub fn active(&self) -> Option<&Sheet> { - self.sheet(self.active_sheet) + self.sheet(self.active_sheet()) } /// A fresh sheet name `SheetN` not in use. pub fn next_sheet_name(&self) -> String { diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index dcee71173..3fe935732 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -119,7 +119,7 @@ fn main() { app.open_dialog(name, p); } // Page Layout view: edit the first page's centre header (or footer) section. - let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s))); + let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet(), s))); if let (Some(part), Some((si, sh))) = (&hf_edit, active) { let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 }; gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bf1dedac1..ec04f327b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -343,9 +343,10 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - Some(d) => match d.command { Some(cmd) => { let params = crate::dialogs::build_params(&d); + let home = app.ui_window(); let r = app.run(cmd, params); if r.is_err() { - app.dialog = Some(d); + app.return_dialog(home, d); } wrap(r) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 55c0819df..d463d8670 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -134,6 +134,20 @@ impl Dialog { }], json!({"shift": "down"}), ), + "arrangeAll" => Dialog::form( + "arrangeAll", + "Arrange Windows", + "view.arrangeAll", + vec![ + Choice { + key: "arrange", + label: "Arrange", + options: vec![("tiled", "Tiled"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("cascade", "Cascade")], + }, + Check { key: "activeWorkbook", label: "Windows of active workbook" }, + ], + json!({"arrange": "tiled", "activeWorkbook": false}), + ), "insertPaste" => Dialog::form( "insertPaste", "Insert Paste", @@ -540,7 +554,7 @@ impl Dialog { } "solver" => { // Starts from the sheet's saved model, else the active cell as objective. - let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet)); + let saved = app.session.active().and_then(|doc| gridcraft_engine::cmd::solver::saved_model(&doc.wb, doc.wb.active_sheet())); let mut values = json!({"objective": active, "goal": "max", "value": "0", "variables": "", "constraints": [], "nonNegative": true, "method": "grg"}); if let (Some(m), Some(v)) = (saved.as_ref().and_then(Json::as_object), values.as_object_mut()) { for (k, x) in m { @@ -776,7 +790,7 @@ impl Dialog { // Chart Design › Select Data: data range, series to keep, category axis labels. let id = p("chart").as_u64(); let found = app.session.active().and_then(|doc| { - let si = doc.wb.active_sheet; + let si = doc.wb.active_sheet(); let sh = doc.wb.sheet(si)?; let ch = sh.charts.iter().find(|c| Some(u64::from(c.id)) == id)?; let data = gridcraft_chart::resolve(&doc.wb, si, ch); @@ -1139,6 +1153,7 @@ pub(crate) fn build_params(d: &Dialog) -> Json { pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let Some(mut d) = app.dialog.take() else { return }; + let home = app.ui_window(); if d.name == "pasteSpecial" { paste_special_shortcuts(&mut d, ctx); } @@ -1415,6 +1430,15 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } else if confirm { open = false; } + if app.ui_window() != home { + // A command run from the dialog switched windows: whatever it opened shows in the new + // window, and this dialog (or the one it returns to) stays in its own. + let keep = if open { Some(d) } else { d.back.take().map(|b| *b) }; + if let Some(keep) = keep { + app.return_dialog(home, keep); + } + return; + } if let Some(child) = app.dialog.as_mut() { // The body opened another dialog (Name Manager › New…). It takes over; this one waits // behind it and comes back when it closes, as Excel returns to the Name Manager. Go To @@ -2032,7 +2056,7 @@ fn insert_function(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir let mut open = true; ok_cancel(ui, confirm, &mut open); if *confirm && let Some(n) = hits.get(d.list_index).and_then(|f| f["name"].as_str()) { - let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet)).unwrap_or(n); + let n = app.session.active().map(|doc| crate::formula_locale::completion_name(n, locale, &doc.wb, doc.wb.active_sheet())).unwrap_or(n); let cur = app.editor.as_ref().map(|e| e.text.clone()); let text = match cur { Some(t) if t.starts_with('=') => format!("{t}{n}("), diff --git a/crates/ui-egui/src/doc_windows.rs b/crates/ui-egui/src/doc_windows.rs index 88ba3967a..f4dcc2107 100644 --- a/crates/ui-egui/src/doc_windows.rs +++ b/crates/ui-egui/src/doc_windows.rs @@ -18,7 +18,7 @@ //! //! [`Session::windows`]: gridcraft_engine::Session::windows -use craft_window::{Frame, Viewports, WindowEvent, WindowId, WindowList}; +use craft_window::{CASCADE_STEP, Frame, Rect, Viewports, WindowEvent, WindowId, WindowList, cascade}; use serde_json::json; use crate::SheetApp; @@ -79,6 +79,33 @@ impl DocWindows { /// the logic then): shows windows created or opened by menu commands, files and scripts. pub fn logic(&mut self, app: &mut SheetApp, ctx: &egui::Context) { self.sync(app, ctx); + if let Some((arrange, windows)) = app.arrange_windows.take() { + self.arrange(ctx, &arrange, &windows); + } + } + + /// View › Arrange All: moves and sizes the native windows of `windows` (engine ids, the first + /// on top or at the top left) over the root window's monitor. + fn arrange(&self, ctx: &egui::Context, arrange: &str, windows: &[u64]) { + let ids: Vec = windows.iter().filter_map(|w| self.find(*w)).collect(); + let (monitor, infos) = ctx.input(|i| (i.viewport().monitor_size, i.raw.viewports.clone())); + // Platforms that don't report the monitor (Wayland, tests) get a common laptop screen. + let size = monitor.filter(|m| m.x > 0.0 && m.y > 0.0).unwrap_or(egui::vec2(1280.0, 800.0)); + let mut rects = arrange_rects(arrange, ids.len(), Rect::new(0.0, 0.0, size.x, size.y)); + if arrange == "cascade" { + // The active window goes in front: the last step of the cascade. + rects.reverse(); + } + for (id, r) in ids.iter().zip(rects) { + let vp = self.viewports.viewport_id(*id); + // Inner size is what can be set: take off the frame the window has now. + let frame = infos.get(&vp).and_then(|v| Some(v.outer_rect?.size() - v.inner_rect?.size())).unwrap_or(egui::Vec2::ZERO); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::OuterPosition(egui::pos2(r.x, r.y))); + ctx.send_viewport_cmd_to(vp, egui::ViewportCommand::InnerSize((egui::vec2(r.width, r.height) - frame).max(egui::vec2(240.0, 160.0)))); + } + if let Some(first) = ids.first() { + self.viewports.focus(ctx, *first); + } } /// Mirrors the engine's visible windows: closed or hidden windows lose their native window @@ -225,3 +252,48 @@ fn window_title(app: &SheetApp, window: u64) -> String { let dirty = app.session.window(window).and_then(|w| app.session.documents().iter().find(|d| d.uid == w.doc_uid)).is_some_and(|d| d.is_dirty()); if dirty { format!("{title} — Edited") } else { title } } + +/// Outer rectangles for `n` windows arranged within `bounds` as View › Arrange All's `arrange` +/// says: `tiled` (a grid, the last row's windows widened to fill it), `horizontal` (stacked top +/// to bottom), `vertical` (side by side) or `cascade` (each one title bar below and right of the +/// previous, wrapping at the edges). +pub fn arrange_rects(arrange: &str, n: usize, bounds: Rect) -> Vec { + if n == 0 { + return vec![]; + } + let count = n as f32; + match arrange { + "horizontal" => { + let h = bounds.height / count; + (0..n).map(|i| Rect::new(bounds.x, bounds.y + h * i as f32, bounds.width, h)).collect() + } + "vertical" => { + let w = bounds.width / count; + (0..n).map(|i| Rect::new(bounds.x + w * i as f32, bounds.y, w, bounds.height)).collect() + } + "cascade" => { + let shift = CASCADE_STEP * (count - 1.0); + let size = [(bounds.width - shift).max(bounds.width * 0.5), (bounds.height - shift).max(bounds.height * 0.5)]; + let mut previous = Rect::new(bounds.x - CASCADE_STEP, bounds.y - CASCADE_STEP, size[0], size[1]); + (0..n) + .map(|_| { + previous = cascade(previous, size, bounds, CASCADE_STEP); + previous + }) + .collect() + } + _ => { + let cols = (count.sqrt().ceil() as usize).max(1); + let rows = n.div_ceil(cols); + let h = bounds.height / rows as f32; + (0..n) + .map(|i| { + let (row, col) = (i / cols, i % cols); + let in_row = if row + 1 == rows { n - row * cols } else { cols }; + let w = bounds.width / in_row as f32; + Rect::new(bounds.x + w * col as f32, bounds.y + h * row as f32, w, h) + }) + .collect() + } + } +} diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..5ca1edd12 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -83,7 +83,7 @@ fn active_input(app: &SheetApp) -> String { } return sh .cell(anchor) - .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .map(|c| crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) .unwrap_or_default(); } if let Some(c) = sh.cell(a) { @@ -91,7 +91,7 @@ fn active_input(app: &SheetApp) -> String { return String::new(); } return if let Some(f) = &c.formula { - let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet); + let shown = crate::formula_locale::display(&c.input_text(), app.ui.language.formula_locale(), &d.wb, d.wb.active_sheet()); // A legacy array formula shows in braces (`{=A1:A2*2}`), as `Cell::bar_text` does. if f.array.is_some() { format!("{{{shown}}}") } else { shown } } else { @@ -126,7 +126,7 @@ fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) { if !target.is_empty() && target != shown { // A new name for the selection, or a place to go. let is_ref = gridcraft_engine::core::RangeRef::parse(target.split('!').next_back().unwrap_or("")).is_some(); - let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet).is_some() || d.wb.table(&target).is_some()); + let known = app.session.active().is_some_and(|d| d.wb.name(&target, d.wb.active_sheet()).is_some() || d.wb.table(&target).is_some()); let r = if is_ref || known { app.run("edit.goTo", json!({"reference": target})) } else { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 145b9f583..37d12759d 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -563,7 +563,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { app.grid.rect = Some(rect); let Some(d) = app.session.active() else { return }; let wb: Arc = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let sel = d.selection.clone(); let place = (d.uid, si); @@ -1803,7 +1803,7 @@ pub(crate) fn paint_selection( } // Marching ants around the copied range. if let Some(clip) = &app.session.clipboard - && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet == clip.sheet) + && app.session.active().is_some_and(|d| d.uid == clip.doc_uid && d.wb.active_sheet() == clip.sheet) && range_in_pane(clip.range, rows, cols) { // A multi-area copy marks each area as selected. @@ -2109,7 +2109,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if primary_clicked && !resp.drag_started() && let Some(p) = pos - && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet, p) + && let Some((id, act)) = crate::chartview::slicer_click(geo, sh, wb, wb.active_sheet(), p) { app.selected_chart = Some(id); match act { @@ -2307,13 +2307,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && ed.can_point() { let anchor = if mods.shift && ed.point_at_caret() { - ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).unwrap_or(c) + ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(c) } else { c }; let range = RangeRef::new(anchor, c); let text = if range.is_single() { c.a1() } else { range.a1() }; - let sheet_name = (ed.sheet != wb.active_sheet).then(|| sh.name.clone()); + let sheet_name = (ed.sheet != wb.active_sheet()).then(|| sh.name.clone()); let r = match sheet_name { Some(n) => format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&n)), None => text, @@ -2325,7 +2325,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & } ed.request_focus = true; ed.point_cell = Some(c); - ed.point_anchor = Some((wb.active_sheet, anchor)); + ed.point_anchor = Some((wb.active_sheet(), anchor)); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2614,7 +2614,7 @@ fn format_painter_apply(app: &mut SheetApp) { fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, extend: bool, add: bool) -> bool { let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false }; let edit_sheet = ed.sheet; - let active_sheet = wb.active_sheet; + let active_sheet = wb.active_sheet(); let range = if extend { ed.point .and_then(|point| crate::editor::formula_refs(&ed.text).into_iter().find(|(start, end, ..)| (*start, *end) == point)) @@ -2636,7 +2636,8 @@ fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef, range }; let reference = range.a1(); - let reference = if ed.sheet != wb.active_sheet { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; + let reference = + if ed.sheet != wb.active_sheet() { format!("{}!{reference}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { reference }; if add { ed.append_ref(&reference); } else { @@ -2656,13 +2657,13 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { - if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet).map(|(_, cell)| cell).or(ed.point_cell) { + if let Some(start) = ed.point_anchor.filter(|(sheet, _)| *sheet == wb.active_sheet()).map(|(_, cell)| cell).or(ed.point_cell) { let range = RangeRef::new(start, cell); let text = if range.is_single() { range.start.a1() } else { range.a1() }; - let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; + let text = if ed.sheet != wb.active_sheet() { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text }; ed.insert_ref(&text); ed.point_cell = Some(cell); - ed.point_anchor = Some((wb.active_sheet, start)); + ed.point_anchor = Some((wb.active_sheet(), start)); } } else if let Some(d) = app.session.active() { // Read the current anchor: starting a drag may have just replaced the selection. @@ -3117,7 +3118,7 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some((col, row, at)) = app.grid.filter_menu else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let _ = geo; // Tables get an autofilter on demand. @@ -3460,12 +3461,12 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte ) }) .unwrap_or(from); - ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet).map(|(_, cell)| cell).unwrap_or(inferred_anchor) + ed.point_anchor.filter(|(sheet, _)| *sheet == d.wb.active_sheet()).map(|(_, cell)| cell).unwrap_or(inferred_anchor) } else { to }; let text = if extend { RangeRef::new(anchor, to).a1() } else { to.a1() }; - let sheet = d.wb.active_sheet; + let sheet = d.wb.active_sheet(); ed.insert_ref(&text); ed.point_cell = Some(to); ed.point_anchor = Some((sheet, anchor)); @@ -3606,7 +3607,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) let Some(c) = app.grid.list_picker else { return }; let Some(d) = app.session.active() else { return }; let wb = d.wb.clone(); - let si = wb.active_sheet; + let si = wb.active_sheet(); let Some(sh) = wb.sheet(si) else { return }; let Some(dv) = sh.validations.iter().find(|x| x.ranges.iter().any(|r| r.contains(c))).cloned() else { app.grid.list_picker = None; @@ -3743,7 +3744,7 @@ fn is_checkbox(sh: &Sheet, app: &SheetApp) -> bool { fn paint_pages(app: &mut SheetApp, p: &Painter, geo: &Geo, sh: &Sheet) { let Some(d) = app.session.active() else { return }; - let key = (d.uid, d.revision, d.wb.active_sheet); + let key = (d.uid, d.revision, d.wb.active_sheet()); if app.grid.pages.as_ref().is_none_or(|(k, _)| *k != key) { let pages: Vec<(RangeRef, u32)> = app .session diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 19f69a90f..973b12de5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -342,6 +342,8 @@ pub struct SheetApp { /// tests, embedding hosts) one window shows the active workbook window, and its close button /// quits. pub native_windows: bool, + /// View › Arrange All's layout and windows, waiting for the native windows to move. + pub arrange_windows: Option<(String, Vec)>, /// The native window that has focus, which menu commands and screenshots go to. pub(crate) focus_window: Option, } @@ -394,6 +396,7 @@ impl SheetApp { ui_window: None, parked: HashMap::new(), native_windows: false, + arrange_windows: None, focus_window: None, } } @@ -479,6 +482,14 @@ impl SheetApp { // Requests the command made (a dialog, a draft) belong to the window it left active. self.follow_active_window(); self.after_engine(); + if id == "view.arrangeAll" + && self.native_windows + && let Ok(r) = &r + { + let arrange = r.get("arrange").and_then(Json::as_str).unwrap_or("tiled").to_string(); + let windows = r.get("windows").and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_u64).collect()).unwrap_or_default(); + self.arrange_windows = Some((arrange, windows)); + } // The new chart is selected, as after inserting any object, so Delete removes it, not its data. if id == "insert.chart" && let Ok(r) = &r @@ -811,7 +822,7 @@ impl SheetApp { let text = text.map(|text| { self.session .active() - .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet)) + .map(|d| formula_locale::display_template(&text, self.ui.language.formula_locale(), &d.wb, d.wb.active_sheet())) .unwrap_or(text) }); self.begin_edit(text, false); @@ -977,12 +988,12 @@ impl SheetApp { /// 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)) + 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)) + Some((self.session.active_window()?, self.session.active()?.wb.active_sheet())) } pub fn view(&self) -> SheetView { @@ -1053,7 +1064,7 @@ impl SheetApp { String::new() } else { match sh.cell(at) { - Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet), + Some(c) if c.formula.is_some() => formula_locale::display(&c.input_text(), locale, &d.wb, d.wb.active_sheet()), _ => gridcraft_engine::display::edit_text(&d.wb, sh, at), } }; @@ -1061,7 +1072,7 @@ impl SheetApp { Some(t) => (t, true), None => (current.clone(), false), }; - let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet, at, text, replace, from_formula_bar); + 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() { @@ -1153,7 +1164,7 @@ impl SheetApp { self.toast = Some((msg, now_ms())); } // Point mode can leave another sheet active; the edit belongs to the sheet it started on. - if self.session.active().is_some_and(|d| d.wb.active_sheet != ed.sheet) + if self.session.active().is_some_and(|d| d.wb.active_sheet() != ed.sheet) && let Err(e) = self.session.run("sheet.activate", json!({"sheet": ed.sheet})) { self.message = Some(("GridCraft".into(), clean_error(&e))); @@ -1214,7 +1225,7 @@ impl SheetApp { let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell()); // Any other move (arrows, clicks, another sheet) leaves the selection off the cell the // last Tab landed on, which ends the run. - let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet, d.selection.active)); + let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active)); let run = self.tab_run.take().filter(|&(uid, sheet, _, at)| here == Some((uid, sheet, at))); if multi { let fwd = dr > 0 || dc > 0; @@ -1227,7 +1238,7 @@ impl SheetApp { if dr == 0 && dc != 0 { let start = run.map(|r| r.2); self.tab_run = self.session.active().and_then(|d| { - let (uid, sheet) = (d.uid, d.wb.active_sheet); + let (uid, sheet) = (d.uid, d.wb.active_sheet()); let col = start.or(here.map(|h| h.2.col))?; Some((uid, sheet, col, d.selection.active)) }); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index a8a7e35f4..623803e2a 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -230,7 +230,7 @@ fn format_chart(app: &mut SheetApp, ui: &mut Ui) { }); }); ui.collapsing("Series", |ui| { - let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &chart)); + let data = app.session.active().map(|d| gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &chart)); for (i, s) in chart.series.iter().enumerate() { let name = data.as_ref().and_then(|d| d.series.get(i)).map(|x| x.name.clone()).unwrap_or_else(|| format!("Series {}", i + 1)); let col = diff --git a/crates/ui-egui/src/paper_view.rs b/crates/ui-egui/src/paper_view.rs index 621a97903..c3bc3312d 100644 --- a/crates/ui-egui/src/paper_view.rs +++ b/crates/ui-egui/src/paper_view.rs @@ -279,14 +279,14 @@ pub(crate) fn paint_headers_footers(app: &SheetApp, ui: &egui::Ui, p: &Painter, pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) { // Leaving the sheet or the view commits. if let Some(e) = &app.grid.hf_edit - && (e.sheet != wb.active_sheet || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) + && (e.sheet != wb.active_sheet() || sh.view != SheetViewMode::PageLayout || geo.paper.is_none()) { commit(app); } let Some(pg) = geo.paper.clone() else { return }; - ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet); + ruler_margins(app, ui, geo, &pg, sh, wb.active_sheet()); if std::mem::take(&mut app.grid.hf_start) { - begin(app, wb.active_sheet, sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); + begin(app, wb.active_sheet(), sh, (pg.data_rows.0, pg.data_cols.0), HfZone { footer: false, section: 1 }); } if resp.clicked_by(egui::PointerButton::Primary) && let Some(p) = ui.input(|i| i.pointer.interact_pos()) @@ -294,7 +294,7 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo let (x, y) = (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64); match pg.hf_zone_at(x, y) { - Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet, sh, page, zone), + Some((page, zone)) if geo.cells.contains(p) => begin(app, wb.active_sheet(), sh, page, zone), _ => { // A click anywhere else ends the edit; the grid then handles the click. if app.grid.hf_edit.is_some() { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6bd45e18f..bea9e7191 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1930,7 +1930,7 @@ fn formulas(app: &mut SheetApp, ui: &mut Ui) { { let locale = app.ui.language.formula_locale(); let name = - app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet)).unwrap_or(n); + app.session.active().map(|d| crate::formula_locale::completion_name(n, locale, &d.wb, d.wb.active_sheet())).unwrap_or(n); let desc = crate::formula_locale::description(n, locale, app.ui.language).unwrap_or_default(); if ui.button(name).on_hover_text(desc).clicked() { app.run_or_alert("formulas.insertFunction", json!({"name": n})); @@ -2318,6 +2318,16 @@ fn view(app: &mut SheetApp, ui: &mut Ui) { act(app, "view.zoomToSelection", json!({"viewWidth": r.width(), "viewHeight": r.height()})); } sep(ui); + let __k = big_button(ui, Icon::NewFile, "New\nWindow", "New Window", false); + control_keytip(app, ui, &__k, "view.newWindow"); + if __k.clicked() { + act(app, "view.newWindow", json!({})); + } + let __k = big_button(ui, Icon::Table, "Arrange\nAll", "Arrange All", false); + control_keytip(app, ui, &__k, "view.arrangeAll"); + if __k.clicked() { + app.open_dialog("arrangeAll", json!({})); + } let fp = big_button(ui, Icon::Freeze, "Freeze\nPanes", "Freeze Panes", true); for sequence in keytip_table::group_extension(app.keytips.prefix().unwrap_or_default(), &["view.freezePanes", "view.freezeTopRow", "view.freezeFirstColumn"]) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index bc6fcc1d8..14d2dfc6a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -72,7 +72,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let all = crate::widgets::icon_button(ui, Icon::SheetList, t.text_dim, app.ui.language.tr("All sheets"), vec2(24.0, 24.0)); all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, app.ui.language.tr("All sheets"))); let items = app.session.active().map(|d| navigator_items(&d.wb.sheets)).unwrap_or_default(); - let active = app.session.active().map(|d| d.wb.active_sheet.to_string()).unwrap_or_default(); + let active = app.session.active().map(|d| d.wb.active_sheet().to_string()).unwrap_or_default(); let rows: Vec<(&str, String)> = items.iter().map(|(i, name)| (i.as_str(), name.clone())).collect(); if let crate::ribbon::ComboResult::Picked(i) = crate::ribbon::editable_combo(ui, &all, "sheet_navigator", &active, &rows, false) && let Ok(i) = i.parse::() @@ -86,7 +86,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { .iter() .enumerate() .filter(|(_, s)| s.visibility == Visibility::Visible) - .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet)) + .map(|(i, s)| (i, s.name.clone(), s.tab_color.and_then(|c| c.resolve(&d.wb.theme)), i == d.wb.active_sheet())) .collect(); // Keep worksheet tabs in their own horizontally scrollable viewport. As in Excel, the // tabs get up to 60% of the bar and the grid's horizontal scrollbar the rest; a strip @@ -94,7 +94,7 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) { let tabs_width = (ui.available_width() * 0.6).max(100.0); // Reveal the selected sheet after navigation or layout changes, but // allow browsing other tabs without snapping back on every frame. - let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet, &sheets, tabs_width.to_bits())); + let reveal_key = egui::Id::new((d.uid, d.wb.active_sheet(), &sheets, tabs_width.to_bits())); let reveal_id = ui.id().with("worksheet_tabs_reveal"); let reveal_active = ui.ctx().data_mut(|data| { let changed = data.get_temp::(reveal_id) != Some(reveal_key); @@ -451,11 +451,11 @@ mod tests { let mut app = crate::SheetApp::new(session, Default::default()); app.begin_edit(Some("99".into()), false); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 0, "a value the validation stops must not leave its sheet"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 0, "a value the validation stops must not leave its sheet"); assert!(app.editor.is_some(), "the edit stays open to be corrected"); app.editor.as_mut().unwrap().text = "7".into(); activate_sheet(&mut app, 1); - assert_eq!(app.session.active().unwrap().wb.active_sheet, 1, "the corrected value commits and the sheet changes"); + assert_eq!(app.session.active().unwrap().wb.active_sheet(), 1, "the corrected value commits and the sheet changes"); let a1 = gridcraft_engine::core::CellRef::parse("A1").unwrap(); assert!(app.session.active().unwrap().wb.sheet(0).unwrap().cell(a1).is_some(), "the value lands on the first sheet"); } diff --git a/crates/ui-egui/src/text_box.rs b/crates/ui-egui/src/text_box.rs index a0b30c025..229f4ea5f 100644 --- a/crates/ui-egui/src/text_box.rs +++ b/crates/ui-egui/src/text_box.rs @@ -44,7 +44,7 @@ impl SheetApp { id, text: shape.text.clone(), uid: d.uid, - sheet: d.wb.active_sheet, + sheet: d.wb.active_sheet(), original: shape.text.clone(), request_focus: true, rect: None, diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 96aa83c79..bc6f31a3d 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -126,6 +126,19 @@ impl SheetApp { self.follow_active_window(); } + /// Puts back a dialog that was taken out of window `home`'s fields while it ran. A command + /// run from the dialog may have switched windows meanwhile; the dialog stays in the window it + /// was opened in (dropped if that window closed). + pub(crate) fn return_dialog(&mut self, home: Option, d: dialogs::Dialog) { + if home == self.ui_window { + self.dialog = Some(d); + } else if let Some(w) = home.and_then(|h| self.parked.get_mut(&h)) + && w.dialog.is_none() + { + w.dialog = Some(d); + } + } + /// Whether a window other than the current one has a cell or text-box draft open. pub fn parked_window_with_draft(&self) -> Option { self.parked.iter().filter(|(_, w)| w.editor.is_some() || w.text_box_editor.is_some()).map(|(id, _)| *id).min() diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/commit_after_pointing.rs index 89e1ccd29..3b29eb7a6 100644 --- a/crates/ui-egui/tests/commit_after_pointing.rs +++ b/crates/ui-egui/tests/commit_after_pointing.rs @@ -34,7 +34,7 @@ fn commit_after_pointing_on_another_sheet_writes_to_the_origin_sheet() { assert!(h.state_mut().commit_edit(0, 0, false, false)); let at = CellRef::parse("F8").unwrap(); let wb = &h.state().session.active().unwrap().wb; - assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again"); assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "formula written on the origin sheet"); assert!(wb.sheet(1).unwrap().cell(at).is_none(), "the pointed-at sheet is untouched"); } @@ -44,7 +44,7 @@ fn ctrl_enter_after_pointing_on_another_sheet_fills_the_origin_selection() { let mut h = pointing_on_second_sheet("B2:C3"); assert!(h.state_mut().commit_edit(0, 0, false, true)); let wb = &h.state().session.active().unwrap().wb; - assert_eq!(wb.active_sheet, 0, "the origin sheet is shown again"); + assert_eq!(wb.active_sheet(), 0, "the origin sheet is shown again"); for a1 in ["B2", "C2", "B3", "C3"] { let at = CellRef::parse(a1).unwrap(); assert!(wb.sheet(0).unwrap().cell(at).is_some_and(|c| c.formula.is_some()), "{a1} filled on the origin sheet"); diff --git a/crates/ui-egui/tests/doc_windows.rs b/crates/ui-egui/tests/doc_windows.rs index b6d518ce2..46427f3bf 100644 --- a/crates/ui-egui/tests/doc_windows.rs +++ b/crates/ui-egui/tests/doc_windows.rs @@ -307,3 +307,116 @@ fn feedback_from_opening_a_workbook_shows_in_its_new_window() { desk.app.enter_window(book1); assert!(desk.app.notice.is_none() && desk.app.message.is_none(), "Book1's window shows neither"); } + +/// Book1 with two sheets, saved, shown in two windows: window 1 on Sheet1 at B2, window 2 on +/// Sheet2 at D4. Returns the window ids. +fn two_windows_on_one_workbook(desk: &mut Desk) -> (u64, u64) { + desk.app.run("home.insertSheet", json!({})).unwrap(); + let path = std::env::temp_dir().join(format!("gridcraft-two-windows-{}-{:?}.xlsx", std::process::id(), std::thread::current().id())); + desk.app.run("file.save", json!({"path": path})).unwrap(); + let _ = std::fs::remove_file(&path); + desk.app.run("sheet.activate", json!({"sheet": 0})).unwrap(); + desk.app.run("selection.set", json!({"range": "B2"})).unwrap(); + desk.frames(2); + let w1 = desk.app.session.active_window().unwrap(); + desk.app.run("view.newWindow", json!({})).unwrap(); + desk.frames(3); + let w2 = desk.app.session.active_window().unwrap(); + desk.app.run("sheet.activate", json!({"sheet": 1})).unwrap(); + desk.app.run("selection.set", json!({"range": "D4"})).unwrap(); + desk.frames(3); + (w1, w2) +} + +#[test] +fn two_windows_on_one_workbook_show_their_own_sheet_and_each_others_edits() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + assert_eq!(desk.windows.shown_windows(), [w1, w2], "New Window opens a native window"); + assert_ne!(desk.viewport(w1), desk.viewport(w2)); + let titles: Vec = [w1, w2].iter().map(|w| desk.app.session.window_title(*w).unwrap()).collect(); + assert!(titles[0].ends_with(".xlsx:1") && titles[1].ends_with(".xlsx:2"), "{titles:?}"); + + // Drawing both windows every frame swaps their views without copying the workbook. + let wb = desk.app.session.doc().unwrap().wb.clone(); + desk.frames(3); + assert!(std::sync::Arc::ptr_eq(&wb, &desk.app.session.doc().unwrap().wb), "a window swap copied the workbook"); + assert!(!desk.app.session.doc().unwrap().is_dirty()); + for (w, sheet, cell) in [(w1, 0, "B2"), (w2, 1, "D4")] { + desk.app.enter_window(w); + let d = desk.app.session.doc().unwrap(); + assert_eq!((d.wb.active_sheet(), d.selection.active), (sheet, CellRef::parse(cell).unwrap()), "window {w}'s sheet and selection"); + assert!(desk.app.grid.rect.is_some(), "window {w} drew its grid"); + } + + // Window 1 refers to Sheet2!D4; window 2 types there; window 1 shows it on the next frame. + desk.app.enter_window(w1); + desk.app.run("cell.set", json!({"cell": "B2", "input": "=Sheet2!D4*2"})).unwrap(); + desk.frames(1); + desk.app.enter_window(w2); + desk.app.run("cell.set", json!({"input": "21"})).unwrap(); + desk.frames(1); + desk.app.enter_window(w1); + let d = desk.app.session.doc().unwrap(); + assert_eq!(d.wb.active_sheet(), 0); + assert_eq!(d.wb.active().unwrap().value(CellRef::parse("B2").unwrap()), Value::Number(42.0)); +} + +#[test] +fn a_dialog_stays_with_its_window_across_a_switch() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + desk.app.enter_window(w1); + desk.app.open_dialog("zoom", json!({})); + desk.frames(2); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom")); + + // From the menu bar. + desk.app.run("view.switchWindows", json!({"window": w2})).unwrap(); + desk.frames(3); + assert_eq!(desk.app.session.active_window(), Some(w2)); + assert!(desk.app.dialog.is_none(), "window 2 shows no dialog"); + + // The user clicks into window 1, then back into window 2. + for (w, other) in [(w1, w2), (w2, w1)] { + let (v, o) = (desk.viewport(w), desk.viewport(other)); + INFOS.with(|i| { + let mut i = i.borrow_mut(); + i.entry(v).or_default().focused = Some(true); + i.entry(o).or_default().focused = Some(false); + }); + desk.frames(2); + assert_eq!(desk.app.session.active_window(), Some(w)); + let expected = if w == w1 { Some("zoom") } else { None }; + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), expected, "window {w}'s dialog"); + } + + // A script switching windows while the dialog is open. + desk.frame_with(|app, ctx| { + let _ = gridcraft_ui_egui::control::handle(app, ctx, "engine.execute", &json!({"command": "window.activate", "params": {"window": w1}})); + }); + desk.frames(2); + assert_eq!(desk.app.dialog.as_ref().map(|d| d.name()), Some("zoom")); + desk.app.enter_window(w2); + assert!(desk.app.dialog.is_none()); +} + +#[test] +fn arrange_all_lays_out_the_native_windows_active_first() { + let mut desk = Desk::new(true); + let (w1, w2) = two_windows_on_one_workbook(&mut desk); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().monitor_size = Some(egui::vec2(1000.0, 800.0))); + desk.app.run("view.arrangeAll", json!({"arrange": "vertical"})).unwrap(); + let sent = desk.frames(1); + let at = |x: f32| ViewportCommand::OuterPosition(egui::pos2(x, 0.0)); + assert!(sent_to(&sent, desk.viewport(w2), &at(0.0)), "the active window goes left: {sent:?}"); + assert!(sent_to(&sent, desk.viewport(w1), &at(500.0))); + assert!(sent_to(&sent, desk.viewport(w1), &ViewportCommand::InnerSize(egui::vec2(500.0, 800.0)))); + + // Without parameters, as a script calls it: tiled. + let r = desk.app.run("view.arrangeAll", json!({})).unwrap(); + assert_eq!(r["arrange"], "tiled"); + let tiled = gridcraft_ui_egui::doc_windows::arrange_rects("tiled", 3, craft_window::Rect::new(0.0, 0.0, 1000.0, 800.0)); + let r = |x, y, w, h| craft_window::Rect::new(x, y, w, h); + assert_eq!(tiled, [r(0.0, 0.0, 500.0, 400.0), r(500.0, 0.0, 500.0, 400.0), r(0.0, 400.0, 1000.0, 400.0)]); +} diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/popup_dismissal.rs index 24d9fafbc..8ea1ec9b1 100644 --- a/crates/ui-egui/tests/popup_dismissal.rs +++ b/crates/ui-egui/tests/popup_dismissal.rs @@ -106,7 +106,7 @@ fn filter_closes_when_clicking_another_table_or_sheet() { click(&mut h, outside, PointerButton::Primary); assert!(!is_open(&h, Menu::Filter), "filter stayed open after switching table/sheet"); if sheet { - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 1); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 1); } else { assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "H2"); } diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/sheet_navigator.rs index a16e306ed..d8ee2eae1 100644 --- a/crates/ui-egui/tests/sheet_navigator.rs +++ b/crates/ui-egui/tests/sheet_navigator.rs @@ -32,7 +32,7 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { #[test] fn typing_a_name_and_enter_activates_that_sheet() { let mut h = harness(); - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 0); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 0); h.get_by_label("All sheets").click(); h.run_steps(4); h.input_mut().events.push(Event::Text("summ".into())); @@ -40,5 +40,5 @@ fn typing_a_name_and_enter_activates_that_sheet() { h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); h.run_steps(4); - assert_eq!(h.state().session.active().unwrap().wb.active_sheet, 3, "Enter on the filtered row must activate Summary"); + assert_eq!(h.state().session.active().unwrap().wb.active_sheet(), 3, "Enter on the filtered row must activate Summary"); } diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/sheet_tabs.rs index c3e5d13fb..8b419ecc7 100644 --- a/crates/ui-egui/tests/sheet_tabs.rs +++ b/crates/ui-egui/tests/sheet_tabs.rs @@ -80,7 +80,7 @@ fn navigation_arrows_reveal_the_selected_tab() { for _ in 0..29 { click(&mut h, next); } - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29); assert_visible(&h, "Tab29"); } @@ -141,7 +141,7 @@ fn hidden_sheets_long_names_and_window_resize_keep_active_tab_visible() { h.set_size(vec2(1000.0, 120.0)); h.run_steps(20); assert_visible(&h, name); - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 29); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 29); } #[test] @@ -185,7 +185,7 @@ fn right_clicking_a_tab_activates_its_sheet_so_the_menu_acts_on_it() { h.step(); h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); h.run_steps(20); - assert_eq!(h.state().session.doc().unwrap().wb.active_sheet, 3, "right-clicking Tab03 should activate it"); + assert_eq!(h.state().session.doc().unwrap().wb.active_sheet(), 3, "right-clicking Tab03 should activate it"); // The menu now offers Unprotect for the protected sheet that was clicked, not Protect for the one that was active. let texts: Vec = h .output() diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/trace_navigation.rs index 11f79fcff..8a74ec87e 100644 --- a/crates/ui-egui/tests/trace_navigation.rs +++ b/crates/ui-egui/tests/trace_navigation.rs @@ -107,11 +107,11 @@ fn cross_sheet_precedents_jump_to_the_other_sheet() { s.execute("cell.set", json!({"cell": "A1", "input": "=Src!B2*2"})).unwrap(); // Back on the first sheet, Ctrl+[ from A1 jumps to Src!B2. let d = s.active().unwrap(); - let first = d.wb.active_sheet; + let first = d.wb.active_sheet(); s.execute("selection.set", json!({"range": "A1"})).unwrap(); let mut h = harness(s); key(&mut h, Key::OpenBracket, Modifiers::CTRL); let d = h.state().session.active().unwrap(); - assert_ne!(d.wb.active_sheet, first, "selection jumped to the precedent's sheet"); + assert_ne!(d.wb.active_sheet(), first, "selection jumped to the precedent's sheet"); assert_eq!(d.selection.active, CellRef { row: 1, col: 1 }, "B2 on the Src sheet"); } diff --git a/crates/xlsx/src/read.rs b/crates/xlsx/src/read.rs index 774567bf8..9381e010e 100644 --- a/crates/xlsx/src/read.rs +++ b/crates/xlsx/src/read.rs @@ -297,7 +297,7 @@ pub fn read_xlsx(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { cx.warn("the workbook has no worksheets; added an empty sheet"); wb.sheets.push(Arc::new(Sheet::new("Sheet1"))); } - wb.active_sheet = file_to_model.get(active_tab).copied().flatten().unwrap_or(0); + wb.set_active_sheet(file_to_model.get(active_tab).copied().flatten().unwrap_or(0)); // Defined names. Chartex parts refer to their data through hidden `_xlchart.*` names. let mut chart_names: Vec<(String, String)> = vec![]; diff --git a/crates/xlsx/src/write.rs b/crates/xlsx/src/write.rs index cf208d6cd..d26dddea4 100644 --- a/crates/xlsx/src/write.rs +++ b/crates/xlsx/src/write.rs @@ -244,8 +244,8 @@ pub fn write_xlsx_named(wb: &Workbook, kind: XlsxKind, book_name: Option<&str>) // Which sheet is active (must be visible), and at least one visible sheet. let any_visible = wb.sheets.iter().any(|s| s.visibility == Visibility::Visible); - let active = if wb.sheets.get(wb.active_sheet).is_some_and(|s| s.visibility == Visibility::Visible) { - wb.active_sheet + let active = if wb.sheets.get(wb.active_sheet()).is_some_and(|s| s.visibility == Visibility::Visible) { + wb.active_sheet() } else { wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0) }; diff --git a/crates/xlsx/src/xls/mod.rs b/crates/xlsx/src/xls/mod.rs index ceaf3e26d..d46b058cb 100644 --- a/crates/xlsx/src/xls/mod.rs +++ b/crates/xlsx/src/xls/mod.rs @@ -198,7 +198,7 @@ pub(crate) fn read_xls_with(bytes: &[u8], mut budget: Budget) -> Result<(Workboo } wb.calc.precision_as_displayed = g.precision_as_displayed; defined_names(&g, &model_index, &mut wb, &mut report); - wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); // The overview comes first. report.warnings.insert( 0, diff --git a/crates/xlsx/src/xlsb.rs b/crates/xlsx/src/xlsb.rs index 3c8454749..39d9fd9f6 100644 --- a/crates/xlsx/src/xlsb.rs +++ b/crates/xlsx/src/xlsb.rs @@ -120,7 +120,7 @@ pub fn read_xlsb(bytes: &[u8]) -> Result<(Workbook, ReadReport), IoError> { if wb.sheets.is_empty() { return Err(invalid("no supported worksheets")); } - wb.active_sheet = wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0); + wb.set_active_sheet(wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0)); Ok((wb, report)) } diff --git a/docs/gaps.md b/docs/gaps.md index 7e2e40dc0..da4703326 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-20 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (§20: a native window per workbook window, New Window and Arrange All) · **Target:** Microsoft Excel (Microsoft 365) Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone using GridCraft for real work first). This is the work list: pick from the top, check the open @@ -156,7 +156,9 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- **Estimate:** 15–23 h. ### 20. Multiple windows; pen pressure (H) -- One window (#162, PR #171); no pressure or tilt in ink. **Estimate:** 10–14 h. +- Desktop windows done: a native window per workbook window, New Window on the same workbook, + Switch Windows, Arrange All. Left: Arrange All across monitors, one window on the web, no + pressure or tilt in ink. **Estimate:** 5–8 h. [hardware-parity.md](hardware-parity.md) ### 21. Automation and add-ins (F) @@ -178,6 +180,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | §20: a native window per workbook window, New Window on the same workbook and Arrange All on the desktop; left: Arrange All across monitors, the web's one window, pen pressure (estimate 10–14 h → 5–8 h) | | 2026-10-20 | minor | §11: the Japanese catalog covers the dialogs, messages and menus — 943 keys (~43%), matching the pt-BR coverage; a test keeps the two catalogs in step | | 2026-10-17 | minor | §6: the five reports it tracked are closed and their PRs merged (#57, #133, #139, #140, #134; PRs #148, #155, #156, #157); the entry stays open for §15's oracle suite | | 2026-10-15 | minor | §13: Solver prepares reports before committing the solution, model and report sheets together; report refusal preserves the workbook and Undo/Redo (#1050) | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f19b4a49d..71cf001d5 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (a native window per workbook window) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (Arrange All) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 The hardware Excel uses, per platform, against GridCraft. Part of [target-app-parity.md](target-app-parity.md). **Hardware: ~40% ready, 25–40 h** (estimated). @@ -11,7 +11,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | GPU rendering of the grid and charts | DirectX / Metal accelerated drawing | wgpu: Metal · DirectX 12 (default since 10-08; OpenGL when DirectX 12 has no GPU driver or fails to start; Vulkan only on request, so the Intel UHD `igvk64.dll` crash (#120) can't happen; shaders compiled with FXC, so no DXC runtime is needed, #339) · Vulkan/GL · Vulkan/GL · WebGPU or WebGL2 | At parity in kind. Driver problems: screen flashing on Windows 11 (#76): 2–4 h | | Pen and touch (Draw tab) | Pen pressure and tilt, eraser end, touch mode, Ink to Shape, ink replay | Pen, highlighter, eraser, Ink to Shape with mouse/pen input; **no pressure or tilt**, no touch mode | Pressure-sensitive strokes where winit reports force; touch-friendly ribbon spacing: 4–6 h | | Trackpad and mouse | Smooth scrolling, pinch zoom, horizontal scroll, Magic Mouse | Smooth scrolling and zoom via egui | Verify pinch zoom on each platform: 1–2 h | -| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | +| Multiple windows and monitors | One window per workbook, New Window for the same workbook, per-monitor DPI | A native window per workbook window on the desktop, New Window for the same workbook; new windows cascade from the last one on its monitor; View › Arrange All tiles, stacks or cascades them over the main window's monitor (web: one window) | Arrange All across monitors; verify per-monitor DPI with windows on two monitors: 2–3 h | | High-DPI and display scaling | yes | yes (display scale matched to desktop density; View › Interface size 75–200 % on top, #230) | — | | Accessibility hardware (screen readers, braille displays, switch control) | Narrator, JAWS, NVDA, VoiceOver | AccessKit enabled in eframe; grid cells and ribbon not yet exposed as an accessible tree | Counted under UI ([ui-parity.md](ui-parity.md)) | | Printers | System print with driver options | Desktop submits through craft-print with printer choice, copies, duplex and grayscale; web browser printing | Counted under print | @@ -20,6 +20,7 @@ The hardware Excel uses, per platform, against GridCraft. Part of | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | View › Arrange All over the main window's monitor. | | 2026-10-22 | minor | Multiple windows: each workbook window is a native window of its own (desktop). | | 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. | | 2026-10-11 | trivial | Windows: DirectX 12 shaders compiled with FXC, no DXC runtime needed (#339, #354) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08a9124bd..f15a13f42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: GridCraft vs Microsoft Excel -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (Page Setup paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, with ISO B4/B5/B6 by name and the Japanese postcards, envelopes and A6 added; no score change) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** minor (several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365) This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it, [`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail: @@ -102,11 +102,11 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2 |---|---|---|---|---| | Features | 45 | ~59% | 300–485 | Table above | | File formats | 15 | ~50% | 80–120 | XLSX read/write is broad but unproven on a real corpus and in Excel; XLSB and ODS are values-only import; no XLS, XML Spreadsheet 2003, SYLK, DIF, PRN; VBA dropped; 100 MB XLSX fails (#175). [file-format-parity.md](file-format-parity.md) | -| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; single window. [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | 12 | ~55% | 50–80 | Excel-style ribbon, galleries, dialogs, formula bar, status bar; 59 commands with shortcuts vs Excel's 200+; key tips on part of the Home tab only; short context menu; a native window per workbook window on the desktop (one on the web). [ui-parity.md](ui-parity.md) | | Performance | 8 | ~35% | 35–55 | [Performance](#performance) | | Stability | 8 | ~60% | 15–25 | Never-crash rules enforced by lint, every command fuzzed with hostile params, random command sequences fuzzed against undo/redo, XLSX round-trip and full-recalc invariants, last-resort guard in `Session::execute`. Field reports: Windows Intel GPU crash (#120), error on close (#117), freeze opening from Finder (#166), screen flashing (#76) | | Localization | 4 | ~10% | 100–165 | Ribbon-only catalog in five languages (ja, zh, ko, pt-BR, ru; ~12% of strings each), CJK/Thai/Hebrew font fallback, no shaping or RTL. [localization-parity.md](localization-parity.md) | -| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; one window. [hardware-parity.md](hardware-parity.md) | +| Hardware | 2 | ~40% | 25–40 | GPU UI via wgpu; single-threaded calc; no pen pressure; a native window per workbook window, Arrange All on one monitor. [hardware-parity.md](hardware-parity.md) | | Platforms | 2 | ~80% | 30–60 | [Platforms](#platforms) | | Ecosystem / add-ins | 2 | ~10% | 25–40 | [Ecosystem and AI](#ecosystem-and-ai) | | AI features | 2 | ~15% | 30–50 | [Ecosystem and AI](#ecosystem-and-ai) | @@ -144,7 +144,7 @@ Discounts for what still stops real work: | Discount | Factor | Evidence | |---|---|---| -| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); one window (#162); missing horizontal scrollbar report (#176) | +| Interaction fidelity | × 0.90 | 59 command shortcuts vs Excel's 200+; key tips only on part of the Home tab (#43); rectangular selection bug (#28); charts can't be resized (#190); missing horizontal scrollbar report (#176) | | Stability on real machines | × 0.92 | Never-crash lints, fuzzed commands and the `Session::execute` guard; but field reports of a startup crash on Intel UHD (#120), an error on close (#117), a freeze opening from Finder (#166) and screen flashing (#76) | | File exchange with Excel users | × 0.88 | Excel opening our output is unverified across features; unmodelled parts dropped on save; advanced chart kinds lose their type; 100 MB files fail (#175); `.xls` and encrypted files are refused with a clear message (#17) rather than opened. Harsher than a drawing app's discount because spreadsheets are exchanged constantly | @@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Several windows on the desktop: a native window per workbook window, New Window on the same workbook, Switch Windows and Arrange All; "one window" removed from UI/UX fidelity, Hardware and the interaction-fidelity discount; no score change | | 2026-10-21 | minor | Page Setup paper sizes: `B4`/`B5`/`B6` are the JIS sizes Japanese Excel uses (ISO B4/B5/B6 by name), and Japanese Postcard, Japanese Double Postcard, the chou/kaku envelopes and A6 are added; PDF MediaBox and `file.print` follow, and the Size menu lists them | | 2026-10-18 | trivial | Stale chart notes: all chart kinds survive XLSX round-trip (PRs #158, #161) and trendlines landed | | 2026-10-16 | minor | Advanced Filter copy-to clears only its actual output and rejects overflow without changing cells (#1031) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 817b5b915..d34b8c464 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (every workbook window is a native window of its own on the desktop, with its own draft, dialog and ribbon; closing one of several windows on a workbook doesn't ask; Quit asks about each unsaved workbook in its window; the last window closed quits on Windows and Linux, macOS keeps running) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (View › Arrange All lays out the native windows; two windows on one workbook show different sheets without copying it; a dialog stays in its window) · **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 @@ -33,8 +33,8 @@ Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes | Backstage (File) view: Info, New with templates, Open recent, Export, Options | yes | yes (#174, 10-11): the File tab opens a full-window view (Esc or the back arrow return) with Home, New (blank + the three samples), Open (recent + Browse; the recent list tells when each workbook was last changed, 10-12), Info (location, properties, Protect Workbook), Save, Save As, Print (page preview, active sheet or workbook, fit to page, Page Setup), Export (PDF, CSV, web page), Close and Options (`file.options`: theme, language, formula bar, AutoSave, calculation) | no Account, Share or template gallery beyond the samples; Options applies at once (no OK/Cancel dialog) and holds only the settings GridCraft has | | 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 | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. Web: one window | no Arrange All; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | -| 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 (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | +| Multiple windows per workbook | yes | yes (10-22, desktop): each workbook window is a native window of its own (New, Open and New Window open one; titles "Book1", or "Book1:1" / "Book1:2" for a workbook's second window, with " — Edited" while unsaved). Each window keeps its own cell draft, dialog, Backstage view, message and grid state, and its ribbon acts on it; on macOS the menu bar acts on the window focused last. Leaving a window enters its draft as Enter would. A window's close button closes that window: one of several on a workbook closes without asking, a workbook's last asks Save / Don't Save / Cancel when unsaved. Quit asks about each unsaved workbook in its window, and Cancel stops it. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar until New or Open. View › Arrange All (dialog: Tiled, Horizontal, Vertical, Cascade, Windows of active workbook; `view.arrangeAll` tiles by default) lays the visible windows out over the main window's monitor, the active one first. Web: one window | Arrange All uses one monitor; a hidden window (View › Hide) has no native window; every window repaints when one does; see [hardware-parity.md](hardware-parity.md) | +| 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 (showing different sheets copies nothing and leaves the workbook unchanged; Save records the sheet of the window used last); 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 (Cmd+Q, the macOS Quit item, the Dock) asks Save / Don't Save / Cancel for each unsaved workbook | | ## 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 | View › New Window and Arrange All on the View ribbon; Arrange All tiles, stacks or cascades the native windows; two windows on one workbook keep their own sheet without copying the workbook; a dialog opened in one window stays there when a command run from it switches windows | | 2026-10-22 | minor | One native window per workbook window on the desktop: per-window drafts, dialogs and ribbons; menu bar commands go to the focused window; closing one of several windows on a workbook doesn't ask; Quit asks in each unsaved workbook's window; the last window closed quits (Windows, Linux) or leaves macOS running | | 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 | From 1062604fee0a6f4bf9394dd9d578f3595ede44a5 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:32:50 -0500 Subject: [PATCH 10/54] i18n: Russian Maximum Change reads as a change limit The Russian labels for Maximum Change said relative error, which is a different quantity. They now say maximum change. --- crates/ui-egui/src/i18n/ru.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 3101d4b35..97033fe4e 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -418,9 +418,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Workbook calculation" => "Вычисления в книге", "Enable iterative calculation" => "Включить итеративные вычисления", "Maximum Iterations:" => "Предельное число итераций:", - "Maximum Change:" => "Относительная погрешность:", + "Maximum Change:" => "Максимальное изменение:", "Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.", - "Maximum Change must be a number of 0 or more." => "Относительная погрешность должна быть числом не меньше 0.", + "Maximum Change must be a number of 0 or more." => "Максимальное изменение должно быть числом не меньше 0.", "Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.", "Insert Sheet" => "Вставить лист", "Rename" => "Переименовать", From a80c7ff570ed25a72a86d921461f6abf3a5bd94e Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:43:24 -0500 Subject: [PATCH 11/54] i18n: Japanese labels for the iteration settings live in ja.rs The merge from main moved the Japanese catalog into i18n/ja.rs, but the feature's old table stayed in i18n.rs and was never used, which failed clippy's dead-code lint. Drop the table and add the four new labels and messages to ja.rs. --- crates/ui-egui/src/i18n.rs | 421 ---------------------------------- crates/ui-egui/src/i18n/ja.rs | 4 + 2 files changed, 4 insertions(+), 421 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5d3a831b1..345037b52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -296,427 +296,6 @@ pub fn set_current(ctx: &egui::Context, language: Language) { ctx.data_mut(|d| d.insert_temp(lang_key(), language)); } -/// English label → Japanese. Keys are the exact interface strings (command ids, file names and -/// document text are never translated); anything absent falls back to English. -const JAPANESE: &[(&str, &str)] = &[ - ("Paste", "貼り付け"), - ("Paste Values", "値の貼り付け"), - ("Paste Formulas", "数式の貼り付け"), - ("Paste Formatting", "書式の貼り付け"), - ("Transpose", "行列を入れ替え"), - ("Paste Link", "リンクの貼り付け"), - ("Paste Special…", "形式を選択して貼り付け…"), - ("Insert Copied Cells", "コピーしたセルの挿入"), - ("Insert Copied Cells…", "コピーしたセルの挿入…"), - ("Insert Cut Cells", "切り取ったセルの挿入"), - ("Insert Paste", "挿入貼り付け"), - ("Cut (⌘X)", "切り取り (⌘X)"), - ("Copy (⌘C)", "コピー (⌘C)"), - ("Format Painter (double-click to keep it on)", "書式のコピー/貼り付け (ダブルクリックで固定)"), - ("Bold (⌘B)", "太字 (⌘B)"), - ("Italic (⌘I)", "斜体 (⌘I)"), - ("Underline (⌘U)", "下線 (⌘U)"), - ("Double Underline", "二重下線"), - ("Strikethrough", "取り消し線"), - ("Underline", "下線"), - ("Increase Font Size", "フォントの拡大"), - ("Decrease Font Size", "フォントの縮小"), - ("Wrap Text", "折り返して全体を表示"), - ("Merge & Center", "セルを結合して中央揃え"), - ("Merge Cells", "セルの結合"), - ("Merge Across", "横方向に結合"), - ("Unmerge Cells", "セル結合の解除"), - ("Fill Color", "塗りつぶしの色"), - ("Font Color", "フォントの色"), - ("Borders", "罫線"), - ("Top\nAlign", "上揃え"), - ("Middle\nAlign", "中央揃え (縦)"), - ("Bottom\nAlign", "下揃え"), - ("Left", "左"), - ("Right", "右"), - ("Center", "中央"), - ("Horizontal", "水平"), - ("Vertical Text", "縦書き"), - ("Decrease Indent", "インデントを減らす"), - ("Increase Indent", "インデントを増やす"), - ("Orientation", "方向"), - ("Page Layout|Orientation", "印刷の向き"), - ("Angle Counterclockwise", "左回りに回転"), - ("Angle Clockwise", "右回りに回転"), - ("Rotate Text Up", "文字列を上に回転"), - ("Rotate Text Down", "文字列を下に回転"), - ("Accounting Number Format", "通貨表示形式"), - ("Percent Style", "パーセント表示"), - ("Comma Style", "桁区切りスタイル"), - ("Increase Decimal", "小数点以下の桁数を増やす"), - ("Decrease Decimal", "小数点以下の桁数を減らす"), - ("More Accounting Formats…", "その他の通貨表示形式…"), - ("Conditional\nFormatting", "条件付き書式"), - ("Conditional Formatting", "条件付き書式"), - ("Format\nas Table", "テーブルとして書式設定"), - ("Cell\nStyles", "セルのスタイル"), - ("Format Cells…", "セルの書式設定…"), - ("Format Cell Alignment…", "セルの配置…"), - ("Format\nPane", "書式ウィンドウ"), - ("Format", "書式"), - ("Insert", "挿入"), - ("Insert Cells…", "セルの挿入…"), - ("Delete", "削除"), - ("Delete Cells…", "セルの削除…"), - ("Insert Sheet Rows", "シートの行を挿入"), - ("Insert Sheet Columns", "シートの列を挿入"), - ("Delete Sheet Rows", "シートの行を削除"), - ("Delete Sheet Columns", "シートの列を削除"), - ("Row Height…", "行の高さ…"), - ("AutoFit Row Height", "行の高さを自動調整"), - ("Column Width…", "列の幅…"), - ("AutoFit Column Width", "列の幅を自動調整"), - ("Default Width…", "標準の幅…"), - ("Hide Rows", "行を表示しない"), - ("Unhide Rows", "再表示"), - ("Hide Columns", "列の表示/非表示"), - ("Unhide Columns", "再表示"), - ("Lock Cell", "セルのロック"), - ("Clear", "クリア"), - ("Clear All", "すべてクリア"), - ("Clear Formats", "書式のクリア"), - ("Clear Contents", "数式と値のクリア"), - ("Clear Comments and Notes", "コメントとメモのクリア"), - ("Clear Hyperlinks", "ハイパーリンクのクリア"), - ("AutoSum", "オート SUM"), - ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), - ("Flash\nFill", "フラッシュ フィル"), - ("Flash Fill", "フラッシュ フィル"), - ("Fill", "フィル"), - ("Clear Print Area", "印刷範囲のクリア"), - ("Find &\nSelect", "検索と選択"), - ("Find…", "検索…"), - ("Replace…", "置換…"), - ("Go To…", "ジャンプ…"), - ("Go To Special…", "セルを選択…"), - ("PivotTable", "ピボットテーブル"), - ("Recommended\nCharts", "推奨グラフ"), - ("Table", "テーブル"), - ("Pictures", "画像"), - ("Shapes", "図形"), - ("Icons", "アイコン"), - ("Text\nBox", "テキスト ボックス"), - ("Comment", "コメント"), - ("New\nComment", "新しいコメント"), - ("New Note", "新しいメモ"), - ("Show/Hide Note", "メモの表示/非表示"), - ("Header &\nFooter", "ヘッダーとフッター"), - ("Text to\nColumns", "区切り位置"), - ("Link", "リンク"), - ("Symbol", "記号"), - ("Sparklines", "スパークライン"), - ("Checkbox", "チェックボックス"), - ("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"), - ("Header & Footer", "ヘッダーとフッター"), - ("Draw with ink", "インクで描画"), - ("Eraser", "消しゴム"), - ("Ink to\nShape", "インクを図形に変換"), - ("Select\nObjects", "オブジェクトの選択"), - ("Themes", "テーマ"), - ("Margins", "余白"), - ("Left Margin", "左余白"), - ("Right Margin", "右余白"), - ("Top Margin", "上余白"), - ("Bottom Margin", "下余白"), - ("Header Margin", "ヘッダーの余白"), - ("Footer Margin", "フッターの余白"), - ("Size", "サイズ"), - ("Print\nArea", "印刷範囲"), - ("Set Print Area", "印刷範囲の設定"), - ("Breaks", "改ページ"), - ("Insert Page Break", "改ページの挿入"), - ("Remove Page Break", "改ページの解除"), - ("Reset All Page Breaks", "すべての改ページを解除"), - ("Print\nTitles", "印刷タイトル"), - ("Sheet\nRight-to-Left", "シートを右から左へ"), - ("Sheet Right-to-Left", "シートを右から左へ"), - ("Narrow", "狭い"), - ("Wide", "広い"), - ("Normal", "標準"), - ("Portrait", "縦"), - ("Landscape", "横"), - ("Custom Margins…", "ユーザー設定の余白…"), - ("Insert\nFunction", "関数の挿入"), - ("Name\nManager", "名前の管理"), - ("Define Name", "名前の定義"), - ("Use in Formula", "数式で使用"), - ("Create from Selection", "選択範囲から作成"), - ("Trace Precedents", "参照元のトレース"), - ("Trace Dependents", "参照先のトレース"), - ("Remove Arrows", "矢印の解除"), - ("Show Formulas", "数式の表示"), - ("Error Checking", "エラーチェック"), - ("Evaluate Formula", "数式の検証"), - ("Watch Window", "ウォッチ ウィンドウ"), - ("Calculation\nOptions", "計算方法の設定"), - ("Calculate Now", "再計算実行"), - ("Calculate Sheet", "シートの再計算"), - ("Automatic", "自動"), - ("Automatic Except for Data Tables", "データ テーブル以外は自動"), - ("Manual", "手動"), - ("Sort &\nFilter", "並べ替えとフィルター"), - ("Sort", "並べ替え"), - ("Sort A to Z", "昇順"), - ("Sort Z to A", "降順"), - ("Custom Sort…", "ユーザー設定の並べ替え…"), - ("Filter", "フィルター"), - ("Reapply", "再適用"), - ("Get Data\n(Text/CSV)", "データの取得 (テキスト/CSV)"), - ("Remove\nDuplicates", "重複の削除"), - ("Data\nValidation", "データの入力規則"), - ("Data Validation", "データの入力規則"), - ("Data Validation…", "データの入力規則…"), - ("Circle Invalid Data", "無効なデータをマーク"), - ("Clear Validation", "入力規則のクリア"), - ("Group", "グループ化"), - ("Ungroup", "グループ解除"), - ("Show Detail", "詳細データの表示"), - ("Hide Detail", "詳細を表示しない"), - ("Subtotal", "小計"), - ("Data\nAnalysis", "データ分析"), - ("Data Analysis", "データ分析"), - ("Solver", "ソルバー"), - ("What-If\nAnalysis", "What-If 分析"), - ("Data Table…", "データ テーブル…"), - ("Scenario Manager…", "シナリオ マネージャー…"), - ("Goal Seek…", "ゴール シーク…"), - ("Forecast\nSheet", "予測シート"), - ("Spelling", "スペルチェック"), - ("Check\nAccessibility", "アクセシビリティ チェック"), - ("Threaded Comments", "スレッド化されたコメント"), - ("Show\nComments", "コメントの表示"), - ("Previous", "前へ"), - ("Next", "次へ"), - ("Previous Comment", "前のコメント"), - ("Next Comment", "次のコメント"), - ("Show All Notes", "すべてのメモを表示"), - ("Convert to Comments", "コメントに変換"), - ("Protect\nWorkbook", "ブックの保護"), - ("Allow Edit\nRanges", "範囲の編集を許可"), - ("Allow Edit Ranges", "範囲の編集を許可"), - ("Unprotect\nSheet", "シート保護の解除"), - ("Protect Sheet…", "シートの保護…"), - ("Comments", "コメント"), - ("Notes", "メモ"), - ("Page Break\nPreview", "改ページ プレビュー"), - ("Page\nLayout", "ページ レイアウト"), - ("Formula Bar", "数式バー"), - ("Gridlines", "目盛線"), - ("Headings", "見出し"), - ("Zoom", "ズーム"), - ("100%", "100%"), - ("Zoom to\nSelection", "選択範囲を拡大"), - ("Freeze\nPanes", "ウィンドウ枠の固定"), - ("Freeze Panes", "ウィンドウ枠の固定"), - ("Freeze Top Row", "最上行の固定"), - ("Freeze First Column", "最左列の固定"), - ("Unfreeze Panes", "ウィンドウ枠固定の解除"), - ("Dark Mode", "ダーク モード"), - ("Display theme", "表示テーマ"), - ("Animations", "アニメーション"), - ( - "Glide the selection outline to the cell you move to instead of jumping there", - "選択範囲の枠を、移動先のセルまで滑らかに動かします(瞬時には切り替えません)", - ), - ("System", "システム"), - ("Light", "ライト"), - ("Dark", "ダーク"), - ("Interface language", "表示言語"), - ("Interface size", "表示サイズ"), - ("Auto", "自動"), - ("Drag to freeze columns", "ドラッグして列を固定"), - ("Drag to freeze rows", "ドラッグして行を固定"), - ("Command\nPalette", "コマンド パレット"), - ("Agent\nControl", "エージェント制御"), - ("Action\nJournal", "操作ジャーナル"), - ("About\nGridCraft", "GridCraft について"), - ("Share", "共有"), - ("Add Chart\nElement", "グラフ要素を追加"), - ("Change\nChart Type", "グラフの種類の変更"), - ("Switch\nRow/Column", "行/列の切り替え"), - ("Select\nData", "データの選択"), - ("Data Labels: None", "データ ラベル: なし"), - ("Data Labels: Show", "データ ラベル: 表示"), - ("Legend: None", "凡例: なし"), - ("Legend: Bottom", "凡例: 下"), - ("Legend: Top", "凡例: 上"), - ("Legend: Right", "凡例: 右"), - ("Gridlines: None", "目盛線: なし"), - ("Gridlines: Show", "目盛線: 表示"), - ("Chart\nTitle", "グラフ タイトル"), - ("Delete\nChart", "グラフの削除"), - ("Convert\nto Range", "範囲に変換"), - ("Table\nStyles", "テーブル スタイル"), - ("Top 10 Items", "上位 10 項目"), - ("Top 10%", "上位 10%"), - ("Bottom 10 Items", "下位 10 項目"), - ("Bottom 10%", "下位 10%"), - ("Above Average", "平均より上"), - ("Below Average", "平均より下"), - ("Clear Rules from Entire Sheet", "シート全体からルールをクリア"), - ("Clear Rules from Selected Cells", "選択したセルからルールをクリア"), - ("Constants", "定数"), - ("Data Bars", "データ バー"), - ("Color Scales", "カラー スケール"), - ("Icon Sets", "アイコン セット"), - ("Home", "ホーム"), - ("Draw", "描画"), - ("Page Layout", "ページ レイアウト"), - ("Formulas", "数式"), - ("Data", "データ"), - ("Review", "レビュー"), - ("View", "表示"), - ("Automate", "自動化"), - ("Table Design", "テーブル デザイン"), - ("Chart Design", "グラフ デザイン"), - ("Ready", "準備完了"), - ("Enter", "入力"), - ("Edit", "編集"), - ("Point", "ポイント"), - ("Calculate", "計算"), - ("Select destination and press Enter or choose Paste", "貼り付け先を選び、Enter キーを押すか [貼り付け] を選びます"), - ("General", "標準"), - ("Number", "数値"), - ("Currency", "通貨"), - ("Accounting", "会計"), - ("Date", "日付"), - ("Time", "時刻"), - ("Percentage", "パーセンテージ"), - ("Fraction", "分数"), - ("Scientific", "指数"), - ("Text", "文字列"), - ("Special", "特殊"), - ("Custom", "ユーザー設定"), - ("Short Date", "日付"), - ("Long Date", "長い日付"), - ("More Number Formats…", "その他の表示形式…"), - ("Financial", "財務"), - ("Logical", "論理"), - ("Date &\nTime", "日付/時刻"), - ("Lookup &\nReference", "検索/行列"), - ("Math &\nTrig", "数学/三角"), - ("More\nFunctions", "その他の関数"), - ("Sheet Options|View", "画面"), - ("Print", "印刷"), - // Page Layout view and the Header & Footer tab. - ("Click to add data", "クリックしてデータを追加"), - ("Add header", "ヘッダーの追加"), - ("Add footer", "フッターの追加"), - ("(none)", "(なし)"), - ("Header", "ヘッダー"), - ("Footer", "フッター"), - ("Page\nNumber", "ページ\n番号"), - ("Number\nof Pages", "ページ数"), - ("Current\nDate", "現在の\n日付"), - ("Current\nTime", "現在の\n時刻"), - ("File\nPath", "ファイルの\nパス"), - ("File\nName", "ファイル名"), - ("Sheet\nName", "シート名"), - ("Go to\nHeader", "ヘッダーに\n移動"), - ("Go to\nFooter", "フッターに\n移動"), - ("Page Number", "ページ番号"), - ("Number of Pages", "ページ数"), - ("Current Date", "現在の日付"), - ("Current Time", "現在の時刻"), - ("File Path", "ファイルのパス"), - ("File Name", "ファイル名"), - ("Sheet Name", "シート名"), - ("Go to Header", "ヘッダーに移動"), - ("Different First Page", "先頭ページのみ別指定"), - ("Different Odd & Even Pages", "奇数/偶数ページ別指定"), - ("Go to Footer", "フッターに移動"), - ("Ruler", "ルーラー"), - // File › Backstage view. - ("File", "ファイル"), - ("Back", "戻る"), - ("New", "新規"), - ("Open", "開く"), - ("Info", "情報"), - ("Save", "上書き保存"), - ("Save As", "名前を付けて保存"), - ("Export", "エクスポート"), - ("Close", "閉じる"), - ("Options", "オプション"), - ("Blank workbook", "空白のブック"), - ("Recent", "最近使ったアイテム"), - ("No recent workbooks yet.", "最近使ったブックはまだありません。"), - ("More workbooks", "その他のブック"), - ("Modified", "更新日時"), - ("Not available", "利用できません"), - ("Just now", "たった今"), - ("Yesterday", "昨日"), - ("{n} min ago", "{n} 分前"), - ("{n} h ago", "{n} 時間前"), - ("{n} days ago", "{n} 日前"), - ("{n} weeks ago", "{n} 週間前"), - ("{n} months ago", "{n} か月前"), - ("{n} years ago", "{n} 年前"), - ("Browse…", "参照…"), - ("Not saved yet", "まだ保存されていません"), - ("Location", "場所"), - ("Sheets", "シート"), - ("Protect Workbook", "ブックの保護"), - ( - "Sheet structure is locked: sheets can't be added, moved, renamed or deleted.", - "シートの構成はロックされています。シートの追加、移動、名前の変更、削除はできません。", - ), - ( - "Lock the sheet structure so sheets can't be added, moved, renamed or deleted.", - "シートの構成をロックして、シートの追加、移動、名前の変更、削除をできないようにします。", - ), - ("Properties", "プロパティ"), - ("Title", "タイトル"), - ("Subject", "件名"), - ("Author", "作成者"), - ("Company", "会社名"), - ("Keywords", "キーワード"), - ("Description", "説明"), - ("No workbook is open.", "ブックが開かれていません。"), - ("Save as type", "ファイルの種類"), - ("Excel Workbook (*.xlsx)", "Excel ブック (*.xlsx)"), - ("CSV UTF-8 (Comma delimited) (*.csv)", "CSV UTF-8 (コンマ区切り) (*.csv)"), - ("Web Page (*.html)", "Web ページ (*.html)"), - ("PDF (*.pdf)", "PDF ファイル (*.pdf)"), - ("Download", "ダウンロード"), - ( - "A CSV file, web page or PDF is a copy: the workbook keeps its own file.", - "CSV ファイル、Web ページ、PDF はコピーです。ブックは元のファイルのままです。", - ), - ("Save the workbook under another name or in another folder.", "ブックを別の名前または別のフォルダーに保存します。"), - ("Print Active Sheets", "作業中のシートを印刷"), - ("Print Entire Workbook", "ブック全体を印刷"), - ("Fit Sheet on One Page", "シートを 1 ページに印刷"), - ("Page Setup…", "ページ設定…"), - ("Page {page} of {pages}", "{page} / {pages} ページ"), - ("Nothing to print.", "印刷する内容がありません。"), - ("Previous page", "前のページ"), - ("Next page", "次のページ"), - ("Create PDF", "PDF の作成"), - ("Save the sheet as a PDF document that keeps its page layout.", "ページ レイアウトを保ったまま、シートを PDF ドキュメントとして保存します。"), - ("Change File Type", "ファイルの種類の変更"), - ("CSV (Comma delimited)", "CSV (コンマ区切り)"), - ("Save the active sheet as comma-separated text.", "作業中のシートをコンマ区切りのテキストとして保存します。"), - ("Web Page (.html)", "Web ページ (.html)"), - ("Save the workbook as a web page.", "ブックを Web ページとして保存します。"), - ("Options|General", "全般"), - ("Options|Save", "保存"), - ("Show formula bar", "数式バーを表示する"), - ("Enable AutoComplete for cell values", "セルの値にオートコンプリートを使用する"), - ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), - ("Workbook calculation", "ブックの計算"), - ("Enable iterative calculation", "反復計算を行う"), - ("Maximum Iterations:", "最大反復回数:"), - ("Maximum Change:", "変化の最大値:"), - ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"), - ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"), - ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), -]; - /// `name` lowercased with the Latin accents that occur in language names folded away, so a /// byte-wise sort reads alphabetically. Other scripts are left as they are. fn name_sort_key(name: &str) -> String { diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 2df4cfc11..1a9ea50d4 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -411,6 +411,10 @@ pub(super) const CORE: &[(&str, &str)] = &[ ("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"), ("Workbook calculation", "ブックの計算"), ("Enable iterative calculation", "反復計算を行う"), + ("Maximum Iterations:", "最大反復回数:"), + ("Maximum Change:", "変化の最大値:"), + ("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"), + ("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"), ("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"), ]; From 14415f410d4c7338d35477f73b5867f0125330b7 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:16:03 +0200 Subject: [PATCH 12/54] Fix PERCENTRANK returning #NUM! for very large significance Found while testing crates/functions/src/stat.rs:257-260 (trunc_digits, used by percentrank_impl) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/stat.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/functions/src/stat.rs b/crates/functions/src/stat.rs index 861c4768e..2ad331607 100644 --- a/crates/functions/src/stat.rs +++ b/crates/functions/src/stat.rs @@ -256,6 +256,9 @@ fn intercept(y: &[f64], x: &[f64]) -> R { fn trunc_digits(r: f64, sig: f64) -> f64 { let f = 10f64.powi(sig as i32); + if !f.is_finite() { + return r; + } ((r * f) + 1e-9).floor() / f } @@ -2486,6 +2489,11 @@ mod tests { close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7); close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381); close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3); + let t = c(&[1.0, 2.0, 3.0]); + close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5); + close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0); + close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5); } #[test] From 6cb32201641eb8016ad57290e87e20b7ddf0b1c1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:09:26 +0000 Subject: [PATCH 13/54] Scale MINVERSE rounding cutoff to the input matrix Found while testing crates/functions/src/math.rs:923 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/math.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 53a92f00f..9824da7bf 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -918,7 +918,9 @@ fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R { } } } - let inv = inv.into_iter().map(|x| if x.abs() < 1e-15 { 0.0 } else { x }).collect(); + // Rounding noise in the inverse scales with 1/scale of the input matrix. + let noise = 1e-15 / scale; + let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect(); to_value_array(n, n, inv) } @@ -1622,6 +1624,17 @@ mod tests { is_text(ev2("ROMAN", 99.0, 4.0), "IC"); } + #[test] + fn minverse_scale_relative() { + let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]); + let r = rows_of(&big); + close_tol(r[0][0].clone(), 1e-20, 1e-25); + close_tol(r[1][1].clone(), 1e-20, 1e-25); + let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]); + let r = rows_of(&small); + close_tol(r[0][0].clone(), 1e20, 1e10); + } + #[test] fn matrices() { let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]); From 276cd0fe78f543f6f5e5f82a4906970237a47aaf Mon Sep 17 00:00:00 2001 From: Arie Nugraha Date: Sat, 24 Oct 2026 13:00:37 +0000 Subject: [PATCH 14/54] web: parse ?chrome= so an embedding host can leave out the title bar and File tab #1877 added Chrome to SheetApp for hosts that embed the sheet natively, but the web build never set it: an iframe host that owns the files still draws the title bar it duplicates, and Backstage's Open/Save As can replace the document behind the host's back. Parse the flag where the other URL flags are parsed; the UI already honours Chrome, so nothing else changes. Co-Authored-By: Claude Code --- apps/gridcraft-web/src/main.rs | 5 +++-- apps/gridcraft-web/src/web.rs | 11 +++++++++++ packaging/web/README.md | 4 ++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/src/main.rs b/apps/gridcraft-web/src/main.rs index 36f3f3eca..68bee92e6 100644 --- a/apps/gridcraft-web/src/main.rs +++ b/apps/gridcraft-web/src/main.rs @@ -11,8 +11,9 @@ //! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample; //! `?host=parent` (inside an `