mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-30 21:56:53 -06:00
Compare commits
147
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55850841d3 | ||
|
|
1f73f62c4f | ||
|
|
fb1e59ff61 | ||
|
|
8f59487cbe | ||
|
|
71d4e2311d | ||
|
|
213fe1150e | ||
|
|
0db7d97f2e | ||
|
|
4d1233d0a2 | ||
|
|
07c8201786 | ||
|
|
31d5cc5b41 | ||
|
|
c0fcc7de16 | ||
|
|
5390e230d8 | ||
|
|
c9eb797d8b | ||
|
|
5441691ca3 | ||
|
|
d3f54debd9 | ||
|
|
64ea0cb69c | ||
|
|
f887ecf1cf | ||
|
|
ae598a74fe | ||
|
|
bc0fffbe97 | ||
|
|
1ebef5a694 | ||
|
|
f812c53814 | ||
|
|
b775b3ce32 | ||
|
|
76aed738e3 | ||
|
|
a44d3d5ee5 | ||
|
|
2bf5dbac84 | ||
|
|
c269ff483d | ||
|
|
15f6297001 | ||
|
|
006b7962bd | ||
|
|
7ea3a4c2c3 | ||
|
|
cae0c9abdb | ||
|
|
ed81d7e34e | ||
|
|
8f7dff0d04 | ||
|
|
7b7e734524 | ||
|
|
6d5ab57f84 | ||
|
|
a04629576d | ||
|
|
3174020a70 | ||
|
|
781149a593 | ||
|
|
322298b202 | ||
|
|
11d6ff1b79 | ||
|
|
eefa7c4a54 | ||
|
|
45939a8a41 | ||
|
|
70da8bb3ae | ||
|
|
a0da53f576 | ||
|
|
ca44a5d68f | ||
|
|
3b61d660e9 | ||
|
|
cca8a58bfa | ||
|
|
d3cd393dba | ||
|
|
8985685833 | ||
|
|
962a5df841 | ||
|
|
fea4c03ca2 | ||
|
|
1d9096e5ec | ||
|
|
8e740d97e1 | ||
|
|
a9bacdca4c | ||
|
|
53d6de3c16 | ||
|
|
6aa2cb8223 | ||
|
|
83e6408b71 | ||
|
|
1b1224c67a | ||
|
|
213b76af10 | ||
|
|
4e5c86d842 | ||
|
|
ad69d4cbdd | ||
|
|
32dbf6a224 | ||
|
|
11b54ebe40 | ||
|
|
9e710199b1 | ||
|
|
5ee65a8005 | ||
|
|
48c6b57060 | ||
|
|
ec2efe3246 | ||
|
|
5ed6a1b9c7 | ||
|
|
5d89b9efdf | ||
|
|
f1ff38504a | ||
|
|
0435ff50f9 | ||
|
|
550085f8b8 | ||
|
|
3a27743a38 | ||
|
|
dc691d52db | ||
|
|
bd8cf4e14a | ||
|
|
dc75555ee9 | ||
|
|
4d725e6bc1 | ||
|
|
9fccec28af | ||
|
|
52d317c8ff | ||
|
|
00359ee93a | ||
|
|
c895acf1f7 | ||
|
|
1f4c67ef0a | ||
|
|
8cf8425745 | ||
|
|
59a8b14d4d | ||
|
|
8f93ba2e9c | ||
|
|
75f7056e1b | ||
|
|
d0cc17852e | ||
|
|
d43baa2ceb | ||
|
|
15ea238fb7 | ||
|
|
b2f984a884 | ||
|
|
4afcaacd44 | ||
|
|
885a5dd6bc | ||
|
|
39020af9e9 | ||
|
|
7abaeba33c | ||
|
|
ecd72eccc4 | ||
|
|
ea0da03e12 | ||
|
|
569fb8b4c6 | ||
|
|
dec916081a | ||
|
|
da3e38750a | ||
|
|
3e4a2ccc9a | ||
|
|
27d5930d6b | ||
|
|
412571bc28 | ||
|
|
6f77f68fbc | ||
|
|
c305315610 | ||
|
|
e4d8aae522 | ||
|
|
92256075f2 | ||
|
|
9e2d5ef17f | ||
|
|
559a0369c3 | ||
|
|
1d38a25c8d | ||
|
|
6f067cce8a | ||
|
|
7d38697f71 | ||
|
|
57e40d2d14 | ||
|
|
9c6de8abde | ||
|
|
0688e83752 | ||
|
|
b004335538 | ||
|
|
45cc92ae3c | ||
|
|
94433a3c4f | ||
|
|
9e0f14cab9 | ||
|
|
bc1e19f1f6 | ||
|
|
d1f063636a | ||
|
|
6af06e9d30 | ||
|
|
f6b900dd5a | ||
|
|
9e23c98f3a | ||
|
|
f01c02cc41 | ||
|
|
f9b869b3a4 | ||
|
|
c22f46628c | ||
|
|
a81cb0f21e | ||
|
|
ff7ed4b57c | ||
|
|
3b73ac9b91 | ||
|
|
87342cb308 | ||
|
|
0f78a6b126 | ||
|
|
28a8e67e7a | ||
|
|
8b35f2ac12 | ||
|
|
a8e03ef40f | ||
|
|
fa6ccc6ae5 | ||
|
|
a38aed68cb | ||
|
|
7d828c01f7 | ||
|
|
4cc2c44678 | ||
|
|
ea5292c94a | ||
|
|
0edf2b850a | ||
|
|
2b6e088a6d | ||
|
|
acc32227cb | ||
|
|
1fadf98a10 | ||
|
|
f4f48f3b23 | ||
|
|
9595fc8ee7 | ||
|
|
9515c29969 | ||
|
|
8cfe970ff8 | ||
|
|
848b0e658a |
Generated
+16
-16
@@ -1936,7 +1936,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1963,7 +1963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1975,7 +1975,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1986,7 +1986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1998,7 +1998,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2009,7 +2009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -2031,7 +2031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2040,7 +2040,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2048,7 +2048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2057,7 +2057,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2069,18 +2069,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2105,7 +2105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2122,7 +2122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -6403,7 +6403,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.14.2"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
// With `noopener` the new browsing context can't hold a reference back to the
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
// editor window and navigate it while the user reads the linked page. It must be a
|
||||
// window *feature*: inside the target string it would only name the new window.
|
||||
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
||||
@@ -12,16 +12,47 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
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.
|
||||
/// How long a new connection may take to send its token, in total.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
|
||||
/// connect without the token, so past this a new connection is closed before anything is read:
|
||||
/// each waiting one holds a thread and a socket.
|
||||
const MAX_PENDING_AUTH: usize = 16;
|
||||
|
||||
/// How the server treats connections that haven't authenticated yet.
|
||||
#[derive(Clone, Copy)]
|
||||
struct AuthLimits {
|
||||
timeout: Duration,
|
||||
max_pending: usize,
|
||||
}
|
||||
|
||||
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
|
||||
|
||||
/// A connection that is still waiting to authenticate; dropping it frees its place.
|
||||
struct Pending(Arc<AtomicUsize>);
|
||||
|
||||
impl Pending {
|
||||
/// A place among the `max` waiting connections, or `None` when they are all taken.
|
||||
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
|
||||
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
|
||||
Some(Pending(Arc::clone(waiting)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
|
||||
}
|
||||
|
||||
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel();
|
||||
let waiting = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else {
|
||||
// Out of sockets or handles: wait for some to free instead of spinning on the error.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
};
|
||||
// Dropping the stream closes it before anything is read.
|
||||
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
let _ =
|
||||
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// 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<TcpStream>, 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),
|
||||
/// `timeout` in total) and check the token. `true` when the peer may go on.
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
|
||||
let mut first = Vec::new();
|
||||
let read = read_auth_line(reader, &mut first, deadline);
|
||||
let verdict = match (read, std::str::from_utf8(&first)) {
|
||||
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, 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);
|
||||
let _ = reader.get_ref().set_read_timeout(None);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
|
||||
///
|
||||
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
|
||||
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
|
||||
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
|
||||
let max = control_auth::MAX_AUTH_LINE;
|
||||
while line.len() < max && line.last() != Some(&b'\n') {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(std::io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
reader.get_ref().set_read_timeout(Some(left))?;
|
||||
let buf = reader.fill_buf()?;
|
||||
if buf.is_empty() {
|
||||
break;
|
||||
}
|
||||
let room = max.saturating_sub(line.len());
|
||||
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
|
||||
line.extend(buf.iter().take(take));
|
||||
reader.consume(take);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
|
||||
// Authenticated or refused, the connection no longer counts as waiting.
|
||||
drop(place);
|
||||
if !allowed {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
@@ -294,7 +368,7 @@ mod tests {
|
||||
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()));
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
|
||||
}
|
||||
});
|
||||
addr
|
||||
@@ -346,6 +420,81 @@ mod tests {
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
|
||||
/// `within`, and how long it took.
|
||||
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
|
||||
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
|
||||
let start = Instant::now();
|
||||
c.set_read_timeout(Some(within)).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match r.read_line(&mut line) {
|
||||
Ok(0) => return (true, start.elapsed()),
|
||||
Ok(_) => continue,
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
|
||||
Err(_) => return (true, start.elapsed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
|
||||
/// byte at a time keeps the connection (and its thread) past it.
|
||||
#[test]
|
||||
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
|
||||
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
|
||||
|
||||
let silent = TcpStream::connect(addr).unwrap();
|
||||
let mut slow = TcpStream::connect(addr).unwrap();
|
||||
let mut drip = slow.try_clone().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for b in control_auth::auth_line(1, TOKEN).bytes() {
|
||||
if drip.write_all(&[b]).is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
let (closed, took) = closed_within(silent, Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
|
||||
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
|
||||
let _ = slow.flush();
|
||||
}
|
||||
|
||||
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
|
||||
/// once, and a waiting one that authenticates frees its place.
|
||||
#[test]
|
||||
fn connections_waiting_to_authenticate_are_capped() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
|
||||
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
|
||||
|
||||
let mut first = TcpStream::connect(addr).unwrap();
|
||||
let _second = TcpStream::connect(addr).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
|
||||
|
||||
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut next = TcpStream::connect(addr).unwrap();
|
||||
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(next).read_line(&mut reply).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
|
||||
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
|
||||
{
|
||||
log::warn!("sample {s}: {e}");
|
||||
}
|
||||
if session.documents().is_empty() {
|
||||
session.new_workbook();
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.start_update_check();
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
// Stamp the startup workbook's author from the persisted user name.
|
||||
if app.session.documents().is_empty() {
|
||||
app.session.new_workbook();
|
||||
}
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
|
||||
// opens them: a password-protected one asks for its password, and errors are shown, not
|
||||
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
|
||||
|
||||
@@ -390,11 +390,12 @@ impl NativeMenu {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
|
||||
// harness, so the #[test] functions are left out and a module-level import would be unused.
|
||||
|
||||
#[test]
|
||||
fn file_menu_has_export_as_pdf_dispatching_the_command() {
|
||||
let tree = tree();
|
||||
let tree = super::tree();
|
||||
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
|
||||
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
|
||||
assert_eq!(item.1, "file.exportPdf");
|
||||
@@ -403,23 +404,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = tree();
|
||||
let tree = super::tree();
|
||||
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
|
||||
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
|
||||
assert_eq!(item.1, "app.checkForUpdates");
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run(item.1, serde_json::json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
for mode in ["dark", "system", "light"] {
|
||||
assert!(select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert_eq!(app.ui.theme_mode(), mode);
|
||||
assert!(app.message.is_none());
|
||||
}
|
||||
assert!(!select_theme(&mut app, "view.formulaBar"));
|
||||
assert!(!super::select_theme(&mut app, "view.formulaBar"));
|
||||
assert_eq!(app.ui.theme_mode(), "light");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,23 @@ fn main() {
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let _menu = native_menu::NativeMenu::install(&ctx);
|
||||
let mut menu = native_menu::NativeMenu::install(&ctx);
|
||||
|
||||
let bar = nsapp.mainMenu().expect("a main menu");
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu");
|
||||
assert_eq!(windows_menu.title().to_string(), "Window");
|
||||
|
||||
// A poll lists the open workbook windows at the end of the Window menu (#716).
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default());
|
||||
menu.poll(&mut app, &ctx, egui::ViewportId::ROOT);
|
||||
let open = app.open_windows();
|
||||
assert_eq!(open.len(), 1, "one workbook window");
|
||||
let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item");
|
||||
assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
@@ -106,3 +106,6 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Plutonium-fream]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifted_lookups_stop_past_their_work_budget() {
|
||||
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
|
||||
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
|
||||
// Ordinary lifted lookups are unaffected.
|
||||
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
|
||||
t.set(&format!("B{}", i + 1), &k.to_string());
|
||||
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
|
||||
}
|
||||
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
|
||||
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
|
||||
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
|
||||
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
|
||||
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
|
||||
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
|
||||
cmd!(
|
||||
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
|
||||
wb.set_active_sheet(target_sheet(s, p)?);
|
||||
}
|
||||
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
|
||||
let bytes = crate::io::save_bytes(&wb, &target)?;
|
||||
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
|
||||
crate::io::csv_values_bytes(&wb)?
|
||||
} else {
|
||||
crate::io::save_bytes(&wb, &target)?
|
||||
};
|
||||
crate::io::write_file(&target, &bytes)?;
|
||||
Ok(json!({"path": target, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
|
||||
use super::objects::draw_cell_picture;
|
||||
use super::text::{TextPlacement, draw_text};
|
||||
use super::{
|
||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
|
||||
split_sheet,
|
||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
|
||||
Workbook, split_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
|| merges.iter().any(|m| m.start == *c)
|
||||
|| !sh.value(*c).is_empty()
|
||||
|| l.style.borders != Borders::default()
|
||||
|| l.style.num_fmt.as_str() == "checkbox"
|
||||
})
|
||||
.collect();
|
||||
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
|
||||
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
}
|
||||
if let Some(picture) = sh.cell_pictures.get(c) {
|
||||
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
||||
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
|
||||
draw_checkbox(pg, (x, y, w, h), b.k, on);
|
||||
} else {
|
||||
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
|
||||
}
|
||||
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
|
||||
/// print as text.
|
||||
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
|
||||
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
|
||||
return None;
|
||||
}
|
||||
match v {
|
||||
Value::Bool(on) => Some(*on),
|
||||
Value::Empty => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
|
||||
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
|
||||
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
|
||||
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
|
||||
if on {
|
||||
let green = [0x10, 0x7C, 0x41];
|
||||
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
|
||||
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
|
||||
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
|
||||
} else {
|
||||
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PendingPdfLink {
|
||||
pub(super) page: usize,
|
||||
pub(super) sheet: usize,
|
||||
|
||||
@@ -565,3 +565,19 @@ fn rotated_wrapped_text_prints_rotated() {
|
||||
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
||||
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_cells_print_as_boxes_not_true_false() {
|
||||
// #2972: a checkbox cell printed its value as TRUE or FALSE.
|
||||
let mut s = s();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
|
||||
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
|
||||
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
|
||||
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
|
||||
}
|
||||
|
||||
@@ -298,7 +298,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -521,7 +521,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
let right = shift == "right";
|
||||
|
||||
@@ -593,7 +593,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
};
|
||||
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
|
||||
if let Some(s) = stop
|
||||
&& ((v >= prev && v > s) || (v < prev && v < s))
|
||||
&& ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s))
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -806,6 +806,21 @@ mod tests {
|
||||
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_keeps_value_equal_to_stop_after_float_drift() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap();
|
||||
s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::<Vec<_>>();
|
||||
let n = Value::number;
|
||||
assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]);
|
||||
assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_programs() {
|
||||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||||
|
||||
@@ -306,6 +306,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
|
||||
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
|
||||
/// writes displayed text instead ([`save_bytes`]) and stays the default.
|
||||
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
|
||||
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
|
||||
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
|
||||
gridcraft_core::Value::Number(n) if n.is_finite() => {
|
||||
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
|
||||
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
|
||||
Some(((date, time), d)) => iso(&d, date, time),
|
||||
None => n.to_string(),
|
||||
}
|
||||
}
|
||||
gridcraft_core::Value::Text(t) => t.to_string(),
|
||||
v => crate::display::value_text_as(wb, &v, "General"),
|
||||
})
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
|
||||
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
|
||||
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
|
||||
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
|
||||
if d.milli != 0 {
|
||||
clock.push_str(&format!(".{:03}", d.milli));
|
||||
}
|
||||
match (date, time) {
|
||||
(true, true) => format!("{day}T{clock}"),
|
||||
(false, true) => clock,
|
||||
_ => day,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
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
|
||||
|
||||
@@ -587,6 +587,7 @@ impl Session {
|
||||
pub fn new_workbook(&mut self) -> usize {
|
||||
let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
|
||||
let mut wb = Workbook::new();
|
||||
wb.props.author = self.prefs.user_name.clone();
|
||||
for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
|
||||
let name = wb.next_sheet_name();
|
||||
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
|
||||
|
||||
@@ -2007,6 +2007,15 @@ fn a_whole_column_table_without_headers_stays_on_the_sheet() {
|
||||
assert!(e.to_string().contains("pushed off the end"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_column_next_to_far_right_data_names_gridcraft_in_the_refusal() {
|
||||
let mut sess = s();
|
||||
sess.execute("cell.set", json!({"cell": "XFD1", "input": "2"})).unwrap();
|
||||
let e = sess.execute("home.insertColumns", json!({"cols": "A:A"})).unwrap_err();
|
||||
assert!(e.to_string().contains("GridCraft cannot shift nonblank cells"), "{e}");
|
||||
assert!(!e.to_string().contains("Excel"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_cells_refuses_to_push_values_off_the_sheet() {
|
||||
for (shift, edge, last_fit, last_ok) in [("right", "XFD1", "XFC1", "XFD1"), ("down", "A1048576", "A1048575", "A1048576")] {
|
||||
@@ -2015,6 +2024,7 @@ fn inserting_cells_refuses_to_push_values_off_the_sheet() {
|
||||
sess.execute("cell.set", json!({"cell": edge, "input": "2"})).unwrap();
|
||||
let e = sess.execute("home.insertCells", json!({"range": "A1", "shift": shift})).unwrap_err();
|
||||
assert!(e.to_string().contains("loss of data"), "{e}");
|
||||
assert!(e.to_string().contains("GridCraft") && !e.to_string().contains("Excel"), "{e}");
|
||||
assert_eq!(v(&sess, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&sess, edge), Value::Number(2.0));
|
||||
// A value that still fits moves to the last valid cell.
|
||||
@@ -2500,6 +2510,53 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// `file.exportCsv` writes displayed text by default (Excel's Save As CSV); `values: true` writes the
|
||||
/// raw values for automation: full precision, ISO dates and times.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn export_csv_values_writes_raw_values_and_the_default_stays_displayed_text() {
|
||||
let mut s = s();
|
||||
for (cell, input, code) in [
|
||||
("A1", "1234.5", "$#,##0.00"),
|
||||
("B1", "0.123456789", "0.00"),
|
||||
("C1", "46023", "d-mmm-yy"),
|
||||
("D1", "46023.5", "yyyy-mm-dd hh:mm"),
|
||||
("E1", "0.75", "h:mm"),
|
||||
("F1", "TRUE", "General"),
|
||||
("G1", "a,b", "General"),
|
||||
("H1", "=1/0", "General"),
|
||||
("I1", "1", "h:mm"),
|
||||
("J1", "1.5", "h:mm"),
|
||||
("K1", "46023", "yyyy-mm-dd hh:mm"),
|
||||
("L1", "1.5", "[h]:mm"),
|
||||
("M1", "2.5", "[>=3]yyyy-mm-dd;[<3]hh:mm"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": cell, "code": code})).unwrap();
|
||||
}
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-csv-values-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let displayed = dir.join("displayed.csv");
|
||||
s.execute("file.exportCsv", json!({"path": displayed})).unwrap();
|
||||
s.execute("file.exportCsv", json!({"path": dir.join("off.csv"), "values": false})).unwrap();
|
||||
let raw = dir.join("raw.csv");
|
||||
s.execute("file.exportCsv", json!({"path": raw, "values": true})).unwrap();
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
|
||||
let default = crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap();
|
||||
// The default export is byte for byte what Save As CSV writes, and `values: false` is the default.
|
||||
assert_eq!(std::fs::read(&displayed).unwrap(), default);
|
||||
assert_eq!(std::fs::read(dir.join("off.csv")).unwrap(), default);
|
||||
let default = String::from_utf8(default).unwrap();
|
||||
assert!(default.starts_with("\"$1,234.50\",0.12,1-Jan-26,"), "{default}");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&raw).unwrap(),
|
||||
"1234.5,0.123456789,2026-01-01,2026-01-01T12:00:00,18:00:00,TRUE,\"a,b\",#DIV/0!,00:00:00,12:00:00,2026-01-01T00:00:00,1.5,12:00:00\r\n"
|
||||
);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
|
||||
fn linked_workbook(link: &str) -> Vec<u8> {
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
@@ -23,9 +23,11 @@ mod pivot_unknown_func;
|
||||
mod pivotchart_link;
|
||||
mod range_fill_formats;
|
||||
mod row_autofit;
|
||||
mod sheet_copy_spill;
|
||||
mod sheet_name_quoting;
|
||||
mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod workbook_author;
|
||||
mod wrap_row_height;
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::calc::Calc;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) {
|
||||
s.execute(command, params).unwrap();
|
||||
}
|
||||
|
||||
fn value(s: &Session, sheet: &str, cell: &str) -> Value {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
wb.sheet(wb.sheet_index(sheet).unwrap()).unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
/// Issue #2053: the copy of a sheet holding a spill keeps the spill's values, in memory and after a full recalc.
|
||||
#[test]
|
||||
fn copied_sheet_keeps_spill_values_through_full_recalc() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"sample": "grades"}));
|
||||
run(&mut s, "home.mergeCells", json!({"range": "F:H"}));
|
||||
run(&mut s, "cell.set", json!({"cell": "J54", "input": "=SEQUENCE(5)*C6"}));
|
||||
run(&mut s, "selection.set", json!({"range": "15:15"}));
|
||||
run(&mut s, "sheet.move", json!({"copy": true, "sheet": 0, "to": 1}));
|
||||
run(&mut s, "cell.set", json!({"array": true, "cell": "D3", "input": "=SUM('Grades (2)'!J54#)"}));
|
||||
|
||||
let before = value(&s, "Grades (2)", "J55");
|
||||
assert!(matches!(before, Value::Number(_)), "J55 should hold a spilled number, got {before:?}");
|
||||
|
||||
let mut fresh = (*s.doc().unwrap().wb).clone();
|
||||
Calc::new().recalc_all(&mut fresh);
|
||||
let after = fresh.sheet(fresh.sheet_index("Grades (2)").unwrap()).unwrap().value(CellRef::parse("J55").unwrap());
|
||||
assert_eq!(before, after);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Regression tests for the default author of newly created workbooks.
|
||||
|
||||
use gridcraft_engine::{DocState, Session, Trust, io, model::Workbook};
|
||||
|
||||
#[test]
|
||||
fn new_workbook_stamps_the_configured_user_name_at_creation() {
|
||||
let mut session = Session::new();
|
||||
|
||||
session.prefs.user_name = "Ada Lovelace".into();
|
||||
let first = session.new_workbook();
|
||||
|
||||
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
|
||||
|
||||
// Changing the preference must not rewrite an existing workbook.
|
||||
session.prefs.user_name = "Grace Hopper".into();
|
||||
|
||||
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
|
||||
|
||||
// A new workbook uses the preference as it exists at creation.
|
||||
let second = session.new_workbook();
|
||||
|
||||
assert_eq!(session.documents()[second].wb.props.author, "Grace Hopper");
|
||||
|
||||
assert_eq!(session.documents()[first].wb.props.author, "Ada Lovelace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_and_resaving_preserves_the_existing_workbook_author() {
|
||||
// An existing XLSX has its own author metadata.
|
||||
let mut source = Workbook::new();
|
||||
source.props.author = "Original Spreadsheet Author".into();
|
||||
|
||||
let bytes = io::save_bytes(&source, "source.xlsx").unwrap();
|
||||
let (opened, _) = io::open_bytes("source.xlsx", &bytes).unwrap();
|
||||
|
||||
let mut session = Session::new();
|
||||
session.prefs.user_name = "Current GridCraft User".into();
|
||||
|
||||
session.add_document(DocState::new(opened, None, "source".into(), Trust::FromFile));
|
||||
|
||||
// Opening an existing workbook must not replace its author.
|
||||
assert_eq!(session.doc().unwrap().wb.props.author, "Original Spreadsheet Author");
|
||||
|
||||
// The original author must survive another XLSX save and reopen.
|
||||
let saved = io::save_bytes(&session.doc().unwrap().wb, "saved.xlsx").unwrap();
|
||||
|
||||
let (reopened, _) = io::open_bytes("saved.xlsx", &saved).unwrap();
|
||||
|
||||
assert_eq!(reopened.props.author, "Original Spreadsheet Author");
|
||||
}
|
||||
@@ -175,8 +175,8 @@ fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
||||
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
|
||||
}
|
||||
(Some(c), None) if s.specifiers.is_empty() => {
|
||||
// Excel writes a name with spaces at its edges in the double-bracket form.
|
||||
if c.trim() == c {
|
||||
// Names with spaces at their edges, or starting with `@` (this-row shorthand), use the double-bracket form.
|
||||
if c.trim() == c && !c.starts_with('@') {
|
||||
let _ = write!(out, "[{}]", esc(c));
|
||||
} else {
|
||||
let _ = write!(out, "[[{}]]", esc(c));
|
||||
|
||||
@@ -558,3 +558,12 @@ fn reference_shapes_print_unchanged() -> Result<(), Box<dyn std::error::Error>>
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_starting_with_at_round_trips() {
|
||||
for name in ["@a", "a@b"] {
|
||||
let s = StructRef { table: "T".into(), specifiers: vec![], col_start: Some(name.into()), col_end: None };
|
||||
let text = format!("={}", printer::struct_ref(&s));
|
||||
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn base_source(a: &[Arg]) -> R<String> {
|
||||
Ok(number_to_text(n))
|
||||
}
|
||||
Value::Empty => Ok(String::new()),
|
||||
v => Ok(v.to_text()?.trim().to_string()),
|
||||
v => Ok(v.to_text()?.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,15 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
|
||||
if v < min || v > max {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let places = places
|
||||
.map(|p| {
|
||||
let p = p.trunc();
|
||||
if !(1.0..=10.0).contains(&p) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok(p as usize)
|
||||
})
|
||||
.transpose()?;
|
||||
if v < 0 {
|
||||
let u = v + (1i64 << bits);
|
||||
return text_val(to_radix(u, radix));
|
||||
@@ -70,11 +79,6 @@ fn format_base(v: i64, radix: u32, places: Option<f64>) -> R<Value> {
|
||||
match places {
|
||||
None => text_val(s),
|
||||
Some(p) => {
|
||||
let p = p.trunc();
|
||||
if !(1.0..=10.0).contains(&p) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let p = p as usize;
|
||||
if s.len() > p {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -97,7 +101,13 @@ fn to_radix(mut v: i64, radix: u32) -> String {
|
||||
}
|
||||
|
||||
fn places_arg(a: &[Arg]) -> R<Option<f64>> {
|
||||
if has(a, 1) { Ok(Some(num(a, 1)?)) } else { Ok(None) }
|
||||
if !has(a, 1) {
|
||||
return Ok(None);
|
||||
}
|
||||
if matches!(a.get(1).map(scalar), Some(Value::Bool(_))) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
Ok(Some(num(a, 1)?))
|
||||
}
|
||||
|
||||
fn convert_base(a: &[Arg], from: u32, to: u32) -> R<Value> {
|
||||
@@ -175,6 +185,9 @@ fn bitxor(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn shift(a: &[Arg], left: bool) -> R<Value> {
|
||||
let n = bit_operand(a, 0)?;
|
||||
let s = num(a, 1)?.trunc();
|
||||
if n == 0 {
|
||||
return Ok(Value::Number(0.0));
|
||||
}
|
||||
if s.abs() > 53.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -248,11 +261,26 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
|
||||
im1!(imln, |z: Cx| z.ln());
|
||||
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
|
||||
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
|
||||
// Excel takes the polar route, so IMSQRT("-4") is 1.22514845490862E-16+2i, not 2i. Its cosine
|
||||
// reduces its argument with a 64-bit-mantissa pi, which is PI/2 + 2.5e-20 and shows up as the
|
||||
// stray real part. `+ 0.0` turns a -0 imaginary part into +0, so the angle stays +pi.
|
||||
// shortcut: parts above ~1E154 or below ~1E-155 keep the exact result (Excel gives #NUM! or 0), no known use.
|
||||
const HALF_PI_EXCESS: f64 = 2.508_278_806_33e-20;
|
||||
im1!(imsqrt, |z: Cx| {
|
||||
let m = z.abs();
|
||||
let re = ((m + z.re) / 2.0).sqrt();
|
||||
let im = ((m - z.re) / 2.0).sqrt();
|
||||
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
|
||||
let im = z.im + 0.0;
|
||||
let theta = match z.re {
|
||||
0.0 => {
|
||||
if im == 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
std::f64::consts::FRAC_PI_2.copysign(im)
|
||||
}
|
||||
}
|
||||
x if x > 0.0 => (im / x).atan(),
|
||||
x => (im / x).atan() + if im < 0.0 { -std::f64::consts::PI } else { std::f64::consts::PI },
|
||||
};
|
||||
let (half, root) = (theta / 2.0, z.abs().sqrt());
|
||||
Ok(Cx::new(root * (half.cos() + HALF_PI_EXCESS * half.abs().sin()), root * half.sin()))
|
||||
});
|
||||
im1!(imsin, |z: Cx| Ok(z.sin()));
|
||||
im1!(imcos, |z: Cx| Ok(z.cos()));
|
||||
@@ -387,6 +415,12 @@ mod tests {
|
||||
is_text(ev("BIN2OCT", vec![n(1111111111.0)]), "7777777777");
|
||||
is_text(ev("DEC2BIN", vec![n(9.0), n(4.0)]), "1001");
|
||||
is_text(ev("DEC2BIN", vec![n(-100.0)]), "1110011100");
|
||||
is_err(ev("DEC2BIN", vec![n(-1.0), n(0.0)]), CellError::Num);
|
||||
is_err(ev("DEC2HEX", vec![n(-1.0), n(11.0)]), CellError::Num);
|
||||
is_err(ev("DEC2OCT", vec![n(-1.0), n(-1.0)]), CellError::Num);
|
||||
is_err(ev("DEC2BIN", vec![n(1.0), crate::Arg::val(Value::Bool(true))]), CellError::Value);
|
||||
is_text(ev("DEC2BIN", vec![n(-1.0), n(1.0)]), "1111111111");
|
||||
is_text(ev("DEC2HEX", vec![n(-1.0), n(10.0)]), "FFFFFFFFFF");
|
||||
is_err(ev("DEC2BIN", vec![n(512.0)]), CellError::Num);
|
||||
is_text(ev("DEC2HEX", vec![n(100.0), n(4.0)]), "0064");
|
||||
is_text(ev("DEC2HEX", vec![n(-54.0)]), "FFFFFFFFCA");
|
||||
@@ -411,6 +445,21 @@ mod tests {
|
||||
is_text(ev("OCT2HEX", vec![n(100.0), n(4.0)]), "0040");
|
||||
is_text(ev("OCT2HEX", vec![n(7777777533.0)]), "FFFFFFFF5B");
|
||||
is_err(ev("BIN2DEC", vec![n(102.0)]), CellError::Num);
|
||||
for (f, input) in [
|
||||
("BIN2DEC", " 10"),
|
||||
("BIN2DEC", "10 "),
|
||||
("BIN2DEC", "\t10"),
|
||||
("BIN2HEX", " 10"),
|
||||
("BIN2OCT", "10 "),
|
||||
("HEX2DEC", " 10"),
|
||||
("HEX2BIN", "10 "),
|
||||
("HEX2OCT", "\t10"),
|
||||
("OCT2DEC", " 10"),
|
||||
("OCT2BIN", "10 "),
|
||||
("OCT2HEX", "\t10"),
|
||||
] {
|
||||
is_err(ev(f, vec![t(input)]), CellError::Num);
|
||||
}
|
||||
is_err(ev("HEX2DEC", vec![t("GG")]), CellError::Num);
|
||||
is_err(ev("BIN2DEC", vec![t("11111111111")]), CellError::Num);
|
||||
is_err(ev("DEC2BIN", vec![t("abc")]), CellError::Value);
|
||||
@@ -431,6 +480,21 @@ mod tests {
|
||||
is_err(ev("BITAND", vec![n(1.5), n(5.0)]), CellError::Num);
|
||||
is_err(ev("BITLSHIFT", vec![n(1.0), n(54.0)]), CellError::Num);
|
||||
is_err(ev("BITLSHIFT", vec![n(2f64.powi(47)), n(1.0)]), CellError::Num);
|
||||
for f in ["BITLSHIFT", "BITRSHIFT"] {
|
||||
for shift in [-100.0, -54.0, -53.0, 53.0, 54.0, 100.0] {
|
||||
close(ev(f, vec![n(0.0), n(shift)]), 0.0);
|
||||
}
|
||||
for shift in [-100.0, -54.0, 54.0, 100.0] {
|
||||
is_err(ev(f, vec![n(1.0), n(shift)]), CellError::Num);
|
||||
}
|
||||
if f == "BITLSHIFT" {
|
||||
is_err(ev(f, vec![n(1.0), n(53.0)]), CellError::Num);
|
||||
} else {
|
||||
close(ev(f, vec![n(1.0), n(53.0)]), 0.0);
|
||||
}
|
||||
is_err(ev(f, vec![n(0.0), t("bad")]), CellError::Value);
|
||||
}
|
||||
is_err(ev("BITRSHIFT", vec![n(-1.0), n(100.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -509,9 +573,36 @@ mod tests {
|
||||
cx(ev("IMLOG10", vec![t("3+4i")]), 0.698_970_004_336_018_8, 0.402_719_196_273_373_2);
|
||||
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
|
||||
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
|
||||
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
|
||||
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
|
||||
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
|
||||
// Strings read back from Excel (16.0): its stray real part on negative reals included.
|
||||
for (input, want) in [
|
||||
("-4", "1.22514845490862E-16+2i"),
|
||||
("-9", "1.83772268236293E-16+3i"),
|
||||
("-1", "6.1257422745431E-17+i"),
|
||||
("-2", "8.66310780426106E-17+1.4142135623731i"),
|
||||
("-100", "6.1257422745431E-16+10i"),
|
||||
("-1E10", "6.1257422745431E-12+100000i"),
|
||||
("-1E-10", "6.1257422745431E-22+0.00001i"),
|
||||
("-4+0j", "1.22514845490862E-16+2j"),
|
||||
("-4-0i", "1.22514845490862E-16+2i"),
|
||||
("-4-1E-20i", "1.22514845490862E-16-2i"),
|
||||
("-1+1E-10i", "5.00000653944413E-11+i"),
|
||||
("-0.5-1E-12i", "7.07212958930194E-13-0.707106781186548i"),
|
||||
("4", "2"),
|
||||
("2", "1.4142135623731"),
|
||||
("0", "0"),
|
||||
("3+4i", "2+i"),
|
||||
("3+4j", "2+j"),
|
||||
("-3+4i", "1+2i"),
|
||||
("-3-4i", "1-2i"),
|
||||
("-2i", "1-i"),
|
||||
("2i", "1+i"),
|
||||
("i", "0.707106781186548+0.707106781186547i"),
|
||||
("5-12i", "3-2i"),
|
||||
("-5+12i", "2+3i"),
|
||||
("1E10+1E10i", "109868.411346781+45508.9860562227i"),
|
||||
] {
|
||||
is_text(ev("IMSQRT", vec![t(input)]), want);
|
||||
}
|
||||
is_text(ev("IMSQRT", vec![t("4")]), "2");
|
||||
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
|
||||
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
|
||||
|
||||
@@ -311,11 +311,8 @@ fn hyperlink(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = a.get(1).map(scalar).unwrap_or(Value::Empty);
|
||||
return Ok(empty_to_zero(v));
|
||||
}
|
||||
let link = a.first().map(scalar).unwrap_or(Value::Empty);
|
||||
if let Value::Error(e) = link {
|
||||
return Err(e);
|
||||
}
|
||||
Ok(link)
|
||||
// Excel returns the link as text, so HYPERLINK(1) is "1" and ISTEXT is TRUE.
|
||||
Ok(Value::Text(text(a, 0)?.into()))
|
||||
}
|
||||
|
||||
fn encodeurl(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -608,6 +605,12 @@ mod tests {
|
||||
is_text(ev("HYPERLINK", vec![t("http://x.com")]), "http://x.com");
|
||||
is_text(ev("HYPERLINK", vec![t("http://x.com"), t("Click")]), "Click");
|
||||
close(ev("HYPERLINK", vec![t("http://x.com"), n(5.0)]), 5.0);
|
||||
// Excel 16: without a friendly name the result is the link as text.
|
||||
is_text(ev("HYPERLINK", vec![n(1.0)]), "1");
|
||||
is_text(ev("HYPERLINK", vec![n(1.5)]), "1.5");
|
||||
is_text(ev("HYPERLINK", vec![b(true)]), "TRUE");
|
||||
is_text(ev("HYPERLINK", vec![rf(Value::Empty)]), "");
|
||||
is_err(ev("HYPERLINK", vec![e(CellError::Div0)]), CellError::Div0);
|
||||
is_text(ev("ENCODEURL", vec![t("a b&c/é")]), "a%20b%26c%2F%C3%A9");
|
||||
is_err(ev("WEBSERVICE", vec![t("http://x")]), CellError::Value);
|
||||
is_err(ev("FILTERXML", vec![t("<a/>"), t("//a")]), CellError::Value);
|
||||
|
||||
@@ -58,6 +58,35 @@ fn reject_multi_cell_refs(spec: &FnSpec, args: &[Arg]) -> Option<Vec<Arg>> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Lookups whose every call searches a lookup vector from its start, so a call lifted over `n`
|
||||
/// needles does `n` x (vector length) work. Their search arguments don't lift.
|
||||
const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XMATCH"];
|
||||
|
||||
/// Work budget of a lifted [`SCANS`] lookup: needle cells times the length of the longest vector
|
||||
/// it searches (the longest dimension of its largest non-lifted array). Each needle re-reads and
|
||||
/// scans the vector (about 8 ns per entry), so without a cap a one-cell formula such as
|
||||
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file.
|
||||
/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst
|
||||
/// case to a few seconds and still admits 5,000 needles over a 100,000-row table.
|
||||
const LIFTED_SCAN_BUDGET: usize = 500_000_000;
|
||||
|
||||
/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec`
|
||||
/// is one of the [`SCANS`] lookups and a non-lifted argument is an array.
|
||||
fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize {
|
||||
if !SCANS.contains(&spec.name) {
|
||||
return 0;
|
||||
}
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !spec.is_scalar_arg(*i))
|
||||
.filter_map(|(_, a)| match &a.value {
|
||||
Value::Array(arr) => Some(arr.rows.max(arr.cols)),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -76,7 +105,8 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val
|
||||
if !any {
|
||||
return (spec.imp)(args, ctx);
|
||||
}
|
||||
if rows.saturating_mul(cols) > MAX_CELLS {
|
||||
let needles = rows.saturating_mul(cols);
|
||||
if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
let mut cell_args: Vec<Arg> = args.to_vec();
|
||||
@@ -149,6 +179,36 @@ mod tests {
|
||||
is_err(huge, CellError::Num);
|
||||
}
|
||||
|
||||
/// Security: a lifted lookup searches its whole lookup vector once per needle, so needles x
|
||||
/// vector length past `LIFTED_SCAN_BUDGET` is `#NUM!` at once.
|
||||
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` used to run for hours (and hung opening
|
||||
/// the 1.6 KB file holding it).
|
||||
#[test]
|
||||
fn lifted_lookups_stop_past_their_work_budget() {
|
||||
use gridcraft_core::Array;
|
||||
let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into());
|
||||
let wide = |n: usize| av(Array::filled(1, n, nv(-1.0)).into());
|
||||
is_err(ev("XMATCH", vec![tall(1_048_576), tall(1_048_576)]), CellError::Num);
|
||||
// Just past the budget, for every lookup: 501 needles over 1,000,000 entries.
|
||||
assert_eq!(super::LIFTED_SCAN_BUDGET, 500 * 1_000_000);
|
||||
is_err(ev("XMATCH", vec![tall(501), tall(1_000_000)]), CellError::Num);
|
||||
is_err(ev("MATCH", vec![tall(501), tall(1_000_000), n(0.0)]), CellError::Num);
|
||||
is_err(ev("XLOOKUP", vec![tall(501), tall(1_000_000), tall(1_000_000)]), CellError::Num);
|
||||
is_err(ev("VLOOKUP", vec![tall(501), tall(1_000_000), n(1.0), b(false)]), CellError::Num);
|
||||
is_err(ev("HLOOKUP", vec![tall(501), wide(1_000_000), n(1.0), b(false)]), CellError::Num);
|
||||
is_err(ev("LOOKUP", vec![tall(501), tall(1_000_000)]), CellError::Num);
|
||||
// Within the budget lifted lookups work as before.
|
||||
let keys = || av(col(&[10.0, 20.0, 30.0]));
|
||||
let pos = ev("XMATCH", vec![av(col(&[30.0, 10.0, 99.0])), keys()]);
|
||||
assert_eq!(rows_of(&pos), vec![vec![nv(3.0)], vec![nv(1.0)], vec![Value::Error(CellError::NA)]]);
|
||||
let found = ev("XLOOKUP", vec![av(row(&[20.0, 30.0])), keys(), av(col(&[1.0, 2.0, 3.0]))]);
|
||||
assert_eq!(rows_of(&found), vec![vec![nv(2.0), nv(3.0)]]);
|
||||
// Other lifted functions aren't metered: INDEX reads one entry per position.
|
||||
let picks: Vec<f64> = (1..=1000u32).map(f64::from).collect();
|
||||
let out = ev("INDEX", vec![tall(1_048_576), av(col(&picks))]);
|
||||
assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_params_reject_multi_cell_references() {
|
||||
let s = FnSpec { value_args: &[true, false], ..spec() };
|
||||
|
||||
@@ -694,6 +694,16 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
f!("ROUNDDOWN", 2, 2, MathTrig, S, "ROUNDDOWN(number, num_digits)", "Rounds a number toward zero to a given number of digits.", rounddown),
|
||||
f!("MROUND", 2, 2, MathTrig, S, "MROUND(number, multiple)", "Rounds a number to the nearest multiple.", mround),
|
||||
f!("CEILING", 2, 2, Compatibility, S, "CEILING(number, significance)", "Rounds a number up to a multiple of significance.", ceiling),
|
||||
f!(
|
||||
"ECMA.CEILING",
|
||||
2,
|
||||
2,
|
||||
Compatibility,
|
||||
S,
|
||||
"ECMA.CEILING(number, significance)",
|
||||
"Rounds a number up to a multiple of significance.",
|
||||
ceiling
|
||||
),
|
||||
f!(
|
||||
"CEILING.MATH",
|
||||
1,
|
||||
|
||||
@@ -101,6 +101,11 @@ fn rounding() {
|
||||
close(ev2("CEILING", 1.5, 0.1), 1.5);
|
||||
close(ev2("CEILING", 0.234, 0.01), 0.24);
|
||||
is_err(ev2("CEILING", 2.5, -1.0), Num);
|
||||
// ECMA.CEILING is CEILING under a second name (Excel table from #2776).
|
||||
close(ev2("ECMA.CEILING", 1.5, 1.0), 2.0);
|
||||
close(ev2("ECMA.CEILING", -2.5, -2.0), -4.0);
|
||||
close(ev2("ECMA.CEILING", 2.5, 0.0), 0.0);
|
||||
is_err(ev2("ECMA.CEILING", 1.5, -1.0), Num);
|
||||
close(ev2("FLOOR", 3.7, 2.0), 2.0);
|
||||
close(ev2("FLOOR", -2.5, -2.0), -2.0);
|
||||
close(ev2("FLOOR", -2.5, 2.0), -4.0);
|
||||
|
||||
@@ -420,7 +420,10 @@ pub(super) fn geomean(v: &[f64]) -> R<f64> {
|
||||
}
|
||||
|
||||
pub(super) fn harmean(v: &[f64]) -> R<f64> {
|
||||
if v.is_empty() || v.iter().any(|&x| x <= 0.0) {
|
||||
if v.is_empty() {
|
||||
return Err(CellError::NA);
|
||||
}
|
||||
if v.iter().any(|&x| x <= 0.0) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok(v.len() as f64 / v.iter().map(|x| 1.0 / x).sum::<f64>())
|
||||
|
||||
@@ -22,7 +22,14 @@ fn t_cdf(t: f64, v: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn t_pdf(t: f64, v: f64) -> f64 {
|
||||
(ln_gamma((v + 1.0) / 2.0) - ln_gamma(v / 2.0) - 0.5 * (v * std::f64::consts::PI).ln() - (v + 1.0) / 2.0 * (1.0 + t * t / v).ln()).exp()
|
||||
let z = v / 2.0;
|
||||
let log_gamma_ratio = if z >= 16.0 {
|
||||
let inv_z = 1.0 / z;
|
||||
0.5 * z.ln() - inv_z / 8.0 + inv_z.powi(3) / 192.0 - inv_z.powi(5) / 640.0 + 17.0 * inv_z.powi(7) / 14_336.0
|
||||
} else {
|
||||
ln_gamma(z + 0.5) - ln_gamma(z)
|
||||
};
|
||||
(log_gamma_ratio - 0.5 * (v.ln() + std::f64::consts::PI.ln()) - (v + 1.0) / 2.0 * (t * t / v).ln_1p()).exp()
|
||||
}
|
||||
|
||||
/// t ≥ 0 with P(T > t) = q, q in (0, 0.5].
|
||||
@@ -51,6 +58,12 @@ fn f_cdf(x: f64, d1: f64, d2: f64) -> f64 {
|
||||
if x <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
if x == 1.0 && d1 == d2 {
|
||||
return 0.5;
|
||||
}
|
||||
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
|
||||
return reg_gamma_q(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
|
||||
}
|
||||
reg_inc_beta(d1 * x / (d1 * x + d2), d1 / 2.0, d2 / 2.0)
|
||||
}
|
||||
|
||||
@@ -58,6 +71,10 @@ fn f_upper(x: f64, d1: f64, d2: f64) -> f64 {
|
||||
if x <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
if d1 >= 1e8 && d2 / d1 <= 1e-8 {
|
||||
// With a much larger numerator degree, the scaled beta tail converges to a gamma tail.
|
||||
return reg_gamma_p(d2 / 2.0, d2 / (2.0 * (x + d2 / d1)));
|
||||
}
|
||||
reg_inc_beta(d2 / (d2 + d1 * x), d2 / 2.0, d1 / 2.0)
|
||||
}
|
||||
|
||||
@@ -74,6 +91,8 @@ fn f_pdf(x: f64, d1: f64, d2: f64) -> f64 {
|
||||
((d1 / 2.0) * (d1 / d2).ln() + (d1 / 2.0 - 1.0) * x.ln() - ((d1 + d2) / 2.0) * (1.0 + d1 * x / d2).ln() - ln_beta(d1 / 2.0, d2 / 2.0)).exp()
|
||||
}
|
||||
|
||||
const MAX_BINOM_TRIALS: f64 = 2_147_483_646.0;
|
||||
|
||||
fn binom_pmf(k: f64, n: f64, p: f64) -> f64 {
|
||||
if k < 0.0 || k > n {
|
||||
return 0.0;
|
||||
@@ -176,8 +195,14 @@ fn df_arg(a: &[Arg], i: usize) -> R<f64> {
|
||||
|
||||
pub(super) fn t_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
let x = num(a, 0)?;
|
||||
let v = df_arg(a, 1)?;
|
||||
let v = num(a, 1)?.trunc();
|
||||
let cum = boolean(a, 2)?;
|
||||
if v == 0.0 {
|
||||
return Err(if cum { CellError::Num } else { CellError::Div0 });
|
||||
}
|
||||
if v < 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(if cum { t_cdf(x, v) } else { t_pdf(x, v) })
|
||||
}
|
||||
|
||||
@@ -209,6 +234,9 @@ pub(super) fn tdist_legacy(a: &[Arg], _: C) -> R<Value> {
|
||||
pub(super) fn t_inv(a: &[Arg], _: C) -> R<Value> {
|
||||
let p = num(a, 0)?;
|
||||
let v = df_arg(a, 1)?;
|
||||
if p == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
if !(p > 0.0 && p < 1.0) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
@@ -347,7 +375,7 @@ fn f_args(a: &[Arg]) -> R<(f64, f64, f64)> {
|
||||
let x = num(a, 0)?;
|
||||
let d1 = num(a, 1)?.trunc();
|
||||
let d2 = num(a, 2)?.trunc();
|
||||
if d1 < 1.0 || d2 < 1.0 || d1 >= 1e10 || d2 >= 1e10 {
|
||||
if d1 < 1.0 || d2 < 1.0 || d1 > 1e10 || d2 > 1e10 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok((x, d1, d2))
|
||||
@@ -378,7 +406,8 @@ pub(super) fn f_inv(a: &[Arg], _: C) -> R<Value> {
|
||||
if p == 0.0 {
|
||||
return num_val(0.0);
|
||||
}
|
||||
num_val(invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60))
|
||||
let value = if p < 0.5 { invert(|x| f_cdf(x, d1, d2), p, 0.0, 1.0, 1e60) } else { invert(|x| f_upper(x, d1, d2), 1.0 - p, 0.0, 1.0, 1e60) };
|
||||
num_val(value)
|
||||
}
|
||||
|
||||
pub(super) fn f_inv_rt(a: &[Arg], _: C) -> R<Value> {
|
||||
@@ -410,7 +439,7 @@ pub(super) fn binom_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
let n = num(a, 1)?.trunc();
|
||||
let p = num(a, 2)?;
|
||||
let cum = boolean(a, 3)?;
|
||||
if k < 0.0 || k > n || !(0.0..=1.0).contains(&p) {
|
||||
if k < 0.0 || n > MAX_BINOM_TRIALS || k > n || !(0.0..=1.0).contains(&p) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(if cum { binom_cdf(k, n, p) } else { binom_pmf(k, n, p) })
|
||||
@@ -421,7 +450,7 @@ pub(super) fn binom_dist_range(a: &[Arg], _: C) -> R<Value> {
|
||||
let p = num(a, 1)?;
|
||||
let s1 = num(a, 2)?.trunc();
|
||||
let s2 = if has(a, 3) { num(a, 3)?.trunc() } else { s1 };
|
||||
if !(0.0..=1e15).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
||||
if !(0.0..=MAX_BINOM_TRIALS).contains(&n) || !(0.0..=1.0).contains(&p) || s1 < 0.0 || s1 > n || s2 < s1 || s2 > n {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
if s2 - s1 > 10_000.0 {
|
||||
@@ -499,13 +528,7 @@ pub(super) fn gamma_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
return num_val(reg_gamma_p(al, x / be));
|
||||
}
|
||||
if x == 0.0 {
|
||||
return if al < 1.0 {
|
||||
Err(CellError::Num)
|
||||
} else if al == 1.0 {
|
||||
num_val(1.0 / be)
|
||||
} else {
|
||||
num_val(0.0)
|
||||
};
|
||||
return if al <= 1.0 { Err(CellError::Num) } else { num_val(0.0) };
|
||||
}
|
||||
num_val(((al - 1.0) * x.ln() - x / be - al * be.ln() - ln_gamma(al)).exp())
|
||||
}
|
||||
@@ -548,7 +571,7 @@ pub(super) fn beta_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
if cum {
|
||||
return num_val(reg_inc_beta(z, al, be));
|
||||
}
|
||||
if (z == 0.0 && al < 1.0) || (z == 1.0 && be < 1.0) {
|
||||
if (z == 0.0 && al <= 1.0) || (z == 1.0 && be <= 1.0) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let lp = if z == 0.0 || z == 1.0 {
|
||||
@@ -608,6 +631,9 @@ pub(super) fn weibull_dist(a: &[Arg], _: C) -> R<Value> {
|
||||
if x < 0.0 || al <= 0.0 || be <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
if x == 0.0 {
|
||||
return num_val(0.0);
|
||||
}
|
||||
let t = (x / be).powf(al);
|
||||
num_val(if cum { -(-t).exp_m1() } else { al / be.powf(al) * x.powf(al - 1.0) * (-t).exp() })
|
||||
}
|
||||
|
||||
@@ -151,6 +151,19 @@ fn spread() {
|
||||
let g = [4.0, 5.0, 8.0, 7.0, 11.0, 4.0, 3.0];
|
||||
close_tol(ev("GEOMEAN", vec![c(&g)]), 5.476986969, 1e-9);
|
||||
close_tol(ev("HARMEAN", vec![c(&g)]), 5.028375962, 1e-9);
|
||||
let text_values = arr(vec![vec![tv("1"), tv("2")]]);
|
||||
let bool_values = arr(vec![vec![Value::Bool(true), Value::Bool(false)]]);
|
||||
let mixed_values = arr(vec![vec![nv(2.0), tv("ignored"), Value::Bool(true)]]);
|
||||
is_err(ev("HARMEAN", vec![av(text_values.clone())]), CellError::NA);
|
||||
is_err(ev("HARMEAN", vec![av(bool_values.clone())]), CellError::NA);
|
||||
is_err(ev("HARMEAN", vec![rf(text_values)]), CellError::NA);
|
||||
is_err(ev("HARMEAN", vec![rf(bool_values)]), CellError::NA);
|
||||
close(ev("HARMEAN", vec![av(mixed_values.clone())]), 2.0);
|
||||
close(ev("HARMEAN", vec![rf(mixed_values)]), 2.0);
|
||||
close(ev("HARMEAN", vec![b(true)]), 1.0);
|
||||
close(ev("HARMEAN", vec![n(2.0), n(4.0)]), 8.0 / 3.0);
|
||||
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(0.0)]]))]), CellError::Num);
|
||||
is_err(ev("HARMEAN", vec![av(arr(vec![vec![nv(-1.0)]]))]), CellError::Num);
|
||||
is_err(ev("GEOMEAN", vec![c(&[1.0, 0.0])]), CellError::Num);
|
||||
close_tol(ev("TRIMMEAN", vec![c(&[4.0, 5.0, 6.0, 7.0, 2.0, 3.0, 4.0, 5.0, 1.0, 2.0, 3.0]), n(0.2)]), 3.777777778, 1e-9);
|
||||
is_err(ev("TRIMMEAN", vec![c(&[1.0]), n(1.0)]), CellError::Num);
|
||||
@@ -360,6 +373,7 @@ fn normal() {
|
||||
|
||||
#[test]
|
||||
fn t_chi_f() {
|
||||
close_tol(ev("T.DIST", vec![n(0.0), n(6e307), b(false)]), 0.3989422804, 1e-10);
|
||||
close_tol(ev("T.DIST", vec![n(60.0), n(1.0), b(true)]), 0.99469533, D7);
|
||||
close_tol(ev("T.DIST", vec![n(8.0), n(3.0), b(false)]), 0.00073691, 1e-5);
|
||||
close_tol(ev("T.DIST.2T", vec![n(1.959999998), n(60.0)]), 0.054644930, D7);
|
||||
@@ -397,6 +411,7 @@ fn t_chi_f() {
|
||||
close_tol(ev("F.DIST.RT", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
||||
close_tol(ev("FDIST", vec![n(15.2069), n(6.0), n(4.0)]), 0.01, 1e-6);
|
||||
close_tol(ev("F.INV", vec![n(0.01), n(6.0), n(4.0)]), 0.10930991, D7);
|
||||
close_tol(ev("F.INV", vec![n(0.99), n(6.0), n(4.0)]), 15.20686, D7);
|
||||
close_tol(ev("F.INV.RT", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
||||
close_tol(ev("FINV", vec![n(0.01), n(6.0), n(4.0)]), 15.20686, D7);
|
||||
close_tol(ev("F.TEST", vec![c(&[6.0, 7.0, 9.0, 15.0, 21.0]), c(&[20.0, 28.0, 31.0, 38.0, 40.0])]), 0.64831785, D7);
|
||||
@@ -404,6 +419,48 @@ fn t_chi_f() {
|
||||
is_err(ev("F.DIST", vec![n(1.0), n(0.0), n(4.0), b(true)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f_distributions_accept_ten_billion_degrees() {
|
||||
close_tol(ev("F.DIST", vec![n(0.0), n(10_000_000_000.0), n(1.0), b(false)]), 0.0, 0.0);
|
||||
close_tol(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_000.0)]), 1.0, 0.0);
|
||||
close_tol(ev("F.DIST", vec![n(0.01), n(1_000_000_000.0), n(1.0), b(true)]), 1.52397e-23, 1e-28);
|
||||
close_tol(ev("F.DIST", vec![n(1.0), n(10_000_000_000.0), n(10_000_000_000.0), b(true)]), 0.5, 0.0);
|
||||
close_tol(ev("F.DIST", vec![n(1_000_000.0), n(10_000_000_000.0), n(1.0), b(true)]), 0.999202115, 1e-8);
|
||||
close_tol(ev("F.INV", vec![n(0.5), n(10_000_000_000.0), n(1.0)]), 2.198110953542527, 2e-6);
|
||||
close_tol(ev("F.DIST.RT", vec![n(0.0), n(9_999_999_999.0), n(1.0)]), 1.0, 0.0);
|
||||
is_err(ev("F.DIST.RT", vec![n(0.0), n(10_000_000_001.0), n(1.0)]), CellError::Num);
|
||||
is_err(ev("F.DIST.RT", vec![n(0.0), n(1.0), n(10_000_000_001.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_dist_zero_degrees_error_depends_on_mode() {
|
||||
for df in [0.0, 0.9, -0.1] {
|
||||
is_err(ev("T.DIST", vec![n(0.0), n(df), b(false)]), CellError::Div0);
|
||||
is_err(ev("T.DIST", vec![n(0.0), n(df), b(true)]), CellError::Num);
|
||||
}
|
||||
is_err(ev("T.DIST", vec![n(0.0), n(-1.0), b(false)]), CellError::Num);
|
||||
close_tol(ev("T.DIST", vec![n(0.0), n(1.99), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
|
||||
close_tol(ev("T.DIST", vec![n(0.0), n(1.0), b(false)]), 1.0 / std::f64::consts::PI, 1e-15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_density_is_stable_for_large_degrees() {
|
||||
close_tol(ev("T.DIST", vec![n(0.0), n(100_000_000.0), b(false)]), 0.398942279404077, 1e-12);
|
||||
close_tol(ev("T.DIST", vec![n(0.0), n(10_000_000_000.0), b(false)]), 0.3989422803914591, 1e-12);
|
||||
close_tol(ev("T.DIST", vec![n(1.0), n(10_000_000_000_000_000.0), b(false)]), 0.24197072451914337, 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_inv_zero_probability_returns_div0() {
|
||||
is_err(ev("T.INV", vec![n(-0.1), n(1.0)]), CellError::Num);
|
||||
is_err(ev("T.INV", vec![n(0.0), n(1.0)]), CellError::Div0);
|
||||
close_tol(ev("T.INV", vec![n(0.75), n(2.0)]), 0.8164966, D7);
|
||||
is_err(ev("T.INV", vec![n(1.0), n(1.0)]), CellError::Num);
|
||||
is_err(ev("T.INV", vec![n(1.1), n(1.0)]), CellError::Num);
|
||||
is_err(ev("T.INV", vec![n(0.0), n(0.0)]), CellError::Num);
|
||||
is_err(ev("T.INV.2T", vec![n(0.0), n(1.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binom_inv_huge_trials_returns() {
|
||||
// Above 2^53 neighbouring f64 values are more than 1 apart; the bisection must still end.
|
||||
@@ -427,7 +484,15 @@ fn discrete() {
|
||||
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
||||
close_tol(ev("BINOM.DIST", vec![n(6.0), n(10.0), n(0.5), b(true)]), 0.828125, 1e-12);
|
||||
close_tol(ev("BINOMDIST", vec![n(6.0), n(10.0), n(0.5), b(false)]), 0.205078125, 1e-12);
|
||||
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.0), n(0.0), b(false)]), 0.0);
|
||||
close(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_646.9), n(0.0), b(false)]), 0.0);
|
||||
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.0), n(0.0), b(false)]), CellError::Num);
|
||||
is_err(ev("BINOM.DIST", vec![n(1.0), n(2_147_483_647.9), n(0.0), b(false)]), CellError::Num);
|
||||
is_err(ev("BINOM.DIST", vec![n(11.0), n(10.0), n(0.5), b(false)]), CellError::Num);
|
||||
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.0), n(0.0), n(1.0)]), 0.0);
|
||||
close(ev("BINOM.DIST.RANGE", vec![n(2_147_483_646.9), n(0.0), n(1.0)]), 0.0);
|
||||
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.0), n(0.0), n(1.0)]), CellError::Num);
|
||||
is_err(ev("BINOM.DIST.RANGE", vec![n(2_147_483_647.9), n(0.0), n(1.0)]), CellError::Num);
|
||||
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(48.0)]), 0.083974967, 1e-6);
|
||||
close_tol(ev("BINOM.DIST.RANGE", vec![n(60.0), n(0.75), n(45.0), n(50.0)]), 0.523629793, 1e-6);
|
||||
close(ev("BINOM.INV", vec![n(6.0), n(0.5), n(0.75)]), 4.0);
|
||||
@@ -466,6 +531,19 @@ fn continuous() {
|
||||
is_err(ev("GAMMA", vec![n(0.0)]), CellError::Num);
|
||||
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(true), n(1.0), n(3.0)]), 0.6854706, D7);
|
||||
close_tol(ev("BETA.DIST", vec![n(2.0), n(8.0), n(10.0), b(false), n(1.0), n(3.0)]), 1.4837646, D7);
|
||||
is_err(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||
is_err(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||
is_err(ev("BETA.DIST", vec![n(0.0), n(0.5), n(2.0), b(false)]), CellError::Num);
|
||||
is_err(ev("BETA.DIST", vec![n(1.0), n(2.0), n(0.5), b(false)]), CellError::Num);
|
||||
close(ev("BETA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0);
|
||||
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(2.0), b(false)]), 0.0);
|
||||
close(ev("BETA.DIST", vec![n(0.0), n(1.0), n(1.0), b(true)]), 0.0);
|
||||
close(ev("BETA.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0);
|
||||
is_err(ev("BETA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false), n(2.0), n(6.0)]), CellError::Num);
|
||||
is_err(ev("BETA.DIST", vec![n(6.0), n(2.0), n(1.0), b(false), n(2.0), n(6.0)]), CellError::Num);
|
||||
close(ev("BETA.DIST", vec![n(4.0), n(1.0), n(1.0), b(false), n(2.0), n(6.0)]), 0.25);
|
||||
close(ev("BETA.DIST", vec![n(2.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 0.0);
|
||||
close(ev("BETA.DIST", vec![n(6.0), n(1.0), n(1.0), b(true), n(2.0), n(6.0)]), 1.0);
|
||||
close_tol(ev("BETADIST", vec![n(2.0), n(8.0), n(10.0), n(1.0), n(3.0)]), 0.6854706, D7);
|
||||
close_tol(ev("BETA.INV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
||||
close_tol(ev("BETAINV", vec![n(0.685470581), n(8.0), n(10.0), n(1.0), n(3.0)]), 2.0, D7);
|
||||
@@ -479,6 +557,30 @@ fn continuous() {
|
||||
close_tol(r[0][1].clone(), 0.841344746, 1e-8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gamma_density_at_zero_shape_one_is_num() {
|
||||
for scale in [0.1, 0.9, 1.0, 1.9, 2.0, 10_000_000_000.0] {
|
||||
is_err(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(scale), b(false)]), CellError::Num);
|
||||
}
|
||||
is_err(ev("GAMMA.DIST", vec![n(0.0), n(0.5), n(1.0), b(false)]), CellError::Num);
|
||||
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(2.0), n(1.0), b(false)]), 0.0, 0.0);
|
||||
close_tol(ev("GAMMA.DIST", vec![n(0.0), n(1.0), n(2.0), b(true)]), 0.0, 0.0);
|
||||
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(false)]), 0.5 * (-1.0f64).exp(), 1e-15);
|
||||
close_tol(ev("GAMMA.DIST", vec![n(2.0), n(1.0), n(2.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
|
||||
is_err(ev("GAMMA.DIST", vec![n(-0.1), n(1.0), n(2.0), b(false)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weibull_density_at_zero_is_zero() {
|
||||
for shape in [0.5, 1.0, 2.0] {
|
||||
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(false)]), 0.0, 0.0);
|
||||
close_tol(ev("WEIBULL.DIST", vec![n(0.0), n(shape), n(1.0), b(true)]), 0.0, 0.0);
|
||||
}
|
||||
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(false)]), (-1.0f64).exp(), 1e-15);
|
||||
close_tol(ev("WEIBULL.DIST", vec![n(1.0), n(1.0), n(1.0), b(true)]), 1.0 - (-1.0f64).exp(), 1e-15);
|
||||
is_err(ev("WEIBULL.DIST", vec![n(-0.1), n(1.0), n(1.0), b(false)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_helper() {
|
||||
let vals = vec![nv(1.0), nv(2.0), tv("x"), Value::Empty, Value::Bool(true), nv(6.0)];
|
||||
|
||||
+113
-12
@@ -111,12 +111,31 @@ fn len(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(text(a, 0)?.chars().count() as f64)
|
||||
}
|
||||
|
||||
fn lower_char(ch: char) -> char {
|
||||
match ch.to_lowercase().next() {
|
||||
Some(lower) => lower,
|
||||
None => ch,
|
||||
}
|
||||
}
|
||||
|
||||
fn upper_char(ch: char) -> char {
|
||||
let mut uppercase = ch.to_uppercase();
|
||||
match uppercase.next() {
|
||||
Some(upper) if uppercase.next().is_none() => upper,
|
||||
_ => ch,
|
||||
}
|
||||
}
|
||||
|
||||
fn proper_upper_char(ch: char) -> char {
|
||||
if ch == '\u{130}' { 'I' } else { upper_char(ch) }
|
||||
}
|
||||
|
||||
fn lower(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
text_val(text(a, 0)?.to_lowercase())
|
||||
text_val(text(a, 0)?.chars().map(lower_char).collect::<String>())
|
||||
}
|
||||
|
||||
fn upper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
text_val(text(a, 0)?.to_uppercase())
|
||||
text_val(text(a, 0)?.chars().map(upper_char).collect::<String>())
|
||||
}
|
||||
|
||||
fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -126,9 +145,9 @@ fn proper(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphabetic() {
|
||||
if prev_letter {
|
||||
out.extend(ch.to_lowercase());
|
||||
out.push(lower_char(ch));
|
||||
} else {
|
||||
out.extend(ch.to_uppercase());
|
||||
out.push(proper_upper_char(ch));
|
||||
}
|
||||
prev_letter = true;
|
||||
} else {
|
||||
@@ -281,7 +300,11 @@ fn unichar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
if !(1.0..=1_114_111.0).contains(&n) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
match char::from_u32(n as u32) {
|
||||
let cp = n as u32;
|
||||
if (0xFDD0..=0xFDEF).contains(&cp) || cp & 0xFFFE == 0xFFFE {
|
||||
return Err(CellError::NA);
|
||||
}
|
||||
match char::from_u32(cp) {
|
||||
Some(c) => text_val(c.to_string()),
|
||||
None => Err(CellError::NA),
|
||||
}
|
||||
@@ -350,10 +373,10 @@ fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
/// Formats `x` (already rounded) with `decimals` places and optional thousands separators.
|
||||
fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
|
||||
let d = decimals.max(0) as usize;
|
||||
let s = format!("{:.*}", d, x.abs());
|
||||
let s = x.abs().to_string();
|
||||
let (int_part, frac) = match s.split_once('.') {
|
||||
Some((i, f)) => (i.to_string(), Some(f.to_string())),
|
||||
None => (s.clone(), None),
|
||||
Some((i, f)) => (i, f),
|
||||
None => (s.as_str(), ""),
|
||||
};
|
||||
let int_part = if commas {
|
||||
let b = int_part.as_bytes();
|
||||
@@ -366,12 +389,14 @@ fn format_fixed(x: f64, decimals: i64, commas: bool) -> String {
|
||||
}
|
||||
o
|
||||
} else {
|
||||
int_part
|
||||
int_part.to_string()
|
||||
};
|
||||
let mut out = int_part;
|
||||
if let Some(f) = frac {
|
||||
if d > 0 {
|
||||
let frac_len = frac.len().min(d);
|
||||
out.push('.');
|
||||
out.push_str(&f);
|
||||
out.push_str(&frac[..frac_len]);
|
||||
out.extend(std::iter::repeat_n('0', d - frac_len));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -394,13 +419,21 @@ fn fixed(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (r, d) = rounded(a, 2.0)?;
|
||||
let no_commas = opt_bool(a, 2, false)?;
|
||||
let body = format_fixed(r, d, !no_commas);
|
||||
if body.len() + if r < 0.0 { 1 } else { 0 } > 255 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
text_val(if r < 0.0 { format!("-{body}") } else { body })
|
||||
}
|
||||
|
||||
fn dollar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let (r, d) = rounded(a, 2.0)?;
|
||||
let negative = r < 0.0 || (r == 0.0 && d >= 0 && num(a, 0)? < 0.0);
|
||||
let body = format_fixed(r, d, true);
|
||||
text_val(if r < 0.0 { format!("(${body})") } else { format!("${body}") })
|
||||
let out = if negative { format!("(${body})") } else { format!("${body}") };
|
||||
if out.len() > 254 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
text_val(out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -1123,6 +1156,20 @@ mod tests {
|
||||
fn case_and_cleanup() {
|
||||
is_text(ev("UPPER", vec![t("abc")]), "ABC");
|
||||
is_text(ev("LOWER", vec![t("ABC")]), "abc");
|
||||
is_text(ev("UPPER", vec![t("Straße")]), "STRAßE");
|
||||
is_text(ev("LOWER", vec![t("İ")]), "i");
|
||||
is_text(ev("PROPER", vec![t("İSTANBUL")]), "Istanbul");
|
||||
is_text(ev("PROPER", vec![t("ß")]), "ß");
|
||||
is_text(ev("PROPER", vec![t("ÉCOLE")]), "École");
|
||||
let proper = ev("PROPER", vec![t("İSTANBUL Straße")]);
|
||||
is_text(proper.clone(), "Istanbul Straße");
|
||||
assert_eq!(
|
||||
match proper {
|
||||
Value::Text(s) => s.chars().count(),
|
||||
_ => 0,
|
||||
},
|
||||
"Istanbul Straße".chars().count()
|
||||
);
|
||||
is_text(ev("PROPER", vec![t("this is a TITLE")]), "This Is A Title");
|
||||
is_text(ev("PROPER", vec![t("2-way street's")]), "2-Way Street'S");
|
||||
is_text(ev("TRIM", vec![t(" a b c ")]), "a b c");
|
||||
@@ -1181,8 +1228,15 @@ mod tests {
|
||||
close(ev("CODE", vec![t("€")]), 128.0);
|
||||
is_err(ev("CODE", vec![t("")]), CellError::Value);
|
||||
is_text(ev("UNICHAR", vec![n(9731.0)]), "☃");
|
||||
for cp in [0xFDD0, 0xFDEF, 0xFFFE, 0xFFFF, 0x1FFFE, 0x1FFFF, 0x10FFFE, 0x10FFFF] {
|
||||
is_err(ev("UNICHAR", vec![n(f64::from(cp))]), CellError::NA);
|
||||
}
|
||||
for (cp, expected) in [(0xFDCF, "\u{FDCF}"), (0xFDF0, "\u{FDF0}"), (0xFFFD, "\u{FFFD}"), (0x1FFFD, "\u{1FFFD}")] {
|
||||
is_text(ev("UNICHAR", vec![n(f64::from(cp))]), expected);
|
||||
}
|
||||
close(ev("UNICODE", vec![t("☃")]), 9731.0);
|
||||
is_err(ev("UNICHAR", vec![n(0.0)]), CellError::Value);
|
||||
is_err(ev("UNICHAR", vec![n(1_114_112.0)]), CellError::Value);
|
||||
is_err(ev("UNICHAR", vec![n(55296.0)]), CellError::NA);
|
||||
}
|
||||
|
||||
@@ -1237,10 +1291,57 @@ mod tests {
|
||||
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
|
||||
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
|
||||
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||
let fixed_16 = format!("1.1{}", "0".repeat(15));
|
||||
let dollar_16 = format!("$1.1{}", "0".repeat(15));
|
||||
let fixed_20 = format!("1.005{}", "0".repeat(17));
|
||||
let dollar_20 = format!("$1.005{}", "0".repeat(17));
|
||||
let fixed_127 = format!("1.1{}", "0".repeat(126));
|
||||
let dollar_127 = format!("$1.1{}", "0".repeat(126));
|
||||
is_text(ev("FIXED", vec![n(1.1), n(16.0)]), &fixed_16);
|
||||
is_text(ev("DOLLAR", vec![n(1.1), n(16.0)]), &dollar_16);
|
||||
is_text(ev("FIXED", vec![n(1.005), n(20.0)]), &fixed_20);
|
||||
is_text(ev("DOLLAR", vec![n(1.005), n(20.0)]), &dollar_20);
|
||||
is_text(ev("FIXED", vec![n(1.1), n(127.0)]), &fixed_127);
|
||||
is_text(ev("DOLLAR", vec![n(1.1), n(127.0)]), &dollar_127);
|
||||
is_text(ev("T", vec![t("x")]), "x");
|
||||
is_text(ev("T", vec![n(1.0)]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_and_dollar_output_limits() {
|
||||
assert!(matches!(ev("FIXED", vec![n(1e191), n(0.0)]), Value::Text(ref s) if s.len() == 255));
|
||||
is_err(ev("FIXED", vec![n(1e192), n(0.0)]), CellError::Value);
|
||||
assert!(matches!(ev("FIXED", vec![n(-1e253), n(0.0), b(true)]), Value::Text(ref s) if s.starts_with('-')));
|
||||
is_err(ev("FIXED", vec![n(-1e191), n(0.0)]), CellError::Value);
|
||||
assert!(matches!(ev("FIXED", vec![n(1e189), n(1.0)]), Value::Text(ref s) if s.len() == 255));
|
||||
is_err(ev("FIXED", vec![n(1e190), n(1.0)]), CellError::Value);
|
||||
|
||||
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(0.0)]), Value::Text(ref s) if s.len() == 254));
|
||||
is_err(ev("DOLLAR", vec![n(1e190), n(0.0)]), CellError::Value);
|
||||
assert!(matches!(ev("DOLLAR", vec![n(-1e188), n(0.0)]), Value::Text(ref s) if s.len() == 254));
|
||||
is_err(ev("DOLLAR", vec![n(-1e189), n(0.0)]), CellError::Value);
|
||||
assert!(matches!(ev("DOLLAR", vec![n(1.23456789e187), n(2.0)]), Value::Text(ref s) if s.len() == 254));
|
||||
is_err(ev("DOLLAR", vec![n(1.23456789e188), n(2.0)]), CellError::Value);
|
||||
is_err(ev("DOLLAR", vec![n(-1.23456789e186), n(2.0)]), CellError::Value);
|
||||
assert!(matches!(ev("DOLLAR", vec![n(1e189), n(-1.0)]), Value::Text(ref s) if s.len() == 254));
|
||||
is_err(ev("DOLLAR", vec![n(1e190), n(-1.0)]), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_keeps_negative_sign_when_nonnegative_precision_rounds_to_zero() {
|
||||
is_text(ev("DOLLAR", vec![n(-0.0001), n(0.0)]), "($0)");
|
||||
is_text(ev("DOLLAR", vec![n(-0.0001), n(2.0)]), "($0.00)");
|
||||
is_text(ev("DOLLAR", vec![n(0.0), n(0.0)]), "$0");
|
||||
is_text(ev("DOLLAR", vec![n(-0.0), n(0.0)]), "$0");
|
||||
is_text(ev("DOLLAR", vec![n(0.0001), n(0.0)]), "$0");
|
||||
is_text(ev("DOLLAR", vec![n(-0.0001), n(-1.0)]), "$0");
|
||||
is_text(ev("DOLLAR", vec![n(-4.0), n(-1.0)]), "$0");
|
||||
is_text(ev("DOLLAR", vec![n(-5.0), n(-1.0)]), "($10)");
|
||||
is_text(ev("DOLLAR", vec![n(-0.4), n(0.0)]), "($0)");
|
||||
is_text(ev("DOLLAR", vec![n(-0.5), n(0.0)]), "($1)");
|
||||
is_text(ev("FIXED", vec![n(-0.0001), n(0.0)]), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_after() {
|
||||
is_text(ev("TEXTBEFORE", vec![t("Red riding hood"), t(" ")]), "Red");
|
||||
|
||||
@@ -15,7 +15,7 @@ use gridcraft_core::{CellError, DateSystem, Value};
|
||||
pub use builtin::{builtin_format, builtin_id};
|
||||
pub use decimal::format_general_fit;
|
||||
|
||||
use parse::{SecKind, Section, Tok};
|
||||
use parse::{DatePart, SecKind, Section, Tok};
|
||||
use render::Out;
|
||||
|
||||
/// Width (in characters) of General output in a cell.
|
||||
@@ -193,6 +193,18 @@ impl NumberFormat {
|
||||
self.num_sections().iter().any(|s| s.kind == SecKind::Date)
|
||||
}
|
||||
|
||||
/// For a date or time format, whether the section that formats `value` shows calendar parts (year, month,
|
||||
/// day) and whether it shows time parts, as `(date, time)`. `None` for any other format and for
|
||||
/// elapsed-time codes like `[h]:mm`, whose value is a duration, not a moment.
|
||||
pub fn date_shape(&self, value: f64) -> Option<(bool, bool)> {
|
||||
let s = self.select(value).map(|(s, _)| s).filter(|s| s.kind == SecKind::Date)?;
|
||||
let parts = || s.toks.iter().filter_map(|t| if let Tok::Date(d) = t { Some(*d) } else { None });
|
||||
if parts().any(|d| matches!(d, DatePart::ElapsedH(_) | DatePart::ElapsedM(_) | DatePart::ElapsedS(_))) {
|
||||
return None;
|
||||
}
|
||||
Some((parts().any(DatePart::is_calendar), parts().any(|d| !d.is_calendar())))
|
||||
}
|
||||
|
||||
pub fn is_percent(&self) -> bool {
|
||||
self.num_sections().iter().any(|s| s.kind == SecKind::Number && s.percent > 0)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,15 @@ impl SheetApp {
|
||||
// Requests the command made (a dialog, a draft) belong to the window it left active.
|
||||
self.follow_active_window();
|
||||
self.after_engine();
|
||||
if id == "formulas.autoSum"
|
||||
&& let Some(ed) = self.editor.as_mut()
|
||||
&& ed.text.starts_with('=')
|
||||
&& ed.text.ends_with("()")
|
||||
{
|
||||
ed.caret = ed.text.chars().count().saturating_sub(1);
|
||||
ed.anchor = ed.caret;
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
if id == "view.arrangeAll"
|
||||
&& self.native_windows
|
||||
&& let Ok(r) = &r
|
||||
|
||||
@@ -264,6 +264,23 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
if objects_hidden(wb) {
|
||||
return painted;
|
||||
}
|
||||
// Source outlines stay in cell space beneath every floating object.
|
||||
if let Some(ch) = sh.charts.iter().find(|ch| app.selected_chart == Some(ch.id))
|
||||
&& anchor_rect(geo, sh, &ch.anchor).intersects(p.clip_rect())
|
||||
{
|
||||
for s in &ch.series {
|
||||
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
|
||||
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
||||
{
|
||||
let rr = geo.range_rect(sh, rf.range());
|
||||
let color = Tokens::get(p.ctx()).ref_color(0, true);
|
||||
for pane in geo.range_panes(rf.range()) {
|
||||
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut done = |start: egui::layers::ShapeIdx, rect: Rect| painted.push(PaintedObject { shapes: start.0..p.add(egui::Shape::Noop).0, rect });
|
||||
// The grid paints the active workbook: its pictures are cached under it.
|
||||
let doc = app.session.active().map_or(0, |d| d.uid);
|
||||
@@ -365,23 +382,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
if app.selected_chart == Some(ch.id) {
|
||||
selection_frame(p, r);
|
||||
}
|
||||
// The source-data highlight below is in cell space: mirrored with the cells.
|
||||
done(start, r);
|
||||
if app.selected_chart == Some(ch.id) {
|
||||
// Highlight the source data like Excel does.
|
||||
for s in &ch.series {
|
||||
if let Ok(gridcraft_engine::formula::Expr::Ref(rf)) = gridcraft_engine::formula::parse(&s.values)
|
||||
&& rf.sheet_name().is_none_or(|n| n.eq_ignore_ascii_case(&sh.name))
|
||||
{
|
||||
let rr = geo.range_rect(sh, rf.range());
|
||||
let color = Tokens::get(p.ctx()).ref_color(0, true);
|
||||
for pane in geo.range_panes(rf.range()) {
|
||||
let pp = p.with_clip_rect(pane.intersect(p.clip_rect()));
|
||||
pp.rect_stroke(rr, 0.0, Stroke::new(1.5, color), StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
paint_slicers(app, p, geo, wb, si, sh);
|
||||
painted
|
||||
|
||||
@@ -72,6 +72,8 @@ pub struct Dialog {
|
||||
focus_initialized: bool,
|
||||
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
|
||||
pick: Option<RefPick>,
|
||||
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
|
||||
original_link_text: Option<String>,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
@@ -165,6 +167,7 @@ impl Dialog {
|
||||
self.values.insert(pick.key.into(), json!(ed.text));
|
||||
}
|
||||
|
||||
/// Creates a command-backed form with its initial field values.
|
||||
fn form(name: &str, title: &str, command: &'static str, fields: Vec<Field>, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
@@ -183,9 +186,11 @@ impl Dialog {
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a dialog whose body handles its own controls instead of a standard form.
|
||||
pub(crate) fn custom(name: &str, title: &str, defaults: Json) -> Dialog {
|
||||
Dialog {
|
||||
name: name.into(),
|
||||
@@ -204,9 +209,11 @@ impl Dialog {
|
||||
back: None,
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the named dialog with defaults from the active workbook, including a cell's stored hyperlink.
|
||||
pub fn open(app: &mut SheetApp, name: &str, params: Json) -> Option<Dialog> {
|
||||
let sel = app.session.active().map(|d| d.selection.current().a1()).unwrap_or_default();
|
||||
let active = app.session.active().map(|d| d.selection.active.a1()).unwrap_or_default();
|
||||
@@ -494,17 +501,30 @@ impl Dialog {
|
||||
json!({"url": url}),
|
||||
)
|
||||
}
|
||||
"insertLink" => Dialog::form(
|
||||
"insertLink",
|
||||
"Insert Hyperlink",
|
||||
"insert.link",
|
||||
vec![
|
||||
Text { key: "text", label: "Display:" },
|
||||
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
|
||||
Text { key: "tooltip", label: "ScreenTip:" },
|
||||
],
|
||||
json!({"target": "https://"}),
|
||||
),
|
||||
"insertLink" => {
|
||||
let mut d = Dialog::form(
|
||||
"insertLink",
|
||||
"Insert Hyperlink",
|
||||
"insert.link",
|
||||
vec![
|
||||
Text { key: "text", label: "Display:" },
|
||||
Text { key: "target", label: "Link to (web address, e-mail or Sheet!A1):" },
|
||||
Text { key: "tooltip", label: "ScreenTip:" },
|
||||
],
|
||||
json!({"target": "https://"}),
|
||||
);
|
||||
if let Some(doc) = app.session.active()
|
||||
&& let Some(sheet) = doc.wb.active()
|
||||
&& let Some(link) = sheet.hyperlinks.get(&doc.selection.active)
|
||||
{
|
||||
let text = gridcraft_engine::display::cell_text(&doc.wb, sheet, doc.selection.active);
|
||||
d.values.insert("target".into(), json!(link.target));
|
||||
d.values.insert("text".into(), json!(text));
|
||||
d.values.insert("tooltip".into(), json!(link.tooltip));
|
||||
d.original_link_text = Some(text);
|
||||
}
|
||||
d
|
||||
}
|
||||
"sparkline" => Dialog::form(
|
||||
"sparkline",
|
||||
"Create Sparklines",
|
||||
|
||||
@@ -15,9 +15,17 @@ use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2};
|
||||
|
||||
/// Builds the command params from form values for dialogs that need translation.
|
||||
/// The parameters OK sends to the dialog's command: its field values, reshaped per dialog.
|
||||
/// Unchanged hyperlink display text is omitted so its number or formula stays intact.
|
||||
pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
let mut v = Json::Object(d.values.clone());
|
||||
match d.name.as_str() {
|
||||
"insertLink" => {
|
||||
if d.original_link_text.as_deref().is_some_and(|text| d.values.get("text").and_then(Json::as_str) == Some(text))
|
||||
&& let Some(m) = v.as_object_mut()
|
||||
{
|
||||
m.remove("text");
|
||||
}
|
||||
}
|
||||
"advancedFilter" => {
|
||||
if let Some(m) = v.as_object_mut() {
|
||||
m.remove("action");
|
||||
|
||||
@@ -518,9 +518,23 @@ fn editor_arrow_keys(
|
||||
ed: &mut crate::editor::EditState,
|
||||
mut action: Option<EditorAction>,
|
||||
) -> (Option<EditorAction>, bool) {
|
||||
let (up, down, left, right, shift) = ui.input(|i| {
|
||||
(i.key_pressed(Key::ArrowUp), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowLeft), i.key_pressed(Key::ArrowRight), i.modifiers.shift)
|
||||
// The modifiers come with the key event: an arrow pressed and released within one frame (a
|
||||
// control client's or an automation tool's keystroke) has already lost them from `i.modifiers`.
|
||||
let arrow = ui.input(|i| {
|
||||
i.events.iter().find_map(|e| match e {
|
||||
egui::Event::Key { key: key @ (Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight), pressed: true, modifiers, .. } => {
|
||||
Some((*key, *modifiers))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
});
|
||||
let (up, down, left, right) = (
|
||||
arrow.is_some_and(|(k, _)| k == Key::ArrowUp),
|
||||
arrow.is_some_and(|(k, _)| k == Key::ArrowDown),
|
||||
arrow.is_some_and(|(k, _)| k == Key::ArrowLeft),
|
||||
arrow.is_some_and(|(k, _)| k == Key::ArrowRight),
|
||||
);
|
||||
let (shift, jump) = arrow.map_or((false, false), |(_, m)| (m.shift, m.command));
|
||||
if (up || down) && !ed.autocomplete.is_empty() {
|
||||
ui.input_mut(|i| {
|
||||
i.consume_key(egui::Modifiers::NONE, Key::ArrowUp);
|
||||
@@ -545,13 +559,18 @@ fn editor_arrow_keys(
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|s| s.right_to_left);
|
||||
let dc = if rtl { -dc } else { dc };
|
||||
if ed.can_point() {
|
||||
// Ctrl+arrow (⌘arrow) jumps the pointed cell to the edge of its data, Ctrl+Shift+arrow
|
||||
// extends the pointed range there, as the same keys do on the selection.
|
||||
ui.input_mut(|i| {
|
||||
for key in [Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight] {
|
||||
i.consume_key(egui::Modifiers::NONE, key);
|
||||
i.consume_key(egui::Modifiers::SHIFT, key);
|
||||
for modifiers in
|
||||
[egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::COMMAND, egui::Modifiers::COMMAND | egui::Modifiers::SHIFT]
|
||||
{
|
||||
i.consume_key(modifiers, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::grid::point_move(app, ed, dr, dc, shift);
|
||||
crate::grid::point_move(app, ed, dr, dc, shift, jump);
|
||||
(action, false)
|
||||
} else {
|
||||
action = Some((dr, dc, false, false));
|
||||
|
||||
@@ -1014,11 +1014,13 @@ pub fn follow_link(app: &mut SheetApp, target: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Used by `EditState` users to keep the active cell visible while pointing.
|
||||
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool) {
|
||||
/// Moves the pointed cell one step, or with `jump` (Ctrl+arrow) to the edge of its data region;
|
||||
/// `extend` (Shift) moves the far end of the pointed range instead. Keeps the pointed cell visible.
|
||||
pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, extend: bool, jump: bool) {
|
||||
let Some(d) = app.session.active() else { return };
|
||||
let Some(sheet) = d.wb.active() else { return };
|
||||
let from = ed.point_cell.unwrap_or(ed.cell);
|
||||
let to = from.offset_clamped(dr, dc);
|
||||
let to = if jump { gridcraft_engine::selection::jump(sheet, from, dr.signum(), dc.signum()) } else { from.offset_clamped(dr, dc) };
|
||||
let anchor = if extend {
|
||||
// The anchor is the corner opposite the moving end: the written range is normalized, so
|
||||
// after extending up or left its first cell is the moving end, not the anchor.
|
||||
|
||||
@@ -5,8 +5,8 @@ use super::cell_paint::{
|
||||
use super::geo::Geo;
|
||||
use super::tables::{filter_button_rect, paint_filter_button};
|
||||
use super::{
|
||||
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, Value,
|
||||
Workbook, pos2, theme, vec2,
|
||||
CellRef, CfCache, CfLook, Color32, FontId, HAlign, MAX_COLS, Painter, Pos2, RangeRef, Rect, Sheet, Stroke, StrokeKind, Tokens, Underline, VAlign,
|
||||
Value, Workbook, pos2, theme, vec2,
|
||||
};
|
||||
|
||||
/// Mirror what the grid painted into `layer` (the shape indices in `range`) for a right-to-left
|
||||
@@ -68,6 +68,30 @@ fn mirror_shape(shape: &mut egui::Shape, axis: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict top-aligned wrapped text to complete lines inside the cell.
|
||||
///
|
||||
/// Preserve the cell and viewport clip when every line fits. Otherwise,
|
||||
/// exclude overflowing lines while retaining the visible fitting lines.
|
||||
fn wrapped_text_clip(p: &Painter, rect: Rect, galley: &egui::Galley, y: f32, valign: VAlign) -> Rect {
|
||||
let mut clip = rect.intersect(p.clip_rect());
|
||||
if valign != VAlign::Top {
|
||||
return clip;
|
||||
}
|
||||
|
||||
let Some(last_row) = galley.rows.last() else {
|
||||
return clip;
|
||||
};
|
||||
|
||||
if y + last_row.rect().bottom() <= rect.bottom() + 0.01 {
|
||||
return clip;
|
||||
}
|
||||
|
||||
let last_full_bottom = galley.rows.iter().map(|row| y + row.rect().bottom()).rfind(|bottom| *bottom <= rect.bottom() + 0.01);
|
||||
|
||||
clip.max.y = last_full_bottom.map(|bottom| clip.bottom().min(bottom)).unwrap_or(clip.top()).max(clip.top());
|
||||
|
||||
clip
|
||||
}
|
||||
pub(crate) struct QuadrantPaint<'a> {
|
||||
pub(crate) painter: &'a Painter,
|
||||
pub(crate) geo: &'a Geo,
|
||||
@@ -80,6 +104,11 @@ pub(crate) struct QuadrantPaint<'a> {
|
||||
pub(crate) tokens: &'a Tokens,
|
||||
}
|
||||
|
||||
/// Paint a worksheet quadrant, including backgrounds, borders, and text.
|
||||
///
|
||||
/// For top-aligned wrapped text, painting is limited to complete lines
|
||||
/// fitting within the cell height while respecting viewport clipping.
|
||||
/// Other vertical alignment modes retain their existing behavior.
|
||||
pub(crate) fn paint_quadrant(mut quadrant: QuadrantPaint<'_>) {
|
||||
let (r0, r1) = quadrant.rows;
|
||||
let (c0, c1) = quadrant.cols;
|
||||
@@ -288,6 +317,10 @@ fn should_expand_text(filter_space: f32, overflows: bool, across_end: u32, col:
|
||||
filter_space == 0.0 && (overflows || across_end > col) && !in_merge
|
||||
}
|
||||
|
||||
/// Paint one worksheet cell using its computed appearance and visible geometry.
|
||||
///
|
||||
/// Handles text layout, wrapping, alignment, overflow into neighbouring cells,
|
||||
/// and clipping, while preserving cell decoration and merged-cell constraints.
|
||||
fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLook>), merges: &[RangeRef]) {
|
||||
let (cell, rect, lk, cfl) = painted;
|
||||
let c = *cell;
|
||||
@@ -363,7 +396,8 @@ fn paint_cell(q: &QuadrantPaint<'_>, painted: &(CellRef, Rect, Look, Option<CfLo
|
||||
_ => text_left + pad + indent,
|
||||
};
|
||||
let y = wrapped_cell_text_y(st.align.v, *rect, galley.size().y, z);
|
||||
p.with_clip_rect(rect.intersect(p.clip_rect())).galley(pos2(x, y), galley, color);
|
||||
let text_clip = wrapped_text_clip(p, *rect, &galley, y, st.align.v);
|
||||
p.with_clip_rect(text_clip).galley(pos2(x, y), galley, color);
|
||||
return;
|
||||
}
|
||||
// Wrap Text off: Excel ignores the line breaks and draws the whole string on one line.
|
||||
|
||||
@@ -61,7 +61,8 @@ pub fn shortcut(app: &mut SheetApp, key: Key, m: Modifiers) {
|
||||
}
|
||||
},
|
||||
// Insert works on one area at a time, so several areas still go through the dialog.
|
||||
Key::Plus | Key::Equals if shift => match whole_lines(app).filter(|_| single_area(app)) {
|
||||
// The numpad's + arrives as Plus without Shift; Excel takes Ctrl with it as well.
|
||||
Key::Plus | Key::Equals if shift || key == Key::Plus => match whole_lines(app).filter(|_| single_area(app)) {
|
||||
Some(true) => Some(("home.insertRows", json!({}))),
|
||||
Some(false) => Some(("home.insertColumns", json!({}))),
|
||||
None => {
|
||||
|
||||
@@ -413,8 +413,20 @@ pub fn status_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
// The layout runs right to left, so the last statistic is drawn first.
|
||||
// Clicking a statistic copies its value (without the label) to the clipboard.
|
||||
for p in selection_statistics(app).into_iter().rev() {
|
||||
text(ui, egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim));
|
||||
let value = p.split_once(": ").map_or(p.as_str(), |(_, v)| v).to_owned();
|
||||
let resp = ui.add(
|
||||
egui::Label::new(egui::RichText::new(p).font(theme::ui_font(12.5)).color(t.text_dim))
|
||||
.selectable(false)
|
||||
.sense(Sense::click()),
|
||||
);
|
||||
let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(app.ui.language.tr("Click to copy"));
|
||||
if resp.clicked() {
|
||||
ui.ctx().copy_text(value);
|
||||
}
|
||||
// The label senses clicks, so it also has to open the statistics menu on right-click.
|
||||
resp.context_menu(|ui| status_bar_menu(app, ui));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! #2861: a selected chart's source outline belongs behind floating objects, in cell space.
|
||||
|
||||
use egui::{Color32, Rect, Shape, pos2, vec2};
|
||||
use gridcraft_engine::core::RangeRef;
|
||||
use gridcraft_engine::model::Anchor;
|
||||
use gridcraft_ui_egui::{SheetApp, theme::Tokens};
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
const SOURCE: &str = "B2:B12";
|
||||
const SHAPE_FILL: Color32 = Color32::from_rgb(0x12, 0x34, 0x56);
|
||||
const CELL_FILL: Color32 = Color32::from_rgb(0xEF, 0xDC, 0xBA);
|
||||
|
||||
/// The source range crosses a shape and its selected chart, while its top remains uncovered.
|
||||
fn fixture(rtl: bool, frozen: bool) -> (Harness, u32) {
|
||||
let mut session = crate::support::blank_session();
|
||||
let values: Vec<_> = (1..=11).map(|value| json!([value])).collect();
|
||||
session.execute("range.setValues", json!({"range": "B2", "values": values})).unwrap();
|
||||
session.execute("home.fillColor", json!({"range": SOURCE, "color": "#EFDCBA"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": SOURCE})).unwrap();
|
||||
let chart = session.execute("insert.chart", json!({"type": "column", "at": "B7", "width": 260, "height": 130})).unwrap();
|
||||
let id = chart["chart"].as_u64().unwrap() as u32;
|
||||
session.execute("insert.shape", json!({"kind": "rectangle", "at": "B4", "width": 160, "height": 35, "fill": "123456"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
session.execute("view.rightToLeft", json!({"on": rtl})).unwrap();
|
||||
if frozen {
|
||||
session.execute("view.freezePanes", json!({"cell": "A2"})).unwrap();
|
||||
}
|
||||
let mut h = crate::support::harness(session, 4);
|
||||
h.state_mut().selected_chart = Some(id);
|
||||
if frozen {
|
||||
h.state_mut().view_mut().unwrap().scroll.y = 20.0;
|
||||
}
|
||||
h.run_steps(3);
|
||||
(h, id)
|
||||
}
|
||||
|
||||
/// The exact on-screen source rectangle and its pane clips, including RTL and frozen rows.
|
||||
fn source_geometry(h: &Harness) -> (Rect, Vec<Rect>) {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = app.grid.geo.as_ref().unwrap();
|
||||
let range = RangeRef::parse(SOURCE).unwrap();
|
||||
let rect = geo.mirror_rect(geo.range_rect(sheet, range));
|
||||
let clips = geo.range_panes(range).into_iter().map(|pane| geo.mirror_rect(pane.intersect(geo.cells))).collect();
|
||||
(rect, clips)
|
||||
}
|
||||
|
||||
/// Allows only floating-point rounding when RTL translation and reflection reach the same bounds.
|
||||
fn same_rect(a: Rect, b: Rect) -> bool {
|
||||
a.min.distance(b.min) < 0.01 && a.max.distance(b.max) < 0.01
|
||||
}
|
||||
|
||||
/// The last frame's actual source-outline paint commands, with their order and visible clip.
|
||||
fn source_strokes(h: &Harness) -> Vec<(usize, Rect)> {
|
||||
let (source, _) = source_geometry(h);
|
||||
let color = Tokens::get(&h.ctx).ref_color(0, true);
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, shape)| match &shape.shape {
|
||||
Shape::Rect(rect) if same_rect(rect.rect, source) && rect.stroke.color == color && rect.stroke.width == 1.5 => {
|
||||
Some((index, shape.clip_rect))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Floating objects move as a whole on RTL sheets, so their on-screen bounds remain unflipped.
|
||||
fn object_rect(h: &Harness, anchor: &Anchor) -> Rect {
|
||||
let app = h.state();
|
||||
let sheet = app.session.active().unwrap().wb.active().unwrap();
|
||||
let geo = app.grid.geo.as_ref().unwrap();
|
||||
geo.mirror_rect(Rect::from_min_size(
|
||||
pos2(geo.x(sheet, anchor.cell.col) + anchor.dx * geo.z, geo.y(sheet, anchor.cell.row) + anchor.dy * geo.z),
|
||||
vec2(anchor.width * geo.z, anchor.height * geo.z),
|
||||
))
|
||||
}
|
||||
|
||||
/// Finds an opaque object fill in final paint order, rather than relying on implementation calls.
|
||||
fn fill_index(h: &Harness, bounds: Rect, color: Color32) -> usize {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|shape| matches!(&shape.shape, Shape::Rect(rect) if same_rect(rect.rect, bounds) && rect.fill == color))
|
||||
.expect("the overlapping floating object's fill was painted")
|
||||
}
|
||||
|
||||
/// Opaque objects must cover the overlapping source stroke while the uncovered stroke stays visible.
|
||||
#[test]
|
||||
fn chart_source_outlines_stay_below_every_overlapping_object() {
|
||||
for rtl in [false, true] {
|
||||
for frozen in [false, true] {
|
||||
let (h, id) = fixture(rtl, frozen);
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let chart = sheet.charts.iter().find(|chart| chart.id == id).unwrap();
|
||||
let chart_bounds = object_rect(&h, &chart.anchor);
|
||||
let shape_bounds = object_rect(&h, &sheet.shapes[0].anchor);
|
||||
let chart_fill = fill_index(&h, chart_bounds, Tokens::get(&h.ctx).grid_bg);
|
||||
let shape_fill = fill_index(&h, shape_bounds, SHAPE_FILL);
|
||||
let (source, expected_clips) = source_geometry(&h);
|
||||
let strokes = source_strokes(&h);
|
||||
assert_eq!(strokes.len(), expected_clips.len(), "source remains painted: rtl={rtl}, frozen={frozen}");
|
||||
for ((_, clip), expected) in strokes.iter().zip(expected_clips) {
|
||||
assert_eq!(*clip, expected, "source clips stay in cell space");
|
||||
}
|
||||
let visible_top = source.top().max(strokes[0].1.top());
|
||||
let x = if rtl { source.left() + 0.75 } else { source.right() - 0.75 };
|
||||
let uncovered = pos2(x, visible_top + 4.0);
|
||||
assert!(source.contains(uncovered) && strokes[0].1.contains(uncovered));
|
||||
assert!(!chart_bounds.contains(uncovered) && !shape_bounds.contains(uncovered), "uncovered source outline remains visible");
|
||||
let background = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, shape)| match &shape.shape {
|
||||
Shape::Rect(rect) if rect.fill == CELL_FILL && rect.rect.contains(uncovered) && shape.clip_rect.contains(uncovered) => {
|
||||
Some(index)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("the uncovered source cell has an opaque fill");
|
||||
assert!(strokes[0].0 > background, "source outline remains above cell backgrounds");
|
||||
for (name, bounds, fill) in [("chart", chart_bounds, chart_fill), ("shape", shape_bounds, shape_fill)] {
|
||||
let covered = pos2(x, bounds.center().y);
|
||||
assert!(source.contains(covered) && bounds.contains(covered), "fixture overlaps the {name}'s source stroke");
|
||||
for (stroke, _) in &strokes {
|
||||
assert!(*stroke < fill, "source outline paints over {name}: rtl={rtl}, frozen={frozen}, stroke={stroke}, fill={fill}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Moving the source pass must preserve deselection, hidden-object and offscreen-chart behavior.
|
||||
#[test]
|
||||
fn unselected_hidden_and_offscreen_charts_do_not_highlight_sources() {
|
||||
for state in ["unselected", "hidden", "offscreen"] {
|
||||
let (mut h, id) = fixture(false, false);
|
||||
assert!(!source_strokes(&h).is_empty(), "selected visible chart highlights its source");
|
||||
match state {
|
||||
"unselected" => h.state_mut().selected_chart = None,
|
||||
"hidden" => {
|
||||
h.state_mut().run("view.showObjects", json!({"on": false})).unwrap();
|
||||
}
|
||||
_ => {
|
||||
h.state_mut().run("object.move", json!({"kind": "chart", "id": id, "at": "B100"})).unwrap();
|
||||
}
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert!(source_strokes(&h).is_empty(), "{state} chart must not highlight its source");
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,15 @@ fn locale_preserves_text_cells_and_existing_entry_conveniences() {
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1,2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_cell_autosum_places_caret_inside_parentheses() {
|
||||
let mut app = app();
|
||||
app.run("formulas.autoSum", json!({"enter": false})).unwrap();
|
||||
let ed = app.editor.as_ref().unwrap();
|
||||
assert_eq!(ed.text, "=SUM()");
|
||||
assert_eq!(ed.caret, ed.text.chars().count() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Insert Function and AutoSum use `=SUMA(`, while user-supplied formula text is not translated twice.
|
||||
fn canonical_engine_templates_enter_the_selected_formula_language() {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! #2876: editing a stored hyperlink starts from its address, display text and ScreenTip.
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use gridcraft_engine::model::{Cell, Hyperlink};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::control::{Outcome, handle};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
/// Runs the same dialog controls used by the public automation interface.
|
||||
fn control(h: &mut Harness, method: &str, params: Json) -> Json {
|
||||
let ctx = h.ctx.clone();
|
||||
let Outcome::Done(result) = handle(h.state_mut(), &ctx, method, ¶ms) else { panic!("unexpected screenshot response") };
|
||||
assert_eq!(result["ok"], true, "{method}: {result}");
|
||||
result
|
||||
}
|
||||
|
||||
/// A non-default active cell carrying a stored hyperlink and the requested input.
|
||||
fn linked_harness(input: &str, target: &str) -> Harness {
|
||||
let mut session = crate::support::blank_session();
|
||||
session.execute("cell.set", json!({"cell": "B2", "input": input})).unwrap();
|
||||
session.execute("insert.link", json!({"cell": "B2", "target": target, "tooltip": "Original tip"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
crate::support::harness(session, 4)
|
||||
}
|
||||
|
||||
/// Captures both content and stored link metadata so cancellation and undo are exact.
|
||||
fn linked_cell(h: &Harness) -> (Cell, Hyperlink) {
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let at = CellRef::parse("B2").unwrap();
|
||||
(sheet.cells.get(at).cloned().unwrap(), sheet.hyperlinks.get(&at).cloned().unwrap())
|
||||
}
|
||||
|
||||
/// Both relationship URLs and workbook locations prefill the same form.
|
||||
#[test]
|
||||
fn existing_external_and_internal_links_prefill_their_fields() {
|
||||
for target in ["https://example.org/original?q=1", "Sheet1!F9"] {
|
||||
let mut h = linked_harness("Original label", target);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
let dialog = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.values["target"], target);
|
||||
assert_eq!(dialog.values["text"], "Original label");
|
||||
assert_eq!(dialog.values["tooltip"], "Original tip");
|
||||
assert!(h.query_by_label("Display:").is_some());
|
||||
assert!(h.query_by_label("ScreenTip:").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard entry point shares the prefilled form rather than resetting the address.
|
||||
#[test]
|
||||
fn ctrl_k_opens_the_existing_link_for_editing() {
|
||||
let mut h = linked_harness("Original label", "https://example.org/original");
|
||||
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::K);
|
||||
h.run_steps(3);
|
||||
let dialog = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.name, "insertLink");
|
||||
assert_eq!(dialog.values["target"], "https://example.org/original");
|
||||
}
|
||||
|
||||
/// Both the OK button and automation confirmation update one link and undo in one step.
|
||||
#[test]
|
||||
fn editing_a_link_confirms_and_undo_restores_it() {
|
||||
for button in [false, true] {
|
||||
let mut h = linked_harness("Original label", "https://example.org/original");
|
||||
let before = linked_cell(&h);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "Sheet1!F9"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Updated label"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Updated tip"}));
|
||||
if button {
|
||||
h.run_steps(4);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(4);
|
||||
} else {
|
||||
control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
}
|
||||
assert!(h.state().dialog.is_none());
|
||||
let (cell, link) = linked_cell(&h);
|
||||
assert_eq!(cell.value.display(), "Updated label");
|
||||
assert_eq!(link.target, "Sheet1!F9");
|
||||
assert_eq!(link.tooltip.as_deref(), Some("Updated tip"));
|
||||
assert!(!link.external);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(linked_cell(&h), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closing a partially edited form leaves the existing link and cell untouched.
|
||||
#[test]
|
||||
fn cancelling_link_edits_keeps_the_original_cell_and_link() {
|
||||
let mut h = linked_harness("Original label", "Sheet1!F9");
|
||||
let before = linked_cell(&h);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/changed"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "text", "value": "Changed label"}));
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "tooltip", "value": "Changed tip"}));
|
||||
control(&mut h, "ui.dialog.cancel", json!({}));
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(linked_cell(&h), before);
|
||||
}
|
||||
|
||||
/// Rendered forms keep linked numbers, formulas and number formats when their display text is unchanged.
|
||||
#[test]
|
||||
fn unchanged_display_text_preserves_number_and_formula_cells() {
|
||||
for (input, format, expected) in [("42", "General", "42"), ("=6*7", "General", "42"), ("42", "0.00", "42.00"), ("=6*7", "0.00", "42.00")] {
|
||||
let mut h = linked_harness(input, "https://example.org/original");
|
||||
h.state_mut().run("home.numberFormat", json!({"code": format})).unwrap();
|
||||
let (before, _) = linked_cell(&h);
|
||||
let doc = h.state().session.active().unwrap();
|
||||
let display = gridcraft_engine::display::cell_text(&doc.wb, doc.wb.active().unwrap(), CellRef::parse("B2").unwrap());
|
||||
assert_eq!(display, expected, "formatted input {input}");
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().unwrap().values["text"], display);
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"}));
|
||||
h.run_steps(3);
|
||||
control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
let (after, link) = linked_cell(&h);
|
||||
assert_eq!(after, before, "input {input}");
|
||||
assert_eq!(link.target, "https://example.org/updated");
|
||||
assert_eq!(link.tooltip.as_deref(), Some("Original tip"));
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(linked_cell(&h).0, before, "unedited OK for {input}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A missing ScreenTip stays absent when only the address changes or OK is pressed unchanged.
|
||||
#[test]
|
||||
fn editing_a_link_without_a_tooltip_keeps_it_absent() {
|
||||
let mut h = linked_harness("Original label", "https://example.org/original");
|
||||
h.state_mut().run("insert.link", json!({"target": "https://example.org/original"})).unwrap();
|
||||
assert_eq!(linked_cell(&h).1.tooltip, None);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
let dialog = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), "");
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/updated"}));
|
||||
h.run_steps(3);
|
||||
control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
let (cell, link) = linked_cell(&h);
|
||||
assert_eq!(cell.value.display(), "Original label");
|
||||
assert_eq!(link.target, "https://example.org/updated");
|
||||
assert_eq!(link.tooltip, None);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
h.get_by_label(" OK ").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none());
|
||||
assert_eq!(linked_cell(&h).1.tooltip, None);
|
||||
}
|
||||
|
||||
/// A new link still begins with the web-address default and uses its target as a blank cell's label.
|
||||
#[test]
|
||||
fn new_links_keep_the_existing_defaults() {
|
||||
let mut h = crate::support::blank_harness(4);
|
||||
control(&mut h, "ui.dialog", json!({"name": "insertLink"}));
|
||||
h.run_steps(3);
|
||||
let dialog = h.state().dialog.as_ref().unwrap();
|
||||
assert_eq!(dialog.values["target"], "https://");
|
||||
assert_eq!(dialog.values.get("text").and_then(Json::as_str).unwrap_or_default(), "");
|
||||
assert_eq!(dialog.values.get("tooltip").and_then(Json::as_str).unwrap_or_default(), "");
|
||||
control(&mut h, "ui.dialog.set", json!({"field": "target", "value": "https://example.org/new"}));
|
||||
h.run_steps(3);
|
||||
control(&mut h, "ui.dialog.confirm", json!({}));
|
||||
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
let at = CellRef::parse("A1").unwrap();
|
||||
assert_eq!(sheet.value(at).display(), "https://example.org/new");
|
||||
assert_eq!(sheet.hyperlinks[&at].target, "https://example.org/new");
|
||||
}
|
||||
@@ -331,6 +331,43 @@ fn shift_arrows_in_point_mode_extend_from_the_pointed_cell() {
|
||||
assert_eq!(edited(&h).as_deref(), Some("=SUM(A8:A10"), "Shift+Down shrinks back towards the anchor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_arrows_in_point_mode_jump_to_the_edge_of_the_data() {
|
||||
// Typing `=COUNTIF(`, clicking the top of a column and pressing Ctrl+Shift+Down (⌘⇧↓) points
|
||||
// at the whole column of data, as the same keys select it outside an edit.
|
||||
let mut s = blank();
|
||||
s.execute("range.setValues", json!({"range": "A1:B5", "values": [[1, 2], [3, 4], [5, 6], [7, 8], [9, 10]]})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "D1"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
text(&mut h, "=COUNTIF(");
|
||||
click_cell(&mut h, "A1");
|
||||
let edited = |h: &egui_kittest::Harness<'static, SheetApp>| h.state().editor.as_ref().map(|e| e.text.clone());
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1"));
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND | Modifiers::SHIFT));
|
||||
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:A5"), "Ctrl+Shift+Down extends to the last filled cell");
|
||||
key(&mut h, Key::ArrowRight, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A1:B5"), "Ctrl+Shift+Right extends the moving end across the data");
|
||||
key(&mut h, Key::ArrowDown, Modifiers::COMMAND | Modifiers::SHIFT);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(A:B"), "past the data, Ctrl+Shift+Down reaches the sheet edge (written as whole columns)");
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::COMMAND));
|
||||
key(&mut h, Key::ArrowUp, Modifiers::COMMAND);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B5"), "Ctrl+Up without Shift points at the next filled cell");
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
key(&mut h, Key::ArrowUp, Modifiers::NONE);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B4"), "a plain arrow still moves one cell");
|
||||
// An automation tool (osascript `key code`) presses and releases within one frame, so by the
|
||||
// time the frame runs `i.modifiers` is already empty: the modifiers that count are the ones on
|
||||
// the press event.
|
||||
h.input_mut().events.extend([
|
||||
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
|
||||
Event::ModifiersChanged(Modifiers::NONE),
|
||||
Event::Key { key: Key::ArrowUp, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
h.run_steps(2);
|
||||
assert_eq!(edited(&h).as_deref(), Some("=COUNTIF(B1"), "Command released in the same frame as the press still jumps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_format_and_extend() {
|
||||
let mut s = blank();
|
||||
|
||||
@@ -15,6 +15,7 @@ mod cell_editor_caret;
|
||||
mod cell_pictures;
|
||||
mod cf_rule_pick;
|
||||
mod chart_resize;
|
||||
mod chart_source_layering;
|
||||
mod clipboard;
|
||||
mod comments_pane;
|
||||
mod commit_after_pointing;
|
||||
@@ -51,6 +52,7 @@ mod header_autofit;
|
||||
mod header_column_drag;
|
||||
mod host_chrome;
|
||||
mod hyperlink_ctrl_click;
|
||||
mod hyperlink_dialog;
|
||||
mod in_cell_editor_size;
|
||||
mod in_cell_editor_wrap;
|
||||
mod insert_copied_cells;
|
||||
|
||||
@@ -12,11 +12,15 @@ const LEFT: &str = "This is a very long left-aligned text that overflows into ma
|
||||
const RIGHT: &str = "This is a very long right-aligned text that overflows into many empty neighbouring cells";
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
harness_with_size(egui::vec2(1200.0, 800.0), setup)
|
||||
}
|
||||
|
||||
fn harness_with_size(size: egui::Vec2, setup: impl FnOnce(&mut Session)) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
setup(&mut s);
|
||||
s.execute("selection.set", json!({"range": "A20"})).unwrap();
|
||||
let mut h = crate::common::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
let mut h = crate::common::builder().with_size(size).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
@@ -108,3 +112,281 @@ fn scrolled_overflow_does_not_draw_over_frozen_columns() {
|
||||
assert!(clips.iter().any(|r| r.intersects(e1.shrink(1.0))), "C1's overflow reaches E1: {clips:?}");
|
||||
assert!(clips.iter().all(|r| !r.intersects(a1.shrink(1.0))), "C1's overflow stays out of frozen column A: {clips:?}");
|
||||
}
|
||||
|
||||
/// Fully fitting wrapped lines retain the whole cell clip, including
|
||||
/// room for glyphs extending below their logical line boxes.
|
||||
#[test]
|
||||
fn wrapped_text_fitting_lines_keep_full_cell_clip() {
|
||||
const TEXT: &str = "Short wrapped text with two or three lines.";
|
||||
|
||||
let h = harness(|s| {
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 15})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 120})).unwrap();
|
||||
});
|
||||
|
||||
let cell = cell_rect(&h, "B3");
|
||||
|
||||
let shapes: Vec<_> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(shapes.len(), 1, "expected one wrapped text shape");
|
||||
|
||||
let (text, clip) = shapes[0];
|
||||
|
||||
assert!(text.galley.rows.len() >= 2, "text must wrap");
|
||||
assert!(text.galley.size().y < cell.height(), "all lines must fit");
|
||||
|
||||
let last_bottom = text.pos.y + text.galley.rows.last().unwrap().rect().bottom();
|
||||
|
||||
assert!(last_bottom < cell.bottom() - 1.0, "room must remain below the final line");
|
||||
|
||||
assert!((clip.bottom() - cell.bottom()).abs() <= 0.5, "full cell clip must be retained: clip={clip:?}, cell={cell:?}");
|
||||
}
|
||||
/// Issue #2752: fixed-height wrapped cells must not paint
|
||||
/// a fraction of a text line against the next row's border.
|
||||
#[test]
|
||||
fn wrapped_text_in_fixed_height_row_shows_only_complete_lines() {
|
||||
const TEXT: &str = "Row 3 target. This is intentionally long text that must wrap \
|
||||
across many lines within a narrow column. It must never \
|
||||
paint over the next row.";
|
||||
|
||||
let h = harness(|s| {
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
|
||||
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
|
||||
|
||||
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
|
||||
|
||||
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
|
||||
|
||||
// Apply the custom height after Wrap Text so AutoFit
|
||||
// cannot expand the row for this scenario.
|
||||
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 62.67})).unwrap();
|
||||
});
|
||||
|
||||
let cell = cell_rect(&h, "B3");
|
||||
|
||||
let shapes: Vec<_> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(shapes.len(), 1, "expected one painted wrapped text shape");
|
||||
|
||||
let (text, clip) = shapes[0];
|
||||
|
||||
assert!(text.galley.rows.len() > 4, "test must produce multiple wrapped lines");
|
||||
|
||||
assert!(text.galley.size().y > cell.height(), "wrapped text must exceed the fixed row height");
|
||||
|
||||
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must stay within B3: {clip:?} vs {cell:?}");
|
||||
|
||||
let mut full_rows_visible = 0;
|
||||
|
||||
for (index, row) in text.galley.rows.iter().enumerate() {
|
||||
let top = text.pos.y + row.rect().top();
|
||||
let bottom = text.pos.y + row.rect().bottom();
|
||||
let full_height = bottom - top;
|
||||
|
||||
let visible_height = (bottom.min(clip.bottom()) - top.max(clip.top())).max(0.0);
|
||||
|
||||
let partly_visible = visible_height > 0.5 && visible_height < full_height - 0.5;
|
||||
|
||||
assert!(
|
||||
!partly_visible,
|
||||
"wrapped line {} is partially visible: \
|
||||
line=[{top:.2}, {bottom:.2}], clip={clip:?}",
|
||||
index + 1
|
||||
);
|
||||
|
||||
if visible_height >= full_height - 0.5 {
|
||||
full_rows_visible += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(full_rows_visible >= 2, "the clipping fix must still show complete text lines");
|
||||
}
|
||||
|
||||
/// Issue #2752: a row shorter than a text line must not
|
||||
/// render a fragment of wrapped text.
|
||||
#[test]
|
||||
fn wrapped_text_in_tiny_row_has_no_partial_lines() {
|
||||
const TEXT: &str = "This wrapped text should not be partly painted.";
|
||||
|
||||
let h = harness(|s| {
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
|
||||
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": TEXT})).unwrap();
|
||||
|
||||
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
|
||||
|
||||
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
|
||||
|
||||
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 5})).unwrap();
|
||||
});
|
||||
|
||||
let cell = cell_rect(&h, "B3");
|
||||
|
||||
let shapes: Vec<_> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Text(t) if t.galley.job.text == TEXT => Some((t, shape.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(shapes.len(), 1, "expected the wrapped text shape");
|
||||
|
||||
let (text, clip) = shapes[0];
|
||||
|
||||
assert!(text.galley.rows.len() > 1, "text must wrap into multiple lines");
|
||||
|
||||
assert!(cell.height() < text.galley.rows[0].rect().height(), "test setup must produce a row shorter than one line");
|
||||
|
||||
assert!(clip.top() >= cell.top() - 0.5 && clip.bottom() <= cell.bottom() + 0.5, "text clip must remain inside its cell");
|
||||
|
||||
assert!(clip.height() <= 0.5, "no partial line should be visible: clip={clip:?}");
|
||||
}
|
||||
|
||||
/// Issue #2752: the viewport bottom can cut through a line
|
||||
/// that otherwise fits fully inside a wrapped cell.
|
||||
#[test]
|
||||
fn wrapped_text_clipping_survives_partial_vertical_scroll() {
|
||||
let text = "Wrapped text must stay within both the worksheet cell \
|
||||
and the viewport during vertical scrolling. "
|
||||
.repeat(40);
|
||||
|
||||
let mut h = harness_with_size(egui::vec2(1200.0, 520.0), |s| {
|
||||
s.execute("home.columnWidth", json!({"cols": "B:B", "chars": 12})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B3", "input": text})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "B3", "on": true})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B3", "style": {"align": {"v": "Top"}}})).unwrap();
|
||||
s.execute("home.rowHeight", json!({"rows": "3:3", "height": 350})).unwrap();
|
||||
});
|
||||
|
||||
let initial_cell = cell_rect(&h, "B3");
|
||||
|
||||
let (target_center, zoom) = {
|
||||
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 shapes: Vec<_> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(shapes.len(), 1, "expected wrapped B3 text");
|
||||
|
||||
let painted = shapes[0];
|
||||
|
||||
let center = painted
|
||||
.galley
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let top = painted.pos.y + row.rect().top();
|
||||
let bottom = painted.pos.y + row.rect().bottom();
|
||||
(top, bottom)
|
||||
})
|
||||
.find(|(top, bottom)| {
|
||||
let center = (top + bottom) / 2.0;
|
||||
center > geo.cells.bottom() + 32.0 && *bottom < initial_cell.bottom() - 1.0
|
||||
})
|
||||
.map(|(top, bottom)| (top + bottom) / 2.0)
|
||||
.expect("fixture must provide a cell-fitting line below the viewport");
|
||||
|
||||
(center, geo.z)
|
||||
};
|
||||
|
||||
let viewport_bottom_before_scroll = {
|
||||
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.cells.bottom()
|
||||
};
|
||||
|
||||
{
|
||||
let app = h.state_mut();
|
||||
app.view_mut().unwrap().scroll.y = (target_center - viewport_bottom_before_scroll) / zoom;
|
||||
}
|
||||
|
||||
h.run_steps(4);
|
||||
|
||||
let cell = cell_rect(&h, "B3");
|
||||
|
||||
let (viewport, scroll_zoom) = {
|
||||
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.cells, geo.z)
|
||||
};
|
||||
|
||||
assert!(scroll_zoom > 0.0);
|
||||
|
||||
let shapes: Vec<_> = h
|
||||
.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Text(t) if t.galley.job.text == text => Some((t, shape.clip_rect)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(shapes.len(), 1, "B3 must remain painted");
|
||||
|
||||
let (painted, clip) = shapes[0];
|
||||
|
||||
assert!(clip.top() >= viewport.top() - 0.5, "clip exceeds viewport top: {clip:?} vs {viewport:?}");
|
||||
|
||||
assert!(clip.bottom() <= viewport.bottom() + 0.5, "clip exceeds viewport bottom: {clip:?} vs {viewport:?}");
|
||||
|
||||
assert!(clip.bottom() <= cell.bottom() + 0.5, "clip exceeds cell bottom: {clip:?} vs {cell:?}");
|
||||
|
||||
let full_line_bottoms: Vec<f32> =
|
||||
painted.galley.rows.iter().map(|row| painted.pos.y + row.rect().bottom()).filter(|bottom| *bottom <= cell.bottom() + 0.01).collect();
|
||||
|
||||
let cell_line_bottom = full_line_bottoms.last().copied().expect("at least one line must fit within B3");
|
||||
|
||||
assert!(
|
||||
painted.galley.rows.iter().any(|row| {
|
||||
let top = painted.pos.y + row.rect().top();
|
||||
let bottom = painted.pos.y + row.rect().bottom();
|
||||
|
||||
top + 0.5 < viewport.bottom() && viewport.bottom() < bottom - 0.5 && bottom <= cell.bottom() + 0.01
|
||||
}),
|
||||
"viewport bottom must cross a line that fits B3: \
|
||||
viewport={viewport:?}, cell={cell:?}"
|
||||
);
|
||||
|
||||
let expected_bottom = viewport.bottom().min(cell_line_bottom);
|
||||
|
||||
assert!(
|
||||
(clip.bottom() - expected_bottom).abs() <= 0.5,
|
||||
"clip must respect viewport and complete-line limits: \
|
||||
clip={clip:?}, expected={expected_bottom:.2}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Ctrl+Shift+= and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel;
|
||||
//! Ctrl+Shift+= (or Ctrl with the numpad's +) and Ctrl+- on whole rows or columns insert or delete them at once, as in Excel;
|
||||
//! any other selection opens the Insert or Delete dialog.
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
@@ -57,6 +57,22 @@ fn whole_columns_are_inserted_and_deleted_at_once() {
|
||||
assert_eq!(value(&h, "A1"), Value::from("a1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numpad_plus_inserts_without_shift() {
|
||||
let mut h = harness("2:2");
|
||||
press(&mut h, Key::Plus, false);
|
||||
assert!(h.state().dialog.is_none(), "no dialog for whole rows");
|
||||
assert!(value(&h, "A2").is_empty(), "a row was inserted above row 2");
|
||||
assert_eq!(value(&h, "A3"), Value::from("a2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unshifted_equals_inserts_nothing() {
|
||||
let mut h = harness("2:2");
|
||||
press(&mut h, Key::Equals, false);
|
||||
assert_eq!(value(&h, "A2"), Value::from("a2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_selections_still_ask_which_way_to_shift() {
|
||||
let mut h = harness("A2:B2");
|
||||
|
||||
@@ -169,6 +169,16 @@ impl Hash {
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest's length in bytes.
|
||||
fn digest_len(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha384 => 48,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
|
||||
fn block_size(self) -> usize {
|
||||
match self {
|
||||
@@ -444,6 +454,8 @@ fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the
|
||||
/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments.
|
||||
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let descriptor = agile_descriptor(xml)?;
|
||||
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
|
||||
@@ -464,15 +476,20 @@ fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, 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.
|
||||
// package key (the verifier above only authenticates the password), computed over the whole
|
||||
// EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a
|
||||
// random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so
|
||||
// both lengths are tried.
|
||||
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()) {
|
||||
let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| {
|
||||
let Some(key) = mac_field.get(..len) else { return false };
|
||||
let mac = hmac(key_data.hash, key, package);
|
||||
declared.get(..mac.len()) == Some(mac.as_slice())
|
||||
});
|
||||
if !verified {
|
||||
return Err(malformed("the decrypted data failed its integrity check"));
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! files are built in code by `crate::testing`. No Excel-produced file is used.
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_standard, with_agile_header};
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, 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
|
||||
@@ -231,3 +231,20 @@ fn integrity_matches_the_stream_not_the_plaintext() {
|
||||
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() {
|
||||
// Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size
|
||||
// key. Both verify, and neither lets a modified package through.
|
||||
let plain = payload(5000);
|
||||
for key in [vec![7u8; 64], vec![7u8; 16]] {
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len());
|
||||
let mut tampered = package.clone();
|
||||
tampered[100] ^= 1;
|
||||
assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
// Another length (a key that matches neither) fails.
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]);
|
||||
assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
|
||||
@@ -21,7 +21,13 @@ 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];
|
||||
/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the
|
||||
/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes.
|
||||
const HMAC_KEY: [u8; 64] = [
|
||||
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
|
||||
141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
|
||||
170, 171, 172, 173, 174, 175,
|
||||
];
|
||||
|
||||
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
||||
/// (AES-256, SHA-512) inside a compound file.
|
||||
@@ -237,6 +243,11 @@ pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
||||
/// `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<u8>, Vec<u8>) {
|
||||
encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY)
|
||||
}
|
||||
|
||||
/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`.
|
||||
pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
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() {
|
||||
@@ -244,8 +255,8 @@ pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>
|
||||
}
|
||||
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))),
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)),
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))),
|
||||
);
|
||||
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Chart parity
|
||||
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-24 · **Change:** minor (100% stacked area charts) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-29 · **Change:** minor (selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Excel's chart types and chart elements against GridCraft. Part of
|
||||
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
|
||||
@@ -38,6 +38,7 @@ Sources: `crates/model/src/features.rs` (`ChartKind`, `Chart`, `Series`), `crate
|
||||
| Element | Excel | GridCraft |
|
||||
|---|---|---|
|
||||
| Move and resize the chart object | yes | yes (corner handle, Format Chart Area Size fields) |
|
||||
| Selected chart source-data outline | visible on uncovered cells, beneath floating objects | painted above cells and below charts, shapes, pictures and slicers; retains pane clipping and right-to-left cell geometry (#2861) |
|
||||
| Chart title, axis titles | text, linked to cells, full formatting | text only |
|
||||
| Legend | position, overlay, formatting, per-entry delete | position only |
|
||||
| Data labels | value, category, series, percentage, from cells, callouts, position | one on/off flag |
|
||||
@@ -90,6 +91,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
|
||||
| 2026-10-24 | minor | 100% Stacked Area: inserted from the ribbon and All Charts, drawn as shares of each category's total, read and written as `percentStacked` |
|
||||
| 2026-10-18 | trivial | Stale notes: stock/combo, treemap/sunburst, box and waterfall kinds survive XLSX round-trip (PRs #158, #161) |
|
||||
| 2026-10-16 | trivial | Series that refer to defined names (`[0]!Name`, `Sheet!Name`) resolve instead of showing `#NAME?`, and are saved qualified, as Excel needs (#1686) |
|
||||
|
||||
@@ -23,7 +23,8 @@ The control port is reachable by every program on the machine, and by a web page
|
||||
`{"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.
|
||||
connection is closed. At most 16 connections may be waiting to send their token at once; past that a new
|
||||
connection is closed before anything is read. 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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** minor (`file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -66,9 +66,9 @@ values) is still dropped.
|
||||
|
||||
| Format | Excel | GridCraft read | GridCraft write | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values; we write raw values. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF; a file opened as CSV is saved with the separator it was read with, so a `;` file stays `;`; otherwise `,`) | Excel writes the **displayed** values, and so do we; `file.exportCsv {values: true}` writes raw values (full precision, ISO 8601 dates) for automation. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes | Text Import Wizard options partly via Text to Columns |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`). No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.csv` UTF-8 / Windows / Mac / MS-DOS | R/W | yes (UTF-8 and UTF-16 BOMs, UTF-8, likely Windows-1256 Arabic detection, otherwise Windows-1252 fallback; delimiter detection) | yes (UTF-8 without BOM, CRLF) | Writes the **displayed** values like Excel (number formats applied, formula results, never column-width `#####`); `file.exportCsv {values: true}` writes raw values instead. No encoding choice on save. Re-import of a single-column export splits text (#127) A used range that would write more than 512 MiB (two far-apart cells span the whole rectangle) is refused with an error instead of being expanded (#528). |
|
||||
| `.txt` tab-delimited, Unicode text | R/W | yes (`.txt`, `.tsv`, `.tab`; likely Windows-1256 Arabic detection for invalid UTF-8) | yes (displayed values) | Text Import Wizard options partly via Text to Columns |
|
||||
| `.prn` formatted text (space-delimited) | R/W | no | no | |
|
||||
| `.dif`, `.slk` (SYLK) | R/W | no | no | Small, legacy |
|
||||
@@ -115,6 +115,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
| 2026-10-20 | minor | A workbook theme's major/minor per-script typefaces (`<a:font script="Arab" typeface="…"/>`, also Hebr, Jpan and the rest) are read and written back in file order after `<a:cs>`; they were dropped before. A hostile file is held to 64 entries of 256 characters |
|
||||
| 2026-10-20 | trivial | A workbook theme's major/minor East Asian (`<a:ea>`) and complex-script (`<a:cs>`) typefaces are read and written back, so a Japanese theme's fonts (e.g. 游ゴシック) survive open + save; they were deleted before because the model had nowhere to keep them. Rendering still resolves fonts without them (follow-up) |
|
||||
| 2026-10-19 | minor | XLSX font charset and family values are preserved on save, retaining the character set and family used for substitute font selection |
|
||||
| 2026-10-26 | trivial | `file.exportCsv {values: true}` writes raw cell values (full-precision numbers, ISO 8601 dates and times) for automation; the default stays Excel's displayed text (#2648) |
|
||||
| 2026-10-19 | minor | CSV and text import decodes likely Windows-1256 Arabic; other invalid UTF-8 retains the Windows-1252 fallback |
|
||||
| 2026-10-19 | minor | .xls import skips dialog sheets (WsBool `fDialog`), which share the worksheet BOF type and were read as empty worksheets |
|
||||
| 2026-10-19 | minor | `.xls` import reads WINDOW2: right-to-left sheets (common in older Arabic and Hebrew files) open right-to-left, and hidden gridlines, headings and zero values and Show Formulas carry over |
|
||||
|
||||
+29
-3
@@ -1,6 +1,6 @@
|
||||
# Function parity: worksheet functions and formulas
|
||||
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-25 · **Change:** minor (the LAMBDA row lists the recursion limit and its `#NUM!`, and the error a defined name holding an error gives when called; earlier: number text with accounting parentheses coerces like Excel 365; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: SUBTOTAL and AGGREGATE cap the cells they visit, #2175) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-31 · **Last updated:** 2026-10-31 · **Change:** minor (edge cases matched to Excel: base conversions, BITLSHIFT/BITRSHIFT, UNICHAR noncharacters, UPPER/LOWER/PROPER casing, FIXED/DOLLAR output, F/T/GAMMA/WEIBULL/BETA distributions, BINOM trial cap, HARMEAN; earlier: ECMA.CEILING listed with USDOLLAR as not on Microsoft's list; earlier: IMSQRT reproduces the stray real part Excel gives for negative reals) · **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).
|
||||
@@ -20,8 +20,8 @@ Excel's worksheet functions, category by category, against GridCraft's function
|
||||
evaluator's special forms in `crates/calc/src/eval.rs` (`SPECIAL_FUNCTIONS`: IF, IFS, CHOOSE,
|
||||
OFFSET, INDIRECT, INDEX, SUBTOTAL, AGGREGATE, TEXT, LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL,
|
||||
MAKEARRAY…), excluding the test-only `ADD2`. Every one is an Excel name.
|
||||
- **Not on Microsoft's list:** USDOLLAR, a legacy function Excel still evaluates (like DOLLAR),
|
||||
is in our library as a Compatibility function for files that use it. It is not counted above.
|
||||
- **Not on Microsoft's list:** USDOLLAR and ECMA.CEILING, legacy functions Excel still evaluates (like DOLLAR),
|
||||
are in our library as Compatibility functions for files that use them. They are not counted above.
|
||||
- **Working** excludes stubs that return an error by design: the 7 CUBE functions (`#N/A`, no
|
||||
cube connections) and WEBSERVICE and FILTERXML (`#VALUE!`). Excel for Mac also lacks
|
||||
WEBSERVICE and FILTERXML; Excel for Windows has them.
|
||||
@@ -90,6 +90,7 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done. A LAMBDA that calls itself more than 140 times, or a formula nested past the depth limit, gives `#NUM!` (the target app allows deeper recursion, #1581). Calling a defined name that holds an error value gives that error, not `#NAME?` |
|
||||
| Iterative calculation for circular references | done: with Enable Iterative Calculation on (`formulas.calculationOptions` `{iterative, maxIterations, maxChange}`, saved as `<calcPr iterate iterateCount iterateDelta>`), 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 | done (#57): `=0.1+0.2=0.3` is TRUE, and MATCH and COUNTIF compare the same way. Arithmetic keeps full precision, as Excel does — `=(0.1+0.2)-0.3` is 5.55E-17 |
|
||||
| IMSQRT and the other IM* functions against Excel's text | IMSQRT follows Excel's polar route, so a negative real gives Excel's stray real part: `IMSQRT("-4")` is `1.22514845490862E-16+2i` (checked in Excel 16.0 on 79 inputs). Differences kept: IMSQRT of a number with a part above about 1E154 or below about 1E-155 gives the exact root (Excel gives `#NUM!` or 0); IMPOWER, IMEXP, IMSIN, IMCOS, IMSINH and IMCOSH keep exact results where Excel shows the same kind of noise (`IMPOWER("-4",2)` is `16-3.92047505570758E-15i`) or different trailing digits near multiples of pi/2; IMCSC and IMCSCH differ by one unit in the 15th digit; IMARGUMENT returns 16–17 digits where Excel returns 15; the text of IM* and COMPLEX results differs for some magnitudes (Excel writes `COMPLEX(1E-10,1)` as `0.0000000001+i` but `COMPLEX(0.000000123456789012345,1)` as `1.23456789012345E-07+i`, and an integer past 15 digits rounded and zero-padded) |
|
||||
| External workbook references (`[Book.xlsx]Sheet1!A1`) | parsed; links dropped on open, no update |
|
||||
| Localized function names and `;` separators | **missing** (#45); en-US only |
|
||||
| Long flat operator chains (`=A1+A2+…`, `-`, `*`, `/`, `^`, `&`, comparisons) | done: no operand limit below Excel's 8,192-character formula limit (Excel, observed: `=1+1+…` with 4,096 operands is exactly 8,192 characters with the `=` and calculates; one more character is refused). Evaluated (#484), printed (#628), stored, copied, adjusted, saved and reopened without recursing once per operand, on a 1 MiB stack (#916); written to XLSX as plain formulas, not dynamic arrays; Spanish entry accepts them |
|
||||
@@ -97,6 +98,12 @@ This improves series preparation; it does not change the model-fitting or season
|
||||
|
||||
## Depth (estimated)
|
||||
|
||||
| Function | Excel parity note |
|
||||
|---|---|
|
||||
| BINOM.DIST / BINOM.DIST.RANGE | Trial count is truncated, then must be at most 2,147,483,646; larger trials return `#NUM!`. |
|
||||
| BETA.DIST | Density mode returns `#NUM!` at the lower or upper interval endpoint when the corresponding shape parameter is less than or equal to 1; cumulative endpoint results remain 0 and 1. |
|
||||
| HARMEAN | Returns `#N/A` when arrays and references contain no numeric observations; zero and negative numeric observations still return `#NUM!`. |
|
||||
|
||||
**~82% ready.** The library is broad and has 96 test functions in `crates/functions` with many
|
||||
assertions each, but there is no conformance suite against Excel-computed results, and user
|
||||
reports keep finding edge cases: MIN() with no arguments (#138), COUNTBLANK past the used range
|
||||
@@ -123,6 +130,25 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | BIN2*, HEX2* and OCT2* reject surrounding spaces and tabs in source strings with `#NUM!`, matching Excel; plain unpadded values remain valid |
|
||||
| 2026-10-31 | minor | DEC2BIN, DEC2HEX and DEC2OCT validate `places` before returning negative two's-complement output, and Boolean places return `#VALUE!`, matching Excel |
|
||||
| 2026-10-31 | minor | BITLSHIFT and BITRSHIFT return `0` for zero operands shifted beyond 53 bits in either direction; nonzero operands retain the `#NUM!` limit, matching Excel |
|
||||
| 2026-10-31 | minor | UNICHAR returns `#N/A` for U+FDD0–U+FDEF and U+FFFE/U+FFFF on every plane; adjacent valid characters and existing invalid-range/surrogate errors remain unchanged, matching Excel |
|
||||
| 2026-10-31 | minor | UPPER, LOWER and PROPER use single-character casing: `UPPER("Straße")` keeps ß, `LOWER("İ")` returns i, and `PROPER("İSTANBUL")` returns Istanbul, matching Windows Excel 16.0 build 20527 in LCID 1033 |
|
||||
| 2026-10-31 | minor | FIXED and DOLLAR format the rounded number's shortest decimal representation before padding requested fractional zeroes, so 1.1 at 16 places and 1.005 at 20 places do not expose binary noise; measured in Windows Excel 16.0 build 20527, LCID 1033 |
|
||||
| 2026-10-31 | minor | FIXED accepts output through 255 characters; DOLLAR accepts output through 254. Windows Excel 16.0 build 20527 (LCID 1033) confirmed the distinct cutoffs with grouped and ungrouped values, signs, currency layout and decimal places |
|
||||
| 2026-10-31 | minor | DOLLAR uses the input sign for negative amounts that round to zero at nonnegative decimal places (`DOLLAR(-0.0001,0)` is `($0)`); when negative decimal places round to zero, Excel returns `$0`. Exact zero and negative zero stay unsigned; FIXED still returns `0` for `FIXED(-0.0001,0)` |
|
||||
| 2026-10-31 | minor | F.DIST, F.DIST.RT, F.INV and F.INV.RT accept degrees of freedom at the 10-billion boundary; F.INV uses the complementary tail, with a gamma-tail limit when numerator degrees dominate, to preserve inverse accuracy; values above the limit remain invalid |
|
||||
| 2026-10-31 | minor | T.DIST returns `#DIV/0!` in density mode and `#NUM!` in cumulative mode when degrees truncate to zero; negative integer degrees remain `#NUM!` |
|
||||
| 2026-10-31 | minor | Student-t density uses an asymptotic log-gamma ratio for large degrees of freedom, avoiding cancellation while preserving bounded work; checked against Excel at 100 million and 10 billion degrees |
|
||||
| 2026-10-31 | minor | T.INV probability zero returns `#DIV/0!`, while other out-of-range probabilities and invalid degrees remain `#NUM!`; T.INV.2T keeps its separate boundary behavior |
|
||||
| 2026-10-31 | minor | GAMMA.DIST density at x=0 and shape 1 returns `#NUM!` for all positive scales; the cumulative branch and other shape boundaries are unchanged |
|
||||
| 2026-10-31 | minor | WEIBULL.DIST returns zero at x=0 in both density and cumulative modes, including shape below one; negative x remains `#NUM!` |
|
||||
| 2026-10-31 | minor | BINOM.DIST and BINOM.DIST.RANGE reject trial counts above 2,147,483,646 after truncation |
|
||||
| 2026-10-31 | minor | BETA.DIST density returns `#NUM!` at the lower endpoint with alpha less than or equal to 1 and upper endpoint with beta less than or equal to 1; cumulative behavior is unchanged |
|
||||
| 2026-10-31 | minor | HARMEAN returns `#N/A` when array or reference inputs contain no numbers; nonpositive numeric observations remain `#NUM!` |
|
||||
| 2026-10-27 | minor | ECMA.CEILING added to the functions that are in our library but not on Microsoft's list (with USDOLLAR), so the counts don't change |
|
||||
| 2026-10-26 | minor | IMSQRT uses Excel's polar route with its cosine reduction, so `IMSQRT("-4")` is `1.22514845490862E-16+2i` as in Excel (exact `2i` before); the rows list the IM* differences still open |
|
||||
| 2026-10-25 | minor | LAMBDA row: a LAMBDA that calls itself more than 140 times, and a formula nested past the depth limit, give `#NUM!` (#1809; nesting gave `#CALC!` before). `=Foo(1)` where the defined name Foo holds `#N/A` gives `#N/A`, not `#NAME?` |
|
||||
| 2026-10-23 | 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-23 | 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 |
|
||||
|
||||
+19
-19
@@ -19,25 +19,24 @@ This is GridCraft's implementation of the shared [release playbook](release-play
|
||||
| Web app dir `apps/gridcraft-web` | `packaging/web/package.sh` |
|
||||
| App colour `#1f9d55` (ink `#147a40`), **proposed** | `assets/app-icon/README.md`, metainfo `<branding>` |
|
||||
|
||||
## Prerequisites still owned by the app crates
|
||||
## Application release prerequisites
|
||||
|
||||
The pipeline references crates that don't exist yet; the first release run fails until they do:
|
||||
The `apps/gridcraft` and `apps/gridcraft-web` crates are workspace members, and
|
||||
`apps/gridcraft-cli` supports `--version`. The `cargo xtask` alias is defined in
|
||||
`.cargo/config.toml`. The current app-specific release setup includes:
|
||||
|
||||
- **`apps/gridcraft`** (the GUI binary `gridcraft`) and **`apps/gridcraft-web`** (trunk
|
||||
site) must be workspace members. `apps/gridcraft-cli` exists and supports `--version`.
|
||||
- **Windows resources:** move `packaging/windows/app-build.rs.in` to `apps/gridcraft/build.rs`
|
||||
and add `winresource = "0.1"` under `[build-dependencies]` (instructions at the top of the
|
||||
file). `package.ps1` sets `GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon
|
||||
fails, and it checks that `gridcraft.exe` is a GUI-subsystem binary, so `main.rs` needs
|
||||
`#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]`.
|
||||
- **Build info:** CI exports `GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE`; read them
|
||||
with `option_env!` (fallback: "dev build") for `--version` and *Help › About GridCraft*.
|
||||
- **Runtime icon:** embed `assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
|
||||
- **Windows resources:** `apps/gridcraft/build.rs` embeds the icon and version information with
|
||||
`winresource` (instructions at the top of the file). `package.ps1` sets
|
||||
`GRIDCRAFT_REQUIRE_WINRES=1`, so a release build without the icon fails, and it checks that
|
||||
`gridcraft.exe` is a GUI-subsystem binary. `apps/gridcraft/src/main.rs` sets
|
||||
`windows_subsystem = "windows"` for release builds.
|
||||
- **Version information:** `gridcraft --version`, `gridcraft-cli --version` and the About dialog
|
||||
use `env!("CARGO_PKG_VERSION")` from `[workspace.package]`. `packaging/env.sh` also exports
|
||||
`GRIDCRAFT_BUILD_SHA` and `GRIDCRAFT_BUILD_DATE` for artifact metadata.
|
||||
- **Runtime icon:** `apps/gridcraft/src/main.rs` embeds
|
||||
`assets/app-icon/gridcraft-macos-512.png` (macOS Dock) and
|
||||
`hicolor/256x256/apps/ai.storyteller.gridcraft.png` (Windows/Linux) as the window icon, and
|
||||
use `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
|
||||
- **`cargo xtask` alias:** the docs use `cargo xtask …`, which needs
|
||||
`.cargo/config.toml` with `[alias] xtask = "run --package xtask --"`. Until it exists, use
|
||||
`cargo run -p xtask -- …`. The workflows already call `cargo run -p xtask` directly.
|
||||
uses `ai.storyteller.gridcraft` as the Wayland app id / `StartupWMClass`.
|
||||
|
||||
## Cutting a release
|
||||
|
||||
@@ -289,7 +288,8 @@ row in `ATTRIBUTION.md` (`cargo xtask assets`).
|
||||
installs, runs and uninstalls the MSI on a Windows 11 ARM64 runner.
|
||||
- `.github/workflows/freebsd.yml`: FreeBSD build + tests (manual run from `release`).
|
||||
|
||||
**Only the `release` branch builds.** No build workflow runs on `main` or pull requests; every
|
||||
build job checks `github.ref == 'refs/heads/release'`, so a manual run from another branch is
|
||||
skipped. Only `packaging-lint.yml` (no build, no secrets) runs on `main` and PRs.
|
||||
**CI and release builds.** `.github/workflows/ci.yml` runs the shared `cargo xtask ci` gate on
|
||||
every push to `main` and every pull request. Release artifacts are built on `release` pushes or
|
||||
manually; manual runs from other branches are dry runs as described above. `packaging-lint.yml`
|
||||
also runs on `main` and PRs.
|
||||
- Locally: `cargo xtask ci` (fmt, clippy, tests, assets, layers, wasm, parity check).
|
||||
|
||||
+10
-2
@@ -1,6 +1,6 @@
|
||||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right; earlier: macro interface on the Automate tab; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (checkbox cells print as boxes; earlier: Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; earlier: editing a hyperlink loads its existing fields; earlier: AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **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
|
||||
@@ -106,7 +106,7 @@ otherwise. Number-format and border keys match the physical key (Ctrl+Shift+4, w
|
||||
| Ctrl+S, Ctrl+Shift+S, Ctrl+O, Ctrl+N, Ctrl+W | Cmd | save, save as, open, new, close | present |
|
||||
| Ctrl+F, Ctrl+H, Ctrl+G, Ctrl+K, Ctrl+T | Cmd (Ctrl+H) | find, replace, go to, hyperlink, table | present |
|
||||
| Ctrl+D, Ctrl+R, Ctrl+E, Ctrl+1, Ctrl+9 / 0, Ctrl+Shift+9 / 0 | Cmd (Ctrl+E on Mac: see below) | fill down/right, Flash Fill, Format Cells, hide/unhide | present |
|
||||
| Ctrl+- , Ctrl+Shift+= | Cmd | Delete / Insert cells dialog | present |
|
||||
| Ctrl+- , Ctrl+Shift+= , Ctrl+numpad + | Cmd | Delete / Insert cells dialog | present |
|
||||
| Ctrl+; , Ctrl+` | Control+; | current date, show formulas | present (Mac Control+; added) |
|
||||
| Arrows, Shift / Ctrl / Ctrl+Shift+arrows, Home, Ctrl+Home / End, Page Up / Down, Ctrl+Page Up / Down | Cmd+arrows | navigation and extension | present |
|
||||
| Tab, Shift+Tab, Enter, Shift+Enter, Ctrl+Space, Shift+Space, Delete, Esc | same | entry and selection | present |
|
||||
@@ -152,6 +152,10 @@ a character (e.g. Ctrl+Alt+A for `ä`) never trigger a shortcut, Ctrl+letter one
|
||||
|
||||
## Dialogs
|
||||
|
||||
Insert Hyperlink loads an existing cell link's address, display text and ScreenTip for editing
|
||||
(#2876). Keeping the display text unchanged preserves the cell's number or formula; Cancel
|
||||
keeps the original link and an edit can be undone in one step.
|
||||
|
||||
Format Cells, Insert Function/Formula Builder, Find and Replace, Go To and Go To Special, Sort,
|
||||
Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Page Setup, Goal
|
||||
Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a
|
||||
@@ -190,6 +194,10 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Checkbox cells print and export to PDF as boxes, as on screen: unchecked white with a grey outline, checked green with a white tick (#2972). They printed their value as TRUE or FALSE; other values in a checkbox cell still print as text |
|
||||
| 2026-10-29 | minor | Formula pointing from the keyboard: Ctrl+arrow (⌘arrow) moves the pointed cell to the edge of its data and Ctrl+Shift+arrow (⌘⇧arrow) extends the pointed range there, as the same keys move and extend the selection, so typing `=COUNTIF(`, clicking the top of a column and pressing ⌘⇧↓ points at the whole column of data; the keys used to move or extend by one cell. A range reaching the sheet edge is written as whole columns or rows (`A:B`). The editor reads the modifiers of the arrow's own key event, so a keystroke pressed and released within one frame (a control client's `ui.key`, an automation tool) keeps its Shift and Ctrl |
|
||||
| 2026-10-29 | minor | Insert Hyperlink opens with an existing link's address, display text and ScreenTip (#2876). An address-only edit preserves the cell's content and formula; Cancel leaves the link unchanged, and Undo restores the edit |
|
||||
| 2026-10-27 | minor | Ctrl with the numpad's + inserts rows, columns or cells as Ctrl+Shift+= does, as in Excel; it did nothing because the numpad key arrives without Shift |
|
||||
| 2026-10-25 | minor | AutoSum (Alt+=, Home › AutoSum) over a selected row puts its total to the right of the row, or in its last cell when that is empty, as in Excel; it wrote a one-cell total under each cell. A selection's empty last column takes each row's total and its empty last row each column's, with the block's total in the corner when both are empty |
|
||||
| 2026-10-25 | minor | Paste Name lays out only the names in view, so a workbook with thousands of names opens it at once; the arrow keys still scroll the chosen name into view. Go To matches a `Sheet2!Name` reference and hides a workbook name behind the active sheet's own name in any case and script (`Größe` = `GRÖSSE`), as formulas do. The two Page Layout view rows are one; the 0.7 in side margin used when Align with Page Margins is off is marked unverified |
|
||||
| 2026-10-24 | minor | Macro step editor: step list with breakpoints, drag to reorder and a command picker, an inspector with reference boxes, a debug bar and grid overlays |
|
||||
|
||||
@@ -17,6 +17,7 @@ runtime-version: '25.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: gridcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
@@ -40,6 +41,11 @@ finish-args:
|
||||
- --filesystem=xdg-documents
|
||||
- --filesystem=xdg-download
|
||||
# Network: update checks reach GitHub. Home/host filesystem access is deliberately not granted.
|
||||
# The release runner's flatpak-builder has no eu-strip (elfutils), which its debuginfo pass runs
|
||||
# on cups-client's unstripped tools, in whichever module comes first after they're installed. The
|
||||
# app's own binaries come from the release tarball, already stripped.
|
||||
build-options:
|
||||
no-debuginfo: true
|
||||
modules:
|
||||
# CUPS client tools for craft-print: the runtime has libcups but not lpstat/lp/lpoptions.
|
||||
# Linked statically against this release's libcups, so a runtime update can't break them;
|
||||
|
||||
@@ -18,6 +18,7 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: gridcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
|
||||
@@ -6,6 +6,30 @@
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<!-- UI translations are embedded in Rust; declare them for AppKit's system dialogs (open and
|
||||
save panels, overwrite confirmation). Keep in sync with the interface languages. -->
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>en</string>
|
||||
<string>cs</string>
|
||||
<string>de</string>
|
||||
<string>el</string>
|
||||
<string>es</string>
|
||||
<string>fr</string>
|
||||
<string>it</string>
|
||||
<string>ja</string>
|
||||
<string>ko</string>
|
||||
<string>nl</string>
|
||||
<string>pl</string>
|
||||
<string>pt-BR</string>
|
||||
<string>ro</string>
|
||||
<string>ru</string>
|
||||
<string>sr</string>
|
||||
<string>tr</string>
|
||||
<string>uk</string>
|
||||
<string>vi</string>
|
||||
<string>zh-Hans</string>
|
||||
</array>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>GridCraft</string>
|
||||
<key>CFBundleName</key>
|
||||
|
||||
Reference in New Issue
Block a user