Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 a8df1b81f3 Grid input: select on press, fill-handle double-click, whole-line references (#900, #877, #872, #873, #875)
- A press selects its cell when the button goes down and a drag extends from it; Shift/Ctrl act
  on the press; checkboxes and links act on the click; the selection border, a selected column
  header, objects and Page Layout headers/footers wait for the drag or click (#900).
- Double-clicking the fill handle fills down to the bottom of the data region around the source,
  stopping above the first filled cell under it: new `edit.autoFillToData`, tested against 13
  cases observed in Excel (#877). The old double-click handler was never reached.
- While typing a formula, column and row headings point `B:B`, `B:D`, `3:3` into it (#872);
  whole-column and whole-row references are coloured and outlined (#873, #875); F4 toggles them
  `$B:$D` / `B:D` as Excel does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 03:32:45 -04:00
26 changed files with 1126 additions and 639 deletions
+176
View File
@@ -0,0 +1,176 @@
name: Publish Flatpak repository to Pages
on:
release:
types: [published]
workflow_dispatch:
inputs:
release_tag:
description: "Published GitHub release tag to publish (for example, v1.2.3)"
required: true
type: string
permissions:
contents: read
concurrency:
group: flatpak-pages-${{ github.repository }}
cancel-in-progress: false
jobs:
build:
name: Build Flatpak repository
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
# here, and the validation step below refuses a manual tag with a pre-release suffix.
if: github.event_name != 'release' || !github.event.release.prerelease
runs-on: ubuntu-24.04
permissions:
contents: read
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
pages: read
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
APP_ID: ai.storyteller.gridcraft
steps:
- name: Configure Pages
id: pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Install Flatpak repository tools
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends flatpak ostree
- name: Download and validate release bundles
shell: bash
run: |
set -euo pipefail
tag="$RELEASE_TAG"
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
exit 1
fi
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
case "${tag%%+*}" in
*-*)
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
exit 1
;;
esac
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
if [[ "$published_tag" != "$tag" ]]; then
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
exit 1
fi
version="${tag#v}"
mkdir -p bundles
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern SHA256SUMS.txt --dir bundles
for arch in x86_64 aarch64; do
asset="gridcraft-$version-linux-$arch.flatpak"
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
--pattern "$asset" --dir bundles
test -s "bundles/$asset"
(
cd bundles
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
SHA256SUMS.txt | sha256sum --check -
)
done
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
for arch in x86_64 aarch64; do
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
validation_repo="$(mktemp -d)"
ostree --repo="$validation_repo" init --mode=archive-z2
flatpak build-import-bundle "$validation_repo" "$asset"
expected_ref="app/$APP_ID/$arch/stable"
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
echo "::error::$(basename "$asset") does not contain $expected_ref"
exit 1
}
rm -rf "$validation_repo"
done
- name: Build repository and site
shell: bash
env:
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
run: |
set -euo pipefail
site="$RUNNER_TEMP/site"
repo_dir="$site/flatpak/repo"
mkdir -p "$repo_dir"
ostree --repo="$repo_dir" init --mode=archive-z2
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
if curl --fail --silent --show-error --location \
--output /dev/null "$previous_url/summary"; then
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
ostree --repo="$repo_dir" pull --mirror previous
ostree --repo="$repo_dir" remote delete previous
fi
version="${RELEASE_TAG#v}"
for arch in x86_64 aarch64; do
flatpak build-import-bundle "$repo_dir" \
"bundles/gridcraft-$version-linux-$arch.flatpak"
done
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
# (re-mirrored and re-deployed on every release) does not grow without bound.
flatpak build-update-repo \
--prune --prune-depth=3 \
--title="GridCraft" \
--comment='Official GridCraft releases' \
--description='Official Flatpak repository for GridCraft' \
--homepage="https://github.com/$GITHUB_REPOSITORY" \
--default-branch=stable \
"$repo_dir"
for arch in x86_64 aarch64; do
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
done
test -s "$repo_dir/summary"
base_url="${PAGES_BASE_URL%/}"
repo_url="$base_url/flatpak/repo/"
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
[Flatpak Repo]
Title=GridCraft
Comment=Official GridCraft releases
Description=Official Flatpak repository for GridCraft
Url=$repo_url
Homepage=https://github.com/$GITHUB_REPOSITORY
META
cat >"$site/flatpak/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
flatpak install gridcraft $APP_ID</pre></main></body></html>
HTML
cat >"$site/index.html" <<HTML
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
HTML
touch "$site/.nojekyll"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ${{ runner.temp }}/site
include-hidden-files: true
deploy:
name: Deploy Pages
needs: build
runs-on: ubuntu-24.04
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+81 -13
View File
@@ -1,27 +1,95 @@
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
# from a branch builds the tarball too; they need no secrets.
#
# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
# release binaries and uploads `gridcraft-<version>-freebsd-x86_64.tar.gz` as the
# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the
# draft GitHub Release.
name: FreeBSD
on:
push:
branches: [release]
pull_request:
paths:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
inputs:
package:
description: "Build the release tarball instead of running the tests"
type: boolean
default: false
workflow_call:
inputs:
version:
description: "Version for the artifact name (empty: Cargo.toml)"
type: string
default: ""
version_override:
description: "Also write the version into Cargo.toml before building (test runs)"
type: boolean
default: false
package:
type: boolean
default: false
concurrency:
group: freebsd-${{ github.ref }}
cancel-in-progress: true
# Called from release.yml, github.workflow is the caller's name; keep the groups apart.
group: freebsd-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.workflow == 'FreeBSD' }}
permissions:
contents: read
jobs:
freebsd:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
if: github.ref == 'refs/heads/release' || inputs.package
runs-on: ubuntu-latest
timeout-minutes: 300
env:
GRIDCRAFT_VERSION: ${{ inputs.version }}
GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }}
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
path: craft-fonts
- name: Build in a FreeBSD VM
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: "14.3"
usesh: true
cpu: 4
mem: 12288
envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA"
# Only the packaging run brings files back (dist/release). The target dir lives outside
# the synced checkout, because copying it out of the VM fails on large builds.
copyback: ${{ inputs.package }}
prepare: |
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
run: |
. "$HOME/.cargo/env"
# The checkout is synced in under another owner; let tests that call git read it.
git config --global --add safe.directory "*"
export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0
rustc --version
if [ -n "$GRIDCRAFT_PACKAGE" ]; then
if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then
cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
fi
CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh
else
cargo build --workspace --all-targets --locked
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
fi
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: inputs.package
with:
name: release-freebsd
path: dist/release/*
if-no-files-found: error
-33
View File
@@ -1,33 +0,0 @@
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
name: Nix
on:
push:
branches: [main]
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
pull_request:
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nix-${{ github.ref }}
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
Generated
+16 -16
View File
@@ -1496,7 +1496,7 @@ dependencies = [
[[package]]
name = "gridcraft"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"arboard",
"craft-print",
@@ -1518,7 +1518,7 @@ dependencies = [
[[package]]
name = "gridcraft-calc"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1530,7 +1530,7 @@ dependencies = [
[[package]]
name = "gridcraft-chart"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-calc",
"gridcraft-core",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]]
name = "gridcraft-cli"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"gridcraft-mcp",
@@ -1553,7 +1553,7 @@ dependencies = [
[[package]]
name = "gridcraft-core"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"js-sys",
"serde",
@@ -1563,7 +1563,7 @@ dependencies = [
[[package]]
name = "gridcraft-engine"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-calc",
"gridcraft-chart",
@@ -1584,7 +1584,7 @@ dependencies = [
[[package]]
name = "gridcraft-formula"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"serde",
@@ -1593,7 +1593,7 @@ dependencies = [
[[package]]
name = "gridcraft-functions"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"regex",
@@ -1601,7 +1601,7 @@ dependencies = [
[[package]]
name = "gridcraft-mcp"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"log",
@@ -1610,7 +1610,7 @@ dependencies = [
[[package]]
name = "gridcraft-model"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
@@ -1622,18 +1622,18 @@ dependencies = [
[[package]]
name = "gridcraft-numfmt"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-core",
]
[[package]]
name = "gridcraft-pdf"
version = "0.8.0"
version = "0.6.1"
[[package]]
name = "gridcraft-ui-egui"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
@@ -1655,7 +1655,7 @@ dependencies = [
[[package]]
name = "gridcraft-web"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@@ -1672,7 +1672,7 @@ dependencies = [
[[package]]
name = "gridcraft-xlsx"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"aes",
"gridcraft-core",
@@ -4526,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.6.1"
dependencies = [
"gridcraft-engine",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
[workspace.package]
version = "0.8.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
+34 -182
View File
@@ -1,11 +1,7 @@
//! Localhost JSON-lines control server. Each connection may send many requests; each line
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
//!
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
//! closed, so nothing sent after it runs.
use std::io::{BufRead, BufReader, Read, Write};
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
@@ -17,13 +13,6 @@ const MAX_LINE: usize = 64 * 1024 * 1024;
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let listener = TcpListener::bind(("127.0.0.1", port))?;
let rx = serve_listener(listener, ctx)?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// Accept connections on `listener`; requests arrive on the returned receiver.
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
let (tx, rx) = channel();
std::thread::Builder::new().name("control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
@@ -32,184 +21,47 @@ fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
})?;
log::info!("control channel listening on 127.0.0.1:{port}");
Ok(rx)
}
/// 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> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
return Ok(true);
}
if buf.len() > max {
return Err(format!("request too large (over {max} bytes); closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
/// One request line, parsed.
enum Line {
Request {
id: Json,
method: String,
params: Json,
},
Blank,
/// Not a request: reply with this error and close the connection.
Reject(String),
}
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
};
let text = text.trim();
if text.is_empty() {
return Line::Blank;
}
if is_http_request_line(text) {
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
}
let msg = match serde_json::from_str::<Json>(text) {
Ok(v) => v,
Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")),
};
let Some(obj) = msg.as_object() else {
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
};
let Some(method) = obj.get("method").and_then(Json::as_str) else {
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
};
Line::Request {
id: obj.get("id").cloned().unwrap_or(Json::Null),
method: method.to_string(),
params: obj.get("params").cloned().unwrap_or(json!({})),
}
}
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
fn is_http_request_line(text: &str) -> bool {
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(false) => break,
// The peer is gone before sending a line: nobody to tell.
Err(_) if buf.is_empty() => break,
Err(e) => e,
Ok(true) => match parse_line(&buf) {
Line::Blank => continue,
Line::Reject(e) => e,
Line::Request { id, method, params } => {
let (req, rx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
// Wake the UI thread: egui only runs frames when something happens.
ctx.request_repaint();
let mut reply = rx
.recv_timeout(Duration::from_secs(60))
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{reply}").is_err() {
break;
}
continue;
}
},
};
// The connection closes either way; a peer that can't read the reason loses nothing.
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
break;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Send `input` on a new connection and read reply lines until the server closes it.
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(input).unwrap();
// A read timeout ends the list too: the server kept the connection open.
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
}
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
(addr, serve_listener(listener, egui::Context::default()).unwrap())
}
/// A browser can POST to the port: its request line closes the connection before the JSON
/// line in its body is read, so nothing is dispatched.
#[test]
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
let (addr, rx) = serve_on_loopback();
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n";
let replies = exchange(addr, http);
assert_eq!(replies.len(), 1, "{replies:?}");
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
assert!(rx.try_recv().is_err(), "the body was dispatched");
}
#[test]
fn a_line_that_is_not_a_request_closes_the_connection() {
let (addr, rx) = serve_on_loopback();
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true}));
let reader = BufReader::new(read);
for line in reader.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
if line.len() > MAX_LINE {
let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"}));
continue;
}
let msg: Json = match serde_json::from_str(&line) {
Ok(v) => v,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
continue;
}
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
let mut r = BufReader::new(c);
for id in [1, 2] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["id"], id, "{line}");
};
let id = msg.get("id").cloned().unwrap_or(Json::Null);
let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let (req, rx) = ControlRequest::new(method, params);
if tx.send(req).is_err() {
break;
}
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
let mut input = junk.to_vec();
input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n");
let replies = exchange(addr, &input);
assert_eq!(replies.len(), 1, "{replies:?}");
assert_eq!(replies[0]["ok"], false);
// Wake the UI thread: egui only runs frames when something happens.
ctx.request_repaint();
let mut reply = rx
.recv_timeout(Duration::from_secs(60))
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
}
if writeln!(out, "{reply}").is_err() {
break;
}
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
assert!(is_http_request_line("PRI * HTTP/2.0"));
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
}
+26 -21
View File
@@ -83,6 +83,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
auto_fill
),
cmd!(
"edit.autoFillToData",
"AutoFill to Adjacent Data",
[],
None,
"{source?: \"B1:B4\", mode?: series|alternate|copy|formats|values}: the fill-handle double-click: fills the source down to the bottom of the data region around it, stopping above the first filled cell under it; {target: null} when there is nothing to fill",
has_doc,
auto_fill_to_data
),
cmd!(
"edit.fillSeries",
"Series…",
@@ -1246,9 +1255,6 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
};
let cells: Vec<(CellRef, Cell)> =
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
// Report every stored cell we touch, even beyond a million coordinates in a sparse
// selection. Removed formula anchors must also reach calc so their spills disappear.
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
let mut cells = cells;
if what == "contents" {
for (_, cell) in cells.iter_mut() {
@@ -1287,24 +1293,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
sh.hyperlinks.retain(|c, _| !r.contains(*c));
}
if matches!(what, "all" | "formats") {
let mut unmerged = false;
sh.merges.retain(|m| {
let keep = !r.contains_range(m);
unmerged |= !keep;
keep
});
if unmerged {
// An empty merge can block a spill without storing any cells in this range.
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
let blocked: Vec<_> = sh
.cells
.iter_range(reach)
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
.map(|(c, _)| (sheet, c))
.collect();
cx.changed.extend(blocked);
}
sh.merges.retain(|m| !r.contains_range(m));
}
for c in r.iter().take(1_000_000) {
cx.changed.push((sheet, c));
}
}
Ok(Json::Null)
@@ -1369,6 +1361,19 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
})
}
fn auto_fill_to_data(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let src = str_param(p, "source").and_then(RangeRef::parse).map_or_else(|| target_range(s, p), Ok)?;
let sh = s.doc()?.wb.sheets.get(sheet).ok_or_else(|| bad("edit.autoFillToData", "no such sheet"))?;
let Some(target) = crate::selection::fill_down_extent(sh, src) else { return Ok(json!({"target": null})) };
let mut params = json!({"source": src.a1(), "target": target.a1(), "sheet": sheet});
if let Some(mode) = str_param(p, "mode") {
params["mode"] = json!(mode);
}
auto_fill(s, &params)?;
Ok(json!({"target": target.a1()}))
}
fn fill_series(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = target_sheet(s, p)?;
let r = target_range(s, p)?;
+27 -1
View File
@@ -186,11 +186,16 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
current_region_of(sheet, RangeRef::cell(c))
}
/// [`current_region`] grown from a whole range rather than one cell.
pub fn current_region_of(sheet: &Sheet, from: RangeRef) -> RangeRef {
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
let (mut r0, mut r1, mut c0, mut c1) = (c.row, c.row, c.col, c.col);
let (mut r0, mut r1, mut c0, mut c1) = (from.start.row, from.end.row, from.start.col, from.end.col);
// Grow one side at a time while the line next to it (diagonals included) has data. Each
// pass scans only the lines next to the region, so tall regions take linear time.
loop {
@@ -216,6 +221,27 @@ pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
RangeRef::new(CellRef::new(r0, c0), CellRef::new(r1, c1))
}
/// Where a double-click on the fill handle fills `src` to, as observed in Excel: down to the last
/// row of the data region around it (its current region, so a column of data on either side, or
/// one reached only diagonally, counts), stopping above the first filled cell under the source.
/// `None` when there is nothing to fill.
pub fn fill_down_extent(sheet: &Sheet, src: RangeRef) -> Option<RangeRef> {
let region = current_region_of(sheet, src);
let mut last = region.end.row;
if last <= src.end.row {
return None;
}
// A filled cell under the source ends the fill just above it. Only stored cells are visited,
// so a tall region costs what the sheet holds, not its height.
let under = RangeRef::new(CellRef::new(src.end.row + 1, src.start.col), CellRef::new(last, src.end.col));
let first_cell = sheet.cells.iter_range(under).find(|(_, x)| !x.value.is_empty() || x.formula.is_some()).map(|(c, _)| c.row);
let first_spill = sheet.spill.range(under.start..=under.end).map(|(c, _)| *c).find(|c| under.contains(*c)).map(|c| c.row);
if let Some(r) = first_cell.into_iter().chain(first_spill).min() {
last = r - 1;
}
(last > src.end.row).then(|| RangeRef::new(src.start, CellRef::new(last, src.end.col)))
}
#[cfg(test)]
mod tests {
use super::*;
+121
View File
@@ -734,6 +734,127 @@ fn fill_series() {
assert_eq!(v(&s, "C2"), Value::from("Item 10"));
}
/// The fill-handle double-click (#877), case by case as observed in Excel 365 for Windows.
#[test]
#[allow(clippy::disallowed_methods)] // times the tall fill; native-only test
fn fill_handle_double_click_fills_to_adjacent_data() {
// The column of a cell, rows 1..=n, with `.` for empty.
let col = |s: &Session, c: &str, n: u32| -> String {
(1..=n)
.map(|r| match v(s, &format!("{c}{r}")) {
Value::Empty => ".".to_string(),
Value::Number(x) => format!("{x}"),
Value::Text(t) => t.to_string(),
x => format!("{x:?}"),
})
.collect::<Vec<_>>()
.join(",")
};
let nums = |s: &mut Session, c: &str, rows: &[u32]| {
for r in rows {
s.execute("cell.set", json!({"cell": format!("{c}{r}"), "input": r.to_string()})).unwrap();
}
};
let dbl = |s: &mut Session, src: &str| s.execute("edit.autoFillToData", json!({"source": src})).unwrap();
// The issue's sheet: a 2, 4, 6, 8 series next to 1..10 continues to row 10.
let mut s = self::s();
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
s.execute("range.setValues", json!({"range": "B1", "values": [[2], [4], [6], [8]]})).unwrap();
assert_eq!(dbl(&mut s, "B1:B4"), json!({"target": "B1:B10"}));
assert_eq!(col(&s, "B", 12), "2,4,6,8,10,12,14,16,18,20,.,.");
assert_eq!(s.doc().unwrap().selection.current().a1(), "B1:B10");
// Undo takes the whole fill back in one step.
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(col(&s, "B", 12), "2,4,6,8,.,.,.,.,.,.,.,.");
// Its last cell alone copies.
assert_eq!(dbl(&mut s, "B4"), json!({"target": "B4:B10"}));
assert_eq!(col(&s, "B", 12), "2,4,6,8,8,8,8,8,8,8,.,.");
// Data only on the right counts.
let mut s = self::s();
nums(&mut s, "D", &(1..=8).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "C1", "input": "5"})).unwrap();
dbl(&mut s, "C1");
assert_eq!(col(&s, "C", 10), "5,5,5,5,5,5,5,5,.,.");
// A filled cell under the source stops the fill above it.
let mut s = self::s();
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
s.execute("cell.set", json!({"cell": "B6", "input": "x"})).unwrap();
dbl(&mut s, "B1");
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,x,.,.,.,.,.,.");
// ... and right under it, nothing happens.
let mut s = self::s();
nums(&mut s, "A", &(1..=6).collect::<Vec<_>>());
s.execute("range.setValues", json!({"range": "B1", "values": [[7], [9]]})).unwrap();
assert_eq!(dbl(&mut s, "B1"), json!({"target": null}));
assert_eq!(col(&s, "B", 8), "7,9,.,.,.,.,.,.");
// A gap in the neighbouring column ends the region.
let mut s = self::s();
nums(&mut s, "A", &[1, 2, 3, 4, 5, 7, 8, 9, 10]);
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
dbl(&mut s, "B1");
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,.,.,.,.,.,.,.");
// The longer side wins: 3 rows on the left, 9 on the right.
let mut s = self::s();
nums(&mut s, "A", &[1, 2, 3]);
nums(&mut s, "C", &(1..=9).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
dbl(&mut s, "B1");
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,7,7,7,7,.,.,.");
// Data reached only diagonally belongs to the region too (A1:A3, then C4:C9).
let mut s = self::s();
nums(&mut s, "A", &[1, 2, 3]);
nums(&mut s, "C", &[4, 5, 6, 7, 8, 9]);
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
dbl(&mut s, "B1");
assert_eq!(col(&s, "B", 12), "7,7,7,7,7,7,7,7,7,.,.,.");
// A column further away doesn't.
let mut s = self::s();
nums(&mut s, "D", &(1..=9).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "B1", "input": "7"})).unwrap();
assert_eq!(dbl(&mut s, "B1"), json!({"target": null}));
// From the middle of the data, and at its end (nothing to do).
let mut s = self::s();
nums(&mut s, "A", &(1..=10).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "B5", "input": "7"})).unwrap();
dbl(&mut s, "B5");
assert_eq!(col(&s, "B", 12), ".,.,.,.,7,7,7,7,7,7,.,.");
let mut s = self::s();
nums(&mut s, "A", &[1, 2, 3]);
s.execute("cell.set", json!({"cell": "B3", "input": "7"})).unwrap();
assert_eq!(dbl(&mut s, "B3"), json!({"target": null}));
// Formulas fill with their references moving.
let mut s = self::s();
nums(&mut s, "A", &(1..=5).collect::<Vec<_>>());
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
dbl(&mut s, "B1");
assert_eq!(v(&s, "B5"), Value::Number(10.0));
// A whole data column a million rows tall is fine (and quick: only stored cells are visited).
let mut s = self::s();
let tall: Vec<Vec<f64>> = (0..200_000).map(|i| vec![i as f64]).collect();
s.execute("range.setValues", json!({"range": "A1", "values": tall})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "1"})).unwrap();
let t = std::time::Instant::now();
assert_eq!(dbl(&mut s, "B1"), json!({"target": "B1:B200000"}));
assert!(t.elapsed().as_secs_f64() < 20.0, "double-click fill of 200,000 rows took {:?}", t.elapsed());
assert_eq!(v(&s, "B200000"), Value::Number(1.0));
// Programmatic calls with nothing selected or empty params don't fail.
let mut s = self::s();
assert_eq!(s.execute("edit.autoFillToData", json!({})).unwrap(), json!({"target": null}));
}
#[test]
fn ctrl_drag_fill_swaps_copy_and_series() {
// Issue #264: Ctrl+drag fills a lone number as a series, and copies what a plain drag extends.
@@ -748,6 +748,7 @@ const CURATED: &[(&str, usize)] = &[
("edit.fillDown", 2),
("edit.fillRight", 1),
("edit.autoFill", 2),
("edit.autoFillToData", 1),
("edit.fillSeries", 1),
("home.bold", 1),
("home.fillColor", 1),
@@ -845,6 +846,7 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json {
let tgt = RangeRef::new(src.start, CellRef::new(src.end.row + rng.int(0, 6) as u32, src.end.col + rng.int(0, 1) as u32));
json!({"source": src.a1(), "target": tgt.a1(), "mode": rng.pick(&["series", "copy", "alternate", "formats", "values"])})
}
"edit.autoFillToData" => json!({"source": near_range(rng).a1(), "mode": rng.pick(&["series", "copy"])}),
"edit.fillSeries" => json!({"range": near_range(rng).a1(), "type": rng.pick(&["linear", "growth", "autofill"]), "step": rng.int(-2, 3)}),
"home.bold" => json!({"range": range(rng)}),
"home.fillColor" => json!({"range": range(rng), "color": color(rng)}),
@@ -1,87 +0,0 @@
//! Clearing a large sparse selection must invalidate every changed cell (issue #1032).
use gridcraft_core::{CellError, CellRef, Value};
use gridcraft_engine::Session;
use serde_json::json;
fn value(s: &Session, address: &str) -> Value {
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap())
}
#[test]
fn large_clear_updates_distant_direct_and_transitive_dependents() {
for command in ["edit.clearContents", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] {
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
}
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)));
// B1 stretches the used range to row 1; the changed source lies just past the old cap.
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo");
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo");
}
}
#[test]
fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() {
for command in ["edit.clearContents", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap();
s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap();
assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0)));
// The spill lies outside the selection, but must disappear when its anchor is cleared.
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}");
assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}");
}
}
#[test]
fn clearing_an_empty_merge_unblocks_a_spill() {
for command in ["edit.clearFormats", "edit.clearAll"] {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
// Keep the empty merged area inside the used range after the spill becomes blocked.
s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap();
s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
s.execute(command, json!({"range": "A2:B3"})).unwrap();
assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}");
assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}");
}
}
#[test]
fn clearing_a_distant_styled_blocker_unblocks_a_spill() {
let mut s = Session::new();
s.new_workbook();
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap();
s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap();
s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap();
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
// The cleared blocker stays stored for its style and lies beyond the local 64-row scan.
s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap();
assert_eq!(value(&s, "A1"), Value::Number(1.0));
assert_eq!(value(&s, "A90"), Value::Number(90.0));
assert_eq!(value(&s, "A100"), Value::Number(100.0));
let wb = &s.doc().unwrap().wb;
let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap();
assert_eq!(cell.value, Value::Empty);
assert!(wb.styles.get(cell.style).font.bold);
}
+1 -1
View File
@@ -12,7 +12,7 @@ pub mod printer;
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
pub use input::{FormulaLocale, parse_input, print_input};
pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse};
pub use parser::{ParseError, normalize_function_name, parse};
pub use printer::{print, print_r1c1, quote_sheet};
#[cfg(test)]
-19
View File
@@ -17,11 +17,6 @@ pub struct ParseError {
pub(crate) const MAX_DEPTH: usize = 256;
/// Longest formula accepted, in characters: Excel's limit for formula contents. It also bounds
/// the depth of the trees long operator chains build (`1+1+…`), which dropping and evaluating
/// walk recursively; without it a formula of a few hundred kilobytes in a file overflows the stack.
pub const MAX_FORMULA_LEN: usize = 8_192;
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
/// for `=`.
pub fn parse(src: &str) -> Result<Expr, ParseError> {
@@ -30,9 +25,6 @@ pub fn parse(src: &str) -> Result<Expr, ParseError> {
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
let body = src.strip_prefix('=').unwrap_or(src);
if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN {
return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 });
}
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
let toks = significant_spaces(toks);
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
@@ -168,18 +160,11 @@ impl Parser {
return Err(self.err("formula is nested too deeply"));
}
let mut lhs = self.prefix(allow_union)?;
// Postfix operators (`%`, invoking a result) wrap `lhs` without recursing: they count
// towards the nesting limit, so `1%%%…` can't build a tree deeper than it.
let mut postfix = 0;
loop {
if *self.peek() == Tok::Op("%") {
if 70 < min_bp {
break;
}
postfix += 1;
if self.depth + postfix > MAX_DEPTH {
return Err(self.err("formula is nested too deeply"));
}
self.next();
lhs = Expr::Unary(UnOp::Percent, Box::new(lhs));
continue;
@@ -189,10 +174,6 @@ impl Parser {
if let Expr::Name(_) = lhs {
// A name followed by '(' was lexed as Func; this is `(name)(…)` only.
}
postfix += 1;
if self.depth + postfix > MAX_DEPTH {
return Err(self.err("formula is nested too deeply"));
}
self.next();
let args = self.args()?;
lhs = Expr::Invoke(Box::new(lhs), args);
-35
View File
@@ -366,38 +366,3 @@ fn prints_long_flat_chain_without_deep_recursion() {
assert_eq!(text, vec!["1"; 20001].join("+"));
assert_eq!(r1c1, text);
}
#[test]
fn hostile_formulas_are_errors_not_stack_overflows() {
// A small stack: parsing must fail cleanly instead of building trees deeper than the nesting
// limit, which dropping, printing and evaluating walk recursively.
let handle = std::thread::Builder::new()
.stack_size(512 * 1024)
.spawn(|| {
let percents = format!("1{}", "%".repeat(1_000));
let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000));
let long_chain = format!("1{}", "+1".repeat(100_000));
let huge_percents = format!("1{}", "%".repeat(1_000_000));
(parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err())
})
.expect("spawn");
assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true));
// Within the limits these still parse.
assert!(parse(&format!("1{}", "%".repeat(100))).is_ok());
assert!(parse("LAMBDA(x,x)(1)(2)").is_ok());
}
#[test]
fn formulas_longer_than_excel_allows_are_refused() {
let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2));
assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1);
assert!(parse(&at_limit).is_ok());
assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count");
let over = format!("{at_limit}+1");
let err = parse(&over).unwrap_err();
assert!(err.msg.contains("8192"), "{}", err.msg);
// Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit.
let text = format!("\"{}\"", "é".repeat(8_000));
assert!(text.len() > crate::MAX_FORMULA_LEN);
assert!(parse(&text).is_ok());
}
+72 -8
View File
@@ -222,22 +222,59 @@ pub fn formula_refs(text: &str) -> Vec<(usize, usize, RangeRef, Option<String>,
} else {
i = j + 1;
}
let key = body.get(start..end).unwrap_or("").replace('$', "").to_ascii_uppercase();
let idx = match seen.iter().position(|k| *k == key) {
Some(k) => k,
None => {
seen.push(key);
seen.len() - 1
}
};
let idx = colour_index(&mut seen, body.get(start..end).unwrap_or(""));
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
continue;
}
// Whole columns and rows (`A:A`, `$B:$D`, `2:2`, `Sheet2!3:5`) are references too.
if let Some(t) = toks.get(j)
&& let Some(range) = whole_line_ref(&t.tok, toks.get(j + 1).map(|t| &t.tok), toks.get(j + 2).map(|t| &t.tok))
&& let Some(last) = toks.get(j + 2)
{
let end = last.end;
let idx = colour_index(&mut seen, body.get(start..end).unwrap_or(""));
out.push((start + offset, end + offset, range, sheet, idx % REF_COLORS.len()));
i = j + 3;
continue;
}
i += 1;
}
out
}
/// The colour slot of a reference: the same text (ignoring `$` and case) keeps its colour.
fn colour_index(seen: &mut Vec<String>, text: &str) -> usize {
let key = text.replace('$', "").to_ascii_uppercase();
match seen.iter().position(|k| *k == key) {
Some(k) => k,
None => {
seen.push(key);
seen.len() - 1
}
}
}
/// `A:C` or `2:5` as three tokens (the lexer splits them around the colon): whole columns or
/// whole rows, the same words the formula parser accepts there.
fn whole_line_ref(
a: &gridcraft_engine::formula::lexer::Tok,
colon: Option<&gridcraft_engine::formula::lexer::Tok>,
b: Option<&gridcraft_engine::formula::lexer::Tok>,
) -> Option<RangeRef> {
use gridcraft_engine::formula::lexer::Tok;
if !matches!(colon, Some(Tok::Colon | Tok::Trim(_))) {
return None;
}
let word = |t: &Tok| match t {
Tok::Word(w) => Some(w.clone()),
Tok::Number(n) if n.fract() == 0.0 && *n >= 1.0 => Some(format!("{n}")),
_ => None,
};
let (w1, w2) = (word(a)?, word(b?)?);
let range = RangeRef::parse(&format!("{w1}:{w2}"))?;
(range.is_full_cols() || range.is_full_rows()).then_some(range)
}
/// Excel-style AutoComplete: the one distinct text entry in the same column (contiguous data
/// above and below) that starts with `typed` (case-insensitive). `None` when ambiguous.
pub fn column_completion(sheet: &gridcraft_engine::model::Sheet, at: CellRef, typed: &str) -> Option<String> {
@@ -329,4 +366,31 @@ mod tests {
assert_eq!(r[1].2.a1(), "B2:C3");
assert_eq!(r[3].3.as_deref(), Some("Sheet2"));
}
/// Whole columns and rows are coloured and outlined like any reference (#873, #875).
#[test]
fn whole_column_and_row_refs() {
use gridcraft_engine::core::{MAX_COLS, MAX_ROWS};
let text = "=SUM(A:A)+SUM(2:2)+COUNT($B:$D,Sheet2!3:5,a:a)";
let r = formula_refs(text);
let spans: Vec<&str> = r.iter().map(|x| &text[x.0..x.1]).collect();
assert_eq!(spans, ["A:A", "2:2", "$B:$D", "Sheet2!3:5", "a:a"]);
assert_eq!(r[0].2, RangeRef::cols(0, 0));
assert_eq!(r[1].2, RangeRef::rows(1, 1));
assert_eq!(r[2].2, RangeRef::cols(1, 3));
assert_eq!((r[3].2, r[3].3.as_deref()), (RangeRef::rows(2, 4), Some("Sheet2")));
// Distinct references get distinct colours; the same one (any case or `$`) keeps its own.
assert_eq!([r[0].4, r[1].4, r[2].4, r[3].4], [0, 1, 2, 3]);
assert_eq!(r[4].4, r[0].4);
// The outline spans the whole sheet's height or width.
assert_eq!(r[0].2.end.row, MAX_ROWS - 1);
assert_eq!(r[1].2.end.col, MAX_COLS - 1);
// Mixed with cells, in order of appearance.
let r = formula_refs("=A1+B:B*C2:D3+10:10");
assert_eq!(r.iter().map(|x| x.2.a1()).collect::<Vec<_>>(), ["A1", "B:B", "C2:D3", "10:10"]);
// Not references: a name, numbers around a colon in text, beyond the sheet, half a range.
for text in ["=SUM(Sales)", "=\"1:2\"&A", "=SUM(XFE:XFE)", "=SUM(0:0)", "=SUM(1048577:1048577)", "=SUM(A:", "=A:"] {
assert!(formula_refs(text).is_empty(), "{text}: {:?}", formula_refs(text));
}
}
}
+70 -19
View File
@@ -422,25 +422,7 @@ fn cycle_anchor(ed: &mut crate::editor::EditState) {
for (a, b, ..) in formula_refs(&ed.text) {
if a <= caret_b && caret_b <= b {
let Some(r) = ed.text.get(a..b) else { return };
let cycled: String = r
.split(':')
.map(|part| {
let p = part.split('!').next_back().unwrap_or(part);
let prefix = &part[..part.len() - p.len()];
let bare = p.replace('$', "");
let letters: String = bare.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let digits = &bare[letters.len()..];
let state = (p.starts_with('$'), p[letters.len().min(p.len())..].contains('$') || p.trim_start_matches('$').contains('$'));
let next = match state {
(false, false) => format!("${letters}${digits}"),
(true, true) => format!("{letters}${digits}"),
(false, true) => format!("${letters}{digits}"),
(true, false) => format!("{letters}{digits}"),
};
format!("{prefix}{next}")
})
.collect::<Vec<_>>()
.join(":");
let cycled = cycle_ref(r);
let (Some(x), Some(y)) = (ed.text.get(..a), ed.text.get(b..)) else { return };
ed.text = format!("{x}{cycled}{y}");
ed.caret = ed.text.get(..a + cycled.len()).map(|s| s.chars().count()).unwrap_or(ed.caret);
@@ -452,6 +434,46 @@ fn cycle_anchor(ed: &mut crate::editor::EditState) {
}
}
/// F4 on one reference: `A1` → `$A$1` → `A$1` → `$A1` → `A1`. Whole columns and rows have one
/// coordinate, so they toggle: `B:D` ⇄ `$B:$D`, `2:3` ⇄ `$2:$3` (as Excel does).
fn cycle_ref(r: &str) -> String {
let unqualified = |part: &str| part.split('!').next_back().unwrap_or(part).to_string();
let first = unqualified(r.split(':').next().unwrap_or(r));
let first_bare = first.replace('$', "");
let whole_line = r.contains(':') && (first_bare.chars().all(|c| c.is_ascii_alphabetic()) || first_bare.chars().all(|c| c.is_ascii_digit()));
if whole_line {
let anchored = first.starts_with('$');
return r
.split(':')
.map(|part| {
let p = unqualified(part);
let prefix = &part[..part.len() - p.len()];
let bare = p.replace('$', "");
if anchored { format!("{prefix}{bare}") } else { format!("{prefix}${bare}") }
})
.collect::<Vec<_>>()
.join(":");
}
r.split(':')
.map(|part| {
let p = part.split('!').next_back().unwrap_or(part);
let prefix = &part[..part.len() - p.len()];
let bare = p.replace('$', "");
let letters: String = bare.chars().take_while(|c| c.is_ascii_alphabetic()).collect();
let digits = &bare[letters.len()..];
let state = (p.starts_with('$'), p[letters.len().min(p.len())..].contains('$') || p.trim_start_matches('$').contains('$'));
let next = match state {
(false, false) => format!("${letters}${digits}"),
(true, true) => format!("{letters}${digits}"),
(false, true) => format!("${letters}{digits}"),
(true, false) => format!("{letters}{digits}"),
};
format!("{prefix}{next}")
})
.collect::<Vec<_>>()
.join(":")
}
fn function_names() -> Vec<String> {
static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
NAMES
@@ -474,3 +496,32 @@ pub fn function_signature(name: &str) -> Option<String> {
pub fn function_description(name: &str) -> Option<String> {
function_info(name).and_then(|f| f["description"].as_str().map(str::to_string))
}
#[cfg(test)]
mod tests {
use super::cycle_ref;
#[test]
fn f4_cycles_cells_through_four_states() {
let mut r = "A1".to_string();
let mut seen = vec![];
for _ in 0..4 {
r = cycle_ref(&r);
seen.push(r.clone());
}
assert_eq!(seen, ["$A$1", "A$1", "$A1", "A1"]);
assert_eq!(cycle_ref("B2:C3"), "$B$2:$C$3");
assert_eq!(cycle_ref("Sheet2!B2"), "Sheet2!$B$2");
}
/// As observed in Excel: F4 on a whole column or row toggles its `$` (#873).
#[test]
fn f4_toggles_whole_columns_and_rows() {
assert_eq!(cycle_ref("B:B"), "$B:$B");
assert_eq!(cycle_ref("$B:$B"), "B:B");
assert_eq!(cycle_ref("2:3"), "$2:$3");
assert_eq!(cycle_ref("$2:$3"), "2:3");
assert_eq!(cycle_ref("Sheet2!C:E"), "Sheet2!$C:$E");
assert_eq!(cycle_ref("'My Sheet'!$4:$4"), "'My Sheet'!4:4");
}
}
+135 -72
View File
@@ -63,6 +63,12 @@ pub enum Drag {
Select,
Rows(u32),
Cols(u32),
/// Point mode while editing a formula: dragging over the column (`cols`) or row headers from
/// `anchor` puts `B:D` or `2:5` into the formula.
PointLines {
cols: bool,
anchor: u32,
},
/// Reordering one contiguous whole-column selection by its header.
MoveCols {
source: RangeRef,
@@ -118,6 +124,12 @@ pub struct GridState {
/// The selection outline on its way to a new selection.
pub glide: crate::selection_glide::SelectionGlide,
pub last_click: Option<(CellRef, f64)>,
/// The grid acted on the current press when the button went down (#900): its drag start or
/// click must not act again. Cleared when the button is up.
pub press_consumed: bool,
/// The cell a plain press selected: releasing over it without dragging is a click, which
/// toggles its checkbox or follows its hyperlink.
pub click_cell: Option<CellRef>,
pub hover_cell: Option<CellRef>,
pub autosave: bool,
pub last_autosave: f64,
@@ -1775,6 +1787,10 @@ pub(crate) fn paint_headers(
const OBJECT_RESIZE_RADIUS: f32 = 10.0;
fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &Geo, sh: &Sheet, wb: &Workbook) {
// A press the grid acted on lasts through its release, however the release is handled.
if ui.input(|i| !i.pointer.any_down() && !i.pointer.any_pressed() && !i.pointer.any_released()) {
app.grid.press_consumed = false;
}
// Text selection and editing must not also move the object or edit worksheet cells.
if app.text_box_editor.is_some() || app.grid.hf_edit.is_some() {
return;
@@ -1966,38 +1982,67 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
// Double-click the fill handle: a double-click is also a click, so run it before the handle press returns.
// A double-click on the fill handle fills down as far as the data next to the source goes
// (#877). It arrives with the second release, after that press armed a fill drag.
if resp.double_clicked()
&& let Some(p) = pos
&& app.editor.is_none()
&& let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos)
&& on_handle(p)
{
// Fill down as far as the neighbouring column goes.
let src = sel.current();
let neighbour = if src.start.col > 0 { src.start.col - 1 } else { src.end.col + 1 };
let mut last = src.end.row;
while last + 1 < MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last - src.end.row < 1_000_000
{
last += 1;
}
if last > src.end.row {
let target = RangeRef::new(src.start, CellRef::new(last, src.end.col));
let _ = app.run("edit.autoFill", json!({"source": src.a1(), "target": target.a1()}));
}
app.grid.drag = Drag::None;
app.grid.drag_select = false;
resp.request_focus();
app.grid.click_cell = None;
app.run_or_alert("edit.autoFillToData", json!({"source": sel.current().a1()}));
return;
}
// A press acts when the button goes down, as in Excel (#900): the cell under it is selected
// at once and a drag extends from it. Targets that behave like buttons (the outline band) or
// whose press may still turn into a drag of something else (the selection border, a selected
// column header, an object) wait for the drag to start or for the click.
let press_now = resp.hovered() && pointer.primary_pressed() && app.grid.drag == Drag::None;
let follow_up = !press_now && (resp.drag_started() || primary_clicked) && app.grid.drag == Drag::None && !app.grid.press_consumed;
if press_now {
app.grid.press_consumed = true;
app.grid.click_cell = None;
}
// Releasing a plain press without dragging away from its cell clicks it.
if primary_clicked
&& !press_now
&& let Some(c) = app.grid.click_cell.take()
&& let Some(p) = pos
&& in_cells
&& geo.cell_at(sh, p) == c
&& app.editor.is_none()
{
click_cell(app, geo, sh, c, p, mods);
}
// Leaves this press to its drag start or its click.
let defer = |app: &mut SheetApp| {
if press_now && !primary_clicked {
app.grid.press_consumed = false;
true
} else {
false
}
};
// Press: decide the drag mode.
if resp.drag_started() || (primary_clicked && app.grid.drag == Drag::None) {
if press_now || follow_up {
// Where the press began (a drag is only recognised after the pointer has moved).
let Some(p) = pointer.press_origin().map(|p| geo.mirror_pos(p)).or(pos) else { return };
// Objects take their press on its drag start or click (above), and a header or footer
// in Page Layout view on its click (`paper_view`).
if (crate::chartview::hit(app, geo, sh, p).is_some() || crate::paper_view::over_header_footer(geo, geo.mirror_pos(p))) && defer(app) {
return;
}
// The outline band's − / + and level buttons act on release, like buttons.
if let Some(action) = crate::outline::hit(geo, sh, p) {
if primary_clicked {
let (command, params) = action.command();
app.run_or_alert(command, params);
} else {
defer(app);
}
return;
}
@@ -2016,17 +2061,20 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let c = geo.col_at(sh, p.x);
if point_header(app, wb, sh, RangeRef::cols(c, c)) {
// Point mode: a column header puts the whole column into the formula (`C:C`).
if point_lines(app, wb, sh, true, c, c) {
app.grid.drag = Drag::PointLines { cols: true, anchor: c };
return;
}
if resp.drag_started()
&& !mods.any()
&& sel.ranges.len() == 1
&& sel.current().is_full_cols()
&& sel.current().contains(CellRef::new(0, c))
{
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
return;
if !mods.any() && sel.ranges.len() == 1 && sel.current().is_full_cols() && sel.current().contains(CellRef::new(0, c)) {
// A selected column: dragging it moves the columns, a click selects just it.
if resp.drag_started() {
app.grid.drag = Drag::MoveCols { source: sel.current(), before: None };
return;
}
if defer(app) {
return;
}
}
let range = if mods.shift { RangeRef::cols(sel.anchor.col.min(c), sel.anchor.col.max(c)) } else { RangeRef::cols(c, c) };
select_ranges(app, range, mods.command, CellRef::new(geo.row_at(sh, geo.cells.top() + 1.0), c));
@@ -2039,7 +2087,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
return;
}
let r = geo.row_at(sh, p.y);
if point_header(app, wb, sh, RangeRef::rows(r, r)) {
if point_lines(app, wb, sh, false, r, r) {
app.grid.drag = Drag::PointLines { cols: false, anchor: r };
return;
}
let range = if mods.shift { RangeRef::rows(sel.anchor.row.min(r), sel.anchor.row.max(r)) } else { RangeRef::rows(r, r) };
@@ -2091,9 +2140,15 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.grid.drag = Drag::Fill { target: None };
return;
}
if on_cur_border(p) && resp.drag_started() {
app.grid.drag = Drag::Move { grab: c, to: None };
return;
if on_cur_border(p) {
// The selection's border drags it to a new place; a click on it selects the cell.
if resp.drag_started() {
app.grid.drag = Drag::Move { grab: c, to: None };
return;
}
if defer(app) {
return;
}
}
// Double-click edits.
let now = crate::now_ms();
@@ -2117,18 +2172,10 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
select_ranges(app, RangeRef::cell(c), true, c);
} else {
let _ = app.session.run("selection.set", json!({"cell": c.a1()}));
if primary_clicked && wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox" {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
// A click on a hyperlink's text follows it (like Excel); elsewhere in the cell selects.
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& primary_clicked
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
if p.x < r.left() + r.width() * 0.8 {
follow_link(app, &h.target);
}
if primary_clicked {
click_cell(app, geo, sh, c, p, mods);
} else {
app.grid.click_cell = Some(c);
}
}
app.grid.drag = Drag::Select;
@@ -2182,6 +2229,11 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
app.session.run("selection.set", json!({"range": RangeRef::cols(anchor.min(c), anchor.max(c)).a1(), "active": sel.active.a1()}));
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::PointLines { cols, anchor } => {
let to = if cols { geo.col_at(sh, p.x) } else { geo.row_at(sh, p.y) };
point_lines(app, wb, sh, cols, anchor, to);
scroll_by(app, if cols { vec2(auto.x, 0.0) } else { vec2(0.0, auto.y) });
}
Drag::MoveCols { source, .. } => {
let col = geo.col_at(sh, p.x.clamp(geo.cells.left() + 1.0, geo.cells.right() - 1.0));
let midpoint = geo.x(sh, col) + sh.col_width(col) * geo.z * 0.5;
@@ -2324,22 +2376,14 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
}
}
if primary_clicked || resp.drag_started() {
// The grid takes the keyboard, except from a formula being pointed at (its press already
// handed focus back to the editor).
if (primary_clicked || resp.drag_started()) && app.editor.is_none() {
resp.request_focus();
}
keyboard(app, &ctx, resp, geo, sh);
}
/// Point mode on a header: inserts the whole-column or whole-row reference instead of selecting.
fn point_header(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, range: RangeRef) -> bool {
let Some(ed) = app.editor.as_mut().filter(|ed| ed.can_point()) else { return false };
let r = range.a1();
let r = if ed.sheet != wb.active_sheet { format!("{}!{r}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { r };
ed.insert_ref(&r);
ed.request_focus = true;
true
}
fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p: Pos2) {
let cell = CellRef::new(
geo.row_at(sh, p.y.clamp(geo.cells.top() + 1.0, geo.cells.bottom() - 1.0)),
@@ -2369,6 +2413,23 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
}
}
/// Point mode on the headers: puts whole columns (`cols`, `B:D`) or rows (`2:5`) from `a` to `b`
/// into the formula being edited, as Excel does for a click or drag on the headings. `false`
/// (nothing changed) when not editing a formula where a reference can go.
fn point_lines(app: &mut SheetApp, wb: &Workbook, sh: &Sheet, cols: bool, a: u32, b: u32) -> bool {
let Some(ed) = app.editor.as_mut() else { return false };
if !ed.can_point() {
return false;
}
let range = if cols { RangeRef::cols(a.min(b), a.max(b)) } else { RangeRef::rows(a.min(b), a.max(b)) };
let text = range.a1();
let text = if ed.sheet != wb.active_sheet { format!("{}!{text}", gridcraft_engine::formula::quote_sheet(&sh.name)) } else { text };
ed.insert_ref(&text);
ed.point_cell = None;
ed.request_focus = true;
true
}
fn coalesce_last_undo(app: &mut SheetApp) {
// Merge consecutive drag steps into one undo entry.
if let Some(d) = app.session.active_mut()
@@ -2524,6 +2585,23 @@ fn delete_selected_object(app: &mut SheetApp, sh: &Sheet) -> bool {
true
}
/// What a click on a cell does beyond selecting it: toggles its checkbox, or follows its
/// hyperlink when the click is on the link's text (like Excel; elsewhere in the cell it selects).
fn click_cell(app: &mut SheetApp, geo: &Geo, sh: &Sheet, c: CellRef, p: Pos2, mods: egui::Modifiers) {
let checkbox = app.session.active().is_some_and(|d| d.wb.styles.get(sh.style_id(c)).num_fmt.as_str() == "checkbox");
if checkbox {
let _ = app.run("cell.toggleCheckbox", json!({"cell": c.a1()}));
}
if let Some(h) = sh.hyperlinks.get(&c).cloned()
&& !mods.any()
{
let r = geo.cell_rect(sh, c);
if p.x < r.left() + r.width() * 0.8 {
follow_link(app, &h.target);
}
}
}
/// A click on a hyperlink. References select; web and e-mail addresses ask first (the dialog's
/// OK runs `link.open`); every other kind is refused with a message. The decision is the
/// engine's ([`gridcraft_engine::links::classify`]), the same one `link.open` applies.
@@ -2573,8 +2651,7 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
resp.request_focus();
}
let other_focus = ctx.memory(|m| m.focused()).is_some_and(|f| f != resp.id && (editing || !stale.contains(&f)));
// An open filter menu owns the keyboard even when a click inside it left focus on nothing (#1516).
if editing || app.dialog.is_some() || other_focus || app.message.is_some() || app.grid.filter_menu.is_some() {
if editing || app.dialog.is_some() || other_focus || app.message.is_some() {
return;
}
if ctx.memory(|m| m.focused()).is_none() {
@@ -2769,24 +2846,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
let key = egui::Id::new(("filter_sel", col));
let search_key = egui::Id::new(("filter_search", col));
let buttons_key = egui::Id::new(("filter_buttons", col));
// Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
// focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
let buttons: Vec<egui::Id> = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let mut close = false;
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
let response = area.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(230.0);
let ascending = ui.button("↑ Sort Ascending");
if ascending.clicked() {
if ui.button("↑ Sort Ascending").clicked() {
let _ = app.run("data.sortAscending", json!({"column": col_to_letters(col)}));
close = true;
}
let descending = ui.button("↓ Sort Descending");
if descending.clicked() {
if ui.button("↓ Sort Descending").clicked() {
let _ = app.run("data.sortDescending", json!({"column": col_to_letters(col)}));
close = true;
}
@@ -2823,16 +2892,11 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
let checks: Vec<(String, bool)> = checks.iter().map(|(v, on)| (v.clone(), *on && matches(v))).collect();
ui.separator();
ui.horizontal(|ui| {
let clear = ui.button("Clear Filter");
if clear.clicked() {
if ui.button("Clear Filter").clicked() {
let _ = app.run("data.filterBy", json!({"column": col_to_letters(col), "clear": true}));
close = true;
}
// Nothing checked would hide every row, so Enter (unlike a click on Apply) does nothing then.
let any = checks.iter().any(|(_, on)| *on);
let apply = ui.button("Apply");
ui.ctx().data_mut(|d| d.insert_temp(buttons_key, vec![ascending.id, descending.id, clear.id, apply.id]));
if apply.clicked() || (enter && any) {
if ui.button("Apply").clicked() {
let blanks = checks.iter().any(|(v, on)| v == "(Blanks)" && *on);
let vals: Vec<String> = checks.iter().filter(|(v, on)| *on && v != "(Blanks)").map(|(v, _)| v.clone()).collect();
let p = if checks.iter().all(|(_, b)| *b) {
@@ -2850,7 +2914,6 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
ui.ctx().data_mut(|d| {
d.remove::<Vec<(String, bool)>>(key);
d.remove::<String>(search_key);
d.remove::<Vec<egui::Id>>(buttons_key);
});
app.grid.filter_menu = None;
}
+7
View File
@@ -306,6 +306,13 @@ pub(crate) fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Respo
editor(app, ui, geo, &pg);
}
/// Whether `p` (screen) is over a header or footer zone, which a click edits in place.
pub fn over_header_footer(geo: &crate::grid::Geo, p: egui::Pos2) -> bool {
let Some(pg) = geo.paper.as_ref() else { return false };
let (x, y) = (geo.scroll.x as f64 + ((p.x - geo.cells.left()) / geo.z) as f64, geo.scroll.y as f64 + ((p.y - geo.cells.top()) / geo.z) as f64);
geo.cells.contains(p) && pg.hf_zone_at(x, y).is_some()
}
/// The `pageLayout.headerFooter` parameter holding one kind of page's header or footer.
fn hf_key(kind: HfPage, footer: bool) -> &'static str {
match (kind, footer) {
@@ -1,4 +1,4 @@
//! Double-clicking the fill handle fills down to the neighbouring column's last used row,
//! Double-clicking the fill handle fills down to the bottom of the data beside it (`edit.autoFillToData`),
//! even with the pointer up for a frame between the two clicks.
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
@@ -55,5 +55,5 @@ fn double_click_on_the_fill_handle_fills_down_with_a_frame_between_the_clicks()
assert_eq!([number(&h, 3), number(&h, 4), number(&h, 5)], [Value::Number(40.0), Value::Number(50.0), Value::Number(60.0)]);
assert_eq!(h.state().session.active().unwrap().selection.a1(), "B1:B6");
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFill").count(), 1);
assert_eq!(h.state().session.journal.iter().filter(|(id, _)| id == "edit.autoFillToData").count(), 1);
}
-87
View File
@@ -1,87 +0,0 @@
//! Enter in the AutoFilter menu works like its Apply button, as Excel's OK (issue #1516): from the
//! search box or with a value focused it applies the filter and closes the menu, and the key never
//! reaches the grid underneath.
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable;
use gridcraft_engine::Session;
use gridcraft_ui_egui::SheetApp;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
/// A1:A4 holds a header and three fruits under an AutoFilter, with the menu opened by Alt+Down on A1.
fn filter_menu_open() -> Harness {
let mut session = Session::new();
session.new_workbook();
session.execute("range.setValues", json!({"range": "A1", "values": [["Fruit"], ["Apple"], ["Banana"], ["Cherry"]]})).unwrap();
session.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
session.execute("selection.set", json!({"cell": "A1"})).unwrap();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
key(&mut h, Key::ArrowDown, Modifiers::ALT);
assert!(h.state().grid.filter_menu.is_some(), "Alt+Down opens the filter menu");
h
}
fn key(h: &mut Harness, k: Key, m: Modifiers) {
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: true, repeat: false, modifiers: m });
h.input_mut().events.push(Event::Key { key: k, physical_key: Some(k), pressed: false, repeat: false, modifiers: m });
h.run_steps(2);
}
/// Rows 2..=4 (Apple, Banana, Cherry): which ones the filter hides.
fn hidden(h: &Harness) -> Vec<bool> {
let sh = h.state().session.active().unwrap().wb.active().unwrap();
(1..=3).map(|r| sh.is_row_hidden(r)).collect()
}
fn active(h: &Harness) -> String {
h.state().session.active().unwrap().selection.active.a1()
}
#[test]
fn enter_in_the_search_box_applies_the_filter() {
let mut h = filter_menu_open();
h.input_mut().events.push(Event::Text("an".into()));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(hidden(&h), [true, false, true], "only Banana matches the search");
assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
assert_eq!(active(&h), "A1", "Enter must not move the active cell");
}
#[test]
fn enter_with_a_value_clicked_applies_the_filter() {
let mut h = filter_menu_open();
let apple = h.get_by_label("Apple").rect().center();
h.input_mut().events.push(Event::PointerMoved(apple));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: apple, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(2);
assert!(h.state().grid.filter_menu.is_some(), "clicking a value keeps the menu open");
key(&mut h, Key::Enter, Modifiers::NONE);
// Enter applies the list as it is: it must not toggle the value back on.
assert_eq!(hidden(&h), [true, false, false], "Apple was unchecked");
assert!(h.state().grid.filter_menu.is_none(), "Enter closes the menu");
assert_eq!(active(&h), "A1", "Enter must not move the active cell");
}
#[test]
fn enter_with_nothing_checked_keeps_the_menu_open() {
let mut h = filter_menu_open();
// A search that matches nothing leaves no value to show, as Excel's OK is then disabled.
h.input_mut().events.push(Event::Text("Bananna".into()));
h.run_steps(2);
key(&mut h, Key::Enter, Modifiers::NONE);
assert_eq!(hidden(&h), [false, false, false], "an empty selection must not hide every row");
assert!(h.state().grid.filter_menu.is_some(), "the menu stays open");
assert_eq!(active(&h), "A1");
}
+348
View File
@@ -0,0 +1,348 @@
//! A press on a cell selects it when the button goes down, as in Excel (#900), and a double-click
//! on the fill handle fills down to the data next to it (#877).
use egui::{Event, Modifiers, PointerButton, Pos2};
use gridcraft_engine::Session;
use gridcraft_engine::core::{CellRef, RangeRef, Value};
use gridcraft_ui_egui::{SheetApp, grid::Geo};
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(session: Session) -> Harness {
// 60 frames a second, so two clicks a few frames apart are a double-click to egui.
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 800.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(session, Default::default()),
);
h.run_steps(4);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn geo(h: &Harness) -> Geo {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
Geo::new(sheet, app.grid.rect.unwrap(), app.view().scroll)
}
fn rect(h: &Harness, a1: &str) -> egui::Rect {
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
geo(h).cell_rect(sheet, CellRef::parse(a1).unwrap())
}
fn frame(h: &mut Harness, event: Event) {
h.input_mut().events.push(event);
h.run_steps(1);
}
fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }
}
fn down(h: &mut Harness, at: Pos2, modifiers: Modifiers) {
h.input_mut().events.push(Event::ModifiersChanged(modifiers));
frame(h, Event::PointerMoved(at));
frame(h, button(at, true, modifiers));
}
fn up(h: &mut Harness, at: Pos2, modifiers: Modifiers) {
frame(h, button(at, false, modifiers));
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
h.run_steps(2);
}
/// Waits out the double-click window (the grid times it on the wall clock).
fn pause(h: &mut Harness) {
std::thread::sleep(std::time::Duration::from_millis(500));
h.run_steps(2);
}
fn selection(h: &Harness) -> (String, String, Vec<String>) {
let s = &h.state().session.active().unwrap().selection;
(s.active.a1(), s.anchor.a1(), s.ranges.iter().map(RangeRef::a1).collect())
}
fn value(h: &Harness, a1: &str) -> Value {
h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse(a1).unwrap())
}
#[test]
fn the_press_selects_before_the_release() {
let mut h = harness(blank());
let c3 = rect(&h, "C3").center();
down(&mut h, c3, Modifiers::NONE);
assert_eq!(selection(&h).0, "C3", "the cell is selected while the button is still down");
up(&mut h, c3, Modifiers::NONE);
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3".into()]));
}
#[test]
fn a_press_that_wobbles_inside_its_cell_still_selects_it() {
let mut h = harness(blank());
let r = rect(&h, "C3");
let at = r.left_top() + egui::vec2(4.0, 4.0);
down(&mut h, at, Modifiers::NONE);
// Further than egui's click distance, but still inside C3: a drag that never leaves the cell.
for step in 1..=6 {
frame(&mut h, Event::PointerMoved(at + egui::vec2(step as f32 * 5.0, step as f32 * 1.5)));
}
up(&mut h, at + egui::vec2(30.0, 9.0), Modifiers::NONE);
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3".into()]));
}
#[test]
fn a_long_press_without_moving_selects_the_cell() {
let mut h = harness(blank());
let c3 = rect(&h, "C3").center();
down(&mut h, c3, Modifiers::NONE);
// Longer than egui's longest click: the release is no longer a click.
h.run_steps(60);
up(&mut h, c3, Modifiers::NONE);
assert_eq!(selection(&h).2, vec!["C3".to_string()]);
}
#[test]
fn dragging_from_the_press_extends_live_then_stays() {
let mut h = harness(blank());
let c3 = rect(&h, "C3").center();
down(&mut h, c3, Modifiers::NONE);
let e5 = rect(&h, "E5").center();
frame(&mut h, Event::PointerMoved(c3.lerp(e5, 0.5)));
frame(&mut h, Event::PointerMoved(e5));
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3:E5".into()]), "the range follows the pointer while held");
up(&mut h, e5, Modifiers::NONE);
assert_eq!(selection(&h), ("C3".into(), "C3".into(), vec!["C3:E5".into()]));
}
#[test]
fn shift_and_ctrl_presses_act_on_the_press() {
let mut h = harness(blank());
let b2 = rect(&h, "B2").center();
down(&mut h, b2, Modifiers::NONE);
up(&mut h, b2, Modifiers::NONE);
pause(&mut h);
let d4 = rect(&h, "D4").center();
down(&mut h, d4, Modifiers::SHIFT);
assert_eq!(selection(&h).2, vec!["B2:D4".to_string()], "Shift+press extends at once");
up(&mut h, d4, Modifiers::SHIFT);
assert_eq!(selection(&h).2, vec!["B2:D4".to_string()]);
pause(&mut h);
let f6 = rect(&h, "F6").center();
let ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
down(&mut h, f6, ctrl);
assert_eq!(selection(&h).2, vec!["B2:D4".to_string(), "F6".to_string()], "Ctrl+press adds an area at once");
up(&mut h, f6, ctrl);
assert_eq!(selection(&h).2, vec!["B2:D4".to_string(), "F6".to_string()], "and the release doesn't add it twice");
}
#[test]
fn a_checkbox_toggles_on_the_click_not_the_press() {
let mut s = blank();
s.execute("insert.checkbox", json!({"range": "B2"})).unwrap();
let mut h = harness(s);
let b2 = rect(&h, "B2").center();
down(&mut h, b2, Modifiers::NONE);
assert_eq!(selection(&h).0, "B2");
assert_eq!(value(&h, "B2"), Value::Bool(false), "pressing doesn't toggle yet");
up(&mut h, b2, Modifiers::NONE);
assert_eq!(value(&h, "B2"), Value::Bool(true), "the click toggles once");
pause(&mut h);
// A press that is dragged off the cell is not a click.
down(&mut h, b2, Modifiers::NONE);
let d2 = rect(&h, "D2").center();
frame(&mut h, Event::PointerMoved(d2));
up(&mut h, d2, Modifiers::NONE);
assert_eq!(value(&h, "B2"), Value::Bool(true));
assert_eq!(selection(&h).2, vec!["B2:D2".to_string()]);
}
#[test]
fn double_click_still_edits_the_cell() {
let mut s = blank();
s.execute("cell.set", json!({"cell": "C3", "input": "hello"})).unwrap();
let mut h = harness(s);
let c3 = rect(&h, "C3").center();
for _ in 0..2 {
down(&mut h, c3, Modifiers::NONE);
frame(&mut h, button(c3, false, Modifiers::NONE));
}
h.run_steps(2);
let editor = h.state().editor.as_ref().expect("a double-click edits");
assert_eq!((editor.cell.a1(), editor.text.as_str()), ("C3".to_string(), "hello"));
}
#[test]
fn a_click_on_the_selection_border_selects_the_cell_and_a_drag_moves_it() {
let mut s = blank();
s.execute("cell.set", json!({"cell": "B2", "input": "7"})).unwrap();
s.execute("selection.set", json!({"range": "B2"})).unwrap();
let mut h = harness(s);
// On B2's right border, which belongs to the move handle: nothing happens on the press.
let r = rect(&h, "B2");
let border = egui::pos2(r.right(), r.center().y);
down(&mut h, border, Modifiers::NONE);
assert_eq!(selection(&h).0, "B2");
let d4 = rect(&h, "D4").center();
frame(&mut h, Event::PointerMoved(border + egui::vec2(20.0, 0.0)));
frame(&mut h, Event::PointerMoved(d4));
up(&mut h, d4, Modifiers::NONE);
assert_eq!(value(&h, "B2"), Value::Empty, "dragging the border moved the cell");
assert_eq!(value(&h, "D4"), Value::Number(7.0));
}
#[test]
fn double_clicking_the_fill_handle_fills_to_the_data_beside_it() {
// The sheet from #877: 1..10 in A, 2, 4, 6, 8 in B1:B4.
let mut s = blank();
let a: Vec<Vec<f64>> = (1..=10).map(|i| vec![i as f64]).collect();
s.execute("range.setValues", json!({"range": "A1", "values": a})).unwrap();
s.execute("range.setValues", json!({"range": "B1", "values": [[2], [4], [6], [8]]})).unwrap();
s.execute("selection.set", json!({"range": "B1:B4"})).unwrap();
let mut h = harness(s);
let handle = rect(&h, "B4").right_bottom() - egui::vec2(1.0, 1.0);
frame(&mut h, Event::PointerMoved(handle));
frame(&mut h, button(handle, true, Modifiers::NONE));
frame(&mut h, button(handle, false, Modifiers::NONE));
// A few frames between the clicks, as a real double-click has (still well inside egui's window).
h.run_steps(4);
frame(&mut h, button(handle, true, Modifiers::NONE));
frame(&mut h, button(handle, false, Modifiers::NONE));
h.run_steps(3);
assert_eq!(value(&h, "B10"), Value::Number(20.0));
assert_eq!(value(&h, "B11"), Value::Empty);
assert_eq!(selection(&h).2, vec!["B1:B10".to_string()]);
// One undo step.
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(value(&h, "B5"), Value::Empty);
assert_eq!(value(&h, "B4"), Value::Number(8.0));
}
fn header_point(h: &Harness, col: Option<&str>, row: Option<u32>) -> Pos2 {
let g = geo(h);
let app = h.state();
let sheet = app.session.active().unwrap().wb.active().unwrap();
match (col, row) {
(Some(c), _) => {
let x = g.cell_rect(sheet, CellRef::parse(&format!("{c}1")).unwrap()).center().x;
egui::pos2(x, g.cells.top() - g.header_h / 2.0)
}
(_, Some(r)) => {
let y = g.cell_rect(sheet, CellRef::new(r - 1, 0)).center().y;
egui::pos2(g.cells.left() - g.header_w / 2.0, y)
}
_ => unreachable!(),
}
}
fn start_typing(h: &mut Harness, cell: &str, text: &str) {
h.state_mut().session.execute("selection.set", json!({"cell": cell})).unwrap();
h.run_steps(2);
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(3);
}
fn editor_text(h: &Harness) -> String {
h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default()
}
fn enter(h: &mut Harness) {
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(3);
}
fn formula(h: &Harness, a1: &str) -> Option<String> {
let sheet = h.state().session.active().unwrap().wb.active().unwrap();
sheet.cell(CellRef::parse(a1).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
}
/// While typing a formula, the headings point like cells do, as observed in Excel: a column
/// heading puts `B:B` in, a drag across headings `B:D`, a row heading `3:3` (#872).
#[test]
fn headings_put_whole_columns_and_rows_into_a_formula() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "B1", "values": [[1, 10, 100], [2, 20, 200], [3, 30, 300]]})).unwrap();
let mut h = harness(s);
start_typing(&mut h, "F1", "=SUM(");
let b = header_point(&h, Some("B"), None);
down(&mut h, b, Modifiers::NONE);
assert_eq!(editor_text(&h), "=SUM(B:B", "the press points");
up(&mut h, b, Modifiers::NONE);
assert_eq!(editor_text(&h), "=SUM(B:B");
assert_eq!(h.state().session.active().unwrap().selection.active.a1(), "F1", "the selection stays on the edited cell");
h.input_mut().events.push(Event::Text(")".into()));
h.run_steps(2);
enter(&mut h);
assert_eq!(formula(&h, "F1").as_deref(), Some("SUM(B:B)"));
assert_eq!(value(&h, "F1"), Value::Number(6.0));
// A drag across the headings widens it, and back again.
pause(&mut h);
start_typing(&mut h, "F2", "=SUM(");
let (b, c, d) = (header_point(&h, Some("B"), None), header_point(&h, Some("C"), None), header_point(&h, Some("D"), None));
down(&mut h, b, Modifiers::NONE);
frame(&mut h, Event::PointerMoved(c));
frame(&mut h, Event::PointerMoved(d));
assert_eq!(editor_text(&h), "=SUM(B:D");
frame(&mut h, Event::PointerMoved(c));
assert_eq!(editor_text(&h), "=SUM(B:C");
up(&mut h, c, Modifiers::NONE);
h.input_mut().events.push(Event::Text(")".into()));
h.run_steps(2);
enter(&mut h);
assert_eq!(value(&h, "F2"), Value::Number(66.0));
// A row heading.
pause(&mut h);
start_typing(&mut h, "F4", "=SUM(");
let r3 = header_point(&h, None, Some(3));
down(&mut h, r3, Modifiers::NONE);
up(&mut h, r3, Modifiers::NONE);
assert_eq!(editor_text(&h), "=SUM(3:3");
h.input_mut().events.push(Event::Text(")".into()));
h.run_steps(2);
enter(&mut h);
assert_eq!(value(&h, "F4"), Value::Number(333.0));
// Not in point mode (after a reference was typed), a heading click ends the edit and selects.
pause(&mut h);
start_typing(&mut h, "F5", "=A1");
let c = header_point(&h, Some("C"), None);
down(&mut h, c, Modifiers::NONE);
up(&mut h, c, Modifiers::NONE);
assert_ne!(editor_text(&h), "=A1C:C");
}
/// Pointing at cells while typing a formula still works with presses: the press puts the
/// reference in, a drag widens it, and the keyboard stays with the formula.
#[test]
fn cells_point_into_a_formula_on_the_press() {
let mut s = blank();
s.execute("range.setValues", json!({"range": "B1", "values": [[1], [2], [3]]})).unwrap();
let mut h = harness(s);
start_typing(&mut h, "D1", "=SUM(");
let (b1, b3) = (rect(&h, "B1").center(), rect(&h, "B3").center());
down(&mut h, b1, Modifiers::NONE);
assert_eq!(editor_text(&h), "=SUM(B1");
frame(&mut h, Event::PointerMoved(b1.lerp(b3, 0.5)));
frame(&mut h, Event::PointerMoved(b3));
up(&mut h, b3, Modifiers::NONE);
assert_eq!(editor_text(&h), "=SUM(B1:B3");
h.input_mut().events.push(Event::Text(")".into()));
h.run_steps(2);
enter(&mut h);
assert_eq!(formula(&h, "D1").as_deref(), Some("SUM(B1:B3)"));
assert_eq!(value(&h, "D1"), Value::Number(6.0));
}
-29
View File
@@ -227,32 +227,3 @@ fn hyperlinks_past_the_cell_cap_are_reported() {
assert_eq!(wb.sheet(0).unwrap().hyperlinks.len(), 3);
assert!(rep.warnings.is_empty(), "{:?}", rep.warnings);
}
/// Formula text built to overflow a recursive parser or tree walk (a million `%`, a 200,000-term
/// sum): the cells keep their formula text and cached value, nothing overflows the stack.
#[test]
fn hostile_formula_text_does_not_overflow_the_stack() {
let percents = format!("1{}", "%".repeat(1_000_000));
let chain = format!("1{}", "+1".repeat(100_000));
let body = format!(r#"<sheetData><row r="1"><c r="A1"><f>{percents}</f><v>3</v></c><c r="B1"><f>{chain}</f><v>4</v></c></row></sheetData>"#);
let names = format!(r#"<definedNames><definedName name="Deep">{percents}</definedName></definedNames>"#);
let bytes = minimal(&body, &[], "", &names);
let handle = std::thread::Builder::new()
.stack_size(1024 * 1024)
.spawn(move || {
let (wb, _) = read_xlsx(&bytes).expect("readable");
let s = wb.sheet(0).expect("sheet");
let a1 = s.cell(gridcraft_core::CellRef::new(0, 0)).and_then(|c| c.formula.clone()).expect("formula kept");
let b1 = s.cell(gridcraft_core::CellRef::new(0, 1)).and_then(|c| c.formula.clone()).expect("formula kept");
let kept = (a1.expr.is_none(), b1.expr.is_none(), a1.text.len(), s.value(gridcraft_core::CellRef::new(0, 0)));
assert_eq!(wb.names.first().map(|n| n.formula.len()), Some(1_000_001));
let out = write_xlsx(&wb).expect("writable");
drop(wb);
(kept, !out.is_empty())
})
.expect("spawn");
let ((a_unparsed, b_unparsed, a_len, a_value), written) = handle.join().expect("no stack overflow");
assert!(a_unparsed && b_unparsed && written);
assert_eq!(a_len, 1_000_001);
assert_eq!(a_value, gridcraft_core::Value::Number(3.0));
}
-4
View File
@@ -15,10 +15,6 @@ Then send newline-delimited JSON to `127.0.0.1:7979`. Every request line gets on
Errors come back as `{"id": …, "ok": false, "error": "…"}`.
A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
bytes that aren't UTF-8, a line over 64 MiB) gets one error reply and the server closes the
connection; nothing sent after it runs. Blank lines are skipped.
## Methods
| Method | Params | What it does |
+1 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** trivial (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; previously trivial: XLSX chart sheets are kept on save (#727)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** trivial (XLSX chart sheets are kept on save (#727); previously minor: CSV save keeps the separator it was opened with) · **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**
@@ -85,7 +85,6 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped |
| 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) |
| 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) |
| 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |
+1 -3
View File
@@ -1,6 +1,6 @@
# Function parity: worksheet functions and formulas
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (clearing large sparse ranges recalculates every changed cell's dependents, #1032) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-12 · **Change:** minor (USDOLLAR added as a compatibility function; MIRR, COUNTIF and INDIRECT edge cases match Excel 365) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
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).
@@ -76,7 +76,6 @@ PIVOTBY, REGEXTEST…) are all present.
| Structured references (`Table1[Col]`, `[#Totals]`, `@`; the unbracketed `[[#This Row],Col]` some writers emit) | done |
| Defined names (workbook and sheet scope), Create from Selection | done |
| Dynamic arrays, spill, `#SPILL!`, spill operator `A1#` | done; stale-spill bugs (#133, #139; PRs #148, #157) |
| Recalculation after clearing large sparse ranges | every stored cell changed by Clear is reported to recalculation, without a million-coordinate cutoff; removed formula spills are cleared, and removing empty merges still unblocks spills (#1032) |
| Implicit intersection `@`, legacy CSE array formulas | done; legacy array range fill (PR #156) |
| Trim references `A1:.E10` (trailing), `A1.:E10` (leading), `A1.:.E10` (both), also on whole rows/columns (`A:.A`) | done; evaluated as `TRIMRANGE(range, code, code)`, adjusted on insert/delete; files store `_xlfn.TRIMRANGE(range,code,code)` (Excel's own file spelling unconfirmed in public docs), `_xlfn._TRO_ALL/_TRO_LEADING/_TRO_TRAILING(...)` read too |
| LET, LAMBDA, named LAMBDAs, ISOMITTED, MAP/REDUCE/SCAN/BYROW/BYCOL/MAKEARRAY | done |
@@ -109,7 +108,6 @@ workbooks; results checked in as data, not Excel files) and fixing what it finds
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Clear reports changed stored cells sparsely, including those beyond the first million coordinates; dependent formulas and removed spills update, while clearing empty merges still unblocks spills (#1032) |
| 2026-10-12 | minor | USDOLLAR added as a Compatibility function (not on Microsoft's list, so the counts don't change); MIRR with no positive flow is -1, COUNTIF's bare `"<="` / `">="` skip empty strings, INDIRECT with `a1` = FALSE gives `#REF!` for A1-style text (#738) |
| 2026-10-11 | minor | TEXT / number formats fixed against Excel-cached results: fraction literals and zero fractions, `0` denominators zero padded, `?` pads the thousands separator, booleans use the text section (226 mismatches → 0 in 352 TEXT cells) |
| 2026-10-11 | minor | Iterative calculation: circular references iterate up to Maximum Iterations / Maximum Change when enabled |
+5 -4
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (horizontal scrollbar in the sheet tab bar, #840) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (presses select on button-down, fill-handle double-click, whole-line references in point mode; observed against Windows Excel) · **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
@@ -43,12 +43,12 @@ this logic, with one undo step.
| Behaviour | Status |
|---|---|
| Click/drag/Shift/Ctrl selection, multi-area selection | rectangular drag works in one direction only in some cases (#28) |
| Click/drag/Shift/Ctrl selection, multi-area selection | done; a press selects when the button goes down and a drag extends from it, as in Excel; clicks on checkboxes and links act on release (#900). Rectangular drag works in one direction only in some cases (#28) |
| Selection outline glides to a new selection (Excel: "Provide feedback with animation") | done (10-11): 0.12 s, decelerating, with the fill and fill handle; View ▸ Animations (`view.animations`) turns it off. The timing is an estimate, not yet measured against Excel; the system's reduced-motion setting isn't followed yet |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); right-drag menu missing |
| Fill handle: drag to fill series, double-click to fill down, right-drag menu | fill done (Ctrl+drag swaps copy and series); double-click fills to the bottom of the data region around the source and stops above a filled cell, matching 13 cases observed in Excel (`edit.autoFillToData`, #877); right-drag menu missing |
| Drag-move and drag-copy cells (border drag, Ctrl/Option, Shift to insert) | done (M3) |
| In-cell editing, Enter/Tab movement, Alt/Option+Enter line breaks | done |
| Point mode (click/drag/arrow references), reference colouring, F4 | done |
| Point mode (click/drag/arrow references), reference colouring, F4 | done; headings point whole columns and rows (`B:D`, `3:3`), which are coloured and outlined, and F4 toggles them `$B:$D` ⇄ `B:D` as in Excel (#872, #873, #875) |
| AutoComplete in columns, function autocomplete and argument tips | done; cell helpers clip to the worksheet and hide when the editor scrolls out (#200) |
| Header resize and double-click AutoFit | done (#42, #88 for header dividers) |
| Outline symbols for grouped rows / columns (band beside the headers, brackets, − / +, level buttons 1…n+1) | done (#373): clicks run `data.hideDetail` / `data.showDetail` / `data.showLevels`, frozen panes and right-to-left sheets included; summary rows/columns are always below/right (the "Summary rows below detail" setting isn't modelled), no "Show outline symbols" option yet, and a block of cells still groups rows without the Rows / Columns dialog |
@@ -161,6 +161,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Presses select on button-down (#900); fill-handle double-click fills to the adjacent data (#877); headings point `A:A`/`2:2` into formulas, coloured, outlined and toggled by F4 (#872, #873, #875) |
| 2026-10-12 | minor | The File view's recent list tells when each workbook was last changed, read off the UI thread |
| 2026-10-13 | minor | Horizontal scrollbar in the sheet tab bar: drag, track click pages, frozen columns excluded from the page (#840) |
| 2026-10-12 | minor | Ctrl+wheel zoom steps along a ladder of percentages about 20% apart that never skips 100%, gliding to each step with animations on, so a notch out undoes a notch in (it drifted: 100 → 122 → 101); Excel steps 15 points, see decisions.md |