Compare commits

...
34 Commits
Author SHA1 Message Date
FredApps dea064b857 Merge pull request #3547 from FredApps/fix/chart-table-series-index-1150
fix(chart): pass series index to data-table keys
2026-11-07 17:47:58 +01:00
FredApps 6b19e445f4 fix(chart): pass series index to data-table keys (#1150) 2026-11-07 17:27:25 +01:00
FredApps 505cae34c8 Merge pull request #1150 from vangel76/fix/chart-combo-logic-cleanup
Charts: one combo plot-kind function, no dead scale helpers (#1146)
2026-11-07 16:52:46 +01:00
FredApps ee1b5df3db Merge pull request #2693 from vangel76/perf/calc-expr-arc-2685
calc: rebuild the dependency graph from shared syntax trees
2026-11-07 16:51:36 +01:00
FredApps cbd765332c Merge pull request #2686 from vangel76/perf/stat-select-2679
perf: select instead of sort in MEDIAN, PERCENTILE, QUARTILE, LARGE, SMALL
2026-11-07 16:51:02 +01:00
FredApps b9e5e0ff89 Merge pull request #3309 from vangel76/fix-autosum-gap-3291
fix(autosum): stop the guessed range at a blank cell
2026-11-07 16:50:28 +01:00
FredApps 66bb6ae239 Merge pull request #3331 from vangel76/contributors-vangel76-names
contributors: set names for vangel76
2026-11-07 16:49:24 +01:00
FredApps 4fc2c9c3b3 Merge pull request #3524 from helenkwok/web/page-api
gridcraft-web: window.gridcraft, the page command API from craft-web-api
2026-11-07 15:38:37 +01:00
FredApps 546a546943 Merge pull request #3508 from ZIDAN44/fix/3484-ctrl-shift-arrow-active-line
Selection: Ctrl+Shift+Arrow searches the active cell's line (#3484)
2026-11-07 15:38:30 +01:00
FredApps 8774fa45fb Merge pull request #3507 from ZIDAN44/fix/3485-scroll-per-window
View: a sheet keeps its own scroll position when sheets move (#3485)
2026-11-07 15:38:25 +01:00
FredApps 8325dbe50e Merge pull request #3506 from ZIDAN44/fix/3446-sort-dialog-column-label
Sort: the closed Sort by box names the column, as its list does (#3446)
2026-11-07 15:38:20 +01:00
Helen Kwok fb17d0d1d5 gridcraft-web: say a call the app already took still finishes after a timeout 2026-11-07 10:28:03 +08:00
Helen Kwok 89ec06f041 gridcraft-web: window.gridcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. A call the page gave up on is released, so the app skips it instead of running it late. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:03:07 +08:00
Zinadin ZidanandOlivier 20473a4fae Selection: Ctrl+Shift+Arrow searches the active cell's line (#3484)
Ctrl+Shift+Arrow searched from the selection's moving corner, so after
Ctrl+Shift+Right from A2 the next Ctrl+Shift+Down searched the far column
and ran to the last row. As in Excel, it now searches the active cell's
column (Up/Down) or row (Left/Right) from the edge being moved, and moves
only that edge. A diagonal move from command params keeps the old jump.

Co-Authored-By: Olivier <5681030+tutis@users.noreply.github.com>
2026-11-07 00:27:08 +06:00
Zinadin ZidanandOlivier 1688b43ae2 View: a sheet keeps its own scroll position when sheets move (#3485)
Scroll positions were stored by (window, sheet index), and nothing re-keyed
them when sheets were inserted, deleted or moved. A new PivotTable sheet,
which goes in front of its source, opened at the source's position, and the
source took over whatever was stored at its new index. Positions are now
keyed by (window, sheet uid), so they follow the sheet through inserts,
deletes, moves and undo. A closed window's positions are dropped. Saving the
active cell still happens when no window is active.

Co-Authored-By: Olivier <5681030+tutis@users.noreply.github.com>
2026-11-07 00:27:07 +06:00
Zinadin Zidan 7d3ede854f Sort: the closed Sort by box names the column, as its list does (#3446)
The Sort dialog's closed "Sort by" box showed only the stored column
letter ("B") while its open list showed "B: Revenue". It now looks the
letter up in the dialog's column labels and shows the same label, falling
back to the bare letter for a column outside the data block.
2026-11-07 00:27:07 +06:00
FredApps 6a8eb4ff42 Merge pull request #3487 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:10 +01:00
vangel76andClaude Sonnet 5.5 b3a709f314 contributors: set names for vangel76
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 02:12:09 +01:00
vangel76andClaude Sonnet 5.5 1ff10df557 fix(autosum): stop the guessed range at a blank cell (#3291)
The single-cell AutoSum guess stepped over one blank cell when a number sat
above it, so a second block of numbers below a gap was summed together with
the first block. It now stops at the first non-number, as Excel does.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 20:56:00 +01:00
vangel76andClaude Sonnet 5.5 21504859c8 Merge upstream/main into perf/calc-expr-arc-2685
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:36:41 +01:00
vangel76 bc7e37fa7d Merge upstream/main into fix/chart-combo-logic-cleanup 2026-10-29 23:03:18 +01:00
vangel76andClaude Sonnet 5.5 8b330ac9e2 Tighten selection comment and test index arithmetic
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:52:59 +01:00
vangel76andClaude Sonnet 5.5 b6bbdcd5cf calc: build precedents from a borrowed syntax tree, no Arc for text-only formulas
Formula::with_expr runs a closure on the cached tree, or on a freshly parsed
one that is dropped afterwards, so rebuild and cells_changed need neither a
tree copy nor an Arc allocation / reference count.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:51:45 +01:00
vangel76andClaude Sonnet 5.5 4523b817ce Read already-sorted input directly instead of selecting
A full sort recognises ascending and descending runs in O(n), which a
selection cannot beat, so sorted, reverse-sorted and tie-block input was
up to 1.8x slower than before. Check for a monotone run first (one pass
that stops at the first out-of-order pair) and index into it. Extend the
bit-for-bit oracle test with ascending, descending and nearly ascending
data.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 00:51:12 +01:00
vangel76 d71cf10e70 Merge remote-tracking branch 'upstream/main' into perf/calc-expr-arc-2685 2026-10-29 00:48:45 +01:00
vangel76 b7c5a69dbd Merge remote-tracking branch 'upstream/main' into perf/stat-select-2679 2026-10-29 00:47:31 +01:00
vangel76andClaude Sonnet 5.5 a434efabf0 Merge upstream/main into perf/stat-select-2679
stat.rs was split into stat/; re-applied the selection changes in stat/descriptive.rs and moved the oracle test to stat/tests.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 00:53:37 +01:00
vangel76andClaude Sonnet 5.5 2fe8a33c5d Merge upstream/main into fix/chart-combo-logic-cleanup
render.rs was split into render/ modules on main; the plot-kind and fixed-scale cleanups are re-applied in the new modules.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:52:57 +01:00
vangel76andClaude Sonnet 5.5 c567511cf1 calc: rebuild the dependency graph from shared syntax trees
Calc::rebuild and Calc::cells_changed called Formula::expr(), which deep-copies each formula's
syntax tree only to read its references. Use expr_arc() (already used when formulas are
calculated), which hands out the cached Arc without copying.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:21:15 +01:00
vangel76andClaude Sonnet 5.5 890870cd57 perf: select instead of sort in MEDIAN, PERCENTILE, QUARTILE, LARGE, SMALL
Read the needed order statistics with select_nth_unstable_by(total_cmp)
instead of a full sort. Results are bit-identical; a test compares them
against the previous full-sort code on randomised data.

Fixes #2679

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 22:11:08 +01:00
vangel76andClaude Sonnet 5.5 ee13457b2f Merge upstream/main into fix/chart-combo-logic-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:55:05 +01:00
vangel76andClaude Sonnet 5.5 8f34c42e88 Merge upstream/main into fix/chart-combo-logic-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:22:03 +01:00
vangel76 7f8ac83dc3 Merge remote-tracking branch 'upstream/main' into fix/chart-combo-logic-cleanup
# Conflicts:
#	ROADMAP.md
#	crates/chart/src/tests.rs
2026-10-22 19:34:40 +02:00
vangel76andClaude Sonnet 5.5 5cf0e24f43 Charts: one combo plot-kind rule, no fixed_scale helper, ticks off the step grid (#1146)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 21:12:43 +02:00
33 changed files with 706 additions and 60 deletions
Generated
+13 -1
View File
@@ -995,10 +995,21 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -2195,6 +2206,7 @@ dependencies = [
name = "gridcraft-web"
version = "0.16.0"
dependencies = [
"craft-web-api",
"craft-web-host",
"eframe",
"egui",
+3
View File
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) |
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
@@ -196,6 +197,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
@@ -259,6 +261,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) |
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
+3 -1
View File
@@ -17,7 +17,9 @@ path = "src/main.rs"
gridcraft-engine = { workspace = true }
gridcraft-ui-egui = { workspace = true }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
+151
View File
@@ -0,0 +1,151 @@
//! `window.gridcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is GridCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
//! applying a command the page was told had failed.
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::sync::mpsc::{Receiver, TryRecvError};
use gridcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
#[cfg(target_arch = "wasm32")]
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
struct Pending {
reply: Receiver<Value>,
state: Arc<AtomicU8>,
answered: bool,
}
impl Pending {
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "the app dropped the request"}))
}
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if !self.answered {
ControlRequest::abandon(&self.state);
}
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::channel;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use gridcraft_ui_egui::{ControlRequest, SheetApp};
use serde_json::json;
use wasm_bindgen_futures::JsFuture;
use super::{POLL_MS, Pending};
/// The app id in `window.<app>`.
const APP: &str = "gridcraft";
/// Give the app its control queue and put `window.gridcraft` on the page. An existing
/// `window.gridcraft` keeps the functions it already has.
pub fn install(app: &mut SheetApp, ctx: egui::Context) {
let (queue, requests) = channel::<ControlRequest>();
app.control_rx = Some(requests);
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
if queue.send(control).is_err() {
pending.answered = true;
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(async move {
loop {
if let Some(reply) = pending.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// `ControlRequest`'s `state` values (`gridcraft_ui_egui::control`): the app has started it, or its
/// sender gave up.
const TAKEN: u8 = 1;
const ABANDONED: u8 = 2;
fn pending() -> (Pending, ControlRequest) {
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
(Pending { reply, state: control.state.clone(), answered: false }, control)
}
#[test]
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
let (waiting, control) = pending();
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
assert!(!control.take(), "the app must not run a request the page was told had failed");
}
#[test]
fn an_answered_call_is_not_released() {
let (mut waiting, control) = pending();
assert!(control.take());
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
}
#[test]
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
let (mut waiting, control) = pending();
assert_eq!(waiting.poll(), None);
drop(control);
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
}
+5
View File
@@ -13,11 +13,16 @@
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`,
//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods
//! (`api`).
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
//! editing options…) are kept in `localStorage` (`prefs`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(any(target_arch = "wasm32", test))]
+1
View File
@@ -99,6 +99,7 @@ pub fn start() {
}
bridge.post_ready();
}
crate::api::install(&mut app, cc.egui_ctx.clone());
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
}),
)
+2 -1
View File
@@ -33,7 +33,8 @@ sync_github_name = false
[people.vangel76]
real_name = "Valentin Klein"
sync_github_name = true
display_name = "vangel76"
sync_github_name = false
[people.MacJedi42]
sync_github_name = true
+5 -6
View File
@@ -128,9 +128,8 @@ impl Calc {
for (si, sheet) in wb.sheets.iter().enumerate() {
for (c, cell) in sheet.cells.iter() {
if let Some(f) = &cell.formula
&& let Some(e) = f.expr()
&& let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e))
{
let (areas, dynamic) = precedents(wb, si, &e);
self.graph.insert((si, c), Node { areas, dynamic });
if cell.value == Value::Error(CellError::Circ) {
circ.push((si, c));
@@ -209,14 +208,14 @@ impl Calc {
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
for &k in changed {
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
let precedent =
wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e)));
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
let python = py.is_some();
// A Python cell whose result (if any) is not an array spills nothing.
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
match formula.and_then(|f| f.expr()) {
Some(e) => {
let (areas, dynamic) = precedents(wb, k.0, &e);
match precedent {
Some((areas, dynamic)) => {
self.graph.insert(k, Node { areas, dynamic });
}
// See `rebuild`: its spill is laid out again from its result when it is calculated.
+39
View File
@@ -2379,6 +2379,45 @@ fn loan_schedule_residue_keeps_excels_sign() {
assert_eq!(t.get("P7"), Value::Text("".into()));
}
/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the
/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that
/// carry their tree and for formulas that only carry their text (as after loading a file).
#[test]
fn graph_built_from_text_only_formulas_matches_cached() {
fn run(cached: bool) -> Vec<Value> {
let mut t = T::new();
t.set("A1", "2");
for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] {
// E1 does not parse, so it has no tree either way and only covers the `None` branch.
let mut formula = Formula::new(f);
if !cached {
formula.expr = None;
}
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula));
}
t.calc.rebuild(&t.wb);
t.calc.recalc_all(&mut t.wb);
let mut out: Vec<Value> = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect();
// Editing the input must reach the dependents through the rebuilt graph.
t.set("A1", "5");
out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a)));
// A text-only formula that is rewritten through `cells_changed` joins the graph too.
let mut formula = Formula::new("=A1+100");
if !cached {
formula.expr = None;
}
t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula));
t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]);
t.set("A1", "7");
out.push(t.get("F1"));
out
}
let cached = run(true);
assert_eq!(cached, run(false));
assert_eq!(cached[4], Value::Number(50.0));
assert_eq!(cached[7], Value::Number(107.0));
}
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
+4 -7
View File
@@ -25,9 +25,11 @@ impl Scale {
(0..=n)
.map(|i| {
let v = self.min + i as f64 * self.step;
// Snap away accumulated float error.
// Snap away accumulated float error, but only onto a multiple of the step that is
// really that close: a `min` off the step grid must keep its own ticks.
let snapped = (v / self.step).round() * self.step;
if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped }
let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v };
if v.abs() < self.step * 1e-9 { 0.0 } else { v }
})
.collect()
}
@@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale {
Scale { min, max, step }
}
/// Exact fixed scale (100% stacked axes).
pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale {
Scale { min, max, step }
}
/// A number format prepared once and reused for many labels.
pub(crate) struct Fmt {
general: bool,
+1 -1
View File
@@ -4,7 +4,7 @@ use std::f32::consts::{PI, TAU};
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
use crate::axis::{Fmt, Scale, clean, nice_scale};
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
use crate::trend::{auto_name, fit_trendline, moving_average};
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
+3 -3
View File
@@ -4,7 +4,7 @@ use super::lines::{decimate_area, draw_markers, draw_runs};
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
use super::{
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
VAlign, WHITE, clean, fixed_scale, nice_scale,
VAlign, WHITE, clean, nice_scale,
};
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
@@ -145,7 +145,7 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
}));
let (lo, hi) = range.unwrap_or((0.0, 0.0));
let (scale, fmt) = if all100 {
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
(Scale { min: if lo < -1e-9 { -1.0 } else { 0.0 }, max: if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, step: 0.2 }, Fmt::new("0%"))
} else {
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
(nice_scale(lo, hi, force_zero), Fmt::new(code))
@@ -187,7 +187,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
if ncat == 0 {
return;
}
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
let kinds: Vec<ChartKind> = data.series.iter().enumerate().map(|(i, s)| if horizontal { chart.kind } else { plot_kind(chart, s, i) }).collect();
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
let any_primary = data.series.iter().any(|s| !s.secondary);
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
+8 -6
View File
@@ -15,9 +15,10 @@ pub enum Swatch {
Marker,
}
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule
/// as `series_plot_kind`, applied to the already resolved series kind.
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind {
crate::resolve::plot_kind(chart.kind, Some(s.kind), i)
}
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
@@ -60,8 +61,9 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
let series = data.series.iter().take(MAX_SERIES);
let mut out: Vec<(String, Rgba, Swatch)> = series
.clone()
.map(|s| {
let sw = match plot_kind(chart, s) {
.enumerate()
.map(|(i, s)| {
let sw = match plot_kind(chart, s, i) {
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
@@ -71,7 +73,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
})
.collect();
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) {
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
}
+1 -1
View File
@@ -70,7 +70,7 @@ pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, lay
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
if settings.show_keys {
let swatch = match plot_kind(chart, series) {
let swatch = match plot_kind(chart, series, i) {
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
ChartKind::LineMarkers => Swatch::LineMarker,
_ => Swatch::Box,
+8 -2
View File
@@ -201,7 +201,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
t.to_string()
}
fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
match (chart_kind, series_kind) {
(ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k,
(ChartKind::Combo, _) => {
@@ -216,9 +216,15 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usiz
}
}
/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the
/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing.
pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind }
}
/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind }
plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i)
}
/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
+32
View File
@@ -1418,6 +1418,38 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
}
}
#[test]
fn drawing_and_resolving_agree_on_the_combo_plot_kind() {
// Resolved series carry `effective_kind`; drawing re-derives the kind from it. Both paths
// must give the same kind for every chart kind, series kind (or none) and series index.
let series_kinds = std::iter::once(None).chain(ALL_KINDS.iter().map(|k| Some(*k)));
for ck in ALL_KINDS {
for sk in series_kinds.clone() {
for i in 0..3 {
let mut c = chart(ck);
c.series = (0..3).map(|j| Series { kind: if j == i { sk } else { None }, ..Default::default() }).collect();
let from_chart = series_plot_kind(&c, i);
let resolved = crate::resolve::effective_kind(ck, sk, i);
assert_eq!(crate::resolve::plot_kind(ck, Some(resolved), i), from_chart, "{ck:?} {sk:?} {i}");
if ck != ChartKind::Combo {
assert_eq!(from_chart, ck);
} else {
assert_ne!(from_chart, ChartKind::Combo);
}
}
}
}
}
#[test]
fn ticks_of_a_scale_off_the_step_grid_start_at_its_min() {
let s = Scale { min: 3.0, max: 7.0, step: 4.0 };
assert_eq!(s.ticks(), vec![3.0, 7.0]);
// Aligned scales still snap float error away.
let s = Scale { min: 0.0, max: 1.0, step: 0.1 };
assert!((s.ticks()[3] - 0.3).abs() < 1e-12 && s.ticks()[0] == 0.0);
}
#[test]
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
let d = ChartData {
+29
View File
@@ -1,6 +1,35 @@
use super::*;
use gridcraft_model::ChartDataTable;
#[test]
fn combo_table_keys_use_each_series_index() {
let mut c = chart(ChartKind::Combo);
c.legend = LegendPos::None;
c.data_labels = false;
c.data_table = Some(ChartDataTable::default());
let mut d = sample(c.kind);
// The public renderer also accepts Combo series: the first defaults to columns, later ones to lines.
for s in &mut d.series {
s.kind = ChartKind::Combo;
}
let p = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
assert_sane(&p, 900.0, 500.0);
let header_y = p
.iter()
.find_map(|p| match p {
Prim::Text { text, y, .. } if text == "Q1" => Some(*y),
_ => None,
})
.unwrap();
for (i, s) in d.series.iter().enumerate() {
let boxes = p.iter().filter(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == s.color && *y > header_y)).count();
let lines =
p.iter().filter(|p| matches!(p, Prim::Line { pts, color, .. } if *color == s.color && pts.iter().all(|p| p[1] > header_y))).count();
assert_eq!(boxes, usize::from(i == 0), "series {i}: column key");
assert_eq!(lines, usize::from(i > 0), "series {i}: line key");
}
}
#[test]
fn tables_draw_source_values_for_every_supported_chart_kind() {
for kind in ALL_KINDS {
+8 -1
View File
@@ -66,7 +66,14 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
// Extension moves the far corner, not the active cell.
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
let mut to = if jmp {
let mut to = if jmp && extend && (dr == 0) != (dc == 0) {
// Excel: Ctrl+Shift+arrow searches the active cell's column (or row) from the moving
// edge, so a header row selected with Ctrl+Shift+Right extends down to the end of the
// active column's data.
let probe = if dr != 0 { CellRef::new(from.row, sel.active.col) } else { CellRef::new(sel.active.row, from.col) };
let edge = jump(sheet, probe, dr.signum(), dc.signum());
if dr != 0 { CellRef::new(edge.row, from.col) } else { CellRef::new(from.row, edge.col) }
} else if jmp {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
+1 -4
View File
@@ -179,10 +179,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
let mut range = None;
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
let mut top = at.row - 1;
while top > 0
&& (numeric(CellRef::new(top - 1, at.col))
|| sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col)))
{
while top > 0 && numeric(CellRef::new(top - 1, at.col)) {
top -= 1;
}
if numeric(CellRef::new(at.row - 1, at.col)) {
+13
View File
@@ -2067,6 +2067,19 @@ fn formula_at(s: &Session, a: &str) -> Option<String> {
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
}
#[test]
fn autosum_of_a_single_cell_stops_at_a_blank_cell() {
// #3291: a blank row between two blocks of numbers ended the guess one block too late, so the
// second total summed the first block (and its total) as well.
let mut s = s();
s.execute("range.setValues", json!({"range": "B4", "values": [[1], [2], [3]]})).unwrap();
s.execute("cell.set", json!({"cell": "B7", "input": "=SUM(B4:B6)"})).unwrap();
s.execute("range.setValues", json!({"range": "B9", "values": [[10], [20]]})).unwrap();
s.execute("selection.set", json!({"cell": "B11"})).unwrap();
s.execute("formulas.autoSum", json!({})).unwrap();
assert_eq!(v(&s, "B11"), Value::Number(30.0));
}
#[test]
fn autosum_over_a_row_totals_it_to_the_right() {
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
@@ -0,0 +1,56 @@
//! Ctrl/Cmd+Shift+arrow searches the active cell's column (or row) from the moving edge (#3484).
use gridcraft_engine::Session;
use serde_json::json;
fn table() -> Session {
// Headers in A2:K2, a key column in A3:A13, nothing else filled.
let mut s = Session::new();
s.new_workbook();
let header: Vec<i64> = (1..=11).collect();
s.execute("range.setValues", json!({"range": "A2:K2", "values": [header]})).unwrap();
let keys: Vec<Vec<i64>> = (1..=11).map(|i| vec![i]).collect();
s.execute("range.setValues", json!({"range": "A3:A13", "values": keys})).unwrap();
s
}
fn ctrl_shift(s: &mut Session, dr: i64, dc: i64) -> String {
s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true, "jump": true})).unwrap();
s.doc().unwrap().selection.a1()
}
#[test]
fn ctrl_shift_down_after_ctrl_shift_right_follows_the_active_column() {
let mut s = table();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K2");
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K13", "column A's data ends on row 13; column K's ends on row 2");
// From the end of column A's data the next press runs to the sheet's last row.
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
}
#[test]
fn ctrl_shift_right_after_ctrl_shift_down_follows_the_active_row() {
let mut s = table();
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:A13");
assert_eq!(ctrl_shift(&mut s, 0, 1), "A2:K13", "row 2's data ends in column K; row 13's ends in column A");
}
#[test]
fn ctrl_shift_down_after_ctrl_shift_left_follows_the_active_column() {
// The active cell is on the right: column K, empty below the header, decides, not column A.
let mut s = table();
s.execute("selection.set", json!({"cell": "K2"})).unwrap();
assert_eq!(ctrl_shift(&mut s, 0, -1), "A2:K2");
assert_eq!(ctrl_shift(&mut s, 1, 0), "A2:K1048576");
}
#[test]
fn ctrl_shift_right_after_ctrl_shift_up_follows_the_active_row() {
// The active cell is at the bottom: row 13, empty right of the key, decides, not row 2.
let mut s = table();
s.execute("selection.set", json!({"cell": "A13"})).unwrap();
assert_eq!(ctrl_shift(&mut s, -1, 0), "A2:A13");
assert_eq!(ctrl_shift(&mut s, 0, 1), "2:13", "row 13's data ends in column A, so the rows extend to the last column");
}
+1
View File
@@ -7,6 +7,7 @@ mod chart_workbook_names;
mod command_sequence_fuzz;
mod cross_sheet_cut;
mod csv_export_budget;
mod ctrl_shift_arrow_active_line;
mod custom_filter_blanks;
mod custom_view_freeze;
mod dynamicmetadata;
+58 -18
View File
@@ -33,19 +33,40 @@ pub(super) fn var_p(x: &[f64]) -> R<f64> {
Ok(devsq(x) / x.len() as f64)
}
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
pub(super) fn sorted(mut v: Vec<f64>) -> Vec<f64> {
v.sort_by(f64::total_cmp);
v
}
/// Sorts `v` if it is already monotone in `total_cmp` order (a descending slice is reversed) and
/// says whether it did. At most two passes, each stopping at the first out-of-order pair, so
/// unordered data costs next to nothing. Needed because the std sort recognises ascending and
/// descending runs in O(n) with a small constant; selection is O(n) too but measured 1.2-1.8x
/// slower on such input.
fn sorted_if_monotone(v: &mut [f64]) -> bool {
if v.is_sorted_by(|a, b| a.total_cmp(b).is_le()) {
return true;
}
if v.is_sorted_by(|a, b| a.total_cmp(b).is_ge()) {
v.reverse();
return true;
}
false
}
pub(super) fn median(v: Vec<f64>) -> R<f64> {
if v.is_empty() {
return Err(CellError::Num);
}
let s = sorted(v);
let n = s.len();
let hi = s.get(n / 2).copied().unwrap_or(0.0);
if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
let mut v = v;
let n = v.len();
if sorted_if_monotone(&mut v) {
let hi = v.get(n / 2).copied().unwrap_or(0.0);
return if n % 2 == 1 { Ok(hi) } else { Ok(v.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) };
}
let (lower, hi, _) = v.select_nth_unstable_by(n / 2, f64::total_cmp);
let hi = *hi;
if n % 2 == 1 { Ok(hi) } else { Ok(lower.iter().copied().max_by(f64::total_cmp).unwrap_or(0.0).midpoint(hi)) }
}
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
@@ -126,34 +147,54 @@ pub(super) fn max_of(v: &[f64]) -> f64 {
}
/// k-th largest (k is rounded up like Excel).
fn kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
pub(super) fn kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
let k = k.ceil();
if k.is_nan() || k < 1.0 || k > v.len() as f64 {
return Err(CellError::Num);
}
let s = sorted(v);
let mut v = v;
let k = k as usize;
let idx = if largest { s.len() - k } else { k - 1 };
s.get(idx).copied().ok_or(CellError::Num)
let idx = if largest { v.len() - k } else { k - 1 };
// Selection instead of a full sort (monotone input is read directly); same total order, so the
// same bit pattern as `sorted(v)[idx]`.
if sorted_if_monotone(&mut v) {
return v.get(idx).copied().ok_or(CellError::Num);
}
Ok(*v.select_nth_unstable_by(idx, f64::total_cmp).1)
}
fn interp(s: &[f64], pos: f64) -> R<f64> {
/// Value at fractional rank `pos` of the sorted data, linearly interpolated, found by selection.
fn interp(mut v: Vec<f64>, pos: f64) -> R<f64> {
let i = pos.floor();
let frac = pos - i;
let i = i as usize;
let lo = *s.get(i).ok_or(CellError::Num)?;
match s.get(i + 1) {
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
_ => Ok(lo),
if i >= v.len() {
return Err(CellError::Num);
}
if sorted_if_monotone(&mut v) {
let lo = v.get(i).copied().ok_or(CellError::Num)?;
return match v.get(i + 1) {
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
_ => Ok(lo),
};
}
let (_, lo, upper) = v.select_nth_unstable_by(i, f64::total_cmp);
let lo = *lo;
// The next order statistic is the minimum of the upper partition.
if frac > 0.0
&& let Some(hi) = upper.iter().copied().min_by(f64::total_cmp)
{
return Ok(lo + frac * (hi - lo));
}
Ok(lo)
}
pub(super) fn percentile_inc(v: Vec<f64>, k: f64) -> R<f64> {
if v.is_empty() || !(0.0..=1.0).contains(&k) {
return Err(CellError::Num);
}
let s = sorted(v);
interp(&s, k * (s.len() - 1) as f64)
let pos = k * (v.len() - 1) as f64;
interp(v, pos)
}
pub(super) fn percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
@@ -165,8 +206,7 @@ pub(super) fn percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
if pos < 1.0 || pos > n {
return Err(CellError::Num);
}
let s = sorted(v);
interp(&s, pos - 1.0)
interp(v, pos - 1.0)
}
pub(super) fn quartile_inc(v: Vec<f64>, q: f64) -> R<f64> {
+122
View File
@@ -1,4 +1,6 @@
use super::descriptive::{kth, median, percentile_exc, percentile_inc, sorted};
use super::{MAX_REGRESSORS, aggregate_values, aggregate_values_k, check_regression_size};
use crate::util::R;
use crate::util::testutil::*;
use gridcraft_core::{CellError, Value};
@@ -200,6 +202,126 @@ fn min_max_order() {
close(ev("RANK.AVG", vec![n(3.5), r2, n(1.0)]), 3.5);
}
// Reference implementations: the previous full-sort versions, as the oracle for the selection ones.
fn old_interp(s: &[f64], pos: f64) -> R<f64> {
let i = pos.floor();
let frac = pos - i;
let i = i as usize;
let lo = *s.get(i).ok_or(CellError::Num)?;
match s.get(i + 1) {
Some(hi) if frac > 0.0 => Ok(lo + frac * (hi - lo)),
_ => Ok(lo),
}
}
fn old_median(v: Vec<f64>) -> R<f64> {
if v.is_empty() {
return Err(CellError::Num);
}
let s = sorted(v);
let n = s.len();
let hi = s.get(n / 2).copied().unwrap_or(0.0);
if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
}
fn old_kth(v: Vec<f64>, k: f64, largest: bool) -> R<f64> {
let k = k.ceil();
if k.is_nan() || k < 1.0 || k > v.len() as f64 {
return Err(CellError::Num);
}
let s = sorted(v);
let k = k as usize;
let idx = if largest { s.len() - k } else { k - 1 };
s.get(idx).copied().ok_or(CellError::Num)
}
fn old_percentile_inc(v: Vec<f64>, k: f64) -> R<f64> {
if v.is_empty() || !(0.0..=1.0).contains(&k) {
return Err(CellError::Num);
}
let s = sorted(v);
old_interp(&s, k * (s.len() - 1) as f64)
}
fn old_percentile_exc(v: Vec<f64>, k: f64) -> R<f64> {
let n = v.len() as f64;
if v.is_empty() || !(k > 0.0 && k < 1.0) {
return Err(CellError::Num);
}
let pos = k * (n + 1.0);
if pos < 1.0 || pos > n {
return Err(CellError::Num);
}
let s = sorted(v);
old_interp(&s, pos - 1.0)
}
/// Same Ok bit pattern or same error.
fn same(a: R<f64>, b: R<f64>) -> bool {
match (a, b) {
(Ok(x), Ok(y)) => x.to_bits() == y.to_bits(),
(Err(x), Err(y)) => x == y,
_ => false,
}
}
#[test]
fn selection_matches_full_sort_bit_for_bit() {
let mut state = 0x9E37_79B9_7F4A_7C15_u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let specials = [0.0, -0.0, f64::INFINITY, f64::NEG_INFINITY, f64::NAN, -f64::NAN, f64::MAX, f64::MIN_POSITIVE, 1.0, -1.0];
for len in (0..=40).chain([63, 64, 65, 100, 257, 1000, 4096]) {
for round in 0..24 {
let mut v: Vec<f64> = (0..len)
.map(|_| match (round % 4, next() % 8) {
// Few distinct values: many ties.
(0, r) => (r % 3) as f64,
// Specials, including both zeros and NaNs.
(1, r) => specials[(next() as usize).wrapping_add(r as usize) % specials.len()],
// Wide random range.
(2, _) => (next() as i64 as f64) / 1e3,
// Integers with some specials mixed in.
(_, 0) => specials[next() as usize % specials.len()],
_ => (next() % 50) as f64,
})
.collect();
// Rounds 12..24 repeat the data as already ascending, already descending and nearly
// ascending (ends swapped): the monotone shortcut and its fallback to selection.
match round / 4 {
3 => v.sort_by(f64::total_cmp),
4 => {
v.sort_by(f64::total_cmp);
v.reverse();
}
5 => {
v.sort_by(f64::total_cmp);
if let Some(last) = v.len().checked_sub(1) {
v.swap(0, last);
}
}
_ => {}
}
assert!(same(median(v.clone()), old_median(v.clone())), "median len={len} round={round}");
let ks = [-1.0, 0.0, 0.2, 1.0, 1.5, 2.0, len as f64 / 2.0, len as f64 - 0.5, len as f64, len as f64 + 1.0, f64::NAN];
for &k in &ks {
for largest in [true, false] {
assert!(same(kth(v.clone(), k, largest), old_kth(v.clone(), k, largest)), "kth len={len} k={k}");
}
}
let ps = [-0.1, 0.0, 0.01, 0.1, 0.25, 0.3, 0.5, 0.75, 0.9, 0.99, 1.0, 1.1, f64::NAN, (next() % 1001) as f64 / 1000.0];
for &p in &ps {
assert!(same(percentile_inc(v.clone(), p), old_percentile_inc(v.clone(), p)), "pinc len={len} p={p}");
assert!(same(percentile_exc(v.clone(), p), old_percentile_exc(v.clone(), p)), "pexc len={len} p={p}");
}
}
}
}
#[test]
fn percentiles() {
close(ev("PERCENTILE.INC", vec![c(&[1.0, 3.0, 2.0, 4.0]), n(0.3)]), 1.9);
+10
View File
@@ -47,6 +47,16 @@ impl Formula {
pub fn expr_arc(&self) -> Option<Arc<Expr>> {
self.expr.clone().or_else(|| gridcraft_formula::parse(&self.text).ok().map(Arc::new))
}
/// Runs `f` on the parsed expression, borrowing the cached tree or, after deserialization,
/// a freshly parsed one that is dropped afterwards. Unlike [`expr`](Self::expr) it never copies
/// the tree, and unlike [`expr_arc`](Self::expr_arc) it needs no reference count or `Arc`
/// allocation. `None` when the text does not parse.
pub fn with_expr<R>(&self, f: impl FnOnce(&Expr) -> R) -> Option<R> {
match &self.expr {
Some(e) => Some(f(e)),
None => gridcraft_formula::parse(&self.text).ok().map(|e| f(&e)),
}
}
}
/// A static picture stored as cell content, independent of floating drawing anchors.
+1
View File
@@ -302,6 +302,7 @@ impl SheetApp {
// The requests belong to the window the command left active.
self.follow_active_window();
self.prune_selected_object();
self.prune_views();
for req in self.session.take_ui_requests() {
match req {
UiRequest::Dialog(name, params) => self.open_dialog(&name, params),
+12 -4
View File
@@ -7,8 +7,8 @@ impl SheetApp {
}
/// The active window and sheet: each window scrolls on its own.
fn scroll_key(&self) -> Option<(u64, usize)> {
Some((self.session.active_window()?, self.session.active()?.wb.active_sheet()))
fn scroll_key(&self) -> Option<(u64, u64)> {
Some((self.session.active_window()?, self.session.active()?.wb.active()?.uid.get()))
}
pub fn view(&self) -> SheetView {
@@ -41,8 +41,10 @@ impl SheetApp {
let window = self.session.active_window();
let Some(d) = self.session.active_mut() else { return };
let mut tops = Vec::new();
for (&(w, si), v) in &self.views {
let Some(sh) = d.wb.sheets.get(si).filter(|sh| Some(w) == window && sh.view == gridcraft_engine::model::SheetViewMode::Normal) else {
for (si, sh) in d.wb.sheets.iter().enumerate() {
let Some(v) =
window.and_then(|w| self.views.get(&(w, sh.uid.get()))).filter(|_| sh.view == gridcraft_engine::model::SheetViewMode::Normal)
else {
continue;
};
let (fr, fc) = sh.freeze.unwrap_or((0, 0));
@@ -53,4 +55,10 @@ impl SheetApp {
}
d.store_view(&tops);
}
/// Drops the scroll positions of windows that have closed: window ids are never reused.
pub(super) fn prune_views(&mut self) {
let session = &self.session;
self.views.retain(|&(w, _), _| session.window(w).is_some());
}
}
+4 -1
View File
@@ -218,8 +218,11 @@ pub(super) fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confir
if columns.is_empty() {
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id);
} else {
// The closed box shows the same "B: Revenue" label as the list, not the bare letter (#3446).
let shown =
columns.iter().find(|(letter, _)| letter.eq_ignore_ascii_case(&col)).map_or_else(|| col.clone(), |(_, label)| label.clone());
craft_ui::ComboBox::from_id_salt(("sort_col", i))
.selected_text(col.clone())
.selected_text(shown)
.show_ui(ui, |ui| {
for (letter, label) in &columns {
ui.selectable_value(&mut col, letter.clone(), label.as_str());
+3 -2
View File
@@ -222,8 +222,9 @@ pub struct SheetApp {
pub ui: UiState,
/// The window chrome to draw (see [`Chrome`]).
pub chrome: Chrome,
/// Scroll position per (window id, sheet index).
pub views: HashMap<(u64, usize), SheetView>,
/// Scroll position per (window id, sheet uid): each sheet keeps its own when sheets are
/// inserted, deleted or moved.
pub views: HashMap<(u64, u64), SheetView>,
pub editor: Option<editor::EditState>,
pub text_box_editor: Option<text_box::EditState>,
pub dialog: Option<dialogs::Dialog>,
@@ -1,4 +1,6 @@
//! The saved scroll position (`sheetView topLeftCell`) is restored on open and saved back (#378).
//! Each window keeps each sheet's own scroll position while sheets are inserted, deleted or
//! moved (#3485).
use gridcraft_engine::core::{CellRef, Value};
use gridcraft_engine::model::Workbook;
@@ -58,3 +60,84 @@ fn saved_scroll_position_is_restored_and_saved_back() {
assert!(!d.is_dirty(), "scrolling and saving view state does not dirty the workbook");
}
}
#[test]
fn a_pivottable_sheet_opens_at_the_top_and_its_source_sheet_keeps_its_place() {
// A new PivotTable sheet goes in front of its source sheet. Scroll positions were kept by
// sheet index, so the new sheet took over the source sheet's position and opened at row 51.
let mut h = harness(None);
h.state_mut().run("range.setValues", json!({"range": "C1:D3", "values": [["Region", "Sales"], ["North", 10], ["South", 20]]})).unwrap();
{
let app = h.state_mut();
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
app.view_mut().unwrap().scroll.y = sh.row_top(50) as f32;
}
h.run_steps(2);
assert_eq!(first_scrolled_row(&h), 50, "setup: Sheet1 is scrolled to row 51");
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$C$1:$D$3", "rows": ["Region"]})).unwrap();
h.run_steps(4);
let wb = &h.state().session.active().unwrap().wb;
assert!(!wb.active().unwrap().pivots.is_empty(), "setup: the PivotTable's new sheet is active");
assert_eq!(first_scrolled_row(&h), 0, "the new sheet opens at row 1");
h.state_mut().run("sheet.next", json!({})).unwrap();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().name, "Sheet1");
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 is still scrolled to row 51");
}
/// Scrolls the active sheet so `row` (0-based) is the first one in view.
fn scroll_to_row(h: &mut Harness, row: u32) {
let app = h.state_mut();
let sh = app.session.active().unwrap().wb.active().unwrap().clone();
app.view_mut().unwrap().scroll.y = sh.row_top(row) as f32;
h.run_steps(2);
}
fn activate(h: &mut Harness, sheet: &str) {
h.state_mut().run("sheet.activate", json!({"sheet": sheet})).unwrap();
h.run_steps(4);
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().name, sheet);
}
#[test]
fn a_sheet_keeps_its_scroll_position_when_sheets_are_inserted_deleted_or_moved() {
let mut h = harness(None);
scroll_to_row(&mut h, 50);
h.state_mut().run("home.insertSheet", json!({"name": "Two", "before": 0})).unwrap();
h.run_steps(4);
assert_eq!(first_scrolled_row(&h), 0, "the inserted sheet opens at row 1");
scroll_to_row(&mut h, 10);
activate(&mut h, "Sheet1");
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 moved one place right and is still at row 51");
h.state_mut().run("home.deleteSheet", json!({"sheet": "Two"})).unwrap();
h.run_steps(4);
assert_eq!(first_scrolled_row(&h), 50, "Sheet1 moved back and is still at row 51");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(4);
activate(&mut h, "Two");
assert_eq!(first_scrolled_row(&h), 10, "the sheet undo brings back is where it was left");
h.state_mut().run("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
h.run_steps(4);
activate(&mut h, "Two");
assert_eq!(first_scrolled_row(&h), 10, "a moved sheet keeps its place");
activate(&mut h, "Sheet1");
assert_eq!(first_scrolled_row(&h), 50, "and so does the sheet it moved past");
}
#[test]
fn each_window_scrolls_on_its_own_and_a_closed_windows_positions_are_dropped() {
let mut h = harness(None);
scroll_to_row(&mut h, 50);
let first = h.state().session.active_window().unwrap();
h.state_mut().run("view.newWindow", json!({})).unwrap();
h.run_steps(4);
let second = h.state().session.active_window().unwrap();
assert_ne!(first, second);
scroll_to_row(&mut h, 10);
assert!(h.state().views.keys().any(|(w, _)| *w == second), "setup: the second window has its own position");
h.state_mut().run("window.close", json!({"window": second})).unwrap();
h.run_steps(4);
assert_eq!(h.state().session.active_window(), Some(first));
assert_eq!(first_scrolled_row(&h), 50, "the first window kept its own position");
assert!(h.state().views.keys().all(|(w, _)| *w == first), "nothing is kept for the closed window: {:?}", h.state().views.keys());
}
@@ -34,6 +34,18 @@ fn sort_dialog_seeds_the_active_column() {
assert_eq!(col(&h), "B", "Sort by starts on the column the cursor is in, not A");
}
/// #3446: the closed "Sort by" box shows the column's name next to its letter,
/// as the open list does ("B: Revenue", not just "B").
#[test]
fn closed_column_box_shows_the_column_name() {
use egui::accesskit::Role;
let mut h = harness(sales());
h.state_mut().open_dialog("sort", json!({}));
h.run_steps(2);
let shown: Vec<String> = h.get_all_by_role(Role::ComboBox).filter_map(|n| n.value()).collect();
assert!(shown.iter().any(|v| v == "B: Revenue"), "the Sort by box shows the column name: {shown:?}");
}
#[test]
fn add_level_picks_a_column_no_level_uses_yet() {
let mut h = harness(sales());
+2 -1
View File
@@ -1,6 +1,6 @@
# Chart parity
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-02 · **Change:** minor (doughnut charts: every series is a concentric ring, the first series innermost; hole size and first slice angle kept; rotated slices cover their own sweep; legend names categories; earlier: charts resize from eight handles, chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-11-07 · **Change:** minor (chart-data-table keys pass the series index to the shared combo plot-kind rule, restoring compilation after #1150; earlier: doughnut charts: every series is a concentric ring, the first series innermost; hole size and first slice angle kept; rotated slices cover their own sweep; legend names categories; earlier: charts resize from eight handles, chart data tables, legend keys and borders, native XLSX round-trip and print/PDF; earlier: selected chart source outlines stay beneath floating objects) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Excel's chart types and chart elements against GridCraft. Part of
[target-app-parity.md](target-app-parity.md). **Breadth ~50%, ready ~30%, 40–60 h** (estimated).
@@ -107,6 +107,7 @@ are (#1686). Such series get no `numCache`/`strCache` on save (#1690).
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | minor | Chart-data-table keys use the shared plot-kind helper with their series index after #1150. A focused regression checks the first combo series uses a column key and later series use line keys. No readiness score change. |
| 2026-11-02 | minor | Charts move and resize from eight handles, each moving only its own edges (#190); resizing leaves the work list (1–2 h, within the 40–60 h rounding) |
| 2026-11-01 | minor | Chart data tables for column, bar, line, area and compatible combo charts: command/UI controls, formatted source values, blanks, grouped category headers, legend keys, independent borders, shared print/PDF rendering and native c:dTable import/export. Dense tables show a space notice; no readiness score change. |
| 2026-10-29 | minor | Selected charts highlight their source cells beneath floating objects, so the blue outline no longer crosses the chart; uncovered source cells remain highlighted (#2861) |
+12
View File
@@ -157,6 +157,18 @@ The messages and both transports are the shared contract of [`craft-web-host`](h
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
- **The event and message names are the interface.** `gridcraft:ready`, `gridcraft:open`, `gridcraft:save`, `gridcraft:dirty` and `gridcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
### Driving it from the page (`window.gridcraft`)
Every GridCraft web build puts `window.gridcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `gridcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects, and the app skips it if it has not started it; a call the app has already taken still finishes, so a retry after a timeout can apply a command twice.
```js
const book = await gridcraft.docInspect(); // sheets, used ranges, tables, charts, names, selection
await gridcraft.execute("cell.set", { cell: "A1", input: "Total" });
const { data } = await gridcraft.screenshot(); // the canvas as PNG (base64)
```
The API is there in every mode, with or without a host. Opening a workbook, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
## Renderer selection
GridCraft's web build is the same egui app as the desktop one, compiled to wasm