From fef1f4ddb1dd546f94de045318932d47fbc934d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 20:25:14 +0200 Subject: [PATCH 001/149] 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 74de1b32ebc2097fb5545f3851c3db0ae6b136c7 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:14:07 -0500 Subject: [PATCH 002/149] Keytips: Alt, H, B border letters apply their own preset Every Alt, H, B letter ran home.borders with no params, which defaults to a bottom border, so H,B,N (No Border) and H,B,A (All Borders) both drew a bottom border. keytips.json entries now take optional fixed `params` (Action::CommandWith / DialogWith), and each Borders menu letter passes the preset Excel's menu gives it. H,B,M opens Format Cells on the Border tab. Erase Border, Line Color and Line Style are reserved (pending) instead of drawing a bottom border. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 3 +- crates/ui-egui/src/keytips.rs | 5 + crates/ui-egui/src/keytips/keytip_table.rs | 4 +- .../src/keytips/keytip_table/generated.rs | 64 ++++++------- crates/ui-egui/src/keytips/keytips.json | 90 +++++++++++++----- crates/ui-egui/src/lib.rs | 12 +++ crates/ui-egui/tests/keytips.rs | 93 +++++++++++++++++++ crates/ui-egui/tests/keytips_generated.rs | 17 +++- docs/ui-parity.md | 5 +- xtask/src/keytips.rs | 39 ++++++-- 10 files changed, 260 insertions(+), 72 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..708f80383 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-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **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-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab | | 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/keytips.rs b/crates/ui-egui/src/keytips.rs index e1163d9bb..07324dac7 100644 --- a/crates/ui-egui/src/keytips.rs +++ b/crates/ui-egui/src/keytips.rs @@ -10,7 +10,12 @@ pub mod keytip_table; pub enum Action { Tab(&'static str), Command(&'static str), + /// A command run with fixed params (a JSON object literal from keytips.json `params`), for + /// menu items that share one command id, like the Borders presets (HBO bottom, HBN none). + CommandWith(&'static str, &'static str), Dialog(&'static str), + /// A dialog opened with fixed params, like Format Cells on its Border tab (HBM). + DialogWith(&'static str, &'static str), /// The target command does not exist yet: the sequence is reserved, its badge shows, and /// invoking it is a silent no-op until the command lands (see keytips.json `pending`). Pending(&'static str), diff --git a/crates/ui-egui/src/keytips/keytip_table.rs b/crates/ui-egui/src/keytips/keytip_table.rs index 5415b9d2b..d14af141b 100644 --- a/crates/ui-egui/src/keytips/keytip_table.rs +++ b/crates/ui-egui/src/keytips/keytip_table.rs @@ -50,8 +50,8 @@ pub fn is_group_level(seq: &str) -> bool { fn matches_target(action: &Action, target: &str) -> bool { match action { - Action::Command(id) | Action::Pending(id) | Action::Control(id) => *id == target, - Action::Dialog(name) => format!("dialog:{name}") == target, + Action::Command(id) | Action::CommandWith(id, _) | Action::Pending(id) | Action::Control(id) => *id == target, + Action::Dialog(name) | Action::DialogWith(name, _) => format!("dialog:{name}") == target, Action::Tab(_) => false, } } diff --git a/crates/ui-egui/src/keytips/keytip_table/generated.rs b/crates/ui-egui/src/keytips/keytip_table/generated.rs index 79920b5b8..5f750e770 100644 --- a/crates/ui-egui/src/keytips/keytip_table/generated.rs +++ b/crates/ui-egui/src/keytips/keytip_table/generated.rs @@ -37,40 +37,40 @@ pub const SEQUENCES: &[(&str, Entry)] = &[ ("HAN", Entry::Finish(Action::Command("home.accounting"))), // Excel: Accounting Format; fires immediately in Excel; deeper aliases dropped as unreachable ("HAS", Entry::Finish(Action::Command("home.addIns"))), // Excel: Add-ins; containment(Add-ins) ("HB", Entry::Continue), // Excel: Borders Menu; group level: - ("HBA", Entry::Finish(Action::Command("home.borders"))), // Excel: All Borders; containment(Borders) - ("HBB", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Double Border; border-side TBD - ("HBC", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Thick Bottom Border; border-side TBD - ("HBD", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Bottom Border; border-side TBD - ("HBE", Entry::Finish(Action::Command("home.borders"))), // Excel: Erase Border; border-side TBD - ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) - ("HBH", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Bottom Border; border-side TBD - ("HBI", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Color; Line Color (submenu TBD) - ("HBL", Entry::Finish(Action::Command("home.borders"))), // Excel: Left Border; border-side TBD - ("HBM", Entry::Finish(Action::Command("home.borders"))), // Excel: More Borders; containment(Borders) - ("HBN", Entry::Finish(Action::Command("home.borders"))), // Excel: No Border; border-side TBD - ("HBO", Entry::Finish(Action::Command("home.borders"))), // Excel: Bottom Border; border-side TBD - ("HBP", Entry::Finish(Action::Command("home.borders"))), // Excel: Top Border; border-side TBD - ("HBR", Entry::Finish(Action::Command("home.borders"))), // Excel: Right Border; border-side TBD - ("HBS", Entry::Finish(Action::Command("home.borders"))), // Excel: Outside Borders; containment(Borders) - ("HBT", Entry::Finish(Action::Command("home.borders"))), // Excel: Thick Outside Borders; border-side TBD - ("HBU", Entry::Finish(Action::Command("home.borders"))), // Excel: Top and Double Bottom Border; border-side TBD - ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border - ("HBY", Entry::Finish(Action::Command("home.borders"))), // Excel: Line Style; Line Style (submenu TBD) - ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable - ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns - ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells - ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows - ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet - ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All - ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents - ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats + ("HBA", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"all\"}"))), // Excel: All Borders; Borders menu item + ("HBB", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"doubleBottom\"}"))), // Excel: Bottom Double Border; Borders menu item + ("HBC", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topThickBottom\"}"))), // Excel: Top and Thick Bottom Border; Borders menu item + ("HBD", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topBottom\"}"))), // Excel: Top and Bottom Border; Borders menu item + ("HBE", Entry::Finish(Action::Pending("home.eraseBorder"))), // pending; Excel: Erase Border; pending: Excel arms an eraser pen; GridCraft has no border pen mode yet + ("HBG", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border Grid; containment(Draw Border) + ("HBH", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickBottom\"}"))), // Excel: Thick Bottom Border; Borders menu item + ("HBI", Entry::Finish(Action::Pending("home.borderLineColor"))), // pending; Excel: Line Color; pending: Line Color submenu + ("HBL", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"left\"}"))), // Excel: Left Border; Borders menu item + ("HBM", Entry::Finish(Action::DialogWith("formatCells", "{\"tab\":\"Border\"}"))), // Excel: More Borders; Format Cells on its Border tab + ("HBN", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"none\"}"))), // Excel: No Border; Borders menu item + ("HBO", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"bottom\"}"))), // Excel: Bottom Border; Borders menu item + ("HBP", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"top\"}"))), // Excel: Top Border; Borders menu item + ("HBR", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"right\"}"))), // Excel: Right Border; Borders menu item + ("HBS", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"outside\"}"))), // Excel: Outside Borders; Borders menu item + ("HBT", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"thickOutside\"}"))), // Excel: Thick Outside Borders; Borders menu item + ("HBU", Entry::Finish(Action::CommandWith("home.borders", "{\"preset\":\"topDoubleBottom\"}"))), // Excel: Top and Double Bottom Border; Borders menu item + ("HBW", Entry::Finish(Action::Command("home.drawBorder"))), // Excel: Draw Border + ("HBY", Entry::Finish(Action::Pending("home.borderLineStyle"))), // pending; Excel: Line Style; pending: Line Style submenu + ("HC", Entry::Finish(Action::Command("edit.copy"))), // Excel: Copy; fires immediately in Excel; deeper aliases dropped as unreachable + ("HDC", Entry::Finish(Action::Command("home.deleteColumns"))), // Excel: Delete Columns; Delete Columns + ("HDD", Entry::Finish(Action::Dialog("deleteCells"))), // Excel: Delete Cells + ("HDR", Entry::Finish(Action::Command("home.deleteRows"))), // Excel: Delete Rows; Delete Rows + ("HDS", Entry::Finish(Action::Command("home.deleteSheet"))), // Excel: Delete Sheet + ("HEA", Entry::Finish(Action::Command("edit.clearAll"))), // Excel: Clear All + ("HEC", Entry::Finish(Action::Command("edit.clearContents"))), // Excel: Clear Contents + ("HEF", Entry::Finish(Action::Command("edit.clearFormats"))), // Excel: Clear Formats ("HEL", Entry::Finish(Action::Command("edit.clearHyperlinks"))), // Excel: Clear Hyperlinks - ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) - ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette + ("HEM", Entry::Finish(Action::Command("edit.clearComments"))), // Excel: Clear Comments and Notes; containment(Clear Comments and Notes) + ("HFC", Entry::Finish(Action::Control("home.fontColor"))), // Excel: Font Color; opens the font-color palette ("HFDC", Entry::Finish(Action::Command("home.conditionalFormat"))), // Excel: Find Conditional Formatting; containment(Conditional Formatting) - ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find - ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To - ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) + ("HFDF", Entry::Finish(Action::Dialog("find"))), // Excel: Find + ("HFDG", Entry::Finish(Action::Dialog("goTo"))), // Excel: Go To + ("HFDM", Entry::Finish(Action::Command("edit.find"))), // Excel: Find Notes; containment(Find) ("HFDO", Entry::Finish(Action::Pending("home.selectObjects"))), // pending; Excel: Select Objects; Select Objects; reserved until the command lands ("HFDP", Entry::Finish(Action::Command("arrange.selectionPane"))), // Excel: Selection Pane ("HFDR", Entry::Finish(Action::Dialog("find"))), // Excel: Replace; opens Find and Replace diff --git a/crates/ui-egui/src/keytips/keytips.json b/crates/ui-egui/src/keytips/keytips.json index 59aeadcfb..b24241c3c 100644 --- a/crates/ui-egui/src/keytips/keytips.json +++ b/crates/ui-egui/src/keytips/keytips.json @@ -8,6 +8,7 @@ " \"continue\" (an explicit group level with no deeper sequences yet).", " - Intermediate levels need no entries: the matcher waits automatically when deeper sequences exist.", " - pending: true = the command does not exist yet; the sequence stays reserved and silent until it lands.", + " - params: an optional JSON object passed to a command or dialog, for menu items that share one id (the Borders presets: HBO {\"preset\": \"bottom\"}).", " - Keep entries grouped by tab and sorted by sequence within a group; the FIRST entry for a tab is its canonical badge (legacy aliases come after it)." ], "sequences": [ @@ -196,36 +197,49 @@ "seq": "HBA", "kind": "command", "target": "home.borders", + "params": { + "preset": "all" + }, "excel": "All Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBB", "kind": "command", "target": "home.borders", + "params": { + "preset": "doubleBottom" + }, "excel": "Bottom Double Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBC", "kind": "command", "target": "home.borders", + "params": { + "preset": "topThickBottom" + }, "excel": "Top and Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBD", "kind": "command", "target": "home.borders", + "params": { + "preset": "topBottom" + }, "excel": "Top and Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBE", "kind": "command", - "target": "home.borders", + "target": "home.eraseBorder", + "pending": true, "excel": "Erase Border", - "note": "border-side TBD" + "note": "pending: Excel arms an eraser pen; GridCraft has no border pen mode yet" }, { "seq": "HBG", @@ -238,78 +252,109 @@ "seq": "HBH", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickBottom" + }, "excel": "Thick Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBI", "kind": "command", - "target": "home.borders", + "target": "home.borderLineColor", + "pending": true, "excel": "Line Color", - "note": "Line Color (submenu TBD)" + "note": "pending: Line Color submenu" }, { "seq": "HBL", "kind": "command", "target": "home.borders", + "params": { + "preset": "left" + }, "excel": "Left Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBM", - "kind": "command", - "target": "home.borders", + "kind": "dialog", + "target": "formatCells", + "params": { + "tab": "Border" + }, "excel": "More Borders", - "note": "containment(Borders)" + "note": "Format Cells on its Border tab" }, { "seq": "HBN", "kind": "command", "target": "home.borders", + "params": { + "preset": "none" + }, "excel": "No Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBO", "kind": "command", "target": "home.borders", + "params": { + "preset": "bottom" + }, "excel": "Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBP", "kind": "command", "target": "home.borders", + "params": { + "preset": "top" + }, "excel": "Top Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBR", "kind": "command", "target": "home.borders", + "params": { + "preset": "right" + }, "excel": "Right Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBS", "kind": "command", "target": "home.borders", + "params": { + "preset": "outside" + }, "excel": "Outside Borders", - "note": "containment(Borders)" + "note": "Borders menu item" }, { "seq": "HBT", "kind": "command", "target": "home.borders", + "params": { + "preset": "thickOutside" + }, "excel": "Thick Outside Borders", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBU", "kind": "command", "target": "home.borders", + "params": { + "preset": "topDoubleBottom" + }, "excel": "Top and Double Bottom Border", - "note": "border-side TBD" + "note": "Borders menu item" }, { "seq": "HBW", @@ -321,9 +366,10 @@ { "seq": "HBY", "kind": "command", - "target": "home.borders", + "target": "home.borderLineStyle", + "pending": true, "excel": "Line Style", - "note": "Line Style (submenu TBD)" + "note": "pending: Line Style submenu" }, { "seq": "HC", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f96cdac3c..4e7de595a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1193,7 +1193,19 @@ impl SheetApp { keytips::Action::Command(id @ ("edit.copy" | "edit.cut")) => self.copy_to_clipboard(ctx, id), keytips::Action::Command("edit.paste") => self.paste_from_clipboard(), keytips::Action::Command(id) => self.run_or_alert(id, json!({})), + keytips::Action::CommandWith(id, params) => { + let params: Json = serde_json::from_str(params).unwrap_or_else(|_| json!({})); + // A Borders preset picked by key tip becomes the split button's face, as a + // click on the same menu item does. + if id == "home.borders" + && let Some(preset) = params.get("preset").and_then(Json::as_str) + { + self.grid.last_border = preset.to_string(); + } + self.run_or_alert(id, params); + } keytips::Action::Dialog(name) => self.open_dialog(name, json!({})), + keytips::Action::DialogWith(name, params) => self.open_dialog(name, serde_json::from_str(params).unwrap_or_else(|_| json!({}))), // Reserved sequence: fires silently while its command is missing. When the // command lands in the catalog it goes live with no table edit. keytips::Action::Pending(id) => { diff --git a/crates/ui-egui/tests/keytips.rs b/crates/ui-egui/tests/keytips.rs index 24be742a5..76ddd4467 100644 --- a/crates/ui-egui/tests/keytips.rs +++ b/crates/ui-egui/tests/keytips.rs @@ -550,3 +550,96 @@ fn every_sequence_terminates_without_panicking() { } assert_eq!(SEQUENCES.len(), SEQUENCES.len(), "every sequence ran"); } + +fn borders(h: &Harness, cell: &str) -> gridcraft_engine::model::Borders { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).borders +} + +/// [left, right, top, bottom] line styles of `cell`. +fn sides(h: &Harness, cell: &str) -> [gridcraft_engine::model::BorderStyle; 4] { + let b = borders(h, cell); + [b.left.style, b.right.style, b.top.style, b.bottom.style] +} + +fn selected(range: &str) -> Session { + let mut s = blank(); + s.execute("selection.set", json!({"range": range})).unwrap(); + s +} + +#[test] +fn border_keytips_apply_their_own_preset() { + use gridcraft_engine::model::BorderStyle::{Double, None as N, Thick, Thin}; + // Alt, H, B, letter on A1 alone: each Borders menu letter draws its own preset (they all drew + // a bare bottom border before). Letters observed in Excel's Borders menu. + for (k, want) in [ + (Key::O, [N, N, N, Thin]), + (Key::P, [N, N, Thin, N]), + (Key::L, [Thin, N, N, N]), + (Key::R, [N, Thin, N, N]), + (Key::A, [Thin, Thin, Thin, Thin]), + (Key::S, [Thin, Thin, Thin, Thin]), + (Key::T, [Thick, Thick, Thick, Thick]), + (Key::B, [N, N, N, Double]), + (Key::H, [N, N, N, Thick]), + (Key::D, [N, N, Thin, Thin]), + (Key::C, [N, N, Thin, Thick]), + (Key::U, [N, N, Thin, Double]), + ] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(h.state().message.is_none() && h.state().toast.is_none(), "H,B,{k:?} must run cleanly"); + assert_eq!(sides(&h, "A1"), want, "H,B,{k:?}"); + } +} + +#[test] +fn border_keytip_no_border_clears_a_bordered_cell() { + use gridcraft_engine::model::BorderStyle::None as N; + let mut s = selected("A1"); + s.execute("home.borders", json!({"range": "A1", "preset": "all"})).unwrap(); + let mut h = harness(s); + sequence(&mut h, &[Key::H, Key::B, Key::N], false); + assert_eq!(sides(&h, "A1"), [N, N, N, N], "H,B,N must clear every side, not add a bottom border"); + assert_eq!(h.state().grid.last_border, "none", "the picked preset becomes the Borders button's face"); +} + +#[test] +fn border_keytip_all_borders_covers_every_side_of_a_block() { + use gridcraft_engine::model::BorderStyle::Thin; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::A], false); + for cell in ["A1", "B1", "A2", "B2"] { + assert_eq!(sides(&h, cell), [Thin; 4], "H,B,A must border every side of {cell}"); + } +} + +#[test] +fn border_keytip_outside_borders_skips_the_inside_edges() { + use gridcraft_engine::model::BorderStyle::{None as N, Thin}; + let mut h = harness(selected("A1:B2")); + sequence(&mut h, &[Key::H, Key::B, Key::S], false); + assert_eq!(sides(&h, "A1"), [Thin, N, Thin, N]); + assert_eq!(sides(&h, "B2"), [N, Thin, N, Thin]); +} + +#[test] +fn border_keytip_more_borders_opens_format_cells_on_the_border_tab() { + let mut h = harness(blank()); + sequence(&mut h, &[Key::H, Key::B, Key::M], false); + let d = h.state().dialog.as_ref().expect("H,B,M opens Format Cells"); + assert_eq!(d.tab, "Border"); +} + +#[test] +fn border_keytips_without_a_command_stay_silent() { + // Erase Border (a pen mode), Line Color and Line Style are reserved: no border is drawn. + for k in [Key::E, Key::I, Key::Y] { + let mut h = harness(selected("A1")); + sequence(&mut h, &[Key::H, Key::B, k], false); + assert!(!h.state().session.journal.iter().any(|(id, _)| id.starts_with("home.")), "H,B,{k:?} must not run a command"); + assert_eq!(borders(&h, "A1"), Default::default(), "H,B,{k:?} must not draw a border"); + } +} diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/keytips_generated.rs index 653554857..dcf71cde1 100644 --- a/crates/ui-egui/tests/keytips_generated.rs +++ b/crates/ui-egui/tests/keytips_generated.rs @@ -308,7 +308,10 @@ fn keytip_hbd_() { fn keytip_hbe_() { let mut h = harness(blank()); sequence(&mut h, "HBE", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBE must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBE must not open a dialog"); + assert!(h.state().message.is_none(), "HBE must not open a message box"); + assert!(h.state().toast.is_none(), "HBE must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBE must not journal"); } #[test] @@ -329,7 +332,10 @@ fn keytip_hbh_() { fn keytip_hbi_() { let mut h = harness(blank()); sequence(&mut h, "HBI", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBI must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBI must not open a dialog"); + assert!(h.state().message.is_none(), "HBI must not open a message box"); + assert!(h.state().toast.is_none(), "HBI must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBI must not journal"); } #[test] @@ -343,7 +349,7 @@ fn keytip_hbl_() { fn keytip_hbm_() { let mut h = harness(blank()); sequence(&mut h, "HBM", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBM must journal home.borders"); + assert!(h.state().dialog.is_some(), "HBM must open the formatCells dialog"); } #[test] @@ -406,7 +412,10 @@ fn keytip_hbw_() { fn keytip_hby_() { let mut h = harness(blank()); sequence(&mut h, "HBY", false); - assert!(h.state().session.journal.iter().any(|(id, _)| id == "home.borders"), "HBY must journal home.borders"); + assert!(h.state().dialog.is_none(), "HBY must not open a dialog"); + assert!(h.state().message.is_none(), "HBY must not open a message box"); + assert!(h.state().toast.is_none(), "HBY must not raise a toast"); + assert!(h.state().session.journal.is_empty(), "HBY must not journal"); } #[test] diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 22e221e69..209c55d93 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-14 · **Change:** minor (Alt, H, B border keytips apply their own preset) · **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 @@ -77,7 +77,7 @@ this logic, with one undo step. | Ctrl+1, Ctrl+; and Ctrl+Shift+; , Ctrl+D/R, Ctrl+E, Ctrl+T/L, Ctrl+K, Ctrl+Shift+L, Ctrl+Shift+~!@#$%^&_ | yes | done | | F2, F4, F5, F7, F9 family, Shift+F2/F3/F10/F11, F12 | yes | done; F11 chart sheet, F8 extend mode, F3 Paste Name, F4 Repeat missing | | Mac-specific (Cmd variants, Control variants, Fn keys) | yes | done where a feature exists (`keymap.rs` picks the Mac variant) | -| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded | +| Sequential Alt access keys (Alt, H, N, …) on every ribbon tab | yes | 364-sequence keytip table for all tabs: per-control badges, prefix waiting, dropdowns open for editing (type/arrows/Enter), pending bindings for not-yet-built commands; QAT/Backstage excluded. Keytips can carry fixed params, so each Borders menu letter (Alt, H, B, O/P/L/R/N/A/S/T/B/H/D/C/U) draws its own preset and B, M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style are reserved | | Requests | — | #110 (more shortcuts and hot keys), #33 (sequential Alt; initial support landed in #43) | ### Shortcut audit (#110) @@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Alt, H, B border keytips apply the preset each letter names in Excel's Borders menu (they all drew a bottom border, so H,B,N No Border added one): keytips.json entries take optional `params`; H,B,M opens Format Cells on the Border tab; Erase Border, Line Color and Line Style stay reserved | | 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 | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 36847222f..11e7773aa 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -44,25 +44,35 @@ struct Entry { kind: String, target: String, pending: bool, + /// Fixed params as a compact JSON object (`params` in keytips.json), empty for none. + params: String, excel: String, note: String, } +impl Entry { + /// The probe key: a command and the params its keytip passes. + fn probe_key(&self) -> String { + if self.params.is_empty() { self.target.clone() } else { format!("{} {}", self.target, self.params) } + } +} + /// 0 = ran and journaled, 1 = ran without a journal step, 2 = errored (params -/// missing, disabled, or environment-dependent). +/// missing, disabled, or environment-dependent). Keyed by [`Entry::probe_key`]. type Probe = std::collections::HashMap; #[cfg(feature = "engine")] -fn probe_live_commands(ids: &[String]) -> Probe { +fn probe_live_commands(calls: &[(String, String)]) -> Probe { use gridcraft_engine::Session; let mut map = Probe::new(); - for id in ids { + for (id, params) in calls { if ENV_SENSITIVE.contains(&id.as_str()) { continue; } + let params = if params.is_empty() { serde_json::json!({}) } else { serde_json::from_str(params).unwrap_or_default() }; let mut s = Session::new(); s.new_workbook(); - let status = match s.execute(id, serde_json::json!({})) { + let status = match s.execute(id, params.clone()) { Ok(_) => { if s.journal.iter().any(|(jid, _)| jid == id) { 0 @@ -72,7 +82,8 @@ fn probe_live_commands(ids: &[String]) -> Probe { } Err(_) => 2, }; - map.insert(id.clone(), status); + let key = if params.as_object().is_some_and(|o| !o.is_empty()) { format!("{id} {params}") } else { id.clone() }; + map.insert(key, status); } map } @@ -132,6 +143,13 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { if pending && kind != "command" { return Err(format!("{at}: `pending` is only valid with kind `command`")); } + let params = match e.get("params") { + None => String::new(), + Some(v) if v.is_object() && matches!(kind.as_str(), "command" | "dialog") && !pending => { + serde_json::to_string(v).map_err(|e| format!("{at}: params: {e}"))? + } + Some(_) => return Err(format!("{at}: `params` must be a JSON object, on a non-pending command or dialog")), + }; match kind.as_str() { "tab" => { if target.is_empty() || !TABS.contains(&target.as_str()) { @@ -163,6 +181,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { kind, target, pending, + params, excel: e.get("excel").and_then(|v| v.as_str()).unwrap_or("").to_string(), note: e.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string(), }); @@ -170,10 +189,10 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { // JSON order is authoritative and curated: the first entry for a tab is its canonical // badge (legacy aliases come later), and groups stay together. Do not sort. - let live_ids: Vec = entries + let live_calls: Vec<(String, String)> = entries .iter() .filter(|e| e.kind == "command" && !e.pending) - .map(|e| e.target.clone()) + .map(|e| (e.target.clone(), e.params.clone())) .collect::>() .into_iter() .collect(); @@ -187,7 +206,7 @@ fn parse_and_probe(source: &str) -> Result<(Vec, Probe), String> { e.target.clone() }) .collect(); - let mut probe = probe_live_commands(&live_ids); + let mut probe = probe_live_commands(&live_calls); for id in landed { let mut s = gridcraft_engine::Session::new(); s.new_workbook(); @@ -219,7 +238,9 @@ fn render_table(entries: &[Entry]) -> String { let value = match e.kind.as_str() { "tab" => format!("Entry::Tab({:?})", e.target), "command" if e.pending => format!("Entry::Finish(Action::Pending({:?}))", e.target), + "command" if !e.params.is_empty() => format!("Entry::Finish(Action::CommandWith({:?}, {:?}))", e.target, e.params), "command" => format!("Entry::Finish(Action::Command({:?}))", e.target), + "dialog" if !e.params.is_empty() => format!("Entry::Finish(Action::DialogWith({:?}, {:?}))", e.target, e.params), "dialog" => format!("Entry::Finish(Action::Dialog({:?}))", e.target), "control" => format!("Entry::Finish(Action::Control({:?}))", e.target), "continue" => "Entry::Continue".to_string(), @@ -269,7 +290,7 @@ fn render_tests(entries: &[Entry], probe: &Probe) -> String { "sequence(&mut h, {seq:?}, false);\n assert!(h.state().dialog.is_none(), \"{seq} must not open a dialog\");\n assert!(h.state().message.is_none(), \"{seq} must not open a message box\");\n assert!(h.state().toast.is_none(), \"{seq} must not raise a toast\");\n assert!(h.state().session.journal.is_empty(), \"{seq} must not journal\");", seq = e.seq, ), - "command" => match probe.get(&e.target).copied().unwrap_or(2) { + "command" => match probe.get(&e.probe_key()).copied().unwrap_or(2) { 0 => format!( "sequence(&mut h, {seq:?}, false);\n assert!(h.state().session.journal.iter().any(|(id, _)| id == {id:?}), \"{seq} must journal {id}\");", seq = e.seq, From ab4d58ca6ffd65a8e9e71fe0470916343ea41133 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:26:06 -0500 Subject: [PATCH 003/149] Lookups compare numbers exactly, like Excel MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compared numbers at 15 significant digits (through compare / compare_numbers), so a computed key such as =0.1+0.2 (0.30000000000000004) found 0.3. Excel 365 compares lookup keys exactly in exact, approximate and binary search modes, while =, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF, MAXIFS) keep the 15-digit comparison. Observed in Excel via COM with synthetic values. lookup_equal now compares numbers with ==, and the lookup searches order values with lookup_order (exact for numbers, compare otherwise). compare_numbers and the criteria path are unchanged. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 143 +++++++++++++++++++++++++++++++ crates/functions/src/criteria.rs | 9 +- crates/functions/src/lookup.rs | 63 ++++++++++++-- docs/function-parity.md | 3 +- 5 files changed, 211 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..fba2c6a35 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-14 · **Change:** minor (lookups compare numbers exactly, like Excel) · **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-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison | | 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) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log: lookups compare numbers exactly | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..4a2eb701a 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,149 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +/// Lookups compare numbers exactly while `=` and the criteria functions compare them at 15 +/// significant digits. Every expectation was observed in Excel 365 with these fixtures +/// (`=0.1+0.2` is 0.30000000000000004, `=100+1E-14` is 100.00000000000001). +#[test] +fn lookups_compare_numbers_exactly() { + let mut t = T::new(); + for (at, input) in [ + ("A1", "=0.1+0.2"), + ("A2", "0.3"), + ("A3", "=100+1E-14"), + ("A4", "100"), + ("A5", "=1E15+0.5"), + ("A6", "1E15"), + // Ascending 0.1, 0.3+e, 0.5 and the same descending. + ("B1", "0.1"), + ("B2", "=0.1+0.2"), + ("B3", "0.5"), + ("C1", "0.5"), + ("C2", "=0.1+0.2"), + ("C3", "0.1"), + // 0.3 next to 0.3+e, ascending and descending. + ("D1", "0.3"), + ("D2", "=0.1+0.2"), + ("D3", "0.7"), + ("E1", "0.7"), + ("E2", "=0.1+0.2"), + ("E3", "0.3"), + ("G1", "=0.1+0.2"), + ("H1", "x"), + ("G2", "row2"), + // The bisection probes 0.3 first and must not run on into 0.3+e. + ("J1", "0.1"), + ("J2", "0.2"), + ("J3", "0.3"), + ("J4", "=0.1+0.2"), + ("J5", "0.4"), + ("K1", "a"), + ("K2", "b"), + ("K3", "c"), + ("K4", "d"), + ("K5", "e"), + ] { + t.set(at, input); + } + let e = 0.1 + 0.2; + let n = Value::Number; + let s = |x: &str| Value::from(x); + let na = Value::Error(CellError::NA); + let cases: Vec<(&str, Value)> = vec![ + // `=` and friends stay at 15 digits. + ("=A1=A2", Value::Bool(true)), + ("=A3=A4", Value::Bool(true)), + ("=A5=A6", Value::Bool(true)), + ("=SWITCH(A1,0.3,\"hit\",\"miss\")", s("hit")), + // Exact match. + ("=MATCH(A1,A2:A2,0)", na.clone()), + ("=MATCH(A2,A1:A1,0)", na.clone()), + ("=VLOOKUP(A1,A2:A2,1,FALSE)", na.clone()), + ("=HLOOKUP(A1,A2,1,FALSE)", na.clone()), + ("=HLOOKUP(0.3,G1:H2,2,FALSE)", na.clone()), + ("=XLOOKUP(A1,A2:A2,A2:A2,\"nf\")", s("nf")), + ("=XMATCH(A1,A2:A2)", na.clone()), + ("=MATCH(0.1+0.2,{0.3},0)", na.clone()), + ("=MATCH(0.3,A1:A2,0)", n(2.0)), + ("=MATCH(A1,A1:A2,0)", n(1.0)), + ("=MATCH(0.3,INDEX(A1:A2,0),0)", n(2.0)), + ("=MATCH(100,A3:A3,0)", na.clone()), + ("=MATCH(1E15,A5:A5,0)", na.clone()), + ("=XLOOKUP(0.3,A1:A2,{1;2},\"nf\",0,-1)", n(2.0)), + ("=XMATCH(A1,A1:A2,0,-1)", n(1.0)), + // Approximate match against a single entry. + ("=MATCH(0.3,A1:A1,1)", na.clone()), + ("=MATCH(0.3,A1:A1,-1)", n(1.0)), + ("=LOOKUP(0.3,A1:A1)", na.clone()), + ("=LOOKUP(0.3,A1:A1,G2)", na.clone()), + ("=VLOOKUP(0.3,A1:A1,1,TRUE)", na.clone()), + ("=HLOOKUP(0.3,G1:H2,2,TRUE)", na.clone()), + ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",-1)", s("nf")), + ("=XLOOKUP(0.3,A1:A1,A1:A1,\"nf\",1)", n(e)), + ("=XMATCH(0.3,A1:A1,-1)", na.clone()), + ("=XMATCH(0.3,A1:A1,1)", n(1.0)), + ("=MATCH(100,A3:A3,1)", na.clone()), + ("=MATCH(A3,A4:A4,1)", n(1.0)), + // Approximate match in sorted lists. + ("=MATCH(0.3,B1:B3,1)", n(1.0)), + ("=MATCH(A1,B1:B3,1)", n(2.0)), + ("=MATCH(0.3,C1:C3,-1)", n(2.0)), + ("=MATCH(0.3,D1:D3,1)", n(1.0)), + ("=MATCH(A1,D1:D3,1)", n(2.0)), + ("=MATCH(0.3,E1:E3,-1)", n(3.0)), + ("=MATCH(A1,E1:E3,-1)", n(2.0)), + ("=MATCH(0.3,J1:J5,1)", n(3.0)), + ("=MATCH(A1,J1:J5,1)", n(4.0)), + ("=MATCH(0.3,J1:J5,0)", n(3.0)), + ("=MATCH(A1,J1:J5,0)", n(4.0)), + ("=VLOOKUP(0.3,J1:K5,2,TRUE)", s("c")), + ("=VLOOKUP(A1,J1:K5,2,TRUE)", s("d")), + ("=LOOKUP(0.3,J1:J5,K1:K5)", s("c")), + ("=LOOKUP(A1,J1:J5,K1:K5)", s("d")), + // XLOOKUP / XMATCH match and search modes. + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",0,2)", s("nf")), + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",-1,2)", n(0.1)), + ("=XLOOKUP(0.3,B1:B3,B1:B3,\"nf\",1,2)", n(e)), + ("=XMATCH(0.3,B1:B3,-1,2)", n(1.0)), + ("=XMATCH(0.3,B1:B3,1,2)", n(2.0)), + ("=XMATCH(0.3,C1:C3,1,-2)", n(2.0)), + ("=XMATCH(0.3,C1:C3,-1,-2)", n(3.0)), + ("=XMATCH(0.3,C1:C3,0,-2)", na.clone()), + ("=XMATCH(0.3,J1:J5,0,2)", n(3.0)), + ("=XMATCH(A1,J1:J5,0,2)", n(4.0)), + ("=XMATCH(0.3,J1:J5,1,2)", n(3.0)), + ("=XMATCH(0.3,J1:J5,-1)", n(3.0)), + ("=XMATCH(0.3,J1:J5,1)", n(3.0)), + ("=XMATCH(A1,J1:J5,-1)", n(4.0)), + ("=XMATCH(0.3,B1:B3,1)", n(2.0)), + ("=XMATCH(0.3,B1:B3,-1)", n(1.0)), + ("=XMATCH(0.3,B1:B3,0,-1)", na.clone()), + ("=XMATCH(0.3,D1:D3,1,-1)", n(1.0)), + // The criteria functions keep 15 digits. + ("=COUNTIF(A1:A1,0.3)", n(1.0)), + ("=COUNTIF(A1:A1,\"<0.3\")", n(0.0)), + ("=COUNTIF(A1:A1,\">0.3\")", n(0.0)), + ("=COUNTIF(A2:A2,A1)", n(1.0)), + ("=COUNTIFS(A1:A1,0.3)", n(1.0)), + ("=SUMIF(A1:A1,0.3)", n(e)), + ("=SUMIFS(A1:A1,A1:A1,0.3)", n(e)), + ("=AVERAGEIF(A1:A1,0.3)", n(e)), + ("=MAXIFS(A1:A1,A1:A1,0.3)", n(e)), + ("=COUNTIF(A3:A3,\">100\")", n(0.0)), + ("=COUNTIF(J1:J5,0.3)", n(2.0)), + ("=ROWS(FILTER(A1:A2,A1:A2=0.3))", n(2.0)), + ("=MATCH(TRUE,A1:A2=0.3,0)", n(1.0)), + ]; + let mut wrong = Vec::new(); + for (formula, expected) in cases { + let got = crate::evaluate(&t.wb, 0, c("Z1"), formula); + if got != expected { + wrong.push(format!("{formula}: got {got:?}, Excel {expected:?}")); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/functions/src/criteria.rs b/crates/functions/src/criteria.rs index e326267ce..58f0e6594 100644 --- a/crates/functions/src/criteria.rs +++ b/crates/functions/src/criteria.rs @@ -314,7 +314,9 @@ pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option bool { match (needle, hay) { (Value::Text(p), Value::Text(t)) => { @@ -324,7 +326,7 @@ pub(crate) fn lookup_equal(needle: &Value, hay: &Value, wild: bool) -> bool { compare_text(p, t) == Ordering::Equal } } - (Value::Number(a), Value::Number(b)) => compare_numbers(*a, *b) == Ordering::Equal, + (Value::Number(a), Value::Number(b)) => a == b, (Value::Bool(a), Value::Bool(b)) => a == b, (Value::Error(a), Value::Error(b)) => a == b, (Value::Empty, Value::Empty) => true, @@ -370,7 +372,8 @@ mod tests { assert!(m("#N/A", Value::Error(CellError::NA))); assert!(Criterion::parse(&Value::Number(3.0)).matches(&Value::Number(3.0))); assert!(Criterion::parse(&Value::Number(0.3)).matches(&Value::Number(0.1 + 0.2))); - assert!(lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(!lookup_equal(&Value::Number(0.3), &Value::Number(0.1 + 0.2), false)); + assert!(lookup_equal(&Value::Number(0.0), &Value::Number(-0.0), false)); assert!(m("<>a*", Value::from("bcd"))); assert!(m(">1/1/2020", Value::Number(44000.0))); // Text patterns never match blank cells, but an empty string is text. diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index 56739c933..89ea86ad7 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -23,6 +23,19 @@ fn needle(args: &[Arg], i: usize) -> R { } } +/// Order of two values for the approximate and binary searches: [`compare`], except that numbers +/// compare exactly. Excel's `=` treats numbers equal to 15 significant digits as equal, but its +/// lookups don't. Observed in Excel 365 with A1 holding +/// `=0.1+0.2` (0.30000000000000004): `A1=0.3` is TRUE, yet `MATCH(0.3,A1,1)` is #N/A because +/// A1 is larger, and in {0.1;0.2;0.3;A1;0.4} `MATCH(0.3,…,1)` is 3 and `MATCH(A1,…,1)` is 4. +/// The same holds for `=100+1E-14` against 100, and in every XLOOKUP / XMATCH mode. +fn lookup_order(a: &Value, b: &Value) -> Ordering { + match (a, b) { + (Value::Number(x), Value::Number(y)) => x.partial_cmp(y).unwrap_or(Ordering::Equal), + _ => compare(a, b), + } +} + fn same_type(a: &Value, b: &Value) -> bool { matches!((a, b), (Value::Number(_), Value::Number(_)) | (Value::Text(_), Value::Text(_)) | (Value::Bool(_), Value::Bool(_))) } @@ -59,10 +72,10 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { continue; }; let v = hay.get(j)?; - let o = compare(v, x); + let o = lookup_order(v, x); if o == Ordering::Equal { let mut k = j; - while hay.get(k + 1).is_some_and(|w| same_type(x, w) && compare(w, x) == Ordering::Equal) { + while hay.get(k + 1).is_some_and(|w| same_type(x, w) && lookup_order(w, x) == Ordering::Equal) { k += 1; } return Some(k); @@ -88,8 +101,8 @@ fn bsearch_last(x: &Value, hay: &[Value], desc: bool) -> Option { fn binary_order(v: &Value, x: &Value, desc: bool) -> Ordering { match v { Value::Empty => Ordering::Greater, - _ if desc => compare(v, x).reverse(), - _ => compare(v, x), + _ if desc => lookup_order(v, x).reverse(), + _ => lookup_order(v, x), } } @@ -261,7 +274,7 @@ fn xsearch(x: &Value, hay: &[Value], match_mode: i64, search_mode: i64) -> R R true, Some(bv) => { - let c = compare(v, bv); + let c = lookup_order(v, bv); if match_mode == -1 { c == Ordering::Greater } else { c == Ordering::Less } } }; @@ -747,6 +760,44 @@ mod tests { assert_eq!(rows_of(&r), vec![vec![nv(3.0), nv(1.0)]]); } + /// Lookups compare numbers exactly, unlike `=` (15 significant digits). Observed in Excel 365. + #[test] + fn lookups_compare_numbers_exactly() { + let e = 0.1 + 0.2; + let one = || rf(col(&[e])); + // Exact modes miss a key one bit away. + is_err(ev("MATCH", vec![n(0.3), one(), n(0.0)]), CellError::NA); + is_err(ev("VLOOKUP", vec![n(0.3), one(), n(1.0), b(false)]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one()]), CellError::NA); + close(ev("MATCH", vec![n(0.3), rf(col(&[e, 0.3])), n(0.0)]), 2.0); + close(ev("MATCH", vec![n(e), rf(col(&[e, 0.3])), n(0.0)]), 1.0); + is_err(ev("MATCH", vec![n(100.0), rf(col(&[100.0 + 1e-14])), n(0.0)]), CellError::NA); + // 0 and -0 are still the same key. + close(ev("MATCH", vec![n(-0.0), rf(col(&[0.0])), n(0.0)]), 1.0); + // Approximate modes: 0.3 sorts below 0.30000000000000004. + is_err(ev("MATCH", vec![n(0.3), one(), n(1.0)]), CellError::NA); + close(ev("MATCH", vec![n(0.3), one(), n(-1.0)]), 1.0); + is_err(ev("LOOKUP", vec![n(0.3), one()]), CellError::NA); + is_err(ev("XMATCH", vec![n(0.3), one(), n(-1.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), one(), n(1.0)]), 1.0); + // The bisection stops on 0.3 and doesn't run on into the next entry. + let list = || rf(col(&[0.1, 0.2, 0.3, e, 0.4])); + close(ev("MATCH", vec![n(0.3), list(), n(1.0)]), 3.0); + close(ev("MATCH", vec![n(e), list(), n(1.0)]), 4.0); + close(ev("XMATCH", vec![n(0.3), list(), n(0.0), n(2.0)]), 3.0); + close(ev("XMATCH", vec![n(e), list(), n(0.0), n(2.0)]), 4.0); + let asc = || rf(col(&[0.1, e, 0.5])); + let desc = || rf(col(&[0.5, e, 0.1])); + close(ev("MATCH", vec![n(0.3), asc(), n(1.0)]), 1.0); + close(ev("MATCH", vec![n(0.3), desc(), n(-1.0)]), 2.0); + is_err(ev("XMATCH", vec![n(0.3), asc(), n(0.0), n(2.0)]), CellError::NA); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0), n(2.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0), n(2.0)]), 2.0); + close(ev("XMATCH", vec![n(0.3), desc(), n(-1.0), n(-2.0)]), 3.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(-1.0)]), 1.0); + close(ev("XMATCH", vec![n(0.3), asc(), n(1.0)]), 2.0); + } + #[test] fn index() { let t3 = table_rows3(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..6dad80804 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-14 · **Change:** minor (lookups compare numbers exactly, like Excel 365) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP and LOOKUP compare numbers exactly in every match and search mode, as Excel 365 does: `MATCH(0.1+0.2,{0.3},0)` is `#N/A` and 0.3 sorts below 0.30000000000000004 in approximate and binary search. `=`, SWITCH and the criteria functions (COUNTIF(S), SUMIF(S), AVERAGEIF(S), MAXIFS…) keep comparing at 15 significant digits, also as observed | | 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 | From 5c20f164342f0b443fb7e6897f52f6e0b3934163 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 18:30:16 -0500 Subject: [PATCH 004/149] core: read accounting negatives like $(1,234) in number text Excel 365 reads a dollar sign before accounting parentheses ($(1,234), $ (5)) and a percent sign after them ((5)%) as negative numbers when the text is typed, coerced by arithmetic or passed to VALUE. GridCraft gave #VALUE! for these. The parentheses branch no longer recurses: Excel reads only an unsigned plain number or fraction inside, so (-5), ((5)), ($-5), and dates or times in parentheses are now #VALUE! as in Excel (they used to give 5 or a negative serial). $5% is #VALUE! too: Excel never combines the currency and percent signs. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 15 +++++ crates/core/src/parse.rs | 115 ++++++++++++++++++++++++++++++++++- crates/functions/src/text.rs | 7 +++ docs/function-parity.md | 3 +- 5 files changed, 139 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..610011d87 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-14 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers) · **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-14 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-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) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log: accounting negatives in number text | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..1d5a1a464 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -310,6 +310,21 @@ fn date_text_coerces_in_the_workbook_date_system() { } } +#[test] +fn accounting_negative_text_coerces() { + let mut t = T::new(); + t.set("A1", "'$(1,234)"); + t.set("B1", "=A1+0"); + t.set("C1", "=VALUE(A1)"); + t.set("D1", "=--\"$ (5)\""); + t.set("E1", "=\"(5)%\"*100"); + t.set("F1", "=\"(-5)\"+0"); + t.set("G1", "$(1,234)"); + assert_eq!((t.num("B1"), t.num("C1"), t.num("D1"), t.num("E1")), (-1234.0, -1234.0, -5.0, -5.0)); + assert_eq!(t.get("F1"), Value::Error(CellError::Value)); + assert_eq!(t.num("G1"), -1234.0); +} + #[test] fn operators() { let mut t = T::new(); diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b465beaf9..bf8487622 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -54,8 +54,13 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) return None; } // Parenthesised negatives: (100) = -100. - if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) { - return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f)); + if let Some(inner) = parenthesized(t) { + // Excel reads no sign and no nested parentheses inside: `(-5)`, `($-5)`, `((5))`. + if inner.strip_prefix('$').map_or(inner.as_str(), str::trim_start).starts_with(['-', '+', '(']) { + return None; + } + let r = parse_plain(&inner).or_else(|| parse_fraction(&inner).map(|n| (n, Some("# ?/?")))); + return r.map(|(n, f)| (-n, f)); } if let Some(r) = parse_plain(t) { return Some(r); @@ -69,6 +74,22 @@ fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>) None } +/// The number inside accounting parentheses, as plain text: `(5)` gives `5`. A dollar sign may +/// stand before them (`$(5)` reads as `($5)`) and a percent sign after them (`(5)%` reads as +/// `(5%)`), with spaces around either. The caller reads only a plain number or a fraction from it. +fn parenthesized(t: &str) -> Option { + let (currency, rest) = match t.strip_prefix('$') { + Some(r) => ("$", r.trim_start()), + None => ("", t), + }; + let rest = rest.strip_prefix('(')?; + let (body, percent) = match rest.strip_suffix(')') { + Some(b) => (b, ""), + None => (rest.strip_suffix('%')?.trim_end().strip_suffix(')')?, "%"), + }; + Some(format!("{currency}{}{percent}", body.trim())) +} + /// Signs, currency, thousands separators, percent, exponent. fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { let mut s = t; @@ -93,7 +114,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { percent = true; s = r.trim_end(); } - if s.is_empty() { + // Excel reads `$5` and `5%` but not `$5%`. + if s.is_empty() || currency && percent { return None; } let has_sep = s.contains(','); @@ -352,6 +374,93 @@ mod tests { assert_eq!(p(".5").value, Value::Number(0.5)); } + /// Observed in Excel 365 (en-US): typed entry, VALUE and `--"text"` agree. + #[test] + fn accounting_parentheses() { + let accepted: [(&str, f64, Option<&str>); 22] = [ + ("$(5)", -5.0, Some("\"$\"#,##0")), + ("$ (5)", -5.0, Some("\"$\"#,##0")), + ("$( 5 )", -5.0, Some("\"$\"#,##0")), + ("$ ( 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("$(1,234)", -1234.0, Some("\"$\"#,##0")), + ("$(1,000.50)", -1000.5, Some("\"$\"#,##0.00")), + ("$(1,234.)", -1234.0, Some("\"$\"#,##0.00")), + ("$(.5)", -0.5, Some("\"$\"#,##0.00")), + ("$(0)", 0.0, Some("\"$\"#,##0")), + (" $(5) ", -5.0, Some("\"$\"#,##0")), + ("($5)", -5.0, Some("\"$\"#,##0")), + ("( $ 1,234 )", -1234.0, Some("\"$\"#,##0")), + ("(5)", -5.0, None), + ("( 5 )", -5.0, None), + ("(1,234.5)", -1234.5, Some("#,##0.00")), + ("(1e-3)", -0.001, Some("0.00E+00")), + ("(5)%", -0.05, Some("0%")), + ("( 5 ) %", -0.05, Some("0%")), + ("(5%)", -0.05, Some("0%")), + ("(5.)%", -0.05, Some("0.00%")), + ("(1,234)%", -12.34, Some("0%")), + ("(1 1/2)", -1.5, Some("# ?/?")), + ]; + for (s, n, f) in accepted { + assert_eq!(p(s), Parsed { value: Value::Number(n), format: f }, "{s}"); + assert_eq!(parse_number_text(s), Some(n), "{s}"); + } + for s in [ + "$5%", + "$(5%)", + "$(5)%", + "($5%)", + "($5)%", + "$ ( 5 ) %", + "(5)%%", + "(5%)%", + "((5))", + "(-5)", + "(+5)", + "($-5)", + "($ -5)", + "($+5)", + "(-$5)", + "$(-5)", + "$ (-5)", + "$(+5)", + "-(5)", + "-$(5)", + "+$(5)", + "$+(5)", + "$$(5)", + "$ $(5)", + "$((5))", + "($(5))", + "(-1 1/2)", + "()", + "$()", + "(5)$", + "$(5)$", + "(5)(5)", + "$(5)0", + "(5)e3", + "(5)-", + "(5-)", + "$(5-)", + "$(5", + "$5)", + "(5", + "$(1,23)", + "$(,5)", + "(5e)", + "(1/2)", + "$(1/2)", + "(3/4/2026)", + "$(3/4/2026)", + "(10:30)", + "$(10:30)", + ] { + assert_eq!(p(s).value, Value::text(s), "{s}"); + assert_eq!(parse_number_text(s), None, "{s}"); + } + } + #[test] fn texts_and_specials() { assert_eq!(p("hello").value, Value::text("hello")); diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 91962d6e7..84fad9898 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -1092,6 +1092,13 @@ mod tests { close(ev("VALUE", vec![t("12:00")]), 0.5); close(ev("VALUE", vec![t("2020-01-01")]), 43831.0); is_err(ev("VALUE", vec![t("abc")]), CellError::Value); + // Accounting negatives, as Excel 365 reads them. + close(ev("VALUE", vec![t("$(5)")]), -5.0); + close(ev("VALUE", vec![t("$ (1,000.50)")]), -1000.5); + close(ev("VALUE", vec![t("(5)%")]), -0.05); + for s in ["$(5)%", "$(5%)", "$5%", "(-5)", "((5))", "$(-5)", "(1/2)"] { + is_err(ev("VALUE", vec![t(s)]), CellError::Value); + } close(ev("NUMBERVALUE", vec![t("2.500,27"), t(","), t(".")]), 2500.27); close(ev("NUMBERVALUE", vec![t("3.5%")]), 0.035); close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..3b25b144f 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-14 · **Change:** minor (number text with accounting parentheses coerces like Excel 365) · **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). @@ -108,6 +108,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Number text coercion (VALUE, arithmetic, typed entry): a dollar sign before accounting parentheses (`$(1,234)`, `$ (5)`) and a percent sign after them (`(5)%`) read as negatives; `(-5)`, `((5))`, `($-5)`, `$5%`, `$(5)%` and dates or times in parentheses are `#VALUE!`, as observed in Excel 365 | | 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 | From ea190adad6a1edb792521a7818ef426570a8229c Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Wed, 14 Oct 2026 19:55:46 -0500 Subject: [PATCH 005/149] functions: Analysis ToolPak value arguments reject multi-cell references EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the coupon, bond and T-bill functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give #VALUE! in Excel 365 for a reference to more than one cell in a value argument, in dynamic-array and legacy formulas alike, where GridCraft lifted the range and spilled. Array constants and computed arrays still lift, and range arguments (holidays, cash flows, schedules) and the .INTL weekend still take ranges. FnSpec gains a value_args mask, filled at registration from one table in lift.rs; call_lifted turns a multi-cell reference at a value position into a scalar #VALUE! before lifting, so EOMONTH(A1:A3,{1;2}) is a 2x1 array of #VALUE! as in Excel. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/calc/src/tests.rs | 85 +++++++++++++++++++++++++++++++++++ crates/functions/src/lib.rs | 18 ++++++++ crates/functions/src/lift.rs | 87 ++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 4 +- 5 files changed, 196 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..2abac7262 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-14 · **Change:** minor (Analysis ToolPak functions reject multi-cell references in value arguments) · **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-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift | | 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) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..a9317528b 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1266,3 +1266,88 @@ fn offset_extreme_arguments_return_ref() { assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}"); } } + +#[test] +fn analysis_toolpak_value_params_reject_multi_cell_references() { + // Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more + // than one cell, however the reference is formed, and lift array constants and computed arrays. + let mut t = T::new(); + for (a, v) in [ + ("A1", "45000"), + ("A2", "45031"), + ("A3", "45061"), + ("B1", "1"), + ("B2", "2"), + ("B3", "3"), + ("C2", "7"), + ("F1", "1"), + ("F2", "2"), + ("F3", "11"), + ] { + t.set(a, v); + } + t.wb.names.push(gridcraft_model::DefinedName { + name: "Dates".into(), + scope: None, + formula: "Sheet1!$A$1:$A$3".into(), + comment: String::new(), + hidden: false, + }); + let value = Value::Error(CellError::Value); + for f in [ + "=EOMONTH(A1:A3,1)", + "=EOMONTH(A1,B1:B3)", + "=EDATE(A1:A3,1)", + "=SUMPRODUCT(EOMONTH(A1:A3,0))", + "=EOMONTH(INDEX(A1:A3,0,1),0)", + "=EOMONTH(OFFSET(A1,0,0,3,1),0)", + "=EOMONTH(IF(TRUE,A1:A3),0)", + "=EOMONTH(CHOOSE(1,A1:A3),0)", + "=LET(x,A1:A3,EOMONTH(x,0))", + "=EOMONTH(Dates,0)", + "=EOMONTH((A1,A2),0)", + "=EOMONTH(Z:Z,0)", + "=EOMONTH(A1:INDEX(A1:A3,3),0)", + "=WORKDAY(A1:A3,1)", + "=NETWORKDAYS(A1,A3:A4)", + "=YEARFRAC(A1,A3,B1:B2)", + "=WEEKNUM(A1,B1:B3)", + "=COUPDAYS(A1:A3,46000,2,0)", + "=DISC(A1,46000,98,100,B1:B3)", + "=BIN2DEC(B1:B3)", + "=ISEVEN(B1:B3)", + ] { + t.set("H5", f); + assert_eq!((t.get("H5"), t.get("H6")), (value.clone(), Value::Empty), "{f}"); + t.set("H5", ""); + } + // One-cell references, intersections, @ and the range parameters are fine. + for (f, want) in [ + ("=EOMONTH(A1:A1,0)", 45016.0), + ("=EOMONTH(INDEX(A1:A3,2),0)", 45046.0), + ("=EOMONTH(OFFSET(A1,0,0,1,1),0)", 45016.0), + ("=EOMONTH(A1:A3 A2:C2,0)", 45046.0), + ("=EOMONTH(@A1:A3,0)", 45046.0), + ("=WORKDAY(A1,1,A1:A3)", 45001.0), + ("=XNPV(0,B1:B3,A1:A3)", 6.0), + ] { + t.set("H2", f); + assert_eq!(t.num("H2"), want, "{f}"); + t.set("H2", ""); + } + // Arrays that aren't references lift; so does the WORKDAY.INTL/NETWORKDAYS.INTL weekend. + for (f, below, right) in [ + ("=EOMONTH(A1:A3+0,0)", Value::Number(45046.0), Value::Empty), + ("=EOMONTH(A1,{1,2})", Value::Empty, Value::Number(45077.0)), + ("=EOMONTH(TRANSPOSE(A1:A3),0)", Value::Empty, Value::Number(45046.0)), + ("=MAP(A1:A3,LAMBDA(x,EOMONTH(x,0)))", Value::Number(45046.0), Value::Empty), + ("=NETWORKDAYS.INTL(A1,A3+30,F1:F3)", Value::Number(66.0), Value::Empty), + ("=ISOWEEKNUM(A1:A3)", Value::Number(15.0), Value::Empty), + // The rejected reference adds no shape: a 2x1 array of #VALUE!. + ("=EOMONTH(A1:A3,{1;2})", value.clone(), Value::Empty), + ] { + t.set("H5", f); + assert_eq!((t.get("H6"), t.get("I5")), (below, right), "{f}"); + t.set("H5", ""); + } +} diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 148d68221..5f9dcb67d 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -30,6 +30,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: true, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -48,6 +49,7 @@ macro_rules! f { category: $crate::Category::$cat, volatile: false, scalar_args: $scal, + value_args: &[], signature: $sig, description: $desc, imp: { @@ -147,6 +149,11 @@ pub struct FnSpec { /// (broadcast like Excel's dynamic arrays). The last entry repeats for variadic tails; an /// empty slice means no lifting. pub scalar_args: &'static [bool], + /// Parameter positions that take a single value and reject a reference to more than one + /// cell with `#VALUE!` (the Analysis ToolPak functions: EOMONTH, WORKDAY, COUPDAYS, BIN2DEC…), + /// where other scalar parameters lift the range. Arrays that aren't references still lift. + /// Same layout as `scalar_args`; filled at registration from [`lift::value_args`]. + pub value_args: &'static [bool], pub signature: &'static str, pub description: &'static str, pub imp: FnImpl, @@ -167,6 +174,14 @@ impl FnSpec { } } + /// Whether parameter `i` takes a single value, so a multi-cell reference there is `#VALUE!`. + pub fn is_value_arg(&self, i: usize) -> bool { + match self.value_args.get(i) { + Some(b) => *b, + None => self.value_args.last().copied().unwrap_or(false), + } + } + /// Whether a call with `n` arguments is valid (Excel allows at most 255). pub fn accepts(&self, n: usize) -> bool { (self.min_args..=self.max_args).contains(&n) && n <= 255 @@ -195,6 +210,9 @@ fn registry() -> &'static (Vec, HashMap<&'static str, usize>) { ] { v.extend(list); } + for s in &mut v { + s.value_args = lift::value_args(s.name); + } let mut map = HashMap::with_capacity(v.len()); for (i, s) in v.iter().enumerate() { map.entry(s.name).or_insert(i); diff --git a/crates/functions/src/lift.rs b/crates/functions/src/lift.rs index cea0067ee..056cb6d31 100644 --- a/crates/functions/src/lift.rs +++ b/crates/functions/src/lift.rs @@ -6,7 +6,61 @@ use gridcraft_core::{Array, CellError, Value}; use crate::util::MAX_CELLS; use crate::{Arg, Ctx, FnSpec}; +/// Value-parameter masks (see [`FnSpec::value_args`]), observed in Excel 365: a reference to more +/// than one cell at these positions gives `#VALUE!` in dynamic-array and legacy formulas alike (no +/// implicit intersection), while array constants and computed arrays lift. Range parameters +/// (holidays, schedules, cash flows, coefficients) and the WORKDAY.INTL/NETWORKDAYS.INTL weekend +/// (which lifts a range) are `false`. +pub(crate) const VALUE_ARGS: &[(&str, &[bool])] = &[ + // Date and time. + ("EOMONTH EDATE YEARFRAC WEEKNUM", &[true]), + ("WORKDAY NETWORKDAYS", &[true, true, false]), + ("WORKDAY.INTL NETWORKDAYS.INTL", &[true, true, false, false]), + // Financial. + ( + "ACCRINT ACCRINTM AMORDEGRC AMORLINC COUPDAYBS COUPDAYS COUPDAYSNC COUPNCD COUPNUM COUPPCD CUMIPMT CUMPRINC DISC \ + DOLLARDE DOLLARFR DURATION EFFECT INTRATE MDURATION NOMINAL ODDFPRICE ODDFYIELD ODDLPRICE ODDLYIELD PRICE PRICEDISC \ + PRICEMAT RECEIVED TBILLEQ TBILLPRICE TBILLYIELD YIELD YIELDDISC YIELDMAT", + &[true], + ), + ("FVSCHEDULE XNPV", &[true, false]), + ("XIRR", &[false, false, true]), + // Math and information. + ("ISEVEN ISODD QUOTIENT MROUND FACTDOUBLE SQRTPI RANDBETWEEN", &[true]), + ("SERIESSUM", &[true, true, true, false]), + // Engineering (IMSUM and IMPRODUCT aggregate, so they aren't here). + ( + "BIN2DEC BIN2HEX BIN2OCT DEC2BIN DEC2HEX DEC2OCT HEX2BIN HEX2DEC HEX2OCT OCT2BIN OCT2DEC OCT2HEX CONVERT DELTA GESTEP \ + ERF ERF.PRECISE ERFC ERFC.PRECISE BESSELI BESSELJ BESSELK BESSELY COMPLEX IMABS IMAGINARY IMARGUMENT IMCONJUGATE \ + IMCOS IMCOSH IMCOT IMCSC IMCSCH IMDIV IMEXP IMLN IMLOG10 IMLOG2 IMPOWER IMREAL IMSEC IMSECH IMSIN IMSINH IMSQRT IMSUB \ + IMTAN", + &[true], + ), +]; + +/// The value-parameter mask of `name` (empty for functions without value parameters). +pub(crate) fn value_args(name: &str) -> &'static [bool] { + VALUE_ARGS.iter().find(|(names, _)| names.split_ascii_whitespace().any(|n| n == name)).map_or(&[], |(_, mask)| mask) +} + +/// A reference to more than one cell at a value parameter becomes a scalar `#VALUE!`, so it adds +/// no shape: `EOMONTH(A1:A3, {1;2})` is a 2x1 array of `#VALUE!`, as in Excel. +fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option> { + let multi = |i: usize, a: &Arg| a.from_ref && spec.is_value_arg(i) && matches!(&a.value, Value::Array(arr) if arr.data.len() > 1); + if !args.iter().enumerate().any(|(i, a)| multi(i, a)) { + return None; + } + Some( + args.iter() + .enumerate() + .map(|(i, a)| if multi(i, a) { Arg { value: Value::Error(CellError::Value), from_ref: false } } else { a.clone() }) + .collect(), + ) +} + pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value { + let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) }; + let args = rejected.as_deref().unwrap_or(args); let mut rows = 0usize; let mut cols = 0usize; let mut any = false; @@ -94,4 +148,37 @@ mod tests { ); is_err(huge, CellError::Num); } + + #[test] + fn value_params_reject_multi_cell_references() { + let s = FnSpec { value_args: &[true, false], ..spec() }; + let mut ctx = TestCtx::default(); + // A multi-cell reference is #VALUE!; a one-cell reference and a computed array are fine. + is_err(crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), n(1.0)], &mut ctx), CellError::Value); + assert_eq!(crate::call(&s, &[rf(col(&[4.0])), n(1.0)], &mut ctx), nv(5.0)); + assert_eq!(rows_of(&crate::call(&s, &[av(col(&[1.0, 2.0])), n(1.0)], &mut ctx)), vec![vec![nv(2.0)], vec![nv(3.0)]]); + // The rejected reference adds no shape: the other argument still lifts. + let v = Value::Error(CellError::Value); + assert_eq!(rows_of(&crate::call(&s, &[rf(col(&[1.0, 2.0, 3.0])), av(col(&[1.0, 2.0]))], &mut ctx)), vec![vec![v.clone()], vec![v]]); + // A position that isn't a value parameter still lifts the reference. + assert_eq!(rows_of(&crate::call(&s, &[n(1.0), rf(row(&[1.0, 2.0]))], &mut ctx)), vec![vec![nv(2.0), nv(3.0)]]); + } + + #[test] + fn value_arg_table_names_registered_scalar_positions() { + for (names, mask) in super::VALUE_ARGS { + assert!(!mask.is_empty()); + for name in names.split_ascii_whitespace() { + let Some(s) = crate::lookup(name) else { panic!("{name} isn't registered") }; + assert_eq!(s.value_args, *mask, "{name}"); + for i in 0..s.max_args.min(12) { + assert!(!s.is_value_arg(i) || s.is_scalar_arg(i), "{name} argument {i} is a value parameter but doesn't lift"); + } + } + } + assert!(crate::lookup("WORKDAY").is_some_and(|s| s.is_value_arg(1) && !s.is_value_arg(2))); + assert!(crate::lookup("WORKDAY.INTL").is_some_and(|s| !s.is_value_arg(2) && s.is_scalar_arg(2))); + assert!(crate::lookup("SUM").is_some_and(|s| s.value_args.is_empty())); + assert!(crate::lookup("ISOWEEKNUM").is_some_and(|s| s.value_args.is_empty())); + } } diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..91283721d 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-14 · **Change:** minor (Analysis ToolPak functions give #VALUE! for a multi-cell reference in a value argument, like Excel 365) · **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). @@ -77,6 +77,7 @@ PIVOTBY, REGEXTEST…) are all present. | Defined names (workbook and sheet scope), Create from Selection | done | | Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) | | Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) | +| Array lifting: an array in a scalar argument evaluates the function per element | done. The Analysis ToolPak functions (EOMONTH, EDATE, WORKDAY(.INTL), NETWORKDAYS(.INTL), YEARFRAC, WEEKNUM, the COUP*, PRICE*, YIELD*, ODD*, TBILL*, ACCRINT*, AMOR*, DISC, DURATION, CUMIPMT/CUMPRINC, EFFECT, NOMINAL, DOLLARDE/FR, XNPV/XIRR rate and guess, FVSCHEDULE principal, ISEVEN/ISODD, QUOTIENT, MROUND, FACTDOUBLE, SQRTPI, RANDBETWEEN, SERIESSUM, the base conversions, CONVERT, DELTA, GESTEP, ERF/ERFC, BESSEL*, COMPLEX and IM* except IMSUM/IMPRODUCT) give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does in both dynamic-array and legacy formulas; array constants and computed arrays (`A1:A3+0`) still lift, and range arguments (holidays, schedule, cash flows) and the .INTL weekend accept ranges. Difference: BYROW/BYCOL pass their row or column to the LAMBDA as values, so `BYROW(A1:B3,LAMBDA(r,EOMONTH(r,0)))` lifts where Excel gives `#VALUE!` | | 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 | @@ -108,6 +109,7 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Analysis ToolPak value arguments (EOMONTH, WORKDAY, YEARFRAC, COUPDAYS, DISC, BIN2DEC, IM* and the rest, observed in Excel 365) give `#VALUE!` for a multi-cell reference instead of lifting it | | 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 | From 5bd6cc3d04701b1f70442f2a2eaae5d285a0f3e8 Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 00:57:17 -0500 Subject: [PATCH 006/149] 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 6db94d154a14697cb8a0d3b85c77f8a5c4c33efb Mon Sep 17 00:00:00 2001 From: Harrison Booth Date: Thu, 15 Oct 2026 05:19:32 -0500 Subject: [PATCH 007/149] Calc: INDEX with one index on a 2-D reference is #REF!, as in Excel INDEX(A1:C3,2) returned the whole second row. Excel gives #REF! when a reference has several rows and columns and the column argument is omitted, so the error also carries into ranges built from it (A1:INDEX(A1:C3,2)), position arrays (INDEX(A1:C3,{1;2})) and outer functions. An empty column argument (INDEX(A1:C3,2,)) still gives the row, and arrays are unchanged: INDEX({1,2;3,4},2) is the row. Observed in Excel that a column array with one index, INDEX({10;20;30},2), is a 1x1 array (TYPE 64), which is what calc already returned; the new test pins it. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++- crates/calc/src/eval.rs | 8 ++++- crates/calc/src/tests.rs | 75 ++++++++++++++++++++++++++++++++++++++++ docs/function-parity.md | 3 +- 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 070552d07..3292b595f 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 (INDEX with one index on a 2-D reference is #REF! as in Excel) · **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 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-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) | @@ -193,6 +194,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! | | 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row | | 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) | | 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) | diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..1f58c50be 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -1251,7 +1251,13 @@ impl<'h> Evaluator<'h> { let area = usize::try_from((area_n as u64).saturating_sub(1)).ok(); let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) }; let r = a.range; - // A single row or column with one index picks along it. + // A single row or column with one index picks along it. An area with several rows + // and columns needs both: `INDEX(A1:C3,2)` is #REF! in Excel (an empty third + // argument, `INDEX(A1:C3,2,)`, still means the whole row). Arrays differ: + // `INDEX({1,2;3,4},2)` is the row. + if two_args && r.height() > 1 && r.width() > 1 { + return err(CellError::Ref); + } let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) }; if row as u64 > r.height() as u64 || col as u64 > r.width() as u64 { return err(CellError::Ref); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5abedec60 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -172,6 +172,81 @@ fn index_area_number_overflow() { } } +/// INDEX with one index, as observed in Excel: a 2-D reference needs both indexes (#REF!), a +/// column array gives a 1×1 array (TYPE 64), a row array or 1-D reference gives one value. +#[test] +fn index_with_one_index() { + let mut t = T::new(); + for (i, v) in [10, 20, 30].iter().enumerate() { + t.set(&format!("A{}", i + 1), &v.to_string()); + } + let mut k = 1; + for r in 5..=7 { + for col in ["A", "B", "C"] { + t.set(&format!("{col}{r}"), &k.to_string()); + k += 1; + } + } + for (row, (formula, expected)) in [ + // Column arrays keep Excel's 1×1 array; row arrays and explicit columns give a value. + ("=INDEX({10;20;30},2)", 20.0), + ("=TYPE(INDEX({10;20;30},2))", 64.0), + ("=TYPE(INDEX(SEQUENCE(3),2))", 64.0), + ("=TYPE(INDEX({10;20;30},2,1))", 1.0), + ("=TYPE(INDEX({10,20,30},2))", 1.0), + ("=TYPE(INDEX(INDEX({10;20;30},2),1))", 1.0), + ("=SUM(INDEX({10;20;30},2))", 20.0), + // 1-D references with one index are single-cell references. + ("=TYPE(INDEX(A1:A3,2))", 1.0), + ("=SUM(INDEX(A1:A3,1):INDEX(A1:A3,3))", 60.0), + ("=SUM(INDEX((A1:A3,A5:C7),2))", 20.0), + // A 2-D array with one index is the row. + ("=SUM(INDEX({1,2;3,4},2))", 7.0), + ("=SUM(INDEX({1,2;3,4},0))", 10.0), + // A 2-D reference with an empty (not omitted) column argument is the row. + ("=SUM(INDEX(A5:C7,2,))", 15.0), + ("=SUM(INDEX(A5:C7,2,,1))", 15.0), + ("=COLUMNS(INDEX((A1:A3,A5:C7),2,,2))", 3.0), + ("=INDEX(A5:C7,2,2)", 5.0), + // A 2-D reference with one index is #REF!, and the error carries through. + ("=TYPE(INDEX(A5:C7,2))", 16.0), + ("=ISREF(INDEX(A5:C7,2))", 0.0), + ] + .iter() + .enumerate() + { + let at = format!("J{}", row * 4 + 1); + t.set(&at, formula); + let got = match t.get(&at) { + Value::Bool(b) => f64::from(u8::from(b)), + v => v.as_f64().unwrap_or_else(|| panic!("{formula} = {v:?}")), + }; + assert_eq!(got, *expected, "{formula}"); + } + for formula in [ + "=INDEX(A5:C7,2)", + "=INDEX(A5:C7,0)", + "=INDEX(A5:C7,1.9)", + "=INDEX(A5:C7,)", + "=INDEX(A5:B6,1)", + "=INDEX(A5:C7,2)+1", + "=SUM(INDEX(A5:C7,{1;2}))", + "=ROWS(INDEX(INDEX(A5:C7,2),3))", + "=SUM(A5:INDEX(A5:C7,2))", + "=SUM(INDEX(A5:C7,2):C7)", + ] { + t.set("L1", formula); + assert_eq!(t.get("L1"), Value::Error(CellError::Ref), "{formula}"); + } + t.set("L1", "=INDEX(A5:C7,{1;2})"); + assert_eq!([t.get("L1"), t.get("L2")], [Value::Error(CellError::Ref), Value::Error(CellError::Ref)]); + t.set("L1", "=IFERROR(INDEX(A5:C7,2),\"e\")"); + assert_eq!(t.get("L1"), Value::from("e")); + // A bad position is still #VALUE!. + t.set("L1", "=INDEX(A5:C7,\"x\")"); + assert_eq!(t.get("L1"), Value::Error(CellError::Value)); +} + #[test] fn errors_and_cycles() { let mut t = T::new(); diff --git a/docs/function-parity.md b/docs/function-parity.md index 760ffd6ac..12bef8ee9 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 (INDEX with one index on a reference of several rows and columns is #REF! as in Excel) · **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). @@ -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 | INDEX with only a row index on a reference of several rows and columns (`INDEX(A1:C3,2)`, `INDEX(A1:C3,0)`) is `#REF!`, as observed in Excel, so it also fails as a range endpoint and inside other functions; an empty column argument (`INDEX(A1:C3,2,)`) still gives the row. Checked that a column array with one index stays a 1×1 array (`TYPE` 64), which is what Excel returns | | 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 | From 672c102eb2eb375b3b2752f1e446147aebd522dc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:02 -0700 Subject: [PATCH 008/149] Calc: a LAMBDA that calls itself without end answers #NUM! instead of overflowing the stack The evaluator stopped at MAX_DEPTH (400) nested eval calls. A LAMBDA call takes one of those levels but several stack frames (eval, eval_inner, call, builtin, apply_lambda, call_lambda_values), so on the 1 MiB main thread of the Windows build `=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))` overflowed the stack before it reached the limit, and the process ended. eval now also stops once a formula has used 512 KiB of stack below its first eval. Both limits answer #NUM!, the error Excel documents for too many recursive calls, and a defined name that holds the recursion passes that error on instead of answering #NAME?. Fixes #1662 --- crates/calc/src/eval.rs | 37 +++++++++++++++++++++++++++++-------- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 0f9cfb03d..2bbedd551 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,6 +49,18 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, +/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main +/// thread and the web build have 1 MiB of stack. +const STACK_BUDGET: usize = 512 * 1024; + +/// The address of a local variable, which shows how far down the native stack this call is. +#[inline(always)] +fn stack_position() -> usize { + let marker = 0u8; + std::hint::black_box(&marker) as *const u8 as usize +} + /// What the evaluator needs from its surroundings. pub trait Host { fn workbook(&self) -> &Workbook; @@ -84,6 +96,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// `stack_position()` at the outermost `eval`. + stack_top: usize, } struct FnCtx<'a, 'h> { @@ -121,14 +135,14 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } } impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -205,10 +219,16 @@ impl<'h> Evaluator<'h> { } pub fn eval(&mut self, e: &Expr) -> Ev { + let here = stack_position(); + if self.depth == 0 { + self.stack_top = here; + } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + return err(CellError::Num); + } self.depth += 1; if self.depth > MAX_DEPTH { self.depth -= 1; - return err(CellError::Calc); + return err(CellError::Num); } let r = self.eval_inner(e); self.depth -= 1; @@ -492,7 +512,7 @@ impl<'h> Evaluator<'h> { } _ => { // A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead - // of one level of recursion per operator (past the depth limit it was #CALC!). + // of one level of recursion per operator (past the depth limit it was an error). let mut rest = vec![(op, b)]; let mut left = a; while let Expr::Binary(o, l, r) = left @@ -801,10 +821,11 @@ impl<'h> Evaluator<'h> { fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev { let Some(spec) = gridcraft_functions::lookup(name) else { // A defined name holding a LAMBDA, or a LET-bound lambda. - if let Ev::L(l) = self.name(name) { - return self.apply_lambda(&l, args); - } - return err(CellError::Name); + return match self.name(name) { + Ev::L(l) => self.apply_lambda(&l, args), + Ev::V(Value::Error(x)) => err(x), + _ => err(CellError::Name), + }; }; if args.len() < spec.min_args || args.len() > spec.max_args { return err(CellError::Value); diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 196c57de7..5cbbd5509 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -643,6 +643,38 @@ fn lambda_argument_count() { assert_eq!(t.get("A9"), Value::Bool(true)); } +#[test] +fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { + // 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run. + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(|| { + let mut t = T::new(); + t.wb.names.push(gridcraft_model::DefinedName { + name: "LOOP".into(), + scope: None, + formula: "LAMBDA(n,LOOP(n))".into(), + comment: String::new(), + hidden: false, + }); + t.calc.rebuild(&t.wb); + let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))"; + t.set("A1", endless); + t.set("A2", "=LOOP(1)"); + t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))"); + let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); + [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] + }) + .expect("spawn"); + let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack"); + assert_eq!(endless, Value::Error(CellError::Num)); + assert_eq!(endless_name, Value::Error(CellError::Num)); + assert_eq!(through_map, Value::Error(CellError::Num)); + assert_eq!(evaluated, Value::Error(CellError::Num)); + assert_eq!(countdown, Value::Number(50.0)); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From 09973e7831ecdb03f8ee71ef9a3841cd26c6547f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 17 Oct 2026 15:12:19 -0700 Subject: [PATCH 009/149] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index d522b84e0..96bead93e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -65,3 +65,7 @@ sync_github_name = true [people.Silvertree2010] display_name = "Netter" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From d0fdf948db4ad425b3fe19ec8ad8ee035d792059 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 13:09:44 -0700 Subject: [PATCH 010/149] Calc: a recursive LAMBDA stops at a fixed count of 140 calls A LAMBDA recursion answered #NUM! once the formula had used 512 KiB of native stack, so the depth it reached depended on frame sizes. On a Windows release build the deepest countdown was F(F,94), where 0.7.0 reached F(F,131). The evaluator now counts the LAMBDA calls a formula has in progress, in call_lambda_values, which direct calls, defined names, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY all go through. The 141st call answers #NUM!. The countdown F(F,131), the deepest that the 400-level MAX_DEPTH allows, has 132 calls in progress and still runs. The stack check stays as a guard. Its budget rises to 896 KiB, which leaves 128 KiB of a 1 MiB thread for the frames above the formula. a_recursive_lambda_stops_at_the_same_call_on_any_stack_size runs recursions on each side of the limit on a 1 MiB and a 64 MiB thread and expects the same results. The 1 MiB test now runs the countdown F(F,131). --- crates/calc/src/eval.rs | 31 ++++++++++++++++++++++++++----- crates/calc/src/tests.rs | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 7 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 2bbedd551..bc60c5b7a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -49,10 +49,16 @@ fn err(e: CellError) -> Ev { Ev::V(Value::Error(e)) } +/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build. +/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132 +/// calls in progress, so it still runs. +const MAX_LAMBDA_CALLS: usize = 140; + /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, -/// `eval` answers `#NUM!`. One level of `MAX_DEPTH` can take several stack frames. The Windows main -/// thread and the web build have 1 MiB of stack. -const STACK_BUDGET: usize = 512 * 1024; +/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames +/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`, +/// which starts its own budget. +const STACK_BUDGET: usize = 896 * 1024; /// The address of a local variable, which shows how far down the native stack this call is. #[inline(always)] @@ -96,6 +102,8 @@ pub struct Evaluator<'h> { pub at: CellRef, env: Vec<(String, Ev)>, depth: usize, + /// LAMBDA calls in progress. + calls: usize, /// `stack_position()` at the outermost `eval`. stack_top: usize, } @@ -135,14 +143,22 @@ impl Ctx for FnCtx<'_, '_> { _ => None, }) .collect(); - let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth, stack_top: self.ev.stack_top }; + let mut ev = Evaluator { + host: &mut *self.ev.host, + sheet: area.sheet, + at, + env, + depth: self.ev.depth, + calls: self.ev.calls, + stack_top: self.ev.stack_top, + }; Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) } } impl<'h> Evaluator<'h> { pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self { - Evaluator { host, sheet, at, env: Vec::new(), depth: 0, stack_top: 0 } + Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0 } } /// Evaluates to a value (references are dereferenced; multi-cell → array). @@ -963,10 +979,15 @@ impl<'h> Evaluator<'h> { } fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev { + if self.calls >= MAX_LAMBDA_CALLS { + return err(CellError::Num); + } + self.calls += 1; let saved = std::mem::replace(&mut self.env, l.env.clone()); self.env.extend(bound); let r = self.eval(&l.body); self.env = saved; + self.calls -= 1; r } diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 99ef117ce..d56bf0c13 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -662,7 +662,7 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { t.set("A1", endless); t.set("A2", "=LOOP(1)"); t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))"); - t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,50))"); + t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))"); let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless); [t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")] }) @@ -672,7 +672,35 @@ fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() { assert_eq!(endless_name, Value::Error(CellError::Num)); assert_eq!(through_map, Value::Error(CellError::Num)); assert_eq!(evaluated, Value::Error(CellError::Num)); - assert_eq!(countdown, Value::Number(50.0)); + assert_eq!(countdown, Value::Number(131.0)); +} + +#[test] +fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + let mut wb = Workbook::new(); + wb.names.push(gridcraft_model::DefinedName { + name: "CNT".into(), + scope: None, + formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(), + comment: String::new(), + hidden: false, + }); + // F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step. + let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))"); + let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))"); + [tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f)) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]); + assert_eq!(run(64 << 20), small); } #[test] From 5392d95bf85d53eab9abedf78a8757e7d292d9ad Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Wed, 21 Oct 2026 21:13:13 -0400 Subject: [PATCH 011/149] scripts: require workbook trust before running embedded scripts (#2178) A workbook opened with GridCraft scripts (`_sc_scripts`) starts untrusted: automate.runScript refuses them until the user trusts the workbook for the session (Macros dialog > Trust Scripts, i.e. automate.trustScripts) or the caller passes `trusted: true` for that one run. Workbooks without scripts, and scripts written in the session, are unaffected. Whatever the trust, a script whose steps reach outside the workbook (file.*, link.*, or any `path` param: Get Data, From Text/CSV, Insert Picture) is refused before any step runs. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/extra.rs | 75 ++++++++++++++++++++++++++--- crates/engine/src/lib.rs | 7 +++ crates/engine/src/tests.rs | 87 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 15 +++++- 4 files changed, 176 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index 01e2ac6e6..b88ce5592 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -193,7 +193,24 @@ pub fn specs() -> Vec { new_script ), cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts), - cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script), + cmd!( + "automate.runScript", + "Run Script", + [], + None, + "{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)", + has_doc, + run_script + ), + cmd!( + noundo "automate.trustScripts", + "Trust Scripts", + ["Automate", "Scripting Tools"], + None, + "{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session", + has_doc, + trust_scripts + ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), // Cell controls, themes, borders, styles cmd!( @@ -782,6 +799,7 @@ 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(); + copy.scripts_trusted = d.scripts_trusted; let i = s.add_document(copy); Ok(json!({"index": i})) } @@ -970,10 +988,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result { } fn all_scripts(s: &mut Session, _: &Json) -> Result { - let wb = &s.doc()?.wb; + let d = s.doc()?; + let (wb, trusted) = (&d.wb, d.scripts_trusted); let all = stash_get(wb, SCRIPTS_NAME); - let mut list: Vec = - all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default(); + let mut list: Vec = all + .as_object() + .map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect()) + .unwrap_or_default(); // A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on // save but never executed here, so it has no step count. if let Some(bytes) = &wb.vba_project { @@ -982,12 +1003,54 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result { Ok(Json::Array(list)) } +/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted). +pub(crate) fn has_scripts(wb: &Workbook) -> bool { + stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty()) +} + +/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the +/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, +/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) +fn denied_script_step(id: &str, params: &Json) -> Option { + if id.starts_with("file.") || id.starts_with("link.") { + return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); + } + if params.get("path").is_some() { + return Some(format!("scripts can't pass a file `path` to `{id}`")); + } + None +} + +fn trust_scripts(s: &mut Session, p: &Json) -> Result { + let trusted = bool_param(p, "trusted").unwrap_or(true); + s.doc_mut()?.scripts_trusted = trusted; + Ok(json!({"trusted": trusted})) +} + +/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is +/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that +/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs. fn run_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?; - let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME); + let d = s.doc()?; + let all = stash_get(&d.wb, SCRIPTS_NAME); let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?; + if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { + return Err(bad( + "automate.runScript", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)", + )); + } + let steps: Vec = steps.into_iter().take(100_000).collect(); + for st in &steps { + let id = st["command"].as_str().unwrap_or(""); + if let Some(why) = denied_script_step(id, &st["params"]) { + return Err(bad("automate.runScript", why)); + } + } let mut n = 0; - for st in steps.iter().take(100_000) { + for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 258a1cb72..bf7dd633d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -112,6 +112,11 @@ pub struct DocState { /// The format `path`'s content was recognised as when its extension doesn't name one (an /// XLSX called `budget.dat`): Save writes it back in that format. pub content_kind: Option, + /// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook + /// that arrives already carrying scripts (opened from a file) starts untrusted until the + /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts + /// trusted, since any script it gains later was written here. + pub scripts_trusted: bool, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -121,6 +126,7 @@ impl DocState { pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); + let scripts_trusted = !cmd::extra::has_scripts(&wb); let wb = Arc::new(wb); let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default()); DocState { @@ -137,6 +143,7 @@ impl DocState { sheet_selections: vec![], unlocked_ranges: vec![], content_kind: None, + scripts_trusted, } } pub fn is_dirty(&self) -> bool { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2ac290654..357b925c1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -940,6 +940,93 @@ fn protection_passwords_stay_out_of_the_journal_and_recorded_scripts() { s.execute("automate.runScript", json!({"name": "Lock"})).unwrap(); } +/// A session holding a workbook opened from file bytes whose `Fill` script sets B1 (#2178). +fn opened_with_script() -> Session { + let mut author = s(); + author + .execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + let bytes = crate::io::save_bytes(&author.doc().unwrap().wb, "book.xlsx").unwrap(); + let mut s = Session::new(); + s.execute("file.open", json!({"name": "downloaded.xlsx", "base64": crate::io::base64_encode(&bytes)})).unwrap(); + s +} + +#[test] +fn scripts_written_in_this_session_run_without_a_trust_step() { + let mut s = s(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.newScript", json!({"name": "Fill", "commands": [{"command": "cell.set", "params": {"cell": "B1", "input": "ran"}}]})) + .unwrap(); + assert_eq!(s.execute("automate.runScript", json!({"name": "Fill"})).unwrap()["ran"], 1); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); +} + +#[test] +fn untrusted_workbook_scripts_are_refused_until_trusted() { + let mut s = opened_with_script(); + s.ui_requests.clear(); + assert!(!s.doc().unwrap().scripts_trusted, "a workbook opened with scripts starts untrusted"); + let listed = s.execute("automate.allScripts", json!({})).unwrap(); + assert_eq!(listed[0]["trusted"], false, "{listed}"); + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("trust"), "{err}"); + assert_eq!(v(&s, "B1"), Value::Empty, "no step of an untrusted script runs"); + // Trust can't be smuggled in as a string, and nothing opens a dialog. + assert!(s.execute("automate.runScript", json!({"name": "Fill", "trusted": "yes"})).is_err()); + assert!(s.ui_requests.is_empty(), "{:?}", s.ui_requests); + + // An explicit `trusted: true` runs it this once without trusting the workbook. + s.execute("automate.runScript", json!({"name": "Fill", "trusted": true})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + assert!(!s.doc().unwrap().scripts_trusted); + s.execute("cell.set", json!({"cell": "B1", "input": ""})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + + // Trusting the workbook (the UI's Trust Scripts action) lets its scripts run this session. + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + s.execute("automate.runScript", json!({"name": "Fill"})).unwrap(); + assert_eq!(v(&s, "B1"), Value::Text("ran".into())); + // A second window on the same workbook keeps the decision; withdrawing it disables them again. + s.execute("view.newWindow", json!({})).unwrap(); + assert!(s.doc().unwrap().scripts_trusted); + s.execute("automate.trustScripts", json!({"trusted": false})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); +} + +#[test] +fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { + let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); + let leak = dir.join("leak.xlsx"); + let leak = leak.to_string_lossy().to_string(); + let mut s = opened_with_script(); + s.ui_requests.clear(); + s.execute("automate.trustScripts", json!({})).unwrap(); + let denied = [ + json!({"command": "link.open", "params": {"url": "https://example.com/?d=secret"}}), + json!({"command": "file.saveAs", "params": {"path": leak}}), + json!({"command": "file.save", "params": {}}), + json!({"command": "file.exportPdf", "params": {"path": leak}}), + json!({"command": "file.open", "params": {"path": leak}}), + json!({"command": "data.getData", "params": {"path": leak}}), + json!({"command": "data.fromTextCsv", "params": {"path": leak}}), + json!({"command": "insert.picture", "params": {"path": leak}}), + ]; + for step in denied { + // The harmless first step proves the whole script is refused before anything runs. + let first = json!({"command": "cell.set", "params": {"cell": "C1", "input": "partial"}}); + s.execute("automate.newScript", json!({"name": "Leak", "commands": [first, step]})).unwrap(); + let r = s.execute("automate.runScript", json!({"name": "Leak", "trusted": true})); + assert!(r.is_err(), "{step} must be refused"); + assert_eq!(v(&s, "C1"), Value::Empty, "{step}: no step runs"); + } + assert!(!s.ui_requests.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_))), "{:?}", s.ui_requests); + assert!(!std::path::Path::new(&leak).exists()); + // Data a script supplies itself is still fine. + s.execute("automate.newScript", json!({"name": "Csv", "commands": [{"command": "data.fromTextCsv", "params": {"text": "a,b\n1,2"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Csv"})).unwrap(); +} + /// A sheet with locked data in A1:B3, unlocked cells in D1:D3 and row 10, then protected. fn protected(extra: serde_json::Value) -> Session { let mut s = s(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fb899c702..10320476c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1077,7 +1077,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { } }); } - "macros" => macros(ui, &mut d), + "macros" => macros(app, ui, &mut d), "statistics" | "accessibility" | "errorChecking" | "evaluateFormula" | "comments" => { let r = if d.name == "comments" { app.session.active().and_then(|doc| doc.wb.active().map(|s| json!(s.comments.iter().map(|(c, m)| json!({"cell": c.a1(), "author": m.author, "text": m.text})).collect::>()))) @@ -1496,7 +1496,7 @@ fn about(ui: &mut egui::Ui, d: &mut Dialog) { /// The Macros dialog: lists GridCraft's own recorded scripts and (if the file carries one) the /// preserved VBA project. Mirrors Excel's Developer ▸ Macros list, but is honest that VBA is /// preserved and not executed. -fn macros(ui: &mut egui::Ui, d: &mut Dialog) { +fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.label("Automation in this workbook. GridCraft runs its own scripts; a file's VBA project is preserved but not executed."); ui.add_space(6.0); let items: Vec = d.result.as_ref().and_then(Json::as_array).cloned().unwrap_or_default(); @@ -1504,6 +1504,17 @@ fn macros(ui: &mut egui::Ui, d: &mut Dialog) { ui.weak("This workbook has no scripts or macros."); return; } + // Scripts that came with a file stay disabled until the user trusts the workbook (#2178). + if items.iter().any(|it| it.get("trusted").and_then(Json::as_bool) == Some(false)) { + ui.horizontal(|ui| { + ui.weak("Scripts in this workbook came from a file and are disabled."); + if ui.button("Trust Scripts").on_hover_text("Let this workbook's scripts run for the rest of this session").clicked() { + let _ = app.run("automate.trustScripts", json!({"trusted": true})); + d.result = app.session.run("view.macros", json!({})).ok(); + } + }); + ui.add_space(6.0); + } egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| { for it in &items { let name = it.get("name").and_then(Json::as_str).unwrap_or("(unnamed)"); From b9801f3f810927d4dd597c0d38b5ba13729221eb Mon Sep 17 00:00:00 2001 From: Viorel Toader Date: Thu, 22 Oct 2026 07:55:04 +0300 Subject: [PATCH 012/149] Romanian: translate Options|Editing; 22 strings main added since (sheet tab menu, account, chart and error-checking labels) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JadgGo4G2GHaAMNb3qyygk --- crates/ui-egui/src/i18n/ro.rs | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ro.rs b/crates/ui-egui/src/i18n/ro.rs index 699e30ff3..9e0d92516 100644 --- a/crates/ui-egui/src/i18n/ro.rs +++ b/crates/ui-egui/src/i18n/ro.rs @@ -1005,7 +1005,31 @@ pub(super) fn translate(text: &str) -> Option<&'static str> { "Print job submitted: {receipt}" => "Lucrarea de imprimare a fost trimisă: {receipt}", "Print failed: {error}" => "Imprimarea a eșuat: {error}", "Print failed: the printing worker stopped." => "Imprimarea a eșuat: procesul de imprimare s-a oprit.", - "Options|Editing" => "Editing", + "Options|Editing" => "Editare", + "Duplicate" => "Duplicare", + "Rename" => "Redenumire", + "Product Information" => "Informații despre produs", + "Tab Color" => "Culoare filă", + "Select All Sheets" => "Selectare toate foile", + "Fit selection" => "Încadrare selecție", + "All sheets" => "Toate foile", + "Version" => "Versiune", + "Unhide…" => "Reafișare…", + "Platform" => "Platformă", + "Unprotect Sheet…" => "Deprotejare foaie…", + "AutoSave: save after every change (workbooks saved to a file)" => { + "Salvare automată: salvează după fiecare modificare (registrele salvate într-un fișier)" + } + "Licence" => "Licență", + "Account" => "Cont", + "No Color" => "Fără culoare", + "Move or Copy…" => "Mutare sau copiere…", + "Number Stored as Text" => "Număr stocat ca text", + "Select Data…" => "Selectare date…", + "Convert to Number" => "Conversie în număr", + "Format Chart Area…" => "Formatare zonă diagramă…", + "Error checking options" => "Opțiuni de verificare a erorilor", + "Paste formulas that refer to the copied cells" => "Lipire formule care fac referire la celulele copiate", _ => return None, }) } From e177bf1c44581da8bf29ebf7e261b8a988449c05 Mon Sep 17 00:00:00 2001 From: Tyrel Haveman <1434399+tyrel@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:41:28 -0700 Subject: [PATCH 013/149] calc: hardening: overlong digits, NaN indexes, REGEXREPLACE output (#2163, #2162) - Text coercion: digits too many for a finite number are not a number, and a fraction must come out finite, so "1 <400 digits>/<400 digits>" (inf/inf) no longer coerces to NaN (test overlong_digits_stay_text). - VLOOKUP/HLOOKUP and the D-functions' field index refuse NaN, which passed their "< 1" checks and became index 0, then underflowed (test nan_index_is_a_value_error; hostile_inputs_never_panic now also passes NaN and infinity to every function). - REGEXREPLACE replaces match by match and gives up once the result can only be too long, instead of building every replacement first: the 32,767 x 32,767 worst case went from 1.05 GB peak to 7 MB (test regexreplace_bounds_its_result). #2165 (MAP's size check) is already fixed on main by the broadcast cap (836b2698, #2182), whose test covers MAP. Also adds my contributors/people.toml entry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP --- ROADMAP.md | 4 +++- contributors/people.toml | 3 +++ crates/core/src/parse.rs | 19 +++++++++++++++++-- crates/functions/src/database.rs | 5 +++-- crates/functions/src/lib.rs | 31 +++++++++++++++++++++++++++++++ crates/functions/src/lookup.rs | 5 +++-- crates/functions/src/text.rs | 16 +++++++++++++++- 7 files changed, 75 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e04e9123..1faf680ca 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:** trivial (progress log: formula hardening, #2163/#2162; previously 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) 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 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | | 2026-10-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 | trivial | Progress log: formula hardening (#2163, #2162) | | 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/contributors/people.toml b/contributors/people.toml index 62635c8ae..88e496fc7 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -93,3 +93,6 @@ sync_github_name = false [people.bilalmughal] sync_github_name = true + +[people.tyrel] +real_name = "Tyrel Haveman" diff --git a/crates/core/src/parse.rs b/crates/core/src/parse.rs index b58586aef..3f2b97a8a 100644 --- a/crates/core/src/parse.rs +++ b/crates/core/src/parse.rs @@ -167,7 +167,8 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> { } /// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects -/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept. +/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a +/// finite number (#2163). fn plain_number(s: &str, frac: bool) -> Option { let (int, dec) = match s.split_once('.') { Some((i, d)) if frac => (i, Some(d)), @@ -177,7 +178,7 @@ fn plain_number(s: &str, frac: bool) -> Option { if !digits(int) || dec.is_some_and(|d| !digits(d)) { return None; } - s.parse().ok() + s.parse().ok().filter(|x: &f64| x.is_finite()) } /// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`. @@ -192,6 +193,9 @@ fn parse_fraction(t: &str) -> Option { return None; } let v = w + a / b; + if !v.is_finite() { + return None; + } Some(if neg { -v } else { v }) } @@ -366,6 +370,17 @@ mod tests { parse_input(s, DateSystem::D1900) } + /// #2163: digits too many for a finite number don't make one (∞/∞ was NaN). + #[test] + fn overlong_digits_stay_text() { + let big = "9".repeat(400); + for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] { + assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}"); + assert_eq!(parse_number_text(&s), None, "{s}"); + } + assert_eq!(parse_number_text("1 1/2"), Some(1.5)); + } + #[test] fn non_digit_time_and_fraction_parts_stay_text() { for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] { diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 67132e98f..455b8a1d6 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -35,10 +35,11 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { match v { Value::Number(n) => { let i = n.trunc(); - if i < 1.0 || i > db.cols as f64 { + // NaN too: it would pass both comparisons and become index 0 (#2163). + if i.is_nan() || i < 1.0 || i > db.cols as f64 { return Err(CellError::Value); } - Ok(Some(i as usize - 1)) + Ok(Some((i as usize).saturating_sub(1))) } Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value), Value::Bool(_) => Err(CellError::Value), diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index d7ccc8726..f870f3fe2 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -264,6 +264,34 @@ mod tests { } } + /// #2163: a NaN column index is refused (it used to become 0 and underflow to `usize::MAX`). + #[test] + fn nan_index_is_a_value_error() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let table = rf(arr(vec![vec![tv("Field"), tv("Other")], vec![nv(1.0), nv(2.0)]])); + let nan = || av(Value::Number(f64::NAN)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("HLOOKUP", vec![t("Field"), table.clone(), nan()]), Value::Error(CellError::Value)); + assert_eq!(ev("DSUM", vec![table.clone(), nan(), table.clone()]), Value::Error(CellError::Value)); + assert_eq!(ev("VLOOKUP", vec![n(1.0), table, n(2.0)]), Value::Number(2.0)); + } + + /// #2162: REGEXREPLACE gives up on a too-long result while building it, and otherwise replaces + /// as before (every match, `$n` groups). + #[test] + fn regexreplace_bounds_its_result() { + use crate::util::testutil::*; + use gridcraft_core::CellError; + let many = "a".repeat(32_767); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("."), t(&"b".repeat(32_767))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("a"), t("b")]), Value::text("b".repeat(32_767))); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b22c"), t("[0-9]+"), t("#")]), Value::text("a#b#c")); + assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); + assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); + assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + } + #[test] fn hostile_inputs_never_panic() { use crate::util::testutil::*; @@ -279,6 +307,9 @@ mod tests { n(-1e308), n(1e10), n(2.5), + // Numbers no formula should make, but code can (#2163). + av(Value::Number(f64::NAN)), + av(Value::Number(f64::INFINITY)), t(""), t("abc"), t("*?~"), diff --git a/crates/functions/src/lookup.rs b/crates/functions/src/lookup.rs index f9c0b6f44..acb23f2ea 100644 --- a/crates/functions/src/lookup.rs +++ b/crates/functions/src/lookup.rs @@ -134,10 +134,11 @@ fn vh_lookup(args: &[Arg], vertical: bool) -> R { return Err(*e); } let idx = num(args, 2)?; - if idx < 1.0 { + // NaN too: it would pass `idx < 1.0` and become index 0 (#2163). + if idx.is_nan() || idx < 1.0 { return Err(CellError::Value); } - let idx = idx.trunc() as usize - 1; + let idx = (idx.trunc() as usize).saturating_sub(1); let approx = opt_bool(args, 3, true)?; let (keys, len) = if vertical { (column(&table, 0), table.cols) } else { (row(&table, 0), table.rows) }; if idx >= len { diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 71a6ccb4b..096e32990 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -769,7 +769,21 @@ fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let occ = opt_int(a, 3, 0)?; let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; if occ == 0 { - return check_len(re.replace_all(&s, rep.as_str()).into_owned()); + // Match by match, giving up once the text can only be too long (a character is at most + // four bytes) rather than building every replacement first (#2162). + let mut out = String::new(); + let mut last = 0; + for cap in re.captures_iter(&s) { + let Some(m) = cap.get(0) else { continue }; + out.push_str(s.get(last..m.start()).unwrap_or_default()); + cap.expand(&rep, &mut out); + last = m.end(); + if out.len() > MAX_TEXT * 4 { + return Err(CellError::Value); + } + } + out.push_str(s.get(last..).unwrap_or_default()); + return check_len(out); } let caps: Vec = re.captures_iter(&s).collect(); let k = occ.unsigned_abs() as usize; From 742a6e8535dde599073ddffb9e55a3f2b58cd83e Mon Sep 17 00:00:00 2001 From: Tyrel Haveman <1434399+tyrel@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:45:50 -0700 Subject: [PATCH 014/149] functions: REGEXREPLACE bounds each replacement as it expands it (#2162) From the local CodeRabbit review: the result was bounded per match, but one match's expansion was built whole first, so a replacement repeating $0 10,000 times over a 32,767-character match allocated about 330 MB before the check. The replacement is now split into pieces as regex's Captures::expand reads it (template_expands_like_regex checks they agree) and appended piece by piece within the bound: 327 MB peak became 7 MB (regexreplace_bounds_its_result). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Nnsfu3QGqjD1aFZbBgUWP --- crates/functions/src/lib.rs | 3 ++ crates/functions/src/text.rs | 100 ++++++++++++++++++++++++++++++----- 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index f870f3fe2..e4f7a1f24 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -290,6 +290,9 @@ mod tests { assert_eq!(ev("REGEXREPLACE", vec![t("2026-10-22"), t("(\\d+)-(\\d+)-(\\d+)"), t("$3/$2/$1")]), Value::text("22/10/2026")); assert_eq!(ev("REGEXREPLACE", vec![t("abc"), t("x*"), t("-")]), Value::text("-a-b-c-")); assert_eq!(ev("REGEXREPLACE", vec![t("a1b2"), t("[0-9]"), t("#"), n(2.0)]), Value::text("a1b#")); + // One match whose replacement repeats it 10,000 times: refused before it is built. + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000))]), Value::Error(CellError::Value)); + assert_eq!(ev("REGEXREPLACE", vec![t(&many), t("^.*$"), t(&"$0".repeat(10_000)), n(1.0)]), Value::Error(CellError::Value)); } #[test] diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 096e32990..95eb6784d 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -763,24 +763,87 @@ fn convert_replacement(r: &str) -> String { out } +/// A piece of a replacement: literal text or a capture group (by number or name). +enum Piece { + Text(String), + Index(usize), + Name(String), +} + +/// A replacement split into pieces as `regex::Captures::expand` reads it: `$$` is `$`, `$name` takes +/// the longest run of `[_0-9A-Za-z]`, `${name}` ends at `}` (`${}` is empty), a name of digits is a +/// group number, and a `$` that starts no reference is itself. +fn template(rep: &str) -> Vec { + let mut out = Vec::new(); + let mut lit = String::new(); + let mut rest = rep; + while let Some(i) = rest.find('$') { + lit.push_str(&rest[..i]); + rest = &rest[i + 1..]; + if let Some(r) = rest.strip_prefix('$') { + lit.push('$'); + rest = r; + continue; + } + // A braced name is a reference even when empty (it expands to nothing). + let braced = rest.strip_prefix('{').and_then(|b| b.find('}').map(|j| (&b[..j], &b[j + 1..]))); + let (name, after) = braced.unwrap_or_else(|| { + let j = rest.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric())).unwrap_or(rest.len()); + (&rest[..j], &rest[j..]) + }); + if braced.is_none() && name.is_empty() { + lit.push('$'); + continue; + } + if !lit.is_empty() { + out.push(Piece::Text(std::mem::take(&mut lit))); + } + out.push(match name.parse::() { + Ok(n) => Piece::Index(n), + Err(_) => Piece::Name(name.to_string()), + }); + rest = after; + } + lit.push_str(rest); + if !lit.is_empty() { + out.push(Piece::Text(lit)); + } + out +} + +/// Append a match's replacement to `out`, piece by piece; `None` once `out` would pass `limit` +/// bytes, before anything that long is built (#2162). +fn expand_capped(cap: ®ex::Captures, pieces: &[Piece], out: &mut String, limit: usize) -> Option<()> { + for p in pieces { + let s = match p { + Piece::Text(t) => t.as_str(), + Piece::Index(i) => cap.get(*i).map_or("", |m| m.as_str()), + Piece::Name(n) => cap.name(n).map_or("", |m| m.as_str()), + }; + if out.len().saturating_add(s.len()) > limit { + return None; + } + out.push_str(s); + } + Some(()) +} + fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = text(a, 0)?; - let rep = convert_replacement(&text(a, 2)?); + let rep = template(&convert_replacement(&text(a, 2)?)); let occ = opt_int(a, 3, 0)?; let re = build_regex(&text(a, 1)?, case_arg(a, 4)?)?; + // Built match by match within a bound past which the text can only be too long (a character + // is at most four bytes), instead of building every replacement first (#2162). + let limit = MAX_TEXT * 4; + let mut out = String::new(); if occ == 0 { - // Match by match, giving up once the text can only be too long (a character is at most - // four bytes) rather than building every replacement first (#2162). - let mut out = String::new(); let mut last = 0; for cap in re.captures_iter(&s) { let Some(m) = cap.get(0) else { continue }; out.push_str(s.get(last..m.start()).unwrap_or_default()); - cap.expand(&rep, &mut out); + expand_capped(&cap, &rep, &mut out, limit).ok_or(CellError::Value)?; last = m.end(); - if out.len() > MAX_TEXT * 4 { - return Err(CellError::Value); - } } out.push_str(s.get(last..).unwrap_or_default()); return check_len(out); @@ -790,10 +853,9 @@ fn regexreplace(a: &[Arg], _c: &mut dyn Ctx) -> R { let idx = if occ > 0 { k.checked_sub(1) } else { caps.len().checked_sub(k) }; let Some(cap) = idx.and_then(|i| caps.get(i)) else { return text_val(s) }; let Some(m) = cap.get(0) else { return text_val(s) }; - let mut out = String::new(); - out.push_str(&s[..m.start()]); - cap.expand(&rep, &mut out); - out.push_str(&s[m.end()..]); + out.push_str(s.get(..m.start()).unwrap_or_default()); + expand_capped(cap, &rep, &mut out, limit).ok_or(CellError::Value)?; + out.push_str(s.get(m.end()..).unwrap_or_default()); check_len(out) } @@ -971,6 +1033,20 @@ pub(crate) fn specs() -> Vec { mod tests { use gridcraft_core::{CellError, Value}; + /// The pieces expand exactly as `regex::Captures::expand` does. + #[test] + fn template_expands_like_regex() { + let re = regex::Regex::new(r"(?\d+)-(\d+)").unwrap(); + let cap = re.captures("x 2026-10 y").unwrap(); + for rep in ["$1", "${1}", "$1a", "${1}a", "$y", "${y}!", "$$", "$$1", "a$", "$}", "${", "${}", "${y", "$0|$2|$9", "$-1", "€$1€", ""] { + let mut want = String::new(); + cap.expand(rep, &mut want); + let mut got = String::new(); + assert!(super::expand_capped(&cap, &super::template(rep), &mut got, usize::MAX).is_some()); + assert_eq!(got, want, "{rep:?}"); + } + } + use crate::util::testutil::*; #[test] From c39d2ad197e00fe4bc55572e075bf1702b1cab40 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:47:21 +0200 Subject: [PATCH 015/149] Keep '!' in structured-reference columns intact on XLSX save Found while testing crates/xlsx/src/fmla.rs:101 (with crates/formula/src/printer.rs:181) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 24 +++++++++++++++++++++++- crates/xlsx/src/fmla.rs | 12 ++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..e69351168 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -85,6 +85,28 @@ pub fn struct_ref_file(s: &StructRef) -> String { write_struct_ref(s, false) } +/// Splits a sheet-qualified name at its last `!` outside brackets. A `!` inside brackets +/// belongs to a structured reference's column (`Table1[Done!]`, `Table1[#REF!]`). +fn sheet_split(n: &str) -> Option<(&str, &str)> { + let mut depth = 0usize; + let mut escaped = false; + let mut at = None; + for (i, c) in n.char_indices() { + if escaped { + escaped = false; + continue; + } + match c { + '\'' if depth > 0 => escaped = true, + '[' => depth = depth.saturating_add(1), + ']' => depth = depth.saturating_sub(1), + '!' if depth == 0 => at = Some(i), + _ => {} + } + } + at.and_then(|i| Some((n.get(..i)?, n.get(i + 1..)?))) +} + fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#"); let item = |i: &StructItem| match i { @@ -178,7 +200,7 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option) { Some(at) => s.push_str(&reference_r1c1(r, at)), None => s.push_str(&reference_a1(r)), }, - Expr::Name(n) => match n.rsplit_once('!') { + Expr::Name(n) => match sheet_split(n) { // A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back. Some((sheet, name)) => { s.push_str("e_sheet(sheet)); diff --git a/crates/xlsx/src/fmla.rs b/crates/xlsx/src/fmla.rs index bef43a5aa..c5e1f686c 100644 --- a/crates/xlsx/src/fmla.rs +++ b/crates/xlsx/src/fmla.rs @@ -183,6 +183,18 @@ mod tests { } } + #[test] + fn struct_ref_column_with_bang() { + // A `!` inside the brackets is part of the column name, not a sheet separator. + assert_eq!(to_file(&Formula::new("SUM(Table1[Done!])")), "SUM(Table1[Done!])"); + assert_eq!(text_to_file("=Table1[[#This Row],[a!b]]"), "Table1[[#This Row],[a!b]]"); + // `Table1[#REF!]` was found by the command-sequence fuzzer (seed 40). + for text in ["SUM(Table1[Done!])", "Table1[#REF!]", "Table1[@[x!]]", "Sheet2!A1+Table1[[#All],[a!b]]"] { + let f = Formula::new(text); + assert_eq!(from_file(&to_file(&f)), f.text); + } + } + #[test] fn prefixes() { assert_eq!(to_file(&Formula::new("XLOOKUP(1,A:A,B:B)+SUM(A1)")), "_xlfn.XLOOKUP(1,A:A,B:B)+SUM(A1)"); From 8307d3cb0526c7af66645cf9d7c79f01e77be632 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:25:54 +0000 Subject: [PATCH 016/149] Treat empty instance_num in TEXTBEFORE/TEXTAFTER as 1 Found while testing crates/functions/src/text.rs:553 Co-Authored-By: Claude Opus 5.5 --- crates/calc/src/tests.rs | 13 +++++++++++++ crates/functions/src/text.rs | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index e5e287d38..622ed6cc9 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1768,3 +1768,16 @@ fn a_dynamic_array_of_one_value_still_anchors_a_spill() { t.set("E11", "=ROWS(D1#)"); assert_eq!(t.get("E11"), Value::Error(CellError::Ref)); } + +#[test] +fn text_before_after_empty_instance_defaults_to_one() { + let mut t = T::new(); + t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)"); + assert_eq!(t.get("A1"), Value::Text("Little ".into())); + t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")"); + assert_eq!(t.get("A2"), Value::Text("b".into())); + t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")"); + assert_eq!(t.get("A3"), Value::Text("nf".into())); + t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); + assert_eq!(t.get("A4"), Value::Error(CellError::Value)); +} diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index 71a6ccb4b..baa86693a 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -5,8 +5,8 @@ use gridcraft_core::{CellError, Value, number_to_text}; use crate::criteria::wildcard_find; use crate::util::{ - A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, - text_val, + A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, int, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, + text, text_val, }; use crate::{Arg, Ctx, FnSpec, VAR}; @@ -550,7 +550,7 @@ fn find_matches(text: &[char], delims: &[Vec], insensitive: bool, from_end fn text_before_after(a: &[Arg], before: bool) -> R { let s = chars(&text(a, 0)?); let delims = delimiters(&arg(a, 1)?.value)?; - let inst = opt_int(a, 2, 1)?; + let inst = if has(a, 2) { int(a, 2)? } else { 1 }; let mode = opt_int(a, 3, 0)?; let match_end = opt_bool(a, 4, false)?; if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) { From 7ed494691cbd272c5dac69f768317fc699fcaa81 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 22 Oct 2026 17:57:56 +0700 Subject: [PATCH 017/149] README: the app icon above the title --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2c224559c..d9dc5eb36 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,10 @@

+

+ GridCraft icon: a spreadsheet page on a green tile +

+

GridCraft

From 56207f2d3cc55f5f0574415998ebcbaf61c90216 Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 17:50:09 +0500 Subject: [PATCH 018/149] run --script: a line's unknown key stops the run (#2256) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script line whose parameters sat under a key the runner does not read ran the command with no parameters at all, and the run still exited 0: {"command":"home.insertSheet","args":{"name":"Report"}} {"command":"home.insertSheet","name":"Report2"} made Sheet2 and Sheet3 instead of Report and Report2, and said nothing. A typo in a script produced a valid, wrong workbook. A line is read for `command` (or `id`) and `params`; any other key is a usage error now, naming the keys and the line, and the run stops before the workbook is written: gridcraft-cli: s.jsonl:2: unknown key `args`: a line is {"command": "id", "params": {…}} The usage text says what a line looks like, which is the other half of the report — the format was not written down anywhere, so the reporter found it by trying `args`, a flat object and `id` in turn. Tested at both levels: `parse_command` for the keys, their sorted list when there are several, and the forms that are read (`id`, `params`, `id={json}`, comments); and a run of a four-line script that stops at line 2 without writing the file, then the same script corrected, whose sheets come out named. Without the check the mistaken line parses again. An unknown key *inside* `params` is still ignored — the rest of #2256 — and needs the command's parameter list, which the engine does not expose yet. Noted on the issue. --- apps/gridcraft-cli/src/main.rs | 33 ++++++++++++++++++++++++++++ apps/gridcraft-cli/tests/cli.rs | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/apps/gridcraft-cli/src/main.rs b/apps/gridcraft-cli/src/main.rs index 0b763e378..972c023fa 100644 --- a/apps/gridcraft-cli/src/main.rs +++ b/apps/gridcraft-cli/src/main.rs @@ -43,6 +43,8 @@ const USAGE: &str = "usage: cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference) gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl] [--out FILE] [--print RANGE] [--quiet] + a script line is id={json} or {\"command\": \"id\", \"params\": {…}}; + blank lines and lines starting with # or // are skipped gridcraft-cli commands [--json] [--search X] gridcraft-cli functions [--json] [--search X] [--category C] gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME] @@ -375,6 +377,17 @@ fn parse_command(text: &str) -> Result, String> { } if t.starts_with('{') { let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?; + // A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and + // `{"command": …, "name": "Report"}` ran the command with no parameters at all and the + // script still exited 0, so a typo made a valid, wrong workbook (#2256). + if let Some(o) = v.as_object() { + let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect(); + if !unknown.is_empty() { + unknown.sort_unstable(); + let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `")); + return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}")); + } + } let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?; return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({}))))); } @@ -567,6 +580,26 @@ mod tests { assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({})))); assert_eq!(parse_command(" # comment").unwrap(), None); assert!(parse_command("x={bad").is_err()); + // A key this does not read is a mistake: the command used to run with no parameters and + // the script still exited 0, so a typo made a valid, wrong workbook (#2256). + for (line, named) in [ + (r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"), + (r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"), + (r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"), + ] { + let e = parse_command(line).expect_err("refused"); + assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}"); + assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}"); + } + // Several at once are listed in one message, sorted. + let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused"); + assert!(e.contains("unknown keys `args`, `name`"), "{e}"); + // `id` is still the accepted alternative to `command`, and `params` still carries them. + assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({})))); + assert_eq!( + parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(), + Some(("home.insertSheet".into(), json!({"name": "R"}))) + ); } #[test] diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/cli.rs index 3105819ec..5295368e3 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/cli.rs @@ -206,3 +206,41 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() { assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit")))); let _ = std::fs::remove_dir_all(dir); } + +/// A script line whose parameters are under a key `run` does not read used to run the command +/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2, +/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256). +#[test] +fn a_script_line_with_an_unknown_key_stops_the_run() { + let dir = tmpdir("script-keys"); + let script = dir.join("s.jsonl"); + let out = dir.join("t.xlsx"); + let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap(); + + write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + let err = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "the run must stop: {err}"); + assert!(err.contains("s.jsonl:2"), "it names the line: {err}"); + assert!(err.contains("unknown key `args`"), "and the key: {err}"); + assert!(!out.exists(), "and writes no workbook: {err}"); + + // The forms that are read still work, in one script: `params`, `id=json`, and a comment. + write(&[ + r#"{"command":"file.new"}"#, + r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#, + "# a comment", + r#"home.insertSheet={"name":"Report2"}"#, + ]); + let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + let info = ok(&["info", out.to_str().unwrap()]); + assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}"); + assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}"); + + // The usage text says what a line looks like, which is how this was found. + let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout); + assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}"); + + let _ = std::fs::remove_dir_all(&dir); +} From 843c9b84049738eaab5cf2fc71d22a31ea86100b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 09:50:06 +0200 Subject: [PATCH 019/149] ui: a too-wide number in a repeat-fill format shows #### A number too wide for its column is drawn as ####. With a repeat fill (`*` in the format, as in the Accounting format) the fill step then rebuilt the text from the formatted value, so it overflowed and, being right-aligned, lost its leading digits: 2,509.00 CHF in a narrow column was drawn as "509.00 CHF". The fill now leaves #### alone. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 7 ++- crates/ui-egui/tests/accounting_fill.rs | 77 +++++++++++++++++++++++++ docs/ui-parity.md | 3 +- 3 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/accounting_fill.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..87aaa3c73 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1268,13 +1268,16 @@ pub(crate) fn paint_quadrant( let mut galley = p.layout_no_wrap(first_line.clone(), font.clone(), color); let mut w = galley.size().x; // Numbers and dates never overflow: they show #### when too narrow. - if !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill { + let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; + if hashed { let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; } - // Repeat fill (`*` in number formats) / Fill alignment. + // Repeat fill (`*` in number formats) / Fill alignment. A number already shown as #### keeps + // it: rebuilding it from the text would overflow and clip its leading digits. if let Some((ch, pos)) = fill_char + && !hashed && pos <= first_line.len() { let base_w = w; diff --git a/crates/ui-egui/tests/accounting_fill.rs b/crates/ui-egui/tests/accounting_fill.rs new file mode 100644 index 000000000..492f581cd --- /dev/null +++ b/crates/ui-egui/tests/accounting_fill.rs @@ -0,0 +1,77 @@ +//! Repeat fill (`*` in a number format, as in the Accounting format): a number that fits is drawn +//! across its cell, and one too wide for its column shows #### instead of losing its leading digits. + +use egui::Shape; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +const ACCOUNTING: &str = r#"_-* #,##0.00\ "CHF"_-;\-* #,##0.00\ "CHF"_-;_-* "-"??\ "CHF"_-;_-@_-"#; + +fn harness() -> Harness { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1:B2", "values": [[344.75, 344.75], [2509, 2509]]})).unwrap(); + s.execute("home.formatCells", json!({"range": "A1:B2", "style": {"num_fmt": ACCOUNTING}})).unwrap(); + // Column A is too narrow for 2,509.00 CHF; column B fits both. + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": 7})).unwrap(); + s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 20})).unwrap(); + s.execute("selection.set", json!({"range": "H20"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(s, Default::default()), + ); + h.run_steps(4); + h +} + +/// Painted text shapes whose text satisfies `f`: (text, left x, width). +fn texts(h: &Harness, f: impl Fn(&str) -> bool) -> Vec<(String, f32, f32)> { + h.output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if f(&t.galley.job.text) => Some((t.galley.job.text.clone(), t.pos.x, t.galley.size().x)), + _ => None, + }) + .collect() +} + +#[test] +fn too_wide_fill_number_shows_hashes_not_a_clipped_value() { + let h = harness(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let a2 = geo.cell_rect(sheet, CellRef::parse("A2").unwrap()); + let b2 = geo.cell_rect(sheet, CellRef::parse("B2").unwrap()); + // A2 is drawn as #### inside its cell. + let hashes = texts(&h, |t| !t.is_empty() && t.chars().all(|c| c == '#')); + assert!(hashes.iter().any(|(_, x, w)| *x >= a2.left() && x + w <= a2.right() + 0.5), "A2 shows ####: {hashes:?}"); + // The only 2,509.00 drawn is B2's, whole and inside column B. + let values = texts(&h, |t| t.contains("509.00")); + assert_eq!(values.len(), 1, "{values:?}"); + let (text, x, w) = &values[0]; + assert!(text.contains("2,509.00 CHF"), "{text:?}"); + assert!(*x >= b2.left() && x + w <= b2.right() + 0.5, "B2's value stays in its cell: {values:?}"); +} + +#[test] +fn fill_spreads_a_fitting_number_across_its_cell() { + let h = harness(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let b1 = geo.cell_rect(sheet, CellRef::parse("B1").unwrap()); + let drawn = texts(&h, |t| t.contains("344.75 CHF")); + let (_, x, w) = drawn.iter().find(|(_, x, _)| *x >= b1.left()).expect("B1 is drawn"); + // The fill pushes the value to the right edge (less the trailing `_-` space). + assert!(b1.right() - (x + w) < 6.0, "B1 ends at its right edge: {drawn:?} in {b1:?}"); + assert!(*x - b1.left() < 6.0, "the fill starts at the left edge: {drawn:?} in {b1:?}"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..601e19d9d 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 (a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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 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 @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | A number in a repeat-fill format (`*`, as in the Accounting format) that is too wide for its column shows #### like any other number, instead of the fill rebuilding the value so its leading digits were cut off (2,509.00 CHF drawn as "509.00 CHF") | | 2026-10-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 99f92f4a28e0c02d661050198499f5daeb6efece Mon Sep 17 00:00:00 2001 From: Hamza Date: Thu, 22 Oct 2026 18:05:53 +0500 Subject: [PATCH 020/149] range.setValues: `parse: false` stores a string as the text it is (#2255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every string in `values` went through typed input, which reads it with en-US rules, and nothing said so: "05.04.2026" (dd.mm.yyyy) → the date 4 May 2026 "02/11/2026" (dd/mm/yyyy) → the date 11 February 2026 "2.500" (thousands) → the number 2.5 "4.25 %" (text) → 0.0425, formatted 0.00% A workbook built from Turkish-format exports therefore held different values than the script put in it, and the only way to notice was to read the saved file back cell by cell. `parse: false` stores every string as the text it is. A formula is still a formula — `=` means a formula in a cell, and the ticket asks for that — and numbers, booleans and null are unaffected either way. A leading apostrophe is part of the text rather than the marker that makes it text, since nothing is being marked. The default is unchanged: `parse` is absent in every existing call, and those calls behave exactly as before. A `parse` that is neither true nor false is a usage error rather than a quiet default. The test pins both sides — the swapped date, the decimal point and the percentage under today's default; each of the six strings stored verbatim with `parse: false`, with `=1+1` still calculating 2; numbers and booleans either way; and the refused `parse` values. Without the change the first string comes back as a date. --- crates/engine/src/cmd/edit.rs | 62 ++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e53740712..67920264d 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Set Values", [], None, - "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}", + "{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}", has_doc, range_set_values ), @@ -299,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { let sheet = target_sheet(s, p)?; let start = target_range(s, p)?.start; let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) }; + // A string is run through typed input by default, which reads it with en-US rules: a caller + // sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence + // (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas + // are unaffected either way. + let parse = match p.get("parse") { + None | Some(Json::Null) => true, + Some(Json::Bool(b)) => *b, + Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))), + }; if rows.len() > MAX_ROWS as usize { return Err(bad("range.setValues", "too many rows")); } @@ -321,6 +330,14 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { Json::Bool(b) => { Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() }) } + // Text as given: no date or number reading, and a leading `'` is part of the + // text rather than the marker that makes it text. A formula is still a + // formula, which is what the ticket asks for and what `=` means in a cell. + Json::String(text) if !parse && !text.starts_with('=') => Some(Cell { + value: Value::Text(text.as_str().into()), + formula: None, + style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), + }), _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; @@ -1769,6 +1786,49 @@ mod tests { assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err()); } + /// Every string in `values` went through typed input, read with en-US rules, in silence: a + /// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and + /// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is. + #[test] + fn set_values_can_store_strings_as_the_text_they_are() { + let mut s = Session::new(); + s.new_workbook(); + let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]; + let row: Vec = strings.iter().map(|v| json!(v)).collect(); + let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap(); + + // The default is unchanged: the dates swap, the thousands separator is a decimal point. + s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap(); + assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy"); + assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal"); + assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage"); + + // With `parse: false` each string is the text it was given, and a formula is still a + // formula. + s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap(); + for (i, want) in strings.iter().enumerate() { + let cell = format!("{}3", (b'A' + i as u8) as char); + let got = get(&mut s, &cell); + if *want == "=1+1" { + assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}"); + assert_eq!(got["formula"], "=1+1", "{got}"); + } else { + assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}"); + assert!(got["formula"].is_null(), "{cell} is not a formula: {got}"); + } + } + // Numbers and booleans are untouched either way. + s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap(); + assert_eq!(get(&mut s, "A5")["value"], json!(12.5)); + assert_eq!(get(&mut s, "B5")["value"], json!(true)); + assert_eq!(get(&mut s, "C5")["text"], ""); + // `parse` is a flag, and anything else is a usage error rather than a quiet default. + s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap(); + assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); + assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + } + #[test] fn legacy_array_entry() { let mut s = Session::new(); From 840ba5c3a5107b642ad243800d53ef095702434b Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 15:54:06 +0200 Subject: [PATCH 021/149] view: Show Outline Symbols (Ctrl+8), read and written A sheet can hide its outline's level buttons and brackets while keeping the outline (Excel's Ctrl+8, saved as showOutlineSymbols="0"). It wasn't modelled, so such sheets showed a wide outline band Excel doesn't. - model: Sheet::show_outline_symbols (default on, also for older JSON). - xlsx: read and written on the sheet view. - engine: view.showOutlineSymbols {on?} (Ctrl+8), undoable. - ui: no outline bands while it's off. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/view.rs | 2 ++ crates/engine/src/tests.rs | 15 +++++++++++++++ crates/model/src/sheet.rs | 9 +++++++++ crates/ui-egui/src/grid.rs | 28 ++++++++++++++++++++++++---- crates/xlsx/src/sheet_read.rs | 1 + crates/xlsx/src/sheet_write.rs | 3 +++ crates/xlsx/src/tests/malformed.rs | 13 +++++++++++++ docs/ui-parity.md | 3 ++- 8 files changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index f0b2f77d3..b42aec9a6 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -35,6 +35,7 @@ pub fn specs() -> Vec { cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")), cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")), cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")), + cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")), cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects), cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")), cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom), @@ -166,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result { "gridlines" => &mut sh.show_gridlines, "headings" => &mut sh.show_headings, "zeros" => &mut sh.show_zeros, + "outlineSymbols" => &mut sh.show_outline_symbols, "rtl" => &mut sh.right_to_left, "printGridlines" => &mut sh.print.gridlines, _ => &mut sh.print.headings, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d6a99be91..81441c827 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2595,6 +2595,21 @@ fn file_options_reports_settings_and_asks_for_the_options_page() { assert_eq!(s.active().unwrap().wb, before, "Options never changes the workbook"); } +/// Ctrl+8 hides and shows the outline symbols; it's undoable and a sheet setting. +#[test] +fn show_outline_symbols_toggles() { + let mut s = s(); + let on = |s: &Session| s.active().unwrap().wb.active().unwrap().show_outline_symbols; + assert!(on(&s)); + assert_eq!(s.execute("view.showOutlineSymbols", json!({})).unwrap(), json!({"on": false})); + assert!(!on(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(on(&s)); + s.execute("view.showOutlineSymbols", json!({"on": false})).unwrap(); + reopen(&mut s); + assert!(!on(&s)); +} + fn reopen(s: &mut Session) { let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap(); let b64 = r["base64"].as_str().unwrap().to_string(); diff --git a/crates/model/src/sheet.rs b/crates/model/src/sheet.rs index 1901d8048..f7ef8f3be 100644 --- a/crates/model/src/sheet.rs +++ b/crates/model/src/sheet.rs @@ -76,6 +76,10 @@ pub struct Sheet { pub show_headings: bool, pub show_formulas: bool, pub show_zeros: bool, + /// The outline's level buttons and brackets (Excel's Show Outline Symbols, Ctrl+8); the + /// outline itself stays. + #[serde(default = "yes")] + pub show_outline_symbols: bool, pub right_to_left: bool, pub zoom: u16, pub cond_formats: Vec, @@ -113,6 +117,10 @@ pub struct Sheet { pub preserved: Option>, } +fn yes() -> bool { + true +} + /// Default width of a column and height of a row (pixels at 96 dpi) for the default font. pub const DEFAULT_COL_WIDTH: f32 = 64.0; pub const DEFAULT_ROW_HEIGHT: f32 = 20.0; @@ -140,6 +148,7 @@ impl Sheet { tab_color: None, visibility: Visibility::Visible, show_gridlines: true, + show_outline_symbols: true, show_headings: true, show_formulas: false, show_zeros: true, diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..78e558cce 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -235,13 +235,15 @@ impl Geo { let frozen_h = (ri.start(fr) as f32) * z; let row_outline = Arc::new(crate::outline::Axis::new(&sh.rows)); let col_outline = Arc::new(crate::outline::Axis::new(&sh.cols)); - let mut band_w = crate::outline::band(row_outline.levels, zoom); - let mut band_h = crate::outline::band(col_outline.levels, zoom); + // Show Outline Symbols off: no bands (the outline's hidden lines stay hidden). + let symbols = sh.show_outline_symbols; + let mut band_w = if symbols { crate::outline::band(row_outline.levels, zoom) } else { 0.0 }; + let mut band_h = if symbols { crate::outline::band(col_outline.levels, zoom) } else { 0.0 }; // Without headings the level buttons still need a corner to sit in. - if row_outline.levels > 0 && header_h == 0.0 { + if symbols && row_outline.levels > 0 && header_h == 0.0 { band_h = band_h.max(crate::outline::lane(zoom) + 3.0); } - if col_outline.levels > 0 && header_w == 0.0 { + if symbols && col_outline.levels > 0 && header_w == 0.0 { band_w = band_w.max(crate::outline::lane(zoom) + 3.0); } let cells = Rect::from_min_max(pos2(rect.left() + band_w + header_w, rect.top() + band_h + header_h), rect.max); @@ -4216,6 +4218,24 @@ mod pane_tests { assert!(range_in_pane(sel, (2, 20), (2, 10))); } + #[test] + fn hidden_outline_symbols_take_no_room() { + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0)); + let mut sh = Sheet::new("S"); + sh.rows.entry(3).or_default().outline = 1; + sh.cols.entry(2).or_default().outline = 2; + let shown = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert!(shown.band_w > 0.0 && shown.band_h > 0.0); + sh.show_outline_symbols = false; + let hidden = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((hidden.band_w, hidden.band_h), (0.0, 0.0)); + assert!(hidden.cells.left() < shown.cells.left() && hidden.cells.top() < shown.cells.top()); + // Without headings too. + sh.show_headings = false; + let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); + assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + } + /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. fn frozen_geo(sh: &mut Sheet) -> Geo { sh.freeze = Some((2, 2)); diff --git a/crates/xlsx/src/sheet_read.rs b/crates/xlsx/src/sheet_read.rs index 83955ee1f..5af3aeab3 100644 --- a/crates/xlsx/src/sheet_read.rs +++ b/crates/xlsx/src/sheet_read.rs @@ -274,6 +274,7 @@ fn read_views(e: &El, sheet: &mut Sheet) { sheet.show_headings = v.flag("showRowColHeaders", true); sheet.show_formulas = v.flag("showFormulas", false); sheet.show_zeros = v.flag("showZeros", true); + sheet.show_outline_symbols = v.flag("showOutlineSymbols", true); sheet.right_to_left = v.flag("rightToLeft", false); sheet.view = v.attr("view").and_then(gridcraft_model::SheetViewMode::parse).unwrap_or_default(); if let Some(z) = v.attr_u32("zoomScale") { diff --git a/crates/xlsx/src/sheet_write.rs b/crates/xlsx/src/sheet_write.rs index daac03daa..e0e863ec3 100644 --- a/crates/xlsx/src/sheet_write.rs +++ b/crates/xlsx/src/sheet_write.rs @@ -172,6 +172,9 @@ pub fn write_sheet(wb: &Workbook, si: usize, selected: bool, out: &mut Out) -> ( if !sheet.show_zeros { s.push_str(" showZeros=\"0\""); } + if !sheet.show_outline_symbols { + s.push_str(" showOutlineSymbols=\"0\""); + } if sheet.right_to_left { s.push_str(" rightToLeft=\"1\""); } diff --git a/crates/xlsx/src/tests/malformed.rs b/crates/xlsx/src/tests/malformed.rs index e746b01de..91b5d4b9c 100644 --- a/crates/xlsx/src/tests/malformed.rs +++ b/crates/xlsx/src/tests/malformed.rs @@ -330,3 +330,16 @@ fn ordinary_overlapping_column_ranges_keep_last_declaration() { assert!(last.hidden); assert_eq!(wb.styles.get(last.style.unwrap()).num_fmt.as_str(), "0.00"); } + +/// Show Outline Symbols off (`showOutlineSymbols="0"`) is read and written back. +#[test] +fn outline_symbols_flag_round_trips() { + let body = r#""#; + let (wb, _) = read_xlsx(&minimal(body, &[], "", "")).unwrap(); + assert!(!wb.sheet(0).unwrap().show_outline_symbols); + let (back, _) = read_xlsx(&crate::write_xlsx(&wb).unwrap()).unwrap(); + assert!(!back.sheet(0).unwrap().show_outline_symbols); + // Absent means shown. + let (wb, _) = read_xlsx(&minimal("", &[], "", "")).unwrap(); + assert!(wb.sheet(0).unwrap().show_outline_symbols); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..404875b5d 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 (Show Outline Symbols, Ctrl+8; 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 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 @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | | 2026-10-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 5bcbaacce7a9de54c4bd2960f25998a2ecc6a8bb Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:02:58 +0200 Subject: [PATCH 022/149] Snapshot: catalog installed fonts before rendering The offscreen snapshot never started the background font scan the app runs from its first frame, so a workbook's installed fonts (Helvetica Neue, say) were drawn in the Calibri substitute. Catalog them up front and run a few more frames so they bind, as in the app. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/snapshot.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/examples/snapshot.rs b/crates/ui-egui/examples/snapshot.rs index dcee71173..9eb3cc69c 100644 --- a/crates/ui-egui/examples/snapshot.rs +++ b/crates/ui-egui/examples/snapshot.rs @@ -79,6 +79,9 @@ fn main() { } i += 1; } + // Catalog the installed fonts first (the app scans in the background from its first frame), so + // the workbook's fonts are drawn in their own faces, not a substitute. + let _ = gridcraft_ui_egui::fonts::catalog(); let mut session = Session::new(); if let Some(p) = &input { session.execute("file.open", json!({"path": p})).expect("open"); @@ -127,7 +130,9 @@ fn main() { app }, ); - harness.run_steps(8); + // The workbook's installed fonts bind once the font scan is done; frames past the first few + // apply them, so the snapshot shows them as the app does. + harness.run_steps(14); let img = harness.render().expect("render"); img.save(&out).expect("save png"); eprintln!("wrote {out} ({}x{})", img.width(), img.height()); From e6ffca60bb2356e87d64f94e6c59b2af626e64c5 Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:09:29 +0200 Subject: [PATCH 023/149] view: hidden outline symbols answer no clicks; parity rows updated With headings hidden too, a level button sat across the empty column band at the grid's top edge, so a click there still ran it. Outline hit testing now stops when both bands are empty. The parity doc no longer lists Ctrl+8 as missing. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 10 ++++++++++ crates/ui-egui/src/outline.rs | 4 ++++ docs/ui-parity.md | 6 +++--- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 78e558cce..78116b4ec 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -4234,6 +4234,16 @@ mod pane_tests { sh.show_headings = false; let bare = Geo::new(&sh, rect, vec2(0.0, 0.0)); assert_eq!((bare.band_w, bare.band_h), (0.0, 0.0)); + // No button answers a click on the empty bands' edges (the grid's left and top), where the + // level buttons would sit with no headings to hold them. + for geo in [&hidden, &bare] { + for i in 0..400 { + let t = i as f32 * 0.5; + for p in [pos2(geo.rect.left(), geo.rect.top() + t), pos2(geo.rect.left() + t, geo.rect.top())] { + assert_eq!(crate::outline::hit(geo, &sh, p), None, "{p:?}"); + } + } + } } /// Two frozen rows and columns, scrolled right so that columns C:E lie under the frozen columns. diff --git a/crates/ui-egui/src/outline.rs b/crates/ui-egui/src/outline.rs index 61c40811c..19fd88497 100644 --- a/crates/ui-egui/src/outline.rs +++ b/crates/ui-egui/src/outline.rs @@ -224,6 +224,10 @@ pub fn layout(geo: &Geo, sh: &Sheet) -> Layout { /// The button under `p` (logical coordinates), if any. pub fn hit(geo: &Geo, sh: &Sheet, p: Pos2) -> Option { + // Hidden symbols (or no outline) leave empty bands: a click on their edge is a cell click. + if geo.band_w <= 0.0 && geo.band_h <= 0.0 { + return None; + } let (rows, cols) = bands(geo); if !rows.contains(p) && !cols.contains(p) { return None; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 404875b5d..d35ed50b7 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -60,7 +60,7 @@ this logic, with one undo step. | AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) | | Header resize and double-click AutoFit | done (#42, #88 for header dividers) | | Data › Advanced Filter | dialog offers in-place or copy-to filtering, list and optional criteria ranges, and unique records; Alt+A,Q opens it (#1622). The engine's copy-to path clears only the result footprint and rejects worksheet overflow (#1031). | -| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog | +| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), View › Show Outline Symbols (Ctrl+8) hides the bands and is saved with the sheet, and a block of cells still groups rows without the Rows / Columns dialog | | Reorder selected whole columns by dragging their headers | done (10-11) | | Freeze panes, split panes | freeze done; split to verify | | A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) | @@ -135,9 +135,9 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+8, Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | outline symbols, Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | -| Ctrl+6, Ctrl+8, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, outline symbols, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | From 74f29a743f51292c9c418af12cc27bd4caaccbaa Mon Sep 17 00:00:00 2001 From: Dominik Strebel Date: Thu, 22 Oct 2026 16:18:45 +0200 Subject: [PATCH 024/149] docs: Ctrl+Shift+A (argument names) is no longer listed as missing Co-Authored-By: Claude Opus 5.5 --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d35ed50b7..0fd350aee 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -135,7 +135,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+Q, Ctrl+Shift+A, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, argument names, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | | Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | From b5b3969377b125cd782768ace8cfbaee06f57bc9 Mon Sep 17 00:00:00 2001 From: Higgs32584 Date: Thu, 22 Oct 2026 12:43:48 -0400 Subject: [PATCH 025/149] scripts: trust gate review fixes (#2178) - automate.trustScripts: only a missing `trusted` defaults to true; a non-boolean value is an error and leaves the workbook untrusted. - The refusal message points at Trust Scripts in View > Macros (Alt+F8) and at automate.trustScripts. - Catalog row for automate.trustScripts; parity checklist regenerated. - Script steps run inside an io::ScriptSandbox (thread-local) so read_file/write_file refuse whatever the parameter is called, and a step that raises OpenUrl is dropped and stops the script. The id/param deny list stays as the up-front refusal. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/catalog.rs | 1 + crates/engine/src/cmd/extra.rs | 28 ++++++++++++--- crates/engine/src/io.rs | 62 ++++++++++++++++++++++++++++++++++ crates/engine/src/tests.rs | 38 +++++++++++++++++++++ docs/parity-checklist.md | 7 ++-- 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6b67e1b4..007cbf4bc 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[ ("automate.newScript", "Automate", "Scripting Tools", "New Script"), ("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"), ("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"), + ("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"), // Table Design ("table.rename", "Table Design", "Properties", "Table Name"), ("table.resize", "Table Design", "Properties", "Resize Table"), diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index b2d8c352d..f248c59f2 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -1017,7 +1017,10 @@ pub(crate) fn has_scripts(wb: &Workbook) -> bool { /// Why a script step is refused, whatever the workbook's trust: scripts stay inside the /// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data, /// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run -/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) +/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by +/// command id and parameter name, so it is only the up-front refusal: the steps also run inside +/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and +/// [`run_script`] drops any `OpenUrl` request a step raises. fn denied_script_step(id: &str, params: &Json) -> Option { if id.starts_with("file.") || id.starts_with("link.") { return Some(format!("scripts can't run `{id}`: it reaches outside the workbook")); @@ -1029,7 +1032,12 @@ fn denied_script_step(id: &str, params: &Json) -> Option { } fn trust_scripts(s: &mut Session, p: &Json) -> Result { - let trusted = bool_param(p, "trusted").unwrap_or(true); + // Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent. + let trusted = match p.get("trusted") { + None => true, + Some(Json::Bool(b)) => *b, + Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")), + }; s.doc_mut()?.scripts_trusted = trusted; Ok(json!({"trusted": trusted})) } @@ -1045,7 +1053,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result { if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) { return Err(bad( "automate.runScript", - "scripts in this workbook came from a file and are disabled; trust the workbook first (Automate › Macros › Trust Scripts, or `trusted: true`)", + "scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)", )); } let steps: Vec = steps.into_iter().take(100_000).collect(); @@ -1055,13 +1063,25 @@ fn run_script(s: &mut Session, p: &Json) -> Result { return Err(bad("automate.runScript", why)); } } + let _sandbox = crate::io::ScriptSandbox::enter(); let mut n = 0; for st in &steps { let id = st["command"].as_str().unwrap_or(""); if id.starts_with("automate.") { continue; } - s.execute(id, st["params"].clone())?; + let before = s.ui_requests.len(); + let ran = s.execute(id, st["params"].clone()); + // A step that asks the host to open a URL leaves the app: drop the request and stop. + if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) { + let mut i = 0; + s.ui_requests.retain(|r| { + i += 1; + i <= before || !matches!(r, crate::UiRequest::OpenUrl(_)) + }); + return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)"))); + } + ran?; n += 1; } Ok(json!({"ran": n})) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5c0bf800b..b18e8577b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -556,8 +556,41 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result { Ok(out) } +thread_local! { + /// How many [`ScriptSandbox`]es are alive on this thread. + static SCRIPT_SANDBOX: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a +/// stored script's steps run inside one (#2178), so no command reaches the file system from a +/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal. +pub(crate) struct ScriptSandbox(()); + +impl ScriptSandbox { + pub(crate) fn enter() -> Self { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1))); + ScriptSandbox(()) + } +} + +impl Drop for ScriptSandbox { + fn drop(&mut self) { + let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1))); + } +} + +/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the +/// thread is being torn down). +fn script_sandbox_check(path: &str) -> Result<()> { + if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) { + return Err(EngineError::Other(format!("{path}: scripts can't read or write files"))); + } + Ok(()) +} + #[cfg(not(target_arch = "wasm32"))] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; if meta.len() > 2 * 1024 * 1024 * 1024 { return Err(EngineError::Other("files larger than 2 GB aren't supported".into())); @@ -567,11 +600,13 @@ pub fn read_file(path: &str) -> Result> { #[cfg(target_arch = "wasm32")] pub fn read_file(path: &str) -> Result> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } #[cfg(not(target_arch = "wasm32"))] pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; // Write to a temporary file, then rename: a crash never leaves a half-written workbook. let tmp = format!("{path}.sctmp"); std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| { @@ -584,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { #[cfg(target_arch = "wasm32")] pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> { + script_sandbox_check(path)?; Err(EngineError::Other(format!("{path}: no file system in the browser"))) } @@ -902,6 +938,32 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// While a script runs (#2178), file reads and writes are refused whatever command or + /// parameter asked for them; nesting and dropping the sandbox restore access. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn script_sandbox_refuses_file_access_until_dropped() { + let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let existing = dir.join("in.csv"); + std::fs::write(&existing, b"a,b\n").unwrap(); + let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string()); + { + let _outer = ScriptSandbox::enter(); + { + let _inner = ScriptSandbox::enter(); + assert!(read_file(&existing).is_err()); + } + assert!(read_file(&existing).is_err(), "the outer sandbox still holds"); + assert!(write_file(&out, b"leak").is_err()); + assert!(!std::path::Path::new(&out).exists()); + } + assert_eq!(read_file(&existing).unwrap(), b"a,b\n"); + write_file(&out, b"ok").unwrap(); + let _ = std::fs::remove_dir_all(&dir); + } + /// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a /// root entry and the given stream names. fn cfb(streams: &[&str]) -> Vec { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 567148d41..dcab1d204 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1147,6 +1147,44 @@ fn untrusted_workbook_scripts_are_refused_until_trusted() { assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); } +#[test] +fn trust_scripts_only_defaults_to_trusted_when_the_flag_is_missing() { + let mut s = opened_with_script(); + // A `trusted` that isn't a boolean is an error and leaves the workbook untrusted. + for wrong in [json!("false"), json!("true"), json!(0), json!(1), json!(null), json!([true])] { + let err = s.execute("automate.trustScripts", json!({"trusted": wrong})).unwrap_err().to_string(); + assert!(err.contains("trusted"), "{wrong}: {err}"); + assert!(!s.doc().unwrap().scripts_trusted, "{wrong} must not trust the workbook"); + assert!(s.execute("automate.runScript", json!({"name": "Fill"})).is_err()); + } + // The refusal names where the Trust Scripts button actually is. + let err = s.execute("automate.runScript", json!({"name": "Fill"})).unwrap_err().to_string(); + assert!(err.contains("View › Macros") && err.contains("automate.trustScripts"), "{err}"); + assert_eq!(s.execute("automate.trustScripts", json!({"trusted": false})).unwrap()["trusted"], false); + assert!(!s.doc().unwrap().scripts_trusted); + assert_eq!(s.execute("automate.trustScripts", json!({})).unwrap()["trusted"], true); + assert!(s.doc().unwrap().scripts_trusted); +} + +#[test] +fn scripts_leave_file_access_working_after_they_run() { + // The sandbox a script runs in is lifted afterwards, also when a step fails. + let dir = std::env::temp_dir().join(format!("gridcraft-script-sandbox-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let csv = dir.join("in.csv"); + std::fs::write(&csv, b"a,b\n1,2\n").unwrap(); + let csv = csv.to_string_lossy().to_string(); + let mut s = s(); + let bad_step = json!({"command": "cell.set", "params": {"cell": "not a cell", "input": "x"}}); + s.execute("automate.newScript", json!({"name": "Bad", "commands": [bad_step]})).unwrap(); + assert!(s.execute("automate.runScript", json!({"name": "Bad"})).is_err()); + s.execute("automate.newScript", json!({"name": "Ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "1"}}]})).unwrap(); + s.execute("automate.runScript", json!({"name": "Ok"})).unwrap(); + s.execute("data.fromTextCsv", json!({"path": csv})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn scripts_cannot_reach_outside_the_workbook_even_when_trusted() { let dir = std::env::temp_dir().join(format!("gridcraft-script-deny-{}", std::process::id())); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d218c8169..ee8bcfb75 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -4,7 +4,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md). -**Overall: 274 / 290 commands implemented (94.5%).** +**Overall: 275 / 291 commands implemented (94.5%).** | Tab | Implemented | Total | Parity | |---|---|---|---| @@ -17,7 +17,7 @@ Each entry is an Excel ribbon or menu command (feature names only). It counts as | Data | 23 | 28 | 82% | | Review | 15 | 18 | 83% | | View | 20 | 20 | 100% | -| Automate | 3 | 3 | 100% | +| Automate | 4 | 4 | 100% | | Table Design | 13 | 13 | 100% | | Chart Design | 8 | 8 | 100% | | Format | 3 | 3 | 100% | @@ -324,13 +324,14 @@ Implemented: - `view.switchWindows` Switch Windows (Window) - `view.macros` Macros (Macros) -## Automate (3 / 3, 100%) +## Automate (4 / 4, 100%) Implemented: - `automate.newScript` New Script (Scripting Tools) - `automate.allScripts` All Scripts (Scripting Tools) - `automate.recordActions` Record Actions (Scripting Tools) +- `automate.trustScripts` Trust Scripts (Scripting Tools) ## Table Design (13 / 13, 100%) From 9cbb674c550e1bdca3a2be9a008e8a455b00f3f8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:34:06 +0200 Subject: [PATCH 026/149] Print numbers near f64::MAX in a form that parses back Found while testing crates/formula/src/printer.rs:134 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/formula/src/printer.rs | 11 ++++++++++- crates/formula/tests/printer_roundtrip.rs | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 crates/formula/tests/printer_roundtrip.rs diff --git a/crates/formula/src/printer.rs b/crates/formula/src/printer.rs index 055b0f145..ba659c188 100644 --- a/crates/formula/src/printer.rs +++ b/crates/formula/src/printer.rs @@ -132,7 +132,16 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String { } fn number(n: f64) -> String { - number_to_text(n) + let text = number_to_text(n); + // 15 significant digits can round past f64::MAX, which no longer parses. + if text.parse::().is_ok_and(f64::is_finite) { + return text; + } + let exact = format!("{:E}", n); + match exact.split_once('E') { + Some((m, e)) if !e.starts_with('-') => format!("{m}E+{e}"), + _ => exact, + } } /// Prints an expression in A1 notation. diff --git a/crates/formula/tests/printer_roundtrip.rs b/crates/formula/tests/printer_roundtrip.rs new file mode 100644 index 000000000..206e5efff --- /dev/null +++ b/crates/formula/tests/printer_roundtrip.rs @@ -0,0 +1,15 @@ +use gridcraft_formula::{Expr, parse, print}; + +#[test] +fn number_near_f64_max_round_trips() { + for src in ["=1.7976931348623157E308", "=-1.7976931348623157E308"] { + let e = parse(src).unwrap(); + let printed = print(&e); + let back = parse(&printed).unwrap_or_else(|err| panic!("{printed}: {err:?}")); + assert_eq!(print(&back), printed); + match (&e, &back) { + (Expr::Number(a), Expr::Number(b)) => assert_eq!(a, b), + _ => assert_eq!(e, back), + } + } +} From a8e2ed31b110bf60e70ba7c860ab798fbd28ffe9 Mon Sep 17 00:00:00 2001 From: coolvitto <160416811+coolvitto@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:05:42 +0900 Subject: [PATCH 027/149] Add extra Japanese translations for UI elements Added extra labels for ribbon menus and pop-ups in Japanese. --- crates/ui-egui/src/i18n/ja.rs | 164 +++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 254ff68f9..09461b4bd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -980,10 +980,160 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[ ("Unprotect Sheet…", "シート保護の解除…"), ]; +/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the +/// Portuguese catalog has no entry for them either). Kept apart so the two catalogs above keep the +/// same key set; it never overlaps them. +pub(super) const EXTRA: &[(&str, &str)] = &[ + ("Cut (⌘X)", "切り取り (⌘X)"), + ("Copy (⌘C)", "コピー (⌘C)"), + ("Bold (⌘B)", "太字 (⌘B)"), + ("Italic (⌘I)", "斜体 (⌘I)"), + ("Underline (⌘U)", "下線 (⌘U)"), + ("AutoSum (⌘⇧T)", "オート SUM (⌘⇧T)"), + ("Filter (⌘⇧F)", "フィルター (⌘⇧F)"), + ("Flash Fill (⌘E)", "フラッシュ フィル (⌘E)"), + ("Insert Link (⌘K)", "リンクの挿入 (⌘K)"), + ("Paste (⌘V)", "貼り付け (⌘V)"), + ("Redo (⌘Y)", "やり直し (⌘Y)"), + ("Save (⌘S)", "上書き保存 (⌘S)"), + ("Search commands (⌘⇧Space)", "コマンドの検索 (⌘⇧Space)"), + ("Table (⌘T)", "テーブル (⌘T)"), + ("Undo (⌘Z)", "元に戻す (⌘Z)"), + ("New Workbook", "新しいブック"), + ("Save As…", "名前を付けて保存…"), + ("Print…", "印刷…"), + ("Export as PDF…", "PDF としてエクスポート…"), + ("Save a Copy as Excel Workbook (.xlsx)…", "Excel ブック (.xlsx) としてコピーを保存…"), + ("Export as CSV…", "CSV としてエクスポート…"), + ("Export as Web Page (.html)…", "Web ページ (.html) としてエクスポート…"), + ("Bottom Border", "下罫線"), + ("Top Border", "上罫線"), + ("Left Border", "左罫線"), + ("Right Border", "右罫線"), + ("No Border", "枠なし"), + ("All Borders", "格子"), + ("Outside Borders", "外枠"), + ("Thick Outside Borders", "太い外枠"), + ("Bottom Double Border", "下二重罫線"), + ("Thick Bottom Border", "下太罫線"), + ("Top and Bottom Border", "上下罫線"), + ("Top and Thick Bottom Border", "上罫線 + 下太罫線"), + ("Top and Double Bottom Border", "上罫線 + 下二重罫線"), + ("Inside Borders", "内側罫線"), + ("More Borders…", "その他の罫線…"), + ("More Functions…", "その他の関数…"), + ("No Fill", "塗りつぶしなし"), + ("Theme Colors", "テーマの色"), + ("Standard Colors", "標準の色"), + ("Highlight Cells Rules", "セルの強調表示ルール"), + ("Greater Than…", "指定の値より大きい…"), + ("Less Than…", "指定の値より小さい…"), + ("Between…", "指定の範囲内…"), + ("Equal To…", "指定の値に等しい…"), + ("Text that Contains…", "文字列…"), + ("A Date Occurring…", "日付…"), + ("Duplicate Values…", "重複する値…"), + ("Top/Bottom Rules", "上位/下位ルール"), + ("Blue", "青"), + ("Green", "緑"), + ("Red", "赤"), + ("Orange", "オレンジ"), + ("Light Blue", "水色"), + ("Purple", "紫"), + ("Green - Yellow - Red", "緑 - 黄 - 赤"), + ("Red - Yellow - Green", "赤 - 黄 - 緑"), + ("Green - White - Red", "緑 - 白 - 赤"), + ("Blue - White - Red", "青 - 白 - 赤"), + ("White - Green", "白 - 緑"), + ("White - Red", "白 - 赤"), + ("3 Arrows", "3 つの矢印"), + ("3 Traffic Lights", "3 つの信号"), + ("3 Symbols", "3 つの記号"), + ("4 Ratings", "4 段階評価"), + ("5 Arrows", "5 つの矢印"), + ("5 Quarters", "5 つの四分円"), + ("New Rule…", "新しいルール…"), + ("Clear Rules", "ルールのクリア"), + ("Manage Rules…", "ルールの管理…"), + ("Good, Bad and Neutral", "良い、悪い、どちらでもない"), + ("Bad", "悪い"), + ("Good", "良い"), + ("Neutral", "どちらでもない"), + ("Data and Model", "データとモデル"), + ("Calculation", "計算"), + ("Check Cell", "チェック セル"), + ("Explanatory Text", "説明文"), + ("Input", "入力"), + ("Linked Cell", "リンク セル"), + ("Note", "メモ"), + ("Output", "出力"), + ("Warning Text", "警告文"), + ("Titles and Headings", "タイトルと見出し"), + ("Heading 1", "見出し 1"), + ("Heading 2", "見出し 2"), + ("Heading 3", "見出し 3"), + ("Heading 4", "見出し 4"), + ("Total", "集計"), + ("Themed Cell Styles", "テーマのセルのスタイル"), + ("Number Format", "表示形式"), + ("Comma [0]", "桁区切り [0]"), + ("Currency [0]", "通貨表示形式 [0]"), + ("Percent", "パーセント"), + ("Place in Cell", "セル内に配置"), + ("Place over Cells", "セルの上に配置"), + ("Rectangle", "四角形"), + ("Rounded Rectangle", "角丸四角形"), + ("Oval", "楕円"), + ("Triangle", "三角形"), + ("Arrow", "矢印"), + ("Column", "縦棒"), + ("Win/Loss", "勝敗"), + ("Type to filter…", "入力して絞り込み…"), + ("Sort Ascending", "昇順"), + ("Sort Descending", "降順"), + ("Search", "検索"), + ("Apply", "適用"), + ("20% - Accent1", "20% - アクセント 1"), + ("20% - Accent2", "20% - アクセント 2"), + ("20% - Accent3", "20% - アクセント 3"), + ("20% - Accent4", "20% - アクセント 4"), + ("20% - Accent5", "20% - アクセント 5"), + ("20% - Accent6", "20% - アクセント 6"), + ("40% - Accent1", "40% - アクセント 1"), + ("40% - Accent2", "40% - アクセント 2"), + ("40% - Accent3", "40% - アクセント 3"), + ("40% - Accent4", "40% - アクセント 4"), + ("40% - Accent5", "40% - アクセント 5"), + ("40% - Accent6", "40% - アクセント 6"), + ("60% - Accent1", "60% - アクセント 1"), + ("60% - Accent2", "60% - アクセント 2"), + ("60% - Accent3", "60% - アクセント 3"), + ("60% - Accent4", "60% - アクセント 4"), + ("60% - Accent5", "60% - アクセント 5"), + ("60% - Accent6", "60% - アクセント 6"), + ("Accent1", "アクセント 1"), + ("Accent2", "アクセント 2"), + ("Accent3", "アクセント 3"), + ("Accent4", "アクセント 4"), + ("Accent5", "アクセント 5"), + ("Accent6", "アクセント 6"), + ("Recent Colors", "最近使用した色"), + ("More Colors…", "その他の色…"), + ("Hex", "16進"), + ("Page Break Preview", "改ページ プレビュー"), + ("Bubble", "バブル"), + ("Box & Whisker", "箱ひげ図"), + ("Treemap", "ツリーマップ"), + ("Sunburst", "サンバースト"), + ("Stock", "株価"), + ("Bar", "横棒"), + ("Drag to fill cells. Drag from inside a cell to select a range.", "ドラッグしてセルを塗りつぶします。セルの内側からドラッグすると範囲を選択します。"), +]; + /// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can /// fall back to English. Dialog strings come first, the shared core second. pub(super) fn translate(text: &str) -> Option<&'static str> { - DIALOGS.iter().chain(CORE).find(|(english, _)| *english == text).map(|(_, translated)| *translated) + DIALOGS.iter().chain(CORE).chain(EXTRA).find(|(english, _)| *english == text).map(|(_, translated)| *translated) } #[cfg(test)] @@ -1088,6 +1238,18 @@ mod tests { assert_eq!(catalog_keys().count(), 954); } + #[test] + fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + let catalog: HashSet<&str> = catalog_keys().collect(); + let mut seen = HashSet::new(); + for (english, japanese) in EXTRA { + assert!(!japanese.trim().is_empty(), "empty translation for {english:?}"); + assert!(!catalog.contains(english), "{english:?} is already in CORE or DIALOGS"); + assert!(seen.insert(*english), "duplicate EXTRA key: {english:?}"); + assert_eq!(translate(english), Some(*japanese)); + } + } + #[test] fn covers_every_key_the_portuguese_catalog_translates() { let portuguese = portuguese_keys(); From 450f457535e3c8537e45986deff5b602cff00e76 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 11:06:01 -0700 Subject: [PATCH 028/149] Calc: a formula calculated inside another one shares its stack budget A cell that another cell's formula calculates on demand got its own evaluator, which started a new 896 KiB stack budget wherever it was called. Two cells that each count down from 131 with a LAMBDA, one read at the deepest call of the other, overflowed a 1 MiB thread. The budget of a formula calculated on demand is now counted from where the outermost formula started. When it runs out there, the formula is calculated again from the top, as a formula needed at the nesting limit is, so its value does not depend on the order cells are calculated in. --- crates/calc/src/eval.rs | 19 ++++++++++++++----- crates/calc/src/recalc.rs | 31 ++++++++++++++++++++++++++++++- crates/calc/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 1d39fd818..f2f5f5ef4 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -60,13 +60,13 @@ const MAX_LAMBDA_CALLS: usize = 140; /// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it, /// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames -/// above the formula. A cell that another cell's formula computes on demand gets its own `Evaluator`, -/// which starts its own budget. +/// above the formula. A cell that another cell's formula computes on demand shares the budget of the +/// outermost formula ([`Host::stack_top`]). const STACK_BUDGET: usize = 896 * 1024; /// The address of a local variable, which shows how far down the native stack this call is. #[inline(always)] -fn stack_position() -> usize { +pub(crate) fn stack_position() -> usize { let marker = 0u8; std::hint::black_box(&marker) as *const u8 as usize } @@ -98,6 +98,13 @@ pub trait Host { fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool { false } + /// While a formula is calculated on demand inside another one: `stack_position()` where the + /// outermost one started, which the stack budget is counted from. + fn stack_top(&self) -> Option { + None + } + /// Called when a formula's evaluation runs past the stack budget. + fn stack_exhausted(&mut self) {} } pub struct Evaluator<'h> { @@ -265,8 +272,10 @@ impl<'h> Evaluator<'h> { pub fn eval(&mut self, e: &Expr) -> Ev { let here = stack_position(); if self.depth == 0 { - self.stack_top = here; - } else if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.stack_top = self.host.stack_top().unwrap_or(here); + } + if self.stack_top.abs_diff(here) > STACK_BUDGET { + self.host.stack_exhausted(); return err(CellError::Num); } self.depth += 1; diff --git a/crates/calc/src/recalc.rs b/crates/calc/src/recalc.rs index 66374bed2..eabc29314 100644 --- a/crates/calc/src/recalc.rs +++ b/crates/calc/src/recalc.rs @@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value}; use gridcraft_formula::Expr; use gridcraft_model::{CalcMode, Workbook}; -use crate::eval::{Area, Evaluator, Host, precedents}; +use crate::eval::{Area, Evaluator, Host, precedents, stack_position}; pub type Key = (usize, CellRef); @@ -432,6 +432,10 @@ struct PassHost<'a> { tainted: HashSet, /// Whether the formula being evaluated has read a previous value. taint: bool, + /// `stack_position()` where the outermost formula being calculated started. + stack_top: usize, + /// Whether an evaluation ran past the stack budget since the innermost formula started. + out_of_stack: bool, } impl<'a> PassHost<'a> { @@ -462,6 +466,8 @@ impl<'a> PassHost<'a> { members: HashSet::default(), tainted: HashSet::default(), taint: false, + stack_top: 0, + out_of_stack: false, } } } @@ -580,6 +586,9 @@ impl PassHost<'_> { self.suspend = Some(k); return Value::Error(CellError::Calc); } + if self.depth == 0 { + self.stack_top = stack_position(); + } self.in_progress.insert(k); self.depth += 1; let outer_taint = std::mem::replace(&mut self.taint, false); @@ -587,10 +596,24 @@ impl PassHost<'_> { self.stack.push(k); } let expr = std::sync::Arc::clone(expr); + let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false); let v = { let mut ev = Evaluator::new(self, k.0, k.1); ev.value(&expr) }; + let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack); + if out_of_stack && self.depth > 1 && self.suspend.is_none() { + // It ran out of the stack left below the formulas that read it: it is calculated + // from the top instead, as a formula needed at `MAX_NESTED` levels deep is. + self.suspend = Some(k); + self.depth -= 1; + self.in_progress.remove(&k); + if self.iterate { + self.stack.pop(); + } + self.taint = outer_taint; + return Value::Error(CellError::Calc); + } if self.suspend.is_some() { // Abandoned: `calculate` evaluates this formula again once the one it needs is done. self.depth -= 1; @@ -745,6 +768,12 @@ impl Host for PassHost<'_> { fn workbook(&self) -> &Workbook { self.wb } + fn stack_top(&self) -> Option { + (self.depth > 1).then_some(self.stack_top) + } + fn stack_exhausted(&mut self) { + self.out_of_stack = true; + } fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option { let k = (sheet, anchor); if !(self.pending.contains(&k) || self.results.contains_key(&k)) { diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 608af9320..6106f1bfc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -834,6 +834,40 @@ fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() { assert_eq!(run(64 << 20), small); } +#[test] +fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() { + let run = |stack: usize| { + std::thread::Builder::new() + .stack_size(stack) + .spawn(|| { + // Row-major order calculates A1 first, so each cell below is calculated on demand + // at the deepest call of the one above it. + let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))"); + let mut wb = Workbook::new(); + let sheet = wb.sheet_mut(0).expect("sheet"); + for (at, f) in [ + ("A1", countdown("A2")), + ("A2", countdown("A3")), + ("A3", countdown("0")), + ("B1", countdown("B2")), + ("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()), + ] { + sheet.cells.set(c(at), Cell::formula(Formula::new(&f))); + } + let mut calc = Calc::new(); + calc.recalc_all(&mut wb); + ["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default()) + }) + .expect("spawn") + .join() + .expect("recursion must not overflow the stack") + }; + let small = run(1 << 20); + let num = Value::Error(CellError::Num); + assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]); + assert_eq!(run(64 << 20), small); +} + #[test] fn names_and_sheets() { let mut t = T::new(); From 09f78cb3a8dd116d524b3850608312c8e062be89 Mon Sep 17 00:00:00 2001 From: "D. Fender" <26173007+postfen@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:12:09 -0600 Subject: [PATCH 029/149] Tables: Format as Table inside a table restyles it Home > Format as Table on a cell or range inside an existing table failed with "A table can't overlap another table." Excel applies the picked style to that table instead. The command now returns that table and its range, as it does for a new table. A range crossing a table's edge is still rejected. Without a range, the active area of the selection decides, as it does for Insert > Table. The native Format menu's Format as Table now runs home.formatAsTable instead of insert.table. --- apps/gridcraft/src/native_menu.rs | 2 +- crates/engine/src/cmd/insert.rs | 19 +++++- .../engine/tests/format_as_table_restyle.rs | 61 +++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 crates/engine/tests/format_as_table_restyle.rs diff --git a/apps/gridcraft/src/native_menu.rs b/apps/gridcraft/src/native_menu.rs index 6985af3c0..7dc549cb7 100644 --- a/apps/gridcraft/src/native_menu.rs +++ b/apps/gridcraft/src/native_menu.rs @@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec)> { ("Unhide Sheet…", "sheet.unhide", None), ("-", "", None), ("Conditional Formatting…", "dialog:manageRules", None), - ("Format as Table", "insert.table", None), + ("Format as Table", "home.formatAsTable", None), ], ), ( diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index aae5a9faa..2388d1f25 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -26,9 +26,9 @@ pub fn specs() -> Vec { "Format as Table", ["Home", "Styles"], None, - "{range?, style: \"TableStyleMedium2\", header?}", + "{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)", has_doc, - insert_table + format_as_table ), cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag( s, p, "totals" @@ -351,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result { }) } +/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one. +fn format_as_table(s: &mut Session, p: &Json) -> Result { + // Without a range, the active area decides (as in insert_table), not the first one. + let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() }; + let sheet = target_sheet(s, p)?; + let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range)); + match inside { + Some((table, range)) => { + table_style(s, &with(p, "table", json!(table)))?; + Ok(json!({"table": table, "range": range.a1()})) + } + None => insert_table(s, p), + } +} + fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> { let d = s.doc()?; if let Some(n) = str_param(p, "table") { diff --git a/crates/engine/tests/format_as_table_restyle.rs b/crates/engine/tests/format_as_table_restyle.rs new file mode 100644 index 000000000..0419027c6 --- /dev/null +++ b/crates/engine/tests/format_as_table_restyle.rs @@ -0,0 +1,61 @@ +use gridcraft_engine::Session; +use serde_json::json; + +fn table() -> Session { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap(); + s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap(); + s +} + +fn styles(s: &Session) -> Vec { + s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect() +} + +#[test] +fn format_as_table_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_on_a_range_inside_a_table_restyles_it() { + let mut s = table(); + s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleDark3"]); +} + +#[test] +fn format_as_table_across_a_table_edge_is_rejected() { + let mut s = table(); + let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err(); + assert!(e.to_string().contains("overlap"), "{e}"); + assert_eq!(styles(&s), ["TableStyleMedium2"]); +} + +#[test] +fn format_as_table_inside_a_table_returns_that_table() { + let mut s = table(); + let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap(); + assert_eq!(r, json!({"table":"T", "range":"A1:B3"})); +} + +#[test] +fn format_as_table_uses_the_active_area_of_a_multi_area_selection() { + let mut s = table(); + s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} + +#[test] +fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() { + let mut s = table(); + s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap(); + s.execute("selection.set", json!({"range":"B2"})).unwrap(); + s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap(); + assert_eq!(styles(&s), ["TableStyleLight9"]); +} From 73af8c6a6dab27e9d19666ac33e16631a7570132 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 15:38:06 -0400 Subject: [PATCH 030/149] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e041aafbc..abc472008 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.10.0" +version = "0.11.0" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.10.0" +version = "0.11.0" [[package]] name = "gridcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.10.0" +version = "0.11.0" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e8a6dc67e..11e52c683 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From 10fb7d566eb8f1afe215117349881e1b6be78ca0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:17 -0400 Subject: [PATCH 031/149] macOS packaging: retry stapler when Apple's CloudKit drops the connection stapler staple/validate fetch the notarization ticket from CloudKit, which sometimes fails with "The network connection was lost" (Error 68). That failed this release's macOS job twice in a row. Both steps are idempotent, so retry up to 5 times with backoff, like hdiutil_retry. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/package.sh | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b128c3c7f..d8b4b097b 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -80,6 +80,20 @@ sign() { codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } +# stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The +# network connection was lost", Error 68). Stapling and validating are idempotent, so retry. +stapler_retry() { + local attempt + for attempt in 1 2 3 4 5; do + if xcrun stapler "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } + warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + sleep $((attempt * 15)) + done +} + notarize() { local file="$1" out id status echo "==> notarizing $(basename "$file") (this can take a few minutes)" @@ -120,8 +134,8 @@ codesign --verify --strict --deep --verbose=2 "$APP" if [ "$NOTARIZE" = 1 ]; then ditto -c -k --keepParent "$APP" "$WORK/GridCraft-notarize.zip" notarize "$WORK/GridCraft-notarize.zip" - xcrun stapler staple "$APP" - xcrun stapler validate "$APP" + stapler_retry staple "$APP" + stapler_retry validate "$APP" spctl --assess --type execute -vvv "$APP" fi @@ -146,8 +160,8 @@ sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" - xcrun stapler staple "$DMG" - xcrun stapler validate "$DMG" + stapler_retry staple "$DMG" + stapler_retry validate "$DMG" spctl --assess --type open --context context:primary-signature -vvv "$DMG" fi From ac8da7d49cbeb4be6aa1ad5869f3d29fe2da8cd9 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 22 Oct 2026 16:16:52 -0400 Subject: [PATCH 032/149] Release 0.11.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 2 +- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index abc472008..c7e3560b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1496,7 +1496,7 @@ dependencies = [ [[package]] name = "gridcraft" -version = "0.11.0" +version = "0.11.1" dependencies = [ "arboard", "craft-print", @@ -1518,7 +1518,7 @@ dependencies = [ [[package]] name = "gridcraft-calc" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1530,7 +1530,7 @@ dependencies = [ [[package]] name = "gridcraft-chart" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-core", @@ -1541,7 +1541,7 @@ dependencies = [ [[package]] name = "gridcraft-cli" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "gridcraft-mcp", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "gridcraft-core" -version = "0.11.0" +version = "0.11.1" dependencies = [ "js-sys", "serde", @@ -1563,7 +1563,7 @@ dependencies = [ [[package]] name = "gridcraft-engine" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-calc", "gridcraft-chart", @@ -1584,7 +1584,7 @@ dependencies = [ [[package]] name = "gridcraft-formula" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "serde", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "gridcraft-functions" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "regex", @@ -1601,7 +1601,7 @@ dependencies = [ [[package]] name = "gridcraft-mcp" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "log", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "gridcraft-model" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", "gridcraft-formula", @@ -1622,18 +1622,18 @@ dependencies = [ [[package]] name = "gridcraft-numfmt" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-core", ] [[package]] name = "gridcraft-pdf" -version = "0.11.0" +version = "0.11.1" [[package]] name = "gridcraft-ui-egui" -version = "0.11.0" +version = "0.11.1" dependencies = [ "craft-ui", "egui", @@ -1655,7 +1655,7 @@ dependencies = [ [[package]] name = "gridcraft-web" -version = "0.11.0" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -1672,7 +1672,7 @@ dependencies = [ [[package]] name = "gridcraft-xlsx" -version = "0.11.0" +version = "0.11.1" dependencies = [ "aes", "gridcraft-core", @@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.11.0" +version = "0.11.1" dependencies = [ "gridcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 11e52c683..bf8eb381e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" From d046ba6aa73cd1b5ce2c7e3f1c8e89e0e5dcba68 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:44:34 +0200 Subject: [PATCH 033/149] Hyperlinks: Ctrl+click on a link follows it instead of extending the selection (#1198) A Ctrl+click went to the add-to-selection branch, so the link under the pointer was never followed. It now goes through follow_link like a plain click (asks before opening web addresses, jumps to references). --- crates/ui-egui/src/grid.rs | 10 ++- crates/ui-egui/tests/hyperlink_ctrl_click.rs | 70 ++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/hyperlink_ctrl_click.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..386f151a3 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2364,7 +2364,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - select_ranges(app, RangeRef::cell(c), true, c); + // Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection. + if let Some(h) = sh.hyperlinks.get(&c).cloned() + && primary_clicked + && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 + { + follow_link(app, &h.target); + } else { + select_ranges(app, RangeRef::cell(c), true, c); + } } else { let _ = app.session.run("selection.set", json!({"cell": c.a1()})); if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" { diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/hyperlink_ctrl_click.rs new file mode 100644 index 000000000..34e110225 --- /dev/null +++ b/crates/ui-egui/tests/hyperlink_ctrl_click.rs @@ -0,0 +1,70 @@ +//! Ctrl+click on a hyperlink cell follows the link (#1198), like a plain click does. + +use egui::{Event, Modifiers, PointerButton, Pos2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "B2", "input": "web"})).unwrap(); + session.execute("insert.link", json!({"cell": "B2", "target": "https://example.org/a"})).unwrap(); + session.execute("cell.set", json!({"cell": "B4", "input": "jump"})).unwrap(); + session.execute("insert.link", json!({"cell": "B4", "target": "Sheet1!F9"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn left_of_cell(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let r = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()); + egui::pos2(r.left() + r.width() * 0.3, r.center().y) +} + +fn click(h: &mut Harness, cell: &str, modifiers: Modifiers) { + let pos = left_of_cell(h, cell); + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }, + ]); + h.run_steps(3); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(1); +} + +#[test] +fn ctrl_click_on_a_web_link_asks_to_open_it() { + let mut h = harness(); + click(&mut h, "B2", Modifiers::COMMAND); + let d = h.state().dialog.as_ref().expect("Ctrl+click on a web link opens the confirmation"); + assert_eq!(d.name, "openLink"); +} + +#[test] +fn ctrl_click_on_an_internal_link_goes_there() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::COMMAND); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} + +#[test] +fn plain_click_still_follows() { + let mut h = harness(); + click(&mut h, "B4", Modifiers::NONE); + assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); +} From 6febf5442f747e32a4b332c967340ab7e23fcba4 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:25:21 +0200 Subject: [PATCH 034/149] Fill handle: Auto Fill Options button after a drag offers Copy Cells, Fill Series and the formatting modes (#1403) A left-drag of the fill handle filled at once and offered no choice. A button now appears below the filled range; its menu undoes that fill and reruns edit.autoFill in the chosen mode. It goes away when the workbook changes or the selection moves. --- crates/ui-egui/src/grid.rs | 105 +++++++++-- crates/ui-egui/tests/fill_options_button.rs | 182 ++++++++++++++++++++ 2 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/fill_options_button.rs diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..e8250dde4 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -102,6 +102,19 @@ pub enum Drag { }, } +/// The fill a left-drag of the handle just made, which the Auto Fill Options button can redo +/// another way. It only applies while nothing else changed the workbook (`revision`) and the +/// filled cells are still selected. +#[derive(Clone, Debug, PartialEq)] +pub struct FillTag { + pub src: RangeRef, + pub target: RangeRef, + pub uid: u64, + pub sheet: usize, + pub revision: u64, + pub undo_len: usize, +} + #[derive(Default)] pub struct GridState { pub drag: Drag, @@ -120,6 +133,8 @@ pub struct GridState { pub context_menu_frame: u64, /// After a right-drag of the fill handle: the source, the dragged range and where to open the menu. pub fill_menu: Option<(RangeRef, RangeRef, Pos2)>, + /// After a left-drag of the fill handle: the Auto Fill Options button beside the filled cells. + pub fill_tag: Option, /// The right-clicked chart, picture, shape or slicer: where to open its menu, its kind and id. pub object_menu: Option<(Pos2, &'static str, u32)>, pub marching_phase: f32, @@ -790,6 +805,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { header_menu(app, ui, previous_popups.2 == app.grid.header_menu); context_menu(app, ui); fill_menu(app, ui, previous_popups.3 == app.grid.fill_menu); + fill_options_tag(app, ui, &geo, sh); object_menu(app, ui); error_check_tag(app, ui, &geo, sh); let track_x = if rtl { (available.left(), rect.left()) } else { (rect.right(), available.right()) }; @@ -2533,8 +2549,9 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & if t != src { // Ctrl (Cmd on macOS) swaps copying and extending, as in Excel. let mode = if mods.command { "alternate" } else { "series" }; - if let Err(e) = app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { - app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + match app.run("edit.autoFill", json!({"source": src.a1(), "target": t.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, src, t), + Err(e) => app.message = Some(("GridCraft".into(), crate::clean_error(&e))), } } } @@ -3317,6 +3334,81 @@ fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) { } } +/// The ways to fill a dragged range, as the fill menu and the Auto Fill Options button list them. +const FILL_MODES: [(&str, &str); 4] = + [("Copy Cells", "copy"), ("Fill Series", "extend"), ("Fill Formatting Only", "formats"), ("Fill Without Formatting", "values")]; + +/// Remembers the fill just made so the Auto Fill Options button can offer the other ways. +fn set_fill_tag(app: &mut SheetApp, src: RangeRef, target: RangeRef) { + app.grid.fill_tag = + app.session.active().map(|d| FillTag { src, target, uid: d.uid, sheet: d.wb.active_sheet, revision: d.revision, undo_len: d.undo.len() }); +} + +/// The Auto Fill Options button below the bottom-right corner of cells a left-drag of the fill +/// handle just filled. Its menu redoes that fill as Copy Cells, Fill Series, Fill Formatting Only +/// or Fill Without Formatting: it undoes the drag's fill, then runs `edit.autoFill` in that mode. +/// The button goes away as soon as anything else changes the workbook or the selection moves. +fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet) { + let Some(tag) = app.grid.fill_tag.clone() else { return }; + let current = app.session.active().is_some_and(|d| { + d.uid == tag.uid + && d.wb.active_sheet == tag.sheet + && d.revision == tag.revision + && d.undo.len() == tag.undo_len + && d.selection.ranges.len() == 1 + && d.selection.ranges.first().is_some_and(|r| *r == tag.target) + }); + if !current { + app.grid.fill_tag = None; + return; + } + if app.editor.is_some() || app.dialog.is_some() || app.grid.drag != Drag::None { + return; + } + let size = vec2(18.0, 18.0); + let corner = geo.range_rect(sh, tag.target).right_bottom(); + let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); + if !geo.cells.contains_rect(button) { + return; + } + let lang = crate::i18n::current(ui.ctx()); + let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options")); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options"))); + let t = Tokens::get(ui.ctx()); + let p = ui.painter(); + p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window }); + p.rect_stroke(button, 3.0, Stroke::new(1.0, t.separator), egui::StrokeKind::Inside); + // Two small cells and a drop-down arrow. + let c = button.center(); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 6.0, c.y - 6.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.rect_stroke(Rect::from_min_size(pos2(c.x - 3.0, c.y - 3.0), vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + p.add(egui::Shape::convex_polygon( + vec![pos2(c.x + 1.0, c.y + 5.0), pos2(c.x + 7.0, c.y + 5.0), pos2(c.x + 4.0, c.y + 8.0)], + t.text, + Stroke::NONE, + )); + let mut chosen = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(190.0); + for (label, mode) in FILL_MODES { + if ui.add(egui::Button::new(lang.tr(label)).frame(false).min_size(vec2(180.0, 20.0))).clicked() { + chosen = Some(mode); + ui.close(); + } + } + }); + if let Some(mode) = chosen { + let _ = app.run("edit.undo", json!({})); + match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { + Ok(_) => set_fill_tag(app, tag.src, tag.target), + Err(e) => { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + } + } + } +} + /// The menu a right-drag of the fill handle opens on release, as in Excel. fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let Some((src, target, at)) = app.grid.fill_menu else { return }; @@ -3324,14 +3416,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) { let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { ui.set_min_width(190.0); - let items = [ - ("Copy Cells", "copy"), - ("Fill Series", "extend"), - ("Fill Formatting Only", "formats"), - ("Fill Without Formatting", "values"), - ("-", ""), - ("Series…", "ui:series"), - ]; + let items = [FILL_MODES[0], FILL_MODES[1], FILL_MODES[2], FILL_MODES[3], ("-", ""), ("Series…", "ui:series")]; let lang = crate::i18n::current(ui.ctx()); for (label, mode) in items { if label == "-" { diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs new file mode 100644 index 000000000..35c314707 --- /dev/null +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -0,0 +1,182 @@ +//! After a left-drag of the fill handle, the Auto Fill Options button redoes the fill as Copy Cells, +//! Fill Series, Fill Formatting Only or Fill Without Formatting (#1403). + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::kittest::Queryable; +use gridcraft_engine::Session; +use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(setup: impl FnOnce(&mut Session)) -> Harness { + let mut session = Session::new(); + session.new_workbook(); + setup(&mut session); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +/// B1:B2 holds 1, 2 and is selected. +fn one_two(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2]]})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +fn corner(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0) +} + +fn center(h: &Harness, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); +} + +fn move_to(h: &mut Harness, from: Pos2, to: Pos2) { + for i in 1..=10 { + h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 10.0))); + h.step(); + } +} + +fn release(h: &mut Harness, pos: Pos2, button: PointerButton) { + h.input_mut().events.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// Drags the handle of the current selection (whose bottom-right cell is `from`) to `to`. +fn drag_handle(h: &mut Harness, from: &str, to: &str, button: PointerButton) { + let (start, end) = (corner(h, from), center(h, to)); + press(h, start, button); + move_to(h, start, end); + release(h, end, button); +} + +fn value(h: &Harness, cell: &str) -> Value { + h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap()) +} + +fn values(h: &Harness, cells: &[&str]) -> Vec { + cells.iter().map(|c| value(h, c)).collect() +} + +fn bold(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.bold +} + +fn italic(h: &Harness, cell: &str) -> bool { + let d = h.state().session.active().unwrap(); + let sh = d.wb.active().unwrap(); + d.wb.styles.get(sh.style_id(CellRef::parse(cell).unwrap())).font.italic +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.a1() +} + +fn n(x: f64) -> Value { + Value::Number(x) +} + +#[test] +fn left_drag_shows_the_auto_fill_options_button() { + let mut h = harness(one_two); + assert!(h.query_by_label("Auto Fill Options").is_none()); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + assert_eq!(values(&h, &["B3", "B6"]), [n(3.0), n(6.0)]); + assert!(h.query_by_label("Auto Fill Options").is_some()); +} + +#[test] +fn copy_cells_replaces_the_series_the_drag_made() { + let mut h = harness(one_two); + let steps_before = h.state().session.active().unwrap().undo.len(); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Copy Cells").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4", "B5", "B6"]), [n(1.0), n(2.0), n(1.0), n(2.0)]); + assert_eq!(selection(&h), "B1:B6"); + assert_eq!(h.state().session.active().unwrap().undo.len(), steps_before + 1, "one fill step, not two"); +} + +#[test] +fn a_lone_number_copies_on_drag_and_counts_up_with_fill_series() { + let mut h = harness(|s| { + s.execute("cell.set", json!({"cell": "B1", "input": "5"})).unwrap(); + s.execute("selection.set", json!({"cell": "B1"})).unwrap(); + }); + drag_handle(&mut h, "B1", "B4", PointerButton::Primary); + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(5.0), n(5.0), n(5.0)]); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Series").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B2", "B3", "B4"]), [n(6.0), n(7.0), n(8.0)]); +} + +fn formatted(s: &mut Session) { + s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [null], [9]]})).unwrap(); + s.execute("home.bold", json!({"range": "B1:B2", "on": true})).unwrap(); + s.execute("home.italic", json!({"range": "B4", "on": true})).unwrap(); + s.execute("selection.set", json!({"range": "B1:B2"})).unwrap(); +} + +#[test] +fn fill_without_formatting_keeps_the_targets_formats() { + let mut h = harness(formatted); + drag_handle(&mut h, "B2", "B4", PointerButton::Primary); + h.get_by_label("Auto Fill Options").click(); + h.run_steps(3); + h.get_by_label("Fill Without Formatting").click(); + h.run_steps(3); + + assert_eq!(values(&h, &["B3", "B4"]), [n(3.0), n(4.0)]); + assert!(!bold(&h, "B3") && !bold(&h, "B4")); + assert!(italic(&h, "B4")); +} + +#[test] +fn the_button_goes_away_once_the_selection_moves() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + let away = center(&h, "E9"); + h.input_mut().events.push(Event::PointerMoved(away)); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.input_mut().events.push(Event::PointerButton { pos: away, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + + assert!(h.query_by_label("Auto Fill Options").is_none()); +} + +#[test] +fn right_drag_makes_no_button() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); + assert!(h.query_by_label("Auto Fill Options").is_none()); +} From d19f65601427d4aacf522d95e57169b6b89c088a Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:17:38 +0200 Subject: [PATCH 035/149] Fill handle: test the Auto Fill Options button's workbook-changed rule, report an undo error, document it (#1403) --- crates/ui-egui/src/grid.rs | 6 +++++- crates/ui-egui/tests/fill_options_button.rs | 12 ++++++++++++ docs/ui-parity.md | 5 +++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index e8250dde4..ab5874111 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3398,7 +3398,11 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet } }); if let Some(mode) = chosen { - let _ = app.run("edit.undo", json!({})); + if let Err(e) = app.run("edit.undo", json!({})) { + app.grid.fill_tag = None; + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } match app.run("edit.autoFill", json!({"source": tag.src.a1(), "target": tag.target.a1(), "mode": mode})) { Ok(_) => set_fill_tag(app, tag.src, tag.target), Err(e) => { diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs index 35c314707..8d5a21a18 100644 --- a/crates/ui-egui/tests/fill_options_button.rs +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -180,3 +180,15 @@ fn right_drag_makes_no_button() { drag_handle(&mut h, "B2", "B6", PointerButton::Secondary); assert!(h.query_by_label("Auto Fill Options").is_none()); } + +#[test] +fn the_button_goes_away_once_the_workbook_changes() { + let mut h = harness(one_two); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some()); + h.state_mut().session.execute("cell.set", json!({"cell": "D1", "input": "x"})).unwrap(); + h.run_steps(3); + + assert_eq!(selection(&h), "B1:B6", "the selection did not move"); + assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..f1c7d4d37 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-23 · **Change:** minor (Auto Fill Options button after a left-drag of the fill handle (#1403); 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 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 @@ -50,7 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | | Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 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 60776ff4de75da9fc05633aa7fbea0a301cd1b26 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:23:37 +0200 Subject: [PATCH 036/149] Hyperlinks: test that Ctrl+click beside the link text still extends the selection (#1198) --- crates/ui-egui/tests/hyperlink_ctrl_click.rs | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/hyperlink_ctrl_click.rs index 34e110225..2e557e12e 100644 --- a/crates/ui-egui/tests/hyperlink_ctrl_click.rs +++ b/crates/ui-egui/tests/hyperlink_ctrl_click.rs @@ -26,16 +26,20 @@ fn harness() -> Harness { h } -fn left_of_cell(h: &Harness, cell: &str) -> Pos2 { +fn at_fraction(h: &Harness, cell: &str, fraction: f32) -> Pos2 { let app = h.state(); let sheet = app.session.active().unwrap().wb.active().unwrap(); let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); let r = geo.cell_rect(sheet, CellRef::parse(cell).unwrap()); - egui::pos2(r.left() + r.width() * 0.3, r.center().y) + egui::pos2(r.left() + r.width() * fraction, r.center().y) } fn click(h: &mut Harness, cell: &str, modifiers: Modifiers) { - let pos = left_of_cell(h, cell); + click_at(h, cell, 0.3, modifiers); +} + +fn click_at(h: &mut Harness, cell: &str, fraction: f32, modifiers: Modifiers) { + let pos = at_fraction(h, cell, fraction); h.input_mut().events.push(Event::ModifiersChanged(modifiers)); h.input_mut().events.extend([ Event::PointerMoved(pos), @@ -68,3 +72,14 @@ fn plain_click_still_follows() { click(&mut h, "B4", Modifiers::NONE); assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F9"); } + +#[test] +fn ctrl_click_beside_the_link_text_still_extends_the_selection() { + let mut h = harness(); + click_at(&mut h, "B2", 0.9, Modifiers::NONE); + click_at(&mut h, "B4", 0.9, Modifiers::COMMAND); + let d = h.state().session.active().unwrap(); + let ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection"); + assert!(h.state().dialog.is_none(), "no link was followed"); +} From 7c1ec1df7dbd27cacccc9b8333cb361a939f3d42 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:37:49 +0200 Subject: [PATCH 037/149] Fill handle: test the Auto Fill Options button on a right-to-left sheet, merge the duplicate fill handle row (#1403) --- crates/ui-egui/tests/fill_options_button.rs | 21 +++++++++++++++++++-- docs/ui-parity.md | 3 +-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/fill_options_button.rs index 8d5a21a18..2cb209fa9 100644 --- a/crates/ui-egui/tests/fill_options_button.rs +++ b/crates/ui-egui/tests/fill_options_button.rs @@ -35,14 +35,14 @@ fn corner(h: &Harness, cell: &str) -> Pos2 { let app = h.state(); let sheet = app.session.active().unwrap().wb.active().unwrap(); let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); - geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0) + geo.mirror_pos(geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).right_bottom() - vec2(1.0, 1.0)) } fn center(h: &Harness, cell: &str) -> Pos2 { let app = h.state(); let sheet = app.session.active().unwrap().wb.active().unwrap(); let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); - geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() + geo.screen_cell_rect(sheet, CellRef::parse(cell).unwrap()).center() } fn press(h: &mut Harness, pos: Pos2, button: PointerButton) { @@ -192,3 +192,20 @@ fn the_button_goes_away_once_the_workbook_changes() { assert_eq!(selection(&h), "B1:B6", "the selection did not move"); assert!(h.query_by_label("Auto Fill Options").is_none(), "its undo would take the unrelated edit with it"); } + +#[test] +fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { + let mut h = harness(|s| { + one_two(s); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let button = h.get_by_label("Auto Fill Options").rect(); + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + assert!(geo.cells.contains_rect(button), "{button:?} outside {:?}", geo.cells); + let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); + assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index f1c7d4d37..cf0ab377d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -50,8 +50,7 @@ this logic, with one undo step. | Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) | | Cursor while dragging cells, headers, freeze handles and objects | stable for the active drag (#936); Draw tools keep their crosshair; header arrows and a custom cell cursor remain unimplemented | | Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | -| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); month patterns use the workbook's 1900 or 1904 date system; right-drag menu missing | +| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series; month patterns use the workbook's 1900 or 1904 date system); right-drag menu partial: Copy Cells, Fill Series, Fill Formatting Only, Fill Without Formatting, Series…; a left-drag leaves an Auto Fill Options button with the first four (gone once the workbook changes or the selection moves); Fill Days/Weekdays/Months/Years, Linear/Growth Trend and Flash Fill missing | | Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) | | In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done | | Point mode (click/drag/arrow references), reference colouring, F4 | done; Command/Ctrl-click adds separate arguments with the edit's locale separator, and Shift-click extends the current reference from a fixed anchor on the same sheet. Pointer-event regressions cover repeated/reversed selections, manual separators, sheet switches and keyboard continuation. | From 8eda57754596533dc473436478e81b37a31f5737 Mon Sep 17 00:00:00 2001 From: RedBearAK <64876997+RedBearAK@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:27:56 -0800 Subject: [PATCH 038/149] Page Up into frozen rows scrolls the rows below back to the top With row 1 frozen, Page Up could land on A1, which is always on screen, while the scrolled rows stayed a page down (row 10, Page Down, Page Up, Page Up ends on row 1 with rows 2-9 hidden). The same held for Ctrl+Up into frozen rows and Ctrl+Left / Alt+Page Up into frozen columns. Arrow and paging keys now record the cell they moved from (`GridState::reveal_from`). When such a move goes from the scrolled rows (columns) into the frozen ones, the scrolled area goes back to its top (left). Other reveals (clicks, Find, Go To, Name Box, Undo, the control channel, formula pointing) leave it unset and keep the view where the user scrolled it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/grid.rs | 14 +++ crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/tests/selection_scroll.rs | 148 +++++++++++++++++++++++ docs/ui-parity.md | 3 +- 4 files changed, 166 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index a0efbfae7..d42ef180b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -109,6 +109,10 @@ pub struct GridState { /// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell /// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves). pub reveal_moving_end: bool, + /// With `ensure_visible`: the cell an arrow or paging key moved from (the moving end when + /// extending), set only by those keys. A move from the scrolled rows or columns into the + /// frozen ones scrolls them back to their top or left. + pub reveal_from: Option, pub last_title: Option, pub drag_select: bool, pub rect: Option, @@ -681,6 +685,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { if app.grid.ensure_visible { app.grid.ensure_visible = false; let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active }; + let from = app.grid.reveal_from.take(); let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell); if paper { crate::paper_view::ensure_covers(app, &wb, si, target); @@ -694,6 +699,11 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.bottom() > area.bottom() - 2.0 { view.scroll.y += (cr.bottom() - area.bottom() + 2.0) / geo.z; } + } else if from.is_some_and(|f| f.row >= geo.fr) { + // A move from the scrolled rows into the frozen ones (Page Up or Ctrl+Up onto a frozen + // row 1) brings the scrolled rows back to their top; the rows it passed over would + // otherwise stay out of sight. + view.scroll.y = 0.0; } if target.col >= geo.fc { if cr.left() < area.left() { @@ -701,6 +711,8 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) { } else if cr.right() > area.right() - 2.0 { view.scroll.x += ((cr.right() - area.right() + 2.0) / geo.z).min(cr.left() - area.left()); } + } else if from.is_some_and(|f| f.col >= geo.fc) { + view.scroll.x = 0.0; } view.scroll = view.scroll.max(egui::Vec2::ZERO); geo = make_geo(app, view.scroll); @@ -2967,9 +2979,11 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: let cmd = m.command; let shift = m.shift; let mv = |app: &mut SheetApp, dr: i64, dc: i64| { + let from = app.session.active().map(|d| if shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": dr, "dc": dc, "extend": shift, "jump": cmd})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = shift; + app.grid.reveal_from = from; }; match key { Key::ArrowUp => mv(app, -1, 0), diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index fea791b69..5840036a1 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -223,9 +223,11 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic let width = ((geo.cells.width() - geo.frozen_w) / geo.z).max(1.0); let cols = (width / sh.default_col_width.max(1.0)).floor().max(1.0) as i64; let dc = if shortcut == Shortcut::ScreenRight { cols } else { -cols }; + let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active }); let _ = app.session.run("selection.move", json!({"dr": 0, "dc": dc, "extend": m.shift})); app.grid.ensure_visible = true; app.grid.reveal_moving_end = m.shift; + app.grid.reveal_from = from; } Shortcut::Edit => app.begin_edit(None, false), Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()), diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/selection_scroll.rs index 719f1a0f2..9a78f9b9f 100644 --- a/crates/ui-egui/tests/selection_scroll.rs +++ b/crates/ui-egui/tests/selection_scroll.rs @@ -142,3 +142,151 @@ fn select_over_the_control_channel_scrolls_the_active_cell_into_view() { assert_eq!(active(&h), CellRef::parse("BZ150").unwrap()); assert!(visible(&h, "BZ150") && !visible(&h, "A1"), "selecting a far range scrolls its active cell into view"); } + +fn scroll(h: &Harness) -> egui::Vec2 { + h.state().view().scroll +} + +#[test] +fn page_down_and_back_up_from_row_10_shows_rows_2_to_9_again() { + // The reported steps: row 1 frozen, row 10 selected, Page Down, Page Up, Page Up. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A10"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::PageDown, Modifiers::NONE); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A10").unwrap()); + key(&mut h, Key::PageUp, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2") && visible(&h, "A9")); +} + +#[test] +fn page_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + // Row 1 frozen: Page Up from far down ends on A1, and rows 2 onwards show under it again + // instead of the view staying a page down. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::NONE); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).y, 0.0, "the scrolled rows are back at the top"); + assert!(visible(&h, "A2")); + // Ctrl+Up from the bottom lands on A1 the same way. + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert!(visible(&h, "A2")); +} + +#[test] +fn ctrl_left_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + key(&mut h, Key::ArrowLeft, CTRL); + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn moving_within_a_frozen_row_leaves_the_scrolled_rows_where_they_are() { + // Selecting in row 1 while the rows below are scrolled down (without a reveal, as a click + // does), then moving along the frozen row, must not jump the view back to the top. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + assert!(down > 0.0); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down, "the view stays scrolled down"); +} + +#[test] +fn alt_page_up_into_a_frozen_first_column_scrolls_the_columns_beside_it_back() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "B1"})).unwrap(); + key(&mut h, Key::ArrowRight, CTRL); + assert!(!visible(&h, "B1")); + for _ in 0..20 { + key(&mut h, Key::PageUp, Modifiers::ALT); + } + assert_eq!(active(&h), CellRef::parse("A1").unwrap()); + assert_eq!(scroll(&h).x, 0.0); + assert!(visible(&h, "B1")); +} + +#[test] +fn extending_up_into_a_frozen_top_row_scrolls_the_rows_below_it_back_to_the_top() { + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + assert!(!visible(&h, "A2")); + key(&mut h, Key::ArrowUp, CTRL_SHIFT); + assert_extended(&h, "A200", "A1"); + assert!(visible(&h, "A2")); +} + +#[test] +fn other_reveals_onto_a_frozen_cell_leave_the_scrolled_rows_where_they_are() { + // Only arrow and paging keys bring the scrolled rows back; selecting a header cell from the + // control channel (as Find, Go To or Undo would) keeps the place the user scrolled to. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + control(&mut h, "engine.execute", json!({"command": "selection.set", "params": {"cell": "B1"}})); + assert_eq!(active(&h), CellRef::parse("B1").unwrap()); + assert_eq!(scroll(&h).y, down); +} + +#[test] +fn pointing_along_a_frozen_row_keeps_the_edited_cell_in_view() { + // Editing a formula far down and pointing at header cells with the arrows must not scroll + // the cell being edited away. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + key(&mut h, Key::ArrowDown, CTRL); + let down = scroll(&h).y; + h.state_mut().begin_edit(Some("=SUM(".into()), false); + h.run_steps(2); + let ed = h.state_mut().editor.as_mut().expect("editing"); + ed.caret = ed.text.chars().count(); + ed.point_cell = Some(CellRef::parse("B1").unwrap()); + h.run_steps(2); + key(&mut h, Key::ArrowRight, Modifiers::NONE); + assert_eq!(h.state().editor.as_ref().and_then(|e| e.point_cell), Some(CellRef::parse("C1").unwrap()), "the arrow moved the point"); + assert_eq!(scroll(&h).y, down); + assert!(visible(&h, "A200"), "the edited cell stays in view"); +} + +#[test] +fn down_from_a_frozen_header_row_goes_to_row_2_at_the_top_from_far_down() { + // Excel's quick way back to the top: select a cell in the frozen header row while far down, + // press Down, and the view jumps to row 2 under the header. Selecting the header cell alone + // doesn't scroll. + let mut h = harness(); + h.state_mut().session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap(); + h.state_mut().session.execute("selection.set", json!({"cell": "A100000"})).unwrap(); + h.state_mut().grid.ensure_visible = true; + h.run_steps(2); + assert!(!visible(&h, "A2") && visible(&h, "A100000")); + h.state_mut().session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + h.run_steps(2); + assert!(visible(&h, "A100000")); + key(&mut h, Key::ArrowDown, Modifiers::NONE); + assert_eq!(active(&h), CellRef::parse("A2").unwrap()); + assert_eq!(scroll(&h).y, 0.0); + assert!(visible(&h, "A2")); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 07797d70d..0b4ee83f2 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-23 · **Change:** minor (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back) · **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 @@ -176,6 +176,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 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 f1e29fa82868fe1a099fc90740a3849f6c8b8ff3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 10:14:10 -0400 Subject: [PATCH 039/149] Review fixes for #2267 and #2292 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - range.setValues with parse: false still turns on Wrap Text for a line break (CodeRabbit). - Japanese: the fill-handle hint says AutoFill (オートフィル), not paint (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 19 ++++++++++++++----- crates/ui-egui/src/i18n/ja.rs | 5 ++++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 67920264d..1998f0b2c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -333,11 +333,14 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { // Text as given: no date or number reading, and a leading `'` is part of the // text rather than the marker that makes it text. A formula is still a // formula, which is what the ticket asks for and what `=` means in a cell. - Json::String(text) if !parse && !text.starts_with('=') => Some(Cell { - value: Value::Text(text.as_str().into()), - formula: None, - style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), - }), + Json::String(text) if !parse && !text.starts_with('=') => { + let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(); + // Line breaks still turn on Wrap Text, as typed input does. + if text.contains('\n') && !cx.wb.styles.get(style).align.wrap { + style = cx.wb.styles.derive(style, |s| s.align.wrap = true); + } + Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style }) + } _ => input_to_cell(&input, sheet, at, &mut cx.wb)?, }; let sh = cx.sheet_mut(sheet)?; @@ -1827,6 +1830,12 @@ mod tests { assert_eq!(get(&mut s, "A7")["value"], json!(2.5)); assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err()); assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err()); + // A line break still turns on Wrap Text. + s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap(); + assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo")); + let d = s.doc().unwrap(); + let sh = d.wb.active().unwrap(); + assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap); } #[test] diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 09461b4bd..482852d8d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1127,7 +1127,10 @@ pub(super) const EXTRA: &[(&str, &str)] = &[ ("Sunburst", "サンバースト"), ("Stock", "株価"), ("Bar", "横棒"), - ("Drag to fill cells. Drag from inside a cell to select a range.", "ドラッグしてセルを塗りつぶします。セルの内側からドラッグすると範囲を選択します。"), + ( + "Drag to fill cells. Drag from inside a cell to select a range.", + "ドラッグしてセルをオートフィルします。セルの内側からドラッグすると範囲を選択します。", + ), ]; /// Translates an interface label; `None` for a label the catalog does not know, so `i18n.rs` can From 9135c7f6f013de8d1608c39e5d65f1c96815255a Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 17:07:42 +0200 Subject: [PATCH 040/149] Ribbon: fall back to Home when the active contextual tab is gone (fixes #2329) --- crates/ui-egui/src/ribbon.rs | 7 ++ crates/ui-egui/tests/contextual_ribbon_tab.rs | 74 +++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 crates/ui-egui/tests/contextual_ribbon_tab.rs diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 356381d66..45856565f 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -204,6 +204,11 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let collapsed = app.ui.ribbon_collapsed; let h = if collapsed { 34.0 } else { 34.0 + 92.0 }; + // A contextual tab exists only while its object is selected (Excel hides Table Design once the + // cell leaves the table); a remembered one that is gone would draw an empty ribbon. + if CONTEXTUAL_TABS.contains(&app.ui.ribbon_tab.as_str()) && !contextual_tabs(app).contains(&app.ui.ribbon_tab.as_str()) { + app.ui.ribbon_tab = "Home".to_string(); + } egui::Panel::top("ribbon") .exact_size(h) .frame(egui::Frame::NONE.fill(t.window).inner_margin(egui::Margin { left: 10, right: 10, top: 0, bottom: 6 })) @@ -322,6 +327,8 @@ fn theme_choices(app: &mut SheetApp, ui: &mut Ui) { } } +const CONTEXTUAL_TABS: [&str; 3] = ["Table Design", "Chart Design", crate::paper_view::TAB]; + fn contextual_tabs(app: &SheetApp) -> Vec<&'static str> { let mut v = Vec::new(); if let Some(d) = app.session.active() diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs new file mode 100644 index 000000000..6e1626dd5 --- /dev/null +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -0,0 +1,74 @@ +//! A contextual ribbon tab (Table Design, Chart Design) only exists while its object is selected. +//! The tab remembered in the saved UI state, or left behind when the selection moves away, must +//! not leave the ribbon blank: it falls back to Home. + +use egui::vec2; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness(session: Session) -> Harness { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn session_with_table() -> Session { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap(); + session.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap(); + session.execute("selection.set", json!({"range": "A2"})).unwrap(); + session +} + +fn app_restored_on(tab: &str, session: Session) -> Harness { + let mut app = SheetApp::new(session, Default::default()); + app.ui.ribbon_tab = tab.into(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +#[test] +fn a_saved_table_design_tab_without_a_table_opens_on_home() { + let mut session = Session::new(); + session.new_workbook(); + let h = app_restored_on("Table Design", session); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} + +#[test] +fn a_saved_chart_design_tab_without_a_chart_opens_on_home() { + let mut session = Session::new(); + session.new_workbook(); + let h = app_restored_on("Chart Design", session); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} + +#[test] +fn leaving_the_table_while_on_table_design_returns_to_home() { + let mut h = harness(session_with_table()); + h.state_mut().ui.ribbon_tab = "Table Design".into(); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Table Design", "the tab stays while the cell is in the table"); + h.state_mut().session.execute("selection.set", json!({"range": "D10"})).unwrap(); + h.run_steps(2); + assert_eq!(h.state().ui.ribbon_tab, "Home"); +} From a0ceea1468a72baf4654950cf9d84611e649dd91 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 17:16:30 +0200 Subject: [PATCH 041/149] Open on the Home tab every launch, as in Excel (fixes #2329) --- apps/gridcraft-web/src/prefs.rs | 2 +- apps/gridcraft/src/main.rs | 2 +- crates/ui-egui/src/lib.rs | 8 +++++++ crates/ui-egui/tests/contextual_ribbon_tab.rs | 22 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/gridcraft-web/src/prefs.rs b/apps/gridcraft-web/src/prefs.rs index 7d8c0abf8..987343c56 100644 --- a/apps/gridcraft-web/src/prefs.rs +++ b/apps/gridcraft-web/src/prefs.rs @@ -22,7 +22,7 @@ pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) { { // No usable saved language: keep the browser's, as the desktop keeps the system's. let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language); - app.ui = state; + app.ui = state.at_startup(); app.ui.language = language; } if let Some(s) = prefs.filter(readable) diff --git a/apps/gridcraft/src/main.rs b/apps/gridcraft/src/main.rs index 7c0eb04e6..6c0d2f676 100644 --- a/apps/gridcraft/src/main.rs +++ b/apps/gridcraft/src/main.rs @@ -111,7 +111,7 @@ fn load_prefs(app: &mut SheetApp) { { let saved = serde_json::from_slice(&b).ok().and_then(|v| gridcraft_ui_egui::i18n::saved_language(&v)); let language = saved.unwrap_or(app.ui.language); - app.ui = ui; + app.ui = ui.at_startup(); app.ui.language = language; } if let Ok(b) = std::fs::read(dir.join("prefs.json")) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index faae9bfae..676359355 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -117,6 +117,14 @@ impl Default for UiState { } impl UiState { + /// The state saved by an earlier run, as the app starts with it. The ribbon tab is written to + /// the preferences but not restored: Excel opens on Home every launch, so a contextual tab + /// (Table Design) cannot come back with nothing selected to justify it. + pub fn at_startup(mut self) -> Self { + self.ribbon_tab = "Home".into(); + self + } + pub fn theme_preference(&self) -> egui::ThemePreference { if self.system_theme { egui::ThemePreference::System diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs index 6e1626dd5..bdd8b5c93 100644 --- a/crates/ui-egui/tests/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -72,3 +72,25 @@ fn leaving_the_table_while_on_table_design_returns_to_home() { h.run_steps(2); assert_eq!(h.state().ui.ribbon_tab, "Home"); } + +#[test] +fn startup_opens_on_home_not_the_saved_tab() { + let mut saved = gridcraft_ui_egui::UiState::default(); + saved.ribbon_tab = "Table Design".into(); + saved.dark = true; + let json = serde_json::to_string(&saved).unwrap(); + assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}"); + let restored = serde_json::from_str::(&json).unwrap().at_startup(); + assert_eq!(restored.ribbon_tab, "Home", "the saved tab is not restored"); + assert!(restored.dark, "the other preferences are"); +} + +#[test] +fn an_ordinary_tab_stays_put_in_a_session() { + let mut session = Session::new(); + session.new_workbook(); + let mut h = harness(session); + h.state_mut().ui.ribbon_tab = "Data".into(); + h.run_steps(3); + assert_eq!(h.state().ui.ribbon_tab, "Data"); +} From 168acc58898d0f358964703f2826f57ca440418d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:17:38 -0400 Subject: [PATCH 042/149] packaging: shell, PowerShell and web page fixes (SonarCloud S7688, S7679, S7682, S8677, S7926) (#2323) - Shell: [[ ]] for conditional tests, positional parameters copied into named locals, explicit return at the end of the env.sh helpers. - PowerShell: the desktop-shortcut smoke test writes its progress with Write-Output instead of Write-Host. - Web: the viewport no longer sets user-scalable=no, so people can zoom the page. --- apps/gridcraft-web/index.html | 2 +- packaging/env.sh | 19 ++++++++------ packaging/freebsd/package.sh | 4 +-- packaging/icons.sh | 5 +++- packaging/linux/flatpak-bundle.sh | 8 +++--- packaging/linux/package.sh | 21 +++++++++------- packaging/macos/import-cert.sh | 6 ++--- packaging/macos/package.sh | 28 ++++++++++----------- packaging/web/package.sh | 6 ++--- packaging/windows/test-desktop-shortcut.ps1 | 6 ++--- 10 files changed, 58 insertions(+), 47 deletions(-) diff --git a/apps/gridcraft-web/index.html b/apps/gridcraft-web/index.html index 5fdd1ea54..321aa302e 100644 --- a/apps/gridcraft-web/index.html +++ b/apps/gridcraft-web/index.html @@ -2,7 +2,7 @@ - + GridCraft diff --git a/packaging/env.sh b/packaging/env.sh index bacb167e1..6f36506b6 100644 --- a/packaging/env.sh +++ b/packaging/env.sh @@ -19,10 +19,11 @@ workspace_version() { /^\[/ { in_pkg = ($0 == "[workspace.package]") ; next } in_pkg && $1 == "version" { gsub(/[" ]/, "", $3); print $3; exit } ' "$ROOT/Cargo.toml" + return } VERSION="${GRIDCRAFT_VERSION:-$(workspace_version)}" -if [ -z "$VERSION" ]; then +if [[ -z "$VERSION" ]]; then echo "error: could not read [workspace.package] version from $ROOT/Cargo.toml" >&2 exit 1 fi @@ -32,7 +33,7 @@ DIST="${DIST:-$ROOT/dist/release}" mkdir -p "$DIST" export DIST -if [ -z "${GRIDCRAFT_BUILD_SHA:-}" ]; then +if [[ -z "${GRIDCRAFT_BUILD_SHA:-}" ]]; then GRIDCRAFT_BUILD_SHA="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || true)" fi export GRIDCRAFT_BUILD_SHA @@ -41,29 +42,33 @@ export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" # Emit a GitHub Actions warning (plain stderr outside Actions). warn() { - if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + if [[ -n "${GITHUB_ACTIONS:-}" ]]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi + return } # Copy licence and readme files that exist into a package directory. copy_docs() { local dest="$1" f for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do - if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi + if [[ -f "$ROOT/$f" ]]; then cp "$ROOT/$f" "$dest/"; fi done copy_font_licences "$dest" + return } # Builds made with craft-fonts (CRAFT_FONTS_DIR, set by the release workflow) embed its fonts, so # the package carries their licences as OFL-.txt. Nothing without CRAFT_FONTS_DIR. copy_font_licences() { local dest="$1" f - [ -n "${CRAFT_FONTS_DIR:-}" ] || return 0 + [[ -n "${CRAFT_FONTS_DIR:-}" ]] || return 0 for f in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do - if [ -f "$f" ]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi + if [[ -f "$f" ]]; then cp "$f" "$dest/OFL-$(basename "$(dirname "$f")").txt"; fi done } # Portable SHA-256 of a file (prints just the hash). sha256() { - if command -v sha256sum >/dev/null; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi + local file="$1" + if command -v sha256sum >/dev/null; then sha256sum "$file" | cut -d' ' -f1; else shasum -a 256 "$file" | cut -d' ' -f1; fi + return } diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e4ee9da7b..ab98e9800 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -16,7 +16,7 @@ APP_ID=ai.storyteller.gridcraft LINUX="$ROOT/packaging/linux" SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; -h | --help) sed -n '2,11p' "$0"; exit 0 ;; @@ -34,7 +34,7 @@ BASENAME="gridcraft-$VERSION-freebsd-$ARCH" echo "==> GridCraft $VERSION for FreeBSD $ARCH" -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) fi BIN="$CARGO_TARGET_DIR/release" diff --git a/packaging/icons.sh b/packaging/icons.sh index 2adcb9a9f..c6f325e3d 100755 --- a/packaging/icons.sh +++ b/packaging/icons.sh @@ -21,7 +21,10 @@ MAC="$TMP/mac.svg" sed 's/viewBox="0 0 512 512"/viewBox="-62.136 -62.136 636.272 636.272"/' "$SVG" >"$MAC" grep -q 'viewBox="-62.136' "$MAC" || { echo "error: expected viewBox=\"0 0 512 512\" in $SVG" >&2; exit 1; } -render() { resvg -w "$2" -h "$2" "$1" "$3" &2; exit 1; } +[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done @@ -49,7 +49,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/gridcraft" ] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } +[[ -x "$WORK/stage/bin/gridcraft" ]] || { echo "error: $TARBALL has no bin/gridcraft" >&2; exit 1; } cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" @@ -62,7 +62,7 @@ OUT="$DIST/$BASENAME.flatpak" flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable echo "wrote $OUT" -if [ "$TEST" = 1 ]; then +if [[ "$TEST" = 1 ]]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" flatpak run --command=gridcraft-cli "$APP_ID" --version diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 105ac744f..f8c53d6b3 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -21,7 +21,7 @@ APP_ID=ai.storyteller.gridcraft SKIP_BUILD=0 FORMATS="appimage deb rpm tar" -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; @@ -42,8 +42,8 @@ BASENAME="gridcraft-$VERSION-linux-$ARCH" echo "==> GridCraft $VERSION for Linux $ARCH ($FORMATS)" -if [ "$SKIP_BUILD" = 0 ]; then - if [ -n "${CROSS_TARGET:-}" ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then + if [[ -n "${CROSS_TARGET:-}" ]]; then (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli --target "$CROSS_TARGET") else (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli) @@ -75,7 +75,10 @@ if command -v appstreamcli >/dev/null; then appstreamcli validate --no-net --explain "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" fi -has() { case " $FORMATS " in *" $1 "*) return 0 ;; *) return 1 ;; esac; } +has() { + local format="$1" + case " $FORMATS " in *" $format "*) return 0 ;; *) return 1 ;; esac +} # ---- .tar.gz ------------------------------------------------------------------------------------ if has tar; then @@ -108,9 +111,9 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [ -z "$TOOL" ]; then + if [[ -z "$TOOL" ]]; then TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [ ! -x "$TOOL" ]; then + if [[ ! -x "$TOOL" ]]; then curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" chmod +x "$TOOL" fi @@ -133,16 +136,16 @@ if has appimage; then # package) appimagetool also writes the .zsync, into its working directory, hence the cd. (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" - if [ -s "$OUT.zsync" ]; then + if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" else warn "zsyncmake not found, so $OUT.zsync was not written; AppImage delta updates need it" fi fi -if [ -z "${CROSS_TARGET:-}" ]; then +if [[ -z "${CROSS_TARGET:-}" ]]; then "$STAGE/usr/bin/gridcraft-cli" --version -elif [ -n "${EMULATOR:-}" ]; then +elif [[ -n "${EMULATOR:-}" ]]; then "$EMULATOR" "$STAGE/usr/bin/gridcraft-cli" --version fi echo "==> done" diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 41d9538bf..4611ffe05 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -12,7 +12,7 @@ umask 077 # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" -if [ -z "${APPLE_CERTIFICATE:-}" ]; then +if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then warn "APPLE_CERTIFICATE is not set: macOS builds will be ad-hoc signed and not notarized" exit 0 fi @@ -36,13 +36,13 @@ security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KC_PASS" security list-keychains -d user -s "$KEYCHAIN" $(security list-keychains -d user | tr -d '"') IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk '/Developer ID Application/ { print $2; exit }')" -if [ -z "$IDENTITY" ]; then +if [[ -z "$IDENTITY" ]]; then security find-identity -v -p codesigning "$KEYCHAIN" >&2 echo "error: no 'Developer ID Application' identity in APPLE_CERTIFICATE" >&2 exit 1 fi echo "Imported signing identity $IDENTITY" -if [ -n "${GITHUB_ENV:-}" ]; then +if [[ -n "${GITHUB_ENV:-}" ]]; then { echo "MACOS_SIGN_IDENTITY=$IDENTITY"; echo "MACOS_KEYCHAIN=$KEYCHAIN"; } >>"$GITHUB_ENV" else echo "MACOS_SIGN_IDENTITY=$IDENTITY" diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index d8b4b097b..b45164ee0 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -19,7 +19,7 @@ HERE="$ROOT/packaging/macos" ARCH=universal SKIP_BUILD=0 -while [ $# -gt 0 ]; do +while [[ $# -gt 0 ]]; do case "$1" in --arch) ARCH="$2"; shift 2 ;; --skip-build) SKIP_BUILD=1; shift ;; @@ -44,9 +44,9 @@ DMG="$DIST/gridcraft-$VERSION-macos-$ARCH.dmg" CLI_ZIP="$DIST/gridcraft-cli-$VERSION-macos-$ARCH.zip" NOTARIZE=0 -if [ "$IDENTITY" = "-" ]; then +if [[ "$IDENTITY" = "-" ]]; then warn "macOS: ad-hoc signing (no MACOS_SIGN_IDENTITY); artifacts are not notarized" -elif [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then +elif [[ -n "${APPLE_ID:-}" ]] && [[ -n "${APPLE_PASSWORD:-}" ]] && [[ -n "${APPLE_TEAM_ID:-}" ]]; then NOTARIZE=1 else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" @@ -55,7 +55,7 @@ fi echo "==> GridCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- -if [ "$SKIP_BUILD" = 0 ]; then +if [[ "$SKIP_BUILD" = 0 ]]; then args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p gridcraft -p gridcraft-cli "${args[@]}") @@ -74,22 +74,22 @@ done sign() { # Hardened runtime + secure timestamp for a real identity; ad-hoc can't be timestamped. local ts=(--timestamp) - [ "$IDENTITY" = "-" ] && ts=(--timestamp=none) + [[ "$IDENTITY" = "-" ]] && ts=(--timestamp=none) local kc=() - [ -n "${MACOS_KEYCHAIN:-}" ] && kc=(--keychain "$MACOS_KEYCHAIN") + [[ -n "${MACOS_KEYCHAIN:-}" ]] && kc=(--keychain "$MACOS_KEYCHAIN") codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@" } # stapler fetches the ticket from Apple's CloudKit, which sometimes drops the connection ("The # network connection was lost", Error 68). Stapling and validating are idempotent, so retry. stapler_retry() { - local attempt + local action="$1" attempt for attempt in 1 2 3 4 5; do if xcrun stapler "$@"; then return 0 fi - [ "$attempt" = 5 ] && { echo "stapler $1 failed 5 times" >&2; return 1; } - warn "stapler $1 failed (attempt $attempt of 5); retrying in $((attempt * 15))s" + [[ "$attempt" = 5 ]] && { echo "stapler $action failed 5 times" >&2; return 1; } + warn "stapler $action failed (attempt $attempt of 5); retrying in $((attempt * 15))s" sleep $((attempt * 15)) done } @@ -102,8 +102,8 @@ notarize() { echo "$out" id="$(printf '%s' "$out" | plutil -extract id raw -o - - 2>/dev/null || true)" status="$(printf '%s' "$out" | plutil -extract status raw -o - - 2>/dev/null || true)" - if [ "$status" != "Accepted" ]; then - if [ -n "$id" ]; then + if [[ "$status" != "Accepted" ]]; then + if [[ -n "$id" ]]; then xcrun notarytool log "$id" --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" || true fi echo "error: notarization of $(basename "$file") failed (status: ${status:-unknown})" >&2 @@ -131,7 +131,7 @@ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/ sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then ditto -c -k --keepParent "$APP" "$WORK/GridCraft-notarize.zip" notarize "$WORK/GridCraft-notarize.zip" stapler_retry staple "$APP" @@ -158,7 +158,7 @@ hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" -if [ "$NOTARIZE" = 1 ]; then +if [[ "$NOTARIZE" = 1 ]]; then notarize "$DMG" stapler_retry staple "$DMG" stapler_retry validate "$DMG" @@ -176,7 +176,7 @@ codesign --verify --strict --verbose=2 "$CLI_DIR/gridcraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. -if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi +if [[ "$NOTARIZE" = 1 ]]; then notarize "$CLI_ZIP"; fi "$WORK/bin/gridcraft-cli" --version echo "==> done" diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 8b1bca63b..7c5be6134 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,15 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" SITE="$ROOT/dist/web" -if [ "${1:-}" != "--skip-build" ]; then +if [[ "${1:-}" != "--skip-build" ]]; then command -v trunk >/dev/null || { echo "error: trunk not found (cargo install trunk --locked)" >&2; exit 1; } - [ -f "$ROOT/apps/gridcraft-web/index.html" ] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } + [[ -f "$ROOT/apps/gridcraft-web/index.html" ]] || { echo "error: apps/gridcraft-web/index.html missing (the web app isn't set up yet)" >&2; exit 1; } # --dist/--public-url here, so the output doesn't depend on Trunk.toml (which should agree: # public_url = "./"). (cd "$ROOT/apps/gridcraft-web" && trunk build --release --dist "$SITE" --public-url ./) fi -[ -f "$SITE/index.html" ] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } +[[ -f "$SITE/index.html" ]] || { echo "error: $SITE/index.html missing; run without --skip-build" >&2; exit 1; } # Paths must be relative so the site works under any prefix (public_url = "./" in Trunk.toml). if grep -Eq '(src|href)="/[^/]' "$SITE/index.html"; then echo "error: $SITE/index.html has root-absolute URLs; it would break when served from a sub-path" >&2 diff --git a/packaging/windows/test-desktop-shortcut.ps1 b/packaging/windows/test-desktop-shortcut.ps1 index 9a5b4cb04..f3c25b2c8 100644 --- a/packaging/windows/test-desktop-shortcut.ps1 +++ b/packaging/windows/test-desktop-shortcut.ps1 @@ -72,10 +72,10 @@ function Invoke-Msi([string] $Name, [string[]] $Arguments) { throw "$Name exceeded two minutes; see $log" } if ($process.ExitCode -ne 0) { - if (Test-Path $log) { Get-Content $log -Tail 60 | Write-Host } + if (Test-Path $log) { Get-Content $log -Tail 60 } throw "$Name exited $($process.ExitCode); see $log" } - Write-Host "ok $Name" + Write-Output "ok $Name" } finally { $process.Dispose() } @@ -146,7 +146,7 @@ function Uninstall-AsSystem($Package) { $info = Get-ScheduledTaskInfo -TaskName $taskName if ($info.LastRunTime -ge $started.AddSeconds(-1) -and $task.State -ne 'Running' -and $info.LastTaskResult -ne 267009) { Assert-That ($info.LastTaskResult -eq 0) "Cross-account uninstall failed with $($info.LastTaskResult); see $log" - Write-Host 'ok uninstall-as-system' + Write-Output 'ok uninstall-as-system' return } } while ((Get-Date) -lt $deadline) From ba980399887494f0539d30cceaf52a5b8b12d05c Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:33:31 +0200 Subject: [PATCH 043/149] Keep the in-cell editor wide enough for the next character at high zoom (#2336) --- crates/ui-egui/src/grid.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..acef3f8cb 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3087,6 +3087,13 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: } } +/// Room the in-cell editor keeps right of its text. It is sized from the text of the last frame, so +/// the next typed character must still fit: a fixed 14 px was less than one glyph at high zoom, +/// and the field scrolled and snapped back on every key (#2336). `size` is the font size in px. +fn editor_slack(size: f32) -> f32 { + 14.0 + size +} + 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 { @@ -3113,7 +3120,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; // Excel keeps the cell's height for one line and only grows for extra lines. let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - ((text_w + 14.0).max(rect.width()).min(geo.cells.right() - rect.left()), h) + ((text_w + editor_slack(size)).max(rect.width()).min(geo.cells.right() - rect.left()), h) }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); @@ -4002,6 +4009,15 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { #[cfg(test)] mod tests { + #[test] + fn editor_slack_fits_one_more_glyph_at_high_zoom() { + use super::editor_slack; + // A glyph is at most about one em wide; at 400% an em is ~60 px, far over the old fixed 14 px. + for size in [14.7_f32, 25.0, 60.0] { + assert!(editor_slack(size) - 4.0 >= size, "slack {} too small for a {size} px glyph", editor_slack(size)); + } + } + #[test] fn explicit_left_and_right_keep_their_side_on_a_right_to_left_sheet() { use super::{HAlign, painted_halign}; From 599762f534bb47e6505fa6044d6aa2c17bc8fa4e Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:33:55 +0200 Subject: [PATCH 044/149] AutoFilter: grow the range when a value is entered in the row below it (#2342) --- crates/engine/src/cmd/data.rs | 42 +++++++++++++++++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 6 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 52a716cff..8fe74779c 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -652,6 +652,24 @@ fn filter_blank(v: &Value) -> bool { v.is_empty() || matches!(v, Value::Text(t) if t.is_empty()) } +/// Excel grows an AutoFilter range when a value is entered in the row right below it, in one of its columns (#2342). +/// A table's own filter is left to the table, and a row typed after a blank row does not count. +/// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. +pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { + if !sh.cells.get(at).is_some_and(|c| !c.value.is_empty() || c.formula.is_some()) { + return; + } + let Some(af) = sh.autofilter.as_ref() else { return }; + let r = af.range; + if r.end.row.checked_add(1) != Some(at.row) || at.col < r.start.col || at.col > r.end.col || sh.tables.iter().any(|t| t.range == r) { + return; + } + let grown = RangeRef::new(r.start, CellRef::new(at.row, r.end.col)); + if let Some(af) = sh.autofilter.as_mut() { + af.range = grown; + } +} + /// Hides rows of the autofilter range that fail any criterion. pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) { let Some(sh) = wb.sheet(sheet) else { return }; @@ -1554,6 +1572,30 @@ mod tests { } } + #[test] + fn autofilter_range_grows_when_a_value_is_entered_in_the_row_below() { + let mut s = Session::new(); + s.new_workbook(); + s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Qty"], ["d", 4], ["b", 2]]})).unwrap(); + s.execute("data.filter", json!({"range": "A1:B3"})).unwrap(); + let range = |s: &Session| s.doc().unwrap().wb.active().unwrap().autofilter.as_ref().map(|a| a.range.a1()); + s.execute("cell.set", json!({"cell": "A4", "input": "e"})).unwrap(); + assert_eq!(range(&s), Some("A1:B4".to_string())); + s.execute("range.setValues", json!({"range": "A5", "values": [["f", 6]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A row typed after a blank row does not grow it. + s.execute("cell.set", json!({"cell": "A7", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A cell outside the filter's columns does not either. + s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); + // A formula and Ctrl+Enter (range.fill) are entries too. + s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); + assert_eq!(range(&s), Some("A1:B6".to_string())); + s.execute("range.fill", json!({"range": "A7:B7", "input": "g"})).unwrap(); + assert_eq!(range(&s), Some("A1:B7".to_string())); + } + #[test] fn list_validation_without_formula1_is_an_error() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1998f0b2c..352797eab 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -272,6 +272,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result { sh.remove_cell(at); } } + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); Ok(Json::Null) })?; @@ -350,6 +351,7 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result { sh.remove_cell(at); } } + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); count += 1; } @@ -390,7 +392,9 @@ fn range_fill(s: &mut Session, p: &Json) -> Result { let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64); cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted))); } - cx.sheet_mut(sheet)?.set_cell(at, cell); + let sh = cx.sheet_mut(sheet)?; + sh.set_cell(at, cell); + super::data::grow_autofilter(sh, at); cx.touch(sheet, at); } } From bdf8b8cc755ae6dfc2266d23b3ede43329114339 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 11:48:02 -0400 Subject: [PATCH 045/149] Use chrono instead of hand-rolled UTC conversion (#2121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled civil_from_days / days_from_civil copies and the is_leap / days_in_month calendar helpers with chrono as a workspace dependency (default-features = false, std only, so wasm is unaffected). The spreadsheet serial-date semantics are untouched: Excel's 1900 fictitious leap day (serial 60 = 1900-02-29), the 1899-12-30 / 1904-01-01 serial offsets, Excel's weekday numbering and the 1900/1904 systems keep their explicit special cases in serial_from_ymd / datetime_from_serial / days_in_month_in. chrono only backs the plain proleptic-Gregorian day-number <-> date conversion and the leap-year / month-length helpers those build on; a small fallback keeps the old answer for dates outside chrono's NaiveDate range. The duplicate local is_leap in the financial functions now uses the core one. Output is byte-identical and pinned by the existing date, parse, financial and logging tests. AGENTS.md now says time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on out-of-range clocks or years; keep calendar results outside chrono's range - logging::timestamp: chrono's From unwraps, so a clock past year 262143 panicked inside the logger. Use the fallible DateTime::from_timestamp and read such a clock as the epoch, like the old saturating code. Years past 9999 are written with {:04} (not %Y, which prefixes a '+'). - date::is_leap: restore the Gregorian leap-year rule for years outside chrono's NaiveDate range (e.g. is_leap(400000) is true again) via direct arithmetic. - date::days_in_month: compute the December boundary with checked_add so days_in_month(i32::MAX, 12) no longer overflows, and compute month lengths through days_from_civil's fallback so out-of-range years keep their real lengths (e.g. days_in_month(400000, 2) is 29). Invalid months still return 0. - civil_from_days: add missing docstring. - AGENTS.md: note the narrow out-of-range fallback exception to the chrono rule. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * Route out-of-range dates through chrono's 400-year cycle, not hand-rolled copies The PR kept hand-rolled Hinnant `days_from_civil_fallback`/`civil_from_days_fallback` copies for dates outside chrono's ±262143-year range, plus a hand-rolled Gregorian leap rule in `is_leap` and an i32::MAX special-case in `days_in_month`. AGENTS.md carved a "narrow fallback" exception for them, which left calendar arithmetic outside chrono after all. Replace all of it with the Gregorian 400-year cycle (146_097 days): chrono converts the day/year within one cycle and whole cycles are added back via i128 (so the i32/i64 extremes can't overflow). `is_leap` and `days_in_month` reduce the year with `rem_euclid(400)` (leap years and month lengths repeat every 400 years), which also removes the December `i32::MAX` overflow special-case. chrono is now the only calendar logic, and every output is unchanged: verified the cycle result equals the old formula over wide ranges and at the i32/i64 extremes with a scratch chrono program. The Excel 1900 leap bug (serial 60 = fictitious 1900-02-29) and 1904 semantics are untouched -- `serial_from_ymd`/ `datetime_from_serial` only ever hand the converters in-range years, so their serials are byte-for-byte the pre-chrono ones (pinned by a new test). New tests pin `days_from_civil`/`civil_from_days` past chrono's range on both sides of the epoch, round-tripping, with values from the old formula; the existing leap/month-length-beyond-range test still passes unchanged. Removed the AGENTS.md "narrow fallback" exception. Tested: cargo test -p gridcraft-core -p gridcraft-functions; fmt --check; clippy --all-targets -D warnings. * time: preserve out-of-range years in the log timestamp instead of the epoch `DateTime::from_timestamp(..).unwrap_or_default()` changed the existing output: a clock set far ahead (an input the old hand-rolled civil-from-days converted) collapsed to `1970-01-01`. Restore the old conversion domain with a cycle-based helper: reduce by whole 400-year Gregorian cycles (146_097 days), convert the in-range remainder with chrono, and carry the year back as i128. Only the year leaves chrono's range; month/day/time stay exact. Pre-1970 still clamps to the epoch as before. chrono stays the only calendar logic (no new features). apps/gridcraft logging.rs `timestamp` (SystemTime, u64 secs), pinned via a platform-safe `UNIX_EPOCH.checked_add`: u64::MAX/2 s → "292277026596-12-04T15:30:07.000Z" (was "1970-01-01..."), 10e12 s → "318857-05-20T17:46:40.000Z", 253_402_300_800 s → "10000-01-01...". * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does. --- AGENTS.md | 1 + Cargo.lock | 11 +++ Cargo.toml | 2 + apps/gridcraft/Cargo.toml | 1 + apps/gridcraft/src/logging.rs | 55 +++++++++----- crates/core/Cargo.toml | 1 + crates/core/src/date.rs | 135 ++++++++++++++++++++++++++++------ 7 files changed, 163 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 963fc6d74..ab73047ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`). - **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.** - **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`. +- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`). - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`). diff --git a/Cargo.lock b/Cargo.lock index c7e3560b6..7f5203188 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,6 +619,15 @@ dependencies = [ "libc", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.5.2" @@ -1499,6 +1508,7 @@ name = "gridcraft" version = "0.11.1" dependencies = [ "arboard", + "chrono", "craft-print", "eframe", "egui", @@ -1555,6 +1565,7 @@ dependencies = [ name = "gridcraft-core" version = "0.11.1" dependencies = [ + "chrono", "js-sys", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index bf8eb381e..9994d44e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } rayon = "1" regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] } quick-xml = "0.41" diff --git a/apps/gridcraft/Cargo.toml b/apps/gridcraft/Cargo.toml index cd8311b34..94cf3fe9b 100644 --- a/apps/gridcraft/Cargo.toml +++ b/apps/gridcraft/Cargo.toml @@ -18,6 +18,7 @@ egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } image = { workspace = true } +chrono = { workspace = true } [build-dependencies] # Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows. diff --git a/apps/gridcraft/src/logging.rs b/apps/gridcraft/src/logging.rs index e3b56a8cb..a0f62d164 100644 --- a/apps/gridcraft/src/logging.rs +++ b/apps/gridcraft/src/logging.rs @@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool { } } -/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock +/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved) +/// instead of panicking. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years +/// (146_097 days), so month, day and time of day come from chrono and only the year is carried, +/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is +/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`. +fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -393,6 +399,17 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than + // collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built + // with a checked add and only asserted where it exists. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); } #[test] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1decf6257..e7d369552 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] serde = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" diff --git a/crates/core/src/date.rs b/crates/core/src/date.rs index b61892f6c..b42c068a0 100644 --- a/crates/core/src/date.rs +++ b/crates/core/src/date.rs @@ -28,15 +28,22 @@ pub struct DateTime { pub weekday: u32, } -/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01. +/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year. +/// +/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400 +/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added +/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce` +/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave. pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 { - let y = if m <= 2 { y as i64 - 1 } else { y as i64 }; - let era = if y >= 0 { y } else { y - 399 } / 400; - let yoe = y - era * 400; - let m = m as i64; - let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - era * 146097 + doe - 719468 + let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400)); + i32::try_from(year) + .ok() + .and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d)) + .and_then(|date| { + let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097; + i64::try_from(days).ok() + }) + .unwrap_or(0) } /// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable. @@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 { } } +/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of +/// [`days_from_civil`]). +/// +/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle +/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the +/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range. +/// 1970-01-01 is `num_days_from_ce` 719_163. pub fn civil_from_days(z: i64) -> (i32, u32, u32) { - let z = z + 719468; - let era = if z >= 0 { z } else { z - 146096 } / 146097; - let doe = z - era * 146097; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; - let y = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = (doy - (153 * mp + 2) / 5 + 1) as u32; - let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; - ((if m <= 2 { y + 1 } else { y }) as i32, m, d) + use chrono::Datelike; + let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097)); + match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) { + Some(d) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day()) + } + // Unreachable: `rest + 719_163` is always a valid CE day. + None => (1970, 1, 1), + } } const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30) const UNIX_1904_01_01: i64 = -24107; +/// Whether `y` is a Gregorian leap year, for every `i32` year. pub fn is_leap(y: i32) -> bool { - (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 + // A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so + // the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono. + let y = i64::from(y).rem_euclid(400) as i32; + chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some() } +/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every +/// `i32` year. pub fn days_in_month(y: i32, m: u32) -> u32 { - match m { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 if is_leap(y) => 29, - 2 => 28, + // The length of a month is the day before the first of the next month. Month lengths repeat + // every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and + // always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`. + if !(1..=12).contains(&m) { + return 0; + } + let y = i64::from(y).rem_euclid(400) as i32; + let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1); + match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) { + (Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0), _ => 0, } } @@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th mod tests { use super::*; + #[test] + fn leap_and_month_length_outside_chrono_range() { + // In range, the ordinary Gregorian rule holds. + assert!(is_leap(2000)); + assert!(!is_leap(1900)); + assert!(is_leap(2024)); + assert!(!is_leap(2023)); + // Outside chrono's NaiveDate range the Gregorian rule is still applied. + assert!(is_leap(400_000)); // divisible by 400 + assert!(!is_leap(300_100)); // century not divisible by 400 + assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century + assert!(!is_leap(i32::MAX)); // 2147483647 is odd + // days_in_month keeps calendar lengths out of range and 0 for invalid months. + assert_eq!(days_in_month(400_000, 2), 29); + assert_eq!(days_in_month(300_100, 2), 28); + assert_eq!(days_in_month(400_000, 1), 31); + assert_eq!(days_in_month(400_000, 4), 30); + assert_eq!(days_in_month(2024, 2), 29); + assert_eq!(days_in_month(2023, 2), 28); + assert_eq!(days_in_month(2023, 0), 0); + assert_eq!(days_in_month(2023, 13), 0); + // December at i32::MAX does not overflow and still reports 31 days. + assert_eq!(days_in_month(i32::MAX, 12), 31); + assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year + } + + #[test] + fn civil_conversions_exact_beyond_chrono_range() { + assert_eq!(days_from_civil(1970, 1, 1), 0); + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of + // the epoch; values round-trip through the proleptic Gregorian converters. + for (y, m, d, z) in [ + (262_144i32, 1u32, 1u32, 95_026_602i64), + (300_000, 1, 1, 108_853_222), + (400_000, 2, 29, 145_377_531), + (-262_145, 3, 1, -96_465_964), + (-300_000, 6, 15, -110_292_112), + ] { + assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}"); + assert_eq!(civil_from_days(z), (y, m, d), "{z}"); + } + // The extremes must not overflow or panic (i128 intermediates). + let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN)); + } + + #[test] + fn serials_unchanged_through_cycle_conversion() { + // The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see + // in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones. + let s = DateSystem::D1900; + assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day + assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0)); + assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0)); + assert_eq!(days_in_month_in(s, 1900, 2), 29); + let s4 = DateSystem::D1904; + assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01 + assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year + assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system + assert_eq!(days_in_month_in(s4, 1900, 2), 28); + } + #[test] fn serial_from_unix_time() { assert_eq!(serial_from_unix_ms(0.0), 25569.0); From 8f2a60d17638aeee39d5152fc48df9a6be7ba292 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:16 +0200 Subject: [PATCH 046/149] Keep the in-cell editor the cell's width while the text fits (#2336) --- crates/ui-egui/src/grid.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index acef3f8cb..859cf1053 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3094,6 +3094,12 @@ fn editor_slack(size: f32) -> f32 { 14.0 + size } +/// Width of the one-line in-cell editor. While the text fits the cell (with the usual 14 px margin) the +/// editor is exactly the cell wide, as in Excel; once it overflows, the extra `editor_slack` applies. +fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 { + if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) } +} + 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 { @@ -3120,7 +3126,7 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, let text_w = ui.painter().layout_no_wrap(ed.text.clone(), font.clone(), Color32::BLACK).size().x; // Excel keeps the cell's height for one line and only grows for extra lines. let h = if lines > 1.0 { (size * 1.3 * lines + 4.0).max(rect.height()) } else { rect.height() }; - ((text_w + editor_slack(size)).max(rect.width()).min(geo.cells.right() - rect.left()), h) + (editor_width(text_w, rect.width(), size).min(geo.cells.right() - rect.left()), h) }; // Grows to the left on a right-to-left sheet. let erect = geo.mirror_rect(Rect::from_min_size(rect.min, vec2(w, h))); @@ -4009,6 +4015,14 @@ pub(crate) fn heading_font_size(zoom: f32) -> f32 { #[cfg(test)] mod tests { + #[test] + fn editor_matches_the_cell_while_the_text_fits() { + use super::editor_width; + assert_eq!(editor_width(10.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(66.0, 80.0, 7.0), 80.0); + assert_eq!(editor_width(67.0, 80.0, 7.0), 88.0); + } + #[test] fn editor_slack_fits_one_more_glyph_at_high_zoom() { use super::editor_slack; From 45ec9dfbfe6b1a0a82f4c6ddf92fbe68a3e6e608 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 18:44:23 +0200 Subject: [PATCH 047/149] Build the saved UiState in the test with struct update syntax (clippy field_reassign_with_default) --- crates/ui-egui/tests/contextual_ribbon_tab.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/contextual_ribbon_tab.rs index bdd8b5c93..5221f2d60 100644 --- a/crates/ui-egui/tests/contextual_ribbon_tab.rs +++ b/crates/ui-egui/tests/contextual_ribbon_tab.rs @@ -75,9 +75,7 @@ fn leaving_the_table_while_on_table_design_returns_to_home() { #[test] fn startup_opens_on_home_not_the_saved_tab() { - let mut saved = gridcraft_ui_egui::UiState::default(); - saved.ribbon_tab = "Table Design".into(); - saved.dark = true; + let saved = gridcraft_ui_egui::UiState { ribbon_tab: "Table Design".into(), dark: true, ..Default::default() }; let json = serde_json::to_string(&saved).unwrap(); assert!(json.contains(r#""ribbonTab":"Table Design""#), "{json}"); let restored = serde_json::from_str::(&json).unwrap().at_startup(); From a7608ada6dc91f9caf53f9eb7c2cdb3231ab1e75 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:59:46 -0400 Subject: [PATCH 048/149] CI: one test binary per crate, nextest, line tables only (#2372) * CI: dev builds keep line tables only * CI: one integration-test binary per crate * CI: xtask runs tests with nextest, doctests apart, --keep-going on clippy and wasm cargo xtask test runs the workspace tests (--lib --bins --tests) with cargo-nextest when it is installed, else cargo test, then the doctests. cargo xtask ci uses the same two steps. * CI: docs name cargo xtask test * CI: agent guides name the moved fuzz test * CI: tests added on main join their crate's test binary --- AGENTS.md | 2 +- CLAUDE.md | 2 +- Cargo.toml | 2 + apps/gridcraft-cli/tests/{ => it}/cli.rs | 2 - apps/gridcraft-cli/tests/it/main.rs | 5 + .../tests/{ => it}/text_to_columns_quotes.rs | 0 .../tests/{ => it}/autofit_filter_buttons.rs | 0 .../tests/{ => it}/cf_formula_validate.rs | 0 .../tests/{ => it}/chart_workbook_names.rs | 0 .../tests/{ => it}/command_sequence_fuzz.rs | 4 +- .../engine/tests/{ => it}/cross_sheet_cut.rs | 0 .../tests/{ => it}/csv_export_budget.rs | 0 .../tests/{ => it}/custom_filter_blanks.rs | 0 .../tests/{ => it}/custom_view_freeze.rs | 0 .../engine/tests/{ => it}/dynamicmetadata.rs | 0 .../tests/{ => it}/extreme_selection_moves.rs | 0 .../tests/{ => it}/format_as_table_restyle.rs | 0 .../tests/{ => it}/hidden_sheet_navigation.rs | 0 crates/engine/tests/{ => it}/ink_roundtrip.rs | 0 .../tests/{ => it}/line_count_overflow.rs | 0 .../long_operator_chains.rs} | 0 crates/engine/tests/it/main.rs | 29 ++++++ .../engine/tests/{ => it}/offset_overflow.rs | 0 crates/engine/tests/{ => it}/percent_entry.rs | 0 .../tests/{ => it}/pivot_unknown_func.rs | 0 .../engine/tests/{ => it}/pivotchart_link.rs | 0 .../tests/{ => it}/range_fill_formats.rs | 0 crates/engine/tests/{ => it}/row_autofit.rs | 0 .../tests/{ => it}/sheet_name_quoting.rs | 0 .../{ => it}/sparse_clear_invalidation.rs | 0 .../tests/{ => it}/stored_list_shape.rs | 0 .../tests/{ => it}/table_resize_overlap.rs | 0 .../{ => it}/table_total_row_last_row.rs | 0 .../engine/tests/{ => it}/wrap_row_height.rs | 0 crates/formula/tests/it/main.rs | 4 + .../tests/{ => it}/printer_roundtrip.rs | 0 .../formula/tests/{ => it}/spanish_input.rs | 0 .../ui-egui/tests/{ => it}/accounting_fill.rs | 0 .../ui-egui/tests/{ => it}/advanced_filter.rs | 0 .../ui-egui/tests/{ => it}/argument_names.rs | 0 .../tests/{ => it}/autosave_persist.rs | 0 crates/ui-egui/tests/{ => it}/borders.rs | 0 .../tests/{ => it}/cell_editor_caret.rs | 0 .../ui-egui/tests/{ => it}/cell_pictures.rs | 0 crates/ui-egui/tests/{ => it}/chart_resize.rs | 0 crates/ui-egui/tests/{ => it}/clipboard.rs | 0 .../ui-egui/tests/{ => it}/comments_pane.rs | 0 .../tests/{ => it}/commit_after_pointing.rs | 0 .../tests/{ => it}/committed_text_fields.rs | 0 .../tests/{ => it}/context_menu_stays_open.rs | 0 .../ui-egui/tests/{ => it}/control_timeout.rs | 0 .../tests/{ => it}/dialog_button_row.rs | 0 .../ui-egui/tests/{ => it}/dialog_confirm.rs | 0 crates/ui-egui/tests/{ => it}/dialog_focus.rs | 0 crates/ui-egui/tests/{ => it}/dialog_move.rs | 0 crates/ui-egui/tests/{ => it}/drag_cursor.rs | 0 crates/ui-egui/tests/{ => it}/file_menu.rs | 0 .../{ => it}/fill_handle_double_click.rs | 0 .../tests/{ => it}/fill_handle_right_drag.rs | 0 .../tests/{ => it}/fill_options_button.rs | 0 .../tests/{ => it}/filter_list_scroll.rs | 0 .../tests/{ => it}/filter_menu_enter.rs | 0 .../tests/{ => it}/find_dialog_focus.rs | 0 .../tests/{ => it}/floating_pictures.rs | 0 .../ui-egui/tests/{ => it}/font_size_box.rs | 0 .../ui-egui/tests/{ => it}/format_painter.rs | 0 .../ui-egui/tests/{ => it}/formula_locale.rs | 0 .../tests/{ => it}/formula_point_headers.rs | 0 .../ui-egui/tests/{ => it}/formula_popup.rs | 0 .../ui-egui/tests/{ => it}/freeze_handles.rs | 0 .../ui-egui/tests/{ => it}/header_autofit.rs | 0 .../tests/{ => it}/header_column_drag.rs | 0 crates/ui-egui/tests/{ => it}/host_chrome.rs | 0 .../tests/{ => it}/hyperlink_ctrl_click.rs | 0 .../tests/{ => it}/in_cell_editor_size.rs | 0 .../tests/{ => it}/in_cell_editor_wrap.rs | 0 .../tests/{ => it}/insert_copied_cells.rs | 0 crates/ui-egui/tests/{ => it}/interaction.rs | 0 .../ui-egui/tests/{ => it}/interface_scale.rs | 0 .../tests/{ => it}/keyboard_shortcuts.rs | 0 .../tests/{ => it}/keytips_generated.rs | 0 crates/ui-egui/tests/it/main.rs | 91 +++++++++++++++++++ crates/ui-egui/tests/{ => it}/menu_hover.rs | 0 crates/ui-egui/tests/{ => it}/merged_cells.rs | 0 crates/ui-egui/tests/{ => it}/more_colors.rs | 0 crates/ui-egui/tests/{ => it}/native_print.rs | 0 .../tests/{ => it}/nowrap_line_breaks.rs | 0 .../tests/{ => it}/number_stored_as_text.rs | 0 .../ui-egui/tests/{ => it}/open_encrypted.rs | 0 crates/ui-egui/tests/{ => it}/outline.rs | 0 .../tests/{ => it}/page_layout_view.rs | 0 .../tests/{ => it}/page_setup_margins.rs | 0 .../tests/{ => it}/paste_special_dialog.rs | 0 crates/ui-egui/tests/{ => it}/pivot_pane.rs | 0 .../ui-egui/tests/{ => it}/popup_dismissal.rs | 0 .../tests/{ => it}/protect_password.rs | 0 .../ui-egui/tests/{ => it}/right_to_left.rs | 0 .../ui-egui/tests/{ => it}/scroll_position.rs | 0 .../tests/{ => it}/scrollbar_column_fill.rs | 0 crates/ui-egui/tests/{ => it}/selection.rs | 0 .../ui-egui/tests/{ => it}/selection_fill.rs | 0 .../ui-egui/tests/{ => it}/selection_glide.rs | 0 .../tests/{ => it}/selection_scroll.rs | 0 .../ui-egui/tests/{ => it}/sheet_navigator.rs | 0 .../tests/{ => it}/sheet_tab_rename.rs | 0 crates/ui-egui/tests/{ => it}/sheet_tabs.rs | 0 crates/ui-egui/tests/{ => it}/show_objects.rs | 0 .../ui-egui/tests/{ => it}/shrink_to_fit.rs | 0 .../tests/{ => it}/sort_dialog_columns.rs | 0 .../ui-egui/tests/{ => it}/symbol_glyphs.rs | 0 crates/ui-egui/tests/{ => it}/system_theme.rs | 0 .../ui-egui/tests/{ => it}/table_headings.rs | 0 .../tests/{ => it}/text_box_editing.rs | 0 .../tests/{ => it}/text_overflow_scroll.rs | 0 crates/ui-egui/tests/{ => it}/touch_pan.rs | 0 .../tests/{ => it}/trace_navigation.rs | 0 .../tests/{ => it}/translated_form_fields.rs | 0 crates/ui-egui/tests/{ => it}/ui_clock.rs | 0 .../tests/{ => it}/validation_alert.rs | 0 .../{ => it}/validation_input_message.rs | 0 crates/ui-egui/tests/{ => it}/wheel_zoom.rs | 0 .../tests/{ => it}/whole_line_shortcuts.rs | 0 crates/ui-egui/tests/{ => it}/widgets.rs | 0 crates/ui-egui/tests/{ => it}/zoom_dialog.rs | 0 docs/roadmap.md | 6 +- docs/target-app-parity.md | 2 +- xtask/src/keytips.rs | 4 +- xtask/src/main.rs | 60 ++++++++---- 128 files changed, 186 insertions(+), 27 deletions(-) rename apps/gridcraft-cli/tests/{ => it}/cli.rs (99%) create mode 100644 apps/gridcraft-cli/tests/it/main.rs rename apps/gridcraft-cli/tests/{ => it}/text_to_columns_quotes.rs (100%) rename crates/engine/tests/{ => it}/autofit_filter_buttons.rs (100%) rename crates/engine/tests/{ => it}/cf_formula_validate.rs (100%) rename crates/engine/tests/{ => it}/chart_workbook_names.rs (100%) rename crates/engine/tests/{ => it}/command_sequence_fuzz.rs (99%) rename crates/engine/tests/{ => it}/cross_sheet_cut.rs (100%) rename crates/engine/tests/{ => it}/csv_export_budget.rs (100%) rename crates/engine/tests/{ => it}/custom_filter_blanks.rs (100%) rename crates/engine/tests/{ => it}/custom_view_freeze.rs (100%) rename crates/engine/tests/{ => it}/dynamicmetadata.rs (100%) rename crates/engine/tests/{ => it}/extreme_selection_moves.rs (100%) rename crates/engine/tests/{ => it}/format_as_table_restyle.rs (100%) rename crates/engine/tests/{ => it}/hidden_sheet_navigation.rs (100%) rename crates/engine/tests/{ => it}/ink_roundtrip.rs (100%) rename crates/engine/tests/{ => it}/line_count_overflow.rs (100%) rename crates/engine/tests/{long-operator-chains.rs => it/long_operator_chains.rs} (100%) create mode 100644 crates/engine/tests/it/main.rs rename crates/engine/tests/{ => it}/offset_overflow.rs (100%) rename crates/engine/tests/{ => it}/percent_entry.rs (100%) rename crates/engine/tests/{ => it}/pivot_unknown_func.rs (100%) rename crates/engine/tests/{ => it}/pivotchart_link.rs (100%) rename crates/engine/tests/{ => it}/range_fill_formats.rs (100%) rename crates/engine/tests/{ => it}/row_autofit.rs (100%) rename crates/engine/tests/{ => it}/sheet_name_quoting.rs (100%) rename crates/engine/tests/{ => it}/sparse_clear_invalidation.rs (100%) rename crates/engine/tests/{ => it}/stored_list_shape.rs (100%) rename crates/engine/tests/{ => it}/table_resize_overlap.rs (100%) rename crates/engine/tests/{ => it}/table_total_row_last_row.rs (100%) rename crates/engine/tests/{ => it}/wrap_row_height.rs (100%) create mode 100644 crates/formula/tests/it/main.rs rename crates/formula/tests/{ => it}/printer_roundtrip.rs (100%) rename crates/formula/tests/{ => it}/spanish_input.rs (100%) rename crates/ui-egui/tests/{ => it}/accounting_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/advanced_filter.rs (100%) rename crates/ui-egui/tests/{ => it}/argument_names.rs (100%) rename crates/ui-egui/tests/{ => it}/autosave_persist.rs (100%) rename crates/ui-egui/tests/{ => it}/borders.rs (100%) rename crates/ui-egui/tests/{ => it}/cell_editor_caret.rs (100%) rename crates/ui-egui/tests/{ => it}/cell_pictures.rs (100%) rename crates/ui-egui/tests/{ => it}/chart_resize.rs (100%) rename crates/ui-egui/tests/{ => it}/clipboard.rs (100%) rename crates/ui-egui/tests/{ => it}/comments_pane.rs (100%) rename crates/ui-egui/tests/{ => it}/commit_after_pointing.rs (100%) rename crates/ui-egui/tests/{ => it}/committed_text_fields.rs (100%) rename crates/ui-egui/tests/{ => it}/context_menu_stays_open.rs (100%) rename crates/ui-egui/tests/{ => it}/control_timeout.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_button_row.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_confirm.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/dialog_move.rs (100%) rename crates/ui-egui/tests/{ => it}/drag_cursor.rs (100%) rename crates/ui-egui/tests/{ => it}/file_menu.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_handle_double_click.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_handle_right_drag.rs (100%) rename crates/ui-egui/tests/{ => it}/fill_options_button.rs (100%) rename crates/ui-egui/tests/{ => it}/filter_list_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/filter_menu_enter.rs (100%) rename crates/ui-egui/tests/{ => it}/find_dialog_focus.rs (100%) rename crates/ui-egui/tests/{ => it}/floating_pictures.rs (100%) rename crates/ui-egui/tests/{ => it}/font_size_box.rs (100%) rename crates/ui-egui/tests/{ => it}/format_painter.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_locale.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_point_headers.rs (100%) rename crates/ui-egui/tests/{ => it}/formula_popup.rs (100%) rename crates/ui-egui/tests/{ => it}/freeze_handles.rs (100%) rename crates/ui-egui/tests/{ => it}/header_autofit.rs (100%) rename crates/ui-egui/tests/{ => it}/header_column_drag.rs (100%) rename crates/ui-egui/tests/{ => it}/host_chrome.rs (100%) rename crates/ui-egui/tests/{ => it}/hyperlink_ctrl_click.rs (100%) rename crates/ui-egui/tests/{ => it}/in_cell_editor_size.rs (100%) rename crates/ui-egui/tests/{ => it}/in_cell_editor_wrap.rs (100%) rename crates/ui-egui/tests/{ => it}/insert_copied_cells.rs (100%) rename crates/ui-egui/tests/{ => it}/interaction.rs (100%) rename crates/ui-egui/tests/{ => it}/interface_scale.rs (100%) rename crates/ui-egui/tests/{ => it}/keyboard_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/keytips_generated.rs (100%) create mode 100644 crates/ui-egui/tests/it/main.rs rename crates/ui-egui/tests/{ => it}/menu_hover.rs (100%) rename crates/ui-egui/tests/{ => it}/merged_cells.rs (100%) rename crates/ui-egui/tests/{ => it}/more_colors.rs (100%) rename crates/ui-egui/tests/{ => it}/native_print.rs (100%) rename crates/ui-egui/tests/{ => it}/nowrap_line_breaks.rs (100%) rename crates/ui-egui/tests/{ => it}/number_stored_as_text.rs (100%) rename crates/ui-egui/tests/{ => it}/open_encrypted.rs (100%) rename crates/ui-egui/tests/{ => it}/outline.rs (100%) rename crates/ui-egui/tests/{ => it}/page_layout_view.rs (100%) rename crates/ui-egui/tests/{ => it}/page_setup_margins.rs (100%) rename crates/ui-egui/tests/{ => it}/paste_special_dialog.rs (100%) rename crates/ui-egui/tests/{ => it}/pivot_pane.rs (100%) rename crates/ui-egui/tests/{ => it}/popup_dismissal.rs (100%) rename crates/ui-egui/tests/{ => it}/protect_password.rs (100%) rename crates/ui-egui/tests/{ => it}/right_to_left.rs (100%) rename crates/ui-egui/tests/{ => it}/scroll_position.rs (100%) rename crates/ui-egui/tests/{ => it}/scrollbar_column_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/selection.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_fill.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_glide.rs (100%) rename crates/ui-egui/tests/{ => it}/selection_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_navigator.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_tab_rename.rs (100%) rename crates/ui-egui/tests/{ => it}/sheet_tabs.rs (100%) rename crates/ui-egui/tests/{ => it}/show_objects.rs (100%) rename crates/ui-egui/tests/{ => it}/shrink_to_fit.rs (100%) rename crates/ui-egui/tests/{ => it}/sort_dialog_columns.rs (100%) rename crates/ui-egui/tests/{ => it}/symbol_glyphs.rs (100%) rename crates/ui-egui/tests/{ => it}/system_theme.rs (100%) rename crates/ui-egui/tests/{ => it}/table_headings.rs (100%) rename crates/ui-egui/tests/{ => it}/text_box_editing.rs (100%) rename crates/ui-egui/tests/{ => it}/text_overflow_scroll.rs (100%) rename crates/ui-egui/tests/{ => it}/touch_pan.rs (100%) rename crates/ui-egui/tests/{ => it}/trace_navigation.rs (100%) rename crates/ui-egui/tests/{ => it}/translated_form_fields.rs (100%) rename crates/ui-egui/tests/{ => it}/ui_clock.rs (100%) rename crates/ui-egui/tests/{ => it}/validation_alert.rs (100%) rename crates/ui-egui/tests/{ => it}/validation_input_message.rs (100%) rename crates/ui-egui/tests/{ => it}/wheel_zoom.rs (100%) rename crates/ui-egui/tests/{ => it}/whole_line_shortcuts.rs (100%) rename crates/ui-egui/tests/{ => it}/widgets.rs (100%) rename crates/ui-egui/tests/{ => it}/zoom_dialog.rs (100%) diff --git a/AGENTS.md b/AGENTS.md index ab73047ef..7fd1629cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/CLAUDE.md b/CLAUDE.md index 280fe8bcb..1d1a77d1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr - **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references). - **Bound recursion** (formula nesting, dependency chains, names referring to names). - **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence. -- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). +- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`). - Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. ## Non-negotiables diff --git a/Cargo.toml b/Cargo.toml index 9994d44e3..4d3fd4b83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,9 +68,11 @@ type_complexity = "allow" [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] opt-level = 3 diff --git a/apps/gridcraft-cli/tests/cli.rs b/apps/gridcraft-cli/tests/it/cli.rs similarity index 99% rename from apps/gridcraft-cli/tests/cli.rs rename to apps/gridcraft-cli/tests/it/cli.rs index 5295368e3..dde018ca8 100644 --- a/apps/gridcraft-cli/tests/cli.rs +++ b/apps/gridcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::io::Write; use std::path::PathBuf; use std::process::{Command, Output, Stdio}; diff --git a/apps/gridcraft-cli/tests/it/main.rs b/apps/gridcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..17f8db87f --- /dev/null +++ b/apps/gridcraft-cli/tests/it/main.rs @@ -0,0 +1,5 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod cli; +mod text_to_columns_quotes; diff --git a/apps/gridcraft-cli/tests/text_to_columns_quotes.rs b/apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs similarity index 100% rename from apps/gridcraft-cli/tests/text_to_columns_quotes.rs rename to apps/gridcraft-cli/tests/it/text_to_columns_quotes.rs diff --git a/crates/engine/tests/autofit_filter_buttons.rs b/crates/engine/tests/it/autofit_filter_buttons.rs similarity index 100% rename from crates/engine/tests/autofit_filter_buttons.rs rename to crates/engine/tests/it/autofit_filter_buttons.rs diff --git a/crates/engine/tests/cf_formula_validate.rs b/crates/engine/tests/it/cf_formula_validate.rs similarity index 100% rename from crates/engine/tests/cf_formula_validate.rs rename to crates/engine/tests/it/cf_formula_validate.rs diff --git a/crates/engine/tests/chart_workbook_names.rs b/crates/engine/tests/it/chart_workbook_names.rs similarity index 100% rename from crates/engine/tests/chart_workbook_names.rs rename to crates/engine/tests/it/chart_workbook_names.rs diff --git a/crates/engine/tests/command_sequence_fuzz.rs b/crates/engine/tests/it/command_sequence_fuzz.rs similarity index 99% rename from crates/engine/tests/command_sequence_fuzz.rs rename to crates/engine/tests/it/command_sequence_fuzz.rs index f8cb603cb..012757f69 100644 --- a/crates/engine/tests/command_sequence_fuzz.rs +++ b/crates/engine/tests/it/command_sequence_fuzz.rs @@ -23,7 +23,7 @@ //! //! Knobs (environment variables): //! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with -//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`. +//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`. //! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line). //! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that //! still fails the same way, printed as `execute` calls ready for a regression test. @@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure) None => format!("end-of-run checks after {} steps", sequence.len()), }; let mut out = format!( - "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n", + "command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n", f.kind, f.what ); let end = (f.step + 1).min(sequence.len()); diff --git a/crates/engine/tests/cross_sheet_cut.rs b/crates/engine/tests/it/cross_sheet_cut.rs similarity index 100% rename from crates/engine/tests/cross_sheet_cut.rs rename to crates/engine/tests/it/cross_sheet_cut.rs diff --git a/crates/engine/tests/csv_export_budget.rs b/crates/engine/tests/it/csv_export_budget.rs similarity index 100% rename from crates/engine/tests/csv_export_budget.rs rename to crates/engine/tests/it/csv_export_budget.rs diff --git a/crates/engine/tests/custom_filter_blanks.rs b/crates/engine/tests/it/custom_filter_blanks.rs similarity index 100% rename from crates/engine/tests/custom_filter_blanks.rs rename to crates/engine/tests/it/custom_filter_blanks.rs diff --git a/crates/engine/tests/custom_view_freeze.rs b/crates/engine/tests/it/custom_view_freeze.rs similarity index 100% rename from crates/engine/tests/custom_view_freeze.rs rename to crates/engine/tests/it/custom_view_freeze.rs diff --git a/crates/engine/tests/dynamicmetadata.rs b/crates/engine/tests/it/dynamicmetadata.rs similarity index 100% rename from crates/engine/tests/dynamicmetadata.rs rename to crates/engine/tests/it/dynamicmetadata.rs diff --git a/crates/engine/tests/extreme_selection_moves.rs b/crates/engine/tests/it/extreme_selection_moves.rs similarity index 100% rename from crates/engine/tests/extreme_selection_moves.rs rename to crates/engine/tests/it/extreme_selection_moves.rs diff --git a/crates/engine/tests/format_as_table_restyle.rs b/crates/engine/tests/it/format_as_table_restyle.rs similarity index 100% rename from crates/engine/tests/format_as_table_restyle.rs rename to crates/engine/tests/it/format_as_table_restyle.rs diff --git a/crates/engine/tests/hidden_sheet_navigation.rs b/crates/engine/tests/it/hidden_sheet_navigation.rs similarity index 100% rename from crates/engine/tests/hidden_sheet_navigation.rs rename to crates/engine/tests/it/hidden_sheet_navigation.rs diff --git a/crates/engine/tests/ink_roundtrip.rs b/crates/engine/tests/it/ink_roundtrip.rs similarity index 100% rename from crates/engine/tests/ink_roundtrip.rs rename to crates/engine/tests/it/ink_roundtrip.rs diff --git a/crates/engine/tests/line_count_overflow.rs b/crates/engine/tests/it/line_count_overflow.rs similarity index 100% rename from crates/engine/tests/line_count_overflow.rs rename to crates/engine/tests/it/line_count_overflow.rs diff --git a/crates/engine/tests/long-operator-chains.rs b/crates/engine/tests/it/long_operator_chains.rs similarity index 100% rename from crates/engine/tests/long-operator-chains.rs rename to crates/engine/tests/it/long_operator_chains.rs diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..385ec76ca --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,29 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod autofit_filter_buttons; +mod cf_formula_validate; +mod chart_workbook_names; +mod command_sequence_fuzz; +mod cross_sheet_cut; +mod csv_export_budget; +mod custom_filter_blanks; +mod custom_view_freeze; +mod dynamicmetadata; +mod extreme_selection_moves; +mod format_as_table_restyle; +mod hidden_sheet_navigation; +mod ink_roundtrip; +mod line_count_overflow; +mod long_operator_chains; +mod offset_overflow; +mod percent_entry; +mod pivot_unknown_func; +mod pivotchart_link; +mod range_fill_formats; +mod row_autofit; +mod sheet_name_quoting; +mod sparse_clear_invalidation; +mod stored_list_shape; +mod table_resize_overlap; +mod table_total_row_last_row; +mod wrap_row_height; diff --git a/crates/engine/tests/offset_overflow.rs b/crates/engine/tests/it/offset_overflow.rs similarity index 100% rename from crates/engine/tests/offset_overflow.rs rename to crates/engine/tests/it/offset_overflow.rs diff --git a/crates/engine/tests/percent_entry.rs b/crates/engine/tests/it/percent_entry.rs similarity index 100% rename from crates/engine/tests/percent_entry.rs rename to crates/engine/tests/it/percent_entry.rs diff --git a/crates/engine/tests/pivot_unknown_func.rs b/crates/engine/tests/it/pivot_unknown_func.rs similarity index 100% rename from crates/engine/tests/pivot_unknown_func.rs rename to crates/engine/tests/it/pivot_unknown_func.rs diff --git a/crates/engine/tests/pivotchart_link.rs b/crates/engine/tests/it/pivotchart_link.rs similarity index 100% rename from crates/engine/tests/pivotchart_link.rs rename to crates/engine/tests/it/pivotchart_link.rs diff --git a/crates/engine/tests/range_fill_formats.rs b/crates/engine/tests/it/range_fill_formats.rs similarity index 100% rename from crates/engine/tests/range_fill_formats.rs rename to crates/engine/tests/it/range_fill_formats.rs diff --git a/crates/engine/tests/row_autofit.rs b/crates/engine/tests/it/row_autofit.rs similarity index 100% rename from crates/engine/tests/row_autofit.rs rename to crates/engine/tests/it/row_autofit.rs diff --git a/crates/engine/tests/sheet_name_quoting.rs b/crates/engine/tests/it/sheet_name_quoting.rs similarity index 100% rename from crates/engine/tests/sheet_name_quoting.rs rename to crates/engine/tests/it/sheet_name_quoting.rs diff --git a/crates/engine/tests/sparse_clear_invalidation.rs b/crates/engine/tests/it/sparse_clear_invalidation.rs similarity index 100% rename from crates/engine/tests/sparse_clear_invalidation.rs rename to crates/engine/tests/it/sparse_clear_invalidation.rs diff --git a/crates/engine/tests/stored_list_shape.rs b/crates/engine/tests/it/stored_list_shape.rs similarity index 100% rename from crates/engine/tests/stored_list_shape.rs rename to crates/engine/tests/it/stored_list_shape.rs diff --git a/crates/engine/tests/table_resize_overlap.rs b/crates/engine/tests/it/table_resize_overlap.rs similarity index 100% rename from crates/engine/tests/table_resize_overlap.rs rename to crates/engine/tests/it/table_resize_overlap.rs diff --git a/crates/engine/tests/table_total_row_last_row.rs b/crates/engine/tests/it/table_total_row_last_row.rs similarity index 100% rename from crates/engine/tests/table_total_row_last_row.rs rename to crates/engine/tests/it/table_total_row_last_row.rs diff --git a/crates/engine/tests/wrap_row_height.rs b/crates/engine/tests/it/wrap_row_height.rs similarity index 100% rename from crates/engine/tests/wrap_row_height.rs rename to crates/engine/tests/it/wrap_row_height.rs diff --git a/crates/formula/tests/it/main.rs b/crates/formula/tests/it/main.rs new file mode 100644 index 000000000..01bff98c1 --- /dev/null +++ b/crates/formula/tests/it/main.rs @@ -0,0 +1,4 @@ +//! The crate's integration tests, one module per file, built as one test binary. + +mod printer_roundtrip; +mod spanish_input; diff --git a/crates/formula/tests/printer_roundtrip.rs b/crates/formula/tests/it/printer_roundtrip.rs similarity index 100% rename from crates/formula/tests/printer_roundtrip.rs rename to crates/formula/tests/it/printer_roundtrip.rs diff --git a/crates/formula/tests/spanish_input.rs b/crates/formula/tests/it/spanish_input.rs similarity index 100% rename from crates/formula/tests/spanish_input.rs rename to crates/formula/tests/it/spanish_input.rs diff --git a/crates/ui-egui/tests/accounting_fill.rs b/crates/ui-egui/tests/it/accounting_fill.rs similarity index 100% rename from crates/ui-egui/tests/accounting_fill.rs rename to crates/ui-egui/tests/it/accounting_fill.rs diff --git a/crates/ui-egui/tests/advanced_filter.rs b/crates/ui-egui/tests/it/advanced_filter.rs similarity index 100% rename from crates/ui-egui/tests/advanced_filter.rs rename to crates/ui-egui/tests/it/advanced_filter.rs diff --git a/crates/ui-egui/tests/argument_names.rs b/crates/ui-egui/tests/it/argument_names.rs similarity index 100% rename from crates/ui-egui/tests/argument_names.rs rename to crates/ui-egui/tests/it/argument_names.rs diff --git a/crates/ui-egui/tests/autosave_persist.rs b/crates/ui-egui/tests/it/autosave_persist.rs similarity index 100% rename from crates/ui-egui/tests/autosave_persist.rs rename to crates/ui-egui/tests/it/autosave_persist.rs diff --git a/crates/ui-egui/tests/borders.rs b/crates/ui-egui/tests/it/borders.rs similarity index 100% rename from crates/ui-egui/tests/borders.rs rename to crates/ui-egui/tests/it/borders.rs diff --git a/crates/ui-egui/tests/cell_editor_caret.rs b/crates/ui-egui/tests/it/cell_editor_caret.rs similarity index 100% rename from crates/ui-egui/tests/cell_editor_caret.rs rename to crates/ui-egui/tests/it/cell_editor_caret.rs diff --git a/crates/ui-egui/tests/cell_pictures.rs b/crates/ui-egui/tests/it/cell_pictures.rs similarity index 100% rename from crates/ui-egui/tests/cell_pictures.rs rename to crates/ui-egui/tests/it/cell_pictures.rs diff --git a/crates/ui-egui/tests/chart_resize.rs b/crates/ui-egui/tests/it/chart_resize.rs similarity index 100% rename from crates/ui-egui/tests/chart_resize.rs rename to crates/ui-egui/tests/it/chart_resize.rs diff --git a/crates/ui-egui/tests/clipboard.rs b/crates/ui-egui/tests/it/clipboard.rs similarity index 100% rename from crates/ui-egui/tests/clipboard.rs rename to crates/ui-egui/tests/it/clipboard.rs diff --git a/crates/ui-egui/tests/comments_pane.rs b/crates/ui-egui/tests/it/comments_pane.rs similarity index 100% rename from crates/ui-egui/tests/comments_pane.rs rename to crates/ui-egui/tests/it/comments_pane.rs diff --git a/crates/ui-egui/tests/commit_after_pointing.rs b/crates/ui-egui/tests/it/commit_after_pointing.rs similarity index 100% rename from crates/ui-egui/tests/commit_after_pointing.rs rename to crates/ui-egui/tests/it/commit_after_pointing.rs diff --git a/crates/ui-egui/tests/committed_text_fields.rs b/crates/ui-egui/tests/it/committed_text_fields.rs similarity index 100% rename from crates/ui-egui/tests/committed_text_fields.rs rename to crates/ui-egui/tests/it/committed_text_fields.rs diff --git a/crates/ui-egui/tests/context_menu_stays_open.rs b/crates/ui-egui/tests/it/context_menu_stays_open.rs similarity index 100% rename from crates/ui-egui/tests/context_menu_stays_open.rs rename to crates/ui-egui/tests/it/context_menu_stays_open.rs diff --git a/crates/ui-egui/tests/control_timeout.rs b/crates/ui-egui/tests/it/control_timeout.rs similarity index 100% rename from crates/ui-egui/tests/control_timeout.rs rename to crates/ui-egui/tests/it/control_timeout.rs diff --git a/crates/ui-egui/tests/dialog_button_row.rs b/crates/ui-egui/tests/it/dialog_button_row.rs similarity index 100% rename from crates/ui-egui/tests/dialog_button_row.rs rename to crates/ui-egui/tests/it/dialog_button_row.rs diff --git a/crates/ui-egui/tests/dialog_confirm.rs b/crates/ui-egui/tests/it/dialog_confirm.rs similarity index 100% rename from crates/ui-egui/tests/dialog_confirm.rs rename to crates/ui-egui/tests/it/dialog_confirm.rs diff --git a/crates/ui-egui/tests/dialog_focus.rs b/crates/ui-egui/tests/it/dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/dialog_focus.rs rename to crates/ui-egui/tests/it/dialog_focus.rs diff --git a/crates/ui-egui/tests/dialog_move.rs b/crates/ui-egui/tests/it/dialog_move.rs similarity index 100% rename from crates/ui-egui/tests/dialog_move.rs rename to crates/ui-egui/tests/it/dialog_move.rs diff --git a/crates/ui-egui/tests/drag_cursor.rs b/crates/ui-egui/tests/it/drag_cursor.rs similarity index 100% rename from crates/ui-egui/tests/drag_cursor.rs rename to crates/ui-egui/tests/it/drag_cursor.rs diff --git a/crates/ui-egui/tests/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs similarity index 100% rename from crates/ui-egui/tests/file_menu.rs rename to crates/ui-egui/tests/it/file_menu.rs diff --git a/crates/ui-egui/tests/fill_handle_double_click.rs b/crates/ui-egui/tests/it/fill_handle_double_click.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_double_click.rs rename to crates/ui-egui/tests/it/fill_handle_double_click.rs diff --git a/crates/ui-egui/tests/fill_handle_right_drag.rs b/crates/ui-egui/tests/it/fill_handle_right_drag.rs similarity index 100% rename from crates/ui-egui/tests/fill_handle_right_drag.rs rename to crates/ui-egui/tests/it/fill_handle_right_drag.rs diff --git a/crates/ui-egui/tests/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs similarity index 100% rename from crates/ui-egui/tests/fill_options_button.rs rename to crates/ui-egui/tests/it/fill_options_button.rs diff --git a/crates/ui-egui/tests/filter_list_scroll.rs b/crates/ui-egui/tests/it/filter_list_scroll.rs similarity index 100% rename from crates/ui-egui/tests/filter_list_scroll.rs rename to crates/ui-egui/tests/it/filter_list_scroll.rs diff --git a/crates/ui-egui/tests/filter_menu_enter.rs b/crates/ui-egui/tests/it/filter_menu_enter.rs similarity index 100% rename from crates/ui-egui/tests/filter_menu_enter.rs rename to crates/ui-egui/tests/it/filter_menu_enter.rs diff --git a/crates/ui-egui/tests/find_dialog_focus.rs b/crates/ui-egui/tests/it/find_dialog_focus.rs similarity index 100% rename from crates/ui-egui/tests/find_dialog_focus.rs rename to crates/ui-egui/tests/it/find_dialog_focus.rs diff --git a/crates/ui-egui/tests/floating_pictures.rs b/crates/ui-egui/tests/it/floating_pictures.rs similarity index 100% rename from crates/ui-egui/tests/floating_pictures.rs rename to crates/ui-egui/tests/it/floating_pictures.rs diff --git a/crates/ui-egui/tests/font_size_box.rs b/crates/ui-egui/tests/it/font_size_box.rs similarity index 100% rename from crates/ui-egui/tests/font_size_box.rs rename to crates/ui-egui/tests/it/font_size_box.rs diff --git a/crates/ui-egui/tests/format_painter.rs b/crates/ui-egui/tests/it/format_painter.rs similarity index 100% rename from crates/ui-egui/tests/format_painter.rs rename to crates/ui-egui/tests/it/format_painter.rs diff --git a/crates/ui-egui/tests/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs similarity index 100% rename from crates/ui-egui/tests/formula_locale.rs rename to crates/ui-egui/tests/it/formula_locale.rs diff --git a/crates/ui-egui/tests/formula_point_headers.rs b/crates/ui-egui/tests/it/formula_point_headers.rs similarity index 100% rename from crates/ui-egui/tests/formula_point_headers.rs rename to crates/ui-egui/tests/it/formula_point_headers.rs diff --git a/crates/ui-egui/tests/formula_popup.rs b/crates/ui-egui/tests/it/formula_popup.rs similarity index 100% rename from crates/ui-egui/tests/formula_popup.rs rename to crates/ui-egui/tests/it/formula_popup.rs diff --git a/crates/ui-egui/tests/freeze_handles.rs b/crates/ui-egui/tests/it/freeze_handles.rs similarity index 100% rename from crates/ui-egui/tests/freeze_handles.rs rename to crates/ui-egui/tests/it/freeze_handles.rs diff --git a/crates/ui-egui/tests/header_autofit.rs b/crates/ui-egui/tests/it/header_autofit.rs similarity index 100% rename from crates/ui-egui/tests/header_autofit.rs rename to crates/ui-egui/tests/it/header_autofit.rs diff --git a/crates/ui-egui/tests/header_column_drag.rs b/crates/ui-egui/tests/it/header_column_drag.rs similarity index 100% rename from crates/ui-egui/tests/header_column_drag.rs rename to crates/ui-egui/tests/it/header_column_drag.rs diff --git a/crates/ui-egui/tests/host_chrome.rs b/crates/ui-egui/tests/it/host_chrome.rs similarity index 100% rename from crates/ui-egui/tests/host_chrome.rs rename to crates/ui-egui/tests/it/host_chrome.rs diff --git a/crates/ui-egui/tests/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs similarity index 100% rename from crates/ui-egui/tests/hyperlink_ctrl_click.rs rename to crates/ui-egui/tests/it/hyperlink_ctrl_click.rs diff --git a/crates/ui-egui/tests/in_cell_editor_size.rs b/crates/ui-egui/tests/it/in_cell_editor_size.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_size.rs rename to crates/ui-egui/tests/it/in_cell_editor_size.rs diff --git a/crates/ui-egui/tests/in_cell_editor_wrap.rs b/crates/ui-egui/tests/it/in_cell_editor_wrap.rs similarity index 100% rename from crates/ui-egui/tests/in_cell_editor_wrap.rs rename to crates/ui-egui/tests/it/in_cell_editor_wrap.rs diff --git a/crates/ui-egui/tests/insert_copied_cells.rs b/crates/ui-egui/tests/it/insert_copied_cells.rs similarity index 100% rename from crates/ui-egui/tests/insert_copied_cells.rs rename to crates/ui-egui/tests/it/insert_copied_cells.rs diff --git a/crates/ui-egui/tests/interaction.rs b/crates/ui-egui/tests/it/interaction.rs similarity index 100% rename from crates/ui-egui/tests/interaction.rs rename to crates/ui-egui/tests/it/interaction.rs diff --git a/crates/ui-egui/tests/interface_scale.rs b/crates/ui-egui/tests/it/interface_scale.rs similarity index 100% rename from crates/ui-egui/tests/interface_scale.rs rename to crates/ui-egui/tests/it/interface_scale.rs diff --git a/crates/ui-egui/tests/keyboard_shortcuts.rs b/crates/ui-egui/tests/it/keyboard_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/keyboard_shortcuts.rs rename to crates/ui-egui/tests/it/keyboard_shortcuts.rs diff --git a/crates/ui-egui/tests/keytips_generated.rs b/crates/ui-egui/tests/it/keytips_generated.rs similarity index 100% rename from crates/ui-egui/tests/keytips_generated.rs rename to crates/ui-egui/tests/it/keytips_generated.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs new file mode 100644 index 000000000..b89253bb9 --- /dev/null +++ b/crates/ui-egui/tests/it/main.rs @@ -0,0 +1,91 @@ +//! The crate's integration tests, one module per file, built as one test binary. +//! +//! `header_autofit_fonts.rs` and `keytips.rs` stay separate binaries: they set process-wide state +//! (the installed-font catalog, a registered test command) that would leak into these tests. + +mod accounting_fill; +mod advanced_filter; +mod argument_names; +mod autosave_persist; +mod borders; +mod cell_editor_caret; +mod cell_pictures; +mod chart_resize; +mod clipboard; +mod comments_pane; +mod commit_after_pointing; +mod committed_text_fields; +mod context_menu_stays_open; +mod control_timeout; +mod dialog_button_row; +mod dialog_confirm; +mod dialog_focus; +mod dialog_move; +mod drag_cursor; +mod file_menu; +mod fill_handle_double_click; +mod fill_handle_right_drag; +mod fill_options_button; +mod filter_list_scroll; +mod filter_menu_enter; +mod find_dialog_focus; +mod floating_pictures; +mod font_size_box; +mod format_painter; +mod formula_locale; +mod formula_point_headers; +mod formula_popup; +mod freeze_handles; +mod header_autofit; +mod header_column_drag; +mod host_chrome; +mod hyperlink_ctrl_click; +mod in_cell_editor_size; +mod in_cell_editor_wrap; +mod insert_copied_cells; +mod interaction; +mod interface_scale; +mod keyboard_shortcuts; +mod keytips_generated; +mod menu_hover; +mod merged_cells; +mod more_colors; +mod native_print; +mod nowrap_line_breaks; +mod number_stored_as_text; +mod open_encrypted; +mod outline; +mod page_layout_view; +mod page_setup_margins; +mod paste_special_dialog; +mod pivot_pane; +mod popup_dismissal; +mod protect_password; +mod right_to_left; +mod scroll_position; +mod scrollbar_column_fill; +mod selection; +mod selection_fill; +mod selection_glide; +mod selection_scroll; +mod sheet_navigator; +mod sheet_tab_rename; +mod sheet_tabs; +mod show_objects; +mod shrink_to_fit; +mod sort_dialog_columns; +mod symbol_glyphs; +mod system_theme; +mod table_headings; +mod text_box_editing; +mod text_overflow_scroll; +mod touch_pan; +mod trace_navigation; +mod translated_form_fields; +mod ui_clock; +mod validation_alert; +mod validation_input_message; +mod wheel_zoom; +mod whole_line_shortcuts; +mod widgets; +mod zoom_dialog; diff --git a/crates/ui-egui/tests/menu_hover.rs b/crates/ui-egui/tests/it/menu_hover.rs similarity index 100% rename from crates/ui-egui/tests/menu_hover.rs rename to crates/ui-egui/tests/it/menu_hover.rs diff --git a/crates/ui-egui/tests/merged_cells.rs b/crates/ui-egui/tests/it/merged_cells.rs similarity index 100% rename from crates/ui-egui/tests/merged_cells.rs rename to crates/ui-egui/tests/it/merged_cells.rs diff --git a/crates/ui-egui/tests/more_colors.rs b/crates/ui-egui/tests/it/more_colors.rs similarity index 100% rename from crates/ui-egui/tests/more_colors.rs rename to crates/ui-egui/tests/it/more_colors.rs diff --git a/crates/ui-egui/tests/native_print.rs b/crates/ui-egui/tests/it/native_print.rs similarity index 100% rename from crates/ui-egui/tests/native_print.rs rename to crates/ui-egui/tests/it/native_print.rs diff --git a/crates/ui-egui/tests/nowrap_line_breaks.rs b/crates/ui-egui/tests/it/nowrap_line_breaks.rs similarity index 100% rename from crates/ui-egui/tests/nowrap_line_breaks.rs rename to crates/ui-egui/tests/it/nowrap_line_breaks.rs diff --git a/crates/ui-egui/tests/number_stored_as_text.rs b/crates/ui-egui/tests/it/number_stored_as_text.rs similarity index 100% rename from crates/ui-egui/tests/number_stored_as_text.rs rename to crates/ui-egui/tests/it/number_stored_as_text.rs diff --git a/crates/ui-egui/tests/open_encrypted.rs b/crates/ui-egui/tests/it/open_encrypted.rs similarity index 100% rename from crates/ui-egui/tests/open_encrypted.rs rename to crates/ui-egui/tests/it/open_encrypted.rs diff --git a/crates/ui-egui/tests/outline.rs b/crates/ui-egui/tests/it/outline.rs similarity index 100% rename from crates/ui-egui/tests/outline.rs rename to crates/ui-egui/tests/it/outline.rs diff --git a/crates/ui-egui/tests/page_layout_view.rs b/crates/ui-egui/tests/it/page_layout_view.rs similarity index 100% rename from crates/ui-egui/tests/page_layout_view.rs rename to crates/ui-egui/tests/it/page_layout_view.rs diff --git a/crates/ui-egui/tests/page_setup_margins.rs b/crates/ui-egui/tests/it/page_setup_margins.rs similarity index 100% rename from crates/ui-egui/tests/page_setup_margins.rs rename to crates/ui-egui/tests/it/page_setup_margins.rs diff --git a/crates/ui-egui/tests/paste_special_dialog.rs b/crates/ui-egui/tests/it/paste_special_dialog.rs similarity index 100% rename from crates/ui-egui/tests/paste_special_dialog.rs rename to crates/ui-egui/tests/it/paste_special_dialog.rs diff --git a/crates/ui-egui/tests/pivot_pane.rs b/crates/ui-egui/tests/it/pivot_pane.rs similarity index 100% rename from crates/ui-egui/tests/pivot_pane.rs rename to crates/ui-egui/tests/it/pivot_pane.rs diff --git a/crates/ui-egui/tests/popup_dismissal.rs b/crates/ui-egui/tests/it/popup_dismissal.rs similarity index 100% rename from crates/ui-egui/tests/popup_dismissal.rs rename to crates/ui-egui/tests/it/popup_dismissal.rs diff --git a/crates/ui-egui/tests/protect_password.rs b/crates/ui-egui/tests/it/protect_password.rs similarity index 100% rename from crates/ui-egui/tests/protect_password.rs rename to crates/ui-egui/tests/it/protect_password.rs diff --git a/crates/ui-egui/tests/right_to_left.rs b/crates/ui-egui/tests/it/right_to_left.rs similarity index 100% rename from crates/ui-egui/tests/right_to_left.rs rename to crates/ui-egui/tests/it/right_to_left.rs diff --git a/crates/ui-egui/tests/scroll_position.rs b/crates/ui-egui/tests/it/scroll_position.rs similarity index 100% rename from crates/ui-egui/tests/scroll_position.rs rename to crates/ui-egui/tests/it/scroll_position.rs diff --git a/crates/ui-egui/tests/scrollbar_column_fill.rs b/crates/ui-egui/tests/it/scrollbar_column_fill.rs similarity index 100% rename from crates/ui-egui/tests/scrollbar_column_fill.rs rename to crates/ui-egui/tests/it/scrollbar_column_fill.rs diff --git a/crates/ui-egui/tests/selection.rs b/crates/ui-egui/tests/it/selection.rs similarity index 100% rename from crates/ui-egui/tests/selection.rs rename to crates/ui-egui/tests/it/selection.rs diff --git a/crates/ui-egui/tests/selection_fill.rs b/crates/ui-egui/tests/it/selection_fill.rs similarity index 100% rename from crates/ui-egui/tests/selection_fill.rs rename to crates/ui-egui/tests/it/selection_fill.rs diff --git a/crates/ui-egui/tests/selection_glide.rs b/crates/ui-egui/tests/it/selection_glide.rs similarity index 100% rename from crates/ui-egui/tests/selection_glide.rs rename to crates/ui-egui/tests/it/selection_glide.rs diff --git a/crates/ui-egui/tests/selection_scroll.rs b/crates/ui-egui/tests/it/selection_scroll.rs similarity index 100% rename from crates/ui-egui/tests/selection_scroll.rs rename to crates/ui-egui/tests/it/selection_scroll.rs diff --git a/crates/ui-egui/tests/sheet_navigator.rs b/crates/ui-egui/tests/it/sheet_navigator.rs similarity index 100% rename from crates/ui-egui/tests/sheet_navigator.rs rename to crates/ui-egui/tests/it/sheet_navigator.rs diff --git a/crates/ui-egui/tests/sheet_tab_rename.rs b/crates/ui-egui/tests/it/sheet_tab_rename.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tab_rename.rs rename to crates/ui-egui/tests/it/sheet_tab_rename.rs diff --git a/crates/ui-egui/tests/sheet_tabs.rs b/crates/ui-egui/tests/it/sheet_tabs.rs similarity index 100% rename from crates/ui-egui/tests/sheet_tabs.rs rename to crates/ui-egui/tests/it/sheet_tabs.rs diff --git a/crates/ui-egui/tests/show_objects.rs b/crates/ui-egui/tests/it/show_objects.rs similarity index 100% rename from crates/ui-egui/tests/show_objects.rs rename to crates/ui-egui/tests/it/show_objects.rs diff --git a/crates/ui-egui/tests/shrink_to_fit.rs b/crates/ui-egui/tests/it/shrink_to_fit.rs similarity index 100% rename from crates/ui-egui/tests/shrink_to_fit.rs rename to crates/ui-egui/tests/it/shrink_to_fit.rs diff --git a/crates/ui-egui/tests/sort_dialog_columns.rs b/crates/ui-egui/tests/it/sort_dialog_columns.rs similarity index 100% rename from crates/ui-egui/tests/sort_dialog_columns.rs rename to crates/ui-egui/tests/it/sort_dialog_columns.rs diff --git a/crates/ui-egui/tests/symbol_glyphs.rs b/crates/ui-egui/tests/it/symbol_glyphs.rs similarity index 100% rename from crates/ui-egui/tests/symbol_glyphs.rs rename to crates/ui-egui/tests/it/symbol_glyphs.rs diff --git a/crates/ui-egui/tests/system_theme.rs b/crates/ui-egui/tests/it/system_theme.rs similarity index 100% rename from crates/ui-egui/tests/system_theme.rs rename to crates/ui-egui/tests/it/system_theme.rs diff --git a/crates/ui-egui/tests/table_headings.rs b/crates/ui-egui/tests/it/table_headings.rs similarity index 100% rename from crates/ui-egui/tests/table_headings.rs rename to crates/ui-egui/tests/it/table_headings.rs diff --git a/crates/ui-egui/tests/text_box_editing.rs b/crates/ui-egui/tests/it/text_box_editing.rs similarity index 100% rename from crates/ui-egui/tests/text_box_editing.rs rename to crates/ui-egui/tests/it/text_box_editing.rs diff --git a/crates/ui-egui/tests/text_overflow_scroll.rs b/crates/ui-egui/tests/it/text_overflow_scroll.rs similarity index 100% rename from crates/ui-egui/tests/text_overflow_scroll.rs rename to crates/ui-egui/tests/it/text_overflow_scroll.rs diff --git a/crates/ui-egui/tests/touch_pan.rs b/crates/ui-egui/tests/it/touch_pan.rs similarity index 100% rename from crates/ui-egui/tests/touch_pan.rs rename to crates/ui-egui/tests/it/touch_pan.rs diff --git a/crates/ui-egui/tests/trace_navigation.rs b/crates/ui-egui/tests/it/trace_navigation.rs similarity index 100% rename from crates/ui-egui/tests/trace_navigation.rs rename to crates/ui-egui/tests/it/trace_navigation.rs diff --git a/crates/ui-egui/tests/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs similarity index 100% rename from crates/ui-egui/tests/translated_form_fields.rs rename to crates/ui-egui/tests/it/translated_form_fields.rs diff --git a/crates/ui-egui/tests/ui_clock.rs b/crates/ui-egui/tests/it/ui_clock.rs similarity index 100% rename from crates/ui-egui/tests/ui_clock.rs rename to crates/ui-egui/tests/it/ui_clock.rs diff --git a/crates/ui-egui/tests/validation_alert.rs b/crates/ui-egui/tests/it/validation_alert.rs similarity index 100% rename from crates/ui-egui/tests/validation_alert.rs rename to crates/ui-egui/tests/it/validation_alert.rs diff --git a/crates/ui-egui/tests/validation_input_message.rs b/crates/ui-egui/tests/it/validation_input_message.rs similarity index 100% rename from crates/ui-egui/tests/validation_input_message.rs rename to crates/ui-egui/tests/it/validation_input_message.rs diff --git a/crates/ui-egui/tests/wheel_zoom.rs b/crates/ui-egui/tests/it/wheel_zoom.rs similarity index 100% rename from crates/ui-egui/tests/wheel_zoom.rs rename to crates/ui-egui/tests/it/wheel_zoom.rs diff --git a/crates/ui-egui/tests/whole_line_shortcuts.rs b/crates/ui-egui/tests/it/whole_line_shortcuts.rs similarity index 100% rename from crates/ui-egui/tests/whole_line_shortcuts.rs rename to crates/ui-egui/tests/it/whole_line_shortcuts.rs diff --git a/crates/ui-egui/tests/widgets.rs b/crates/ui-egui/tests/it/widgets.rs similarity index 100% rename from crates/ui-egui/tests/widgets.rs rename to crates/ui-egui/tests/it/widgets.rs diff --git a/crates/ui-egui/tests/zoom_dialog.rs b/crates/ui-egui/tests/it/zoom_dialog.rs similarity index 100% rename from crates/ui-egui/tests/zoom_dialog.rs rename to crates/ui-egui/tests/it/zoom_dialog.rs diff --git a/docs/roadmap.md b/docs/roadmap.md index 4894d77b1..62e1735f7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap detail -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (PivotTable calculated fields) · **Target:** Microsoft Excel (Microsoft 365) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (test command) · **Target:** Microsoft Excel (Microsoft 365) Forward-looking plan. The one-page summary is [`ROADMAP.md`](../ROADMAP.md); the ranked work list is [`gaps.md`](gaps.md); numbers and method are in @@ -100,7 +100,8 @@ to check our files, and Windows machines with the reported GPU drivers. - `cargo xtask parity` regenerates [`parity-checklist.md`](parity-checklist.md) (ribbon catalog vs registered commands); `cargo xtask ci` checks it is current. -- `cargo test --workspace`: engine, formula, function, XLSX and UI behaviour. +- `cargo xtask test`: engine, formula, function, XLSX and UI behaviour (cargo-nextest when + installed, else `cargo test`), then the doctests. - `cargo run --release -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`: look at the UI offscreen. - Function coverage: the script method in [function-parity.md](function-parity.md). A @@ -112,6 +113,7 @@ to check our files, and Windows machines with the reported GPU drivers. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Measuring: tests run through `cargo xtask test` | | 2026-10-11 | minor | PivotTable calculated fields landed | | 2026-10-10 | minor | Current focus and M15 updated for the 10-10 merges | | 2026-10-10 | minor | Added the alpha gate table (six core workflows; passes, stays alpha) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 08a9124bd..99a1e0717 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -341,7 +341,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land | 2026-10-12 | minor | Essentials AutoFit evidence: rows auto-fit oversized fonts on open and row formatting (#833) | | 2026-10-12 | minor | Sheet tabs: reveal the selected tab on navigation and resize while preserving manual scrolling (#681); no score change | | 2026-10-12 | minor | PivotTables: values on rows (Σ Values in Rows at any position, all three layouts; `pivot.moveField` and the field pane move it; `dataOnRows` round-trips) (no score change) | -| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | +| 2026-10-11 | minor | Stability: command-sequence fuzz harness (`crates/engine/tests/it/command_sequence_fuzz.rs`) checks random command sequences against undo/redo, XLSX round-trip, full-recalc and structure invariants; the sequence bugs it found are fixed (no score change) | | 2026-10-11 | minor | Password-encrypted workbooks open (#11): Essentials file-opening work 24–36 h | | 2026-10-11 | minor | Worksheet functions 505 / 523 (96.6%; 496 working): ODDFPRICE, ODDFYIELD, ODDLPRICE, ODDLYIELD | | 2026-10-11 | minor | Worksheet functions 503 / 523 (96.2%; 494 working): PERCENTOF, EUROCONVERT | diff --git a/xtask/src/keytips.rs b/xtask/src/keytips.rs index 63b1554a0..f6df981b7 100644 --- a/xtask/src/keytips.rs +++ b/xtask/src/keytips.rs @@ -1,6 +1,6 @@ //! Regenerate the keytip sequence table and its per-sequence test suite from `keytips.json`. //! -//! `cargo xtask keytips` regenerate the table + `tests/keytips_generated.rs` +//! `cargo xtask keytips` regenerate the table + `tests/it/keytips_generated.rs` //! `cargo xtask keytips --check` fail if either generated file differs from the JSON source //! //! Requires the default `engine` feature: the generator probes the live command @@ -92,7 +92,7 @@ pub fn run(root: &Path, rest: &[&str]) -> Result<(), String> { { let json_path = root.join("crates/ui-egui/src/keytips/keytips.json"); let table_path = root.join("crates/ui-egui/src/keytips/keytip_table/generated.rs"); - let tests_path = root.join("crates/ui-egui/tests/keytips_generated.rs"); + let tests_path = root.join("crates/ui-egui/tests/it/keytips_generated.rs"); let source = fs::read_to_string(&json_path).map_err(|e| format!("read {}: {e}", json_path.display()))?; let (entries, probe) = parse_and_probe(&source)?; let generated = diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 14e9af32a..0c9f5547d 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,17 +15,19 @@ mod stats; mod version; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitCode}; +use std::process::{Command, ExitCode, Stdio}; const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, assets, layers, wasm, parity --check - (stops at the first failure; the parity check runs only if docs/parity-checklist.md exists) + ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, + keytips --check (stops at the first failure; the parity check runs only if + docs/parity-checklist.md exists) + test workspace tests (cargo nextest when installed, else cargo test), then doctests assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md layers enforce the crate dependency layering (AGENTS.md \"Layering\") - wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web) + wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] write docs/parity-checklist.md from gridcraft_engine::catalog (implemented vs missing ids) keytips [--check] @@ -44,6 +46,7 @@ fn main() -> ExitCode { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), + Some("test") => cmd_test().and_then(|()| cmd_doctest()), Some("ci") => cmd_ci(), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), Some("parity") => parity::run(&root(), &rest), @@ -139,17 +142,19 @@ fn wasm_set() -> Result, String> { .collect()) } +/// One `cargo check` for the whole set; `--keep-going` reports every failing crate, not just the +/// first. fn cmd_wasm() -> Result<(), String> { let set = wasm_set()?; if set.is_empty() { return Err("no wasm crates found in the workspace".into()); } let mut c = cargo(); - c.args(["check", "--target", "wasm32-unknown-unknown"]); + c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]); for p in &set { c.args(["-p", p]); } - let r = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {}", set.join(" -p "))); + let r = run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going -p {}", set.join(" -p "))); println!("\nwasm32-unknown-unknown check ({}): {}", set.join(", "), if r.is_ok() { "ok" } else { "FAIL" }); if r.is_err() { println!("(if the target is missing: rustup target add wasm32-unknown-unknown)"); @@ -157,6 +162,35 @@ fn cmd_wasm() -> Result<(), String> { r } +/// Targets for the test build. Examples are left out so they aren't linked; clippy +/// `--all-targets` still compiles them. +const TEST_TARGETS: [&str; 4] = ["--workspace", "--lib", "--bins", "--tests"]; + +fn nextest_available() -> bool { + cargo().args(["nextest", "--version"]).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success()) +} + +fn cmd_test() -> Result<(), String> { + let mut c = cargo(); + let tool = if nextest_available() { + c.args(["nextest", "run"]); + "nextest run" + } else { + eprintln!("note: cargo-nextest is not installed, running cargo test (cargo install cargo-nextest --locked)"); + c.arg("test"); + "test" + }; + c.args(TEST_TARGETS); + run(c, &format!("cargo {tool} {}", TEST_TARGETS.join(" "))) +} + +/// nextest does not run doctests. +fn cmd_doctest() -> Result<(), String> { + let mut c = cargo(); + c.args(["test", "--workspace", "--doc"]); + run(c, "cargo test --workspace --doc") +} + fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ @@ -172,18 +206,12 @@ fn cmd_ci() -> Result<(), String> { "clippy", Box::new(|| { let mut c = cargo(); - c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]); - run(c, "cargo clippy --workspace --all-targets -- -D warnings") - }), - ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") + c.args(["clippy", "--workspace", "--all-targets", "--keep-going", "--", "-D", "warnings"]); + run(c, "cargo clippy --workspace --all-targets --keep-going -- -D warnings") }), ), + ("test", Box::new(cmd_test)), + ("doctest", Box::new(cmd_doctest)), ("assets", Box::new(|| assets::run(&root()))), ("layers", Box::new(cmd_layers)), ("wasm", Box::new(cmd_wasm)), From 33a45130a1786bd9dce1aa2f8b431339a56ed8ee Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:02:16 -0400 Subject: [PATCH 049/149] Packaging: pin the signing client and appimagetool, and check their hashes (#2073) * Packaging: pin the Windows signing client and check its hash * Packaging: pin appimagetool and check its hash * Packaging: pin the AppImage runtime and stop clearly without a pinned tool * Packaging: shellcheck-clean empty hashes --- packaging/linux/package.sh | 66 +++++++++++++++++++++++++++++++------- packaging/windows/sign.ps1 | 13 ++++++-- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index f8c53d6b3..4de3aaf80 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -9,8 +9,9 @@ # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # -# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage -# (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, +# Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool and the AppImage +# runtime for the AppImage (pinned downloads into $CARGO_TARGET_DIR, or APPIMAGETOOL and +# APPIMAGE_RUNTIME, e.g. on riscv64, which has no pinned ones). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. set -euo pipefail @@ -25,7 +26,7 @@ while [[ $# -gt 0 ]]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + -h | --help) sed -n '2,18p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done @@ -102,6 +103,32 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then + APPIMAGETOOL_VERSION=1.9.1 + # appimagetool would otherwise embed type2-runtime's unchecked `continuous` build. + APPIMAGE_RUNTIME_VERSION=20251108 + case "$ARCH" in + x86_64) + APPIMAGETOOL_SHA256=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + APPIMAGE_RUNTIME_SHA256=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + ;; + aarch64) + APPIMAGETOOL_SHA256=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + APPIMAGE_RUNTIME_SHA256=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 + ;; + *) APPIMAGETOOL_SHA256='' APPIMAGE_RUNTIME_SHA256='' ;; + esac + # Downloads a pinned file into the cache once and checks its hash on every run. + fetch_pinned() { + local path="$1" url="$2" sha256="$3" + if [[ ! -x "$path" ]]; then + curl --proto '=https' -fsSL -o "$path.download" "$url" + printf '%s %s\n' "$sha256" "$path.download" | sha256sum -c - + chmod 755 "$path.download" + mv "$path.download" "$path" + fi + printf '%s %s\n' "$sha256" "$path" | sha256sum -c - + return + } APPDIR="$WORK/GridCraft.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" @@ -111,18 +138,33 @@ if has appimage; then ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" - if [[ -z "$TOOL" ]]; then - TOOL="$CARGO_TARGET_DIR/appimagetool-$ARCH.AppImage" - if [[ ! -x "$TOOL" ]]; then - curl --proto '=https' -fsSL -o "$TOOL" "https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-$ARCH.AppImage" - chmod +x "$TOOL" - fi + if [[ -z "$TOOL" ]] && [[ -z "$APPIMAGETOOL_SHA256" ]]; then + echo "no pinned appimagetool for $ARCH; set APPIMAGETOOL or put appimagetool on PATH" >&2 + exit 2 fi - # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR or APPIMAGETOOL may be - # relative, e.g. target/agent-). + RUNTIME="${APPIMAGE_RUNTIME:-}" + if [[ -z "$RUNTIME" ]] && [[ -z "$APPIMAGE_RUNTIME_SHA256" ]]; then + echo "no pinned AppImage runtime for $ARCH; set APPIMAGE_RUNTIME" >&2 + exit 2 + fi + if [[ -z "$TOOL" ]]; then + TOOL="$CARGO_TARGET_DIR/appimagetool-$APPIMAGETOOL_VERSION-$ARCH.AppImage" + fetch_pinned "$TOOL" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$ARCH.AppImage" \ + "$APPIMAGETOOL_SHA256" + fi + if [[ -z "$RUNTIME" ]]; then + RUNTIME="$CARGO_TARGET_DIR/appimage-runtime-$APPIMAGE_RUNTIME_VERSION-$ARCH" + fetch_pinned "$RUNTIME" \ + "https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-$ARCH" \ + "$APPIMAGE_RUNTIME_SHA256" + fi + # Absolute, because appimagetool runs in $DIST below (CARGO_TARGET_DIR, APPIMAGETOOL or + # APPIMAGE_RUNTIME may be relative, e.g. target/agent-). OUT="$(cd "$DIST" && pwd)/$BASENAME.AppImage" APPDIR="$(cd "$APPDIR" && pwd)" TOOL="$(cd "$(dirname "$TOOL")" && pwd)/$(basename "$TOOL")" + RUNTIME="$(cd "$(dirname "$RUNTIME")" && pwd)/$(basename "$RUNTIME")" # A .zsync left by an earlier run would hide a missing zsyncmake and describe another file. rm -f "$OUT.zsync" # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and @@ -134,7 +176,7 @@ if has appimage; then # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream --runtime-file "$RUNTIME" -u "$UPDATE_INFO" "$APPDIR" "$OUT") echo "wrote $OUT" if [[ -s "$OUT.zsync" ]]; then echo "wrote $OUT.zsync" diff --git a/packaging/windows/sign.ps1 b/packaging/windows/sign.ps1 index a886a8de6..222d5705b 100644 --- a/packaging/windows/sign.ps1 +++ b/packaging/windows/sign.ps1 @@ -56,8 +56,8 @@ if (-not $haveCert -and -not $haveAzure) { $script:SignTool = Find-SignTool $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'GridCraft', '/du', 'https://github.com/storytold/gridcraft') -$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$tmp = Join-Path ([IO.Path]::GetTempPath()) "gridcraft-sign-$([IO.Path]::GetRandomFileName())" +New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($haveCert) { @@ -73,8 +73,15 @@ try { Write-Output "Signing with Azure Trusted Signing ($env:AZURE_SIGNING_ACCOUNT / $env:AZURE_CERT_PROFILE): $($Files -join ', ')" # The dlib authenticates with DefaultAzureCredential, which reads AZURE_TENANT_ID, # AZURE_CLIENT_ID and AZURE_CLIENT_SECRET from the environment. + $trustedSigningVersion = '1.0.95' + $trustedSigningSha256 = '3bfcf1e0a3cb42af1692f0a8ed45c15de070c2de86f28a59b2795d904d8a920f' $pkg = Join-Path $tmp 'trusted-signing.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.Trusted.Signing.Client' -OutFile $pkg + $uri = "https://api.nuget.org/v3-flatcontainer/microsoft.trusted.signing.client/$trustedSigningVersion/microsoft.trusted.signing.client.$trustedSigningVersion.nupkg" + Invoke-WebRequest -Uri $uri -OutFile $pkg + $actualHash = (Get-FileHash -Path $pkg -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $trustedSigningSha256) { + throw "Microsoft.Trusted.Signing.Client $trustedSigningVersion checksum mismatch" + } Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 From 39450b80d1eebfd78b4a0729fb4d57c2b1da832f Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:48:45 +0200 Subject: [PATCH 050/149] Hyperlinks: Ctrl+Alt+click on a link extends the selection again (#2368) --- crates/ui-egui/src/grid.rs | 3 ++- crates/ui-egui/tests/it/hyperlink_ctrl_click.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..75858ad4e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2397,9 +2397,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & d.selection.anchor = sel.anchor; } } else if mods.command { - // Ctrl+click on a hyperlink's text follows it instead of adding the cell to the selection. + // Ctrl+click (without Alt) on a hyperlink's text follows it instead of adding the cell to the selection. if let Some(h) = sh.hyperlinks.get(&c).cloned() && primary_clicked + && !mods.alt && p.x < geo.cell_rect(sh, c).left() + geo.cell_rect(sh, c).width() * 0.8 { follow_link(app, &h.target); diff --git a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs index 2e557e12e..a61350d3d 100644 --- a/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs +++ b/crates/ui-egui/tests/it/hyperlink_ctrl_click.rs @@ -83,3 +83,15 @@ fn ctrl_click_beside_the_link_text_still_extends_the_selection() { assert_eq!(ranges, ["B2", "B4"], "the hyperlink cell joins the selection"); assert!(h.state().dialog.is_none(), "no link was followed"); } + +#[test] +fn ctrl_alt_click_on_the_link_text_still_extends_the_selection() { + let mut h = harness(); + click_at(&mut h, "B2", 0.9, Modifiers::NONE); + click_at(&mut h, "B4", 0.3, Modifiers { alt: true, ..Modifiers::COMMAND }); + let d = h.state().session.active().unwrap(); + let ranges: Vec = d.selection.ranges.iter().map(|r| r.a1()).collect(); + assert_eq!(ranges, ["B2", "B4"], "Ctrl+Alt+click adds the hyperlink cell to the selection"); + assert_eq!(d.selection.active.a1(), "B4", "the link was not followed"); + assert!(h.state().dialog.is_none(), "no link dialog"); +} From 49f26bdd79f88ee12637a6728022557ac749f327 Mon Sep 17 00:00:00 2001 From: xc4pr <88853037+xc4pr@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:51:53 +0200 Subject: [PATCH 051/149] Fill handle: the Auto Fill Options button on a right-to-left sheet is checked in screen space (#2368) --- crates/ui-egui/src/grid.rs | 3 +- .../ui-egui/tests/it/fill_options_button.rs | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 75858ad4e..ee9eb600b 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -3396,7 +3396,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet let size = vec2(18.0, 18.0); let corner = geo.range_rect(sh, tag.target).right_bottom(); let button = geo.mirror_rect(Rect::from_min_size(corner + vec2(2.0, 2.0), size)); - if !geo.cells.contains_rect(button) { + // `button` is in screen space, so compare it with the mirrored cells area. + if !geo.mirror_rect(geo.cells).contains_rect(button) { return; } let lang = crate::i18n::current(ui.ctx()); diff --git a/crates/ui-egui/tests/it/fill_options_button.rs b/crates/ui-egui/tests/it/fill_options_button.rs index 2cb209fa9..d735f846a 100644 --- a/crates/ui-egui/tests/it/fill_options_button.rs +++ b/crates/ui-egui/tests/it/fill_options_button.rs @@ -209,3 +209,65 @@ fn the_button_stays_inside_the_grid_on_a_right_to_left_sheet() { let filled = geo.screen_cell_rect(sheet, CellRef::parse("B6").unwrap()); assert!(button.center().x < filled.left() && button.top() >= filled.bottom() - 1.0, "below-left of the mirrored range's corner"); } + +/// Right-to-left sheet with column A made `chars` wide (so column B's right edge, where the fill +/// range B1:B6 ends, sits far enough right to be scrolled to either edge of the cells area). +fn rtl_wide_a(chars: u32) -> Harness { + harness(|s| { + one_two(s); + s.execute("home.columnWidth", json!({"cols": "A:A", "chars": chars})).unwrap(); + s.execute("view.rightToLeft", json!({"on": true})).unwrap(); + }) +} + +/// Logical right edge of the filled column B (scroll-dependent) and the current geometry. +fn b_right(h: &Harness) -> (f32, Geo) { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + (geo.cell_rect(sheet, CellRef::parse("B6").unwrap()).right(), geo) +} + +fn scroll_b_right_to(h: &mut Harness, logical_x: f32) { + let (now, geo) = b_right(h); + h.state_mut().view_mut().unwrap().scroll.x += (now - logical_x) / geo.z; + h.run_steps(3); + let got = b_right(h).0; + assert!((got - logical_x).abs() < 1.0, "setup: column B ends at logical x {got}, wanted {logical_x}"); +} + +/// Logical x of the range's corner is `cells.right() - 25`: the 18 px button (at corner + 2 .. +/// corner + 20) fits in the cells area, so on screen it sits at the left edge of the mirrored cells +/// area, left of the filled range. `fill_options_tag` tested the mirrored button against the +/// unmirrored cells, i.e. against the row-heading strip it does not own, and hid it. +#[test] +fn the_button_shows_near_the_left_screen_edge_on_a_right_to_left_sheet() { + let mut h = rtl_wide_a(220); + let (_, geo) = b_right(&h); + scroll_b_right_to(&mut h, geo.cells.right() - 25.0); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + + let (_, geo) = b_right(&h); + let cells = h.state().grid.cells_rect.unwrap(); + assert!(geo.header_w > 20.0 && geo.cells.right() - 25.0 + 20.0 > geo.cells.right() - geo.header_w, "setup: in the zone the old check hides"); + let button = h.query_by_label("Auto Fill Options").expect("the button fits inside the cells area, so it shows").rect(); + assert!(cells.contains_rect(button), "{button:?} outside {cells:?}"); +} + +/// The corner is 10 px left of `cells.left()` (scrolled partly behind the row headings, which are +/// on the right on screen). The button would start 8 px behind the headings, so it must not be +/// drawn there. The old check let it through because the mirrored button fits the unmirrored cells. +#[test] +fn the_button_stays_off_the_row_headings_on_a_right_to_left_sheet() { + let mut h = rtl_wide_a(20); + drag_handle(&mut h, "B2", "B6", PointerButton::Primary); + assert!(h.query_by_label("Auto Fill Options").is_some(), "setup: the button shows before scrolling"); + let (_, geo) = b_right(&h); + scroll_b_right_to(&mut h, geo.cells.left() - 10.0); + + let cells = h.state().grid.cells_rect.unwrap(); + if let Some(b) = h.query_by_label("Auto Fill Options") { + let button = b.rect(); + assert!(cells.contains_rect(button), "{button:?} overlaps the row headings, cells area is {cells:?}"); + } +} From aa8399be9507ebc789063117c71d02efb5fb7251 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:14:15 -0400 Subject: [PATCH 052/149] Grid: #### is sized by the width of #, so it fits its cell in every font (#2378) A number too wide for its column is drawn as ####, with as many # as fit. The count used the width of 0, and in fonts where # is wider than 0 the hashes ran past the cell edge. accounting_fill::too_wide_fill_number_shows_hashes_not_a_clipped_value caught it on FreeBSD and on Linux machines without the CI font set. --- crates/ui-egui/src/grid.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..590294904 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -1300,7 +1300,9 @@ pub(crate) fn paint_quadrant( // Numbers and dates never overflow: they show #### when too narrow. let hashed = !shrunk && (numeric || matches!(v, Value::Number(_))) && w > avail_w - 2.0 * pad && st.align.h != HAlign::Fill; if hashed { - let n = ((avail_w - 2.0 * pad) / char_w).floor().max(1.0) as usize; + // Measured on `#` itself: it is wider than `0` in some fonts, and #### must fit its cell. + let hash_w = p.layout_no_wrap("#".to_string(), font.clone(), color).size().x.max(1.0); + let n = ((avail_w - 2.0 * pad) / hash_w).floor().max(1.0) as usize; galley = p.layout_no_wrap("#".repeat(n), font.clone(), color); w = galley.size().x; } From 0c14f595550ca0970a10fa963bc35f3f3cf60e2f Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:21:27 +0200 Subject: [PATCH 053/149] PivotTables: Show Details, double-click a value cell to list its source records on a new sheet (#2364) --- crates/engine/src/cmd/pivot.rs | 207 +++++++++++++++++- crates/engine/src/pivot.rs | 32 +++ crates/ui-egui/src/grid.rs | 7 + crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/pivot_show_details.rs | 76 +++++++ docs/gaps.md | 6 +- 6 files changed, 317 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/tests/it/pivot_show_details.rs diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 43c3aa4b5..80f8d0205 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -179,6 +179,15 @@ pub fn specs() -> Vec { has_doc, change_source ), + cmd!( + "pivot.showDetails", + "Show Details", + [], + None, + "{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}", + has_doc, + show_details + ), cmd!( "pivot.delete", "Delete PivotTable", @@ -654,16 +663,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 nm = cx.wb.next_sheet_name(); - cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm))); - for n in cx.wb.names.iter_mut() { - if let Some(sc) = n.scope.as_mut() - && *sc >= at - { - *sc += 1; - } - } + let at = insert_sheet_before_active(cx); new_sheet_at = Some(at); (at, CellRef::new(2, 0)) } @@ -726,6 +726,104 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result { Ok(res) } +/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a +/// Show Details sheet); returns its index. +fn insert_sheet_before_active(cx: &mut Ctx) -> usize { + 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() { + if let Some(sc) = n.scope.as_mut() + && *sc >= at + { + *sc += 1; + } + } + at +} + +/// Show Details (double-click a value cell): the source records behind it, as values with their +/// column's number format, under the source headers on a new sheet. +fn show_details(s: &mut Session, p: &Json) -> Result { + let d = s.doc()?; + let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active); + let active = d.wb.active_sheet; + let sh = d.wb.active().ok_or(EngineError::NoDocument)?; + let pt = sh + .pivots + .iter() + .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); + let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; + let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + if d.wb.protected_structure { + return Err(other("The workbook is protected and cannot be changed.")); + } + let count = records.len(); + let mut new_sheet_at: Option = None; + let res = edit(s, |cx| { + let si = insert_sheet_before_active(cx); + new_sheet_at = Some(si); + let base = cx.wb.styles.get(StyleId::default()).clone(); + let fmts: Vec = src + .formats + .iter() + .map(|f| { + let mut st = base.clone(); + st.num_fmt = NumFmt::new(f); + cx.wb.styles.intern(st) + }) + .collect(); + // Columns wide enough for their text, as the PivotTable report's are (dates would show + // as ####); the first rows are enough to judge a column. + let font = base.font.clone(); + let widths: Vec = src + .headers + .iter() + .enumerate() + .map(|(c, h)| { + let fmt = src.formats.get(c).map(String::as_str).unwrap_or("General"); + let texts = records.iter().take(1_000).filter_map(|i| src.rows.get(*i).and_then(|r| r.get(c))); + let widest = texts + .map(|v| crate::cmd::format::approx_text_width(&crate::display::format(v, fmt, &cx.wb).text, font.size, false)) + .fold(crate::cmd::format::approx_text_width(h, font.size, false), f32::max); + (widest + 14.0).min(320.0) + }) + .collect(); + let sh = cx.sheet_mut(si)?; + for (c, w) in widths.into_iter().enumerate() { + if w > sh.col_width(c as u32) { + sh.cols.entry(c as u32).or_default().size = Some(w); + } + } + for (c, h) in src.headers.iter().enumerate() { + sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() }); + } + for (r, i) in records.iter().enumerate() { + let Some(rec) = src.rows.get(*i) else { continue }; + for (c, v) in rec.iter().enumerate() { + let style = fmts.get(c).copied().unwrap_or_default(); + if v.is_empty() && style == StyleId::default() { + continue; + } + sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style }); + } + } + let name = sh.name.clone(); + cx.wb.active_sheet = si; + *cx.sel = crate::Selection::at(CellRef::new(0, 0)); + cx.structural = true; + Ok(json!({"sheet": name, "records": count})) + })?; + if let Some(at) = new_sheet_at + && let Ok(d) = s.doc_mut() + { + d.sheet_selections.insert(at.min(d.sheet_selections.len()), crate::Selection::default()); + } + Ok(res) +} + fn summarize_table(s: &mut Session, p: &Json) -> Result { let d = s.doc()?; let name = match str_param(p, "table") { @@ -2236,6 +2334,7 @@ mod tests { "data.refreshAll", "pivot.changeSource", "pivot.delete", + "pivot.showDetails", ]; let hostile = [ json!({}), @@ -2363,4 +2462,92 @@ mod tests { assert_eq!(l[0]["anchor"], "A3"); s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap(); } + + /// The active sheet's first five columns as shown, down to the first blank row. + fn sheet_rows(s: &mut Session) -> Vec> { + let mut out = Vec::new(); + for r in 0..30u32 { + let row: Vec = (0..5) + .map(|c| s.execute("cell.get", json!({"cell": CellRef::new(r, c).a1()})).unwrap()["text"].as_str().unwrap_or("").to_string()) + .collect(); + if row.iter().all(String::is_empty) { + break; + } + out.push(row); + } + out + } + + #[test] + fn show_details_lists_a_value_cells_records_on_a_new_sheet() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 is East × Apples (100 + 60); the active cell is the default. + s.execute("selection.set", json!({"range": "B5"})).unwrap(); + let r = s.execute("pivot.showDetails", json!({})).unwrap(); + assert_eq!(r["records"], 2); + let d = s.doc().unwrap(); + assert_eq!(d.wb.sheets.len(), 3); + assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's"); + assert_eq!(d.wb.active().unwrap().name, "Sheet3"); + assert_eq!( + sheet_rows(&mut s), + vec![ + vec!["Region", "Product", "Date", "Units", "Sales"], + vec!["East", "Apples", "1/15/2026", "10", "100"], + vec!["East", "Apples", "9/9/2026", "6", "60"], + ] + ); + // The dates fit their column instead of showing ####. + let sh = s.doc().unwrap().wb.active().unwrap().clone(); + assert!(sh.col_width(2) > sh.col_width(5), "{} vs {}", sh.col_width(2), sh.col_width(5)); + // One undo step takes the sheet away. + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + } + + #[test] + fn show_details_on_a_total_takes_every_record_it_adds_up() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + let records = |s: &mut Session, cell: &str| { + let n = s.execute("pivot.showDetails", json!({"cell": cell})).unwrap()["records"].as_u64().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + n + }; + assert_eq!(records(&mut s, "E5"), 5, "East's row total"); + assert_eq!(records(&mut s, "C8"), 4, "Pears' column total"); + assert_eq!(records(&mut s, "E8"), 12, "the grand total"); + // A subtotal at the top of its group, in a two-level row axis. + s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap(); + assert_eq!(text(&s, "A4"), "East"); + assert_eq!(records(&mut s, "B4"), 5); + assert_eq!(text(&s, "A6"), "Pears"); + assert_eq!(records(&mut s, "B6"), 2); + } + + #[test] + fn show_details_leaves_out_the_records_the_report_filters_out() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Product"], "values": ["Sales"], "filters": ["Region"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Region", "selected": ["East"]})).unwrap(); + s.execute("pivot.filter", json!({"field": "Product", "selected": ["Apples"]})).unwrap(); + let gt = find_row(&s, "Grand Total").unwrap(); + let r = s.execute("pivot.showDetails", json!({"cell": CellRef::new(gt, 1).a1()})).unwrap(); + assert_eq!(r["records"], 2); + let rows = sheet_rows(&mut s); + assert_eq!(rows.len(), 3); + assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); + } + + #[test] + fn show_details_needs_a_value_cell() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + for cell in ["A3", "B3", "A4", "B4", "A5", "A8", "H20"] { + assert!(s.execute("pivot.showDetails", json!({"cell": cell})).is_err(), "{cell}"); + } + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } } diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 08ec15dfb..43a77c11a 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -803,6 +803,30 @@ fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) - /// Computes a PivotTable report from the workbook. pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result { let src = read_source(wb, &pt.source, pivot_sheet)?; + compute_from(wb, pt, &src, None) +} + +/// Show Details: the source list and the indices of its records behind the value cell at +/// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when +/// that cell holds no value (a label, a header, a blank). +pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { + let src = read_source(wb, &pt.source, pivot_sheet)?; + let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; + compute_from(wb, pt, &src, Some(&mut probe))?; + let Some((rp, cp)) = probe.hit else { return Ok(None) }; + let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); + Ok(Some((src, records))) +} + +/// What [`detail_records`] asks of a report computation: the row and column item paths of the +/// value cell at `at`, and the paths of every record the filters let through. +struct Probe { + at: (usize, usize), + hit: Option<(Vec, Vec)>, + paths: Vec<(usize, Vec, Vec)>, +} + +fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result { let fmt_of = |c: usize| src.formats.get(c).cloned().unwrap_or_else(|| "General".into()); let rows: Vec<(usize, &PivotField)> = pt.rows.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); let cols: Vec<(usize, &PivotField)> = pt.columns.iter().filter_map(|f| src.col(&f.source_col).map(|c| (c, f))).collect(); @@ -914,6 +938,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result raw, }, }; + if let Some(pr) = probe.as_deref_mut() + && pr.at == (r, l + j) + { + pr.hit = Some((rp.to_vec(), cp.to_vec())); + } g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten()); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 40ef2c8e9..1b72576e5 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2386,6 +2386,13 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & && !mods.shift { app.grid.last_click = None; + // A PivotTable's value cell shows its source records on a new sheet (Excel's Show + // Details); its labels and headers still edit. + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) + && app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok() + { + return; + } // Following a hyperlink requires a single click in Excel; double-click edits. app.begin_edit(None, false); return; diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b89253bb9..9b04d3869 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -59,6 +59,7 @@ mod page_layout_view; mod page_setup_margins; mod paste_special_dialog; mod pivot_pane; +mod pivot_show_details; mod popup_dismissal; mod protect_password; mod right_to_left; diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs new file mode 100644 index 000000000..e78ca53ec --- /dev/null +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -0,0 +1,76 @@ +//! Double-clicking a PivotTable's value cell shows its source records on a new sheet (Excel's +//! Show Details); a label of the report still opens the cell for editing. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_engine::core::CellRef; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +fn harness() -> egui_kittest::Harness<'static, SheetApp> { + let mut session = Session::new(); + session.new_workbook(); + session.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 100], ["West", 80], ["East", 30]]})).unwrap(); + // Report at D3: D4 East, E4 its Sum of Sales. + session + .execute("insert.pivotTable", json!({"source": "Sheet1!A1:B4", "destination": "Sheet1!D3", "rows": ["Region"], "values": ["Sales"]})) + .unwrap(); + session.execute("selection.set", json!({"cell": "A1"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn center(h: &egui_kittest::Harness<'_, SheetApp>, cell: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + geo.cell_rect(sheet, CellRef::parse(cell).unwrap()).center() +} + +fn double_click(h: &mut egui_kittest::Harness<'_, SheetApp>, pos: Pos2) { + for _ in 0..2 { + h.input_mut().events.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + h.run_steps(2); + } +} + +#[test] +fn double_click_on_a_value_cell_shows_its_records_on_a_new_sheet() { + let mut h = harness(); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + let wb = &app.session.active().unwrap().wb; + assert_eq!(wb.sheets.len(), 2); + let sheet = wb.active().unwrap(); + assert_eq!(sheet.name, "Sheet2"); + let text = |a: &str| sheet.value(CellRef::parse(a).unwrap()).display(); + assert_eq!( + [text("A1"), text("B1"), text("A2"), text("B2"), text("A3"), text("B3"), text("A4")], + ["Region", "Sales", "East", "100", "East", "30", ""] + ); +} + +#[test] +fn double_click_on_a_row_label_still_edits() { + let mut h = harness(); + let pos = center(&h, "D4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_some()); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} diff --git a/docs/gaps.md b/docs/gaps.md index 7e2e40dc0..b07727991 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-23 · **Change:** minor (§8: PivotTable Show Details) · **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 @@ -84,7 +84,8 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- ### 8. PivotTables: no slicers, calculated items, PivotCharts; unverified in Excel (F) - **Evidence:** `parity-checklist.md` missing `insert.slicer`, `insert.timeline`, `insert.pivotChart`, `table.insertSlicer`; calculated items and GETPIVOTDATA missing (calculated fields - and values on rows done). + and values on rows done). Show Details (double-click a value, subtotal or grand total cell) lists + its source records on a new sheet; the "Enable show details" option isn't modelled yet. - **Impact:** pivots are the analyst's main tool. - **Estimate:** 20–30 h for beta (25–40 h full). @@ -178,6 +179,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha- | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | §8: PivotTable Show Details: double-clicking a value cell lists its source records on a new sheet (#2364) | | 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) | From 08820cb4b27c274258bd55576caa621ac31ffbb2 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:32:15 -0400 Subject: [PATCH 054/149] CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#2338) ci.yml calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69. --- .github/workflows/ci.yml | 44 +++++++++-------------------------- .github/workflows/freebsd.yml | 5 ++++ 2 files changed, 16 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77cde0cec..52068cea1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,8 +1,7 @@ -# The AGENTS.md quality gate (`cargo xtask ci`: fmt, clippy -D warnings, tests, assets, layers, -# wasm, parity --check) on every pull request and every push to main, so a merge burst can't land -# a red tree that only the next agent session discovers. name: CI +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. on: push: branches: [main] @@ -12,37 +11,16 @@ on: permissions: contents: read +# A newer push to a PR branch cancels its older run; every push to main runs to the end. concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: gate: - name: cargo xtask ci - runs-on: ubuntu-24.04 - timeout-minutes: 60 - env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: "0" - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux build dependencies (winit, wgpu, rfd) - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev \ - libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers - - - name: Install Rust (stable + clippy, rustfmt, wasm32) - run: | - rustup toolchain install stable --profile minimal --component clippy,rustfmt \ - --target wasm32-unknown-unknown - rustup default stable - - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Quality gate - run: cargo xtask ci + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev + libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6cdde6e47..054491322 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -2,6 +2,9 @@ # (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: branches: [release] @@ -10,6 +13,8 @@ on: - .github/workflows/freebsd.yml - packaging/freebsd/** - packaging/env.sh + schedule: + - cron: "57 2 * * *" workflow_dispatch: concurrency: From 7111812b8793dbf0cd04aeed8c8e2da02722d691 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 20:41:47 +0200 Subject: [PATCH 055/149] PivotTables: Show Details refuses an out-of-date report, and reports its errors instead of editing the cell --- crates/engine/src/cmd/pivot.rs | 20 +++++++++++++++-- crates/engine/src/pivot.rs | 22 +++++++++++++++++-- crates/ui-egui/src/grid.rs | 15 +++++++++---- crates/ui-egui/tests/it/pivot_show_details.rs | 14 ++++++++++++ 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 80f8d0205..3b6ed83a4 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -753,10 +753,10 @@ fn show_details(s: &mut Session, p: &Json) -> Result { .pivots .iter() .find(|pt| pt.last_range.is_some_and(|r| r.contains(at))) - .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + .ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; let origin = pt.last_range.map(|r| r.start).unwrap_or_default(); let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?; - let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), "select a value cell in a PivotTable".into()))?; + let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?; if d.wb.protected_structure { return Err(other("The workbook is protected and cannot be changed.")); } @@ -2541,6 +2541,22 @@ mod tests { assert!(rows.iter().skip(1).all(|row| row[0] == "East" && row[1] == "Apples"), "{rows:?}"); } + #[test] + fn show_details_refuses_a_report_its_source_has_moved_on_from() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // B5 still shows East × Apples (160), but the first East record is now Central's. + s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap(); + assert_eq!(val(&s, "B5"), Value::Number(160.0)); + let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + // After a refresh East × Apples (now B6) has the one record left. + s.execute("pivot.refresh", json!({})).unwrap(); + assert_eq!(text(&s, "A6"), "East"); + assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); + } + #[test] fn show_details_needs_a_value_cell() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 43a77c11a..45c4eb0a5 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -806,14 +806,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; - compute_from(wb, pt, &src, Some(&mut probe))?; + let out = compute_from(wb, pt, &src, Some(&mut probe))?; let Some((rp, cp)) = probe.hit else { return Ok(None) }; + // Excel reads its PivotTable cache; without one, the report written by the last refresh + // must still be what the source gives. + let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; + let current = pt.last_range.is_some_and(|r| { + r.height() == out.height().max(1) + && r.width() == out.width.max(1) + && out.grid.iter().enumerate().all(|(ri, line)| { + line.iter().enumerate().all(|(ci, oc)| sh.value(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)) == oc.value) + }) + }); + if !current { + return Err("The PivotTable report is out of date. Refresh it first.".into()); + } let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); Ok(Some((src, records))) } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 1b72576e5..9fbdfc911 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2388,10 +2388,17 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.last_click = None; // A PivotTable's value cell shows its source records on a new sheet (Excel's Show // Details); its labels and headers still edit. - if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) - && app.run("pivot.showDetails", json!({"cell": c.a1()})).is_ok() - { - return; + if sh.pivots.iter().any(|pt| pt.last_range.is_some_and(|r| r.contains(c))) { + let not_a_value_cell = + gridcraft_engine::EngineError::Disabled("pivot.showDetails".into(), gridcraft_engine::pivot::NOT_A_VALUE_CELL.into()).to_string(); + match app.run("pivot.showDetails", json!({"cell": c.a1()})) { + Ok(_) => return, + Err(e) if e == not_a_value_cell => {} + Err(e) => { + app.message = Some(("GridCraft".into(), crate::clean_error(&e))); + return; + } + } } // Following a hyperlink requires a single click in Excel; double-click edits. app.begin_edit(None, false); diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index e78ca53ec..629774b04 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -74,3 +74,17 @@ fn double_click_on_a_row_label_still_edits() { assert!(app.editor.is_some()); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_edit() { + let mut h = harness(); + h.state_mut().session.execute("review.protectWorkbook", json!({"on": true})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E4"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the value cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} From 530a8cc810ea82daf318575a560d99f5c48ac3e0 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:47:33 -0400 Subject: [PATCH 056/149] CI: the gate builds with the release craft-fonts pin (#2381) --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52068cea1..c704eb4d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: + # The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship. + craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695 packages: >- pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers From 43a5a3c435f1c8829c974a8180800ba4bb4685d5 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:00:07 +0200 Subject: [PATCH 057/149] PivotTables: Show Details checks the report is up to date before looking at the cell --- crates/engine/src/cmd/pivot.rs | 14 ++++++++++++++ crates/engine/src/pivot.rs | 13 ++++++++----- crates/ui-egui/tests/it/pivot_show_details.rs | 16 ++++++++++++++++ 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/pivot.rs b/crates/engine/src/cmd/pivot.rs index 3b6ed83a4..229ff22b6 100644 --- a/crates/engine/src/cmd/pivot.rs +++ b/crates/engine/src/cmd/pivot.rs @@ -2557,6 +2557,20 @@ mod tests { assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B6"})).unwrap()["records"], 1); } + #[test] + fn show_details_refuses_a_report_that_would_now_be_shorter() { + let mut s = sales(); + s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap(); + // North goes away: the source now gives one row fewer, so row 8 is no longer the Grand Total. + s.execute("range.setValues", json!({"range": "Sheet1!A5", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A8", "values": [["East"]]})).unwrap(); + s.execute("range.setValues", json!({"range": "Sheet1!A11", "values": [["East"]]})).unwrap(); + assert_eq!(text(&s, "A8"), "Grand Total"); + let e = s.execute("pivot.showDetails", json!({"cell": "E8"})).unwrap_err().to_string(); + assert!(e.contains("out of date"), "{e}"); + assert_eq!(s.doc().unwrap().wb.sheets.len(), 2); + } + #[test] fn show_details_needs_a_value_cell() { let mut s = sales(); diff --git a/crates/engine/src/pivot.rs b/crates/engine/src/pivot.rs index 45c4eb0a5..397dd5ad2 100644 --- a/crates/engine/src/pivot.rs +++ b/crates/engine/src/pivot.rs @@ -812,26 +812,29 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable"; /// Show Details: the source list and the indices of its records behind the value cell at /// (`row`, `col`) of the report grid (relative to its top-left), in source order. `None` when -/// that cell holds no value (a label, a header, a blank). An error when the report on the sheet -/// is no longer what the source computes: the cell's records would be another cell's. +/// that cell holds no value (a label, a header, a blank). An error, for any cell, when the report +/// on the sheet is no longer what the source computes: the cell's records would be another cell's. pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result)>, String> { let src = read_source(wb, &pt.source, pivot_sheet)?; let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() }; let out = compute_from(wb, pt, &src, Some(&mut probe))?; - let Some((rp, cp)) = probe.hit else { return Ok(None) }; // Excel reads its PivotTable cache; without one, the report written by the last refresh - // must still be what the source gives. + // must still be what the source gives. Checked first: in a stale report even a cell that + // computes as a label may show a value. let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?; let current = pt.last_range.is_some_and(|r| { r.height() == out.height().max(1) && r.width() == out.width.max(1) && out.grid.iter().enumerate().all(|(ri, line)| { - line.iter().enumerate().all(|(ci, oc)| sh.value(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)) == oc.value) + line.iter().enumerate().all(|(ci, oc)| { + sh.value_ref(CellRef::new(r.start.row + ri as u32, r.start.col + ci as u32)).unwrap_or(&Value::Empty) == &oc.value + }) }) }); if !current { return Err("The PivotTable report is out of date. Refresh it first.".into()); } + let Some((rp, cp)) = probe.hit else { return Ok(None) }; let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect(); Ok(Some((src, records))) } diff --git a/crates/ui-egui/tests/it/pivot_show_details.rs b/crates/ui-egui/tests/it/pivot_show_details.rs index 629774b04..9f11b29eb 100644 --- a/crates/ui-egui/tests/it/pivot_show_details.rs +++ b/crates/ui-egui/tests/it/pivot_show_details.rs @@ -88,3 +88,19 @@ fn double_click_on_a_value_cell_of_a_protected_workbook_says_why_and_does_not_ed assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("protected")), "{:?}", app.message); assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); } + +#[test] +fn double_click_in_an_out_of_date_report_asks_for_a_refresh() { + let mut h = harness(); + // West goes away without a refresh: E6, the Grand Total on the sheet, has no cell in the + // report the source now gives. + h.state_mut().session.execute("range.setValues", json!({"range": "Sheet1!A3", "values": [["East"]]})).unwrap(); + h.run_steps(2); + let pos = center(&h, "E6"); + double_click(&mut h, pos); + + let app = h.state(); + assert!(app.editor.is_none(), "the cell must not open for editing"); + assert!(app.message.as_ref().is_some_and(|(_, m)| m.contains("out of date")), "{:?}", app.message); + assert_eq!(app.session.active().unwrap().wb.sheets.len(), 1); +} From 15a705e23457d502f053e2b7b9fac1876830d403 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:39:59 -0400 Subject: [PATCH 058/149] i18n: missing translations are reported, never a failure (#2383) A string a catalog lacks shows in English. Coverage, key-parity and per-language count tests and tests of one language's exact wording are removed; UI wiring tests take their expected text from the catalog. cargo xtask i18n-report lists the missing strings per language and always passes; cargo xtask ci runs it first. --- crates/ui-egui/src/backstage.rs | 5 +- crates/ui-egui/src/formula_locale.rs | 7 - crates/ui-egui/src/i18n.rs | 210 ++--------------- crates/ui-egui/src/i18n/ja.rs | 132 +---------- crates/ui-egui/tests/it/file_menu.rs | 24 +- crates/ui-egui/tests/it/formula_locale.rs | 5 - crates/ui-egui/tests/it/interaction.rs | 50 ++-- .../tests/it/translated_form_fields.rs | 17 +- docs/localization-parity.md | 8 +- xtask/Cargo.toml | 2 +- xtask/src/i18n_report.rs | 218 ++++++++++++++++++ xtask/src/main.rs | 11 +- 12 files changed, 297 insertions(+), 392 deletions(-) create mode 100644 xtask/src/i18n_report.rs diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c2acf025b..f5bdc20ab 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1084,11 +1084,8 @@ mod tests { } assert!(age(Language::En, f64::MAX).ends_with(" years ago")); // Other languages put the count where their own words want it. - assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前"); - assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen"); - assert_eq!(age(Language::Fr, 90.0), "il y a 1 min"); - assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa"); for lang in Language::ALL { + assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY)); for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] { let words = age(lang, seconds); assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}"); diff --git a/crates/ui-egui/src/formula_locale.rs b/crates/ui-egui/src/formula_locale.rs index 699167934..d53c5fa34 100644 --- a/crates/ui-egui/src/formula_locale.rs +++ b/crates/ui-egui/src/formula_locale.rs @@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> { Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() })) } -/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the -/// function help tables are checked against them. -#[cfg(test)] -pub(crate) fn argument_names(signature: &str) -> Vec<&str> { - split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default() -} - /// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's /// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`). fn argument_name(arg: &str) -> &str { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4156c2cd3..345037b52 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String { mod tests { use super::*; - /// Help tables are sorted, unique, and keyed by text the function catalog still has. + /// Help tables are sorted and unique: lookups binary-search them. #[test] - fn function_help_is_sorted_and_keyed_by_current_catalog_text() { - // Lookups binary-search the tables. A key that no longer matches the catalog (an English - // description reworded, an argument renamed) would quietly show English again. - let catalog = gridcraft_engine::cmd::formulas::function_list(); - let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); - let arguments: std::collections::HashSet<&str> = - catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect(); + fn function_help_tables_are_sorted_and_unique() { for language in Language::ALL { let Some((description_table, argument_table)) = language.function_help() else { continue }; - for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] { + for table in [description_table, argument_table] { assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique"); for (english, translated) in table { - assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use"); assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}"); } } @@ -343,105 +336,27 @@ mod tests { } #[test] - fn translations_are_unique_and_preserve_unknown_text() { - for (i, (en, ja)) in ja::CORE.iter().enumerate() { - assert!(!ja.is_empty()); - assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other)); - assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label)); - } + fn untranslated_text_falls_back_to_english() { + let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label); for (english, _) in ja::CORE { - assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}"); - assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}"); - assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}"); - assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}"); - assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}"); - assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}"); - assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}"); - assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}"); - assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}"); - assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}"); - assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}"); - assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}"); - assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}"); - assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}"); - assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}"); - assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}"); - assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}"); + assert_eq!(Language::En.tr(english), label(english)); } - assert_eq!(Language::Ja.tr("Data"), "データ"); - assert_eq!(Language::Zh.tr("Data"), "数据"); - assert_eq!(Language::Ko.tr("Data"), "데이터"); - assert_eq!(Language::Ru.tr("Data"), "Данные"); - assert_eq!(Language::PtBr.tr("Data"), "Dados"); - assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); - assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro."); - assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); - assert_eq!(Language::Es.tr("Data"), "Datos"); - assert_eq!(Language::El.tr("Data"), "Δεδομένα"); - assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας"); - assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}"); - assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles"); - assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::De.tr("Data"), "Daten"); - assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Fr.tr("Data"), "Données"); - assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Data"), "Veri"); - assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::PtBr.tr("Date"), "Data"); - assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1"); - // Czech "Data" is spelled as in English, so probe it with a label that differs. - assert_eq!(Language::Cs.tr("Data"), "Data"); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Tr.tr("Formulas"), "Formüller"); - // A `context|label` key shows only its label in English and splits the translation. + for language in Language::ALL { + assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1"); + assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated"); + } + // A `context|label` key shows only its label in English. assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation"); - assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き"); - assert_eq!(Language::Ja.tr("Orientation"), "方向"); - assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace"); - assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu"); - assert_eq!(Language::Sr.tr("Data"), "Podaci"); - assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija"); - assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija"); - assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü"); - assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme"); - assert_eq!(Language::Pl.tr("Data"), "Dane"); - assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja"); - assert_eq!(Language::It.tr("Data"), "Dati"); - assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri"); - assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento"); - assert_eq!(Language::It.tr("Orientation"), "Orientamento"); - assert_eq!(Language::Uk.tr("Data"), "Дані"); - assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Data"), "Dữ liệu"); - assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy"); - assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ"); - assert_eq!(Language::Nl.tr("Data"), "Gegevens"); - assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand"); - assert_eq!(Language::Nl.tr("Orientation"), "Richting"); - assert_eq!(Language::Ro.tr("Data"), "Date"); - assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1"); - assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare"); - assert_eq!(Language::Ro.tr("Orientation"), "Orientare text"); + // Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole. + assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first.")); + assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure"); + assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied"); assert_eq!(Language::parse("xx"), None); } /// A catalog whose translations slip onto their neighbours' keys still covers every key, so /// check the shape of each translation against its English: placeholders survive, and a - /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French - /// once shipped with over a hundred translations shifted this way ("Close" read "Société"). + /// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. #[test] fn translations_keep_the_shape_of_their_english() { let placeholders = @@ -457,106 +372,19 @@ mod tests { } } } - for (english, french) in [ - ("Close", "Fermer"), - ("File", "Fichier"), - ("Save", "Enregistrer"), - ("Animations", "Animations"), - ("Company", "Société"), - ("Show Detail", "Afficher le détail"), - ("Solver", "Solveur"), - ] { - assert_eq!(Language::Fr.tr(english), french); - } - } - - #[test] - fn german_cell_and_header_context_menus_are_fully_translated() { - for label in [ - "Cut", - "Copy", - "Paste", - "Paste Special…", - "Insert…", - "Delete…", - "Clear Contents", - "Filter by Selected Cell's Value", - "Sort A to Z", - "Sort Z to A", - "New Comment", - "New Note", - "Format Cells…", - "Pick From Drop-down List…", - "Define Name…", - "Insert Copied Cells", - "Insert Copied Cells…", - "Insert Cut Cells", - "Insert", - "Delete", - "Row Height…", - "AutoFit Row Height", - "Column Width…", - "AutoFit Column Width", - "Hide", - "Unhide", - "Group", - ] { - assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}"); - } - } - - #[test] - fn sheet_tab_menu_and_autosave_labels_are_translated() { - let labels = [ - "Insert Sheet", - "Delete", - "Rename", - "Move or Copy…", - "Duplicate", - "Tab Color", - "No Color", - "Hide", - "Unhide…", - "Protect Sheet…", - "Unprotect Sheet…", - "Select All Sheets", - "AutoSave", - "AutoSave: save after every change (workbooks saved to a file)", - "Previous sheet", - "Next sheet", - "All sheets", - "New sheet (⇧F11)", - ]; - for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] { - for label in labels { - assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code()); - } - } - assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado"); } #[test] fn translations_never_show_the_context_prefix() { // `tr` returns a found translation as it is, so a catalog that keeps `context|` in its // value shows it on screen (File › Options read "Options|Général" in French). - for language in Language::ALL { - for (english, _) in ja::CORE { + let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]); + for english in keys { + for language in Language::ALL { let shown = language.tr(english); assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code()); } } - assert_eq!(Language::Fr.tr("Options|General"), "Général"); - assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer"); - assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage"); - assert_eq!(Language::Sr.tr("Options|General"), "Opšte"); - assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj"); - assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz"); - assert_eq!(Language::Nl.tr("Options|General"), "Algemeen"); - assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan"); - assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven"); - assert_eq!(Language::Ro.tr("Options|General"), "General"); - assert_eq!(Language::Ro.tr("Options|Save"), "Salvare"); - assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare"); } #[test] diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 482852d8d..2df4cfc11 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1,12 +1,12 @@ //! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's //! Japanese wording, and anything absent falls back to English. //! -//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs, -//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels, -//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian -//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap. +//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog +//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items, +//! template names and native-menu strings. The two lists never overlap. A string a language doesn't +//! translate shows in English; `cargo xtask i18n-report` lists them per language. -/// English label → Japanese, the shared reference set every catalog covers. +/// English label → Japanese, the shared reference set. pub(super) const CORE: &[(&str, &str)] = &[ ("Paste", "貼り付け"), ("Paste Values", "値の貼り付け"), @@ -1150,99 +1150,17 @@ mod tests { DIALOGS.iter().chain(CORE).map(|(english, _)| *english) } - /// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and - /// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are - /// decoded, so the keys compare equal to the ones in the tables above. - fn string_literal(source: &str, start: usize) -> Option<(String, usize)> { - if !source[start..].starts_with('"') { - return None; - } - let mut value = String::new(); - let mut chars = source[start + 1..].char_indices(); - while let Some((offset, c)) = chars.next() { - match c { - '"' => return Some((value, start + 1 + offset + 1)), - '\\' => match chars.next().map(|(_, c)| c) { - Some('n') => value.push('\n'), - Some('t') => value.push('\t'), - Some('r') => value.push('\r'), - Some('0') => value.push('\0'), - Some('\\') => value.push('\\'), - Some('"') => value.push('"'), - Some('\'') => value.push('\''), - Some('u') => { - let mut hex = String::new(); - for (_, c) in chars.by_ref() { - if c == '}' { - break; - } - if c != '{' { - hex.push(c); - } - } - if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { - value.push(c); - } - } - // A line continuation (backslash, newline, indentation) adds nothing. - _ => {} - }, - _ => value.push(c), - } - } - None - } - - /// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a - /// string literal whose `=>` is followed by another string literal, which covers the block form - /// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from - /// drifting apart again instead of checking a remembered count. - fn portuguese_keys() -> Vec { - let source = include_str!("pt.rs"); - let mut literals: Vec<(String, usize, usize)> = Vec::new(); - let mut i = 0; - while i < source.len() { - let rest = &source[i..]; - if rest.starts_with("//") { - i += rest.find('\n').unwrap_or(rest.len()); - continue; - } - if rest.starts_with('"') { - let (value, end) = string_literal(source, i).unwrap(); - literals.push((value, i, end)); - i = end; - continue; - } - i += rest.chars().next().unwrap().len_utf8(); - } - let mut keys = Vec::new(); - for pair in literals.windows(2) { - let (key, _, end) = &pair[0]; - let start = pair[1].1; - let gap = source[*end..start].trim(); - if gap == "=>" || gap == "=> {" { - keys.push(key.clone()); - } - } - keys - } - #[test] - fn keys_are_unique_and_every_value_is_translated() { + fn keys_are_unique_and_no_value_is_blank() { let mut seen = HashSet::new(); for (english, japanese) in DIALOGS.iter().chain(CORE) { - assert!(!japanese.is_empty(), "empty translation for {english:?}"); assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}"); assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}"); } - assert_eq!(seen.len(), catalog_keys().count()); - assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers"); - assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it"); - assert_eq!(catalog_keys().count(), 954); } #[test] - fn extra_labels_are_translated_and_do_not_overlap_the_catalog() { + fn extra_labels_do_not_overlap_the_catalog() { let catalog: HashSet<&str> = catalog_keys().collect(); let mut seen = HashSet::new(); for (english, japanese) in EXTRA { @@ -1252,40 +1170,4 @@ mod tests { assert_eq!(translate(english), Some(*japanese)); } } - - #[test] - fn covers_every_key_the_portuguese_catalog_translates() { - let portuguese = portuguese_keys(); - assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count"); - let japanese: HashSet<&str> = catalog_keys().collect(); - for key in &portuguese { - assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not"); - } - assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set"); - } - - #[test] - fn high_value_strings_use_excel_japanese_wording() { - for (english, japanese) in [ - ("OK", "OK"), - ("Cancel", "キャンセル"), - ("Don't Save", "保存しない"), - ("Text Box", "テキスト ボックス"), - ("Row Height", "行の高さ"), - ("Column Width", "列の幅"), - ("Sort", "並べ替え"), - ("Filter", "フィルター"), - ("Conditional Formatting", "条件付き書式"), - ("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"), - ("Save a copy as .{format}", ".{format} としてコピーを保存"), - ("Format Cells|Fill", "塗りつぶし"), - ("Insert\nSlicer", "スライサーの\n挿入"), - ] { - assert_eq!(translate(english), Some(japanese), "{english:?}"); - assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}"); - } - // A `context|label` key shows only its label in English; an unknown label keeps the text. - assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill"); - assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog"); - } } diff --git a/crates/ui-egui/tests/it/file_menu.rs b/crates/ui-egui/tests/it/file_menu.rs index 047efe1b1..092876b8d 100644 --- a/crates/ui-egui/tests/it/file_menu.rs +++ b/crates/ui-egui/tests/it/file_menu.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::{Session, core::CellRef, core::Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{Services, SheetApp}; fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> { @@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() { #[test] fn translated_options_headings_show_no_context_prefix() { - for (code, headings, prefixed, (view, view_prefixed)) in [ - ("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")), - ("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")), - ("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")), - ("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")), - ] { + for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] { let mut h = harness(Services::default()); - h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap(); + h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap(); h.state_mut().run("file.options", serde_json::json!({})).unwrap(); h.run_steps(3); assert_eq!(page(&h), Some("options")); - for heading in headings { - assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading"); - } - for label in prefixed { - assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context"); + for key in ["Options|General", "Options|Save"] { + let heading = language.tr(key); + assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}"); + assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading"); } // Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same // prefix. The View tab may share the word, so count at least the two checkboxes. h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap(); h.state_mut().ui.ribbon_tab = "Page Layout".into(); h.run_steps(3); - assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context"); - assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}"); + let view = language.tr("Sheet Options|View"); + assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}"); + assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}"); } } diff --git a/crates/ui-egui/tests/it/formula_locale.rs b/crates/ui-egui/tests/it/formula_locale.rs index 16f4dac15..98ab70d57 100644 --- a/crates/ui-egui/tests/it/formula_locale.rs +++ b/crates/ui-egui/tests/it/formula_locale.rs @@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"pt-BR"})).unwrap(); assert_eq!(app.ui.language, Language::PtBr); - assert_eq!(app.ui.language.tr("Data"), "Dados"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); assert!(!enter(&mut app, "=SUMA(1;2)")); let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap(); assert_eq!(restored.language, Language::PtBr); - assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación"); } #[test] @@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap(); assert_eq!(app.ui.language, Language::Cs); - assert_eq!(app.ui.language.tr("Formulas"), "Vzorce"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); @@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() { let mut app = app(); app.run("app.language.set", json!({"code":"tr-TR"})).unwrap(); assert_eq!(app.ui.language, Language::Tr); - assert_eq!(app.ui.language.tr("Data"), "Veri"); - assert_eq!(app.ui.language.tr("Formulas"), "Formüller"); assert!(enter(&mut app, "=SUM(1.5,2.25)")); app.begin_edit(None, true); assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)"); diff --git a/crates/ui-egui/tests/it/interaction.rs b/crates/ui-egui/tests/it/interaction.rs index 46e981d0a..94fefe6d3 100644 --- a/crates/ui-egui/tests/it/interaction.rs +++ b/crates/ui-egui/tests/it/interaction.rs @@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton}; use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_engine::core::{CellRef, Value}; +use gridcraft_ui_egui::i18n::Language; use gridcraft_ui_egui::{SheetApp, grid::Geo}; use serde_json::json; @@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() { } #[test] -fn language_switch_translates_the_ribbon_and_persists() { - use gridcraft_ui_egui::i18n::Language; +fn language_switch_renders_the_ribbon_and_persists() { let mut h = harness(blank()); // English by default whatever the host's locale: only the desktop app reads the system language. assert_eq!(h.state().ui.language, Language::En); @@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() { for id in ["app.language.set", "app.language.english", "app.language.japanese"] { assert!(ids.contains(&id), "engine.commands lists {id}"); } - for (language, expected) in [ - (Language::Zh, "数据"), - (Language::Ja, "データ"), - (Language::Ko, "데이터"), - (Language::Ru, "Данные"), - (Language::PtBr, "Dados"), - (Language::De, "Daten"), - (Language::Fr, "Données"), - (Language::Tr, "Veri"), - (Language::It, "Dati"), - ] { + for language in Language::ALL { h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap(); h.run_steps(2); // every locale renders the ribbon without panicking assert_eq!(h.state().ui.language, language); - assert_eq!(language.tr("Data"), expected); let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); assert_eq!(restored.language, language); } - // Czech spells "Data" as English does, so the Czech table is checked on a label that differs. - h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap(); - h.run_steps(2); - assert_eq!(h.state().ui.language, Language::Cs); - assert_eq!(Language::Cs.tr("Formulas"), "Vzorce"); - let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap(); - assert_eq!(restored.language, Language::Cs); h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap(); assert_eq!(h.state().ui.language, Language::En); } @@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() { h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap(); h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap(); h.run_steps(4); - for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] { - assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`"); + let es = |english| Language::Es.tr(english); + for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] { + assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english)); + } + if es("PivotTable Fields") != "PivotTable Fields" { + assert!(h.query_by_label("PivotTable Fields").is_none()); } - assert!(h.query_by_label("PivotTable Fields").is_none()); h.get_by_label("Region ▾").click(); h.run_steps(2); - for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] { - assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`"); + for english in ["Move to Column Labels", "Group by Years", "Remove Field"] { + assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english)); + } + if es("Remove Field") != "Remove Field" { + assert!(h.query_by_label("Remove Field").is_none()); } - assert!(h.query_by_label("Remove Field").is_none()); } #[test] @@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() { h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap(); h.state_mut().open_dialog("formatCells", json!({"tab": "Number"})); h.run_steps(2); - assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated"); - assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); - h.get_by_label("Wissenschaftlich").click(); + let scientific = Language::De.tr("Scientific"); + assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated"); + if scientific != "Scientific" { + assert!(h.query_by_label("Scientific").is_none(), "no English category is left"); + } + h.get_by_label(scientific).click(); h.run_steps(2); let style = &h.state().dialog.as_ref().unwrap().values["style"]; assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code"); diff --git a/crates/ui-egui/tests/it/translated_form_fields.rs b/crates/ui-egui/tests/it/translated_form_fields.rs index 35847a425..b284bf786 100644 --- a/crates/ui-egui/tests/it/translated_form_fields.rs +++ b/crates/ui-egui/tests/it/translated_form_fields.rs @@ -4,6 +4,7 @@ use egui_kittest::kittest::Queryable as _; use gridcraft_engine::Session; use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::i18n::Language; use serde_json::json; fn harness() -> egui_kittest::Harness<'static, SheetApp> { @@ -23,19 +24,17 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> { #[test] fn form_fields_are_translated() { - for (dialog, translated, english) in [ - ("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]), - ("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]), - ] { + for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] { let mut h = harness(); h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap(); h.state_mut().open_dialog(dialog, json!({})); h.run_steps(3); - for label in translated { - assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}"); - } - for label in english { - assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated"); + for english in labels { + let translated = Language::PtBr.tr(english); + assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}"); + if translated != english { + assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated"); + } } } } diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 979ac1b81..39a9b3f91 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Per-language status against Excel, which ships its UI in dozens of languages (Windows language packs cover 100+), with localized function names, argument separators, number and date formats, @@ -96,8 +96,9 @@ language (none so far) and of localized function names. ## Infrastructure (do first) 1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes, - status and error messages, command labels, function descriptions), with a missing-key check - in `cargo xtask ci`. Language switching and the system-language default already exist (#31). + status and error messages, command labels, function descriptions). A string a catalog lacks + shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings + per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31). 2. Locale-aware formulas: localized function-name tables and separators at the formula-bar boundary only (files keep en-US invariant names), locale number and date formats, list separators in CSV. @@ -109,6 +110,7 @@ language (none so far) and of localized function names. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay | | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review | | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) | | 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) | diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index cc3da3bb2..c40c97697 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -4,7 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true publish = false -description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico" +description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico" [lints] workspace = true diff --git a/xtask/src/i18n_report.rs b/xtask/src/i18n_report.rs new file mode 100644 index 000000000..d72a887fa --- /dev/null +++ b/xtask/src/i18n_report.rs @@ -0,0 +1,218 @@ +//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from +//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the +//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log. +//! +//! The reference set is every key any catalog translates. A catalog is a source file with a +//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a +//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by +//! English the function catalog no longer has: those show the English help. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt::Write as _; +use std::path::Path; + +/// Missing keys listed per language; the rest are counted. +const LIST_CAP: usize = 50; + +pub fn run(root: &Path) -> Result<(), String> { + let dir = root.join("crates/ui-egui/src/i18n"); + let mut catalogs = BTreeMap::new(); + let mut help = BTreeMap::new(); + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(e) => { + println!("i18n-report: can't read {}: {e}", dir.display()); + return Ok(()); + } + }; + for entry in entries.flatten() { + let path = entry.path(); + let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { continue }; + if let Some(language) = stem.strip_suffix("_functions") { + help.insert(language.to_owned(), catalog_keys(&source)); + } else if source.contains("fn translate(") { + catalogs.insert(stem, catalog_keys(&source)); + } + } + print!("{}", render(&catalogs)); + print!("{}", function_help(&help)); + Ok(()) +} + +/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them. +pub fn render(catalogs: &BTreeMap>) -> String { + let reference: BTreeSet<&String> = catalogs.values().flatten().collect(); + let mut out = + format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len()); + for (language, keys) in catalogs { + let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect(); + let _ = writeln!(out, " {language}: {} missing", missing.len()); + list(&mut out, missing.iter().map(|key| key.as_str()), missing.len()); + } + out +} + +fn list<'a>(out: &mut String, keys: impl Iterator, count: usize) { + for key in keys.take(LIST_CAP) { + let _ = writeln!(out, " {key:?}"); + } + if count > LIST_CAP { + let _ = writeln!(out, " … and {} more", count - LIST_CAP); + } +} + +/// Function help rows whose English the function catalog no longer has (an English description +/// reworded, an argument renamed). +#[cfg(feature = "engine")] +fn function_help(help: &BTreeMap>) -> String { + let catalog = gridcraft_engine::cmd::formulas::function_list(); + let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect(); + known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names)); + let mut out = String::new(); + for (language, keys) in help { + let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect(); + let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len()); + list(&mut out, stale.iter().copied(), stale.len()); + } + out +} + +#[cfg(not(feature = "engine"))] +fn function_help(_: &BTreeMap>) -> String { + String::new() +} + +/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped. +#[cfg(feature = "engine")] +fn argument_names(signature: &str) -> Vec<&str> { + let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() }; + inner + .split(", ") + .map(|arg| { + let arg = arg.strip_prefix('[').unwrap_or(arg); + let arg = arg.strip_suffix(']').unwrap_or(arg); + arg.trim_end_matches(|c: char| c.is_ascii_digit()) + }) + .filter(|name| !name.is_empty() && *name != "...") + .collect() +} + +/// The keys of one catalog source: string literals that start a match arm or open a table row. +/// Comments and the test module are skipped. +pub fn catalog_keys(source: &str) -> BTreeSet { + let source = source.split("#[cfg(test)]").next().unwrap_or(source); + let mut keys = BTreeSet::new(); + for (value, start, end) in literals(source) { + let before = source.get(..start).unwrap_or("").trim_end(); + let after = source.get(end..).unwrap_or("").trim_start(); + if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) { + keys.insert(value); + } + } + keys +} + +/// Every string literal outside `//` comments: its decoded value, and the byte range from its +/// opening quote to just past its closing one. +fn literals(source: &str) -> Vec<(String, usize, usize)> { + let mut out = Vec::new(); + let mut chars = source.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) { + for (_, c) in chars.by_ref() { + if c == '\n' { + break; + } + } + continue; + } + if c != '"' { + continue; + } + let mut value = String::new(); + let mut end = source.len(); + while let Some((j, c)) = chars.next() { + match c { + '"' => { + end = j + 1; + break; + } + '\\' => match chars.next().map(|(_, c)| c) { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some('0') => value.push('\0'), + Some(c @ ('\\' | '"' | '\'')) => value.push(c), + Some('u') => { + let mut hex = String::new(); + for (_, c) in chars.by_ref() { + match c { + '}' => break, + '{' => {} + c => hex.push(c), + } + } + if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) { + value.push(c); + } + } + // A line continuation skips the newline and the next line's indentation. + Some('\n') => { + while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) { + chars.next(); + } + } + _ => {} + }, + c => value.push(c), + } + } + out.push((value, i, end)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() { + let matched = r#" +pub(super) fn translate(text: &str) -> Option<&'static str> { + Some(match text { + "Paste" => "Einfügen", // "Commented" => "x", + "Line\nbreak \"quoted\"" => { + "Zeile" + } + _ => return None, + }) +} +#[cfg(test)] +mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; } +"#; + let table = r#" +pub(super) const CORE: &[(&str, &str)] = &[ + ("Paste", "貼り付け"), + ( + "Long \u{2026}", + "長い", + ), +]; +pub(super) fn translate(text: &str) -> Option<&'static str> { None } +"#; + let de = catalog_keys(matched); + assert_eq!(de.iter().map(String::as_str).collect::>(), ["Line\nbreak \"quoted\"", "Paste"]); + let ja = catalog_keys(table); + assert_eq!(ja.iter().map(String::as_str).collect::>(), ["Long …", "Paste"]); + + let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)])); + assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}"); + assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}"); + assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}"); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 0c9f5547d..fe54b0f4e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,6 +6,7 @@ #![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] mod assets; +mod i18n_report; mod ico; mod keytips; mod layers; @@ -21,11 +22,12 @@ const USAGE: &str = "\ usage: cargo xtask commands: - ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check, - keytips --check (stops at the first failure; the parity check runs only if - docs/parity-checklist.md exists) + ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, + parity --check, keytips --check (stops at the first failure; the parity check runs + only if docs/parity-checklist.md exists) test workspace tests (cargo nextest when installed, else cargo test), then doctests assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md + i18n-report list the interface strings each language doesn't translate yet (always passes) layers enforce the crate dependency layering (AGENTS.md \"Layering\") wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call parity [--check] [--out PATH] @@ -44,6 +46,7 @@ fn main() -> ExitCode { let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect(); let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), + Some("i18n-report") => i18n_report::run(&root()), Some("layers") => cmd_layers(), Some("wasm") => cmd_wasm(), Some("test") => cmd_test().and_then(|()| cmd_doctest()), @@ -194,6 +197,8 @@ fn cmd_doctest() -> Result<(), String> { fn cmd_ci() -> Result<(), String> { type Step = (&'static str, Box Result<(), String>>); let steps: Vec = vec![ + // Missing translations never fail the gate; the list goes first so every log shows it. + ("i18n-report", Box::new(|| i18n_report::run(&root()))), ( "fmt", Box::new(|| { From c746eea8764eb5919b1b0ff4ffc55e02940d5407 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:43:22 +0200 Subject: [PATCH 059/149] Fix MOD returning wrong sign and magnitude for tiny divisors Found while testing crates/functions/src/math.rs:305-319 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/math.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 7611dc410..998a1c069 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -306,8 +306,8 @@ fn modulo(x: f64, d: f64) -> R { if d == 0.0 { return Err(CellError::Div0); } - let q = (x / d).floor(); - let r = x - d * q; + // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. + let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. if r != 0.0 && (r < 0.0) != (d < 0.0) { return Ok(r + d); @@ -1442,6 +1442,19 @@ mod tests { close(ev1("ODD", 0.0), 1.0); } + #[test] + fn mod_tiny_divisor_stays_in_range() { + for (x, d) in [(3.0, 1e-300), (-1.0, 1e-300), (3.0, -1e-300), (-1.0, -1e-300)] { + match ev2("MOD", x, d) { + Value::Number(r) => { + assert!(r == 0.0 || (r < 0.0) == (d < 0.0), "MOD({x}, {d}) = {r}"); + assert!(r.abs() < d.abs(), "MOD({x}, {d}) = {r}"); + } + other => panic!("MOD({x}, {d}) = {other:?}"), + } + } + } + #[test] fn arithmetic() { close(ev2("MOD", 3.0, 2.0), 1.0); From d5aa6fbcbcabaf195c904202bcf3c44a5b094f9e Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 21:56:39 +0200 Subject: [PATCH 060/149] Conditional formatting: New Rule's formula box takes references clicked or dragged on the grid (#2348) --- crates/ui-egui/src/dialogs.rs | 84 +++++++++++++ crates/ui-egui/src/grid.rs | 22 +++- crates/ui-egui/tests/it/cf_rule_pick.rs | 160 ++++++++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 6 +- 5 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/cf_rule_pick.rs diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bfb203c16..8be7f55cb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -7,6 +7,7 @@ use egui::{Color32, Key, Sense, Stroke, StrokeKind, vec2}; use crate::icons::{self, Icon}; +use gridcraft_engine::core::{CellRef, RangeRef}; use serde_json::{Map, Value as Json, json}; use crate::SheetApp; @@ -44,6 +45,24 @@ pub struct Dialog { /// The dialog this one was opened from (Name Manager › New…): it comes back when this closes. back: Option>, focus_initialized: bool, + /// The formula box that takes references clicked or dragged on the grid (New Formatting Rule). + pick: Option, +} + +/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute +/// reference at the caret instead of selecting it, and a drag grows it into a range. +#[derive(Clone, Debug)] +struct RefPick { + key: &'static str, + /// The sheet the dialog was opened on: references to another sheet carry its name. + sheet: usize, + /// Caret (char index), mirrored from the text box while it has the keyboard. + caret: usize, + /// The reference last inserted (byte span), replaced by the next click or the drag. + point: Option<(usize, usize)>, + anchor: CellRef, + /// Give the box the keyboard back, caret after the reference, on the next frame. + refocus: bool, } impl Dialog { @@ -77,6 +96,43 @@ impl Dialog { self.values.insert(key.into(), value); } + /// Whether a click on the grid goes to a formula box of this dialog rather than the selection. + pub fn picks_references(&self) -> bool { + self.pick.is_some() + } + + /// A click (`extend` false) or a drag reaching `cell`: writes the absolute reference at the + /// formula box's caret, replacing the reference the press inserted, as in the formula bar's + /// point mode. An empty box starts with `=`. After an operand there is no place for a + /// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the + /// dialog's own. + pub fn pick_reference(&mut self, cell: CellRef, active_sheet: (usize, &str), extend: bool) { + let Some(pick) = self.pick.as_mut() else { return }; + let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string(); + let mut ed = crate::editor::EditState::new(pick.sheet, cell, text, true, false); + ed.caret = pick.caret.min(ed.text.chars().count()); + ed.point = pick.point; + if ed.text.trim().is_empty() { + ed.text = "=".into(); + ed.caret = 1; + ed.point = None; + } + if !ed.can_point() { + return; + } + if !extend { + pick.anchor = cell; + } + let range = abs(&RangeRef::new(pick.anchor, cell)); + let reference = + if active_sheet.0 == pick.sheet { range } else { format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet.1)) }; + ed.insert_ref(&reference); + pick.caret = ed.caret; + pick.point = ed.point; + pick.refocus = true; + self.values.insert(pick.key.into(), json!(ed.text)); + } + fn form(name: &str, title: &str, command: &'static str, fields: Vec, defaults: Json) -> Dialog { Dialog { name: name.into(), @@ -94,6 +150,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false, + pick: None, } } @@ -114,6 +171,7 @@ impl Dialog { picture_target: None, back: None, focus_initialized: false, + pick: None, } } @@ -690,6 +748,10 @@ impl Dialog { fields, json!({"preset": "lightRedFillDarkRedText", "period": "today"}), ); + if ty == "expression" { + let sheet = app.session.active().map(|doc| doc.wb.active_sheet).unwrap_or(0); + d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false }); + } d.values.insert("type".into(), json!(ty)); d.values.insert("operator".into(), p("operator")); d @@ -1634,6 +1696,25 @@ fn take_first_focus(d: &mut Dialog, r: &egui::Response) { d.focus_first_field = false; } +/// Keeps a picking formula box's caret, and gives it the keyboard back after a grid click put a +/// reference in it (the click took the focus), its caret after the reference. +fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) { + if typed { + pick.point = None; + } + if pick.refocus { + let mut state = egui::text_edit::TextEditState::load(&r.ctx, r.id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(pick.caret)))); + state.store(&r.ctx, r.id); + r.request_focus(); + pick.refocus = false; + } else if r.has_focus() + && let Some(range) = egui::text_edit::TextEditState::load(&r.ctx, r.id).and_then(|state| state.cursor.char_range()) + { + pick.caret = range.primary.index.into(); + } +} + /// Draws a dialog's `fields`, editing its values. fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { // Field labels, choices and notes are English source strings, translated as they are drawn. @@ -1659,6 +1740,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) { if r.changed() { d.values.insert(key.into(), json!(s)); } + if let Some(pick) = d.pick.as_mut().filter(|pick| pick.key == key) { + track_pick(pick, &r, r.changed()); + } }); } Field::Password { key, label } => { diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 590294904..157f608fe 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2336,6 +2336,19 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & app.grid.filter_menu = Some((c.col, c.row, geo.mirror_pos(p))); return; } + // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the + // selection, which the dialog acts on, stays. + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(c, (wb.active_sheet, &sh.name), false); + app.grid.drag = Drag::Select; + app.grid.drag_select = true; + if resp.drag_started() + && let Some(p) = pos + { + select_to_pointer(app, geo, sh, wb, p); + } + return; + } // Point mode while editing a formula: clicking inserts a reference. if let Some(ed) = app.editor.as_mut() && ed.can_point() @@ -2643,7 +2656,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & /// An armed Format Painter pastes onto the cells just clicked or dragged over (not while /// pointing a formula reference, which also drags a Select). fn format_painter_apply(app: &mut SheetApp) { - if app.session.format_painter.is_some() && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) { + if app.session.format_painter.is_some() + && !app.editor.as_ref().is_some_and(|ed| ed.can_point()) + && !app.dialog.as_ref().is_some_and(|d| d.picks_references()) + { let dest = app.session.active().map(|d| d.selection.current().a1()); if let Some(dest) = dest && let Err(e) = app.run("edit.formatPainter", json!({"apply": dest})) @@ -2696,7 +2712,9 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)), geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), ); - if let Some(ed) = app.editor.as_mut() + if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { + d.pick_reference(cell, (wb.active_sheet, &sh.name), true); + } else 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) { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs new file mode 100644 index 000000000..dc004ff25 --- /dev/null +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -0,0 +1,160 @@ +//! New Formatting Rule ▸ formula: clicking or dragging on the grid writes the absolute reference +//! into the formula box at its caret, as in Excel, and leaves the selection the rule applies to alone. + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2, vec2}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::{SheetApp, grid::Geo}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("home.insertSheet", json!({})).unwrap(); + session.execute("sheet.activate", json!({"sheet": 0})).unwrap(); + session.execute("selection.set", json!({"range": "F8:G9"})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_step_dt(1.0 / 60.0).with_size(vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h.state_mut().open_dialog("cfQuick", json!({"type": "expression", "title": "New Formatting Rule"})); + h.run_steps(6); + h +} + +fn formula(h: &Harness) -> String { + h.state().dialog.as_ref().and_then(|d| d.values.get("formula")).and_then(|v| v.as_str()).unwrap_or("").to_string() +} + +fn selection(h: &Harness) -> String { + h.state().session.active().unwrap().selection.current().a1() +} + +fn type_text(h: &mut Harness, text: &str) { + for c in text.chars() { + h.input_mut().events.push(Event::Text(c.to_string())); + h.run_steps(1); + } +} + +fn cell_center(h: &Harness, a1: &str) -> Pos2 { + let app = h.state(); + let sheet = app.session.active().unwrap().wb.active().unwrap(); + let geo = Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll); + let c = gridcraft_engine::core::CellRef::parse(a1).unwrap(); + pos2(geo.x(sheet, c.col) + sheet.col_width(c.col) * geo.z * 0.5, geo.y(sheet, c.row) + sheet.row_height(c.row) * geo.z * 0.5) +} + +fn click(h: &mut Harness, a1: &str) { + let pos = cell_center(h, a1); + for pressed in [true, false] { + h.input_mut() + .events + .extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + h.step(); + } + h.run_steps(3); +} + +fn drag(h: &mut Harness, from: &str, to: &str) { + let (a, b) = (cell_center(h, from), cell_center(h, to)); + h.input_mut() + .events + .extend([Event::PointerMoved(a), Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }]); + h.step(); + for i in 1..=6 { + let p = a + (b - a) * (i as f32 / 6.0); + h.input_mut().events.push(Event::PointerMoved(p)); + h.step(); + } + h.input_mut().events.push(Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.step(); + h.run_steps(3); +} + +#[test] +fn clicking_a_cell_after_equals_inserts_its_absolute_reference() { + let mut h = harness(); + type_text(&mut h, "="); + assert_eq!(formula(&h), "=", "the formula box had the keyboard"); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=$B$2"); + assert_eq!(selection(&h), "F8:G9", "the range the rule applies to is still selected"); +} + +#[test] +fn clicking_a_cell_in_an_empty_box_starts_the_formula() { + let mut h = harness(); + click(&mut h, "C3"); + assert_eq!(formula(&h), "=$C$3"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn a_second_click_replaces_the_reference_just_inserted() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + click(&mut h, "D5"); + assert_eq!(formula(&h), "=$D$5"); +} + +#[test] +fn dragging_inserts_a_range() { + let mut h = harness(); + type_text(&mut h, "="); + drag(&mut h, "B2", "C4"); + assert_eq!(formula(&h), "=$B$2:$C$4"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn the_reference_goes_at_the_caret_and_typing_continues_after_it() { + let mut h = harness(); + type_text(&mut h, "=SUM()"); + h.key_press(Key::ArrowLeft); + h.run_steps(2); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=SUM($B$2)"); + type_text(&mut h, "+1"); + assert_eq!(formula(&h), "=SUM($B$2+1)", "the box has the keyboard back, its caret after the reference"); +} + +#[test] +fn a_click_after_an_operand_changes_nothing() { + let mut h = harness(); + type_text(&mut h, "=A1>5"); + click(&mut h, "B2"); + assert_eq!(formula(&h), "=A1>5"); + assert_eq!(selection(&h), "F8:G9"); +} + +#[test] +fn ok_applies_the_picked_formula_to_the_selection() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + type_text(&mut h, ">0"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let sheet = h.state().session.active().unwrap().wb.active().unwrap(); + let cf = sheet.cond_formats.last().expect("a rule was added"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); + assert!(format!("{:?}", cf.rule).contains("$B$2>0"), "rule: {:?}", cf.rule); +} + +#[test] +fn other_dialogs_leave_grid_clicks_alone() { + let mut h = harness(); + h.state_mut().dialog = None; + h.state_mut().open_dialog("cfQuick", json!({"type": "containsText", "title": "Text that Contains"})); + h.run_steps(6); + click(&mut h, "B2"); + assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index b89253bb9..94d654089 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -10,6 +10,7 @@ mod autosave_persist; mod borders; mod cell_editor_caret; mod cell_pictures; +mod cf_rule_pick; mod chart_resize; mod clipboard; mod comments_pane; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c7cbc349e..4795fb196 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 (Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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 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 @@ -152,6 +152,9 @@ Seek, Scenario Manager, Data Analysis, Forecast Sheet and others exist. Missing Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges, Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its title bar, keeping its place while it is open (#1250). +New Formatting Rule's formula box points like the formula bar: a click on the grid writes the +cell's absolute reference at the caret (`=$B$2`) and a drag a range, leaving the selection the rule +applies to as it is (#2348). Other dialogs' reference fields don't take clicks yet. ## Accessibility @@ -175,6 +178,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | | 2026-10-22 | minor | Show Outline Symbols (`view.showOutlineSymbols`, Ctrl+8): a sheet can hide the outline's level buttons and brackets while keeping its outline, as Excel saves with `showOutlineSymbols="0"` (read and written); the grid then gives the bands no room | From 45f06f50da6c32fd2f76b8a9de0673d5dd2e3675 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:05:24 +0200 Subject: [PATCH 061/149] Conditional formatting: New Rule keeps the range it was opened for when pointing on another sheet (#2348) --- crates/ui-egui/src/dialogs.rs | 15 +++++++++++++-- crates/ui-egui/tests/it/cf_rule_pick.rs | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8be7f55cb..95855e56a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -63,6 +63,9 @@ struct RefPick { anchor: CellRef, /// Give the box the keyboard back, caret after the reference, on the next frame. refocus: bool, + /// The selection the rule applies to, fixed when the dialog opened as Excel's "Applies to": + /// pointing on another sheet must not move the rule there. + applies_to: String, } impl Dialog { @@ -749,8 +752,12 @@ impl Dialog { json!({"preset": "lightRedFillDarkRedText", "period": "today"}), ); if ty == "expression" { - let sheet = app.session.active().map(|doc| doc.wb.active_sheet).unwrap_or(0); - d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false }); + let (sheet, applies_to) = app + .session + .active() + .map(|doc| (doc.wb.active_sheet, doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","))) + .unwrap_or_default(); + d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to }); } d.values.insert("type".into(), json!(ty)); d.values.insert("operator".into(), p("operator")); @@ -1177,6 +1184,10 @@ pub(crate) fn build_params(d: &Dialog) -> Json { rule.insert(k.clone(), val.clone()); } v = json!({"rule": Json::Object(rule)}); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty()) { + v["sheet"] = json!(pick.sheet); + v["range"] = json!(pick.applies_to); + } } "series" => { if let Some(s) = d.values.get("stop").and_then(Json::as_str).and_then(|s| s.parse::().ok()) { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs index dc004ff25..642a91a18 100644 --- a/crates/ui-egui/tests/it/cf_rule_pick.rs +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -158,3 +158,20 @@ fn other_dialogs_leave_grid_clicks_alone() { click(&mut h, "B2"); assert_eq!(selection(&h), "B2", "a box that is not a formula does not take references"); } + +#[test] +fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() { + let mut h = harness(); + type_text(&mut h, "="); + h.state_mut().session.execute("sheet.activate", json!({"sheet": 1})).unwrap(); + h.run_steps(3); + click(&mut h, "A1"); + assert_eq!(formula(&h), "=Sheet2!$A$1"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let wb = &h.state().session.active().unwrap().wb; + assert!(wb.sheet(1).unwrap().cond_formats.is_empty(), "nothing lands on the sheet pointed at"); + let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); +} From 7cf44d0fbbb853617a09a608d2fe988b64f5ccac Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 23 Oct 2026 16:05:43 -0400 Subject: [PATCH 062/149] Integration and review fixes for #2367, #2369 and #2387 - #2369: its test joins the single ui-egui integration-test binary (tests/it), as main now builds them. - #2367: a cell set to empty text is a blank entry and doesn't grow the AutoFilter (CodeRabbit). - #2387: MOD's range check also covers the sign-corrected remainder (CodeRabbit). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/data.rs | 5 ++++- crates/functions/src/math.rs | 4 +--- crates/ui-egui/tests/{ => it}/contextual_ribbon_tab.rs | 0 crates/ui-egui/tests/it/main.rs | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) rename crates/ui-egui/tests/{ => it}/contextual_ribbon_tab.rs (100%) diff --git a/crates/engine/src/cmd/data.rs b/crates/engine/src/cmd/data.rs index 8fe74779c..8f7c47fb8 100644 --- a/crates/engine/src/cmd/data.rs +++ b/crates/engine/src/cmd/data.rs @@ -656,7 +656,7 @@ fn filter_blank(v: &Value) -> bool { /// A table's own filter is left to the table, and a row typed after a blank row does not count. /// A formula counts before it is calculated: this runs inside the edit, ahead of the recalc. pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) { - if !sh.cells.get(at).is_some_and(|c| !c.value.is_empty() || c.formula.is_some()) { + if !sh.cells.get(at).is_some_and(|c| !filter_blank(&c.value) || c.formula.is_some()) { return; } let Some(af) = sh.autofilter.as_ref() else { return }; @@ -1589,6 +1589,9 @@ mod tests { // A cell outside the filter's columns does not either. s.execute("cell.set", json!({"cell": "C6", "input": "x"})).unwrap(); assert_eq!(range(&s), Some("A1:B5".to_string())); + // Empty text is a blank entry, not a value. + s.execute("range.setValues", json!({"range": "A6", "parse": false, "values": [[""]]})).unwrap(); + assert_eq!(range(&s), Some("A1:B5".to_string())); // A formula and Ctrl+Enter (range.fill) are entries too. s.execute("cell.set", json!({"cell": "B6", "input": "=1+1"})).unwrap(); assert_eq!(range(&s), Some("A1:B6".to_string())); diff --git a/crates/functions/src/math.rs b/crates/functions/src/math.rs index 998a1c069..53a92f00f 100644 --- a/crates/functions/src/math.rs +++ b/crates/functions/src/math.rs @@ -309,9 +309,7 @@ fn modulo(x: f64, d: f64) -> R { // `%` is an exact remainder (truncated), so |r| < |d| even for tiny divisors. let r = x % d; // Keep the sign of the divisor and avoid returning |d| from rounding. - if r != 0.0 && (r < 0.0) != (d < 0.0) { - return Ok(r + d); - } + let r = if r != 0.0 && (r < 0.0) != (d < 0.0) { r + d } else { r }; if r.abs() >= d.abs() { return Ok(0.0); } diff --git a/crates/ui-egui/tests/contextual_ribbon_tab.rs b/crates/ui-egui/tests/it/contextual_ribbon_tab.rs similarity index 100% rename from crates/ui-egui/tests/contextual_ribbon_tab.rs rename to crates/ui-egui/tests/it/contextual_ribbon_tab.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 9b04d3869..fc00fb520 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -16,6 +16,7 @@ mod comments_pane; mod commit_after_pointing; mod committed_text_fields; mod context_menu_stays_open; +mod contextual_ribbon_tab; mod control_timeout; mod dialog_button_row; mod dialog_confirm; From 4b2ee2e2dd8c4e71793fa8a658242627e356fd22 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 23 Oct 2026 22:07:22 +0200 Subject: [PATCH 063/149] Conditional formatting: New Rule remembers its sheet by name, so moving or inserting sheets while it is open keeps the rule there (#2348) --- crates/ui-egui/src/dialogs.rs | 27 ++++++++++++++++--------- crates/ui-egui/src/grid.rs | 4 ++-- crates/ui-egui/tests/it/cf_rule_pick.rs | 20 ++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 95855e56a..e83db2fa1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -54,8 +54,9 @@ pub struct Dialog { #[derive(Clone, Debug)] struct RefPick { key: &'static str, - /// The sheet the dialog was opened on: references to another sheet carry its name. - sheet: usize, + /// The sheet the dialog was opened on, by name: its index changes when sheets are inserted or + /// moved while the dialog is open. References to another sheet carry that sheet's name. + sheet: String, /// Caret (char index), mirrored from the text box while it has the keyboard. caret: usize, /// The reference last inserted (byte span), replaced by the next click or the drag. @@ -109,10 +110,10 @@ impl Dialog { /// point mode. An empty box starts with `=`. After an operand there is no place for a /// reference, so nothing changes. `active_sheet` names the sheet shown when it is not the /// dialog's own. - pub fn pick_reference(&mut self, cell: CellRef, active_sheet: (usize, &str), extend: bool) { + pub fn pick_reference(&mut self, cell: CellRef, active_sheet: &str, extend: bool) { let Some(pick) = self.pick.as_mut() else { return }; let text = self.values.get(pick.key).and_then(Json::as_str).unwrap_or("").to_string(); - let mut ed = crate::editor::EditState::new(pick.sheet, cell, text, true, false); + let mut ed = crate::editor::EditState::new(0, cell, text, true, false); ed.caret = pick.caret.min(ed.text.chars().count()); ed.point = pick.point; if ed.text.trim().is_empty() { @@ -127,8 +128,11 @@ impl Dialog { pick.anchor = cell; } let range = abs(&RangeRef::new(pick.anchor, cell)); - let reference = - if active_sheet.0 == pick.sheet { range } else { format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet.1)) }; + let reference = if active_sheet.eq_ignore_ascii_case(&pick.sheet) { + range + } else { + format!("{}!{range}", gridcraft_engine::formula::quote_sheet(active_sheet)) + }; ed.insert_ref(&reference); pick.caret = ed.caret; pick.point = ed.point; @@ -755,7 +759,12 @@ impl Dialog { let (sheet, applies_to) = app .session .active() - .map(|doc| (doc.wb.active_sheet, doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","))) + .map(|doc| { + ( + doc.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), + doc.selection.ranges.iter().map(RangeRef::a1).collect::>().join(","), + ) + }) .unwrap_or_default(); d.pick = Some(RefPick { key: "formula", sheet, caret: 0, point: None, anchor: CellRef::new(0, 0), refocus: false, applies_to }); } @@ -1184,8 +1193,8 @@ pub(crate) fn build_params(d: &Dialog) -> Json { rule.insert(k.clone(), val.clone()); } v = json!({"rule": Json::Object(rule)}); - if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty()) { - v["sheet"] = json!(pick.sheet); + if let Some(pick) = d.pick.as_ref().filter(|pick| !pick.applies_to.is_empty() && !pick.sheet.is_empty()) { + v["sheet"] = json!(pick.sheet.as_str()); v["range"] = json!(pick.applies_to); } } diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs index 157f608fe..87ac5787e 100644 --- a/crates/ui-egui/src/grid.rs +++ b/crates/ui-egui/src/grid.rs @@ -2339,7 +2339,7 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: & // A dialog's formula box in point mode (New Formatting Rule) takes the reference; the // selection, which the dialog acts on, stays. if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(c, (wb.active_sheet, &sh.name), false); + d.pick_reference(c, &sh.name, false); app.grid.drag = Drag::Select; app.grid.drag_select = true; if resp.drag_started() @@ -2713,7 +2713,7 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0)), ); if let Some(d) = app.dialog.as_mut().filter(|d| d.picks_references()) { - d.pick_reference(cell, (wb.active_sheet, &sh.name), true); + d.pick_reference(cell, &sh.name, true); } else if let Some(ed) = app.editor.as_mut() && ed.point.is_some() { diff --git a/crates/ui-egui/tests/it/cf_rule_pick.rs b/crates/ui-egui/tests/it/cf_rule_pick.rs index 642a91a18..a523c9c6d 100644 --- a/crates/ui-egui/tests/it/cf_rule_pick.rs +++ b/crates/ui-egui/tests/it/cf_rule_pick.rs @@ -175,3 +175,23 @@ fn pointing_on_another_sheet_keeps_the_rule_on_the_sheet_it_was_opened_for() { let cf = wb.sheet(0).unwrap().cond_formats.last().expect("the rule is on the sheet the dialog was opened on"); assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); } + +#[test] +fn the_rule_follows_its_sheet_when_sheets_move_while_the_dialog_is_open() { + let mut h = harness(); + type_text(&mut h, "="); + click(&mut h, "B2"); + h.state_mut().session.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap(); + h.run_steps(3); + let names = |h: &Harness| h.state().session.active().unwrap().wb.sheets.iter().map(|s| s.name.clone()).collect::>(); + assert_eq!(names(&h), ["Sheet2", "Sheet1"], "Sheet1 now sits at index 1"); + h.key_press(Key::Enter); + h.run_steps(4); + assert!(h.state().dialog.is_none(), "Enter confirmed the rule"); + let wb = &h.state().session.active().unwrap().wb; + let sheet1 = wb.sheets.iter().find(|s| s.name == "Sheet1").unwrap(); + let sheet2 = wb.sheets.iter().find(|s| s.name == "Sheet2").unwrap(); + assert!(sheet2.cond_formats.is_empty(), "nothing lands on the sheet that took index 0"); + let cf = sheet1.cond_formats.last().expect("the rule stays on Sheet1"); + assert_eq!(cf.ranges.iter().map(|r| r.a1()).collect::>(), ["F8:G9"]); +} From 2329507a8e14e31fd559d44035329052d4768d28 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 23 Oct 2026 16:51:16 -0400 Subject: [PATCH 064/149] ci(sonar): don't cancel superseded runs on main, so coverage is published --- .github/workflows/sonarcloud.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index ecee45a6d..e78fe1ea3 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -11,7 +11,8 @@ permissions: concurrency: group: sonarcloud-${{ github.ref }} - cancel-in-progress: true + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: sonarcloud: From 1ce4781d3e059e14696cd4a351f61e4242062767 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:24:18 -0500 Subject: [PATCH 065/149] Accuracy: PMT to Excel's last bits via ln_1p/exp_m1 PMT computed (1 + rate)^nper with powf, which rounds 1 + rate first and drops the low bits of a small rate. Over 400 synthetic probes that left PMT up to about 1000 ULPs from Excel (59 bit-exact); the ln_1p/exp_m1 form is within 4 ULPs (244 bit-exact). A loan schedule guarded by IF(p - SUM(principal) > 0, ...) turns on those bits: in 3000 synthetic schedules main disagreed with Excel on the number of live periods in 1382, the fix in 823. Also follows Excel at the edges: a rate of -1 or less is #NUM!, a huge nper gives -pv*rate instead of #NUM!, a tiny rate no longer collapses, and a small negative rate keeps its payment. IPMT, PPMT, CUMIPMT and CUMPRINC pick this up through pmt_calc. FV and PV are unchanged: Excel's own FV/PV sit further from the exact value than the current powf form does. The tests allow the one-ULP libm difference between platforms. --- crates/calc/src/tests.rs | 37 ++++++++++++++++++++ crates/functions/src/financial.rs | 58 +++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 622ed6cc9..e71015213 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1781,3 +1781,40 @@ fn text_before_after_empty_instance_defaults_to_one() { t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)"); assert_eq!(t.get("A4"), Value::Error(CellError::Value)); } + +#[test] +fn loan_schedule_residue_keeps_excels_sign() { + // A synthetic monthly loan schedule: interest on the opening balance, principal = PMT - interest, + // each later period guarded by IF(p - SUM(principal so far) > 0, ...). After the 12th payment + // Excel is left with 2^-30 > 0, so it takes a 13th, tiny-interest period. That last bit comes + // from PMT; the old (1 + rate)^nper form left -2.8e-8 and returned "" instead. + let mut t = T::new(); + t.set("B1", "4653300"); + t.set("B2", "1"); + t.set("B3", "0.141"); + let col = |i: u8| char::from(b'A' + i).to_string(); + t.set("C8", "=$B$1*($B$3/12)"); + t.set("C7", "=PMT($B$3/12,$B$2*12,-$B$1)-C8"); + t.set("D8", "=($B1-C7)*($B$3/12)"); + t.set("D7", "=PMT($B$3/12,$B$2*12,-$B$1)-D8"); + for i in 4..16 { + let (c, p) = (col(i), col(i - 1)); + t.set(&format!("{c}8"), &format!("=IF($B1-SUM($C7:{p}7)>0,($B1-SUM($C7:{p}7))*($B$3/12),\"\")")); + t.set(&format!("{c}7"), &format!("=IF($B1-SUM($C7:{p}7)>0,PMT($B$3/12,$B$2*12,-$B$1)-{c}8,\"\")")); + } + t.set("N4", "=IF($B$1-SUM($C7:N7)>0,$B$1-SUM($C7:N7),\"\")"); + // Excel's values (Value2) for the same synthetic inputs. + // The leftover (2^-30 in Excel) is 12 payments' worth of rounding, so a one-ULP libm difference + // moves it by as much as its own size: only its sign is checked, and the 13th period must exist. + let positive = |cell: &str| match t.get(cell) { + Value::Number(x) => assert!(x > 0.0 && x < 1e-6, "{cell}: {x}"), + other => panic!("{cell}: expected a small positive number, got {other:?}"), + }; + positive("N4"); + positive("O8"); + match t.get("O7") { + Value::Number(x) => assert!((x - 418025.37230211904).abs() < 1e-3, "O7: {x}"), + other => panic!("O7: expected a number, got {other:?}"), + } + assert_eq!(t.get("P7"), Value::Text("".into())); +} diff --git a/crates/functions/src/financial.rs b/crates/functions/src/financial.rs index af28326e7..043c09ed6 100644 --- a/crates/functions/src/financial.rs +++ b/crates/functions/src/financial.rs @@ -13,12 +13,31 @@ use crate::{Arg, Ctx, FnSpec, VAR}; // Time value of money // --------------------------------------------------------------------------------------------- +/// The payment per period. `(1 + rate)^nper` comes from `nper * ln_1p(rate)` and `p - 1` from +/// `exp_m1`: rounding `1 + rate` first throws away the low bits of a small rate, which left PMT up to +/// about 1000 ULPs from Excel (observed over 400 synthetic probes; this form is within 4). A loan +/// schedule's last `IF(p - SUM(principal) > 0, ...)` turns on those bits. Excel gives #NUM! for a +/// rate of -1 or less, and -pv*rate (no divide) once `(1 + rate)^nper` overflows. fn pmt_calc(rate: f64, nper: f64, pv: f64, fv: f64, typ: f64) -> f64 { if rate == 0.0 { return -(pv + fv) / nper; } - let p = (1.0 + rate).powf(nper); - -(rate * (fv + pv * p)) / ((1.0 + rate * typ) * (p - 1.0)) + if rate <= -1.0 { + return f64::NAN; + } + let l = nper * rate.ln_1p(); + let m = l.exp_m1(); + let k = 1.0 + rate * typ; + if m > 0.0 { + // (fv + pv*p) / (p - 1) = pv + (pv + fv) / (p - 1), without the cancellation in p - 1. + -(pv + (pv + fv) / m) * rate / k + } else if m > -0.5 { + // p near 1 from below: fv + pv*p = (pv + fv) + pv*m keeps the small terms that rounding p would drop. + -(rate * (pv + fv + pv * m)) / (k * m) + } else { + // p well below 1: m nears -1 and pv*m would swallow pv + fv, so use p itself. + -(rate * (fv + pv * l.exp())) / (k * m) + } } fn fv_calc(rate: f64, nper: f64, pmt: f64, pv: f64, typ: f64) -> f64 { @@ -1790,6 +1809,41 @@ mod tests { is_err(ev("CUMIPMT", vec![n(0.01), n(360.0), n(125000.0), n(1.0), n(1.0), n(2.0)]), CellError::Num); } + /// PMT within about 4 ULPs of Excel's (Value2 of synthetic probes; libm differs by an ULP across + /// platforms). The old `(1 + rate)^nper` form was 25 to 91 ULPs off on these, enough to flip a loan schedule's final `IF(p - SUM(r) > 0, ...)`. + #[test] + fn pmt_matches_excel_within_a_few_ulps() { + for (rate, nper, pv, fv, typ, excel) in [ + (0.141 / 12.0, 12.0, -4653300.0, 0.0, 0.0, 418025.37230211904), + (0.005716666666666666, 26.0, 42678411.0, -26010.62, 0.0, -1770235.3828507778), + (0.012164, 29.0, 16722649.0, 0.0, 1.0, -679516.0784762775), + (0.009916666666666666, 5.0, 8590842.8, 0.0, 0.0, -1769620.32624324), + (0.011158333333333333, 36.0, 21457108.0, 49218.26, 1.0, -720095.3912155787), + (-0.5, 2.5, 100.0, 0.0, 0.0, -10.736861692729645), + ] { + let got = ev("PMT", vec![n(rate), n(nper), n(pv), n(fv), n(typ)]); + close_tol(got, excel, 1e-15); + } + } + + /// Edge cases observed in Excel: a rate of -1 or less is #NUM!, a huge nper leaves -pv*rate, and a + /// tiny rate no longer loses its low digits in 1 + rate. + #[test] + fn pmt_edge_cases_follow_excel() { + is_err(ev("PMT", vec![n(-2.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-1.0), n(3.0), n(100.0)]), CellError::Num); + is_err(ev("PMT", vec![n(-2.0), n(2.5), n(100.0)]), CellError::Num); + assert_eq!(ev("PMT", vec![n(0.1), n(10000.0), n(100.0)]), Value::Number(-10.0)); + assert_eq!(ev("PMT", vec![n(5.0), n(1000.0), n(100.0)]), Value::Number(-500.0)); + assert_eq!(ev("PMT", vec![n(1e-300), n(10.0), n(100.0)]), Value::Number(-10.0)); + close_tol(ev("PMT", vec![n(1e-12), n(10.0), n(100.0)]), -10.000000000055001, 1e-15); + close_tol(ev("PMT", vec![n(-0.999), n(5.0), n(100.0)]), -9.990_000_000_000_092e-14, 1e-15); + close_tol(ev("PMT", vec![n(0.05), n(-10.0), n(100.0)]), 7.950_457_496_545_667, 1e-15); + close_tol(ev("PMT", vec![n(-1e-17), n(1.0), n(1e18), n(-1e18)]), 10.0, 1e-9); + close_tol(ev("PMT", vec![n(-0.5), n(60.0), n(1e18)]), -0.433_680_868_994_201_8, 1e-6); + close_tol(ev("PMT", vec![n(0.05), n(10.0), n(100.0), n(50.0), n(1.0)]), -16.119701185541427, 1e-15); + } + #[test] fn cash_flows() { close_tol(ev("NPV", vec![n(0.1), n(-10000.0), n(3000.0), n(4200.0), n(6800.0)]), 1188.443412, 1e-9); 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 066/149] 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 067/149] 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 b47c83f6aa3b730c9bc2fce74913ee1121f199cf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:48:12 +0000 Subject: [PATCH 068/149] Reject unmatched '.' and ',' in NUMBERVALUE text Found while testing crates/functions/src/text.rs:326-340 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/functions/src/text.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/functions/src/text.rs b/crates/functions/src/text.rs index b9b06e608..2d7fca13a 100644 --- a/crates/functions/src/text.rs +++ b/crates/functions/src/text.rs @@ -334,6 +334,8 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R { if seen_dec { return Err(CellError::Value); } + } else if c == '.' || c == ',' { + return Err(CellError::Value); } else { out.push(c); } @@ -1216,6 +1218,8 @@ mod tests { close(ev("NUMBERVALUE", vec![t(" 1 000 ")]), 1000.0); close(ev("NUMBERVALUE", vec![t("")]), 0.0); is_err(ev("NUMBERVALUE", vec![t("1.2.3")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1.5"), t(","), t(" ")]), CellError::Value); + is_err(ev("NUMBERVALUE", vec![t("1,5"), t("."), t(" ")]), CellError::Value); is_text(ev("FIXED", vec![n(1234.567), n(1.0)]), "1,234.6"); is_text(ev("FIXED", vec![n(1234.567), n(-1.0)]), "1,230"); is_text(ev("FIXED", vec![n(-1234.567), n(1.0), b(true)]), "-1234.6"); From b389d9067a46539540bc6007b7bb81630d80bff9 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:32 +0800 Subject: [PATCH 069/149] XLSX: write accounting formats 42 and 44 with their code builtin_id already wrote the currency formats 5-8 as custom formats because readers resolve those ids by locale. The accounting formats with a currency, 42 and 44, are resolved the same way but were written as bare ids, so LibreOffice showed a 44 cell holding -0.14 as "- 0.14" where US Excel shows "$ (0.14)". Treat them like 5-8. --- crates/xlsx/src/tables.rs | 7 +-- crates/xlsx/src/tests/locale_numfmt.rs | 61 ++++++++++++++++++++++++++ crates/xlsx/src/tests/mod.rs | 1 + 3 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 crates/xlsx/src/tests/locale_numfmt.rs diff --git a/crates/xlsx/src/tables.rs b/crates/xlsx/src/tables.rs index 141d8673a..23c936c4a 100644 --- a/crates/xlsx/src/tables.rs +++ b/crates/xlsx/src/tables.rs @@ -46,10 +46,11 @@ pub fn builtin_format(id: u32) -> Option<&'static str> { BUILTIN_FORMATS.iter().find(|(i, _)| *i == id).map(|(_, c)| *c) } -/// Id to write for a format code when it is a locale-independent built-in (currency formats 5–8 -/// are written as custom formats because their meaning depends on the reader's locale). +/// Id to write for a format code when it is a locale-independent built-in. The currency formats +/// 5–8 and the accounting formats with a currency, 42 and 44, are written as custom formats because +/// their meaning depends on the reader's locale. pub fn builtin_id(code: &str) -> Option { - BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !(5..=8).contains(i)).map(|(i, _)| *i) + BUILTIN_FORMATS.iter().find(|(i, c)| *c == code && !matches!(i, 5..=8 | 42 | 44)).map(|(i, _)| *i) } pub use gridcraft_core::color::INDEXED; diff --git a/crates/xlsx/src/tests/locale_numfmt.rs b/crates/xlsx/src/tests/locale_numfmt.rs new file mode 100644 index 000000000..3259a3657 --- /dev/null +++ b/crates/xlsx/src/tests/locale_numfmt.rs @@ -0,0 +1,61 @@ +//! Number formats whose meaning depends on the reader's locale are written with their code: the +//! currency formats 5–8 and the accounting formats with a currency, 42 and 44. Written as a bare +//! built-in id, LibreOffice and Excel in other locales show their own version (a 44 cell holding +//! -0.14 reads `$ (0.14)` in US Excel but `- 0.14` in LibreOffice). + +use std::io::Read; +use std::sync::Arc; + +use gridcraft_core::{CellRef, Value}; +use gridcraft_model::*; + +use crate::tables::builtin_format; +use crate::{read_xlsx, write_xlsx}; + +fn styles_xml(bytes: &[u8]) -> String { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut s = String::new(); + zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut s).unwrap(); + s +} + +/// Writes one cell formatted with `code`; returns the `numFmtId` its cell format points at, the +/// `styles.xml` and the code read back. +fn round_trip(code: &str) -> (u32, String, String) { + let mut wb = Workbook::new(); + let style = wb.styles.derive(StyleId::DEFAULT, |s| s.num_fmt = NumFmt::new(code)); + let mut sheet = Sheet::new("Sheet1"); + sheet.cells.set(CellRef::new(0, 0), Cell { value: Value::Number(-0.14), formula: None, style }); + wb.sheets = vec![Arc::new(sheet)]; + let bytes = write_xlsx(&wb).unwrap(); + let xml = styles_xml(&bytes); + let xfs = &xml[xml.find("().unwrap()) + .find(|&id| id != 0) + .unwrap(); + let (back, _) = read_xlsx(&bytes).unwrap(); + let st = back.styles.get(back.sheet(0).unwrap().cell(CellRef::new(0, 0)).unwrap().style); + (id, xml, st.num_fmt.as_str().to_string()) +} + +#[test] +fn currency_and_accounting_formats_are_written_with_their_code() { + for builtin in [5, 6, 7, 8, 42, 44] { + let code = builtin_format(builtin).unwrap(); + let (id, xml, back) = round_trip(code); + assert!(id >= 164, "format {builtin} written as built-in id {id}"); + assert!(xml.contains(&format!(" for id {id}"); + assert_eq!(back, code, "format {builtin}"); + } +} + +#[test] +fn locale_independent_built_ins_keep_their_id() { + for builtin in [3, 4, 10, 14, 41, 43, 49] { + let (id, _, back) = round_trip(builtin_format(builtin).unwrap()); + assert_eq!(id, builtin); + assert_eq!(back, builtin_format(builtin).unwrap()); + } +} diff --git a/crates/xlsx/src/tests/mod.rs b/crates/xlsx/src/tests/mod.rs index c8f199a47..a7f6f1eb7 100644 --- a/crates/xlsx/src/tests/mod.rs +++ b/crates/xlsx/src/tests/mod.rs @@ -11,6 +11,7 @@ mod encrypted_tests; mod fixtures; mod ink_roundtrip; mod literal_category_roundtrip; +mod locale_numfmt; mod malformed; mod multilevel_category_roundtrip; mod note_box; From e93d15b50a00d87ff70ab1523bf4d604b3ead149 Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:17:32 +0800 Subject: [PATCH 070/149] Contributors: credit helenkwok as Helen Kwok --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 88e496fc7..43252507d 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -96,3 +96,6 @@ sync_github_name = true [people.tyrel] real_name = "Tyrel Haveman" + +[people.helenkwok] +real_name = "Helen Kwok" From 6ead1fb51936f7d368c09e8ce68806f51589b6c3 Mon Sep 17 00:00:00 2001 From: Pranav <53838599+prxnvt@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:06:44 -0400 Subject: [PATCH 071/149] Engine: building blocks for multi-command actions (undo groups, recording buffer, document trust, chunked hidden names) (#462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Engine: undo groups make several commands one undo step Session::grouped(label, f) runs f as one undo step on the active document. On success the group leaves exactly one undo entry holding the workbook from before the group; on error it restores the workbook, selection, undo and redo stacks as they were and returns the error. group_begin, group_end and group_rollback do the same across separate calls, for an action that runs one step per call. While a group is open on a document, undoable commands on it add no undo steps of their own, so a long group neither grows the history nor pushes older entries past the undo limit, and Undo and Redo are disabled there. Groups nest: the outermost one makes the step, and a failing inner group rolls back only its own changes. Closing a document drops its groups, and a command that panics drops the groups it opened. history.list reports whether a group is open. Insert Copied Cells folds its nested steps into one undo step by counting revisions; inside a group the nested steps push no entries, so it now leaves the history alone there instead of removing entries from before the group. * Engine: a recording buffer of top-level commands, apart from the journal A macro recorder needs more than a journal index: the journal is trimmed past 10,000 entries, it also holds the commands that other commands run (so playback would run them twice), and it keeps no selection, which relative references need. Session now tracks how deeply execute calls are nested. While recording, a buffer (record.rs) keeps each successful, journaled top-level call with the document uid, the active sheet (index and name) and the selection it ran in, then its id and parameters without secrets, as the journal stores them. Queries, failed commands and the command that starts recording are not kept. The buffer holds up to 100,000 steps and counts any it drops. Session::start_recording, stop_recording, is_recording and depth are the interface. Automate › Record Actions keeps recording through the journal as before. * Engine: document trust for workbooks opened from files Active content such as macros must not run only because a workbook was opened. DocState gets a trust state: Local for workbooks made in the session (new workbooks and samples) and FromFile for anything opened with file.open. Active content in a FromFile workbook asks before it runs. A second window on a workbook shares its trust. The clipboard carries the trust of its source document. Pasting formulas, formats (conditional formats hold formulas) or validation from a FromFile workbook into another workbook makes that workbook FromFile too; pasting values, column widths or notes carries nothing active and changes nothing. Sheet moves and copies stay inside one workbook, so its trust is unchanged. document.inspect reports the trust. * Engine: workbook data in chunked hidden names (cmd/stash.rs) GridCraft keeps workbook data in hidden defined names so it travels inside the xlsx file. The existing items write one name holding one string literal, which past Excel's 255-character literal and 8,192-character formula limits gives a file Excel does not accept. cmd/stash.rs stores an item `key` as hidden workbook-scope names _gc__0, _gc__1, ..., each a formula of string literals of at most 255 characters (UTF-16 units, as Excel counts them) joined with &, at most 8,192 characters per name. Reading concatenates the chunks in order and stops at a gap; it also accepts the older single _sc_ name. Names are matched case-insensitively and only at workbook scope. Keys are limited to letters, digits, _ and ., items to 4,194,304 characters, and junk around or inside the literals, duplicate or out-of-order chunks, non-JSON and over-deep JSON all read safely. keys(prefix) lists the stored items that start with a prefix. Custom views, scripts, the watch list and scenarios keep their current storage; nothing reads or writes the new names yet. docs/architecture.md describes the engine building blocks added in this series: undo groups, the recording buffer, document trust and this storage. * Engine: a linked paste of values from a file carries the file's trust A linked paste writes formulas even when it pastes values, but the values exception skipped the trust update, so a Local workbook stayed Local after linking to a FromFile one. The exception now applies only to unlinked pastes. * Engine: a stash item with only later chunks reads as missing and falls back to the legacy name With chunk _gc_k_2 but no _gc_k_0, get returned Some("") and never looked at _sc_k. It now takes the legacy path unless chunk 0 exists. * Engine: a command that runs its own undo groups makes one undo step for itself execute treated any change in the group epoch as 'the group owns the step'. A command that edited outside its own group, or whose group rolled back before it edited, left that edit with no undo step or only part of it. Now only a group that was open before the command (or is still open after) owns the step; a command that opened and closed its own groups replaces their steps with one entry for the command. * Engine: a full undo history keeps one step per command that runs its own undo groups When the history held 100 steps, group_end pushed its step and dropped the oldest one at once, so execute's collapse compared against a length that no longer matched. A command that was one group kept the group's step and added its own, losing two old steps. After test.editThenGroup, the second Undo brought back the intermediate state after the outside edit. The history limit now applies after the outermost command has collapsed its steps, and when a group ends outside any command. A document with an open group is trimmed when the group ends. * Engine: pasting only formats from a file leaves the destination's trust alone Paste Special › Formats copies cell styles and merged areas. It copies no conditional formats, formulas or validation, so it carries nothing that runs. It still marked the destination FromFile: copying a formula cell from an opened file and pasting its formats into a new workbook made that workbook ask before running its own active content. Formats pastes now count as passive, like values, column widths and notes. The comment and docs/architecture.md no longer say that pasted formats carry trust. * Engine: a command keeps its group's name only for a group it ended itself The record of the last group step (document, history length, epoch) outlived the Undo of that step. A later command that opened and closed its own groups at the same history length, after rolling one back, passed the epoch check (the record's epoch merely differed from the one the command started with) and took the name of the nested edit it had made instead of its own. The step must now come from a group ended during the command (an epoch past the command's starting one), and a rollback drops a record of a step it truncated away. * Engine: a group rolled back inside a command keeps the redo history A command that changed the workbook cleared the redo history even when an open group owned its step. When the group was then rolled back inside the same command (the debugger's Stop with Undo Changes), the rollback put the redo history back and the command's bookkeeping cleared it again. Now only a command that makes a step of its own clears the redo history; a group clears it when it ends as a step, and a rollback restores it. * Engine: a visible name of the item shape is not workbook data The item store read, listed and removed every workbook-scope name of the `_gc__` shape, visible or not, so saving an item removed a person's own visible name that happened to look like one. Items are the hidden names the store writes; a legacy `_sc_` name still reads. A visible `_gc_` name is left alone by every operation, and writing an item that would need that name is refused before anything changes: a hidden name beside it with the same name is the one an xlsx file drops, so the item would be lost on save. * Engine: a command keeps its group's name only for a step that is exactly the group's The record of the last group step held the document, the history length and a group epoch, and a command reused the group's label when the lengths matched and the epoch was newer. A direct edit after the group (no nested command, so the length still matches) then left the step named after the group alone. The record now holds the workbook the group's step led to (a weak reference, so it keeps nothing alive). A command keeps the group's name only when the one entry it added starts from the command's own starting workbook and ends at its result, which is provenance by identity rather than by position: an entry a nested command made, a step of an earlier command undone since, and a group step the command edited past all name the step after the command. A later group that rolled back changes nothing, and the rollback no longer needs to clear the record. * Engine: the recording buffer is bounded in bytes and keeps the start of a recording The buffer kept at most 100,000 steps but each step keeps its full parameters, and a file.open step holds the whole file as base64, so a recording of a few opens of a large workbook kept tens of MB alive. Like the journal, the buffer now also stops at 64 MB of steps serialized as JSON. A step that does not fit is counted as dropped, and once a step is dropped every later one is too, so what the buffer holds is always how the recording began, never a sequence with gaps. * Docs: the architecture revision row carries the date the change lands; document trust beside script trust The header and the revision row said 2026-10-11, older than the row above them. The document trust paragraph now says how DocState::trust (provenance) differs from DocState::scripts_trusted (per-session consent for stored scripts, #2178). * Engine: writing a stash item checks for a clashing visible name in one pass over the names The check ran once per chunk, each a scan of every defined name: an item of many chunks in a workbook with thousands of names compared every pair. One pass now parses each visible workbook-scope name and refuses when it is a chunk of this key below the chunk count. The behaviour and its test are unchanged. * Engine: a command's changes after ending a group, or before opening one it leaves open, are not lost or a step of their own Two boundaries of the group rules were wrong. A command that ended a group open before it was treated as grouped whatever it did next: with an unchanged group (no step) its later edit had no undo entry at all, and with nested edits after the end the entry was the nested command's. The group's step now stays the group's, and the command makes one step of its own, under its name, for what changed after the end, from the workbook the group ended at. A command that edited and then opened a group it leaves open (a debugger's start) left its nested entries in the history, so one Undo after the group ended brought back a state from inside the command. Such a group now takes the command's start as its own, and its step covers the command's earlier edits. The record of where the last group ended now holds the workbook and selection it ended at, the history length then and whether it made a step; it lives until the outermost command finishes. A rollback records where it ended too, unless the document is back exactly where the last recorded step left it. * Engine: the undo steps made inside a command are bounded while it runs The history limit is applied once the outermost command has finished, so the lengths its bookkeeping compares stay true (round 3). That left the steps nested commands make unbounded until then: a script of up to 100,000 steps, or a subtotal with a step per group, kept every one of them alive, each with the cells it retains, and before this branch every push trimmed the history at once. Each nested command now bounds the steps made since the latest mark on its document to the undo limit and byte budget, dropping the oldest of them. The marks are the starts of the running commands, the starts of the open groups and the end of the last group: the positions a command or group collapses its steps by when it finishes, so nothing before them moves. What the bound drops is only what the final trim would drop. * Engine: a grouped action that closes its document succeeds Session::grouped ended its group after the action, and when the action had closed the group's document (file.close, or file.open taking the place of an untouched new workbook) the group had gone with it, so the action was reported as failed although it had done what it was asked. A group that is gone because its document is gone now counts as ended; a group ended by someone else while the document is open is still an error. * Docs: undo groups at a command's boundaries, and the bound on steps made inside a command * Engine: a linked paste carries the file's trust whatever it pastes Paste Link writes a formula into every cell whatever `what` says, so a linked paste of formats from a file now makes the destination FromFile as a paste of formulas does. Column widths and notes write no cells, linked or not, and still carry nothing. Regression test for the three kinds. * Engine: a stash chunk number with leading zeros names someone else's name `_gc_watch_00` parsed as chunk 0, so a hidden one was read as the item's first chunk and a visible one stopped `_gc_watch_0` from being written. Only the chunk number as `set` writes it counts now. Regression test. * Engine: a command that panics leaves the undo history as it found it The panic guard restored the workbook and selection but not the history, so a nested edit made before the panic left a no-op undo step and the redo history it had cleared stayed lost. The guard now truncates the steps made during the command and puts the redo history back. Regression test, and the architecture note says what the guard restores. * Engine: a headless call that fails for want of a file is not recorded file.open with no file succeeds by queuing a dialog, which execute_headless turns into an error for callers with no UI; the recording kept the step. Recorder::truncate forgets it and keeps the byte count right. Regression test. * Engine: a later stash chunk on its own is not a listed item stash::keys reported a key that get read as missing, because only a later chunk of it existed. keys now counts chunk 0 and legacy names only, as get does. Regression assertions in the hostile-names test. * Tests: a linked paste of column widths or notes copies its metadata and no cell The source now carries a note and a column width, and the test checks that the destination received them while its trust stays Local and no cell is written. * Engine: the panic guard restores entries a nested Undo removed Truncating the undo history to its length at the command's start cannot bring back an entry that a nested edit.undo popped. The guard now keeps the undo and redo history as they were and puts both back. The regression test gains the undo-then-panic case. * Engine: a group a panicking command ended keeps its step A group open before a command and ended or rolled back inside it lost its undo step when the command then panicked: the restored history held no entry for the group's changes, so Undo skipped the state before the group. The panic path now pushes the outermost such group's step from its start to the restored workbook. The group stays ended, because its token went with the command. Regression test for a group ended and one rolled back. * Docs: list every paste that carries a file's trust The architecture note named formulas and validation only; it now lists the Paste Special kinds and linked pastes that write formulas, and the kinds that carry nothing. * Engine: a grouped action that panics is rolled back before the panic goes on Session::grouped ran its action without a panic guard, so a caller outside a command that caught the panic found the group still open: Undo and Redo disabled, the partial edits kept, and the next grouped action nested in the abandoned group. The group is now rolled back and the panic resumed. Regression test. * Engine: the recorder counts a dropped step without serializing it Once a step has been dropped, or the step count is full, push serialized each later step only to count it; a file.open step holds a whole file. The checks that need no size come first. * Engine: a failed command rolls back the undo groups it opened A command that opened an undo group and then returned an error (a `?` between `group_begin` and `group_end`) left the group open for good: Undo and Redo refused with "finish or stop the running action first", later edits made no undo steps, and nothing could stop the group. `execute` now rolls back every group a failed command opened, as `group_rollback` does, restoring the workbook, selection, undo and redo history to where each group began. Only a command that succeeds keeps a group it opened, which is what a debugger's start needs. `Session::abort_groups(doc)` and the menu-less command `automate.stopAction` roll back the groups left open on a document, for a debugger the person abandons or a caller that dropped its token. The command is not undoable and works while a group is open, and the Undo refusal now names it. * Engine: the recorder skips a call that only opened a dialog A menu-style call with no parameters, such as `file.open`, `data.fromTextCsv` or `home.formatCells` with `{}`, only queues its dialog and succeeds. The recorder kept it as a step with empty parameters, and then kept the dialog's confirming call as well, so playing the recording back ran the command twice, or failed on the empty step when played back headless. Only `execute_headless` took the step back, and only for the open and save-as dialogs. `execute` now compares the queued UI requests before and after a top-level command and does not record one that queued a dialog. The special case in `execute_headless` goes (it keeps turning a missing file into an error), and with it `Recorder::truncate`, which had no other caller. * Engine: stash items keep characters XML can't carry The xlsx writer drops characters XML 1.0 can't carry: controls other than tab, line feed and carriage return, and U+FFFE and U+FFFF. A stash item holding one lost it on save without a warning, and so did a JSON item, since serde_json leaves U+FFFE and U+FFFF unescaped: a stored macro parameter changed silently. `chunk_formulas` now writes each such character, and the marker itself, as the noncharacter U+FDD0 followed by four hex digits, and `get` turns them back for `_gc_` items. A marker that isn't followed by four hex digits naming a character is read as it is. An escape counts as five units against the 255-unit literal limit and is never split across literals, and the guard on the stored size allows the longest item even when every character is escaped. Legacy `_sc_` items are read unchanged. * Engine: list stash items in one pass over the names Each `stash::get` looks at every defined name, so listing the items with `keys` and then reading each with `get` took time quadratic in the number of names. A file with 20,000 hidden `_gc_macro__0` names took about ten seconds in a debug build, and the macro list reads its items this way from data that comes from the file. `stash::items(wb, prefix)` now groups the names by item in one pass and returns each item's text by key, and `keys` lists from the same pass. Both return at most `MAX_ITEMS` (10,000) items, the first keys in sorted order, so a file with any number of hidden names can't make a listing unbounded. A test counts the names each listing looks at. * Engine: the name commands leave GridCraft's data names alone `formulas.defineName {"name": "_gc_macro_1_0"}` replaced the hidden chunk of a stored item with a visible name: the item vanished from `stash::get` and `stash::keys`, and its other chunks stayed behind as orphan hidden names. `formulas.deleteName` could remove chunks the same way, and `formulas.createFromSelection` with `replace` could replace one from a cell label. `stash::set` refused a clash with a visible name, but nothing guarded the other direction. Defining, renaming or deleting a name that `stash::item_key` recognises (`_gc__` and legacy `_sc_`) is now refused with an error that names `force: true`, which lets the call go ahead. Create from Selection lists such labels in `skipped` unless `force` is true. The params docs of the three commands say so. * Engine: queries take no copy of the undo history `execute` copied the document's undo and redo history on every call, so a panic could put it back: up to 100 entries per stack, each with a label and a selection. Queries paid for it too, although no query runs a command or changes the history, and front ends and agents run queries all the time. The copy is now skipped for queries (commands that neither journal nor make undo steps). A test runs every query, with empty and with typical parameters, and checks the history is unchanged, so a query that ever changes it fails there. Commands that can change the history, nested ones included, still take the copy: a nested command that panics is restored at its own level, and the outer command can carry on, so relying on the outermost copy would weaken that restore. * Engine: a document's maker says where its content came from `Trust` defaulted to `Local`, and `DocState::new` set it, so a document was trusted unless its maker remembered to mark it. Every current path that reads a file goes through `file.open`, which marks it `FromFile`, but a later way of building a document from bytes (recovery, templates, a new import) would have come out trusted by omission. `DocState::new` now takes the `Trust`, and `Trust` has no default. New workbooks and samples pass `Local`, `file.open` passes `FromFile`, a new window passes the trust of its workbook, and the tests that build documents from bytes pass `FromFile`. * Engine: the name commands refuse only names that hold GridCraft's data The guard refused any name of the stash's shape, so a visible name a person had defined, such as _gc_rate_0, could not be edited or deleted from Name Manager, which cannot pass force. It now refuses a name only when the workbook holds a hidden workbook-scope item under it; a visible name of the shape is the person's own, as the stash itself treats it. The test covers such a name. The query comment says what the copy skip relies on: a query runs nothing that changes the history. * UI: the toolbar's Undo and Redo follow the commands' own availability The ribbon enabled them from the history lengths alone, so while an undo group was open on the document (an action spanning several calls) a click ran edit.undo and got its refusal. The buttons now ask cmd::can_undo and cmd::can_redo, the undo list is empty while a group is open, and the two buttons carry a name and their enabled state for assistive technology. A UI test opens a group, clicks both and checks the history and their state. * Engine: the name commands use the stash's own rule for what holds its data The guard asked for a hidden name, but the stash reads a legacy _sc_ name whether it is hidden or not, so a visible one could be removed or replaced without force. stash::holds_item is the one rule (item_of), shared by the guard; the test covers a visible legacy name. * Docs: the panic guard's note says what happens to a group the command ended It keeps a step from the group's start to the restored workbook, so Undo still returns to the state before the group; the note said the history came back exactly. --- crates/engine/src/cmd/edit.rs | 12 +- crates/engine/src/cmd/extra.rs | 18 +- crates/engine/src/cmd/file.rs | 5 +- crates/engine/src/cmd/formulas.rs | 97 +- crates/engine/src/cmd/inspect.rs | 13 +- crates/engine/src/cmd/mod.rs | 25 + crates/engine/src/cmd/sheet.rs | 8 +- crates/engine/src/cmd/stash.rs | 606 ++++++++++ crates/engine/src/lib.rs | 448 ++++++- crates/engine/src/record.rs | 66 ++ crates/engine/src/sample.rs | 2 +- crates/engine/src/tests.rs | 6 + crates/engine/src/tests/seams.rs | 1052 +++++++++++++++++ crates/engine/tests/it/dynamicmetadata.rs | 2 +- .../engine/tests/it/long_operator_chains.rs | 2 +- crates/engine/tests/it/stored_list_shape.rs | 3 +- crates/ui-egui/src/ribbon.rs | 22 +- crates/ui-egui/tests/it/interaction.rs | 2 +- crates/ui-egui/tests/it/main.rs | 1 + crates/ui-egui/tests/it/scroll_position.rs | 2 +- .../ui-egui/tests/it/undo_while_grouping.rs | 57 + docs/architecture.md | 51 +- 22 files changed, 2461 insertions(+), 39 deletions(-) create mode 100644 crates/engine/src/cmd/stash.rs create mode 100644 crates/engine/src/record.rs create mode 100644 crates/engine/src/tests/seams.rs create mode 100644 crates/ui-egui/tests/it/undo_while_grouping.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 352797eab..edb0e2d7c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -741,7 +741,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result { // The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and // scripts get it only when they ask for it. let html = if p.get("html").and_then(Json::as_bool) == Some(true) { crate::io::range_html(&wb, sheet, range, &text) } else { None }; - let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected }; + let clip = Clipboard { wb, sheet, range, cut, doc_uid: d.uid, text: text.clone(), areas, selected, trust: d.trust }; s.clipboard = Some(clip); let mut result = json!({"range": range.a1(), "text": text}); if let Some(html) = html { @@ -1264,6 +1264,16 @@ fn paste_special(s: &mut Session, p: &Json) -> Result { *cx.sel = crate::Selection { active: at, anchor: at, ranges: vec![RangeRef::new(at, end)] }; Ok(()) })?; + // Formulas and validation from an untrusted file leave the destination untrusted; plain values, + // formats (cell styles and merged areas, no conditional formats), column widths and notes + // carry nothing active. A linked paste writes a formula into every cell whatever `what` says, + // except for column widths and notes, which write no cells at all. + let passive = + matches!(what, What::ColumnWidths | What::Comments) || (!link && matches!(what, What::Formats | What::Values | What::ValuesAndNumberFormats)); + if !same_doc && !passive { + let d = s.doc_mut()?; + d.trust = d.trust.receive(clip.trust); + } if cut_move { s.clipboard = None; } diff --git a/crates/engine/src/cmd/extra.rs b/crates/engine/src/cmd/extra.rs index f248c59f2..009d77f2a 100644 --- a/crates/engine/src/cmd/extra.rs +++ b/crates/engine/src/cmd/extra.rs @@ -212,6 +212,15 @@ pub fn specs() -> Vec { trust_scripts ), cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script), + cmd!( + noundo "automate.stopAction", + "Stop Action", + [], + None, + "{} → {stopped: bool}: stops an action left running on the active workbook (an open undo group, such as a debugger's) and puts back the workbook and its undo history as they were when it began", + has_doc, + stop_action + ), // Cell controls, themes, borders, styles cmd!( "insert.checkbox", @@ -800,7 +809,7 @@ 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())); + let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust); copy.selection = d.selection.clone(); copy.scripts_trusted = d.scripts_trusted; let i = s.add_document(copy); @@ -1087,6 +1096,13 @@ fn run_script(s: &mut Session, p: &Json) -> Result { Ok(json!({"ran": n})) } +/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and +/// Redo work there again. +fn stop_action(s: &mut Session, _: &Json) -> Result { + let uid = s.doc()?.uid; + Ok(json!({"stopped": s.abort_groups(uid)})) +} + fn delete_script(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string(); edit(s, |cx| { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a61c6298f..b3e7309e2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -34,7 +34,7 @@ fn new_workbook(s: &mut Session, p: &Json) -> Result { Some(name) => { let wb = crate::sample::build(name).ok_or_else(|| bad("file.new", format!("unknown sample `{name}`")))?; let title = crate::sample::title(name); - s.add_document(DocState::new(wb, None, title)) + s.add_document(DocState::new(wb, None, title, crate::Trust::Local)) } None => s.new_workbook(), }; @@ -83,7 +83,8 @@ fn open(s: &mut Session, p: &Json) -> Result { { s.close_document(0); } - let mut doc = DocState::new(wb, path, title); + // Someone else may have made the file: its active content asks before it runs. + let mut doc = DocState::new(wb, path, title, crate::Trust::FromFile); if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx { doc.content_kind = Some(crate::io::FileKind::Xlsx); } diff --git a/crates/engine/src/cmd/formulas.rs b/crates/engine/src/cmd/formulas.rs index 5334784fa..7718279b2 100644 --- a/crates/engine/src/cmd/formulas.rs +++ b/crates/engine/src/cmd/formulas.rs @@ -1,7 +1,7 @@ //! Formulas tab: AutoSum, Insert Function, names, auditing, calculation. use gridcraft_core::{CellError, CellRef, RangeRef, Value}; -use gridcraft_model::{CalcMode, DefinedName, Formula}; +use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook}; use serde_json::{Value as Json, json}; use super::*; @@ -32,18 +32,26 @@ pub fn specs() -> Vec { "Define Name…", ["Formulas", "Defined Names"], None, - "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas)", + "{name, refersTo?: \"=Sheet1!$A$1:$A$10\" (default: the selection), scope?: \"Workbook\"|sheet name, comment?, oldName?, oldScope?, force?: bool} (oldName: edit that name (Name Manager › Edit); a new name also renames it in formulas; a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", has_doc, define_name ), - cmd!("formulas.deleteName", "Delete Name", [], None, "{name, scope?: \"Workbook\"|sheet name (default: every scope)}", has_doc, delete_name), + cmd!( + "formulas.deleteName", + "Delete Name", + [], + None, + "{name, scope?: \"Workbook\"|sheet name (default: every scope), force?: bool} (a hidden name GridCraft keeps its own data in, `_gc__` or `_sc_`, is refused unless force is true)", + has_doc, + delete_name + ), cmd!(query "formulas.nameManager", "Name Manager", ["Formulas", "Defined Names"], Some("Cmd+F3"), "{} → names with values", has_doc, name_manager), cmd!( "formulas.createFromSelection", "Create from Selection", ["Formulas", "Defined Names"], Some("Cmd+Shift+F3"), - "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`)}", + "{range?, top?: true, left?: false, bottom?: false, right?: false, replace?: false (existing names are kept and listed in `skipped`), force?: false (labels of the shape GridCraft keeps its own data in, `_gc__` and `_sc_`, are listed in `skipped` unless force is true)}", has_doc, create_from_selection ), @@ -268,6 +276,9 @@ fn define_name(s: &mut Session, p: &Json) -> Result { if !valid_name(&name) { return Err(EngineError::Other("The name that you entered is not valid.".into())); } + for n in [Some(name.as_str()), str_param(p, "oldName")].into_iter().flatten() { + refuse_stash_name(&s.doc()?.wb, p, n)?; + } let d = s.doc()?; let scope = match str_param(p, "scope") { None | Some("Workbook") => None, @@ -374,8 +385,29 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc } } +/// Refuses a name of the shape GridCraft keeps its own workbook data in ([`super::stash`]): +/// defining, renaming or deleting one would replace or remove part of an item (a stored macro, +/// script or view) and leave the rest behind. `force: true` lets the call go ahead. +/// Whether `name` is one the workbook holds GridCraft's own data in: a hidden workbook-scope +/// name of an item's shape. A visible name of that shape is the person's own (see `stash`), and +/// the name commands may change it. +fn holds_gridcraft_data(wb: &Workbook, name: &str) -> bool { + // The stash's own rule, so the two never disagree: a legacy `_sc_` item is read visible or not. + stash::holds_item(wb, name) +} + +fn refuse_stash_name(wb: &Workbook, p: &Json, name: &str) -> Result<()> { + if holds_gridcraft_data(wb, name) && !bool_param(p, "force").unwrap_or(false) { + return Err(EngineError::Other(format!( + "'{name}' is a name GridCraft keeps its own data in (stored macros, scripts or views). Pass `force: true` to change it anyway." + ))); + } + Ok(()) +} + fn delete_name(s: &mut Session, p: &Json) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("formulas.deleteName", "missing `name`"))?.to_string(); + refuse_stash_name(&s.doc()?.wb, p, &name)?; let scope = match str_param(p, "scope") { None => None, Some("Workbook") => Some(None), @@ -461,7 +493,11 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result { // Excel asks before replacing an existing name; here it's kept unless `replace` is set, and // reported. let exists = |n: &str| d.wb.names.iter().any(|x| x.name.eq_ignore_ascii_case(n) && x.scope.is_none()); - let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| replace || !exists(n)); + // A label that names one of GridCraft's own hidden data names would replace part of an item + // (see `refuse_stash_name`). + let force = bool_param(p, "force").unwrap_or(false); + let ours = |n: &str| !force && holds_gridcraft_data(&d.wb, n); + let (made, skipped): (Vec<_>, Vec<_>) = made.into_iter().partition(|(n, _)| !ours(n) && (replace || !exists(n))); let skipped: Vec = skipped.into_iter().map(|(n, _)| n).collect(); let n = made.len(); edit(s, |cx| { @@ -742,6 +778,57 @@ mod tests { assert_eq!(r["refersTo"], "=Sheet1!$A$1"); } + #[test] + fn the_name_commands_leave_gridcraft_data_alone() { + // Defining, renaming or deleting a name of a stored item's shape would replace or remove + // one of its chunks and leave the rest behind: refused unless `force` is true. + let mut s = Session::new(); + s.new_workbook(); + let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb); + let text = "m".repeat(9_000); + stash::set(wb, "macro_1", &text).unwrap(); + wb.names.push(DefinedName { name: "_sc_watch".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: true }); + // A legacy item is read whether or not it is hidden, so a visible one is data too. + wb.names.push(DefinedName { name: "_sc_old".into(), scope: None, formula: "\"[]\"".into(), comment: String::new(), hidden: false }); + let names = |s: &Session| s.doc().unwrap().wb.names.clone(); + let before = names(&s); + assert!(before.len() >= 3, "the item takes two chunks"); + for params in [ + json!({"name": "_gc_macro_1_0", "refersTo": "=1"}), + json!({"name": "_SC_Watch", "refersTo": "=1"}), + json!({"name": "Mine", "refersTo": "=1", "oldName": "_gc_macro_1_1"}), + json!({"name": "_gc_macro_1_0", "refersTo": "=1", "force": false}), + json!({"name": "Mine2", "refersTo": "=1", "oldName": "_SC_old"}), + ] { + let err = s.execute("formulas.defineName", params.clone()).unwrap_err().to_string(); + assert!(err.contains("force"), "{params}: {err}"); + } + for name in ["_gc_macro_1_1", "_sc_watch", "_sc_old"] { + let err = s.execute("formulas.deleteName", json!({"name": name})).unwrap_err().to_string(); + assert!(err.contains("force"), "{name}: {err}"); + } + s.execute("range.setValues", json!({"range": "A1:B2", "values": [["_gc_macro_1_0", "Rate"], [1, 2]]})).unwrap(); + let r = s.execute("formulas.createFromSelection", json!({"range": "A1:B2", "top": true, "replace": true})).unwrap(); + assert_eq!((r["created"].clone(), r["skipped"].clone()), (json!(1), json!(["_gc_macro_1_0"]))); + let after = names(&s); + assert_eq!(after.iter().filter(|n| n.name != "Rate").cloned().collect::>(), before); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str())); + // A visible name of the shape is the person's own (the stash never reads it): the name + // commands change and remove it without `force`, as Name Manager cannot pass one. + let own = DefinedName { name: "_gc_rate_0".into(), scope: None, formula: "=1".into(), comment: String::new(), hidden: false }; + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own.clone()); + s.execute("formulas.defineName", json!({"name": "Rate0", "refersTo": "=2", "oldName": "_gc_rate_0"})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "Rate0"})).unwrap(); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).names.push(own); + s.execute("formulas.deleteName", json!({"name": "_gc_rate_0"})).unwrap(); + assert_eq!(stash::get(&s.doc().unwrap().wb, "macro_1").as_deref(), Some(text.as_str()), "the hidden item is untouched"); + // Ordinary names and forced calls go ahead. + s.execute("formulas.deleteName", json!({"name": "Rate"})).unwrap(); + s.execute("formulas.defineName", json!({"name": "_gc_macro_1_1", "refersTo": "=1", "force": true})).unwrap(); + s.execute("formulas.deleteName", json!({"name": "_gc_macro_1_1", "force": true})).unwrap(); + assert!(!names(&s).iter().any(|n| n.name == "_gc_macro_1_1")); + } + #[test] fn arabic_defined_names_count_characters_not_bytes() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index f373de489..f5b478ccf 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -7,11 +7,11 @@ use super::*; pub fn specs() -> Vec { vec![ - cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, history", has_doc, inspect), + cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect), cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read), cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get), cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats), - cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels", has_doc, history), + cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history), cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands), ] } @@ -63,6 +63,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result { "title": d.display_title(), "path": d.path, "dirty": d.is_dirty(), + "trust": d.trust, "activeSheet": wb.active_sheet, "sheets": sheets, "selection": d.selection.a1(), @@ -148,9 +149,11 @@ fn sel_stats(s: &mut Session, _: &Json) -> Result { fn history(s: &mut Session, _: &Json) -> Result { let d = s.doc()?; - Ok( - json!({"undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>()}), - ) + Ok(json!({ + "undo": d.undo.iter().rev().map(|e| e.label.clone()).collect::>(), + "redo": d.redo.iter().rev().map(|e| e.label.clone()).collect::>(), + "grouping": s.in_group(d.uid), + })) } fn list_commands(s: &mut Session, p: &Json) -> Result { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 1f15ddfb8..8a0bb1052 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ pub mod sheet; pub mod slicer; pub mod solver; pub mod spelling; +pub mod stash; pub mod view; pub mod whatif; @@ -96,12 +97,19 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> { } pub fn can_undo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) } } pub fn can_redo(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; + not_grouping(s)?; if s.active().is_some_and(|d| !d.redo.is_empty()) { Ok(()) } else { Err("nothing to redo".into()) } } +/// Undo and Redo wait while an undo group is open on the active document (an action that spans +/// several calls is still running). +fn not_grouping(s: &Session) -> std::result::Result<(), String> { + if s.active().is_some_and(|d| s.in_group(d.uid)) { Err("finish or stop the running action first (automate.stopAction)".into()) } else { Ok(()) } +} pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.clipboard.is_some() { Ok(()) } else { Err("the clipboard is empty".into()) } @@ -179,6 +187,23 @@ pub fn register_test_command(id: &'static str, run: Run) { test_commands().push(spec); } +/// Test-only: an undoable command, to exercise how `execute` records its step. +#[cfg(test)] +pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) { + let spec: &'static CommandSpec = Box::leak(Box::new(CommandSpec { + id, + label: id, + menu: &[], + shortcut: None, + params: "", + enabled: |_| Ok(()), + run, + journal: false, + undoable: true, + })); + test_commands().push(spec); +} + fn test_commands() -> std::sync::MutexGuard<'static, Vec<&'static CommandSpec>> { #[allow(clippy::unwrap_used)] // a poisoned test registry has no meaningful recovery match TEST_COMMANDS.lock() { diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs index 555c2fb79..a453f250b 100644 --- a/crates/engine/src/cmd/sheet.rs +++ b/crates/engine/src/cmd/sheet.rs @@ -586,11 +586,15 @@ fn insert_copied_cells(s: &mut Session, p: &Json) -> Result { } let revision = d.revision; let before = (d.wb.clone(), d.selection.clone()); + // Inside an undo group the nested steps add no undo entries of their own. + let nested_entries = !s.in_group(d.uid); let result = insert_copied_steps(s, &clip, sheet, at, shift, cut); let d = s.doc_mut()?; // The nested steps collapse into this command's single undo step. - for _ in 0..d.revision.saturating_sub(revision) { - d.undo.pop(); + if nested_entries { + for _ in 0..d.revision.saturating_sub(revision) { + d.undo.pop(); + } } match result { Ok((final_range, out)) => { diff --git a/crates/engine/src/cmd/stash.rs b/crates/engine/src/cmd/stash.rs new file mode 100644 index 000000000..c0e8302ca --- /dev/null +++ b/crates/engine/src/cmd/stash.rs @@ -0,0 +1,606 @@ +//! GridCraft data kept in hidden workbook-scope defined names, so that it travels inside the xlsx +//! file. +//! +//! An item `key` is stored as names `_gc__0`, `_gc__1`, …, each a formula of string +//! literals joined with `&`: `"…"&"…"`. Excel limits a string literal to 255 characters and a +//! formula to 8,192, so a long value is split over as many names as it needs and the file stays +//! one that Excel accepts. Older items (custom views, scripts, the watch list and scenarios) keep +//! one name each, `_sc_`; reading accepts that form as well, so those items can move to this +//! format later, and a write of the same key replaces it. +//! +//! XML can't carry every character (controls other than tab, line feed and carriage return, and +//! U+FFFE and U+FFFF), and the xlsx writer drops those. In `_gc_` items each of them, and the +//! marker itself, is written as [`ESCAPE`] followed by its code as four upper-case hex digits +//! (`U+0001` becomes `\u{FDD0}0001`), and reading turns them back. The marker is a Unicode +//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on +//! ordinary text. Legacy `_sc_` items are read as they are. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use gridcraft_model::{DefinedName, Workbook}; +use serde_json::Value as Json; + +use crate::{EngineError, Result}; + +const PREFIX: &str = "_gc_"; +const LEGACY_PREFIX: &str = "_sc_"; +/// Characters (UTF-16 units, as Excel counts them) in one string literal. +const LITERAL_UNITS: usize = 255; +/// Characters in one name's formula. +const FORMULA_UNITS: usize = 8_192; +/// The longest key: `_gc__` stays well inside Excel's 255-character names. +const MAX_KEY: usize = 200; +/// The longest item: 4,194,304 characters. +pub const MAX_ITEM_CHARS: usize = 4 * 1024 * 1024; +/// The most bytes the stored (escaped) form of an item can take: an escaped one-byte character +/// takes seven (the three-byte marker and four digits). +const MAX_STORED_BYTES: usize = MAX_ITEM_CHARS * 7; +/// Starts an escaped character in a `_gc_` item: U+FDD0, a noncharacter. +pub const ESCAPE: char = '\u{FDD0}'; +/// The most items [`items`] and [`keys`] return: the first this many keys in sorted order. A +/// file can carry any number of hidden names; the rest are not listed. +pub const MAX_ITEMS: usize = 10_000; + +/// The names that hold one item: its `_gc_` chunks by number (the first of each number) and its +/// legacy `_sc_` name. +#[derive(Default)] +struct Parts<'a> { + chunks: BTreeMap, + legacy: Option<&'a str>, +} + +impl<'a> Parts<'a> { + fn add(&mut self, chunk: Option, formula: &'a str) { + match chunk { + Some(i) => { + self.chunks.entry(i).or_insert(formula); + } + None => self.legacy = self.legacy.or(Some(formula)), + } + } + + /// Whether the names make an item: a later chunk on its own does not. + fn is_item(&self) -> bool { + self.chunks.contains_key(&0) || self.legacy.is_some() + } +} + +#[cfg(test)] +thread_local! { + /// Names [`group`] has looked at on this thread, so a test can bound the work of a listing. + static NAME_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The workbook's items whose lower-cased key starts with `prefix` (lower-cased), with the names +/// that hold them, in one pass over the names. +fn group<'a>(wb: &'a Workbook, prefix: &str) -> BTreeMap> { + let prefix = prefix.to_ascii_lowercase(); + let mut items: BTreeMap> = BTreeMap::new(); + for n in wb.names.iter() { + #[cfg(test)] + NAME_VISITS.with(|v| v.set(v.get().saturating_add(1))); + let Some((k, chunk)) = item_of(n) else { continue }; + let k = k.to_ascii_lowercase(); + if k.starts_with(&prefix) { + items.entry(k).or_default().add(chunk, &n.formula); + } + } + items +} + +/// The item text stored under `key`, or `None` when there is none (or it is over the size limit). +pub fn get(wb: &Workbook, key: &str) -> Option { + let mut parts = Parts::default(); + for n in wb.names.iter() { + if let Some((k, chunk)) = item_of(n) + && k.eq_ignore_ascii_case(key) + { + parts.add(chunk, &n.formula); + } + } + assemble(&parts) +} + +/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass +/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit. +pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap { + group(wb, prefix) + .into_iter() + .filter(|(_, parts)| parts.is_item()) + .take(MAX_ITEMS) + .filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text))) + .collect() +} + +/// The text of the item held by `parts`, as [`get`] reads it. +fn assemble(parts: &Parts) -> Option { + let chunks = &parts.chunks; + let mut out = String::new(); + if !chunks.contains_key(&0) { + out = string_literals(parts.legacy?); + } else { + // Chunks run 0, 1, 2, …; anything after a gap isn't part of the item. + for (want, (i, f)) in chunks.iter().enumerate() { + if *i != want as u64 { + break; + } + out.push_str(&string_literals(f)); + if out.len() > MAX_STORED_BYTES { + return None; + } + } + out = unescape(&out); + } + (out.chars().count() <= MAX_ITEM_CHARS).then_some(out) +} + +/// Whether `c` is stored escaped: XML can't carry it, or it is the marker. +fn needs_escape(c: char) -> bool { + c == ESCAPE || !matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') +} + +/// Turns [`ESCAPE`] and four hex digits back into the character. A marker not followed by four +/// hex digits naming a character (a hand-made name) stays as it is. +fn unescape(s: &str) -> String { + if !s.contains(ESCAPE) { + return s.to_string(); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find(ESCAPE) { + out.push_str(rest.get(..at).unwrap_or("")); + let after = rest.get(at + ESCAPE.len_utf8()..).unwrap_or(""); + let code = after.get(..4).filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit())).and_then(|h| u32::from_str_radix(h, 16).ok()); + match code.and_then(char::from_u32) { + Some(c) => { + out.push(c); + rest = after.get(4..).unwrap_or(""); + } + None => { + out.push(ESCAPE); + rest = after; + } + } + } + out.push_str(rest); + out +} + +/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it. +pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> { + check_key(key)?; + if text.chars().count() > MAX_ITEM_CHARS { + return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024)))); + } + let formulas = chunk_formulas(text); + // A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside + // it with the same name would be the one an xlsx file drops. Refuse before changing anything, + // in one pass over the names (a file can carry many thousands). + let chunks = formulas.len() as u64; + let taken = |n: &&DefinedName| { + n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks)) + }; + if let Some(n) = wb.names.iter().find(taken) { + return Err(EngineError::Other(format!( + "The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.", + n.name + ))); + } + remove(wb, key); + for (i, formula) in formulas.into_iter().enumerate() { + wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true }); + } + Ok(()) +} + +/// Removes the item stored under `key`. +pub fn remove(wb: &mut Workbook, key: &str) { + wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| k.eq_ignore_ascii_case(key))); +} + +/// The item under `key` as JSON (`None` when missing or not JSON). +pub fn get_json(wb: &Workbook, key: &str) -> Option { + serde_json::from_str(&get(wb, key)?).ok() +} + +pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> { + set(wb, key, &v.to_string()) +} + +/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted +/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass +/// where a [`get`] per key would take one each. +pub fn keys(wb: &Workbook, prefix: &str) -> Vec { + group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect() +} + +/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope +/// `_gc__` name, or a workbook-scope legacy `_sc_` name, which is read whether or not +/// it is hidden. A visible `_gc_` name is the person's own. +pub fn holds_item(wb: &Workbook, name: &str) -> bool { + wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(name) && item_of(n).is_some()) +} + +/// The item a hidden name belongs to, lower-cased: `_gc__` and `_sc_` give ``. +pub fn item_key(name: &str) -> Option { + parse_name(name).map(|(k, _)| k.to_ascii_lowercase()) +} + +/// The item a name holds a part of: a hidden workbook-scope `_gc__` name, or a legacy +/// `_sc_` one. A visible name of the `_gc_` shape is someone's own name, not an item: it is +/// neither read, nor listed, nor removed on write. +fn item_of(n: &DefinedName) -> Option<(&str, Option)> { + if n.scope.is_some() { + return None; + } + parse_name(&n.name).filter(|(_, chunk)| chunk.is_none() || n.hidden) +} + +/// `_gc__` → (`key`, `Some(k)`); `_sc_` → (`key`, `None`). +fn parse_name(name: &str) -> Option<(&str, Option)> { + if let Some(rest) = strip_prefix_ci(name, PREFIX) { + let (key, k) = rest.rsplit_once('_')?; + // Only the chunk number as `set` writes it: `_gc_watch_00` is someone else's name, not + // chunk 0 under another spelling, so it is neither read nor in the way of `_gc_watch_0`. + let canonical = !k.is_empty() && k.len() <= 9 && k.bytes().all(|b| b.is_ascii_digit()) && (k == "0" || !k.starts_with('0')); + if key.is_empty() || !canonical { + return None; + } + return k.parse().ok().map(|i| (key, Some(i))); + } + strip_prefix_ci(name, LEGACY_PREFIX).filter(|k| !k.is_empty()).map(|k| (k, None)) +} + +fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> { + let head = s.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| s.get(prefix.len()..)).flatten() +} + +fn check_key(key: &str) -> Result<()> { + let ok = !key.is_empty() && key.len() <= MAX_KEY && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.'); + if ok { Ok(()) } else { Err(EngineError::Other(format!("`{key}` can't name workbook data: use letters, digits, `_` and `.`"))) } +} + +/// `text` as name formulas: string literals of at most 255 characters joined with `&`, each formula +/// at most 8,192 characters, with the characters XML can't carry escaped (see [`ESCAPE`]). +/// Concatenating [`string_literals`] of each and turning the escapes back gives `text` back. +pub fn chunk_formulas(text: &str) -> Vec { + let mut formulas = Vec::new(); + let (mut formula, mut units) = (String::new(), 0usize); + let (mut literal, mut lit_units) = (String::from("\""), 0usize); + let mut flush_literal = |literal: &mut String, lit_units: &mut usize, formula: &mut String, units: &mut usize| { + literal.push('"'); + let cost = literal.encode_utf16().count() + usize::from(!formula.is_empty()); + if *units + cost > FORMULA_UNITS && !formula.is_empty() { + formulas.push(std::mem::take(formula)); + *units = 0; + } + if !formula.is_empty() { + formula.push('&'); + *units += 1; + } + *units += literal.encode_utf16().count(); + formula.push_str(literal); + *literal = String::from("\""); + *lit_units = 0; + }; + for ch in text.chars() { + // An escaped character is the marker and four digits, kept in one literal. + let escaped = needs_escape(ch); + let cost = if escaped { ESCAPE.len_utf16() + 4 } else { ch.len_utf16() }; + if lit_units + cost > LITERAL_UNITS { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + lit_units += cost; + if escaped { + literal.push(ESCAPE); + let _ = write!(literal, "{:04X}", u32::from(ch)); + } else { + if ch == '"' { + literal.push('"'); + } + literal.push(ch); + } + } + if lit_units > 0 { + flush_literal(&mut literal, &mut lit_units, &mut formula, &mut units); + } + if !formula.is_empty() { + formulas.push(formula); + } + formulas +} + +/// Concatenates the string literals of a `"a"&"b""c"` formula (anything outside them is ignored). +pub fn string_literals(f: &str) -> String { + let mut out = String::new(); + let mut it = f.chars().peekable(); + let mut in_str = false; + while let Some(c) = it.next() { + if !in_str { + if c == '"' { + in_str = true; + } + continue; + } + if c == '"' { + if it.peek() == Some(&'"') { + it.next(); + out.push('"'); + } else { + in_str = false; + } + } else { + out.push(c); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn name(n: &str, formula: &str) -> DefinedName { + DefinedName { name: n.into(), scope: None, formula: formula.into(), comment: String::new(), hidden: true } + } + + fn excel_limits_hold(wb: &Workbook) { + for n in &wb.names { + assert!(n.formula.encode_utf16().count() <= FORMULA_UNITS, "{} is {} long", n.name, n.formula.len()); + // Every literal (quotes undoubled) is at most 255 characters. + for lit in n.formula.split("\"&\"") { + let inner = lit.trim_start_matches('"').trim_end_matches('"').replace("\"\"", "\""); + assert!(inner.encode_utf16().count() <= LITERAL_UNITS, "{}", inner.len()); + } + } + } + + #[test] + fn long_values_split_over_names_and_come_back() { + let mut wb = Workbook::new(); + // Quotes double when written; characters outside the BMP count twice. + let text: String = (0..30_000).map(|i| ['a', '"', 'é', '\u{1D11E}', '&', '\n'][i % 6]).collect(); + set(&mut wb, "big", &text).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert!(names.len() > 5, "{names:?}"); + assert_eq!(names[0], "_gc_big_0"); + assert!(wb.names.iter().all(|n| n.hidden && n.scope.is_none())); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "big").as_deref(), Some(text.as_str())); + assert_eq!(get(&wb, "BIG").as_deref(), Some(text.as_str()), "names are case-insensitive"); + // Shorter again: the extra chunks go. + set(&mut wb, "big", "short").unwrap(); + assert_eq!(wb.names.len(), 1); + assert_eq!(wb.names[0].formula, "\"short\""); + set(&mut wb, "big", "").unwrap(); + assert!(wb.names.is_empty()); + assert_eq!(get(&wb, "big"), None); + } + + #[test] + fn chunks_and_literals_respect_the_boundaries() { + for len in [0, 1, 254, 255, 256, 8_000, 8_192, 8_193, 20_000] { + let text = "q".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text, "{len}"); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS), "{len}"); + } + // All quotes: each literal doubles in the formula. + let text = "\"".repeat(9_000); + let fs = chunk_formulas(&text); + assert_eq!(fs.iter().map(|f| string_literals(f)).collect::(), text); + assert!(fs.iter().all(|f| f.len() <= FORMULA_UNITS)); + } + + #[test] + fn the_old_single_name_still_reads_and_is_replaced_on_write() { + let mut wb = Workbook::new(); + wb.names.push(name("_sc_scripts", "\"{\"\"A\"\":[1]}\"")); + wb.names.push(name("_sc_scenarios", "\"[1,\"&\"2]\"")); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"A": [1]}))); + assert_eq!(get_json(&wb, "scenarios"), Some(serde_json::json!([1, 2]))); + set_json(&mut wb, "scripts", &serde_json::json!({"B": []})).unwrap(); + let names: Vec<&str> = wb.names.iter().map(|n| n.name.as_str()).collect(); + assert_eq!(names, ["_sc_scenarios", "_gc_scripts_0"]); + assert_eq!(get_json(&wb, "scripts"), Some(serde_json::json!({"B": []}))); + } + + #[test] + fn hostile_names_read_safely() { + let mut wb = Workbook::new(); + // A gap: chunk 2 without chunk 1. + wb.names.push(name("_gc_gap_0", "\"a\"")); + wb.names.push(name("_gc_gap_2", "\"c\"")); + assert_eq!(get(&wb, "gap").as_deref(), Some("a")); + // Only a later chunk: no item, not listed, and the legacy name is still read. + wb.names.push(name("_gc_orphan_2", "\"c\"")); + assert_eq!(get(&wb, "orphan"), None); + assert_eq!(keys(&wb, "orphan"), Vec::::new()); + wb.names.push(name("_sc_orphan", "\"old\"")); + assert_eq!(get(&wb, "orphan").as_deref(), Some("old")); + assert_eq!(keys(&wb, "orphan"), ["orphan"]); + // Chunks out of order, a duplicate, junk around the literals, an unterminated literal. + wb.names.push(name("_gc_mix_1", "\"b\"&A1&")); + wb.names.push(name("_gc_mix_0", "SUM(\"a\")")); + wb.names.push(name("_gc_mix_0", "\"ignored\"")); + wb.names.push(name("_gc_mix_2", "\"c")); + assert_eq!(get(&wb, "mix").as_deref(), Some("abc")); + // Names that only look like chunks. + for odd in ["_gc_", "_gc__0", "_gc_x_", "_gc_x_-1", "_gc_x_99999999999999999999", "_sc_", "_gcx_0"] { + wb.names.push(name(odd, "\"zzz\"")); + } + assert_eq!(get(&wb, "x"), None); + assert_eq!(get(&wb, ""), None); + // Not JSON, or JSON nested deeper than the parser allows. + wb.names.push(name("_gc_notjson_0", "\"{\"")); + assert_eq!(get_json(&wb, "notjson"), None); + let deep = format!("\"{}{}\"", "[".repeat(10_000), "]".repeat(10_000)); + wb.names.push(name("_gc_deep_0", &deep)); + assert_eq!(get_json(&wb, "deep"), None); + // Sheet-scoped names are someone else's. + wb.names.push(DefinedName { scope: Some(0), ..name("_gc_scoped_0", "\"s\"") }); + assert_eq!(get(&wb, "scoped"), None); + // Bad keys are refused. + for bad in ["", "has space", "quote\"", "a/b", &"k".repeat(201)] { + assert!(set(&mut wb, bad, "v").is_err(), "{bad}"); + } + assert!(set(&mut wb, "big", &"x".repeat(MAX_ITEM_CHARS + 1)).is_err()); + } + + #[test] + fn oversized_items_are_not_read() { + let mut wb = Workbook::new(); + let chunk = format!("\"{}\"", "x".repeat(250)); + let big = vec![chunk.as_str(); 32].join("&"); + for i in 0..(MAX_ITEM_CHARS / 8_000 + 2) { + wb.names.push(name(&format!("_gc_huge_{i}"), &big)); + } + assert_eq!(get(&wb, "huge"), None); + } + + #[test] + fn a_visible_name_of_the_item_shape_is_not_an_item() { + // Someone's own visible `_gc_watch_0` is not storage: it is not read or listed, and + // writing or removing the item leaves it alone. + let mut wb = Workbook::new(); + let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") }; + wb.names.push(own.clone()); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + // Writing the item would put a hidden name of the same name beside it, and an xlsx file + // keeps only one of them: refused, with the names as they were. + let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string(); + assert!(err.contains("_gc_watch_0"), "{err}"); + assert_eq!(wb.names, vec![own.clone()]); + assert_eq!(get(&wb, "watch"), None); + set(&mut wb, "other", "theirs").unwrap(); + assert_eq!(get(&wb, "other").as_deref(), Some("theirs")); + remove(&mut wb, "watch"); + remove(&mut wb, "other"); + assert_eq!(wb.names, vec![own]); + } + + #[test] + fn a_chunk_number_with_leading_zeros_is_another_name() { + // `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a + // visible one is not in the way of writing `_gc_watch_0`. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_watch_00", "\"theirs\"")); + assert_eq!(get(&wb, "watch"), None); + assert!(keys(&wb, "").is_empty()); + let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") }; + wb.names.push(own.clone()); + set(&mut wb, "watch", "ours").unwrap(); + assert_eq!(get(&wb, "watch").as_deref(), Some("ours")); + assert_eq!(wb.names.len(), 3, "both other names stay"); + remove(&mut wb, "watch"); + assert_eq!(wb.names, vec![name("_gc_watch_00", "\"theirs\""), own]); + } + + #[test] + fn keys_list_items_by_prefix() { + let mut wb = Workbook::new(); + set(&mut wb, "macro_2", "b").unwrap(); + set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap(); + set(&mut wb, "scripts", "{}").unwrap(); + assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]); + assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1")); + assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts")); + assert_eq!(item_key("Total"), None); + } + + #[test] + fn listing_many_items_takes_one_pass_and_is_capped() { + // 20,000 hidden items, as a file can carry: a `get` per key looked at every name for each + // key (400 million visits); `items` looks at each name once, and lists at most MAX_ITEMS. + let mut wb = Workbook::new(); + let total = 20_000; + for i in 0..total { + wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\""))); + } + set(&mut wb, "macro_big", &"b".repeat(9_000)).unwrap(); + wb.names.push(name("_sc_macro_legacy", "\"old\"")); + wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\"")); + wb.names.push(name("_gc_other_0", "\"not a macro\"")); + NAME_VISITS.with(|v| v.set(0)); + let all = items(&wb, "MACRO_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names"); + assert_eq!(all.len(), MAX_ITEMS); + let first: Vec<&String> = all.keys().take(2).collect(); + assert_eq!(first, ["macro_00000", "macro_00001"]); + assert_eq!(all.get("macro_09999").map(String::as_str), Some("m9999")); + assert!(!all.contains_key("macro_10000"), "past the cap"); + NAME_VISITS.with(|v| v.set(0)); + let listed = keys(&wb, "macro_"); + assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len()); + assert_eq!(listed, all.keys().cloned().collect::>(), "keys and items agree"); + + // Under the cap every item is there, read as `get` reads it. + wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1")); + let few = items(&wb, "macro_"); + let want: BTreeMap = + [("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect(); + assert_eq!(few, want); + for (k, text) in &few { + assert_eq!(get(&wb, k).as_ref(), Some(text)); + } + assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]); + assert_eq!(items(&wb, "").len(), 3, "the other item too"); + } + + #[test] + fn items_round_trip_through_xlsx() { + let mut wb = Workbook::new(); + let text: String = (0..20_000).map(|i| ['x', '"', 'ü', '\u{1D11E}', ' '][i % 5]).collect(); + set(&mut wb, "blob", &text).unwrap(); + wb.names.push(name("_sc_watch", "\"[\"\"A1\"\"]\"")); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "blob").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "watch"), Some(serde_json::json!(["A1"]))); + + // Characters XML can't carry, and the escape marker itself, come back too, in text and + // in JSON (which leaves U+FFFE and U+FFFF unescaped). + let mut wb = Workbook::new(); + let odd: String = (0u32..0x20).filter_map(char::from_u32).chain(['\u{FFFE}', '\u{FFFF}', ESCAPE, 'a', '\u{7F}', '\u{FDD1}']).collect(); + let text: String = odd.chars().cycle().take(odd.chars().count() * 300).collect(); + set(&mut wb, "odd", &text).unwrap(); + excel_limits_hold(&wb); + let json = serde_json::json!({"s": "x\u{FFFF}y\u{1}z\u{FDD0}0041"}); + set_json(&mut wb, "json", &json).unwrap(); + // All escapes: each literal holds whole escapes, within the limits. + let controls = "\u{1}".repeat(9_000); + set(&mut wb, "controls", &controls).unwrap(); + excel_limits_hold(&wb); + let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap(); + let (back, warnings) = crate::io::open_bytes("data.xlsx", &bytes).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(get(&back, "odd").as_deref(), Some(text.as_str())); + assert_eq!(get_json(&back, "json"), Some(json)); + assert_eq!(get(&back, "controls").as_deref(), Some(controls.as_str())); + } + + #[test] + fn escapes_count_against_the_limits_and_odd_markers_read_as_they_are() { + // Excel's limits hold however many characters are escaped, and the longest item of + // characters that all need escaping still reads. + for len in [1, 50, 51, 52, 1_638, 1_639, 5_000] { + let text = "\u{2}".repeat(len); + let fs = chunk_formulas(&text); + assert_eq!(unescape(&fs.iter().map(|f| string_literals(f)).collect::()), text, "{len}"); + assert!(fs.iter().all(|f| f.encode_utf16().count() <= FORMULA_UNITS), "{len}"); + } + let mut wb = Workbook::new(); + let longest = "\u{0}".repeat(MAX_ITEM_CHARS); + set(&mut wb, "longest", &longest).unwrap(); + excel_limits_hold(&wb); + assert_eq!(get(&wb, "longest").map(|t| t.len()), Some(MAX_ITEM_CHARS)); + // A marker in a hand-made name that is not an escape stays as it is. + let mut wb = Workbook::new(); + wb.names.push(name("_gc_hand_0", "\"a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004\u{FDD0}0041\u{FDD0}\"")); + assert_eq!(get(&wb, "hand").as_deref(), Some("a\u{FDD0}zz\u{FDD0}D800\u{FDD0}004A\u{FDD0}")); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 066a9e559..b4ab60f3a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -19,6 +19,7 @@ pub mod outline; pub mod paper; pub mod pivot; pub mod pivot_calc; +pub mod record; pub mod sample; pub mod selection; pub mod tables; @@ -66,6 +67,51 @@ pub struct HistoryEntry { pub retained_bytes: usize, } +/// An undo group opened with [`Session::group_begin`]. Hand it back to [`Session::group_end`] +/// (keep the changes as one undo step) or [`Session::group_rollback`] (undo them all). +#[derive(Debug, PartialEq, Eq)] +#[must_use = "an undo group stays open until it is ended or rolled back"] +pub struct GroupToken { + id: u64, + doc: u64, +} + +impl GroupToken { + /// The uid of the document the group belongs to. + pub fn doc(&self) -> u64 { + self.doc + } +} + +/// An open undo group: what runs on `doc` until it ends becomes one undo step. +#[derive(Clone, Debug)] +struct OpenGroup { + id: u64, + doc: u64, + label: String, + /// Undo depth when the group began; the group's own steps are cut back to it. + start: usize, + wb: Arc, + selection: Selection, + /// The redo stack when the group began (a rollback puts it back). + redo: Vec, +} + +/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the +/// workbook and selection it ended at, the undo length then, and whether it made a step. A +/// command that ended the group tells its own later changes from the group's step by it, and +/// keeps the group's name only when the group's step is exactly its own (the entry holds the +/// workbook the step started from). Dropped when the outermost command finishes, so it keeps no +/// workbook alive between commands. +#[derive(Debug)] +struct GroupStep { + doc: u64, + after: Arc, + selection: Selection, + len: usize, + stepped: bool, +} + /// The clipboard: a block of cells (with formulas and styles) or marching-ants reference. #[derive(Clone, Debug)] pub struct Clipboard { @@ -84,6 +130,34 @@ pub struct Clipboard { /// A multi-area copy: the areas as selected (for the marquee). `wb` then holds them slid /// together into one block at `range`. Empty for a single-range copy. pub areas: Vec, + /// Trust of the source document: pasting content from a file someone else made carries it. + pub trust: Trust, +} + +/// Whether a document's active content (macros) may run without asking the person first. There +/// is no default: whoever makes a document says where it came from ([`DocState::new`]), so a new +/// way of building one from bytes can't come out trusted by omission. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Trust { + /// Made in this session: a new workbook or a sample. + Local, + /// Opened from a file, or holding content pasted from one: active content asks before it + /// runs. + FromFile, +} + +impl Trust { + /// Whether active content may run without asking. + pub fn allows_code(self) -> bool { + self == Trust::Local + } + + /// The trust of a document after it receives content from a document with trust `source`: + /// content from an untrusted file leaves the receiver untrusted. + pub fn receive(self, source: Trust) -> Trust { + if source == Trust::FromFile { Trust::FromFile } else { self } + } } /// In-cell edit mode as shown in the status bar. @@ -120,6 +194,11 @@ pub struct DocState { /// user trusts it in this session (`automate.trustScripts`); one with no scripts starts /// trusted, since any script it gains later was written here. pub scripts_trusted: bool, + /// Where the workbook's content came from: whether its active content may run without + /// asking. Unlike `scripts_trusted`, this is provenance, not consent: it is set when the + /// workbook is opened from a file, carried by content pasted from such a workbook, and never + /// changed by a trust decision. + pub trust: Trust, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -160,17 +239,31 @@ fn push_history_with_budget( selection: Selection, byte_budget: usize, ) { + push_entry(history, label, wb, next, selection); + trim_to_budget(history, byte_budget); +} + +/// Adds a step without applying the limits: inside a command they wait for the outermost +/// command to collapse its steps (see [`Session::trim_history`]). +fn push_entry(history: &mut Vec, label: String, wb: Arc, next: &Workbook, selection: Selection) { let retained_bytes = history_retained_bytes(&wb, next); history.push(HistoryEntry { label, wb, selection, retained_bytes }); +} + +/// Drops the oldest steps past [`UNDO_LIMIT`] or `byte_budget`, always keeping the latest one +/// (even if that one edit exceeds the budget). +fn trim_to_budget(history: &mut Vec, byte_budget: usize) { let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes)); - // Always preserve the latest action, even if that one edit exceeds the budget. while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) { total = total.saturating_sub(history.remove(0).retained_bytes); } } impl DocState { - pub fn new(mut wb: Workbook, path: Option, title: String) -> DocState { + /// A document for `wb`, which came from where `trust` says: [`Trust::FromFile`] for anything + /// read from bytes (a file, a download, a recovery copy), [`Trust::Local`] only for a workbook + /// made in this session. + pub fn new(mut wb: Workbook, path: Option, title: String, trust: Trust) -> DocState { let mut calc = Calc::new(); calc.recalc_all(&mut wb); let scripts_trusted = !cmd::extra::has_scripts(&wb); @@ -191,6 +284,7 @@ impl DocState { unlocked_ranges: vec![], content_kind: None, scripts_trusted, + trust, } } pub fn is_dirty(&self) -> bool { @@ -307,8 +401,13 @@ pub struct Session { pub last_find: Option, /// View › Show › Ruler is off (rulers in Page Layout view; an application option). pub hide_ruler: bool, - /// Journal position where action recording started. + /// Journal position where Automate › Record Actions started. pub recording: Option, + /// The recording buffer while a recorder runs ([`Session::start_recording`]): top-level + /// commands with the place they ran in, kept apart from the journal. + pub recorder: Option, + /// How many `execute` calls are running (0 between top-level commands). + depth: u32, /// Windows hidden with View › Hide. pub hidden_windows: Vec, /// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width. @@ -320,6 +419,17 @@ pub struct Session { /// Review › Notes › Show All Notes: every note is displayed. An application display setting /// (like Excel's comment indicator option), so it's not saved in the file. pub show_all_notes: bool, + /// Open undo groups, innermost last. + groups: Vec, + next_group: u64, + /// Counts group begins and ends, so `execute` can tell that a command opened or closed one. + group_epoch: u64, + /// Where the last group ended, so `execute` can tell a command's own changes from its group's. + group_step: Option, + /// For each running command, innermost last: its document and that document's undo length + /// when it began. Steps before that length are not touched while the command runs (see + /// [`Session::bound_tail`]). + marks: Vec<(u64, usize)>, /// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses /// [`cmd::format::approx_text_width`]. pub text_width: Option, @@ -359,7 +469,8 @@ impl Session { } pub fn close_document(&mut self, i: usize) { if i < self.docs.len() { - self.docs.remove(i); + let d = self.docs.remove(i); + self.groups.retain(|g| g.doc != d.uid); if self.active >= self.docs.len() { self.active = self.docs.len().saturating_sub(1); } @@ -373,7 +484,7 @@ impl Session { let name = wb.next_sheet_name(); wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name))); } - self.add_document(DocState::new(wb, None, format!("Book{n}"))) + self.add_document(DocState::new(wb, None, format!("Book{n}"), Trust::Local)) } /// Journals a command, then trims the oldest entries past either cap. A recording in progress @@ -412,7 +523,8 @@ impl Session { command_specs().iter().map(|c| c.info(self)).collect() } - /// Runs a command by id. Undoable commands record a history step when the workbook changed. + /// Runs a command by id. Undoable commands record a history step when the workbook changed, + /// unless an undo group is open on the document (the group then makes the step). /// Panics inside commands are caught and reported; the workbook is restored. pub fn execute(&mut self, id: &str, params: Json) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; @@ -421,36 +533,352 @@ impl Session { } let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone())); let params = if params.is_null() { Json::Object(Default::default()) } else { params }; + let (first_group, group_epoch) = (self.next_group, self.group_epoch); + // Whether a group was open on the document before the command, and how long its history was. + let start = before.as_ref().and_then(|(uid, ..)| self.docs.iter().find(|d| d.uid == *uid).map(|d| (self.in_group(*uid), d.undo.len()))); + // A command that panics leaves the history as it found it: the steps commands nested in it + // made or undid, and the redo history they cleared, come back. A length is not enough, + // because a nested `edit.undo` removes entries. A query runs nothing that changes the + // history (`every_query_leaves_the_history_alone` runs each one), so it needs no + // copy; front ends run queries all the time. + let query = !spec.journal && !spec.undoable; + let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| { + #[cfg(test)] + tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1))); + self.docs.iter().find(|d| d.uid == *uid).map(|d| (d.undo.clone(), d.redo.clone())) + }); + // The groups open on the document before the command (usually none), for the same reason. + let groups_before: Vec<(u64, String, Arc, Selection)> = match &before { + Some((uid, ..)) => { + self.groups.iter().filter(|g| g.doc == *uid).map(|g| (g.id, g.label.clone(), g.wb.clone(), g.selection.clone())).collect() + } + None => Vec::new(), + }; + // The recorder keeps top-level calls with the place they ran in. + let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() { + Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()), + None => (0, 0, String::new(), Selection::default()), + }); + let doc_uid = before.as_ref().map(|(uid, ..)| *uid); + let requests_before = self.ui_requests.len(); + let marked = doc_uid.zip(start).map(|(uid, (_, len))| (uid, len)); + self.marks.extend(marked); + self.depth = self.depth.saturating_add(1); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))); + self.depth = self.depth.saturating_sub(1); + if marked.is_some() { + self.marks.pop(); + } let result = match result { Ok(r) => r, Err(p) => { let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); - // Restore the workbook as it was. + // Restore the workbook and its history as they were, and drop the undo groups the + // command opened. + self.groups.retain(|g| g.id < first_group); + let group_open = before.as_ref().is_some_and(|(uid, ..)| self.groups.iter().any(|g| g.doc == *uid)); if let Some((uid, wb, sel)) = &before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == *uid) { d.wb = wb.clone(); d.selection = sel.clone(); + if let Some((undo, redo)) = history_before { + d.undo = undo; + d.redo = redo; + } + // A group open before the command and ended or rolled back inside it stays + // ended (its token went with the command), and keeps a step from its start to + // the restored workbook, so Undo still returns to the state before the group. + if !group_open + && let Some((_, label, wb, selection)) = groups_before.into_iter().min_by_key(|g| g.0) + && !Arc::ptr_eq(&wb, &d.wb) + { + push_entry(&mut d.undo, label, wb, &d.wb, selection); + d.redo.clear(); + } d.calc.rebuild(&d.wb); } + if self.depth == 0 { + self.trim_history(); + self.group_step = None; + } return Err(EngineError::Internal(id.to_string(), msg)); } }; + // A command that fails leaves none of its own undo groups open (a `?` between + // `group_begin` and `group_end`): each is rolled back as `group_rollback` does. Only a + // command that succeeds keeps a group it opened (a debugger's start). + if result.is_err() { + self.rollback_groups_from(first_group); + } if spec.journal && result.is_ok() { + // A command that started recording is not part of the recording; one that stopped it + // finds no recorder. A menu-style call that only opened a dialog (`file.open` with no + // file, `home.formatCells` with no style) is not recorded either: the dialog's own + // call is, so playing the recording back runs the command once, with its parameters. + let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..)))); + if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog) + && let Some(rec) = self.recorder.as_mut() + { + rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) }); + } self.push_journal(id, cmd::journal_params(¶ms)); } + // Inside an undo group the group owns the step. A command that ended a group open before + // it leaves the group its step and makes one of its own for what it changed after the end. + // A command that opened and closed its own groups makes one step of its own, which + // replaces the steps those groups made. + let epoch_changed = self.group_epoch != group_epoch; + let in_group_now = before.as_ref().is_some_and(|(uid, ..)| self.in_group(*uid)); + let was_open = start.is_some_and(|(was_open, _)| was_open); if spec.undoable && let Some((uid, wb, sel)) = before && let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) && !Arc::ptr_eq(&d.wb, &wb) { - push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel); + let label = spec.label.trim_end_matches('…').to_string(); + if in_group_now { + // A group this command opened and left open (a debugger's start) takes the + // command's start as its own, so what the command changed before opening it is + // part of the group's step and not a step of its own. + if !was_open + && let Some((_, len)) = start + && let Some(g) = self.groups.iter_mut().filter(|g| g.doc == uid && g.id >= first_group).min_by_key(|g| g.id) + { + g.start = len; + g.wb = wb; + g.selection = sel; + } + } else if epoch_changed && was_open { + // The group it ended made its step; changes after the end, whether direct or by + // nested commands, are the command's own step from where the group ended. + if let Some(step) = self.group_step.as_ref().filter(|g| g.doc == uid) + && (d.undo.len() > step.len || !Arc::ptr_eq(&step.after, &d.wb)) + { + d.undo.truncate(step.len); + push_entry(&mut d.undo, label, step.after.clone(), &d.wb, step.selection.clone()); + d.redo.clear(); + } + } else { + let mut label = label; + if epoch_changed && let Some((_, len)) = start { + // A command whose only step is its group's keeps that name ("Run Macro: Totals"). + // That is so only when the one entry the command added is a group's step from + // the command's own start to its own result: an entry a nested command made, + // a group step of an earlier command (undone since), or a group's step that + // the command edited past all name the step after the command instead. + if d.undo.len() == len + 1 + && let Some(last) = d.undo.last() + && Arc::ptr_eq(&last.wb, &wb) + && self.group_step.as_ref().is_some_and(|g| g.doc == uid && g.stepped && Arc::ptr_eq(&g.after, &d.wb)) + { + label = last.label.clone(); + } + d.undo.truncate(len); + } + push_entry(&mut d.undo, label, wb, &d.wb, sel); + d.redo.clear(); + } + // Inside a group the redo history waits for the group: `group_end` clears it when the + // group becomes a step, and `group_rollback` puts it back as it was. + d.revision += 1; + } + // The history limit applies once the outermost command has collapsed its steps, so the + // lengths it compared stay true. Meanwhile the steps made inside it are bounded on their own. + if self.depth == 0 { + self.trim_history(); + self.group_step = None; + } else if let Some(uid) = doc_uid { + self.bound_tail(uid); + } + result + } + + /// Runs `f` as one undo step labelled `label` on the active document: whatever commands `f` + /// runs, one Undo brings back the workbook as it was before. If `f` fails, every change it made + /// is rolled back and its error is returned. Groups nest; the outermost one makes the step. + pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let token = self.group_begin(label)?; + let doc = token.doc(); + // A panic in `f` rolls the group back before it goes on, so a caller that catches it + // (outside `execute`, which drops a command's groups itself) finds no group left open. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(Ok(r)) => match self.group_end(token) { + Ok(_) => Ok(r), + // `f` closed the group's document (`file.close`, or `file.open` taking the place of + // an untouched new workbook), and the group went with it: nothing is left to undo. + Err(_) if !self.docs.iter().any(|d| d.uid == doc) => Ok(r), + Err(e) => Err(e), + }, + Ok(Err(e)) => { + // The error from `f` is the one to report. A rollback fails only when the group + // has already ended, and then there is nothing left to undo. + let _ = self.group_rollback(token); + Err(e) + } + Err(panic) => { + let _ = self.group_rollback(token); + std::panic::resume_unwind(panic) + } + } + } + + /// Opens an undo group on the active document that can span several calls (for example a + /// debugger that runs one step per call). Until [`Session::group_end`] or + /// [`Session::group_rollback`], undoable commands on that document add no undo steps of their + /// own, and Undo and Redo are unavailable there. + pub fn group_begin(&mut self, label: &str) -> Result { + let d = self.doc()?; + let g = OpenGroup { + id: self.next_group, + doc: d.uid, + label: label.trim_end_matches('…').to_string(), + start: d.undo.len(), + wb: d.wb.clone(), + selection: d.selection.clone(), + redo: d.redo.clone(), + }; + let token = GroupToken { id: g.id, doc: g.doc }; + self.next_group = self.next_group.saturating_add(1); + self.group_epoch = self.group_epoch.wrapping_add(1); + self.groups.push(g); + Ok(token) + } + + /// Ends an undo group. If the workbook changed, the group becomes one undo step, unless it is + /// nested in another open group on the same document, which then owns the step. Groups opened + /// inside it and still open end with it. Returns whether the workbook changed. + pub fn group_end(&mut self, token: GroupToken) -> Result { + let g = self.take_group(&token)?; + let outer = self.in_group(g.doc); + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(false) }; + // Steps pushed directly while the group was open fold into the group's step. + d.undo.truncate(g.start); + let changed = !Arc::ptr_eq(&d.wb, &g.wb); + if changed && !outer { + push_entry(&mut d.undo, g.label, g.wb, &d.wb, g.selection); d.redo.clear(); d.revision += 1; } - result + if !outer { + self.group_step = + Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: d.undo.len(), stepped: changed }); + } + // Inside a command, the outermost command applies the history limit when it finishes. + if self.depth == 0 { + self.trim_history(); + } + Ok(changed) + } + + /// Stops the running action on the document with uid `doc`: every undo group open on it is + /// rolled back as [`Session::group_rollback`] does, putting back the workbook, selection, undo + /// and redo history as they were when the outermost one began. Their tokens are then spent + /// (ending or rolling one back reports that it has already ended). Returns whether a group + /// was open. `automate.stopAction` runs it on the active document. + pub fn abort_groups(&mut self, doc: u64) -> bool { + let Some(id) = self.groups.iter().filter(|g| g.doc == doc).map(|g| g.id).min() else { return false }; + // Rolling back the outermost group ends the ones opened inside it. + self.group_rollback(GroupToken { id, doc }).is_ok() + } + + /// Rolls back every open undo group numbered `first` or later (the groups a failed command + /// opened), outermost first on each document. + fn rollback_groups_from(&mut self, first: u64) { + let opened: Vec<(u64, u64)> = self.groups.iter().filter(|g| g.id >= first).map(|g| (g.id, g.doc)).collect(); + for (id, doc) in opened { + // A group inside one rolled back before it is already gone. + if self.groups.iter().any(|g| g.id == id) { + let _ = self.group_rollback(GroupToken { id, doc }); + } + } + } + + /// Bounds the undo steps pushed while a command runs (a script of many steps, a subtotal per + /// group): at most [`UNDO_LIMIT`] of them within [`UNDO_BYTES_LIMIT`], dropping the oldest. + /// Only steps after the latest mark go: a running command's start, an open group's start and + /// the end of the last group are positions the command or group collapses its steps by when + /// it finishes, so what is before them stays as it is until [`Session::trim_history`]. + fn bound_tail(&mut self, uid: u64) { + let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; + let marks = self.marks.iter().filter(|(doc, _)| *doc == uid).map(|(_, len)| *len); + let groups = self.groups.iter().filter(|g| g.doc == uid).map(|g| g.start); + let step = self.group_step.as_ref().filter(|g| g.doc == uid).map(|g| g.len); + let mark = marks.chain(groups).chain(step).max().unwrap_or(0).min(d.undo.len()); + let mut bytes = d.undo.iter().skip(mark).fold(0usize, |sum, e| sum.saturating_add(e.retained_bytes)); + while d.undo.len() > mark + 1 && (d.undo.len() - mark > UNDO_LIMIT || bytes > UNDO_BYTES_LIMIT) { + bytes = bytes.saturating_sub(d.undo.remove(mark).retained_bytes); + } + } + + /// Drops the oldest undo steps past [`UNDO_LIMIT`] or [`UNDO_BYTES_LIMIT`]. A document with + /// an open group waits for the group to end, because the group remembers where its steps start. + fn trim_history(&mut self) { + let groups = &self.groups; + for d in self.docs.iter_mut().filter(|d| !groups.iter().any(|g| g.doc == d.uid)) { + trim_to_budget(&mut d.undo, UNDO_BYTES_LIMIT); + } + } + + /// Ends an undo group by putting back the workbook, selection, undo and redo history as they + /// were when it began. + pub fn group_rollback(&mut self, token: GroupToken) -> Result<()> { + let g = self.take_group(&token)?; + let Some(d) = self.docs.iter_mut().find(|d| d.uid == g.doc) else { return Ok(()) }; + if !Arc::ptr_eq(&d.wb, &g.wb) { + d.wb = g.wb; + d.calc.rebuild(&d.wb); + } + d.selection = g.selection; + d.undo.truncate(g.start); + d.redo = g.redo; + d.revision += 1; + // The record of the last group step stays when the document is back exactly where that + // step left it (this group began there); otherwise the rollback is where the group ended. + let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb)); + if !kept && !self.groups.iter().any(|o| o.doc == g.doc) { + self.group_step = Some(GroupStep { doc: g.doc, after: d.wb.clone(), selection: d.selection.clone(), len: g.start, stepped: false }); + } + Ok(()) + } + + /// Starts recording top-level commands into an empty buffer, replacing any recording in + /// progress. Queries, failed commands and commands run by other commands are not recorded. + pub fn start_recording(&mut self) { + self.recorder = Some(record::Recorder::default()); + } + + /// Stops recording and returns what was recorded (`None` when nothing was recording). + pub fn stop_recording(&mut self) -> Option { + self.recorder.take() + } + + /// Whether the recording buffer is collecting commands. + pub fn is_recording(&self) -> bool { + self.recorder.is_some() + } + + /// How many commands are running: 0 between top-level commands, 1 inside one, 2 inside a + /// command run by a command, and so on. + pub fn depth(&self) -> u32 { + self.depth + } + + /// Whether an undo group is open on the document with this uid. + pub fn in_group(&self, doc: u64) -> bool { + self.groups.iter().any(|g| g.doc == doc) + } + + /// Removes the token's group and any group on the same document opened after it. + fn take_group(&mut self, token: &GroupToken) -> Result { + let ended = || EngineError::Other("That undo group has already ended.".into()); + let at = self.groups.iter().position(|g| g.id == token.id).ok_or_else(ended)?; + let mut later = self.groups.split_off(at).into_iter(); + let g = later.next().ok_or_else(ended)?; + self.groups.extend(later.filter(|o| o.doc != g.doc)); + self.group_epoch = self.group_epoch.wrapping_add(1); + Ok(g) } pub fn run(&mut self, id: &str, params: Json) -> std::result::Result { @@ -468,6 +896,8 @@ impl Session { let r = self.execute(id, params); let requests = self.take_ui_requests(); let asked_for_file = requests.iter().any(|q| matches!(q, UiRequest::Dialog(name, _) if name == "open" || name == "saveAs")); + // A call that only opened a dialog is not recorded (see `execute`), so the recording + // has no step to take back here. if r.is_ok() && asked_for_file { let what = match id { "file.open" => "`path` or `base64`", diff --git a/crates/engine/src/record.rs b/crates/engine/src/record.rs new file mode 100644 index 000000000..7804be6c9 --- /dev/null +++ b/crates/engine/src/record.rs @@ -0,0 +1,66 @@ +//! The recording buffer: the commands a person (or an agent) ran while recording, each with the +//! document, sheet and selection it ran in. +//! +//! Only top-level calls are kept. A command that runs other commands (Custom Views › Show, a +//! script) is one step, so playing the recording back does not run the inner commands twice. The +//! buffer is separate from the journal, so trimming the journal never loses recorded steps, and +//! secret parameters are left out as they are in the journal ([`crate::cmd::journal_params`]). + +use serde::Serialize; +use serde_json::Value as Json; + +use crate::Selection; + +/// The most steps one recording keeps; later ones are counted in [`Recorder::dropped`]. +pub const RECORD_LIMIT: usize = 100_000; +/// The most bytes (steps serialized as JSON) one recording keeps. A step keeps its full +/// parameters so it can be played back, and a `file.open` holds the whole file as base64, so +/// without a size cap a recording of a few opens of a large workbook keeps tens of MB alive. +pub const RECORD_MAX_BYTES: usize = 64 * 1024 * 1024; + +/// One recorded command and where it ran. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RecordedStep { + /// Uid of the document that was active when the command ran (0 when none was open). + pub doc: u64, + /// That document's active sheet (index and name) just before the command ran. + pub sheet: usize, + pub sheet_name: String, + /// That document's selection just before the command ran. + pub selection: Selection, + /// The command id and its parameters, without secrets. + pub id: String, + pub params: Json, +} + +/// The steps recorded since recording started. +#[derive(Clone, Debug, Default)] +pub struct Recorder { + pub steps: Vec, + /// Steps not kept because the recording reached [`RECORD_LIMIT`] or [`RECORD_MAX_BYTES`]. + /// Once a step is dropped, every later one is too, so `steps` is always the beginning of + /// what ran, never a sequence with gaps. + pub dropped: usize, + /// The size of `steps` serialized as JSON. + bytes: usize, +} + +impl Recorder { + /// Adds a step, or counts it as dropped once the buffer is full. + pub fn push(&mut self, step: RecordedStep) { + // Once a step has been dropped, or the step count is full, the rest are counted without + // being serialized (a `file.open` step holds a whole file). + if self.dropped > 0 || self.steps.len() >= RECORD_LIMIT { + self.dropped = self.dropped.saturating_add(1); + return; + } + let size = serde_json::to_vec(&step).map_or(0, |v| v.len()); + if self.bytes.saturating_add(size) <= RECORD_MAX_BYTES { + self.bytes = self.bytes.saturating_add(size); + self.steps.push(step); + } else { + self.dropped = self.dropped.saturating_add(1); + } + } +} diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index 129fe2171..90029bac3 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -26,7 +26,7 @@ pub fn build(name: &str) -> Option { _ => return None, }; } - s.add_document(DocState::new(wb, None, title(name))); + s.add_document(DocState::new(wb, None, title(name), crate::Trust::Local)); let ok = match name { "budget" => budget(&mut s), "sales" => sales(&mut s), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 756a4d090..1be90d56c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3,6 +3,11 @@ use serde_json::json; use crate::Session; +thread_local! { + /// Copies of a document's undo and redo history `execute` has taken on this thread. + pub(crate) static HISTORY_COPIES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + fn s() -> Session { let mut s = Session::new(); s.new_workbook(); @@ -4599,3 +4604,4 @@ 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("

0.0) { continue; } let mut t = 0.0; - let mut n = 0; - while t < len && n < 10_000 { + while t < len && budget > 0 { let e = (t + on).min(len); out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len])); t += on + off; - n += 1; + budget -= 1; } } segs = out; @@ -262,3 +269,23 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec { } cv.buf } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dashed_geometry_stays_within_the_dash_budget() { + // Forty very long dashed lines, each allowed 10_000 dashes per segment: before the + // shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like + // number of quads before any of it reached a 4-pixel canvas (finding: dash expansion). + let prims: Vec = + (0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect(); + let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]); + assert_eq!(img.len(), 2 * 2 * 4); + // And the geometry itself is bounded even for one huge dashed polyline. + assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536); + let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }]; + assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4); + } +} diff --git a/crates/ui-egui/src/floating_pictures.rs b/crates/ui-egui/src/floating_pictures.rs index 68de6e203..5caa0869c 100644 --- a/crates/ui-egui/src/floating_pictures.rs +++ b/crates/ui-egui/src/floating_pictures.rs @@ -95,6 +95,13 @@ impl Cache { } None => (None, u32::MAX), }; + // Eviction only frees entries used before this frame, so a workbook covered in same-frame + // pictures can leave the cache unable to make room. Then draw the texture without + // retaining it: the handle is dropped at the end of the frame and the GPU memory goes + // with it, instead of the budget being outflanked (finding: cache admission). + if self.entries.len() >= MAX_CACHE_ITEMS || self.bytes.saturating_add(bytes) > MAX_CACHE_BYTES { + return texture; + } self.bytes = self.bytes.saturating_add(bytes); self.entries.insert(key, Entry { texture: texture.clone(), bytes, side, used: frame }); texture @@ -212,13 +219,24 @@ mod tests { } assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes); assert!(cache.entries.len() < data.len()); - // Many on screen at once, over the budget: every one keeps its texture, frame after frame. + // Many on screen at once, over the budget: each still gets a texture this frame, but the + // cache cannot grow past its budget whatever the workbook shows. Eight of the 16 MB + // textures fit the 128 MB budget, and those stay pinned; the rest re-upload each frame + // instead of being retained over it (finding: cache admission). + // Two frames pass so the rehearsal's entries are old enough to be evictable. + frame(&ctx); + frame(&ctx); let shown: Vec<_> = data.iter().enumerate().take(12).map(|(i, d)| cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id()).collect(); for _ in 0..3 { frame(&ctx); for (i, d) in data.iter().enumerate().take(12) { - assert_eq!(cache.texture(&ctx, key(1, i as u32, d), d, 2048).map(|t| t.id()), Some(shown[i])); + let id = cache.texture(&ctx, key(1, i as u32, d), d, 2048).unwrap().id(); + if i < 8 { + assert_eq!(id, shown[i], "a picture that fits keeps its texture"); + } } + assert!(cache.bytes <= MAX_CACHE_BYTES, "{} bytes kept", cache.bytes); + assert!(cache.entries.len() <= 8, "{} entries kept", cache.entries.len()); } } diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 4611ffe05..ce9432e4e 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -17,12 +17,16 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then exit 0 fi -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -KEYCHAIN="$TMP/gridcraft-signing.keychain-db" -CERT="$TMP/gridcraft-signing.p12" +BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +# A private directory created exclusively, not predictable names in a possibly shared temp +# root: another local user's symlink at `gridcraft-signing.p12` would otherwise select the +# write target and capture the decoded certificate. +WORK="$(mktemp -d "$BASE/gridcraft-signing.XXXXXXXX")" +KEYCHAIN="$WORK/gridcraft-signing.keychain-db" +CERT="$WORK/gridcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT +trap 'rm -rf "$WORK"' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" diff --git a/xtask/src/stats.rs b/xtask/src/stats.rs index f40b1300a..462f1c7de 100644 --- a/xtask/src/stats.rs +++ b/xtask/src/stats.rs @@ -2,6 +2,9 @@ use std::path::Path; +#[cfg(unix)] +use std::os::unix::fs::MetadataExt as _; + #[derive(Default, Clone, Copy)] struct Counts { files: usize, @@ -10,7 +13,24 @@ struct Counts { proptests: usize, } -fn walk(dir: &Path, c: &mut Counts) { +fn walk(dir: &Path, c: &mut Counts, seen: &mut std::collections::HashSet, budget: &mut usize) { + if *budget == 0 { + // Hard stop for pathological trees: two directory symlinks pointing at each other + // would otherwise be walked exponentially. + return; + } + // Directory identities (dev+inode on unix) make a symlink to a visited directory a + // no-op, not another exponential branch of the scan. + #[cfg(unix)] + let id = std::fs::metadata(dir).ok().and_then(|m| m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))); + #[cfg(not(unix))] + let id: Option = None; + if let Some(id) = id + && !seen.insert(id) + { + return; + } + *budget -= 1; let Ok(rd) = std::fs::read_dir(dir) else { return; }; @@ -18,10 +38,11 @@ fn walk(dir: &Path, c: &mut Counts) { let p = e.path(); let name = e.file_name(); let name = name.to_string_lossy(); + // A symlinked directory is descended only if its identity is new (checked above). if p.is_dir() { // Nested fuzz workspaces and build output are not part of the crate. if name != "target" && name != "fuzz" && !name.starts_with('.') { - walk(&p, c); + walk(&p, c, seen, budget); } } else if name.ends_with(".rs") && let Ok(s) = std::fs::read_to_string(&p) @@ -71,7 +92,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> { continue; }; let mut c = Counts::default(); - walk(&dir, &mut c); + walk(&dir, &mut c, &mut std::collections::HashSet::new(), &mut 10_000); let ex = if exact { exact_tests(&name) } else { None }; rows.push((name, c, ex)); } From 07890b47f8545ea71385a6f4b996a4a0a1ae2fe7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 03:41:44 -0700 Subject: [PATCH 083/149] Keyboard: Ctrl+Shift+U expands and collapses the formula bar instead of underlining Ctrl+Shift+U ran the Ctrl+U shortcut on Windows and Linux and underlined the selection. In Excel it expands or collapses the formula bar, on Windows and as Control+Shift+U on a Mac. It now does that on the grid and in the cell editor or formula bar while editing, and the entry stays open. The new UI command view.expandFormulaBar {on?} sets the state, or toggles it without a parameter, and returns {on}. The chevron at the end of the formula bar is unchanged. --- ROADMAP.md | 1 + crates/ui-egui/src/control.rs | 1 + crates/ui-egui/src/formula_bar.rs | 4 + crates/ui-egui/src/keymap.rs | 2 + crates/ui-egui/src/lib.rs | 4 + crates/ui-egui/tests/it/formula_bar_expand.rs | 95 +++++++++++++++++++ crates/ui-egui/tests/it/main.rs | 1 + docs/ui-parity.md | 8 +- 8 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/it/formula_bar_expand.rs diff --git a/ROADMAP.md b/ROADMAP.md index da06359b5..1d9cf5f48 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline | | 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text | | 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row | | 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) | diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 11d535ac4..8e29bccb4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -230,6 +230,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) - for (id, label, params) in [ ("ui.ribbonTab", "Ribbon Tab", ""), ("view.collapseRibbon", "Collapse Ribbon", ""), + ("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"), ("view.darkMode", "Dark Mode", ""), ("view.theme", "Display Theme", ""), ("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"), diff --git a/crates/ui-egui/src/formula_bar.rs b/crates/ui-egui/src/formula_bar.rs index b88699f57..3a33f3867 100644 --- a/crates/ui-egui/src/formula_bar.rs +++ b/crates/ui-egui/src/formula_bar.rs @@ -290,6 +290,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: state.store(ui.ctx(), id); } } + // Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar while editing too. + if ui.input_mut(|i| i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::SHIFT, Key::U)) { + app.run_or_alert("view.expandFormulaBar", json!({})); + } } // The text widget. let refs = formula_refs(&ed.text); diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 5840036a1..03bd2d655 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -103,6 +103,8 @@ pub fn lookup(key: Key, physical: Option, m: Modifiers, mac: bool) -> Optio L if (cmd || ctrl) && s => run("data.filter"), L if cmd && mac => run("home.alignLeft"), L if ctrl && mac => Shortcut::Dialog("defineName", json!({})), + // Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands or collapses the formula bar. + U if ctrl && s => run("view.expandFormulaBar"), U if ctrl && mac && !s => Shortcut::Edit, L if cmd => run("insert.table"), E if cmd && mac && !s => run("home.alignCenter"), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 14c442331..5b421a4db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -632,6 +632,10 @@ impl SheetApp { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; Ok(Json::Null) } + "view.expandFormulaBar" => { + self.ui.formula_bar_expanded = p.get("on").and_then(Json::as_bool).unwrap_or(!self.ui.formula_bar_expanded); + Ok(json!({"on": self.ui.formula_bar_expanded})) + } "view.darkMode" => { let current = if self.ui.system_theme { self.effective_dark } else { self.ui.dark }; self.ui.dark = p.get("on").and_then(Json::as_bool).unwrap_or(!current); diff --git a/crates/ui-egui/tests/it/formula_bar_expand.rs b/crates/ui-egui/tests/it/formula_bar_expand.rs new file mode 100644 index 000000000..95b4368e5 --- /dev/null +++ b/crates/ui-egui/tests/it/formula_bar_expand.rs @@ -0,0 +1,95 @@ +//! Ctrl+Shift+U (Control+Shift+U in Excel for Mac) expands and collapses the formula bar. + +use egui::{Event, Key, Modifiers}; +use gridcraft_engine::Session; +use gridcraft_ui_egui::SheetApp; +use gridcraft_ui_egui::keymap::{Shortcut, lookup}; +use serde_json::json; + +type Harness = egui_kittest::Harness<'static, SheetApp>; + +/// Ctrl+Shift on Windows and Linux sets `ctrl`, `command` and `shift`. +const CTRL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: true }; +/// Control+Shift on a Mac: `ctrl` without `command`. +const MAC_CONTROL_SHIFT: Modifiers = Modifiers { alt: false, ctrl: true, shift: true, mac_cmd: false, command: false }; + +fn harness() -> Harness { + let mut session = Session::new(); + session.new_workbook(); + session.execute("cell.set", json!({"cell": "A1", "input": "text"})).unwrap(); + session.execute("selection.set", json!({"range": "A1"})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state( + |ui, app: &mut SheetApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + SheetApp::new(session, Default::default()), + ); + h.run_steps(4); + h +} + +fn ctrl_shift_u(h: &mut Harness, m: Modifiers) { + h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: true, repeat: false, modifiers: m }); + h.input_mut().events.push(Event::Key { key: Key::U, physical_key: Some(Key::U), pressed: false, repeat: false, modifiers: m }); + h.run_steps(2); +} + +fn expanded(h: &Harness) -> bool { + h.state().ui.formula_bar_expanded +} + +fn underlined(h: &Harness) -> bool { + h.state().session.journal.iter().any(|(id, _)| id == "home.underline") +} + +#[test] +fn ctrl_shift_u_on_the_grid_expands_and_collapses_the_formula_bar() { + let mut h = harness(); + assert!(!expanded(&h)); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(expanded(&h), "the first Ctrl+Shift+U expands the formula bar"); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(!expanded(&h), "the second Ctrl+Shift+U collapses it"); + assert!(!underlined(&h), "Ctrl+Shift+U is not Underline"); +} + +#[test] +fn control_shift_u_expands_the_formula_bar_in_excel_for_mac() { + let mut h = harness(); + h.ctx.set_os(egui::os::OperatingSystem::Mac); + h.run_steps(2); + ctrl_shift_u(&mut h, MAC_CONTROL_SHIFT); + assert!(expanded(&h)); + assert!(h.state().editor.is_none(), "Control+Shift+U does not start editing as Control+U does"); + let expand = Some(Shortcut::Run("view.expandFormulaBar", json!({}))); + assert_eq!(lookup(Key::U, Some(Key::U), MAC_CONTROL_SHIFT, true), expand); + assert_eq!(lookup(Key::U, Some(Key::U), CTRL_SHIFT, false), expand); + // Control+U on a Mac still edits the active cell. + assert_eq!(lookup(Key::U, Some(Key::U), Modifiers::CTRL, true), Some(Shortcut::Edit)); +} + +#[test] +fn ctrl_shift_u_while_editing_keeps_the_entry() { + let mut h = harness(); + h.state_mut().begin_edit(Some("draft".into()), false); + h.run_steps(2); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(expanded(&h), "Ctrl+Shift+U works in the cell editor too"); + let ed = h.state().editor.as_ref().expect("the edit stays open"); + assert_eq!(ed.text, "draft"); + ctrl_shift_u(&mut h, CTRL_SHIFT); + assert!(!expanded(&h)); + assert!(h.state().editor.is_some()); +} + +#[test] +fn expand_formula_bar_command_sets_or_toggles_the_state() { + let mut h = harness(); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true}))); + assert!(expanded(&h)); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({"on": true})), Ok(json!({"on": true}))); + assert_eq!(h.state_mut().run("view.expandFormulaBar", json!({})), Ok(json!({"on": false}))); + assert!(!expanded(&h)); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 3a9769c1c..06ffaac02 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -34,6 +34,7 @@ mod find_dialog_focus; mod floating_pictures; mod font_size_box; mod format_painter; +mod formula_bar_expand; mod formula_locale; mod formula_point_headers; mod formula_popup; diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5d5f1efee..17538e580 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-23 · **Change:** minor (New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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-24 · **Change:** minor (Ctrl+Shift+U expands and collapses the formula bar; New Formatting Rule's formula box takes references clicked or dragged on the grid, #2348; Page Up and Ctrl+Up into frozen rows, Ctrl+Left and Alt+Page Up into frozen columns bring the scrolled area back; Auto Fill Options button after a left-drag of the fill handle (#1403); Show Outline Symbols, Ctrl+8; a too-wide number in a repeat-fill format such as Accounting shows #### instead of losing its leading digits; 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 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 @@ -134,9 +134,10 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w | F5, Shift+F5 (Mac), F7, Shift+F9, Ctrl+Alt+F9, F12, Ctrl+F4 | same | Go To, Find, Spelling, calculate sheet / all, Save As, close | **added** | | Shift+F10, Alt+Shift+F10, Ctrl+F1 | Shift+F10, Cmd+Option+R | context menu, Error Checking, ribbon | **added** | | F1, F3, F4 (repeat), F6 / Shift+F6, F8 / Shift+F8, F11, End mode | same | help, Paste Name, Repeat, pane cycling, extend/add mode, chart sheet | missing (no such feature yet) | -| Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+A after a function name | Control+Shift+A | argument names: `=SUMIF(` becomes `=SUMIF(range,criteria,sum_range)`, the `(` added when missing; one Undo takes them back | **added** | -| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Shift+U, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, formula bar height, tooltips | missing | +| Ctrl+Shift+U | Control+Shift+U | expand or collapse the formula bar (`view.expandFormulaBar`), on the grid and while editing; on Windows and Linux it no longer underlines | **added** | +| Ctrl+6, Ctrl+Q, Ctrl+A after a function name, Ctrl+Alt+P | same | objects, Quick Analysis, Function Arguments, tooltips | missing | | Ctrl+Shift+Page Up / Down, Ctrl+Alt+5, Ctrl+Shift+F1, Ctrl+F5–F10, Alt+F8 / F11 / F12 | same | group sheets, shape cycling, full screen, window keys, macros, VBA, Power Query | missing | | — | Cmd+= (calculate), Cmd+T (references while editing; F4 works), Shift+F7, Cmd+Shift+L | Mac-only extras | missing (Cmd+= is egui's zoom key; no thesaurus or Modify Cell Style) | @@ -178,6 +179,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Ctrl+Shift+U (Control+Shift+U on a Mac) expands or collapses the formula bar, as in Excel ("Expand or collapse the formula bar. Ctrl+Shift+U"), on the grid and in the cell editor or formula bar while editing. On Windows and Linux it used to underline the selection, as Ctrl+U does. The command is `view.expandFormulaBar` (`{on?}`, toggles without it) | | 2026-10-23 | minor | Conditional Formatting ▸ New Rule, formula: clicking a cell on the grid writes its absolute reference at the formula box's caret (`=$B$2`, an empty box starts with `=`), a drag writes a range (`$B$2:$C$4`) and a second click replaces the reference just written, as in Excel ("Selecting cells on the worksheet inserts absolute cell references"). The selection the rule applies to doesn't move, and the box keeps the keyboard. After an operand (`=A1>5`) the click does nothing (#2348) | | 2026-10-23 | minor | Frozen panes: Page Up, Ctrl+Up and Shift variants that move from the scrolled rows into the frozen ones bring the scrolled rows back to the top, so the rows in between are seen again (Page Up from far down used to end on row 1 with the view still a page down); Ctrl+Left and Alt+Page Up do the same for frozen columns. Other ways of selecting a frozen cell (click, Find, Go To, Undo, formula pointing) leave the view where it is | | 2026-10-23 | minor | After a left-drag of the fill handle an Auto Fill Options button sits below the filled cells; its menu redoes the fill as Copy Cells, Fill Series, Fill Formatting Only or Fill Without Formatting (undoes the drag's fill, then `edit.autoFill` in that mode). It goes away when the workbook changes or the selection moves (#1403) | 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 084/149] 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 d0fe8b6423694b9124e35cd8f283b7a58220dbe5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:50:46 -0700 Subject: [PATCH 085/149] Tests: the engine's file-system tests each use their own temporary folder save_fails_upfront_when_the_temp_name_is_taken, save_keeps_the_destination_access_restrictions and open_refuses_directories_and_unknowable_lengths in crates/engine/src/io.rs all worked in temp_dir()/gridcraft-fs-safety and removed it at the start and the end. cargo test runs them on parallel threads, so one test could delete the folder while another was writing to it, and the other test failed. Each test now uses its own folder, named with a short label and the process id, as the gridcraft-write and gridcraft-sandbox tests in the same file do. --- crates/engine/src/io.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index be47e0a67..92ab69038 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -770,7 +770,7 @@ mod tests { fn save_fails_upfront_when_the_temp_name_is_taken() { // Another local user who can write the directory must not be able to point the // save's predictable temporary name at their own file: creation is exclusive. - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let victim = dir.join("victim.txt"); @@ -788,7 +788,7 @@ mod tests { #[test] fn save_keeps_the_destination_access_restrictions() { use std::os::unix::fs::PermissionsExt; - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let dest = dir.join("private.xlsx"); @@ -808,7 +808,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn open_refuses_directories_and_unknowable_lengths() { - let dir = std::env::temp_dir().join("gridcraft-fs-safety"); + let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file"); From 3099df934eda27cd457a9b8d1525d3fc973a1b7b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 07:58:13 -0400 Subject: [PATCH 086/149] M7: verify Agile encryption's dataIntegrity before returning plaintext (#2425) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agile decryption checked only the password verifier, which authenticates the key — not the package. An attacker who could replace EncryptedPackage ciphertext could change workbook content undetected ([MS-OFFCRYPTO] 2.3.4.14, the descriptor's dataIntegrity element). The reader now requires the dataIntegrity pair, decrypts it with the recovered package key, and checks HMAC(hash, hmacKey) over the whole EncryptedPackage stream (StreamSize field included) against the stored value before any plaintext is returned. Excel always writes the element ("SHOULD" in the spec); descriptors without it are refused as damaged rather than accepted unauthenticated. - crypto: RFC 2104/4231 HMAC test vectors, RFC-test cases for a key longer than the block size - fixtures write the pair like Excel; new tests: missing/empty/ragged integrity fields, tampered ciphertext and descriptor, a changed StreamSize field --- crates/xlsx/src/crypto.rs | 58 ++++++++++++++++- crates/xlsx/src/crypto/tests.rs | 82 +++++++++++++++++++++++- crates/xlsx/src/testing.rs | 25 +++++--- crates/xlsx/src/tests/encrypted_tests.rs | 3 +- 4 files changed, 153 insertions(+), 15 deletions(-) diff --git a/crates/xlsx/src/crypto.rs b/crates/xlsx/src/crypto.rs index 46a19d046..0a63044a0 100644 --- a/crates/xlsx/src/crypto.rs +++ b/crates/xlsx/src/crypto.rs @@ -26,6 +26,9 @@ const BLOCK: usize = 16; pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79]; pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E]; pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6]; +/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field. +pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6]; +pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33]; /// The password key encryptor's URI (the other kind is certificate-based). pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password"; @@ -165,6 +168,14 @@ impl Hash { Hash::Sha512 => run::(parts), } } + + /// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64. + fn block_size(self) -> usize { + match self { + Hash::Sha384 | Hash::Sha512 => 128, + Hash::Sha1 | Hash::Sha256 => 64, + } + } } /// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds. @@ -184,6 +195,19 @@ pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec { k } +/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses. +pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec { + let bs = hash.block_size(); + let mut k = key.to_vec(); + if k.len() > bs { + k = hash.digest(&[&k]); + } + k.resize(bs, 0); + let pad = |x: u8| k.iter().map(|b| b ^ x).collect::>(); + let inner = hash.digest(&[&pad(0x36), message]); + hash.digest(&[&pad(0x5C), &inner]) +} + /// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the /// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`. pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec { @@ -368,8 +392,9 @@ impl Params { } } -/// What an Agile descriptor holds: the package's cipher, and the password key encryptor with its -/// three encrypted values (verifier input, verifier hash, package key). +/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its +/// three encrypted values (verifier input, verifier hash, package key), and the package's +/// integrity pair, which Excel always writes and the reader requires. struct AgileDescriptor { key_data: Params, key: Params, @@ -377,6 +402,8 @@ struct AgileDescriptor { verifier_input: Vec, verifier_hash: Vec, key_value: Vec, + hmac_key: Vec, + hmac_value: Vec, } /// `xml` is the XML encryption descriptor after the 8-byte version and reserved field. @@ -395,6 +422,16 @@ fn agile_descriptor(xml: &[u8]) -> Result { return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed"))); } let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?); + // A descriptor without dataIntegrity can't prove its ciphertext is the one the password + // protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14). + let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?; + let hmac_field = |attr: &str| { + let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?; + if v.is_empty() || v.len() % BLOCK != 0 { + return Err(malformed(format!("a whole-block {attr}"))); + } + Ok(v) + }; Ok(AgileDescriptor { key_data, key, @@ -402,11 +439,14 @@ fn agile_descriptor(xml: &[u8]) -> Result { verifier_input: field("encryptedVerifierHashInput")?, verifier_hash: field("encryptedVerifierHashValue")?, key_value: field("encryptedKeyValue")?, + hmac_key: hmac_field("encryptedHmacKey")?, + hmac_value: hmac_field("encryptedHmacValue")?, }) } fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptError> { - let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value } = agile_descriptor(xml)?; + let descriptor = agile_descriptor(xml)?; + let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor; let h = iterated_hash(key.hash, password, &key.salt, spin); let iv = key.iv(None); let field = |data: &[u8], block_key: &[u8]| -> Result, DecryptError> { @@ -423,6 +463,18 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result, DecryptE } let secret = field(&key_value, &BLOCK_KEY)?; let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?; + // Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the + // package key (the verifier above only authenticates the password), keyed by a random + // salt-size array, computed over the whole EncryptedPackage stream, StreamSize field included. + let mut mac_field = hmac_key; + aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?; + mac_field.truncate(key_data.salt.len()); + let mut declared = hmac_value; + aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?; + let mac = hmac(key_data.hash, &mac_field, package); + if declared.get(..mac.len()) != Some(mac.as_slice()) { + return Err(malformed("the decrypted data failed its integrity check")); + } let (size, data) = package_parts(package)?; let mut out = Vec::with_capacity(size); for (i, segment) in data.chunks(SEGMENT).enumerate() { diff --git a/crates/xlsx/src/crypto/tests.rs b/crates/xlsx/src/crypto/tests.rs index 0fb0b921b..5f14686e9 100644 --- a/crates/xlsx/src/crypto/tests.rs +++ b/crates/xlsx/src/crypto/tests.rs @@ -6,6 +6,9 @@ use super::*; use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header}; const PASSWORD: &str = "Pass1234"; +/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values +/// can't be reached anyway (scheme errors, descriptor-parse damage). +const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA=="; fn hex(s: &str) -> Vec { (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect() @@ -107,9 +110,9 @@ fn unsupported_schemes_are_named() { assert_eq!(unsupported(&rc4), "RC4"); assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible"); assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1"); - let des = agile_info(PASSWORD, 1000).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); + let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\""); assert_eq!(unsupported(&with_agile_header(&des)), "3DES"); - let cert = agile_info(PASSWORD, 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate"); assert_eq!(unsupported(&with_agile_header(&cert)), "certificate"); } @@ -136,7 +139,7 @@ fn damaged_files_are_malformed_not_panics() { ragged[..8].copy_from_slice(&len.to_le_bytes()); assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD))); } - let xml = agile_info(PASSWORD, 1000); + let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB); let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD)); assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\""))); assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*"))); @@ -155,3 +158,76 @@ fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() { hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a") ); } + +#[test] +fn hmac_matches_the_rfc2104_test_vectors() { + use crate::crypto::hmac as mac; + let h = |v: Vec| v.iter().map(|b| format!("{b:02x}")).collect::(); + // RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors. + assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00"); + assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"); + // RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first. + assert_eq!( + h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")), + "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54" + ); +} + +/// Strips the `` element from an Agile descriptor. +fn without_integrity(info: &[u8]) -> Vec { + let xml = std::str::from_utf8(&info[8..]).unwrap().to_string(); + let start = xml.find("").unwrap() + 2; + let stripped = format!("{}{}", &xml[..start], &xml[end..]); + with_agile_header(&stripped) +} + +#[test] +fn integrity_is_required_and_enforced() { + let plain = payload(300); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + // An attacker can't just delete the integrity element to get the package verified. + assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD), + Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity"))); + // An empty or ragged field is damage, not a pass. + for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] { + let xml = + agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1)); + let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD); + assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1); + } +} + +#[test] +fn modified_ciphertext_fails_the_integrity_check() { + let plain = payload(300); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + // The correct password proves the verifier can't vouch for a modified package. + for at in [8usize, 100, package.len() - 5] { + let mut tampered = package.clone(); + tampered[at] ^= 0x80; + assert!( + matches!(decrypt_streams(&info, &tampered, PASSWORD), + Err(DecryptError::Malformed(m)) if m.contains("integrity")), + "byte {at}" + ); + } + // And neither can the descriptor: a swapped integrity value is refused. + let xml = String::from_utf8_lossy(&info[8..]).to_string(); + let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len(); + let mut xml: Vec = xml.into_bytes(); + xml[at] ^= b'A' ^ b'B'; + assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_)))); +} + +#[test] +fn integrity_matches_the_stream_not_the_plaintext() { + // The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the + // stated size alone must fail, while the actual bytes round-trip. + let plain = payload(4200); + let (info, package) = encrypt_agile(&plain, PASSWORD, 1000); + let mut ours = package.clone(); + ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes()); + assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity"))); + assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain); +} diff --git a/crates/xlsx/src/testing.rs b/crates/xlsx/src/testing.rs index 348c08af5..5729250bb 100644 --- a/crates/xlsx/src/testing.rs +++ b/crates/xlsx/src/testing.rs @@ -9,8 +9,8 @@ use aes::cipher::{BlockCipherEncrypt, KeyInit}; use crate::crypto::{ - BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, SEGMENT, derive, iterated_hash, - standard_key, + BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params, + SEGMENT, derive, hmac, iterated_hash, standard_key, }; /// The spin count `encrypt_workbook` uses: Excel's default. @@ -21,6 +21,7 @@ const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, const SECRET_KEY: [u8; 32] = [64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95]; const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]; +const HMAC_SALT: [u8; 16] = [112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127]; /// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption /// (AES-256, SHA-512) inside a compound file. @@ -202,8 +203,9 @@ fn package(size: usize, body: Vec) -> Vec { p } -/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16. -pub(crate) fn agile_info(password: &str, spin: u32) -> String { +/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the +/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries. +pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String { let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 }; let h = iterated_hash(Hash::Sha512, password, &key.salt, spin); let iv = key.iv(None); @@ -217,9 +219,10 @@ pub(crate) fn agile_info(password: &str, spin: u32) -> String { base64_encode(salt) ) }; + // The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key. format!( - "\r\n", - params(&KEY_DATA_SALT), + "\r\n", + params(KEY_DATA_SALT.as_slice()), params(&PASSWORD_SALT), ) } @@ -231,14 +234,20 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec { info } -/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512). +/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the +/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it. pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec, Vec) { let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 }; let mut body = vec![]; for (i, segment) in plain.chunks(SEGMENT).enumerate() { body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment)); } - (with_agile_header(&agile_info(password, spin)), package(plain.len(), body)) + let stream = package(plain.len(), body); + let (hmac_key_b64, hmac_value_b64) = ( + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), &HMAC_SALT)), + base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, &HMAC_SALT, &stream))), + ); + (with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream) } /// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1). diff --git a/crates/xlsx/src/tests/encrypted_tests.rs b/crates/xlsx/src/tests/encrypted_tests.rs index 7a1a9bb82..525ed9193 100644 --- a/crates/xlsx/src/tests/encrypted_tests.rs +++ b/crates/xlsx/src/tests/encrypted_tests.rs @@ -56,7 +56,8 @@ fn schemes_no_password_opens_are_named_before_asking_for_one() { let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]); assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4")); assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible")); - let cert = agile_info("pw", 1000).replace("keyEncryptor/password", "keyEncryptor/certificate"); + let cert = + agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate"); assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate")); assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_)))); // Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo. From 276cd0fe78f543f6f5e5f82a4906970237a47aaf Mon Sep 17 00:00:00 2001 From: Arie Nugraha Date: Sat, 24 Oct 2026 13:00:37 +0000 Subject: [PATCH 087/149] 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 `