mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 19:56:48 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8fbdfbf3f |
@@ -1,3 +0,0 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# 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: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-title: GridCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
@@ -8,10 +8,6 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
|
||||
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
Generated
+216
-2216
File diff suppressed because it is too large
Load Diff
+7
-31
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.1"
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/gridcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -24,9 +24,6 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one storytold/craft-libs revision.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -46,10 +43,10 @@ sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
# The desktop's preferred language, for the first-run interface language (native only).
|
||||
@@ -58,10 +55,7 @@ sys-locale = "0.3"
|
||||
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
|
||||
skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false, features = ["egui"] }
|
||||
epaint_default_fonts = "0.36"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -85,21 +79,3 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -12,265 +12,5 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
|
||||
Font License 1.1.
|
||||
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
|
||||
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
|
||||
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://github.com/source-foundry/Hack
|
||||
The following text is reproduced verbatim from Hack-Regular.txt:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
|
||||
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
|
||||
The following text is reproduced verbatim from OFL.txt:
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
|
||||
is distributed under the Ubuntu Font Licence, Version 1.0.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://design.ubuntu.com/font
|
||||
The following text is reproduced verbatim from UFL.txt:
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
|
||||
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
|
||||
|
||||
+1
-24
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,22 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
|
||||
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
|
||||
| 2026-10-24 | Fill handle: from a date, the right-drag menu and the Auto Fill Options button offer Fill Days, Fill Weekdays (Saturday and Sunday skipped), Fill Months and Fill Years |
|
||||
| 2026-10-24 | PivotTables: Enable show details (`pivot.options` `enableShowDetails`); turned off, Show Details and double-clicking a value cell list no records, and XLSX files keep the setting (`enableDrill`) |
|
||||
| 2026-10-24 | PivotTables: right-clicking a value, subtotal or grand total cell offers Show Details in the cell menu, which lists the cell's source records on a new sheet as double-clicking does |
|
||||
| 2026-10-24 | Sheet tabs: dragging the handle between the tabs and the horizontal scrollbar gives the tabs more or less of the bar, as in Excel; each open workbook keeps its own share |
|
||||
| 2026-10-24 | Charts: 100% Stacked Area (`insert.chart` `{type: "area", subtype: "stacked100"}`), drawn as each value's share of its category's total; XLSX `percentStacked` area charts open as 100% stacked instead of stacked |
|
||||
| 2026-10-23 | Accounting negatives in text: `$(1,234)`, `$ (5)` and `(5)%` read as negative numbers when typed, coerced by arithmetic or passed to VALUE, like Excel 365; `(-5)`, `((5))`, `$5%` and dates or times in parentheses stay text |
|
||||
| 2026-10-23 | Calc: `INDEX(A1:C3,2)` (one index on a reference of several rows and columns) is `#REF!` as in Excel, instead of the whole row; `INDEX(A1:C3,2,)` still gives the row |
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-22 | View › New Window and Arrange All on the ribbon. Arrange All tiles, stacks side by side or top to bottom, or cascades the native windows over the main window's monitor, the active one first. Two windows on one workbook show different sheets without copying the workbook or marking it changed; Save records the sheet of the window used last. A dialog stays in the window it was opened in |
|
||||
| 2026-10-22 | On the desktop every workbook window is a native window of its own: New, Open and New Window open one, each keeps its own cell draft, dialog and ribbon, and the macOS menu bar acts on the window focused last. Closing one of several windows on a workbook doesn't ask; closing a workbook's last window, or quitting, asks about unsaved changes in that window. The last window closed quits on Windows and Linux; macOS keeps running with the menu bar. First craft-libs `craft-window` use (window list and viewport driver) |
|
||||
| 2026-10-22 | View › New Window opens a second window on the same workbook (it used to open a copy whose Save overwrote the original): each window has its own selection, active sheet and scroll, titles read "Book1:1" / "Book1:2", and Switch Windows, Hide, Unhide and Close act per window. The control channel, CLI `send` and MCP tools take an optional `window` or `doc` id and default to the active window; `window.list` (MCP `list_windows`) lists the windows |
|
||||
| 2026-10-22 | Typing in a cell and then choosing File › New no longer strands the first workbook: the entry is committed into its own workbook before any File or window command (a refused entry stays open and stops the command), View › Switch Windows and the macOS Window menu list the open workbooks with unsaved markers, `view.switchWindows {doc}` switches by a stable workbook id, and the macOS Quit item asks about unsaved workbooks |
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
@@ -160,7 +147,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
@@ -179,7 +165,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 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-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) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
@@ -196,7 +181,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
||||
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | UI building blocks: theme tokens with a WCAG contrast test over both themes (icons and reference colours follow dark mode); ribbon scroll arrows and a large split button (Formulas › AutoSum opens its menu); the formula bar grows from one to five lines, then scrolls; a code editor widget with Python and LaTeX colouring |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||
| 2026-10-11 | Text boxes keep their formatting: run size, bold, italic, typeface, text colour and outline, alignment, vertical anchor and "no outline" (#377) |
|
||||
@@ -242,14 +226,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-22 | minor | Progress log: Arrange All; two windows on one workbook without copying it |
|
||||
| 2026-10-22 | minor | Progress log: a native window per workbook window |
|
||||
| 2026-10-22 | minor | Progress log: New Window shares the workbook; window ids for scripts and MCP |
|
||||
| 2026-10-22 | minor | Progress log: drafts stay in their workbook, Switch Windows, macOS Quit asks to save |
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
|
||||
@@ -268,7 +247,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
@@ -276,7 +254,6 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | UI building blocks; progress log row |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
| 2026-10-11 | minor | Worksheet functions 505 / 523 (ODD* bond functions); progress log row |
|
||||
|
||||
@@ -248,11 +248,10 @@ impl Discovery {
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
if !walk(&p, 0, &mut out, &mut seen) {
|
||||
if !walk(&p, 0, &mut out) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
@@ -269,20 +268,7 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
}
|
||||
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||
// depth cap only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if let Ok(m) = std::fs::metadata(dir)
|
||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||
return true;
|
||||
@@ -315,10 +301,10 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
if !walk(&p, depth + 1, out) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
@@ -333,7 +319,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
|
||||
guarded.unwrap_or_else(|p| {
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
|
||||
})
|
||||
}
|
||||
@@ -995,12 +981,6 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
// Several relationships name the same part; each fresh check decompressed it again, so
|
||||
// repeated references to one highly compressible part multiplied the work. The verdict is
|
||||
// remembered per (part, named content) and the decompression the package can cause is
|
||||
// budgeted as a whole (finding: repeated decompression).
|
||||
let mut verdicts: HashMap<(String, &'static str), bool> = HashMap::new();
|
||||
let mut budget: u64 = 512 * 1024 * 1024;
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
@@ -1014,7 +994,7 @@ fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String>
|
||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if !external
|
||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||
&& !holds_something(&mut zip, part, kind, &mut verdicts, &mut budget)
|
||||
&& !holds_something(&mut zip, part, kind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -1033,39 +1013,13 @@ fn rels_source_dir(key: &str) -> Option<String> {
|
||||
|
||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||
/// `verdicts` remembers the answer per (part, named content); `budget` is the decompression this
|
||||
/// package's remaining checks may cause, and once it is spent the part counts as having content.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str, verdicts: &mut HashMap<(String, &'static str), bool>, budget: &mut u64) -> bool {
|
||||
let wanted: Option<&'static [u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let marker = match wanted {
|
||||
Some(b"comment") => "comment",
|
||||
Some(_) => "shape",
|
||||
None => "any",
|
||||
};
|
||||
if let Some(v) = verdicts.get(&(name.to_ascii_lowercase(), marker)) {
|
||||
return *v;
|
||||
}
|
||||
let v = holds_something_uncached(zip, name, marker, budget);
|
||||
verdicts.insert((name.to_ascii_lowercase(), marker), v);
|
||||
v
|
||||
}
|
||||
|
||||
fn holds_something_uncached(zip: &mut Zip<'_>, name: &str, marker: &'static str, budget: &mut u64) -> bool {
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
if *budget == 0 {
|
||||
return true;
|
||||
}
|
||||
let take = f.size().min(MAX_XML_PART).min(*budget);
|
||||
*budget -= take;
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(take)));
|
||||
let wanted: Option<&'static [u8]> = match marker {
|
||||
"comment" => Some(b"comment"),
|
||||
"shape" => Some(b"shape"),
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
@@ -1367,8 +1321,7 @@ mod tests {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||
assert!(walk(&path, 0, &mut discovery));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
|
||||
@@ -195,18 +195,7 @@ fn pretty(v: &Value) -> String {
|
||||
|
||||
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
|
||||
fn number_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
format!("{}", n as i64)
|
||||
} else if n.is_finite() && (n.abs() >= 1e15 || n.abs() < 1e-5) {
|
||||
// Rust's `Display` never uses exponent form; `{:e}` omits the `+` on positive exponents.
|
||||
let s = format!("{n:e}");
|
||||
match s.split_once('e') {
|
||||
Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"),
|
||||
_ => s,
|
||||
}
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
|
||||
}
|
||||
|
||||
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
|
||||
@@ -617,11 +606,6 @@ mod tests {
|
||||
fn value_texts() {
|
||||
assert_eq!(value_text(&json!(6.0)), "6");
|
||||
assert_eq!(value_text(&json!(0.25)), "0.25");
|
||||
assert_eq!(value_text(&json!(1e300)), "1e+300");
|
||||
assert_eq!(value_text(&json!(-1.5e300)), "-1.5e+300");
|
||||
assert_eq!(value_text(&json!(1e-20)), "1e-20");
|
||||
assert_eq!(value_text(&json!(1e-300)), "1e-300");
|
||||
assert_eq!(value_text(&json!(0.0001)), "0.0001");
|
||||
assert_eq!(value_text(&json!(true)), "TRUE");
|
||||
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
|
||||
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
|
||||
|
||||
@@ -11,9 +11,8 @@
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments. `?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`).
|
||||
//! notes and comments. 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(target_arch = "wasm32")]
|
||||
|
||||
@@ -8,7 +8,6 @@ use wasm_bindgen::JsValue;
|
||||
const CANVAS_ID: &str = "gridcraft_canvas";
|
||||
const LOADING_ID: &str = "gridcraft_loading";
|
||||
|
||||
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -61,21 +60,8 @@ pub fn start() {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services);
|
||||
app.ui.system_theme = true;
|
||||
// `?chrome=none|notitlebar|nofiletab`: a host that embeds the sheet in its own window and owns the files
|
||||
// can leave the title bar and/or the File tab undrawn (`Chrome`, #1877). An unknown value keeps
|
||||
// everything, as the default does.
|
||||
if let Some(chrome) = query_param("chrome") {
|
||||
match chrome.as_str() {
|
||||
"none" => app.chrome = gridcraft_ui_egui::Chrome { title_bar: false, file_tab: false },
|
||||
"notitlebar" => app.chrome.title_bar = false,
|
||||
"nofiletab" => app.chrome.file_tab = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Follow the browser's language, except a CJK one. CJK coverage comes from
|
||||
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
|
||||
// cannot be assumed to render localized labels without tofu. Users can still
|
||||
// choose a language explicitly.
|
||||
// Follow the browser's language, except a CJK one: the web build has no system
|
||||
// CJK fonts (none are bundled), so that interface would render as tofu.
|
||||
let browser = gridcraft_ui_egui::i18n::Language::system();
|
||||
if !matches!(
|
||||
browser,
|
||||
@@ -170,9 +156,8 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwards a browser frame's raw input to the shared app before eframe handles it.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -229,9 +214,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
if let Some(w) = web_sys::window()
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
// With `noopener` the new browsing context can't hold a reference back to the
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
@@ -367,7 +350,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
|
||||
|
||||
if let Some(picker_fn) = show_picker_fn {
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ workspace = true
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -24,19 +24,10 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-update = { workspace = true }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
@@ -43,8 +43,7 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
app.open_path(p);
|
||||
}
|
||||
}
|
||||
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
|
||||
Event::Quit => app.request_quit(ctx),
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +1,40 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! The first line of a connection must authenticate with the app's token (#777; see
|
||||
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
|
||||
//! JSON, is answered once and closed.
|
||||
//!
|
||||
//! 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::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use gridcraft_mcp::control_auth;
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
/// How long a new connection may take to send its token.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
|
||||
/// else 256 random bits from the operating system, written to a file only the user can read.
|
||||
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
|
||||
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
|
||||
if t.len() < control_auth::MIN_TOKEN_LEN {
|
||||
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
|
||||
}
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
|
||||
return Ok(t);
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
|
||||
let token = control_auth::hex(&bytes);
|
||||
let path = control_auth::write_token_file(port, &token)?;
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let token: Arc<str> = make_token(port, chosen_token)?.into();
|
||||
let rx = serve_listener(listener, token, ctx)?;
|
||||
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, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
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() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
|
||||
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
|
||||
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
|
||||
let mut first = String::new();
|
||||
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
|
||||
let verdict = match read {
|
||||
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
|
||||
_ => Err(json!({"ok": false, "error": "authentication required"})),
|
||||
};
|
||||
let allowed = verdict.is_ok();
|
||||
let reply = verdict.unwrap_or_else(|e| e);
|
||||
let _ = writeln!(out, "{reply}");
|
||||
let _ = out.set_read_timeout(None);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -148,13 +103,10 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
@@ -202,29 +154,21 @@ fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Authenticate a new connection, send `input`, and read reply lines (after the
|
||||
/// authentication reply) until the server closes it.
|
||||
/// 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(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
// A read timeout ends the list too: the server kept the connection open.
|
||||
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
|
||||
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
|
||||
replies.remove(0);
|
||||
replies
|
||||
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, TOKEN.into(), egui::Context::default()).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
|
||||
@@ -250,11 +194,8 @@ mod tests {
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut auth_reply = String::new();
|
||||
r.read_line(&mut auth_reply).unwrap();
|
||||
for id in [1, 2] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
@@ -280,75 +221,4 @@ mod tests {
|
||||
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");
|
||||
}
|
||||
|
||||
/// A server on a loopback port with a stand-in UI that echoes each method.
|
||||
fn spawn() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(send.as_bytes()).unwrap();
|
||||
// Replies up to the server closing the connection.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_opens_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
|
||||
line.clear();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Json = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_but_the_token_first_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
|
||||
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
|
||||
// The second request is never served: one refusal, then end of stream.
|
||||
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 1, "{first}: {got:?}");
|
||||
assert_eq!(got[0]["ok"], json!(false), "{first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparsable_line_after_auth_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-47
@@ -1,10 +1,9 @@
|
||||
//! GridCraft desktop app.
|
||||
//!
|
||||
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
|
||||
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
|
||||
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -23,34 +22,31 @@ use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::doc_windows::DocWindows;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
use serde_json::json;
|
||||
|
||||
/// The app, its native window per workbook window, and (macOS) the menu bar and Finder events.
|
||||
struct App(SheetApp, DocWindows, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
|
||||
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>, #[cfg(target_os = "macos")] fmv_macos_events::Inbox);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.2.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.2 = Some(native_menu::NativeMenu::install(ctx));
|
||||
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.1 = Some(native_menu::NativeMenu::install(ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.2 {
|
||||
m.poll(&mut self.0, ctx, self.1.focused_viewport());
|
||||
if let Some(m) = &self.1 {
|
||||
m.poll(&mut self.0, ctx);
|
||||
}
|
||||
apple_events::poll(&self.3, &mut self.0, ctx);
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.1.logic(&mut self.0, ctx);
|
||||
self.0.poll_printing(ctx);
|
||||
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
}
|
||||
/// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
// Files dropped on the window open as workbooks.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
|
||||
@@ -59,7 +55,7 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.1.show(&mut self.0, ui);
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
@@ -184,11 +180,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let path = std::path::Path::new(suggested);
|
||||
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
|
||||
const TYPES: [(&str, &str); 4] =
|
||||
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
|
||||
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
|
||||
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
|
||||
@@ -255,20 +251,11 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
}));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("GridCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
|
||||
let mut sample: Option<String> = None;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -278,10 +265,6 @@ fn main() -> eframe::Result<()> {
|
||||
i += 1;
|
||||
control_port = args.get(i).and_then(|p| p.parse().ok());
|
||||
}
|
||||
"--control-token" => {
|
||||
i += 1;
|
||||
control_token = args.get(i).cloned();
|
||||
}
|
||||
"--sample" => {
|
||||
i += 1;
|
||||
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
|
||||
@@ -316,7 +299,6 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.start_update_check();
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
@@ -338,20 +320,7 @@ fn main() -> eframe::Result<()> {
|
||||
if let Some(i) = icon() {
|
||||
viewport = viewport.with_icon(i);
|
||||
}
|
||||
let window_builder = viewport.clone();
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let mut options = options;
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some_and(|d| !d.is_empty()) && std::env::var_os("GRIDCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// Before eframe creates the wgpu instance: Windows starts on DirectX 12, or OpenGL when
|
||||
// DirectX 12 has no GPU driver, and never touches Vulkan unless asked (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -383,15 +352,13 @@ fn main() -> eframe::Result<()> {
|
||||
};
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
}
|
||||
let windows = DocWindows::new(&mut app, window_builder.clone(), DocWindows::platform_quits_with_last_window());
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
windows,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use gridcraft_ui_egui::{OpenWindow, SheetApp};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::{Value, json};
|
||||
@@ -168,20 +168,10 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
("Calculate Now", "formulas.calculateNow", None),
|
||||
],
|
||||
),
|
||||
// The open workbooks follow the separator (`NativeMenu::sync_windows`).
|
||||
(
|
||||
"Window",
|
||||
vec![
|
||||
("New Window", "view.newWindow", None),
|
||||
("Next Sheet", "sheet.next", None),
|
||||
("Previous Sheet", "sheet.previous", None),
|
||||
("-", "", None),
|
||||
],
|
||||
),
|
||||
("Window", vec![("New Window", "view.newWindow", None), ("Next Sheet", "sheet.next", None), ("Previous Sheet", "sheet.previous", None)]),
|
||||
(
|
||||
"Help",
|
||||
vec![
|
||||
("Check for Updates…", "app.checkForUpdates", None),
|
||||
("Agent Control (MCP)…", "dialog:agents", None),
|
||||
("Contributors…", "dialog:about:Contributors", None),
|
||||
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
|
||||
@@ -195,19 +185,8 @@ pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
events: Receiver<MenuEvent>,
|
||||
theme_items: Vec<(&'static str, CheckMenuItem)>,
|
||||
window_menu: Option<Submenu>,
|
||||
/// The Window menu's window items and what they last showed.
|
||||
window_items: Vec<CheckMenuItem>,
|
||||
windows: Vec<OpenWindow>,
|
||||
}
|
||||
|
||||
/// Menu id of the Window menu item that brings workbook `doc` forward.
|
||||
const WINDOW_ITEM: &str = "window:id:";
|
||||
|
||||
/// The Quit item: asks about each unsaved workbook in its window first
|
||||
/// (`SheetApp::request_quit`), which AppKit's own Quit (terminate:) would skip.
|
||||
const QUIT: &str = "app.quit";
|
||||
|
||||
fn select_theme(app: &mut SheetApp, id: &str) -> bool {
|
||||
let mode = match id {
|
||||
"theme:system" => "system",
|
||||
@@ -233,7 +212,7 @@ impl NativeMenu {
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
&PredefinedMenuItem::show_all(None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&MenuItem::with_id(QUIT, "Quit GridCraft", true, Accelerator::from_str("CMD+Q").ok()),
|
||||
&PredefinedMenuItem::quit(None),
|
||||
]);
|
||||
let _ = menu.append(&app_menu);
|
||||
let mut theme_items = Vec::new();
|
||||
@@ -268,7 +247,7 @@ impl NativeMenu {
|
||||
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
|
||||
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
|
||||
// the submenu up in the installed main menu.
|
||||
if let Some(window) = &window_menu {
|
||||
if let Some(window) = window_menu {
|
||||
window.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
@@ -277,54 +256,12 @@ impl NativeMenu {
|
||||
let _ = tx.send(e);
|
||||
ctx.request_repaint();
|
||||
}));
|
||||
NativeMenu { _menu: menu, events: rx, theme_items, window_menu, window_items: Vec::new(), windows: Vec::new() }
|
||||
NativeMenu { _menu: menu, events: rx, theme_items }
|
||||
}
|
||||
|
||||
/// Keeps the Window menu's list of open windows (title, unsaved bullet, active check) in
|
||||
/// step with the session.
|
||||
fn sync_windows(&mut self, app: &SheetApp) {
|
||||
let windows = app.open_windows();
|
||||
if windows != self.windows
|
||||
&& let Some(menu) = &self.window_menu
|
||||
{
|
||||
let same_windows = windows.len() == self.windows.len() && windows.iter().zip(&self.windows).all(|(a, b)| a.window == b.window);
|
||||
if same_windows {
|
||||
for (item, w) in self.window_items.iter().zip(&windows) {
|
||||
item.set_text(w.label());
|
||||
}
|
||||
} else {
|
||||
for item in self.window_items.drain(..) {
|
||||
let _ = menu.remove(&item);
|
||||
}
|
||||
for w in &windows {
|
||||
let item = CheckMenuItem::with_id(format!("{WINDOW_ITEM}{}", w.window), w.label(), true, w.active, None);
|
||||
let _ = menu.append(&item);
|
||||
self.window_items.push(item);
|
||||
}
|
||||
}
|
||||
self.windows = windows;
|
||||
}
|
||||
// AppKit flips a check item when it is chosen, so the active check is put back every poll.
|
||||
for (item, w) in self.window_items.iter().zip(&self.windows) {
|
||||
if item.is_checked() != w.active {
|
||||
item.set_checked(w.active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the chosen menu items. Commands go to the active window, the most recently focused;
|
||||
/// `viewport` is its egui viewport, for the items that act on its input.
|
||||
pub fn poll(&mut self, app: &mut SheetApp, ctx: &egui::Context, viewport: egui::ViewportId) {
|
||||
pub fn poll(&self, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
let id = ev.id.as_ref().to_string();
|
||||
if id == QUIT {
|
||||
app.request_quit(ctx);
|
||||
continue;
|
||||
}
|
||||
if let Some(window) = id.strip_prefix(WINDOW_ITEM).and_then(|w| w.parse::<u64>().ok()) {
|
||||
app.run_or_alert("view.switchWindows", json!({"window": window}));
|
||||
continue;
|
||||
}
|
||||
// Text selection belongs to the inline editor, not the worksheet clipboard/undo.
|
||||
if app.text_box_editor.is_some() {
|
||||
let clipboard = match id.as_str() {
|
||||
@@ -334,11 +271,11 @@ impl NativeMenu {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(command) = clipboard {
|
||||
ctx.send_viewport_cmd_to(viewport, command);
|
||||
ctx.send_viewport_cmd(command);
|
||||
continue;
|
||||
}
|
||||
if id == "edit.undo" || id == "edit.redo" {
|
||||
ctx.input_mut_for(viewport, |i| {
|
||||
ctx.input_mut(|i| {
|
||||
i.events.push(egui::Event::Key {
|
||||
key: egui::Key::Z,
|
||||
physical_key: None,
|
||||
@@ -384,7 +321,6 @@ impl NativeMenu {
|
||||
item.set_checked(checked);
|
||||
}
|
||||
}
|
||||
self.sync_windows(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,16 +337,6 @@ mod tests {
|
||||
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = tree();
|
||||
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
|
||||
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
|
||||
assert_eq!(item.1, "app.checkForUpdates");
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run(item.1, serde_json::json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
@@ -21,7 +17,7 @@ pub fn discover() -> Discover {
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -49,7 +45,7 @@ pub fn submit() -> Submit {
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -99,7 +99,3 @@ real_name = "Tyrel Haveman"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
+9
-132
@@ -22,11 +22,6 @@ const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// range, so that formulas over them stay fast.
|
||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
/// Lambda invocations one cell may make. A semantically bounded formula in Excel can invoke
|
||||
/// its lambda at most once per cell of an array (capped here by `MAX_CELLS`), so this covers
|
||||
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
||||
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
||||
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Area {
|
||||
@@ -58,24 +53,6 @@ fn err(e: CellError) -> Ev {
|
||||
Ev::V(Value::Error(e))
|
||||
}
|
||||
|
||||
/// LAMBDA calls that one formula may have in progress at once, counted the same way on every build.
|
||||
/// The next call answers `#NUM!`. A countdown from 131, the deepest that `MAX_DEPTH` allows, has 132
|
||||
/// calls in progress, so it still runs.
|
||||
const MAX_LAMBDA_CALLS: usize = 140;
|
||||
|
||||
/// Native stack, in bytes, that one formula's evaluation may use below its first `eval`. Past it,
|
||||
/// `eval` answers `#NUM!`. It guards the stack and leaves 128 KiB of a 1 MiB thread for the frames
|
||||
/// above the formula. A cell that another cell's formula computes on demand shares the budget of the
|
||||
/// outermost formula ([`Host::stack_top`]).
|
||||
const STACK_BUDGET: usize = 896 * 1024;
|
||||
|
||||
/// The address of a local variable, which shows how far down the native stack this call is.
|
||||
#[inline(always)]
|
||||
pub(crate) fn stack_position() -> usize {
|
||||
let marker = 0u8;
|
||||
std::hint::black_box(&marker) as *const u8 as usize
|
||||
}
|
||||
|
||||
/// What the evaluator needs from its surroundings.
|
||||
pub trait Host {
|
||||
fn workbook(&self) -> &Workbook;
|
||||
@@ -103,13 +80,6 @@ pub trait Host {
|
||||
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
false
|
||||
}
|
||||
/// While a formula is calculated on demand inside another one: `stack_position()` where the
|
||||
/// outermost one started, which the stack budget is counted from.
|
||||
fn stack_top(&self) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
/// Called when a formula's evaluation runs past the stack budget.
|
||||
fn stack_exhausted(&mut self) {}
|
||||
}
|
||||
|
||||
pub struct Evaluator<'h> {
|
||||
@@ -118,15 +88,6 @@ pub struct Evaluator<'h> {
|
||||
pub at: CellRef,
|
||||
env: Vec<(String, Ev)>,
|
||||
depth: usize,
|
||||
/// LAMBDA calls in progress.
|
||||
calls: usize,
|
||||
/// `stack_position()` at the outermost `eval`.
|
||||
stack_top: usize,
|
||||
/// Lambda invocations this cell still has left. Every call spends one charge, so a
|
||||
/// compact hostile formula (a LAMBDA that calls itself, nested `MAP`s over big arrays)
|
||||
/// runs out of work after the same number of calls that a legitimately wide one could
|
||||
/// make, instead of an amount of work that squares with array nesting (#2382).
|
||||
fuel: u64,
|
||||
}
|
||||
|
||||
struct FnCtx<'a, 'h> {
|
||||
@@ -166,20 +127,8 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut ev = Evaluator {
|
||||
host: &mut *self.ev.host,
|
||||
sheet: area.sheet,
|
||||
at,
|
||||
env,
|
||||
depth: self.ev.depth,
|
||||
calls: self.ev.calls,
|
||||
stack_top: self.ev.stack_top,
|
||||
fuel: self.ev.fuel,
|
||||
};
|
||||
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
||||
// The child's spend counts against this cell's budget.
|
||||
self.ev.fuel = ev.fuel;
|
||||
Some(v)
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
@@ -198,7 +147,7 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0 }
|
||||
}
|
||||
|
||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||
@@ -284,18 +233,10 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
pub fn eval(&mut self, e: &Expr) -> Ev {
|
||||
let here = stack_position();
|
||||
if self.depth == 0 {
|
||||
self.stack_top = self.host.stack_top().unwrap_or(here);
|
||||
}
|
||||
if self.stack_top.abs_diff(here) > STACK_BUDGET {
|
||||
self.host.stack_exhausted();
|
||||
return err(CellError::Num);
|
||||
}
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
self.depth -= 1;
|
||||
return err(CellError::Num);
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
let r = self.eval_inner(e);
|
||||
self.depth -= 1;
|
||||
@@ -579,7 +520,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
_ => {
|
||||
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
|
||||
// of one level of recursion per operator (past the depth limit it was an error).
|
||||
// of one level of recursion per operator (past the depth limit it was #CALC!).
|
||||
let mut rest = vec![(op, b)];
|
||||
let mut left = a;
|
||||
while let Expr::Binary(o, l, r) = left
|
||||
@@ -888,11 +829,10 @@ impl<'h> Evaluator<'h> {
|
||||
fn builtin(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
let Some(spec) = gridcraft_functions::lookup(name) else {
|
||||
// A defined name holding a LAMBDA, or a LET-bound lambda.
|
||||
return match self.name(name) {
|
||||
Ev::L(l) => self.apply_lambda(&l, args),
|
||||
Ev::V(Value::Error(x)) => err(x),
|
||||
_ => err(CellError::Name),
|
||||
};
|
||||
if let Ev::L(l) = self.name(name) {
|
||||
return self.apply_lambda(&l, args);
|
||||
}
|
||||
return err(CellError::Name);
|
||||
};
|
||||
if args.len() < spec.min_args || args.len() > spec.max_args {
|
||||
return err(CellError::Value);
|
||||
@@ -1043,22 +983,10 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn call_lambda_values(&mut self, l: &Lambda, bound: Vec<(String, Ev)>) -> Ev {
|
||||
if self.calls >= MAX_LAMBDA_CALLS {
|
||||
return err(CellError::Num);
|
||||
}
|
||||
// #2382: one charge per invocation, so a cell's total work stays bounded however the
|
||||
// lambdas nest. Foregrounding the charge here means every path into a body (direct
|
||||
// `(2,3)` application, `MAP`/`REDUCE`/`BYROW` helpers, D-function criteria) meters.
|
||||
self.fuel = match self.fuel.checked_sub(1) {
|
||||
Some(f) => f,
|
||||
None => return err(CellError::Calc),
|
||||
};
|
||||
self.calls += 1;
|
||||
let saved = std::mem::replace(&mut self.env, l.env.clone());
|
||||
self.env.extend(bound);
|
||||
let r = self.eval(&l.body);
|
||||
self.env = saved;
|
||||
self.calls -= 1;
|
||||
r
|
||||
}
|
||||
|
||||
@@ -2056,54 +1984,3 @@ pub fn is_known_function(n: &str) -> bool {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: RefKind) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fuel_tests {
|
||||
use super::*;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
struct Empty {
|
||||
wb: Workbook,
|
||||
}
|
||||
impl Host for Empty {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
&self.wb
|
||||
}
|
||||
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
||||
Value::Empty
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn random(&mut self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_with(fuel: u64, src: &str) -> Value {
|
||||
let mut host = Empty { wb: Workbook::default() };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||
ev.fuel = fuel;
|
||||
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
||||
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
||||
// here: an inner MAP result is an array, and MAP of array elements is #CALC! on shape,
|
||||
// which happens well before the budget does.)
|
||||
let r = eval_with(3, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert_eq!(r, Value::number(4.0), "{r:?}");
|
||||
// One charge short: the nest bottoms out in #CALC! instead of running unbounded.
|
||||
let r = eval_with(2, "=LAMBDA(x,LAMBDA(y,LAMBDA(z,z+1)(3))(2))(1)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
// One charge short of a single application: #CALC! with a zero budget.
|
||||
let r = eval_with(0, "=LAMBDA(x,x+1)(2)");
|
||||
assert!(matches!(r, Value::Error(CellError::Calc)), "{r:?}");
|
||||
let r = eval_with(1, "=LAMBDA(x,x+1)(2)");
|
||||
assert_eq!(r, Value::number(3.0));
|
||||
// A flat helper map keeps working (3 of 3 invokes inside the budget).
|
||||
let r = eval_with(3, "=MAP({1,2,3},LAMBDA(x,x*2))");
|
||||
assert!(matches!(r, Value::Array(_)) && !matches!(&r, Value::Array(a) if a.data.iter().any(|v| matches!(v, Value::Error(_)))), "{r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-154
@@ -44,7 +44,7 @@ use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{CalcMode, Workbook};
|
||||
|
||||
use crate::eval::{Area, Evaluator, Host, precedents, stack_position};
|
||||
use crate::eval::{Area, Evaluator, Host, precedents};
|
||||
|
||||
pub type Key = (usize, CellRef);
|
||||
|
||||
@@ -432,10 +432,6 @@ struct PassHost<'a> {
|
||||
tainted: HashSet<Key>,
|
||||
/// Whether the formula being evaluated has read a previous value.
|
||||
taint: bool,
|
||||
/// `stack_position()` where the outermost formula being calculated started.
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -466,8 +462,6 @@ impl<'a> PassHost<'a> {
|
||||
members: HashSet::default(),
|
||||
tainted: HashSet::default(),
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,9 +580,6 @@ impl PassHost<'_> {
|
||||
self.suspend = Some(k);
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.depth == 0 {
|
||||
self.stack_top = stack_position();
|
||||
}
|
||||
self.in_progress.insert(k);
|
||||
self.depth += 1;
|
||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||
@@ -596,24 +587,10 @@ impl PassHost<'_> {
|
||||
self.stack.push(k);
|
||||
}
|
||||
let expr = std::sync::Arc::clone(expr);
|
||||
let outer_out_of_stack = std::mem::replace(&mut self.out_of_stack, false);
|
||||
let v = {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
ev.value(&expr)
|
||||
};
|
||||
let out_of_stack = std::mem::replace(&mut self.out_of_stack, outer_out_of_stack);
|
||||
if out_of_stack && self.depth > 1 && self.suspend.is_none() {
|
||||
// It ran out of the stack left below the formulas that read it: it is calculated
|
||||
// from the top instead, as a formula needed at `MAX_NESTED` levels deep is.
|
||||
self.suspend = Some(k);
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
if self.iterate {
|
||||
self.stack.pop();
|
||||
}
|
||||
self.taint = outer_taint;
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.suspend.is_some() {
|
||||
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
||||
self.depth -= 1;
|
||||
@@ -768,12 +745,6 @@ impl Host for PassHost<'_> {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
self.wb
|
||||
}
|
||||
fn stack_top(&self) -> Option<usize> {
|
||||
(self.depth > 1).then_some(self.stack_top)
|
||||
}
|
||||
fn stack_exhausted(&mut self) {
|
||||
self.out_of_stack = true;
|
||||
}
|
||||
fn spill_range(&mut self, sheet: usize, anchor: CellRef) -> Option<RangeRef> {
|
||||
let k = (sheet, anchor);
|
||||
if !(self.pending.contains(&k) || self.results.contains_key(&k)) {
|
||||
@@ -927,70 +898,6 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pops one finished component off Tarjan's stack, labelling its nodes with `comp_index`. The
|
||||
/// component is everything up to and including `root`.
|
||||
fn pop_component(stack: &mut Vec<usize>, on_stack: &mut [bool], comp: &mut [usize], comp_index: usize, root: usize) {
|
||||
while let Some(w) = stack.pop() {
|
||||
on_stack[w] = false;
|
||||
comp[w] = comp_index;
|
||||
if w == root {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Strongly connected components of a graph given as adjacency lists (Tarjan, iterative): each
|
||||
/// node's component index, and the number of nodes in each component.
|
||||
fn components(edges: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
|
||||
let n = edges.len();
|
||||
let (mut low, mut num) = (vec![0usize; n], vec![usize::MAX; n]);
|
||||
let (mut on_stack, mut stack, mut comp) = (vec![false; n], Vec::new(), vec![usize::MAX; n]);
|
||||
let (mut next, mut comps) = (0usize, 0usize);
|
||||
for root in 0..n {
|
||||
if num[root] != usize::MAX {
|
||||
continue;
|
||||
}
|
||||
let mut call: Vec<(usize, usize)> = vec![(root, 0)];
|
||||
num[root] = next;
|
||||
low[root] = next;
|
||||
next += 1;
|
||||
stack.push(root);
|
||||
on_stack[root] = true;
|
||||
while let Some(top) = call.last_mut() {
|
||||
let v = top.0;
|
||||
if let Some(&w) = edges.get(v).and_then(|e| e.get(top.1)) {
|
||||
top.1 += 1;
|
||||
if num[w] == usize::MAX {
|
||||
num[w] = next;
|
||||
low[w] = next;
|
||||
next += 1;
|
||||
stack.push(w);
|
||||
on_stack[w] = true;
|
||||
call.push((w, 0));
|
||||
} else if on_stack[w] {
|
||||
low[v] = low[v].min(num[w]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
call.pop();
|
||||
if let Some(&(u, _)) = call.last() {
|
||||
low[u] = low[u].min(low[v]);
|
||||
}
|
||||
if low[v] == num[v] {
|
||||
pop_component(&mut stack, &mut on_stack, &mut comp, comps, v);
|
||||
comps += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut size = vec![0usize; comps];
|
||||
for &c in &comp {
|
||||
if let Some(s) = size.get_mut(c) {
|
||||
*s += 1;
|
||||
}
|
||||
}
|
||||
(comp, size)
|
||||
}
|
||||
|
||||
impl Calc {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
@@ -1006,7 +913,6 @@ impl Calc {
|
||||
/// Rebuilds the graph from scratch.
|
||||
pub fn rebuild(&mut self, wb: &Workbook) {
|
||||
self.graph = Graph::default();
|
||||
let mut circ = Vec::new();
|
||||
for (si, sheet) in wb.sheets.iter().enumerate() {
|
||||
for (c, cell) in sheet.cells.iter() {
|
||||
if let Some(f) = &cell.formula
|
||||
@@ -1014,56 +920,10 @@ impl Calc {
|
||||
{
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.graph.settle();
|
||||
self.circular = if wb.calc.iterative { Vec::new() } else { self.loops_among(&circ) };
|
||||
}
|
||||
|
||||
/// The circular references of a rebuilt workbook (after undo, redo or a rollback, which put
|
||||
/// back cells without calculating them), from the formulas whose cached value is `#CIRC!`:
|
||||
/// the listed cells still on a loop, and one cell of each loop none of them is on.
|
||||
fn loops_among(&self, circ: &[Key]) -> Vec<Key> {
|
||||
if circ.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let n = circ.len();
|
||||
let index: HashMap<Key, usize> = circ.iter().enumerate().map(|(i, k)| (*k, i)).collect();
|
||||
// Edges from a cell to the `#CIRC!` formulas that read it; loops are the same either way.
|
||||
let mut deps = Vec::new();
|
||||
let edges: Vec<Vec<usize>> = circ
|
||||
.iter()
|
||||
.map(|k| {
|
||||
deps.clear();
|
||||
self.graph.dependents(k.0, k.1, &mut deps);
|
||||
let mut e: Vec<usize> = deps.iter().filter_map(|d| index.get(d).copied()).collect();
|
||||
e.sort_unstable();
|
||||
e.dedup();
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
// Loop members: nodes in a component of more than one, or with an edge to themselves.
|
||||
let (comp, size) = components(&edges);
|
||||
let on_loop = |i: usize| comp.get(i).and_then(|c| size.get(*c)).is_some_and(|s| *s > 1) || edges.get(i).is_some_and(|e| e.contains(&i));
|
||||
// A formula with dynamic references (INDIRECT, OFFSET) may close a loop the graph can't see.
|
||||
let mut out: Vec<Key> =
|
||||
self.circular.iter().copied().filter(|k| index.get(k).is_some_and(|&i| on_loop(i) || self.graph.dynamic.contains(k))).collect();
|
||||
let mut covered: HashSet<usize> = out.iter().filter_map(|k| index.get(k)).filter_map(|&i| comp.get(i).copied()).collect();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by_key(|&i| circ.get(i).map(|(s, c)| (*s, c.row, c.col)));
|
||||
for i in order {
|
||||
if let (Some(&c), Some(&k)) = (comp.get(i), circ.get(i))
|
||||
&& on_loop(i)
|
||||
&& covered.insert(c)
|
||||
{
|
||||
out.push(k);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
||||
@@ -1243,12 +1103,7 @@ impl Calc {
|
||||
}
|
||||
let tw = prof_now();
|
||||
self.last_recalc_cells += results.len();
|
||||
// The loops this round finds replace the ones listed for the formulas it evaluated.
|
||||
if !self.circular.is_empty() {
|
||||
let evaluated: HashSet<Key> = dirty.iter().copied().collect();
|
||||
self.circular.retain(|k| !evaluated.contains(k));
|
||||
}
|
||||
self.circular.extend(cycles);
|
||||
self.circular = cycles;
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
let mut freed: Vec<(usize, RangeRef)> = Vec::new();
|
||||
@@ -1361,13 +1216,6 @@ impl Calc {
|
||||
}
|
||||
dirty = self.dirty_closure(wb, &spill_changes);
|
||||
}
|
||||
// Loops among formulas the run did not evaluate stay listed while they are still formulas.
|
||||
// Iterative calculation reports none.
|
||||
if wb.calc.iterative {
|
||||
self.circular.clear();
|
||||
} else {
|
||||
self.circular.retain(|k| self.graph.nodes.contains_key(k));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -384,32 +384,6 @@ fn circular_references_without_iterative_calculation() {
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
/// The list of circular references keeps the loops that a recalculation of other cells does not
|
||||
/// reach, and drops a loop once a constant is typed over one of its cells.
|
||||
#[test]
|
||||
fn circular_references_stay_listed_until_the_loop_is_removed() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=A1+1");
|
||||
t.set("B1", "=B2");
|
||||
t.set("B2", "=B1");
|
||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
t.set("D4", "7");
|
||||
t.set("D5", "=D4*2");
|
||||
assert!(t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
||||
t.set("A1", "5");
|
||||
assert!(!t.calc.circular.contains(&(0, c("A1"))), "{:?}", t.calc.circular);
|
||||
assert!(t.calc.circular.iter().any(|k| *k == (0, c("B1")) || *k == (0, c("B2"))), "{:?}", t.calc.circular);
|
||||
t.set("B2", "3");
|
||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
||||
// In manual calculation only the edited formulas are evaluated; a constant typed over the listed cell removes it.
|
||||
t.wb.calc.mode = gridcraft_model::CalcMode::Manual;
|
||||
t.set("C1", "=C1+1");
|
||||
assert_eq!(t.calc.circular, vec![(0, c("C1"))]);
|
||||
t.set("C1", "1");
|
||||
assert!(t.calc.circular.is_empty(), "{:?}", t.calc.circular);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_coerces_in_the_workbook_date_system() {
|
||||
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
||||
@@ -596,24 +570,6 @@ fn lambda_helpers_check_shapes_and_parameters() {
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||
// #2382: lambda nesting multiplies invocations, and the budget meters every one of them.
|
||||
// Exhaustion itself is tested on a mock host in `eval::fuel_tests` (burning the real 16M
|
||||
// budget in a full recalc needs tens of millions of calls, far too slow for a unit test);
|
||||
// here the same nested shape runs inside it: 6 × 9 ≈ 59k scalar REDUCE invocations.
|
||||
let mut t = T::new();
|
||||
t.set(
|
||||
"A1",
|
||||
"=LET(s,SEQUENCE(9),REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,REDUCE(0,s,LAMBDA(a,v,a+v)))))))))))",
|
||||
);
|
||||
// The bodies are the same inner reduce, so every level sums to 45 while the invocation
|
||||
// count multiplies by 9 per level (9, 81, 729, 6561, 59049 calls).
|
||||
assert_eq!(t.num("A1"), 45.0);
|
||||
t.set("A2", "=REDUCE(0,SEQUENCE(4000),LAMBDA(a,v,a+v))");
|
||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
@@ -1051,100 +1007,6 @@ fn lambda_argument_count() {
|
||||
assert_eq!(t.get("A9"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lambda_that_calls_itself_without_end_is_num_on_a_small_stack() {
|
||||
// 1 MiB is the main thread's stack on Windows, where commands, recalculation and file open run.
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let mut t = T::new();
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "LOOP".into(),
|
||||
scope: None,
|
||||
formula: "LAMBDA(n,LOOP(n))".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.calc.rebuild(&t.wb);
|
||||
let endless = "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))";
|
||||
t.set("A1", endless);
|
||||
t.set("A2", "=LOOP(1)");
|
||||
t.set("A3", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+SUM(MAP({1},LAMBDA(v,me(me,n-1)))))),F(F,100000))");
|
||||
t.set("A4", "=LET(F,LAMBDA(me,n,IF(n<=0,0,1+me(me,n-1))),F(F,131))");
|
||||
let evaluated = crate::evaluate(&t.wb, 0, c("B1"), endless);
|
||||
[t.get("A1"), t.get("A2"), t.get("A3"), evaluated, t.get("A4")]
|
||||
})
|
||||
.expect("spawn");
|
||||
let [endless, endless_name, through_map, evaluated, countdown] = handle.join().expect("recursion must not overflow the stack");
|
||||
assert_eq!(endless, Value::Error(CellError::Num));
|
||||
assert_eq!(endless_name, Value::Error(CellError::Num));
|
||||
assert_eq!(through_map, Value::Error(CellError::Num));
|
||||
assert_eq!(evaluated, Value::Error(CellError::Num));
|
||||
assert_eq!(countdown, Value::Number(131.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recursive_lambda_stops_at_the_same_call_on_any_stack_size() {
|
||||
let run = |stack: usize| {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(stack)
|
||||
.spawn(|| {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "CNT".into(),
|
||||
scope: None,
|
||||
formula: "LAMBDA(n,IF(n=0,0,1+CNT(n-1)))".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
// F(F,n) has n + 1 calls in progress at its deepest; W adds one more call per step.
|
||||
let tail = |n: u32| format!("=LET(F,LAMBDA(me,n,IF(n=0,0,me(me,n-1))),F(F,{n}))");
|
||||
let wrapped = |n: u32| format!("=LET(W,LAMBDA(f,k,f(f,k)),F,LAMBDA(me,n,IF(n<=0,0,1+W(me,n-1))),F(F,{n}))");
|
||||
[tail(139), tail(140), wrapped(69), wrapped(70), "=CNT(131)".to_string()].map(|f| crate::evaluate(&wb, 0, c("B1"), &f))
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("recursion must not overflow the stack")
|
||||
};
|
||||
let small = run(1 << 20);
|
||||
assert_eq!(small, [Value::Number(0.0), Value::Error(CellError::Num), Value::Number(69.0), Value::Error(CellError::Num), Value::Number(131.0)]);
|
||||
assert_eq!(run(64 << 20), small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recursive_lambda_that_reads_another_cells_recursive_lambda_shares_the_stack() {
|
||||
let run = |stack: usize| {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(stack)
|
||||
.spawn(|| {
|
||||
// Row-major order calculates A1 first, so each cell below is calculated on demand
|
||||
// at the deepest call of the one above it.
|
||||
let countdown = |then: &str| format!("=LET(F,LAMBDA(me,n,IF(n<=0,{then},1+me(me,n-1))),F(F,131))");
|
||||
let mut wb = Workbook::new();
|
||||
let sheet = wb.sheet_mut(0).expect("sheet");
|
||||
for (at, f) in [
|
||||
("A1", countdown("A2")),
|
||||
("A2", countdown("A3")),
|
||||
("A3", countdown("0")),
|
||||
("B1", countdown("B2")),
|
||||
("B2", "=LET(f,LAMBDA(g,x,g(g,x)),f(f,1))".to_string()),
|
||||
] {
|
||||
sheet.cells.set(c(at), Cell::formula(Formula::new(&f)));
|
||||
}
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
["A1", "A2", "A3", "B1", "B2"].map(|at| wb.sheet(0).map(|s| s.value(c(at))).unwrap_or_default())
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("recursion must not overflow the stack")
|
||||
};
|
||||
let small = run(1 << 20);
|
||||
let num = Value::Error(CellError::Num);
|
||||
assert_eq!(small, [Value::Number(393.0), Value::Number(262.0), Value::Number(131.0), num.clone(), num]);
|
||||
assert_eq!(run(64 << 20), small);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_sheets() {
|
||||
let mut t = T::new();
|
||||
|
||||
@@ -160,28 +160,21 @@ fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<P
|
||||
segs.push((*a, *b));
|
||||
}
|
||||
if dash {
|
||||
// The per-segment dash cap bounds each input line, but a caller-supplied vector of many
|
||||
// long dashed lines multiplies: together they would eagerly allocate megabytes of
|
||||
// geometry even when nothing of it reaches the canvas, so the total is capped too and
|
||||
// the line is drawn solid once the budget is gone.
|
||||
let (on, off) = (width * 4.0, width * 3.0);
|
||||
let mut out = Vec::new();
|
||||
let mut budget = 65_536usize;
|
||||
for (a, b) in segs {
|
||||
if budget == 0 {
|
||||
return vec![vec![pts.to_vec()]];
|
||||
}
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
let len = (dx * dx + dy * dy).sqrt();
|
||||
if !(len.is_finite() && len > 0.0) {
|
||||
continue;
|
||||
}
|
||||
let mut t = 0.0;
|
||||
while t < len && budget > 0 {
|
||||
let mut n = 0;
|
||||
while t < len && n < 10_000 {
|
||||
let e = (t + on).min(len);
|
||||
out.push(([a[0] + dx * t / len, a[1] + dy * t / len], [a[0] + dx * e / len, a[1] + dy * e / len]));
|
||||
t += on + off;
|
||||
budget -= 1;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
segs = out;
|
||||
@@ -269,23 +262,3 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
||||
}
|
||||
cv.buf
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dashed_geometry_stays_within_the_dash_budget() {
|
||||
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
||||
// shared cap they eagerly allocated ~1.6 MB of segment pairs × points and a like
|
||||
// number of quads before any of it reached a 4-pixel canvas (finding: dash expansion).
|
||||
let prims: Vec<Prim> =
|
||||
(0..40).map(|i| Prim::Line { pts: vec![[0.0, i as f32], [900.0, i as f32]], color: [0, 0, 0, 255], width: 1.0, dash: true }).collect();
|
||||
let img = rasterize(&prims, 2, 2, [255, 255, 255, 255]);
|
||||
assert_eq!(img.len(), 2 * 2 * 4);
|
||||
// And the geometry itself is bounded even for one huge dashed polyline.
|
||||
assert!(stroke_paths(&[[0.0, 0.0], [1e6, 0.0]], 1.0, true, false).len() <= 65_536);
|
||||
let prim = [Prim::Line { pts: vec![[0.0, 0.0], [10.0, 0.0]], color: [0, 0, 0, 255], width: 1.0, dash: true }];
|
||||
assert_eq!(rasterize(&prim, 1, 1, [0, 0, 0, 255]).len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-31
@@ -425,7 +425,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
if !horizontal {
|
||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
||||
let y2labels = y2.map(ValAxis::labels);
|
||||
let y2labels = y2.map(|a| a.labels());
|
||||
let right = match (&y2labels, &cat) {
|
||||
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||
@@ -868,7 +868,6 @@ enum Fam {
|
||||
LineStack,
|
||||
Area,
|
||||
AreaStack,
|
||||
Area100,
|
||||
}
|
||||
|
||||
fn family(k: ChartKind) -> Fam {
|
||||
@@ -880,16 +879,10 @@ fn family(k: ChartKind) -> Fam {
|
||||
LineStacked => Fam::LineStack,
|
||||
Area => Fam::Area,
|
||||
AreaStacked => Fam::AreaStack,
|
||||
AreaStacked100 => Fam::Area100,
|
||||
_ => Fam::Col,
|
||||
}
|
||||
}
|
||||
|
||||
/// Families stacked to 100%: each category's values are shares of its total.
|
||||
fn is_100(f: Fam) -> bool {
|
||||
matches!(f, Fam::Col100 | Fam::Area100)
|
||||
}
|
||||
|
||||
fn is_col(f: Fam) -> bool {
|
||||
matches!(f, Fam::Col | Fam::ColStack | Fam::Col100)
|
||||
}
|
||||
@@ -907,38 +900,25 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
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();
|
||||
|
||||
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
|
||||
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
|
||||
// Totals for 100% stacking, per axis.
|
||||
let mut totals = [vec![0.0f64; ncat], vec![0.0f64; ncat]];
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
if is_100(fam) {
|
||||
let key = (sec.get(i).copied().unwrap_or(false), fam);
|
||||
let p = match totals.iter().position(|(k, _)| *k == key) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
totals.push((key, vec![0.0; ncat]));
|
||||
totals.len() - 1
|
||||
}
|
||||
};
|
||||
if fams.get(i) == Some(&Fam::Col100) {
|
||||
let a = sec.get(i).copied().unwrap_or(false) as usize;
|
||||
for (j, v) in s.values.iter().take(ncat).enumerate() {
|
||||
if let (Some(v), Some(t)) = (v, totals.get_mut(p).and_then(|(_, t)| t.get_mut(j))) {
|
||||
if let (Some(v), Some(t)) = (v, totals.get_mut(a).and_then(|t| t.get_mut(j))) {
|
||||
*t += v.abs();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A value's share of its category's total, for the 100% families.
|
||||
let share = |key: (bool, Fam), j: usize, v: f64| {
|
||||
let t = totals.iter().find(|(k, _)| *k == key).and_then(|(_, t)| t.get(j)).copied().unwrap_or(0.0);
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
};
|
||||
// Stack accumulators keyed by (axis, family): positive and negative running sums.
|
||||
let mut stacks: Vec<((bool, Fam), Vec<f64>, Vec<f64>)> = Vec::new();
|
||||
let mut spans: Vec<Vec<Option<(f64, f64)>>> = Vec::with_capacity(data.series.len());
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
let a = sec.get(i).copied().unwrap_or(false);
|
||||
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack | Fam::Area100);
|
||||
let stacked = matches!(fam, Fam::ColStack | Fam::Col100 | Fam::LineStack | Fam::AreaStack);
|
||||
let key = (a, fam);
|
||||
let si = if stacked {
|
||||
match stacks.iter().position(|(k, _, _)| *k == key) {
|
||||
@@ -956,7 +936,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
let v = s.values.get(j).copied().flatten();
|
||||
let span = match (si.and_then(|p| stacks.get_mut(p)), fam) {
|
||||
(Some((_, pos, neg)), Fam::ColStack | Fam::Col100) => v.and_then(|v| {
|
||||
let v = if fam == Fam::Col100 { share(key, j, v) } else { v };
|
||||
let v = if fam == Fam::Col100 {
|
||||
let t = totals.get(a as usize).and_then(|t| t.get(j)).copied().unwrap_or(0.0);
|
||||
if t > 0.0 { v / t } else { 0.0 }
|
||||
} else {
|
||||
v
|
||||
};
|
||||
let acc = if v >= 0.0 { pos.get_mut(j) } else { neg.get_mut(j) }?;
|
||||
let base = *acc;
|
||||
*acc += v;
|
||||
@@ -964,7 +949,6 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
}),
|
||||
(Some((_, pos, _)), _) => {
|
||||
let v = v.unwrap_or(0.0);
|
||||
let v = if fam == Fam::Area100 { share(key, j, v) } else { v };
|
||||
pos.get_mut(j).map(|acc| {
|
||||
let base = *acc;
|
||||
*acc = clean(*acc + v);
|
||||
@@ -986,7 +970,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
continue;
|
||||
}
|
||||
let force_zero = members.iter().any(|i| fams.get(*i).is_some_and(|f| !is_line(*f)));
|
||||
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
|
||||
let all100 = members.iter().all(|i| fams.get(*i) == Some(&Fam::Col100));
|
||||
let range = value_range(members.iter().flat_map(|i| {
|
||||
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
||||
@@ -1060,7 +1044,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
for (i, s) in data.series.iter().enumerate() {
|
||||
let fam = fams.get(i).copied().unwrap_or(Fam::Col);
|
||||
let want = match pass {
|
||||
0 => matches!(fam, Fam::Area | Fam::AreaStack | Fam::Area100),
|
||||
0 => matches!(fam, Fam::Area | Fam::AreaStack),
|
||||
1 => is_col(fam),
|
||||
_ => is_line(fam),
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
const ALL_KINDS: [ChartKind; 25] = [
|
||||
ChartKind::ColumnClustered,
|
||||
ChartKind::ColumnStacked,
|
||||
ChartKind::ColumnStacked100,
|
||||
@@ -19,7 +19,6 @@ const ALL_KINDS: [ChartKind; 26] = [
|
||||
ChartKind::Doughnut,
|
||||
ChartKind::Area,
|
||||
ChartKind::AreaStacked,
|
||||
ChartKind::AreaStacked100,
|
||||
ChartKind::Scatter,
|
||||
ChartKind::ScatterLines,
|
||||
ChartKind::Bubble,
|
||||
@@ -77,7 +76,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
||||
ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(ToString::to_string).collect(),
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
||||
b,
|
||||
@@ -1074,7 +1073,7 @@ fn pie_points_follow_workbook_theme() {
|
||||
|
||||
#[test]
|
||||
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked, ChartKind::AreaStacked100] {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
|
||||
let d = sample(kind);
|
||||
let mut c = chart(kind);
|
||||
c.data_labels = false;
|
||||
@@ -1098,45 +1097,6 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: ["Q1", "Q2", "Q3", "Q4"].iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("North", &[10.0, 30.0, 0.0, 5.0], [20, 96, 130, 255], ChartKind::AreaStacked100),
|
||||
series("South", &[30.0, 10.0, 0.0, 15.0], [25, 107, 36, 255], ChartKind::AreaStacked100),
|
||||
],
|
||||
};
|
||||
let mut c = chart(ChartKind::AreaStacked100);
|
||||
c.data_labels = false;
|
||||
let p = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let t = texts(&p);
|
||||
assert!(t.contains(&"100%") && t.contains(&"0%"), "{t:?}");
|
||||
assert!(!t.contains(&"40"), "{t:?}");
|
||||
let polys: Vec<&Vec<[f32; 2]>> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Polygon { pts, .. } => Some(pts),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let (Some(north), Some(south)) = (polys.first(), polys.get(1)) else { panic!("two areas expected: {polys:?}") };
|
||||
// Each polygon is its top edge left to right, then its base right to left.
|
||||
let top = |poly: &[[f32; 2]], j: usize| poly.get(j).map(|p| p[1]).unwrap_or(f32::NAN);
|
||||
let base = |poly: &[[f32; 2]], j: usize| poly.get(poly.len() - 1 - j).map(|p| p[1]).unwrap_or(f32::NAN);
|
||||
let (y0, y100) = (base(north, 0), top(south, 0));
|
||||
assert!(y0 > y100, "{y0} {y100}");
|
||||
let frac = |y: f32| (y0 - y) / (y0 - y100);
|
||||
for (j, share) in [(0, 0.25), (1, 0.75), (3, 0.25)] {
|
||||
assert!((frac(top(north, j)) - share).abs() < 1e-3, "Q{}: North reaches {}", j + 1, frac(top(north, j)));
|
||||
assert!((frac(top(south, j)) - 1.0).abs() < 1e-3, "Q{}: South reaches {}", j + 1, frac(top(south, j)));
|
||||
}
|
||||
// A category whose total is zero stays on the baseline.
|
||||
assert!(frac(top(south, 2)).abs() < 1e-3, "Q3: South reaches {}", frac(top(south, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_level_categories_resolve_and_draw() {
|
||||
let years = ["2023", "", "", "2024", "", ""];
|
||||
|
||||
+1
-16
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
|
||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||
chars.next();
|
||||
letters += 1;
|
||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
@@ -350,21 +350,6 @@ mod tests {
|
||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||
}
|
||||
|
||||
/// #2312: device/backslash garbage in front of `!` is cosmetic at worst — `split_sheet` only
|
||||
/// splits at `!`, and every consumer matches the sheet part against workbook sheet names (it
|
||||
/// never reaches a filesystem path or a shell); the body half always stays a pure cell/range
|
||||
/// reference.
|
||||
#[test]
|
||||
fn split_sheet_leaves_device_names_inert_in_the_body() {
|
||||
for s in ["C:\\a\\file!A1", "CON!A1", "NUL!A1", "a[b]!A1"] {
|
||||
let (sheet, rest) = split_sheet(s);
|
||||
assert_eq!(CellRef::parse(rest), Some(CellRef::new(0, 0)), "{s:?}: body must stay a bare reference");
|
||||
let _ = sheet;
|
||||
}
|
||||
// A plain single-letter sheet is still split, not mistaken for a body.
|
||||
assert_eq!(split_sheet("C!A1").0, Some("C".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
|
||||
@@ -174,9 +174,6 @@ pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
|
||||
if frac_ms >= 86_400_000 {
|
||||
frac_ms -= 86_400_000;
|
||||
day += 1;
|
||||
if day > max_serial(sys) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (y, m, d, weekday) = match sys {
|
||||
DateSystem::D1900 => {
|
||||
@@ -224,14 +221,6 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serial_rounding_into_next_day_past_max_is_none() {
|
||||
for sys in [DateSystem::D1900, DateSystem::D1904] {
|
||||
let serial = max_serial(sys) as f64 + 0.9999999995;
|
||||
assert_eq!(datetime_from_serial(sys, serial), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
|
||||
@@ -371,16 +371,10 @@ fn parse_datetime(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>
|
||||
if let Some((d, f)) = parse_date(t, sys) {
|
||||
return Some((d, Some(f)));
|
||||
}
|
||||
// date + time: split at the last space(s) before a time-looking tail. Only splits where the
|
||||
// tail could be a time are tried: a long tail can't parse, so this stays linear in the text
|
||||
// instead of reparsing every prefix for every space (one long text field quadratic).
|
||||
// date + time: split at the last space(s) before a time-looking tail.
|
||||
for (i, _) in t.match_indices(' ') {
|
||||
let Some(b) = t.get(i + 1..) else { continue };
|
||||
let bytes = b.as_bytes();
|
||||
if b.len() > 36 || !bytes.first().is_some_and(u8::is_ascii_digit) || !b.contains(':') {
|
||||
continue;
|
||||
}
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (t.get(..i).and_then(|a| parse_date(a, sys)), parse_time(b)) {
|
||||
let (a, b) = (t.get(..i)?, t.get(i + 1..)?);
|
||||
if let (Some((d, _)), Some((tf, _, _))) = (parse_date(a, sys), parse_time(b)) {
|
||||
return Some((d + tf, Some("m/d/yyyy h:mm")));
|
||||
}
|
||||
}
|
||||
@@ -406,20 +400,6 @@ mod tests {
|
||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlong_time_fields_stay_text() {
|
||||
// #2168: all-digit field lengths are gated before allocation/derivation,
|
||||
// so hostile strings never spin the time-derivation machinery.
|
||||
let big = "9".repeat(500);
|
||||
for s in [format!("{big}:00"), format!("1:00:{big}"), format!("{big}:{big}:{big}"), format!("1:0{big}"), format!("00:00:{big}.1")] {
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
||||
}
|
||||
// Within the gate, times still work.
|
||||
assert_eq!(p("01:02:03").value, Value::number(3.0 / 86_400.0 + 1.0 / 24.0 + 2.0 / 1440.0));
|
||||
assert_eq!(parse_number_text("13:14:15"), Some(0.5515625));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Value {
|
||||
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
|
||||
Value::Text(t) => Ok(t.to_string()),
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
|
||||
}
|
||||
}
|
||||
/// Coercion to a boolean as `IF` does it.
|
||||
@@ -264,7 +264,7 @@ impl Value {
|
||||
}
|
||||
}
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
|
||||
}
|
||||
}
|
||||
/// Display text with General formatting (for logs, CSV and tests).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
||||
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
|
||||
use gridcraft_model::*;
|
||||
|
||||
/// The conditional look of one cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -267,12 +267,6 @@ fn attr<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Entities are at most `&` + 8 chars + `;` (a decimal code point at most 7 digits deep), so
|
||||
/// the semicolon is only looked for in the first 12 bytes — and `unwrap_or(rest)` never falls
|
||||
/// back to scanning the remainder of the text either (that turn re-scans per `&`, quadratic
|
||||
/// when a paste holds many bare `&`).
|
||||
const ENTITY_WINDOW: usize = 12;
|
||||
|
||||
/// HTML text to plain text: entities decoded, whitespace runs (and `<br>`) collapsed to one
|
||||
/// space, trimmed. The text only serves to match a cell with the tab-separated text.
|
||||
fn decode(raw: &str) -> String {
|
||||
@@ -281,9 +275,7 @@ fn decode(raw: &str) -> String {
|
||||
while let Some(amp) = rest.find('&') {
|
||||
out.push_str(rest.get(..amp).unwrap_or_default());
|
||||
rest = rest.get(amp..).unwrap_or_default();
|
||||
// `.min(len)` (not an `unwrap_or(rest)`) — a `get(..12)` that fails on a multibyte
|
||||
// boundary used to fall back to the complete suffix, re-scanning per '&'.
|
||||
let semi = rest.get(..ENTITY_WINDOW.min(rest.len())).and_then(|w| w.find(';'));
|
||||
let semi = rest.get(..12).unwrap_or(rest).find(';');
|
||||
let entity = semi.and_then(|e| rest.get(1..e));
|
||||
let ch = entity.and_then(|e| match e {
|
||||
"amp" => Some('&'),
|
||||
@@ -390,19 +382,6 @@ mod tests {
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entities_recognized_only_within_the_twelve_byte_window() {
|
||||
// The window never scans the rest of the text for a semicolon: a bare '&' followed by
|
||||
// long plain text stays a bare '&', and one '&' after another is linear.
|
||||
assert_eq!(decode("a&lifier; b"), "a&lifier; b");
|
||||
assert_eq!(decode("&ampampampAMP;"), "&ampampampAMP;");
|
||||
// Inside the window the usual forms still decode, and a trailing bare '&' too.
|
||||
assert_eq!(decode("&"), "&");
|
||||
assert_eq!(decode("♪"), "\u{266A}");
|
||||
assert_eq!(decode("a&b c"), "a&b\u{a0}c");
|
||||
assert_eq!(decode("amp&"), "amp&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_markup_does_not_panic() {
|
||||
for html in [
|
||||
|
||||
@@ -1182,7 +1182,7 @@ mod tests {
|
||||
fn v(s: &Session, a: &str) -> Value {
|
||||
let d = s.doc().unwrap();
|
||||
let (sh, body) = crate::cmd::split_sheet(a);
|
||||
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
|
||||
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
|
||||
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
|
||||
}
|
||||
fn n(s: &Session, a: &str) -> f64 {
|
||||
|
||||
+17
-178
@@ -106,15 +106,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
show_levels
|
||||
),
|
||||
cmd!(
|
||||
"data.outlineSettings",
|
||||
"Outline Settings",
|
||||
["Data", "Outline"],
|
||||
None,
|
||||
"{sheet?, summaryBelow?: bool, summaryRight?: bool} (summary rows below their detail, summary columns right of it; off puts them above / left) → {summaryBelow, summaryRight}",
|
||||
has_doc,
|
||||
outline_settings
|
||||
),
|
||||
cmd!(
|
||||
"data.subtotal",
|
||||
"Subtotal",
|
||||
@@ -167,7 +158,7 @@ fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
{
|
||||
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
@@ -219,7 +210,7 @@ fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
|
||||
{
|
||||
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet() && p.get("range").is_none() {
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
@@ -511,7 +502,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.filter")?;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
|
||||
let at = if sheet == s.doc()?.wb.active_sheet() { s.doc()?.selection.active } else { r.start };
|
||||
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
|
||||
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
|
||||
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
|
||||
if let Some(name) = in_table {
|
||||
@@ -563,7 +554,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let af = match &sh.autofilter {
|
||||
@@ -768,7 +759,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
}
|
||||
|
||||
fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(a) = sh.autofilter.as_mut() {
|
||||
@@ -781,7 +772,7 @@ fn clear_filter(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn reapply(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
apply_filter(&mut cx.wb, sheet);
|
||||
cx.structural = true;
|
||||
@@ -1285,7 +1276,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let input = str_param(p, "input").unwrap_or("");
|
||||
match check_validation(&d.wb, d.wb.active_sheet(), at, input) {
|
||||
match check_validation(&d.wb, d.wb.active_sheet, at, input) {
|
||||
None => Ok(json!({"ok": true})),
|
||||
Some((dv, msg)) => Ok(json!({"ok": false, "message": msg, "title": dv.error_title, "style": format!("{:?}", dv.error_style)})),
|
||||
}
|
||||
@@ -1293,7 +1284,7 @@ fn validate_cmd(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let mut bad_cells = Vec::new();
|
||||
for dv in &sh.validations {
|
||||
@@ -1332,13 +1323,12 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
if si != s.doc()?.wb.active_sheet() && p.get("rows").is_none() && p.get("cols").is_none() {
|
||||
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let b = b.min(a.saturating_add(1_048_575));
|
||||
for i in a..=b {
|
||||
@@ -1349,10 +1339,9 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
// A summary line whose group is gone has nothing left to collapse.
|
||||
let summary = if before { a.checked_sub(1) } else { b.checked_add(1) };
|
||||
if !on
|
||||
&& !outline::groups_with(map, before).iter().any(|g| if before { g.start == a } else { g.end == b })
|
||||
&& let Some(e) = summary.and_then(|i| map.get_mut(&i))
|
||||
&& !outline::groups(map).iter().any(|g| g.end == b)
|
||||
&& let Some(e) = map.get_mut(&b.saturating_add(1))
|
||||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
@@ -1370,9 +1359,8 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = outline::summary_before(sh, rows);
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let all = outline::groups_with(map, before);
|
||||
let all = outline::groups(map);
|
||||
let level_ok = |g: &outline::OutlineGroup| want.is_none_or(|l| g.level == l);
|
||||
let pick = |hit: &dyn Fn(&outline::OutlineGroup) -> bool| -> Vec<outline::OutlineGroup> {
|
||||
let hits: Vec<_> = all.iter().filter(|g| level_ok(g) && hit(g)).copied().collect();
|
||||
@@ -1381,13 +1369,13 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
};
|
||||
let mut chosen = pick(&|g| g.start <= b && g.end >= a);
|
||||
if chosen.is_empty() {
|
||||
chosen = pick(&|g| g.has_summary() && g.summary() >= a && g.summary() <= b);
|
||||
chosen = pick(&|g| g.summary() >= a && g.summary() <= b);
|
||||
}
|
||||
for g in chosen {
|
||||
// Inner groups collapsed on their own (their summary marked), before this one changes.
|
||||
let inner: Vec<outline::OutlineGroup> = all
|
||||
.iter()
|
||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.has_summary() && o.summary() != g.summary())
|
||||
.filter(|o| o.level > g.level && o.start >= g.start && o.end <= g.end && o.summary() != g.summary())
|
||||
.filter(|o| map.get(&o.summary()).is_some_and(|e| e.collapsed))
|
||||
.copied()
|
||||
.collect();
|
||||
@@ -1396,9 +1384,6 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
||||
}
|
||||
}
|
||||
if !g.has_summary() {
|
||||
continue;
|
||||
}
|
||||
if hide {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
} else if let Some(e) = map.get_mut(&g.summary()) {
|
||||
@@ -1434,16 +1419,15 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let before = (outline::summary_before(sh, true), outline::summary_before(sh, false));
|
||||
for (map, n, before) in [(&mut sh.rows, rows, before.0), (&mut sh.cols, cols, before.1)] {
|
||||
for (map, n) in [(&mut sh.rows, rows), (&mut sh.cols, cols)] {
|
||||
let Some(n) = n else { continue };
|
||||
let groups = outline::groups_with(map, before);
|
||||
let groups = outline::groups(map);
|
||||
for e in map.values_mut().filter(|e| e.outline > 0) {
|
||||
set_hidden(e, e.outline >= n);
|
||||
}
|
||||
// Inner groups come first, so a summary line shared with the group around it ends up
|
||||
// as that outer group says.
|
||||
for g in groups.into_iter().filter(|g| g.has_summary()) {
|
||||
for g in groups {
|
||||
map.entry(g.summary()).or_default().collapsed = g.level >= n;
|
||||
}
|
||||
}
|
||||
@@ -1452,57 +1436,6 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Data ▸ Outline ▸ Settings: whether summary rows sit below their detail and summary columns
|
||||
/// right of it. A changed direction moves each collapsed group's mark (+) to its new summary line.
|
||||
/// Moves the collapsed marks of a line axis to the summary line on its new side (`on` is the new
|
||||
/// setting, `was_before` the old one). A line that nested groups share keeps its mark on the outermost
|
||||
/// group whose detail is all hidden, or the outermost when none is.
|
||||
fn move_marks(map: &mut std::collections::BTreeMap<u32, LineInfo>, was_before: bool, on: bool) {
|
||||
let mut marked: Vec<outline::OutlineGroup> =
|
||||
outline::groups_with(map, was_before).into_iter().filter(|g| g.has_summary() && map.get(&g.summary()).is_some_and(|e| e.collapsed)).collect();
|
||||
// A mark on a line that nested groups share moves with the outermost of them whose
|
||||
// detail is all hidden, or with the outermost when none is.
|
||||
let mut owner: std::collections::BTreeMap<u32, (bool, u8)> = std::collections::BTreeMap::new();
|
||||
for g in &marked {
|
||||
let key = (!g.collapsed, g.level);
|
||||
let best = owner.entry(g.summary()).or_insert(key);
|
||||
*best = (*best).min(key);
|
||||
}
|
||||
marked.retain(|g| owner.get(&g.summary()) == Some(&(!g.collapsed, g.level)));
|
||||
for g in &marked {
|
||||
if let Some(e) = map.get_mut(&g.summary()) {
|
||||
e.collapsed = false;
|
||||
}
|
||||
}
|
||||
for g in marked {
|
||||
let g = outline::OutlineGroup { summary_before: !on, ..g };
|
||||
if g.has_summary() {
|
||||
map.entry(g.summary()).or_default().collapsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn outline_settings(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (rows, key) in [(true, "summaryBelow"), (false, "summaryRight")] {
|
||||
let Some(on) = bool_param(p, key) else { continue };
|
||||
let was_before = outline::summary_before(sh, rows);
|
||||
if rows {
|
||||
sh.summary_below = on;
|
||||
} else {
|
||||
sh.summary_right = on;
|
||||
}
|
||||
if was_before != on {
|
||||
continue;
|
||||
}
|
||||
move_marks(if rows { &mut sh.rows } else { &mut sh.cols }, was_before, on);
|
||||
}
|
||||
Ok(json!({"summaryBelow": sh.summary_below, "summaryRight": sh.summary_right}))
|
||||
})
|
||||
}
|
||||
|
||||
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||
let header = bool_param(p, "header").unwrap_or(true);
|
||||
@@ -1543,11 +1476,6 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
start = row;
|
||||
}
|
||||
}
|
||||
// The totals go below each group, so the outline puts summary rows below their detail (the
|
||||
// Subtotal dialog's "Summary below data"), in the same undo step.
|
||||
if !sh.summary_below {
|
||||
s.execute("data.outlineSettings", json!({"sheet": sheet, "summaryBelow": true}))?;
|
||||
}
|
||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||
let mut inserted = 0;
|
||||
for (g0, g1, name) in groups.iter().rev() {
|
||||
@@ -1612,28 +1540,6 @@ mod tests {
|
||||
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_puts_summary_rows_below_their_detail() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 10], ["East", 20], ["West", 30]]})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "columns": ["B"]})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert!(sh.summary_below, "the totals sit below their groups");
|
||||
// East's detail is rows 2–3 and its total row 4: collapsing level 1 keeps the total showing.
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
let hidden = |s: &Session, row: u32| s.doc().unwrap().wb.active().unwrap().is_row_hidden(row);
|
||||
assert!(hidden(&s, 1) && hidden(&s, 2), "East's detail rows are hidden");
|
||||
assert!(!hidden(&s, 3), "East Total stays visible");
|
||||
// One undo takes the subtotals and the setting back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.summary_below);
|
||||
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
@@ -1865,73 +1771,6 @@ mod tests {
|
||||
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_rows_above_the_detail_take_the_collapsed_mark() {
|
||||
let mut s = nested_rows();
|
||||
let r = s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(r, json!({"summaryBelow": false, "summaryRight": true}));
|
||||
// Row 4 (index 3) is the outer group's summary, row 5 the inner one's.
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, false), (true, false), (true, false), (true, false), (false, false)]);
|
||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
||||
// Hide Detail on the summary row collapses the group below it.
|
||||
s.execute("data.hideDetail", json!({"rows": "4:4"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=4), [(false, true), (true, false)]);
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, true), (true, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
// Ungrouping the outer group clears its mark on the row above.
|
||||
s.execute("data.showLevels", json!({"rows": 3})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.ungroup", json!({"rows": "5:8"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=3), [(false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_summary_direction_moves_collapsed_marks() {
|
||||
let mut s = nested_rows();
|
||||
s.execute("data.group", json!({"cols": "C:E"})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"cols": "C:E"})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false, "summaryRight": false})).unwrap();
|
||||
let col_marked = |s: &Session, c: u32| s.doc().unwrap().wb.active().unwrap().cols.get(&c).is_some_and(|l| l.collapsed);
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
||||
assert!(col_marked(&s, 1) && !col_marked(&s, 5));
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [false, false, false, false, false, true]);
|
||||
assert!(col_marked(&s, 1), "columns keep their direction");
|
||||
// One undo step per change; no params leaves the settings as they are.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(row_state(&s, 3..=3)[0].1);
|
||||
assert_eq!(s.execute("data.outlineSettings", json!({})).unwrap(), json!({"summaryBelow": false, "summaryRight": false}));
|
||||
// Nested groups that end on one row share its mark: it moves to the outer group only.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("data.group", json!({"rows": "5:8"})).unwrap();
|
||||
s.execute("data.group", json!({"rows": "6:8"})).unwrap();
|
||||
s.execute("data.showLevels", json!({"rows": 1})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8).iter().map(|r| r.1).collect::<Vec<_>>(), [true, false, false, false, false, false]);
|
||||
s.execute("data.showDetail", json!({"rows": "4:4"})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false); 6]);
|
||||
// With only the inner group collapsed, its mark goes to row 5, and the outer group's
|
||||
// Hide and Show Detail keep it collapsed.
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": true})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "6:8", "level": 2})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
s.execute("data.hideDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
s.execute("data.showDetail", json!({"rows": "5:8", "level": 1})).unwrap();
|
||||
assert_eq!(row_state(&s, 3..=8), [(false, false), (false, true), (true, false), (true, false), (true, false), (false, false)]);
|
||||
// A group on the first row has no row above it to mark.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("data.group", json!({"rows": "1:3"})).unwrap();
|
||||
s.execute("data.outlineSettings", json!({"summaryBelow": false})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "1:3"})).unwrap();
|
||||
assert_eq!(row_state(&s, 0..=3), [(true, false), (true, false), (true, false), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_sort_rejects_row_key_zero_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -72,7 +72,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
str_param(p, "color").map(str::to_string).unwrap_or_else(|| if s.draw_color.is_empty() { "#1F5FC9".into() } else { s.draw_color.clone() });
|
||||
let width = f64_param(p, "width").map(|w| w as f32).unwrap_or(if s.draw_width <= 0.0 { 2.0 } else { s.draw_width }).clamp(0.5, 40.0);
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let si = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let cell = CellRef::new(sh.row_at(miny), sh.col_at(minx));
|
||||
let dx = (minx - sh.col_left(cell.col)) as f32;
|
||||
@@ -115,7 +115,7 @@ pub fn ink_points(sh: &Shape) -> Vec<[f32; 2]> {
|
||||
}
|
||||
|
||||
fn ink_to_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
let si = s.doc()?.wb.active_sheet;
|
||||
let id = u32_param(p, "id");
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
|
||||
+31
-284
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "selection.currentRegion", "Select Current Region", [], Some("Ctrl+Shift+8"), "{}", has_doc, select_region),
|
||||
cmd!(noundo "selection.row", "Select Entire Row", [], Some("Shift+Space"), "{}", has_doc, select_row),
|
||||
cmd!(noundo "selection.column", "Select Entire Column", [], Some("Ctrl+Space"), "{}", has_doc, select_col),
|
||||
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name | \"Sheet2!Name\" | \"B14:C22,F19:G30\" | \"Price, Type\", record?: bool} (record: keep a reference without names among the recent ones) or {list: true} → {names, recent}", has_doc, go_to),
|
||||
cmd!(noundo "edit.goTo", "Go To…", ["Home", "Editing", "Find & Select"], Some("Ctrl+G"), "{reference: \"B5\" | \"Sheet2!A1:C3\" | name}", has_doc, go_to),
|
||||
cmd!(noundo "edit.goToSpecial", "Go To Special…", ["Home", "Editing", "Find & Select"], None, "{kind: blanks|constants|formulas|comments|lastCell|currentRegion|visible|conditionalFormats|dataValidation|errors|numbers|text}", has_doc, go_to_special),
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{steps?: 1}", can_undo, undo),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Y"), "{steps?: 1}", can_redo, redo),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend|days|weekdays|months|years}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up; days, weekdays, months, years = Fill Days, Fill Weekdays, Fill Months, Fill Years: dates step by one unit, or by the difference between the first two dates in that unit, and weekdays skip Saturday and Sunday)",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -237,7 +237,7 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other(m));
|
||||
}
|
||||
let active_sheet = s.doc()?.wb.active_sheet();
|
||||
let active_sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
if array {
|
||||
// Legacy Ctrl+Shift+Enter array formula over the selection, when the selection holds
|
||||
@@ -438,18 +438,23 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
impl crate::DocState {
|
||||
/// Switches the active sheet, remembering each sheet's selection.
|
||||
pub fn wb_switch_sheet(&mut self, i: usize) {
|
||||
if i >= self.wb.sheets.len() || i == self.wb.active_sheet() {
|
||||
if i >= self.wb.sheets.len() || i == self.wb.active_sheet {
|
||||
return;
|
||||
}
|
||||
let cur = self.wb.active_sheet();
|
||||
let cur = self.wb.active_sheet;
|
||||
if self.sheet_selections.len() < self.wb.sheets.len() {
|
||||
self.sheet_selections.resize(self.wb.sheets.len(), crate::Selection::default());
|
||||
}
|
||||
if let Some(slot) = self.sheet_selections.get_mut(cur) {
|
||||
*slot = self.selection.clone();
|
||||
}
|
||||
// View state: set in place, so the workbook is neither copied nor made dirty.
|
||||
self.wb.set_active_sheet(i);
|
||||
// The active sheet is view state: change it without making the workbook dirty.
|
||||
let dirty = self.is_dirty();
|
||||
let wb = std::sync::Arc::make_mut(&mut self.wb);
|
||||
wb.active_sheet = i;
|
||||
if !dirty {
|
||||
self.saved = self.wb.clone();
|
||||
}
|
||||
self.selection = self.sheet_selections.get(i).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
@@ -572,154 +577,26 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(json!({"selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
/// How many references the Go To dialog keeps for going back to.
|
||||
const GO_TO_RECENT: usize = 10;
|
||||
/// The most areas one Go To selects.
|
||||
const GO_TO_MAX_AREAS: usize = 2000;
|
||||
/// How deep a name that refers to other names is followed.
|
||||
const GO_TO_NAME_DEPTH: usize = 8;
|
||||
|
||||
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if bool_param(p, "list") == Some(true) {
|
||||
let d = s.doc()?;
|
||||
return Ok(json!({"names": go_to_names(&d.wb, d.wb.active_sheet()), "recent": d.go_to_recent}));
|
||||
}
|
||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||
// A defined name or table?
|
||||
let d = s.doc()?;
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas: Vec<RangeRef> = Vec::new();
|
||||
let mut named = false;
|
||||
for part in reference_parts(reference) {
|
||||
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
};
|
||||
// One selection holds the areas of one sheet.
|
||||
if target.is_some_and(|t| t != sheet) {
|
||||
let resolved = if RangeRef::parse(split_sheet(reference).1).is_none() {
|
||||
let sheet = d.wb.active_sheet;
|
||||
if let Some(n) = d.wb.name(reference, sheet) {
|
||||
n.formula.clone()
|
||||
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
|
||||
} else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
}
|
||||
target = Some(sheet);
|
||||
named |= by_name;
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return Err(EngineError::Other(format!("Go To selects at most {GO_TO_MAX_AREAS} areas.")));
|
||||
}
|
||||
}
|
||||
let Some(sheet) = target else { return Err(EngineError::Other("Reference isn't valid.".into())) };
|
||||
let range = areas.iter().map(RangeRef::a1).collect::<Vec<_>>().join(",");
|
||||
let out = selection_set(s, &json!({"sheet": sheet, "range": range}))?;
|
||||
if bool_param(p, "record") == Some(true) && !named {
|
||||
let d = s.doc_mut()?;
|
||||
let prefix = gridcraft_core::quote_sheet(&d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default());
|
||||
let entry = areas.iter().map(|r| format!("{prefix}!{}", r.a1())).collect::<Vec<_>>().join(",");
|
||||
d.go_to_recent.retain(|r| !r.eq_ignore_ascii_case(&entry));
|
||||
d.go_to_recent.insert(0, entry);
|
||||
d.go_to_recent.truncate(GO_TO_RECENT);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The parts of a Go To reference, split at the commas outside quoted sheet names and
|
||||
/// parentheses: `Price, Type` or `B14:C22,F19:G30`. The parts are read one at a time, so a caller
|
||||
/// that stops at an invalid part or at `GO_TO_MAX_AREAS` does not split the rest of the text.
|
||||
fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
|
||||
let (mut depth, mut quoted, mut start, mut done) = (0usize, false, 0, false);
|
||||
let mut chars = text.char_indices();
|
||||
std::iter::from_fn(move || {
|
||||
if done {
|
||||
return None;
|
||||
}
|
||||
for (i, c) in chars.by_ref() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
'(' if !quoted => depth += 1,
|
||||
')' if !quoted => depth = depth.saturating_sub(1),
|
||||
',' if !quoted && depth == 0 => {
|
||||
let part = text.get(start..i).unwrap_or("").trim();
|
||||
start = i + 1;
|
||||
return Some(part);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
done = true;
|
||||
Some(text.get(start..).unwrap_or("").trim())
|
||||
})
|
||||
}
|
||||
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
|
||||
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
|
||||
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
|
||||
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
} else {
|
||||
reference.to_string()
|
||||
};
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
return Some((on, vec![r], false));
|
||||
}
|
||||
let name = match prefix {
|
||||
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
|
||||
None => wb.name(body, sheet),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
|
||||
/// reference.
|
||||
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas = Vec::new();
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
target = Some(s);
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
target.map(|t| (t, areas))
|
||||
}
|
||||
|
||||
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
|
||||
/// of every sheet, in alphabetical order. A name scoped to another sheet is written `Sheet2!Name`,
|
||||
/// and the active sheet's own name hides the workbook name it shares. Hidden names and names that
|
||||
/// do not refer to a range are left out.
|
||||
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
|
||||
let own: std::collections::HashSet<String> =
|
||||
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut out: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
|
||||
.filter_map(|n| match n.scope {
|
||||
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
|
||||
None => Some(n.name.clone()),
|
||||
Some(s) if s == sheet => Some(n.name.clone()),
|
||||
Some(s) => Some(format!("{}!{}", gridcraft_core::quote_sheet(&wb.sheet(s)?.name), n.name)),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_cached_key(|n| gridcraft_formula::fold_name(n));
|
||||
out.dedup_by(|a, b| gridcraft_formula::same_name(a, b));
|
||||
out
|
||||
let resolved = resolved.replace('$', "");
|
||||
selection_set(s, &json!({"range": resolved}))
|
||||
}
|
||||
|
||||
fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -1585,10 +1462,6 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
"days" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Day),
|
||||
"weekdays" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Weekday),
|
||||
"months" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Month),
|
||||
"years" => crate::fill::FillMode::Dates(crate::fill::DateUnit::Year),
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||
@@ -1696,7 +1569,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
s.last_find = Some(p.clone());
|
||||
let d = s.doc()?;
|
||||
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet()] };
|
||||
let sheets: Vec<usize> = if workbook { (0..d.wb.sheets.len()).collect() } else { vec![d.wb.active_sheet] };
|
||||
let mut hits: Vec<(usize, CellRef, String)> = Vec::new();
|
||||
for si in sheets {
|
||||
let Some(sh) = d.wb.sheet(si) else { continue };
|
||||
@@ -1726,7 +1599,7 @@ fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Ok(json!({"count": list.len(), "results": list}));
|
||||
}
|
||||
// Next hit after the active cell.
|
||||
let cur = (d.wb.active_sheet(), d.selection.active);
|
||||
let cur = (d.wb.active_sheet, d.selection.active);
|
||||
let key = |si: usize, c: CellRef| if by_cols { (si, c.col, c.row) } else { (si, c.row, c.col) };
|
||||
let next = hits.iter().find(|(si, c, _)| key(*si, *c) > key(cur.0, cur.1)).or_else(|| hits.first()).cloned();
|
||||
let Some((si, c, t)) = next else { return Err(EngineError::Other("not found".into())) };
|
||||
@@ -1748,7 +1621,7 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let active = s.doc()?.selection.active;
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet] };
|
||||
let mut n = 0;
|
||||
for si in sheets {
|
||||
let cells: Vec<(CellRef, String)> =
|
||||
@@ -1820,7 +1693,7 @@ fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(dest) = str_param(p, "apply").and_then(RangeRef::parse) {
|
||||
let Some((wb, sheet, src, sticky)) = s.format_painter.clone() else { return Err(EngineError::Other("Pick up a format first.".into())) };
|
||||
let dest_sheet = s.doc()?.wb.active_sheet();
|
||||
let dest_sheet = s.doc()?.wb.active_sheet;
|
||||
let same = std::sync::Arc::ptr_eq(&wb, &s.doc()?.wb) || s.doc()?.wb.styles == wb.styles;
|
||||
edit(s, |cx| {
|
||||
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
|
||||
@@ -1900,7 +1773,7 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let sticky = bool_param(p, "sticky").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet(), d.selection.current(), sticky));
|
||||
s.format_painter = Some((d.wb.clone(), d.wb.active_sheet, d.selection.current(), sticky));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -1913,132 +1786,6 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Sheet1 and Sheet2 with a workbook range name, a name scoped to each sheet, a constant, a
|
||||
/// formula and a hidden name.
|
||||
fn go_to_workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Sheet2"})).unwrap();
|
||||
s.execute("selection.set", json!({"sheet": 0})).unwrap();
|
||||
let define = |s: &mut Session, name: &str, refers: &str, scope: &str| {
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
};
|
||||
define(&mut s, "Price", "=Sheet1!$B$2:$B$5", "Workbook");
|
||||
define(&mut s, "type", "=Sheet1!$D$2:$D$5", "Workbook");
|
||||
define(&mut s, "Areas", "=Sheet1!$A$1,Sheet1!$C$3:$C$4", "Workbook");
|
||||
define(&mut s, "Local", "=Sheet2!$C$3", "Sheet2");
|
||||
define(&mut s, "Own", "=Sheet1!$E$1", "Sheet1");
|
||||
define(&mut s, "Rate", "=0.07", "Workbook");
|
||||
define(&mut s, "Total", "=SUM(Sheet1!$B$2:$B$5)", "Workbook");
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Secret".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$Z$9".into(),
|
||||
comment: String::new(),
|
||||
hidden: true,
|
||||
});
|
||||
s
|
||||
}
|
||||
|
||||
/// "The Go to popup window shows named ranges on every worksheet in your workbook." Names
|
||||
/// that are not ranges, and hidden names, are not listed.
|
||||
#[test]
|
||||
fn go_to_lists_the_named_ranges_of_every_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Own", "Price", "Sheet2!Local", "type"]), "{list}");
|
||||
assert_eq!(list["recent"], json!([]), "{list}");
|
||||
// Seen from Sheet2, its own name is bare and Sheet1's is qualified.
|
||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!Local"})).unwrap();
|
||||
assert_eq!(out["selection"], "C3", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
|
||||
// A name of several areas selects all of them.
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Areas"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,C3:C4", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
}
|
||||
|
||||
/// A name matches in any case and any script, as a formula matches it.
|
||||
#[test]
|
||||
fn go_to_matches_a_sheet_name_in_any_case() {
|
||||
let mut s = go_to_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": "Größe", "refersTo": "=Sheet2!$D$4", "scope": "Sheet2"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "GRÖSSE", "refersTo": "=Sheet1!$F$6", "scope": "Workbook"})).unwrap();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!GRÖSSE"})).unwrap();
|
||||
assert_eq!(out["selection"], "D4", "{out}");
|
||||
// Seen from Sheet2, its own name hides the workbook name it shares.
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["names"], json!(["Areas", "Größe", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
/// "You can enter multiple named ranges or cell references in the Reference box. Separating
|
||||
/// each with a comma ... Excel will highlight all the ranges."
|
||||
#[test]
|
||||
fn go_to_selects_every_reference_and_name_in_a_list() {
|
||||
let mut s = go_to_workbook();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30,H21:H29"})).unwrap();
|
||||
assert_eq!(out["selection"], "B14:C22,F19:G30,H21:H29", "{out}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Price, Type"})).unwrap();
|
||||
assert_eq!(out["selection"], "B2:B5,D2:D5", "{out}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet2!$A$1,Sheet2!B2"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,B2", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 1);
|
||||
// Areas on two sheets, and a part that is not a reference, leave the selection alone.
|
||||
for bad in ["A1,Sheet1!B2", "A1,Rate", "A1,", "Total"] {
|
||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "A1,B2", "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_reads_the_parts_of_a_long_reference_one_at_a_time() {
|
||||
let mut parts = reference_parts("A1, 'Q1, Q2'!B2,SUM(C3,D4)");
|
||||
assert_eq!(parts.next(), Some("A1"));
|
||||
assert_eq!(parts.next(), Some("'Q1, Q2'!B2"));
|
||||
assert_eq!(parts.next(), Some("SUM(C3,D4)"));
|
||||
assert_eq!(parts.next(), None);
|
||||
assert_eq!(reference_parts("").collect::<Vec<_>>(), vec![""]);
|
||||
let mut s = go_to_workbook();
|
||||
let err = s.execute("edit.goTo", json!({"reference": "A1,".repeat(GO_TO_MAX_AREAS + 1)})).unwrap_err();
|
||||
assert!(err.to_string().contains("at most"), "{err}");
|
||||
let bad = format!("Total{}", ",".repeat(1_000_000));
|
||||
assert!(s.execute("edit.goTo", json!({"reference": bad})).is_err());
|
||||
}
|
||||
|
||||
/// "The Go to box keeps track of ranges as you enter them, and you can return to any of them."
|
||||
#[test]
|
||||
fn go_to_keeps_the_references_entered_newest_first() {
|
||||
let mut s = go_to_workbook();
|
||||
s.execute("edit.goTo", json!({"reference": "B14:C22,F19:G30", "record": true})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "Sheet2!A5", "record": true})).unwrap();
|
||||
// Names are listed already; a call without `record` (the Name Box) is not kept.
|
||||
s.execute("edit.goTo", json!({"reference": "Price", "record": true})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "Z9"})).unwrap();
|
||||
s.execute("edit.goTo", json!({"reference": "b14:c22,f19:g30", "record": true})).unwrap();
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["recent"], json!(["Sheet1!B14:C22,Sheet1!F19:G30", "Sheet2!A5"]), "{list}");
|
||||
// Going back from Sheet2 to a recent reference returns to its sheet.
|
||||
s.execute("selection.set", json!({"sheet": 1})).unwrap();
|
||||
let out = s.execute("edit.goTo", json!({"reference": "Sheet1!B14:C22,Sheet1!F19:G30", "record": true})).unwrap();
|
||||
assert_eq!(out["selection"], "B14:C22,F19:G30", "{out}");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
for i in 0..GO_TO_RECENT + 2 {
|
||||
s.execute("edit.goTo", json!({"reference": format!("A{}", i + 1), "record": true})).unwrap();
|
||||
}
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
assert_eq!(list["recent"].as_array().map(Vec::len), Some(GO_TO_RECENT), "{list}");
|
||||
assert_eq!(list["recent"][0], format!("Sheet1!A{}", GO_TO_RECENT + 2), "{list}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_set_takes_numbers_for_input_and_value() {
|
||||
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
||||
|
||||
@@ -141,27 +141,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(noundo "view.customViews", "Custom Views…", ["View", "Workbook Views"], None, "{save?: name, show?: name, delete?: name} → list", has_doc, custom_views),
|
||||
cmd!(noundo "view.split", "Split", ["View", "Window"], None, "{} (toggles a split at the active cell; shown as frozen panes in this version)", has_doc, split),
|
||||
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} → {window, doc, title}: opens another window on the active workbook (the same workbook: saving from either window saves it)", has_doc, new_window),
|
||||
cmd!(
|
||||
noundo "view.arrangeAll",
|
||||
"Arrange All",
|
||||
["View", "Window"],
|
||||
None,
|
||||
"{arrange?: tiled|horizontal|vertical|cascade, activeWorkbook?: bool} → {arrange, windows}: lays out the visible windows (tiled by default; `activeWorkbook` only the active workbook's), the active window first",
|
||||
has_doc,
|
||||
arrange_all
|
||||
),
|
||||
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{window?, doc?} (the active window by default)", has_doc, hide_window),
|
||||
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{window?, doc?, index?} (the most recently hidden window by default)", has_doc, unhide_window),
|
||||
cmd!(
|
||||
noundo "view.switchWindows",
|
||||
"Switch Windows",
|
||||
["View", "Window"],
|
||||
None,
|
||||
"{window?, doc?} → open windows [{window, doc, index, title, dirty, active, hidden}]; with `window` (an id from the list), or `doc` (a workbook id: its most recent window), shows and activates that window first",
|
||||
always,
|
||||
switch_windows
|
||||
),
|
||||
cmd!(noundo "view.newWindow", "New Window", ["View", "Window"], None, "{} opens a second window on the same workbook (a linked copy)", has_doc, new_window),
|
||||
cmd!(noundo "view.arrangeAll", "Arrange All", ["View", "Window"], None, "{}", has_doc, |_, _| ok()),
|
||||
cmd!(noundo "view.hideWindow", "Hide", ["View", "Window"], None, "{}", has_doc, hide_window),
|
||||
cmd!(noundo "view.unhideWindow", "Unhide…", ["View", "Window"], None, "{index?}", has_doc, unhide_window),
|
||||
cmd!(query "view.switchWindows", "Switch Windows", ["View", "Window"], None, "{} → open windows", always, switch_windows),
|
||||
cmd!(noundo "view.ruler", "Ruler", ["View", "Show"], None, "{on?} → {on}: rulers in Page Layout view (an application option)", has_doc, ruler),
|
||||
cmd!(query "view.macros", "Macros", ["View", "Macros"], None, "{} → scripts", has_doc, all_scripts),
|
||||
// Table tools
|
||||
@@ -310,7 +294,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let si = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let idx = match u32_param(p, "chart") {
|
||||
Some(id) => sh.charts.iter().position(|c| c.id == id),
|
||||
@@ -476,7 +460,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet_name, body) = split_sheet(range);
|
||||
let src_si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet(),
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "not a range"))?;
|
||||
if r.count() > 1_000_000 {
|
||||
@@ -498,7 +482,7 @@ fn select_data(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet_name, body) = split_sheet(t);
|
||||
let si = match sheet_name {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad("chart.selectData", "no such sheet"))?,
|
||||
None => d.wb.active_sheet(),
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body).ok_or_else(|| bad("chart.selectData", "`categories` is not a range"))?;
|
||||
let name = d.wb.sheet(si).map(|sh| sh.name.clone()).ok_or(EngineError::NoDocument)?;
|
||||
@@ -580,7 +564,7 @@ fn shape_outline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<Json> {
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
let si = s.doc()?.wb.active_sheet;
|
||||
let id = u32_param(p, "id");
|
||||
edit(s, |cx| {
|
||||
let shapes = &mut cx.sheet_mut(si)?.shapes;
|
||||
@@ -598,7 +582,7 @@ fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
let si = s.doc()?.wb.active_sheet;
|
||||
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
if i >= v.len() {
|
||||
return;
|
||||
@@ -664,7 +648,7 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ids: Vec<u32> =
|
||||
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
|
||||
let how = str_param(p, "align").unwrap_or("left").to_string();
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
let si = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let geo: Vec<(u32, f64, f64, f32, f32)> = {
|
||||
@@ -777,7 +761,7 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut views = stash_map(&d.wb, VIEWS_NAME);
|
||||
if let Some(name) = str_param(p, "save") {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
views[name] = json!({"sheet": d.wb.active_sheet(), "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
|
||||
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
|
||||
let v = views.clone();
|
||||
edit(s, |cx| {
|
||||
stash_set(&mut cx.wb, VIEWS_NAME, &v);
|
||||
@@ -824,71 +808,47 @@ fn split(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let id = s.new_window()?;
|
||||
let doc = s.window(id).map(|w| w.doc_uid);
|
||||
Ok(json!({"window": id, "doc": doc, "title": s.window_title(id)}))
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()), d.trust);
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
let i = s.add_document(copy);
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
|
||||
/// The window a window command names: `window` (an id), else `doc` (a workbook id: its most
|
||||
/// recent window), else the active window.
|
||||
pub(crate) fn window_param(s: &Session, cmd: &str, p: &Json) -> Result<u64> {
|
||||
let t = crate::Target::from_json(p).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
s.target_window(t).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
|
||||
/// View › Arrange All. The engine has no window geometry: it picks the windows and the layout,
|
||||
/// and the interface moves its native windows ([`Session::window_list`] order, active first).
|
||||
fn arrange_all(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let arrange = match str_param(p, "arrange").unwrap_or("tiled") {
|
||||
a @ ("tiled" | "horizontal" | "vertical" | "cascade") => a,
|
||||
other => return Err(bad("view.arrangeAll", format!("`arrange` must be tiled, horizontal, vertical or cascade, not `{other}`"))),
|
||||
};
|
||||
let active = s.active_window();
|
||||
let doc = active.and_then(|w| s.window(w)).map(|w| w.doc_uid);
|
||||
let only_active = p.get("activeWorkbook").and_then(Json::as_bool).unwrap_or(false);
|
||||
let mut windows: Vec<u64> = s.windows().iter().filter(|w| !w.hidden && (!only_active || Some(w.doc_uid) == doc)).map(|w| w.id).collect();
|
||||
if let Some(at) = windows.iter().position(|w| Some(*w) == active)
|
||||
&& let Some(first) = windows.get_mut(..=at)
|
||||
{
|
||||
first.rotate_right(1);
|
||||
fn hide_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
s.hidden_windows.push(s.active_index());
|
||||
let n = s.documents().len();
|
||||
if n > 1 {
|
||||
let next = (0..n).find(|i| !s.hidden_windows.contains(i)).unwrap_or(0);
|
||||
s.set_active(next);
|
||||
}
|
||||
Ok(json!({"arrange": arrange, "windows": windows}))
|
||||
}
|
||||
|
||||
fn hide_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = window_param(s, "view.hideWindow", p)?;
|
||||
s.hide_window(id);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn unhide_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
|
||||
Some(window_param(s, "view.unhideWindow", p)?)
|
||||
} else if let Some(i) = u32_param(p, "index") {
|
||||
// A workbook's position in the document list.
|
||||
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("view.unhideWindow", "no such window"))?;
|
||||
s.windows().iter().filter(|w| w.doc_uid == uid && w.hidden).map(|w| w.id).next_back().or_else(|| s.doc_window(uid))
|
||||
} else {
|
||||
s.last_hidden_window()
|
||||
};
|
||||
if let Some(id) = id {
|
||||
s.unhide_window(id);
|
||||
let i = u32_param(p, "index").map(|v| v as usize).or_else(|| s.hidden_windows.last().copied());
|
||||
if let Some(i) = i {
|
||||
s.hidden_windows.retain(|x| *x != i);
|
||||
s.set_active(i);
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
fn switch_windows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("window").is_some_and(|v| !v.is_null()) || p.get("doc").is_some_and(|v| !v.is_null()) {
|
||||
let id = window_param(s, "view.switchWindows", p)?;
|
||||
s.unhide_window(id);
|
||||
}
|
||||
Ok(s.window_list())
|
||||
fn switch_windows(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(
|
||||
s.documents()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| json!({"index": i, "title": d.display_title(), "active": i == s.active_index(), "hidden": s.hidden_windows.contains(&i)}))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = str_param(p, "range").and_then(RangeRef::parse).ok_or_else(|| bad("table.resize", "missing `range`"))?;
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let si = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let ti = match str_param(p, "table") {
|
||||
Some(n) => sh.tables.iter().position(|t| t.name.eq_ignore_ascii_case(n)),
|
||||
@@ -981,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("Nothing to import.".into()));
|
||||
}
|
||||
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
|
||||
});
|
||||
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
@@ -997,7 +957,7 @@ fn from_table_range(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let r = if r.is_single() { crate::selection::current_region(sh, r.start) } else { r };
|
||||
let text = crate::display::range_text(&d.wb, d.wb.active_sheet(), r);
|
||||
let text = crate::display::range_text(&d.wb, d.wb.active_sheet, r);
|
||||
from_csv(s, &json!({"text": text, "delimiter": "\t", "sheet": format!("{} (2)", sh.name).chars().take(31).collect::<String>()}))
|
||||
}
|
||||
|
||||
@@ -1074,11 +1034,6 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
if id.starts_with("file.") || id.starts_with("link.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
// Scripts are scoped to their workbook: a step that switches the active window would
|
||||
// point every later step at another open document, so those are refused too.
|
||||
if id.starts_with("window.") || matches!(id, "view.switchWindows" | "view.newWindow" | "view.hideWindow" | "view.unhideWindow") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
if params.get("path").is_some() {
|
||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||
}
|
||||
@@ -1247,7 +1202,7 @@ fn theme_fonts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn background(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if bool_param(p, "clear").unwrap_or(false) {
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
let si = s.doc()?.wb.active_sheet;
|
||||
return edit(s, |cx| {
|
||||
cx.sheet_mut(si)?.images.retain(|i| i.alt != "Sheet background");
|
||||
Ok(Json::Null)
|
||||
@@ -1310,7 +1265,7 @@ fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
|
||||
fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("formulas.useInFormula", "missing `name`"))?;
|
||||
s.ui_requests.push(crate::UiRequest::InsertName(name.to_string()));
|
||||
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={name}"))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -1340,7 +1295,7 @@ fn watch_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.iter()
|
||||
.map(|w| {
|
||||
let (sheet, body) = split_sheet(w);
|
||||
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet());
|
||||
let si = sheet.and_then(|n| d.wb.sheet_index(&n)).unwrap_or(d.wb.active_sheet);
|
||||
let c = CellRef::parse(body).unwrap_or_default();
|
||||
let sh = d.wb.sheet(si);
|
||||
json!({"cell": w, "value": sh.map(|s| s.value(c).display()), "formula": sh.and_then(|s| s.cell(c)).and_then(|x| x.formula.as_ref()).map(|f| format!("={}", f.text))})
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool}", has_doc, close),
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
|
||||
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
|
||||
@@ -25,9 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
options
|
||||
),
|
||||
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{window} | {doc} | {index} (a window id, a workbook id, or the workbook's position in the list)", has_doc, activate_window),
|
||||
cmd!(noundo "window.close", "Close Window", ["Window"], None, "{window?, doc?, force?: bool} → {closed, document}: closes a window (the active one by default); its workbook closes with its last window, asking first when it has unsaved changes unless `force`", has_doc, close_window),
|
||||
cmd!(query "window.list", "Windows", ["Window"], None, "{} → [{window, doc, index, title, dirty, active, hidden}]", always, |s, _| Ok(s.window_list())),
|
||||
cmd!(noundo "window.activate", "Switch Window", ["Window"], None, "{index}", has_doc, activate_window),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -71,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
let title = if imported {
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||
} else {
|
||||
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
|
||||
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||
};
|
||||
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
|
||||
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
||||
@@ -116,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
||||
// document keeps its file and its unsaved changes.
|
||||
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
return super::print::export_pdf(s, &json!({"path": path}));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
@@ -162,16 +160,7 @@ fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
/// File › Close closes the active window; the workbook closes with its last window.
|
||||
fn close(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !bool_param(p, "document").unwrap_or(false)
|
||||
&& let Some(id) = s.active_window()
|
||||
&& let Some(doc) = s.window(id).map(|w| w.doc_uid)
|
||||
&& s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1
|
||||
{
|
||||
s.close_window(id);
|
||||
return Ok(json!({"closed": true, "document": false}));
|
||||
}
|
||||
let force = bool_param(p, "force").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
if d.is_dirty() && !force {
|
||||
@@ -186,9 +175,9 @@ fn close(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export", "missing `path`"))?.to_string();
|
||||
let d = s.doc()?;
|
||||
let wb = (*d.wb).clone();
|
||||
let mut wb = (*d.wb).clone();
|
||||
if p.get("sheet").is_some() {
|
||||
wb.set_active_sheet(target_sheet(s, p)?);
|
||||
wb.active_sheet = target_sheet(s, p)?;
|
||||
}
|
||||
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
|
||||
let bytes = crate::io::save_bytes(&wb, &target)?;
|
||||
@@ -232,35 +221,10 @@ fn options(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn activate_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = match u32_param(p, "index").filter(|_| p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null)) {
|
||||
Some(i) => {
|
||||
let uid = s.documents().get(i as usize).map(|d| d.uid).ok_or_else(|| bad("window.activate", "no such window"))?;
|
||||
s.doc_window(uid).ok_or_else(|| bad("window.activate", "no such window"))?
|
||||
}
|
||||
None if p.get("window").is_none_or(Json::is_null) && p.get("doc").is_none_or(Json::is_null) => {
|
||||
return Err(bad("window.activate", "missing `window`"));
|
||||
}
|
||||
None => super::extra::window_param(s, "window.activate", p)?,
|
||||
};
|
||||
s.unhide_window(id);
|
||||
let i = u32_param(p, "index").ok_or_else(|| bad("window.activate", "missing `index`"))? as usize;
|
||||
if i >= s.documents().len() {
|
||||
return Err(bad("window.activate", "no such window"));
|
||||
}
|
||||
s.set_active(i);
|
||||
ok()
|
||||
}
|
||||
|
||||
fn close_window(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = super::extra::window_param(s, "window.close", p)?;
|
||||
let doc = s.window(id).map(|w| w.doc_uid).unwrap_or_default();
|
||||
if s.windows().iter().filter(|w| w.doc_uid == doc).count() > 1 {
|
||||
s.close_window(id);
|
||||
return Ok(json!({"closed": true, "document": false}));
|
||||
}
|
||||
let force = bool_param(p, "force").unwrap_or(false);
|
||||
let d = s.documents().iter().find(|d| d.uid == doc).ok_or(crate::EngineError::NoDocument)?;
|
||||
if d.is_dirty() && !force {
|
||||
let title = d.display_title();
|
||||
s.activate_window(id);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("saveChanges".into(), json!({"title": title})));
|
||||
return Ok(json!({"closed": false, "dirty": true}));
|
||||
}
|
||||
s.close_window(id);
|
||||
Ok(json!({"closed": true, "document": true}))
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ fn range_on(s: &Session, t: &str) -> Result<(usize, RangeRef)> {
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = match sh {
|
||||
Some(n) => d.wb.sheet_index(&n).ok_or_else(|| bad(CMD, format!("no sheet named `{n}`")))?,
|
||||
None => d.wb.active_sheet(),
|
||||
None => d.wb.active_sheet,
|
||||
};
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(CMD, format!("not a range: `{t}`")))?;
|
||||
let r = if r.is_full_cols() || r.is_full_rows() {
|
||||
@@ -101,7 +101,7 @@ fn source(s: &Session, p: &Json) -> Result<(usize, Vec<CellRef>, Vec<CellRef>)>
|
||||
None => {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = d.selection.current();
|
||||
(d.wb.active_sheet(), if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
|
||||
(d.wb.active_sheet, if sel.is_single() { current_region(sh, d.selection.active) } else { sel })
|
||||
}
|
||||
};
|
||||
if r.width() < 2 {
|
||||
@@ -307,7 +307,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let stats_col = width + 1;
|
||||
let chart_col = if opts.statistics { stats_col + 3 } else { width + 1 };
|
||||
|
||||
let at = s.doc()?.wb.active_sheet();
|
||||
let at = s.doc()?.wb.active_sheet;
|
||||
let index = edit(s, |cx| {
|
||||
let at = at.min(cx.wb.sheets.len());
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
@@ -440,7 +440,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
cx.wb.set_active_sheet(at);
|
||||
cx.wb.active_sheet = at;
|
||||
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
|
||||
cx.structural = true;
|
||||
Ok(at)
|
||||
@@ -502,7 +502,7 @@ mod tests {
|
||||
assert_eq!(r["periods"], 4);
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
// Inserted before the data sheet and activated.
|
||||
assert_eq!(wb.active_sheet(), 0);
|
||||
assert_eq!(wb.active_sheet, 0);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert_eq!(sh.name, r["sheet"].as_str().unwrap());
|
||||
assert_eq!(value(&s, 0, "C1"), Value::text("Forecast(Sales)"));
|
||||
|
||||
@@ -233,23 +233,10 @@ fn with(p: &Json, key: &str, v: Json) -> Json {
|
||||
pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let guarded = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.is_some());
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pr.format_cells)) {
|
||||
return Err(EngineError::Other("The cell or chart you're trying to change is on a protected sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Formatting on a protected sheet keeps the cell's permission flags: a format call
|
||||
// that flips `locked` from the format permission would authorize content edits that
|
||||
// the password should still gate, so those flags may not change (Excel keeps that
|
||||
// checkbox disabled while the sheet is protected).
|
||||
const FLAG_GUARD: &str = "You can't change cell protection on a protected sheet. Unprotect it first.";
|
||||
macro_rules! guard_flags {
|
||||
($old:expr, $new:expr) => {
|
||||
if guarded && cx.wb.styles.get($old).protection != cx.wb.styles.get($new).protection {
|
||||
return Err(EngineError::Other(FLAG_GUARD.into()));
|
||||
}
|
||||
};
|
||||
}
|
||||
let mut cache: std::collections::HashMap<StyleId, StyleId> = std::collections::HashMap::new();
|
||||
for r in &ranges {
|
||||
let full_rows = r.is_full_rows();
|
||||
@@ -264,7 +251,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
info.and_then(|i| i.style).unwrap_or_default()
|
||||
};
|
||||
let new = *cache.entry(cur).or_insert_with(|| cx.wb.styles.derive(cur, &f));
|
||||
guard_flags!(cur, new);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let map = if full_rows && !full_cols { &mut sh.rows } else { &mut sh.cols };
|
||||
map.entry(line).or_default().style = Some(new);
|
||||
@@ -274,7 +260,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
||||
for (c, st) in &cells {
|
||||
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
||||
guard_flags!(*st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(*c, new);
|
||||
}
|
||||
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
||||
@@ -291,7 +276,6 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
let st = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
||||
if new != st {
|
||||
guard_flags!(st, new);
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
}
|
||||
}
|
||||
@@ -1027,7 +1011,7 @@ fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u
|
||||
return Ok((sheet, lines));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if sheet != d.wb.active_sheet() {
|
||||
if sheet != d.wb.active_sheet {
|
||||
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
|
||||
}
|
||||
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, Cell, DefinedName, Formula, Workbook};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
@@ -55,15 +55,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
create_from_selection
|
||||
),
|
||||
cmd!(
|
||||
"formulas.pasteName",
|
||||
"Paste Names…",
|
||||
["Formulas", "Defined Names"],
|
||||
Some("F3"),
|
||||
"{name?: \"Sales\" (one of the names listed; OK: puts the name at the caret of the cell being edited, or starts `=Sales` in the active cell), list?: true (Paste List: writes each name and what it refers to in two columns, from `at`), at?: cell (default the active cell)} → {names: [{name, refersTo}]}: the names the Paste Name dialog lists (workbook names and the active sheet's own, hidden names left out, in alphabetical order). With neither nothing changes; the menu opens the dialog.",
|
||||
has_doc,
|
||||
paste_name
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
@@ -96,73 +87,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Whether a cell on the active sheet is empty.
|
||||
fn blank(sh: &gridcraft_model::sheet::Sheet, row: u32, col: u32) -> bool {
|
||||
sh.value(CellRef::new(row, col)).is_empty()
|
||||
}
|
||||
|
||||
fn sum_formula(func: &str, from: CellRef, to: CellRef) -> String {
|
||||
format!("={func}({})", RangeRef::new(from, to).a1())
|
||||
}
|
||||
|
||||
/// Totals for a multi-cell selection as `(cell, formula)` writes. `None` when the sheet has no data.
|
||||
fn selection_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, func: &str) -> Result<Option<Vec<(CellRef, String)>>> {
|
||||
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
|
||||
// past it: to the right of a single row, below each column of anything taller. Columns
|
||||
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), nor do rows past it, and all totals are one edit (one undo step).
|
||||
let Some(used) = sh.cells.used_range() else { return Ok(None) };
|
||||
if sel.start.row == sel.end.row { single_row_totals(sh, sel, used, func).map(Some) } else { block_totals(sh, sel, used, func).map(Some) }
|
||||
}
|
||||
|
||||
/// One row: its total goes in its last cell when that is empty, else just after it.
|
||||
fn single_row_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
|
||||
let row = sel.start.row;
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
|
||||
let (data_end, out) = if end_col > sel.start.col && blank(sh, row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
|
||||
if out >= gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
|
||||
}
|
||||
Ok(vec![(CellRef::new(row, out), sum_formula(func, CellRef::new(row, sel.start.col), CellRef::new(row, data_end)))])
|
||||
}
|
||||
|
||||
/// A block of rows: each column's total below it, and each row's total in an empty last column.
|
||||
fn block_totals(sh: &gridcraft_model::sheet::Sheet, sel: RangeRef, used: RangeRef, func: &str) -> Result<Vec<(CellRef, String)>> {
|
||||
let mut writes: Vec<(CellRef, String)> = Vec::new();
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
// An empty last column takes each row's total; rows past the data get none.
|
||||
let rows_end = sel.end.row.min(used.end.row);
|
||||
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| blank(sh, r, sel.end.col));
|
||||
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| blank(sh, sel.end.row, c));
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
// Each column's total, below it: unless only the last column is there to take totals.
|
||||
if last_row_blank || !right_blank {
|
||||
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = match str_param(p, "range") {
|
||||
Some(_) => target_range(s, p)?,
|
||||
None => d.selection.current(),
|
||||
};
|
||||
if !sel.is_single() {
|
||||
// A block: totals below each column (and to the right when a blank column is included).
|
||||
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), and all totals are one edit (one undo step).
|
||||
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
if out_row >= gridcraft_core::MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||
}
|
||||
for c in sel.start.col..=data_end_col.min(last_col) {
|
||||
writes.push((CellRef::new(out_row, c), sum_formula(func, CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
|
||||
}
|
||||
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
|
||||
.map(|c| {
|
||||
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
|
||||
})
|
||||
.collect();
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
for (c, f) in &writes {
|
||||
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
|
||||
cx.sheet_mut(sheet)?.set_cell(*c, cell);
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
if right_blank {
|
||||
for r in sel.start.row..=data_end_row.min(rows_end) {
|
||||
writes.push((CellRef::new(r, sel.end.col), sum_formula(func, CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
|
||||
}
|
||||
// Both empty: the corner takes the total of the whole block.
|
||||
if last_row_blank {
|
||||
writes.push((
|
||||
CellRef::new(sel.end.row, sel.end.col),
|
||||
sum_formula(func, CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(writes)
|
||||
}
|
||||
|
||||
/// The range a single cell's AutoSum guesses: the numbers above it, else the numbers to its left.
|
||||
fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<RangeRef> {
|
||||
// Single cell: guess the range above, else to the left.
|
||||
let at = sel.start;
|
||||
let numeric = |c: CellRef| sh.value(c).is_number();
|
||||
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))) {
|
||||
@@ -184,33 +144,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
|
||||
}
|
||||
range = Some(RangeRef::new(CellRef::new(at.row, left), CellRef::new(at.row, at.col - 1)));
|
||||
}
|
||||
range
|
||||
}
|
||||
|
||||
fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let func = str_param(p, "function").unwrap_or("SUM").to_ascii_uppercase();
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sel = match str_param(p, "range") {
|
||||
Some(_) => target_range(s, p)?,
|
||||
None => d.selection.current(),
|
||||
};
|
||||
if !sel.is_single() {
|
||||
let Some(writes) = selection_totals(sh, sel, &func)? else { return ok() };
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
for (c, f) in &writes {
|
||||
let cell = super::edit::input_to_cell(f, sheet, *c, &mut cx.wb)?.unwrap_or_default();
|
||||
cx.sheet_mut(sheet)?.set_cell(*c, cell);
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
// Single cell: guess the range above, else to the left.
|
||||
let at = sel.start;
|
||||
let text = match guessed_range(sh, at) {
|
||||
let text = match range {
|
||||
Some(r) => format!("={func}({})", r.a1()),
|
||||
None => {
|
||||
s.ui_requests.push(crate::UiRequest::EditCell(Some(format!("={func}()"))));
|
||||
@@ -498,7 +432,7 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
.iter()
|
||||
.filter(|n| !n.hidden)
|
||||
.map(|n| {
|
||||
let sheet = n.scope.unwrap_or(d.wb.active_sheet());
|
||||
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
|
||||
// parse_name, not evaluate's parse: a name's top-level commas are unions.
|
||||
let v = gridcraft_formula::parse_name(&n.formula)
|
||||
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
|
||||
@@ -576,65 +510,10 @@ fn create_from_selection(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The names Paste Names lists, as (name, refers-to text with `=`): the workbook's names and the
|
||||
/// active sheet's own, without hidden names, in alphabetical order. A sheet's name hides the
|
||||
/// workbook name it shares.
|
||||
pub fn pasteable_names(wb: &Workbook) -> Vec<(String, String)> {
|
||||
let sheet = wb.active_sheet();
|
||||
let shown = |local: bool| wb.names.iter().filter(move |n| !n.hidden && n.scope.is_some() == local && n.scope.is_none_or(|i| i == sheet));
|
||||
let local: std::collections::HashSet<String> = shown(true).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut out: Vec<(String, String)> = shown(true).map(|n| (n.name.clone(), format!("={}", n.formula))).collect();
|
||||
for n in shown(false) {
|
||||
if !local.contains(&gridcraft_formula::fold_name(&n.name)) {
|
||||
out.push((n.name.clone(), format!("={}", n.formula)));
|
||||
}
|
||||
}
|
||||
out.sort_by_cached_key(|(n, _)| n.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let names = pasteable_names(&d.wb);
|
||||
let listed: Vec<Json> = names.iter().map(|(n, r)| json!({"name": n, "refersTo": r})).collect();
|
||||
if !bool_param(p, "list").unwrap_or(false) {
|
||||
if let Some(name) = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
let Some((listed_name, _)) = names.iter().find(|(n, _)| gridcraft_formula::same_name(n, name)) else {
|
||||
return Err(EngineError::Other(format!("There's no name \"{name}\" to paste.")));
|
||||
};
|
||||
s.ui_requests.push(crate::UiRequest::InsertName(listed_name.clone()));
|
||||
}
|
||||
return Ok(json!({"names": listed}));
|
||||
}
|
||||
if names.is_empty() {
|
||||
return Err(EngineError::Other("This workbook has no names to paste.".into()));
|
||||
}
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let rows = u32::try_from(names.len()).unwrap_or(u32::MAX);
|
||||
if at.row.saturating_add(rows) > gridcraft_core::MAX_ROWS || at.col.saturating_add(2) > gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There isn't room on the sheet for the list of names.".into()));
|
||||
}
|
||||
let sheet = d.wb.active_sheet();
|
||||
let range = RangeRef::new(at, CellRef::new(at.row + rows - 1, at.col + 1));
|
||||
edit(s, |cx| {
|
||||
for (row, (name, refers)) in (at.row..).zip(&names) {
|
||||
for (col, text) in [(at.col, name), (at.col + 1, refers)] {
|
||||
let c = CellRef::new(row, col);
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(c);
|
||||
// The second column holds the refers-to text as a text value, with no formula.
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
Ok(json!({"names": listed, "range": range.a1()}))
|
||||
})
|
||||
}
|
||||
|
||||
fn trace_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let areas = d.calc.graph.precedents_of((d.wb.active_sheet(), at));
|
||||
let areas = d.calc.graph.precedents_of((d.wb.active_sheet, at));
|
||||
let v: Vec<Json> = areas.iter().map(|a| json!({"sheet": d.wb.sheet(a.sheet).map(|s| s.name.clone()), "range": a.range.a1()})).collect();
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
@@ -643,7 +522,7 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let mut deps = Vec::new();
|
||||
d.calc.graph.dependents(d.wb.active_sheet(), at, &mut deps);
|
||||
d.calc.graph.dependents(d.wb.active_sheet, at, &mut deps);
|
||||
deps.sort();
|
||||
deps.dedup();
|
||||
let v: Vec<Json> = deps.iter().map(|(si, c)| json!({"sheet": d.wb.sheet(*si).map(|s| s.name.clone()), "cell": c.a1()})).collect();
|
||||
@@ -656,7 +535,7 @@ fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Jso
|
||||
use std::collections::BTreeSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
|
||||
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
|
||||
@@ -727,7 +606,7 @@ fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
|
||||
let on = bool_param(p, "on").unwrap_or(!cur);
|
||||
edit(s, |cx| {
|
||||
@@ -785,19 +664,15 @@ fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let circ: Vec<String> = d
|
||||
.calc
|
||||
.circular
|
||||
.iter()
|
||||
.map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| gridcraft_formula::quote_sheet(&s.name)).unwrap_or_default(), c.a1()))
|
||||
.collect();
|
||||
let circ: Vec<String> =
|
||||
d.calc.circular.iter().map(|(si, c)| format!("{}!{}", d.wb.sheet(*si).map(|s| s.name.clone()).unwrap_or_default(), c.a1())).collect();
|
||||
Ok(json!({"errors": out, "circular": circ}))
|
||||
}
|
||||
|
||||
fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let text = match str_param(p, "formula") {
|
||||
Some(f) => f.trim_start_matches('=').to_string(),
|
||||
None => {
|
||||
@@ -836,7 +711,7 @@ fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
|
||||
crate::cmd::edit::check_arg_counts(&expr)?;
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet(), at, &expr);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
|
||||
Ok(crate::cmd::inspect::value_json(&v))
|
||||
}
|
||||
|
||||
@@ -1002,92 +877,4 @@ mod tests {
|
||||
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
||||
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
||||
}
|
||||
|
||||
/// A workbook with `Sales` and `rate`, a hidden name, a name of Sheet1 that hides a workbook
|
||||
/// `Tax`, and a name of another sheet.
|
||||
fn named_workbook() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "rate", "refersTo": "=0.07"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Tax", "refersTo": "=0.2"})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let name = |name: &str, scope, formula: &str, hidden| DefinedName {
|
||||
name: name.into(),
|
||||
scope,
|
||||
formula: formula.into(),
|
||||
comment: String::new(),
|
||||
hidden,
|
||||
};
|
||||
wb.names.push(name("_Filter", None, "Sheet1!$A$1:$B$9", true));
|
||||
wb.names.push(name("TAX", Some(0), "0.25", false));
|
||||
wb.names.push(name("Other", Some(1), "1", false));
|
||||
s
|
||||
}
|
||||
|
||||
fn text_at(s: &Session, cell: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_names_lists_the_names_the_active_sheet_can_use() {
|
||||
let mut s = named_workbook();
|
||||
let r = s.execute("formulas.pasteName", json!({})).unwrap();
|
||||
assert_eq!(
|
||||
r["names"],
|
||||
json!([
|
||||
{"name": "rate", "refersTo": "=0.07"},
|
||||
{"name": "Sales", "refersTo": "=Sheet1!$A$1:$A$3"},
|
||||
{"name": "TAX", "refersTo": "=0.25"},
|
||||
])
|
||||
);
|
||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "listing the names writes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_names_and_use_in_formula_ask_the_ui_to_insert_the_name() {
|
||||
let mut s = named_workbook();
|
||||
s.take_ui_requests();
|
||||
s.execute("formulas.pasteName", json!({"name": "Sales"})).unwrap();
|
||||
s.execute("formulas.useInFormula", json!({"name": "rate"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into()), crate::UiRequest::InsertName("rate".into())]);
|
||||
assert_eq!(text_at(&s, "A1"), Value::Empty, "the cell changes only when the edit is entered");
|
||||
// A name Paste Name doesn't list is refused; one typed in another case is pasted as listed.
|
||||
assert!(s.execute("formulas.pasteName", json!({"name": "NoSuchName"})).is_err());
|
||||
s.execute("formulas.pasteName", json!({"name": "SALES"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::InsertName("Sales".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_list_writes_each_name_and_what_it_refers_to() {
|
||||
let mut s = named_workbook();
|
||||
s.execute("selection.set", json!({"range": "D2"})).unwrap();
|
||||
let r = s.execute("formulas.pasteName", json!({"list": true})).unwrap();
|
||||
assert_eq!(r["range"], json!("D2:E4"));
|
||||
let rows = [("D2", "rate"), ("E2", "=0.07"), ("D3", "Sales"), ("E3", "=Sheet1!$A$1:$A$3"), ("D4", "TAX"), ("E4", "=0.25")];
|
||||
for (cell, text) in rows {
|
||||
assert_eq!(text_at(&s, cell), Value::text(text), "{cell}");
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse("E3").unwrap()).is_some_and(|c| c.formula.is_none()), "refers-to is text");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
for (cell, _) in rows {
|
||||
assert_eq!(text_at(&s, cell), Value::Empty, "{cell} after Undo");
|
||||
}
|
||||
s.execute("formulas.pasteName", json!({"list": true, "at": "B7"})).unwrap();
|
||||
assert_eq!(text_at(&s, "B7"), Value::text("rate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_list_needs_names_and_room() {
|
||||
let mut s = named_workbook();
|
||||
let last = format!("A{}", gridcraft_core::MAX_ROWS - 1);
|
||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last})).is_err(), "three rows don't fit below the second-to-last row");
|
||||
let last_col = format!("{}1", gridcraft_core::col_to_letters(gridcraft_core::MAX_COLS - 1));
|
||||
assert!(s.execute("formulas.pasteName", json!({"list": true, "at": last_col})).is_err(), "two columns don't fit in the last column");
|
||||
let mut empty = Session::new();
|
||||
empty.new_workbook();
|
||||
assert!(empty.execute("formulas.pasteName", json!({"list": true})).is_err());
|
||||
assert_eq!(empty.execute("formulas.pasteName", json!({})).unwrap()["names"], json!([]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -371,7 +371,7 @@ fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
if let Some(n) = str_param(p, "table") {
|
||||
return d.wb.table(n).ok_or_else(|| bad("table", format!("no table named `{n}`")));
|
||||
}
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let ti = sh
|
||||
.tables
|
||||
@@ -582,7 +582,6 @@ pub fn chart_kind(t: &str, sub: &str) -> ChartKind {
|
||||
("pie", _) => ChartKind::Pie,
|
||||
("doughnut", _) => ChartKind::Doughnut,
|
||||
("area", "stacked") => ChartKind::AreaStacked,
|
||||
("area", "stacked100") => ChartKind::AreaStacked100,
|
||||
("area", _) => ChartKind::Area,
|
||||
("scatter", "lines") => ChartKind::ScatterLines,
|
||||
("scatter", _) => ChartKind::Scatter,
|
||||
@@ -835,7 +834,7 @@ fn insert_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let idx = match u32_param(p, "chart") {
|
||||
Some(id) => sh.charts.iter().position(|c| c.id == id),
|
||||
@@ -984,7 +983,7 @@ fn insert_sparkline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
_ => SparklineKind::Line,
|
||||
};
|
||||
let markers = bool_param(p, "markers").unwrap_or(false);
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.sparklines.retain(|x| x.cell != loc);
|
||||
@@ -1009,7 +1008,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if placement == "cell" {
|
||||
let mime = validate_cell_picture(&data)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let at = match str_param(p, "at") {
|
||||
Some(a) => CellRef::parse(a).ok_or_else(|| bad("insert.picture", "invalid cell address"))?,
|
||||
@@ -1053,7 +1052,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mime,
|
||||
alt: str_param(p, "alt").unwrap_or("").into(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.images.push(img);
|
||||
Ok(json!({"image": id}))
|
||||
@@ -1123,7 +1122,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
text: str_param(p, "text").unwrap_or("").into(),
|
||||
format: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
@@ -1141,7 +1140,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("shape.setText", "`text` is longer than 32,767 characters"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
@@ -1164,7 +1163,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match kind.as_str() {
|
||||
@@ -1180,7 +1179,7 @@ fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let at = cell_param(s, p, "at")?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -1221,7 +1220,7 @@ fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
|
||||
}
|
||||
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
@@ -1317,20 +1316,9 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let named = str_param(p, "author").map(str::to_string);
|
||||
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
let comments = &mut cx.sheet_mut(sheet)?.comments;
|
||||
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
|
||||
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
|
||||
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
|
||||
c.text = text.clone();
|
||||
if let Some(author) = named.clone() {
|
||||
c.author = author;
|
||||
}
|
||||
return Ok(json!({"cell": at.a1()}));
|
||||
}
|
||||
comments.insert(
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
@@ -1730,7 +1718,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
|
||||
@@ -64,7 +64,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"path": d.path,
|
||||
"dirty": d.is_dirty(),
|
||||
"trust": d.trust,
|
||||
"activeSheet": wb.active_sheet(),
|
||||
"activeSheet": wb.active_sheet,
|
||||
"sheets": sheets,
|
||||
"selection": d.selection.a1(),
|
||||
"activeCell": d.selection.active.a1(),
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
test_commands().iter().find(|c| c.id == id).copied()
|
||||
}
|
||||
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Test-only: register an extra command so UI integration tests can prove a pending
|
||||
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
||||
@@ -296,7 +296,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
|
||||
{
|
||||
return d.wb.sheet_index(&sh).ok_or_else(|| bad("range", format!("no sheet named `{sh}`")));
|
||||
}
|
||||
Ok(d.wb.active_sheet())
|
||||
Ok(d.wb.active_sheet)
|
||||
}
|
||||
|
||||
pub(crate) use gridcraft_core::split_sheet;
|
||||
@@ -305,7 +305,7 @@ pub(crate) use gridcraft_core::split_sheet;
|
||||
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
|
||||
/// silent fallback to some other cell.
|
||||
pub(crate) fn cell_param(s: &Session, p: &Json, key: &str) -> Result<Option<CellRef>> {
|
||||
cell_param_on(s, p, key, s.doc()?.wb.active_sheet())
|
||||
cell_param_on(s, p, key, s.doc()?.wb.active_sheet)
|
||||
}
|
||||
|
||||
/// Like [`cell_param`], for a command that operates on `sheet` (a `Sheet!` prefix must name it).
|
||||
@@ -552,15 +552,9 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
return;
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
// The cell loop must not rescan every merge per cell: the merges touching this row are
|
||||
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
|
||||
// a sheet with many merges and many saved columns would otherwise scan all of them per
|
||||
// cell, rows × merges × columns).
|
||||
let row_merges: Vec<gridcraft_core::RangeRef> =
|
||||
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
@@ -593,7 +587,7 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
|
||||
impl Ctx<'_> {
|
||||
pub fn sheet_index(&self) -> usize {
|
||||
self.wb.active_sheet()
|
||||
self.wb.active_sheet
|
||||
}
|
||||
pub fn sheet_mut(&mut self, i: usize) -> Result<&mut gridcraft_model::Sheet> {
|
||||
self.wb.sheet_mut(i).ok_or_else(|| EngineError::Other("no such sheet".into()))
|
||||
|
||||
+15
-125
@@ -184,19 +184,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Show Details",
|
||||
[],
|
||||
None,
|
||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}. An error when the PivotTable's Enable show details option is off (`pivot.options` `{enableShowDetails: true}` turns it on).",
|
||||
"{cell?: \"B5\" (a value, subtotal or grand total cell of a PivotTable on the active sheet; default: the active cell)} puts the source records behind that value on a new sheet before the current one → {sheet, records}",
|
||||
has_doc,
|
||||
show_details
|
||||
),
|
||||
cmd!(
|
||||
"pivot.options",
|
||||
"PivotTable Options",
|
||||
["PivotTable Analyze", "PivotTable"],
|
||||
None,
|
||||
"{pivot?, enableShowDetails?: bool (Data › Enable show details: off, Show Details and double-clicking a value cell list no records)} → {enableShowDetails}",
|
||||
has_doc,
|
||||
options
|
||||
),
|
||||
cmd!(
|
||||
"pivot.delete",
|
||||
"Delete PivotTable",
|
||||
@@ -224,7 +215,7 @@ fn other(msg: impl Into<String>) -> EngineError {
|
||||
/// (sheet, index) of the pivot a call targets.
|
||||
fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
let active = d.wb.active_sheet();
|
||||
let active = d.wb.active_sheet;
|
||||
if let Some(v) = p.get("pivot").filter(|v| !v.is_null()) {
|
||||
let mut order: Vec<usize> = vec![active];
|
||||
order.extend((0..d.wb.sheets.len()).filter(|i| *i != active));
|
||||
@@ -253,7 +244,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
}
|
||||
|
||||
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
||||
match v.map(str::to_ascii_lowercase) {
|
||||
match v.map(|x| x.to_ascii_lowercase()) {
|
||||
Some(a) if a == "rows" || a == "row" => Ok("rows"),
|
||||
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
|
||||
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
|
||||
@@ -634,7 +625,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
let source = source_param(s, p)?;
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
let active = s.doc()?.wb.active_sheet;
|
||||
// Validate the source now (header names, size).
|
||||
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
|
||||
let name = match str_param(p, "name") {
|
||||
@@ -713,7 +704,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.pivots.push(pt);
|
||||
let pi = sh.pivots.len() - 1;
|
||||
let area = pv::refresh(&mut cx.wb, si, pi, replace).map_err(other)?;
|
||||
cx.wb.set_active_sheet(si);
|
||||
cx.wb.active_sheet = si;
|
||||
*cx.sel = crate::Selection::at(anchor);
|
||||
cx.structural = true;
|
||||
let sheet_name = cx.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
@@ -738,7 +729,7 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// Inserts a new, empty sheet before the active one (Excel's place for a new PivotTable or a
|
||||
/// Show Details sheet); returns its index.
|
||||
fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
|
||||
let at = cx.wb.active_sheet().min(cx.wb.sheets.len());
|
||||
let at = cx.wb.active_sheet.min(cx.wb.sheets.len());
|
||||
let nm = cx.wb.next_sheet_name();
|
||||
cx.wb.sheets.insert(at, Arc::new(Sheet::new(nm)));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
@@ -756,7 +747,7 @@ fn insert_sheet_before_active(cx: &mut Ctx) -> usize {
|
||||
fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let active = d.wb.active_sheet();
|
||||
let active = d.wb.active_sheet;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let pt = sh
|
||||
.pivots
|
||||
@@ -764,13 +755,8 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.find(|pt| pt.last_range.is_some_and(|r| r.contains(at)))
|
||||
.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
let origin = pt.last_range.map(|r| r.start).unwrap_or_default();
|
||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col));
|
||||
// With the option off, a report that is out of date or a source that cannot be read is
|
||||
// reported as the option; only a cell that is not a value cell keeps its own message.
|
||||
if !pt.enable_show_details && !matches!(found, Ok(None)) {
|
||||
return Err(other(SHOW_DETAILS_OFF));
|
||||
}
|
||||
let (src, records) = found.map_err(other)?.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
let found = pv::detail_records(&d.wb, pt, active, at.row.saturating_sub(origin.row), at.col.saturating_sub(origin.col)).map_err(other)?;
|
||||
let (src, records) = found.ok_or_else(|| EngineError::Disabled("pivot.showDetails".into(), pv::NOT_A_VALUE_CELL.into()))?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(other("The workbook is protected and cannot be changed."));
|
||||
}
|
||||
@@ -825,7 +811,7 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
let name = sh.name.clone();
|
||||
cx.wb.set_active_sheet(si);
|
||||
cx.wb.active_sheet = si;
|
||||
*cx.sel = crate::Selection::at(CellRef::new(0, 0));
|
||||
cx.structural = true;
|
||||
Ok(json!({"sheet": name, "records": count}))
|
||||
@@ -838,32 +824,6 @@ fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// The message Show Details gives while Enable show details is off.
|
||||
const SHOW_DETAILS_OFF: &str = "Show Details is turned off for this PivotTable.";
|
||||
|
||||
/// PivotTable Options: Enable show details (Data). Changes only the option; the report is not
|
||||
/// refreshed. Without params it returns the current setting.
|
||||
fn options(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, pi) = find_pivot(s, p)?;
|
||||
let enable = bool_param(p, "enableShowDetails");
|
||||
if p.get("enableShowDetails").is_some_and(|v| !v.is_null()) && enable.is_none() {
|
||||
return Err(bad("pivot.options", "`enableShowDetails` must be true or false"));
|
||||
}
|
||||
let current = |s: &Session| -> Result<Json> {
|
||||
let pt = s.doc()?.wb.sheet(si).and_then(|sh| sh.pivots.get(pi)).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
Ok(json!({"enableShowDetails": pt.enable_show_details}))
|
||||
};
|
||||
let Some(on) = enable else { return current(s) };
|
||||
if current(s)?["enableShowDetails"] == json!(on) {
|
||||
return current(s);
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let pt = cx.sheet_mut(si)?.pivots.get_mut(pi).ok_or_else(|| other("There's no such PivotTable."))?;
|
||||
pt.enable_show_details = on;
|
||||
Ok(json!({"enableShowDetails": on}))
|
||||
})
|
||||
}
|
||||
|
||||
fn summarize_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "table") {
|
||||
@@ -901,7 +861,6 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
"valuesOnRows": pt.values_on_rows,
|
||||
"valuesPosition": pt.values_position,
|
||||
"enableShowDetails": pt.enable_show_details,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1534,7 +1493,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let sh = gridcraft_core::unquote_sheet(sh);
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet()), t),
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
};
|
||||
if let (Some(si), Some(c)) = (sheet, CellRef::parse(addr.trim().replace('$', "").as_str())) {
|
||||
let hit = d.wb.sheet(si).and_then(|sh| sh.pivots.iter().position(|pt| pt.last_range.is_some_and(|r| r.contains(c)) || pt.anchor == c));
|
||||
@@ -1668,7 +1627,7 @@ fn suggestions(wb: &Workbook, src: &pv::Source) -> Vec<Json> {
|
||||
|
||||
fn recommended(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let source = source_param(s, p)?;
|
||||
let active = s.doc()?.wb.active_sheet();
|
||||
let active = s.doc()?.wb.active_sheet;
|
||||
let src = pv::read_source(&s.doc()?.wb, &source, active).map_err(other)?;
|
||||
let list = suggestions(&s.doc()?.wb, &src);
|
||||
let Some(apply) = p.get("apply").and_then(Json::as_u64) else {
|
||||
@@ -1751,7 +1710,7 @@ mod tests {
|
||||
assert_eq!(r["fields"], json!(["Region", "Product", "Date", "Units", "Sales"]));
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.sheets.len(), 2);
|
||||
assert_eq!(d.wb.active_sheet(), 0, "new sheet goes before the current one");
|
||||
assert_eq!(d.wb.active_sheet, 0, "new sheet goes before the current one");
|
||||
assert_eq!(d.wb.active().unwrap().name, "Sheet2");
|
||||
assert!(text(&s, "A3").starts_with("PivotTable1"));
|
||||
assert_eq!(pt(&s).source, "Sheet1!$A$1:$E$13");
|
||||
@@ -2132,7 +2091,7 @@ mod tests {
|
||||
}
|
||||
fn plotted(s: &Session) -> Vec<Vec<Option<f64>>> {
|
||||
let d = s.doc().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), &pivot_chart(s));
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, &pivot_chart(s));
|
||||
data.series.into_iter().map(|x| x.values).collect()
|
||||
}
|
||||
|
||||
@@ -2376,7 +2335,6 @@ mod tests {
|
||||
"pivot.changeSource",
|
||||
"pivot.delete",
|
||||
"pivot.showDetails",
|
||||
"pivot.options",
|
||||
];
|
||||
let hostile = [
|
||||
json!({}),
|
||||
@@ -2530,7 +2488,7 @@ mod tests {
|
||||
assert_eq!(r["records"], 2);
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.sheets.len(), 3);
|
||||
assert_eq!(d.wb.active_sheet(), 0, "the new sheet goes before the PivotTable's");
|
||||
assert_eq!(d.wb.active_sheet, 0, "the new sheet goes before the PivotTable's");
|
||||
assert_eq!(d.wb.active().unwrap().name, "Sheet3");
|
||||
assert_eq!(
|
||||
sheet_rows(&mut s),
|
||||
@@ -2622,72 +2580,4 @@ mod tests {
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_show_details_off_stops_show_details() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
assert_eq!(s.execute("pivot.options", json!({})).unwrap()["enableShowDetails"], true);
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
assert!(!pt(&s).enable_show_details);
|
||||
assert_eq!(s.execute("pivot.fields", json!({})).unwrap()["enableShowDetails"], false);
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
||||
assert_eq!(e, "Show Details is turned off for this PivotTable.");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
// A label still reports that it is not a value cell.
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "A5"})).unwrap_err().to_string();
|
||||
assert!(!e.contains("turned off"), "{e}");
|
||||
// Setting the same value again adds no undo step.
|
||||
let after = s.doc().unwrap().revision;
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, after);
|
||||
assert!(s.execute("pivot.options", json!({"enableShowDetails": "no"})).is_err());
|
||||
// One undo turns the option back on.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(pt(&s).enable_show_details);
|
||||
assert_eq!(s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap()["records"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_show_details_off_is_reported_before_an_out_of_date_report() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
s.execute("pivot.options", json!({"enableShowDetails": false})).unwrap();
|
||||
// The source changes after the report was written, so the report is out of date.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
||||
let e = s.execute("pivot.showDetails", json!({"cell": "B5"})).unwrap_err().to_string();
|
||||
assert!(e.contains("turned off") && !e.contains("out of date"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_cells_are_the_cells_show_details_takes() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "columns": ["Product"], "values": ["Sales"]})).unwrap();
|
||||
for layout in 0..2 {
|
||||
if layout == 1 {
|
||||
// A two-level row axis, with subtotals at the top of each group.
|
||||
s.execute("pivot.moveField", json!({"field": "Product", "from": "columns", "to": "rows"})).unwrap();
|
||||
}
|
||||
let d = s.doc().unwrap();
|
||||
let si = d.wb.active_sheet();
|
||||
let pt = &d.wb.active().unwrap().pivots[0];
|
||||
let r = pt.last_range.unwrap();
|
||||
let cells = crate::pivot::detail_cells(&d.wb, pt, si).unwrap();
|
||||
let mut values = 0;
|
||||
for row in 0..r.height() {
|
||||
for col in 0..r.width() {
|
||||
let one = crate::pivot::detail_records(&d.wb, pt, si, row, col).unwrap();
|
||||
assert_eq!(cells.contains(&(row, col)), one.is_some(), "layout {layout}, cell ({row}, {col})");
|
||||
values += usize::from(one.is_some());
|
||||
}
|
||||
}
|
||||
assert!(values > 4, "layout {layout} has value cells");
|
||||
}
|
||||
// An out-of-date report answers as Show Details does.
|
||||
s.execute("range.setValues", json!({"range": "Sheet1!A2", "values": [["Central"]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let pt = &d.wb.active().unwrap().pivots[0];
|
||||
assert!(crate::pivot::detail_cells(&d.wb, pt, d.wb.active_sheet()).unwrap_err().contains("out of date"));
|
||||
}
|
||||
}
|
||||
|
||||
+96
-177
@@ -139,26 +139,8 @@ impl SheetLayout {
|
||||
let [l, r, t, b, ..] = self.margins;
|
||||
((self.page_w - l - r).max(36.0), (self.page_h - t - b).max(36.0))
|
||||
}
|
||||
/// Left and right margins, in points, that header and footer sections line up with: the
|
||||
/// page's side margins with Align with Page Margins on, otherwise 0.7 inch on each side, at
|
||||
/// most a quarter of the page width.
|
||||
pub(crate) fn hf_sides(&self) -> (f32, f32) {
|
||||
if self.settings.hf_align_with_margins {
|
||||
(self.margins[0], self.margins[1])
|
||||
} else {
|
||||
let side = HF_FIXED_SIDE_MARGIN.min(self.page_w / 4.0);
|
||||
(side, side)
|
||||
}
|
||||
}
|
||||
/// Scale of header and footer text: the print scale with Scale with Document on, otherwise 1.
|
||||
pub(crate) fn hf_text_scale(&self) -> f32 {
|
||||
if self.settings.hf_scale_with_doc { self.scale } else { 1.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Side margin of headers and footers, in points, when Align with Page Margins is off.
|
||||
const HF_FIXED_SIDE_MARGIN: f32 = 0.7 * 72.0;
|
||||
|
||||
/// Options shared by the print commands.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct PrintOptions {
|
||||
@@ -388,13 +370,13 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
|
||||
let sheets: Vec<usize> = if opts.all_sheets {
|
||||
(0..wb.sheets.len()).filter(|i| wb.sheet(*i).is_some_and(|s| s.visibility == Visibility::Visible)).collect()
|
||||
} else {
|
||||
vec![wb.active_sheet()]
|
||||
vec![wb.active_sheet]
|
||||
};
|
||||
for si in sheets {
|
||||
if pages >= MAX_PAGES {
|
||||
break;
|
||||
}
|
||||
let ovr = if si == wb.active_sheet() { opts.range } else { None };
|
||||
let ovr = if si == wb.active_sheet { opts.range } else { None };
|
||||
if opts.all_sheets && ovr.is_none() && wb.sheet(si).is_some_and(|s| print_range(s).is_none()) {
|
||||
continue;
|
||||
}
|
||||
@@ -405,7 +387,7 @@ pub fn layouts(wb: &Workbook, opts: &PrintOptions) -> Vec<SheetLayout> {
|
||||
}
|
||||
}
|
||||
if out.is_empty() && opts.all_sheets {
|
||||
out.extend(layout_sheet(wb, wb.active_sheet(), opts, None));
|
||||
out.extend(layout_sheet(wb, wb.active_sheet, opts, None));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -617,7 +599,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
i += rest.chars().count() + 1;
|
||||
}
|
||||
d if d.is_ascii_digit() => {
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
|
||||
i += digits.len() - 1;
|
||||
if let Ok(v) = digits.parse::<f32>() {
|
||||
sec.size = Some(v.clamp(1.0, 409.0));
|
||||
@@ -802,27 +784,22 @@ fn wrap_lines(text: &str, font: Font, size: f32, width: f32) -> Vec<String> {
|
||||
out.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
// Break a single over-long word by characters. The cut that fits is found by binary
|
||||
// search (each try measures one candidate prefix), so an over-long word costs
|
||||
// O(n log n) measurements instead of re-measuring the whole cut per character for
|
||||
// every emitted line (`a` × 100k pasted text would be cubic worst case).
|
||||
// Break a single over-long word by characters.
|
||||
while gridcraft_pdf::text_width(font, size, &line) > width && line.chars().count() > 1 {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let (mut lo, mut hi) = (1usize, chars.len());
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
let cut: String = chars[..mid].iter().collect();
|
||||
if gridcraft_pdf::text_width(font, size, &cut) <= width {
|
||||
lo = mid;
|
||||
let mut cut = String::new();
|
||||
let mut rest = String::new();
|
||||
for ch in line.chars() {
|
||||
if rest.is_empty() && gridcraft_pdf::text_width(font, size, &format!("{cut}{ch}")) <= width || cut.is_empty() {
|
||||
cut.push(ch);
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
rest.push(ch);
|
||||
}
|
||||
}
|
||||
if lo == chars.len() {
|
||||
if rest.is_empty() {
|
||||
break;
|
||||
}
|
||||
out.push(chars[..lo].iter().collect());
|
||||
line = chars[lo..].iter().collect();
|
||||
out.push(cut);
|
||||
line = rest;
|
||||
}
|
||||
if out.len() > 2000 {
|
||||
return out;
|
||||
@@ -1329,16 +1306,7 @@ fn decode_image(data: &[u8], max_w: u32, max_h: u32) -> Option<(Vec<u8>, u32, u3
|
||||
}
|
||||
|
||||
/// Draws charts, pictures, shapes and sparklines overlapping the body block of a page.
|
||||
/// `pics` memoizes decoded pictures for the whole render (one page at a time draws each
|
||||
/// picture again, but the decode happens once).
|
||||
fn draw_objects(
|
||||
pg: &mut Page,
|
||||
wb: &Workbook,
|
||||
si: usize,
|
||||
sh: &Sheet,
|
||||
b: &Block,
|
||||
pics: &mut std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>,
|
||||
) {
|
||||
fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block) {
|
||||
let block = b.range();
|
||||
let (sx0, sy0) = (sh.col_left(block.start.col), sh.row_top(block.start.row));
|
||||
let (sx1, sy1) = (sh.col_left(block.end.col.saturating_add(1)), sh.row_top(block.end.row.saturating_add(1)));
|
||||
@@ -1348,42 +1316,6 @@ fn draw_objects(
|
||||
let place = |r: (f64, f64, f64, f64)| (bx + ((r.0 - sx0) as f32) * b.k, by + ((r.1 - sy0) as f32) * b.k, r.2 as f32 * b.k, r.3 as f32 * b.k);
|
||||
pg.save();
|
||||
pg.clip_rect(bx, by, bw, bh);
|
||||
draw_sparklines(pg, wb, si, sh, b, block);
|
||||
for (imi, im) in sh.images.iter().enumerate() {
|
||||
let r = anchor_rect(sh, &im.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
if let Some((px, pw, ph)) = decoded.as_ref() {
|
||||
pg.image_rgba(x, y, w, h, px, *pw, *ph);
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
let r = anchor_rect(sh, &shp.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Block, block: RangeRef) {
|
||||
for sp in &sh.sparklines {
|
||||
if !block.contains(sp.cell) {
|
||||
continue;
|
||||
@@ -1398,64 +1330,90 @@ fn draw_sparklines(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet, b: &Bloc
|
||||
let prims = gridcraft_chart::render_sparkline(sp.kind, &vals, [color[0], color[1], color[2], 255], sp.markers, wpx, hpx);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Block, (x, y, w, h): (f32, f32, f32, f32)) {
|
||||
let fill = rgb_of(shp.fill, wb);
|
||||
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
|
||||
match shp.kind {
|
||||
ShapeKind::Ellipse => {
|
||||
let pts: Vec<[f32; 2]> = (0..72)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
pg.polygon(&pts, fill, line);
|
||||
for im in &sh.images {
|
||||
let r = anchor_rect(sh, &im.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
|
||||
ShapeKind::Line | ShapeKind::Arrow => {
|
||||
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
|
||||
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
|
||||
if shp.kind == ShapeKind::Arrow {
|
||||
let ang = h.atan2(w);
|
||||
let al = 8.0 * b.k;
|
||||
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
|
||||
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
if let Some((px, pw, ph)) = decode_image(&im.data, mw, mh) {
|
||||
pg.image_rgba(x, y, w, h, &px, pw, ph);
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
let r = anchor_rect(sh, &shp.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
let fill = rgb_of(shp.fill, wb);
|
||||
let line = rgb_of(shp.line, wb).filter(|_| !shp.format.no_line).map(|c| (c, (0.75 * b.k).max(0.25)));
|
||||
match shp.kind {
|
||||
ShapeKind::Ellipse => {
|
||||
let pts: Vec<[f32; 2]> = (0..72)
|
||||
.map(|i| {
|
||||
let a = i as f32 / 72.0 * std::f32::consts::TAU;
|
||||
[x + w / 2.0 + w / 2.0 * a.cos(), y + h / 2.0 + h / 2.0 * a.sin()]
|
||||
})
|
||||
.collect();
|
||||
pg.polygon(&pts, fill, line);
|
||||
}
|
||||
ShapeKind::Triangle => pg.polygon(&[[x + w / 2.0, y], [x + w, y + h], [x, y + h]], fill, line),
|
||||
ShapeKind::Line | ShapeKind::Arrow => {
|
||||
let c = line.map(|l| l.0).or(fill).unwrap_or([0, 0, 0]);
|
||||
pg.stroke_line(x, y, x + w, y + h, (1.0 * b.k).max(0.3), c);
|
||||
if shp.kind == ShapeKind::Arrow {
|
||||
let ang = h.atan2(w);
|
||||
let al = 8.0 * b.k;
|
||||
let p = |da: f32| [x + w - al * (ang + da).cos(), y + h - al * (ang + da).sin()];
|
||||
pg.polygon(&[[x + w, y + h], p(0.4), p(-0.4)], Some(c), None);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
pg.polygon(&[[x, y], [x + w, y], [x + w, y + h], [x, y + h]], fill, line);
|
||||
if !shp.text.is_empty() {
|
||||
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let fm = &shp.format;
|
||||
let text_box = shp.kind == ShapeKind::TextBox;
|
||||
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
|
||||
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
|
||||
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
|
||||
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
|
||||
let block_h = lines.len().min(500) as f32 * size * 1.2;
|
||||
let mut yy = match anchor {
|
||||
ShapeTextAnchor::Top => y + 2.0 * b.k,
|
||||
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
|
||||
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
|
||||
} + size * 0.8;
|
||||
for l in lines.iter().take(500) {
|
||||
let lw = gridcraft_pdf::text_width(font, size, l);
|
||||
let lx = match align {
|
||||
ShapeTextAlign::Left => x + 2.0 * b.k,
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if shp.text.is_empty() {
|
||||
return;
|
||||
}
|
||||
use gridcraft_model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let fm = &shp.format;
|
||||
let text_box = shp.kind == ShapeKind::TextBox;
|
||||
let size = fm.font_size.unwrap_or(11.0).clamp(1.0, 4000.0) * b.scale;
|
||||
let font = Font::styled(Font::family_for(fm.font.as_deref().unwrap_or("")), fm.bold, fm.italic);
|
||||
let color = fm.text_color.and_then(|c| rgb_of(c, wb)).unwrap_or([0, 0, 0]);
|
||||
let align = fm.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = fm.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let lines = wrap_lines(&shp.text, font, size, (w - 4.0 * b.k).max(size));
|
||||
let block_h = lines.len().min(500) as f32 * size * 1.2;
|
||||
let mut yy = match anchor {
|
||||
ShapeTextAnchor::Top => y + 2.0 * b.k,
|
||||
ShapeTextAnchor::Middle => y + (h - block_h) / 2.0,
|
||||
ShapeTextAnchor::Bottom => y + h - 2.0 * b.k - block_h,
|
||||
} + size * 0.8;
|
||||
for l in lines.iter().take(500) {
|
||||
let lw = gridcraft_pdf::text_width(font, size, l);
|
||||
let lx = match align {
|
||||
ShapeTextAlign::Left => x + 2.0 * b.k,
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
for ch in &sh.charts {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at: (f32, f32), size: (f32, f32)) {
|
||||
@@ -1494,9 +1452,7 @@ fn draw_headings(pg: &mut Page, b: &Block, col_heads: bool, row_heads: bool, at:
|
||||
}
|
||||
|
||||
fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::HfPage, cx: &HfContext) {
|
||||
let [_, _, _, _, hm, fm] = lay.margins;
|
||||
let (l, r) = lay.hf_sides();
|
||||
let text_scale = lay.hf_text_scale();
|
||||
let [l, r, _, _, hm, fm] = lay.margins;
|
||||
let pw = lay.page_w;
|
||||
let ph = lay.page_h;
|
||||
for (spec, header) in [(lay.settings.hf(which, false), true), (lay.settings.hf(which, true), false)] {
|
||||
@@ -1508,7 +1464,7 @@ fn draw_header_footer(pg: &mut Page, lay: &SheetLayout, which: gridcraft_model::
|
||||
if sec.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0) * text_scale;
|
||||
let size = sec.size.unwrap_or(10.0).clamp(1.0, 72.0);
|
||||
let font = Font::styled(gridcraft_pdf::Family::Sans, sec.bold, sec.italic);
|
||||
let lines: Vec<&str> = sec.text.split('\n').take(20).collect();
|
||||
let n = lines.len() as f32;
|
||||
@@ -1572,9 +1528,6 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let mut page_no = 0usize;
|
||||
let mut pending_links = Vec::new();
|
||||
let mut printed_blocks = Vec::new();
|
||||
// Decoded pictures are reused across the pages their anchor covers: a floating picture
|
||||
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
|
||||
let mut pics: std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>> = std::collections::HashMap::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -1611,7 +1564,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
y += seg_size(sh, *rs, true) * k;
|
||||
}
|
||||
if let Some(b) = &body {
|
||||
draw_objects(pg, wb, lay.sheet, sh, b, &mut pics);
|
||||
draw_objects(pg, wb, lay.sheet, sh, b);
|
||||
}
|
||||
if lay.settings.headings {
|
||||
let fill = [0xF2, 0xF2, 0xF2];
|
||||
@@ -2041,40 +1994,6 @@ mod tests {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
/// The x position and font size of the first `(text) Tj` in a PDF.
|
||||
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
let before = &s[..s.find(&format!("({text}) Tj"))?];
|
||||
let tf = before.rfind(" Tf")?;
|
||||
let size = before[..tf].rsplit(' ').next()?.parse().ok()?;
|
||||
let tm = before.rfind(" Tm")?;
|
||||
let mut nums = before[..tm].rsplit([' ', '\n']);
|
||||
let _y = nums.next()?;
|
||||
let x = nums.next()?.parse().ok()?;
|
||||
Some((x, size))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_scale_with_the_sheet_and_align_with_its_margins_unless_turned_off() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
s.execute("pageLayout.pageSetup", json!({"scale": 50, "left": 1.5, "right": 1.5})).unwrap();
|
||||
s.execute("pageLayout.headerFooter", json!({"header": "&LLeft&C&14Middle"})).unwrap();
|
||||
// Both on (the default): the text prints at half size, from the 1.5-inch left margin.
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(text_at(&pdf, "Left"), Some((108.0, 5.0)));
|
||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(7.0));
|
||||
let r = s.execute("pageLayout.headerFooter", json!({"scaleWithDoc": false, "alignWithMargins": false})).unwrap();
|
||||
assert_eq!((&r["scaleWithDoc"], &r["alignWithMargins"]), (&json!(false), &json!(false)));
|
||||
// Both off: full size, 0.7 inch from the paper's left edge.
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(text_at(&pdf, "Left"), Some((50.4, 10.0)));
|
||||
assert_eq!(text_at(&pdf, "Middle").map(|t| t.1), Some(14.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let pr = &s.doc().unwrap().wb.sheet(0).unwrap().print;
|
||||
assert!(pr.hf_scale_with_doc && pr.hf_align_with_margins);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -90,7 +90,7 @@ fn has_comments(s: &Session) -> std::result::Result<(), String> {
|
||||
fn step_comment(s: &mut Session, forward: bool) -> Result<Json> {
|
||||
let list = comment_cells(s);
|
||||
let d = s.doc()?;
|
||||
let here = (d.wb.active_sheet(), d.selection.active);
|
||||
let here = (d.wb.active_sheet, d.selection.active);
|
||||
let target = if forward {
|
||||
list.iter().find(|c| **c > here).or_else(|| list.first())
|
||||
} else {
|
||||
@@ -374,7 +374,7 @@ mod tests {
|
||||
|
||||
fn at(s: &Session) -> (usize, String) {
|
||||
let d = s.doc().unwrap();
|
||||
(d.wb.active_sheet(), d.selection.active.a1())
|
||||
(d.wb.active_sheet, d.selection.active.a1())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -443,7 +443,7 @@ mod tests {
|
||||
);
|
||||
let prot: SheetProtection = serde_json::from_str(&old).unwrap();
|
||||
assert_eq!((prot.legacy_password_hash, prot.password.is_none()), (Some(password_hash("old")), true));
|
||||
let i = s.doc().unwrap().wb.active_sheet();
|
||||
let i = s.doc().unwrap().wb.active_sheet;
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
std::sync::Arc::make_mut(&mut wb.sheets[i]).protection = Some(prot);
|
||||
assert!(s.execute("review.unprotectSheet", json!({"password": "new"})).is_err());
|
||||
|
||||
@@ -7,10 +7,7 @@ use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
|
||||
str_param, target_range, target_sheet, u32_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -206,13 +203,11 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
|
||||
o.anchor = a;
|
||||
}
|
||||
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
|
||||
Some(c) => {
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
if let Some(c) = map_cell(sp.cell) {
|
||||
sp.cell = c;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
}
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa);
|
||||
}
|
||||
@@ -275,29 +270,6 @@ fn cancel_copy_mode(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
|
||||
if at == 0 {
|
||||
return;
|
||||
}
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
@@ -307,7 +279,13 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
|
||||
// Refuse to push data off the sheet.
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
@@ -319,7 +297,15 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, true);
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
if at > 0 {
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
cx.structural = true;
|
||||
@@ -415,10 +401,8 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("To prevent possible loss of data, columns cannot be shifted off of the worksheet.".into()));
|
||||
}
|
||||
// The checks must authorize the sheet this command modifies (`target_sheet`), not whatever
|
||||
// sheet is active: both carry the command's own `sheet` parameter on.
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1(), "sheet": p.get("sheet")}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1(), "sheet": p.get("sheet")}), Axis::Cols, from, count, false)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(before, before).a1()}), Axis::Cols, before, count, true)?;
|
||||
check_lines_protection(s, &json!({"cols": RangeRef::cols(from, end - 1).a1()}), Axis::Cols, from, count, false)?;
|
||||
|
||||
let moved_from = if before <= from { from + count } else { from };
|
||||
if moved_from.checked_add(count).is_none_or(|end| end > MAX_COLS) {
|
||||
@@ -707,7 +691,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let before = p.get("before").and_then(Json::as_u64).map(|v| v as usize);
|
||||
let idx = edit_doc(d, |cx| {
|
||||
// Excel inserts before the active sheet; the "+" button appends after it.
|
||||
let at = before.unwrap_or(cx.wb.active_sheet() + 1).min(cx.wb.sheets.len());
|
||||
let at = before.unwrap_or(cx.wb.active_sheet + 1).min(cx.wb.sheets.len());
|
||||
cx.wb.sheets.insert(at, Arc::new(Sheet::new(name.clone())));
|
||||
// Names scoped to later sheets shift.
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
@@ -717,7 +701,7 @@ fn insert_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*sc += 1;
|
||||
}
|
||||
}
|
||||
cx.wb.set_active_sheet(at);
|
||||
cx.wb.active_sheet = at;
|
||||
*cx.sel = crate::Selection::default();
|
||||
cx.structural = true;
|
||||
Ok(at)
|
||||
@@ -805,8 +789,8 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if cx.wb.active_sheet() >= cx.wb.sheets.len() || cx.wb.active_sheet() > i {
|
||||
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1)));
|
||||
if cx.wb.active_sheet >= cx.wb.sheets.len() || cx.wb.active_sheet > i {
|
||||
cx.wb.active_sheet = cx.wb.active_sheet.saturating_sub(1).min(cx.wb.sheets.len().saturating_sub(1));
|
||||
}
|
||||
*cx.sel = crate::Selection::default();
|
||||
cx.structural = true;
|
||||
@@ -920,12 +904,12 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
cx.wb.names.extend(copies);
|
||||
cx.wb.sheets.insert(at, Arc::new(sh));
|
||||
cx.wb.set_active_sheet(at);
|
||||
cx.wb.active_sheet = at;
|
||||
} else {
|
||||
let sh = cx.wb.sheets.remove(i);
|
||||
let at = to.min(n - 1);
|
||||
cx.wb.sheets.insert(at, sh);
|
||||
cx.wb.set_active_sheet(at);
|
||||
cx.wb.active_sheet = at;
|
||||
// Sheet-level names follow their sheet.
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut() {
|
||||
@@ -962,8 +946,8 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(i)?.visibility = Visibility::Hidden;
|
||||
if cx.wb.active_sheet() == i {
|
||||
cx.wb.set_active_sheet(cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0));
|
||||
if cx.wb.active_sheet == i {
|
||||
cx.wb.active_sheet = cx.wb.sheets.iter().position(|s| s.visibility == Visibility::Visible).unwrap_or(0);
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -984,7 +968,7 @@ fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(i)?.visibility = Visibility::Visible;
|
||||
cx.wb.set_active_sheet(i);
|
||||
cx.wb.active_sheet = i;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -998,7 +982,7 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut i = d.wb.active_sheet();
|
||||
let mut i = d.wb.active_sheet;
|
||||
while let Some(next) = i.checked_add_signed(dir) {
|
||||
let Some(sheet) = d.wb.sheet(next) else { break };
|
||||
if sheet.visibility == Visibility::Visible {
|
||||
|
||||
@@ -165,7 +165,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (idx, id) = find_slicer(s, p)?;
|
||||
let value = str_param(p, "value").ok_or_else(|| bad("slicer.toggle", "missing `value`"))?.to_string();
|
||||
let multi = bool_param(p, "multi").unwrap_or(false);
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let (table, column, current) = {
|
||||
let d = s.doc()?;
|
||||
let sl = d.wb.active().and_then(|sh| sh.slicers.get(idx)).ok_or_else(|| bad("slicer.toggle", "no slicer"))?;
|
||||
@@ -204,7 +204,7 @@ fn slicer_toggle(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (idx, id) = find_slicer(s, p)?;
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
if let Some(sl) = cx.sheet_mut(sheet)?.slicers.get_mut(idx) {
|
||||
sl.selected = None;
|
||||
@@ -217,7 +217,7 @@ fn slicer_select_all(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn slicer_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (idx, id) = find_slicer(s, p)?;
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
// Absolute column of the slicer, so its filter criterion can be removed too.
|
||||
let abs_col = s.doc().ok().and_then(|d| {
|
||||
let sh = d.wb.active()?;
|
||||
|
||||
@@ -28,10 +28,8 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::check_arrays;
|
||||
use super::whatif::Trial;
|
||||
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
|
||||
use gridcraft_core::split_sheet;
|
||||
use super::*;
|
||||
|
||||
const CMD: &str = "data.solver";
|
||||
const REPORT_CMD: &str = "data.solverReport";
|
||||
@@ -301,7 +299,7 @@ fn parse_model(s: &Session, p: &Json) -> Result<Model> {
|
||||
let d = s.doc()?;
|
||||
let wb = &d.wb;
|
||||
let obj_text = str_param(p, "objective").or_else(|| str_param(p, "setObjective")).or_else(|| str_param(p, "set")).unwrap_or("");
|
||||
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet(), 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
|
||||
let (sheet, obj) = cells_of(wb, obj_text, wb.active_sheet, 1).map_err(|_| EngineError::Other("The objective must be a single cell.".into()))?;
|
||||
let objective = *obj.first().ok_or_else(|| EngineError::Other("The objective must be a single cell.".into()))?;
|
||||
let sh = wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
if sh.cell(objective).is_none_or(|c| c.formula.is_none()) {
|
||||
@@ -1707,26 +1705,6 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
|
||||
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
|
||||
}
|
||||
|
||||
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
let m = inp.model;
|
||||
let mut trial = Trial::new(d);
|
||||
@@ -1790,7 +1768,29 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
Rhs::None => vec![],
|
||||
};
|
||||
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
|
||||
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
rows.push(vec![
|
||||
Value::text(abs_ref(*cell)),
|
||||
Value::text(cell_name(sh, *cell)),
|
||||
num(*v),
|
||||
Value::text(formula),
|
||||
Value::text(status),
|
||||
num(slack),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
|
||||
@@ -1913,8 +1913,8 @@ fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<St
|
||||
*sc += count;
|
||||
}
|
||||
}
|
||||
if cx.wb.active_sheet() >= at {
|
||||
cx.wb.set_active_sheet(cx.wb.active_sheet().saturating_add(count));
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
names
|
||||
@@ -1939,7 +1939,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let params = match p.get("model") {
|
||||
Some(m @ Json::Object(_)) => m.clone(),
|
||||
Some(Json::Null) | None => {
|
||||
let mut params = saved_model(&d.wb, d.wb.active_sheet()).unwrap_or_else(|| json!({}));
|
||||
let mut params = saved_model(&d.wb, d.wb.active_sheet).unwrap_or_else(|| json!({}));
|
||||
if let (Some(base), Some(over)) = (params.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in over {
|
||||
base.insert(k.clone(), v.clone());
|
||||
@@ -1988,7 +1988,7 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let active = d.wb.active_sheet();
|
||||
let active = d.wb.active_sheet;
|
||||
let mut params = p.clone();
|
||||
if bool_param(p, "saved").unwrap_or(false) {
|
||||
let saved = saved_model(&d.wb, active).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?;
|
||||
@@ -2145,7 +2145,7 @@ mod tests {
|
||||
/// The first saved-model name scoped to the active sheet.
|
||||
fn saved(s: &Session) -> String {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet())).map(|n| n.name.clone()).unwrap_or_default()
|
||||
d.wb.names.iter().find(|n| n.scope == Some(d.wb.active_sheet)).map(|n| n.name.clone()).unwrap_or_default()
|
||||
}
|
||||
fn set(s: &mut Session, cells: &[(&str, &str)]) {
|
||||
for (c, v) in cells {
|
||||
|
||||
@@ -44,15 +44,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.prefs.formula_bar = bool_param(p, "on").unwrap_or(!s.prefs.formula_bar);
|
||||
Ok(json!({"on": s.prefs.formula_bar}))
|
||||
}),
|
||||
cmd!(
|
||||
query "view.statusBar",
|
||||
"Customize Status Bar",
|
||||
[],
|
||||
None,
|
||||
"{average?, count?, numericalCount?, minimum?, maximum?, sum?: bool (the statistics shown for a selection; returns all six)}",
|
||||
always,
|
||||
status_bar
|
||||
),
|
||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||
cmd!(
|
||||
@@ -124,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Header & Footer",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, scaleWithDoc?: bool, alignWithMargins?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, scaleWithDoc, alignWithMargins, firstHeader, firstFooter, evenHeader, evenFooter}",
|
||||
"{sheet?, header?: \"&C&P\", footer?: \"&CPage &P of &N\", differentFirst?: bool, differentOddEven?: bool, firstHeader?, firstFooter?, evenHeader?, evenFooter?} or one section: {sheet?, page?: odd|even|first, part: header|footer, section: left|center|right, text: \"Page &[Page] of &[Pages]\"} (field names or codes) → {header, footer, differentFirst, differentOddEven, firstHeader, firstFooter, evenHeader, evenFooter}",
|
||||
has_doc,
|
||||
header_footer
|
||||
),
|
||||
@@ -149,7 +140,7 @@ fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let at = match cell_param_on(s, p, "cell", si)? {
|
||||
Some(c) => c,
|
||||
None if si == d.wb.active_sheet() => d.selection.active,
|
||||
None if si == d.wb.active_sheet => d.selection.active,
|
||||
// The selection belongs to the active sheet.
|
||||
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
|
||||
};
|
||||
@@ -196,36 +187,12 @@ fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
fn status_bar(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let st = &mut s.prefs.status_bar;
|
||||
for (key, flag) in [
|
||||
("average", &mut st.average),
|
||||
("count", &mut st.count),
|
||||
("numericalCount", &mut st.numerical_count),
|
||||
("minimum", &mut st.minimum),
|
||||
("maximum", &mut st.maximum),
|
||||
("sum", &mut st.sum),
|
||||
] {
|
||||
if let Some(on) = bool_param(p, key) {
|
||||
*flag = on;
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"average": st.average,
|
||||
"count": st.count,
|
||||
"numericalCount": st.numerical_count,
|
||||
"minimum": st.minimum,
|
||||
"maximum": st.maximum,
|
||||
"sum": st.sum,
|
||||
}))
|
||||
}
|
||||
|
||||
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
||||
let d = s.doc_mut()?;
|
||||
let dirty = d.is_dirty();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let si = wb.active_sheet();
|
||||
let si = wb.active_sheet;
|
||||
if let Some(sh) = wb.sheet_mut(si) {
|
||||
sh.zoom = z;
|
||||
}
|
||||
@@ -425,12 +392,6 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(on) = bool_param(p, "differentOddEven") {
|
||||
pr.different_odd_even = on;
|
||||
}
|
||||
if let Some(on) = bool_param(p, "scaleWithDoc") {
|
||||
pr.hf_scale_with_doc = on;
|
||||
}
|
||||
if let Some(on) = bool_param(p, "alignWithMargins") {
|
||||
pr.hf_align_with_margins = on;
|
||||
}
|
||||
if let Some((page, footer, idx, text)) = §ion {
|
||||
let spec = pr.hf_mut(*page, *footer);
|
||||
let mut parts = super::print::split_header_footer(spec);
|
||||
@@ -444,8 +405,6 @@ fn header_footer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"footer": pr.footer,
|
||||
"differentFirst": pr.different_first,
|
||||
"differentOddEven": pr.different_odd_even,
|
||||
"scaleWithDoc": pr.hf_scale_with_doc,
|
||||
"alignWithMargins": pr.hf_align_with_margins,
|
||||
"firstHeader": pr.first_header,
|
||||
"firstFooter": pr.first_footer,
|
||||
"evenHeader": pr.even_header,
|
||||
@@ -519,22 +478,4 @@ mod tests {
|
||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||
assert!(old.formula_bar);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_statistics_default_to_average_count_and_sum_and_turn_on_one_at_a_time() {
|
||||
let mut s = Session::new();
|
||||
let all = json!({"average": true, "count": true, "numericalCount": false, "minimum": false, "maximum": false, "sum": true});
|
||||
assert_eq!(s.execute("view.statusBar", json!({})).unwrap(), all);
|
||||
let r = s.execute("view.statusBar", json!({"minimum": true, "sum": false, "count": "yes"})).unwrap();
|
||||
assert_eq!(r, json!({"average": true, "count": true, "numericalCount": false, "minimum": true, "maximum": false, "sum": false}));
|
||||
assert!(s.prefs.status_bar.minimum && !s.prefs.status_bar.sum);
|
||||
// A prefs.json saved before the option existed, or with only some of the six, keeps the defaults for the rest.
|
||||
let old: crate::Prefs = serde_json::from_str(r#"{"userName": "Ann"}"#).unwrap();
|
||||
assert_eq!(old.status_bar, crate::StatusBarStats::default());
|
||||
let part: crate::Prefs = serde_json::from_str(r#"{"statusBar": {"maximum": true}}"#).unwrap();
|
||||
assert!(part.status_bar.maximum && part.status_bar.average && part.status_bar.sum && !part.status_bar.minimum);
|
||||
let saved = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(saved["statusBar"]["minimum"], true);
|
||||
assert_eq!(saved["statusBar"]["numericalCount"], false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
|
||||
let d = s.doc()?;
|
||||
let t = str_param(p, key).ok_or_else(|| bad(cmd, format!("missing `{key}`")))?;
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = sheet_named(&d.wb, sh, d.wb.active_sheet(), cmd)?;
|
||||
let si = sheet_named(&d.wb, sh, d.wb.active_sheet, cmd)?;
|
||||
let c = CellRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("`{key}` is not a cell: `{t}`")))?;
|
||||
Ok((si, c))
|
||||
}
|
||||
@@ -110,7 +110,7 @@ fn cell_arg(s: &Session, p: &Json, key: &str, cmd: &str) -> Result<(usize, CellR
|
||||
/// are trimmed to the used range.
|
||||
pub(crate) fn parse_range(wb: &Workbook, t: &str, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
let (sh, body) = split_sheet(t);
|
||||
let si = sheet_named(wb, sh, wb.active_sheet(), cmd)?;
|
||||
let si = sheet_named(wb, sh, wb.active_sheet, cmd)?;
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(cmd, format!("not a range: `{t}`")))?;
|
||||
Ok((si, trim_to_used(wb, si, r)))
|
||||
}
|
||||
@@ -152,7 +152,7 @@ fn cell_list(wb: &Workbook, t: &str, cmd: &str, cap: usize) -> Result<(usize, Ve
|
||||
return Err(bad(cmd, format!("too many cells (at most {cap})")));
|
||||
}
|
||||
}
|
||||
Ok((sheet.unwrap_or(wb.active_sheet()), out))
|
||||
Ok((sheet.unwrap_or(wb.active_sheet), out))
|
||||
}
|
||||
|
||||
/// What a JSON value typed into a cell is, as a constant.
|
||||
@@ -525,7 +525,7 @@ fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioSummary";
|
||||
let d = s.doc()?;
|
||||
let si = d.wb.active_sheet();
|
||||
let si = d.wb.active_sheet;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let scenarios: Vec<Scenario> = load_scenarios(&d.wb).into_iter().filter(|x| x.sheet.eq_ignore_ascii_case(&sh.name)).collect();
|
||||
if scenarios.is_empty() {
|
||||
@@ -799,7 +799,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(t) => parse_range(&d.wb, t, CMD)?,
|
||||
None => {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
(d.wb.active_sheet(), crate::selection::current_region(sh, d.selection.active))
|
||||
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
@@ -1024,7 +1024,7 @@ fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
(si, r.start)
|
||||
}
|
||||
None => (d.wb.active_sheet(), d.selection.active),
|
||||
None => (d.wb.active_sheet, d.selection.active),
|
||||
};
|
||||
let mut row_keys: Vec<Key> = Vec::new();
|
||||
let mut col_keys: Vec<Key> = Vec::new();
|
||||
@@ -1146,7 +1146,7 @@ mod tests {
|
||||
fn v(s: &Session, a: &str) -> Value {
|
||||
let d = s.doc().unwrap();
|
||||
let (sh, body) = super::split_sheet(a);
|
||||
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet());
|
||||
let si = sh.map(|n| d.wb.sheet_index(&n).unwrap()).unwrap_or(d.wb.active_sheet);
|
||||
d.wb.sheet(si).unwrap().value(CellRef::parse(body).unwrap())
|
||||
}
|
||||
fn n(s: &Session, a: &str) -> f64 {
|
||||
|
||||
+8
-147
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
||||
|
||||
use crate::Result;
|
||||
@@ -23,43 +23,15 @@ pub enum FillMode {
|
||||
ValuesOnly,
|
||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||
Extend,
|
||||
/// Fill Days, Fill Weekdays, Fill Months or Fill Years: a source of dates steps by that unit.
|
||||
/// A lone date steps by one unit; two or more step by the difference between the first two,
|
||||
/// counted in that unit (one unit when they fall in the same one). A source that isn't dates
|
||||
/// extends like a plain drag.
|
||||
Dates(DateUnit),
|
||||
}
|
||||
|
||||
/// The step of a [`FillMode::Dates`] fill.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DateUnit {
|
||||
Day,
|
||||
/// Monday to Friday: Saturday and Sunday are skipped.
|
||||
Weekday,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl FillMode {
|
||||
/// The modes that continue the source's series rather than copy it.
|
||||
fn extends_series(self) -> bool {
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly | FillMode::Dates(_))
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a number format shows a calendar date (`YYYY-MM-DD` and `m/d/yyyy h:mm` do; `0 "days"`
|
||||
/// and `h:mm` don't).
|
||||
fn is_date_format(fmt: &str) -> bool {
|
||||
NumberFormat::parse(fmt).kind() == FormatKind::Date
|
||||
}
|
||||
|
||||
/// Whether the first cell of `src` holds a date, so a fill from it can step by days, weekdays,
|
||||
/// months or years: a number (not a formula) shown in a date format.
|
||||
pub fn starts_with_date(wb: &Workbook, sheet: usize, src: RangeRef) -> bool {
|
||||
let Some(cell) = wb.sheet(sheet).and_then(|sh| sh.cell(src.start)) else { return false };
|
||||
cell.formula.is_none() && cell.value.is_number() && is_date_format(wb.styles.get(cell.style).num_fmt.as_str())
|
||||
}
|
||||
|
||||
/// Built-in custom lists.
|
||||
fn lists() -> Vec<Vec<String>> {
|
||||
let short_days: Vec<String> = WEEKDAYS.iter().map(|d| d.get(..3).unwrap_or(d).to_string()).collect();
|
||||
@@ -130,12 +102,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
};
|
||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
|
||||
let dates = match mode {
|
||||
FillMode::Dates(unit) => Pattern::dates(&src_cells, unit, cx.wb.date_system, first_fmt),
|
||||
_ => None,
|
||||
};
|
||||
let detected = dates.unwrap_or_else(|| Pattern::detect(&src_cells, &custom, cx.wb.date_system, first_fmt));
|
||||
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||||
match (mode, detected) {
|
||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||
(_, p) => p,
|
||||
@@ -163,7 +130,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend | FillMode::Dates(_) => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
@@ -237,63 +204,9 @@ enum Pattern {
|
||||
months: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
/// Dates stepping by weekdays, skipping Saturday and Sunday.
|
||||
Weekdays {
|
||||
last: f64,
|
||||
first: f64,
|
||||
step: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
}
|
||||
|
||||
impl Pattern {
|
||||
/// The pattern of Fill Days, Fill Weekdays, Fill Months or Fill Years, or `None` when the
|
||||
/// source isn't all dates (numbers, not formulas, under a date format in its first cell).
|
||||
fn dates(cells: &[(CellRef, Option<Cell>)], unit: DateUnit, sys: gridcraft_core::DateSystem, fmt: &str) -> Option<Pattern> {
|
||||
if !is_date_format(fmt) {
|
||||
return None;
|
||||
}
|
||||
let mut nums = Vec::with_capacity(cells.len());
|
||||
for (_, cell) in cells {
|
||||
let cell = cell.as_ref()?;
|
||||
if cell.formula.is_some() || !cell.value.is_number() {
|
||||
return None;
|
||||
}
|
||||
nums.push(cell.value.as_f64()?);
|
||||
}
|
||||
let first = *nums.first()?;
|
||||
let last = *nums.last()?;
|
||||
let second = nums.get(1).copied();
|
||||
// The difference between the first two dates in the unit, and one unit when they fall in
|
||||
// the same one.
|
||||
let at_least_one = |n: i64| if n == 0 { 1 } else { n };
|
||||
Some(match unit {
|
||||
DateUnit::Day => {
|
||||
let days = second.map_or(1.0, |b| b.floor() - first.floor());
|
||||
Pattern::Linear { last, first, step: if days == 0.0 { 1.0 } else { days } }
|
||||
}
|
||||
DateUnit::Weekday => {
|
||||
let step = match second {
|
||||
Some(b) => at_least_one(weekdays_between(sys, first, b)?),
|
||||
None => 1,
|
||||
};
|
||||
Pattern::Weekdays { last, first, step, sys }
|
||||
}
|
||||
DateUnit::Month | DateUnit::Year => {
|
||||
let months = match second {
|
||||
Some(b) => {
|
||||
let (a, b) = (datetime_from_serial(sys, first)?, datetime_from_serial(sys, b)?);
|
||||
let years = b.year as i64 - a.year as i64;
|
||||
if unit == DateUnit::Year { at_least_one(years) * 12 } else { at_least_one(years * 12 + b.month as i64 - a.month as i64) }
|
||||
}
|
||||
None if unit == DateUnit::Year => 12,
|
||||
None => 1,
|
||||
};
|
||||
Pattern::Months { last, first, months, sys }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||||
@@ -430,72 +343,20 @@ impl Pattern {
|
||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
Pattern::Weekdays { last, first, step, sys } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
Some(Value::number(add_weekdays(*sys, base, step.checked_mul(k)?.checked_mul(sign)?)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `serial` falls on Monday to Friday; `None` outside the supported date range.
|
||||
fn is_weekday(sys: gridcraft_core::DateSystem, serial: f64) -> Option<bool> {
|
||||
datetime_from_serial(sys, serial).map(|d| d.weekday != 0 && d.weekday != 6)
|
||||
}
|
||||
|
||||
/// `serial` moved by `n` weekdays (back when `n` is negative), keeping its time of day. A start on
|
||||
/// a Saturday or Sunday moves to the next (or previous) weekday for the first step.
|
||||
fn add_weekdays(sys: gridcraft_core::DateSystem, serial: f64, n: i64) -> Option<f64> {
|
||||
if n == 0 {
|
||||
return Some(serial);
|
||||
}
|
||||
let dir = n.signum() as f64;
|
||||
let mut d = serial;
|
||||
let next_weekday = |d: &mut f64| -> Option<()> {
|
||||
loop {
|
||||
*d += dir;
|
||||
if is_weekday(sys, *d)? {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
};
|
||||
next_weekday(&mut d)?;
|
||||
// From a weekday, every seven days hold five weekdays.
|
||||
let rest = n.unsigned_abs() - 1;
|
||||
d += dir * (rest / 5 * 7) as f64;
|
||||
for _ in 0..rest % 5 {
|
||||
next_weekday(&mut d)?;
|
||||
}
|
||||
datetime_from_serial(sys, d)?;
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// How many weekdays lie after `a` up to and including `b` (negative when `b` comes first),
|
||||
/// counting whole days; `None` outside the supported date range.
|
||||
fn weekdays_between(sys: gridcraft_core::DateSystem, a: f64, b: f64) -> Option<i64> {
|
||||
let (lo, hi, sign) = if b >= a { (a.floor(), b.floor(), 1) } else { (b.floor(), a.floor(), -1) };
|
||||
let days = (hi - lo) as i64;
|
||||
let mut count = days / 7 * 5;
|
||||
let mut d = lo + (days / 7 * 7) as f64;
|
||||
for _ in 0..days % 7 {
|
||||
d += 1.0;
|
||||
if is_weekday(sys, d)? {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Some(count * sign)
|
||||
}
|
||||
|
||||
/// `serial` shifted by `months` calendar months, keeping the day of month but clamping it to the
|
||||
/// target month's last day (Jan 31 + 1 month = Feb 28/29). Always measured from the original date,
|
||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30. The time of day is kept.
|
||||
/// so a series does not drift: Jan 31, Feb 28, Mar 31, Apr 30.
|
||||
fn add_months_clamped(sys: gridcraft_core::DateSystem, serial: f64, months: i64) -> Option<f64> {
|
||||
let d = datetime_from_serial(sys, serial)?;
|
||||
let total = (d.year as i64).checked_mul(12)?.checked_add(d.month as i64 - 1)?.checked_add(months)?;
|
||||
let year = total.div_euclid(12);
|
||||
let month = total.rem_euclid(12) + 1;
|
||||
let last = days_in_month(i32::try_from(year).ok()?, month as u32);
|
||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64)).map(|day| day + serial - serial.floor())
|
||||
serial_from_ymd(sys, year, month, (d.day as i64).min(last as i64))
|
||||
}
|
||||
|
||||
fn round15(x: f64) -> f64 {
|
||||
@@ -527,9 +388,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
||||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||||
let chars: Vec<char> = t.chars().collect();
|
||||
let end = chars.iter().rposition(char::is_ascii_digit)?;
|
||||
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
|
||||
let mut start = end;
|
||||
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
|
||||
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
|
||||
start -= 1;
|
||||
}
|
||||
let digits: String = chars.get(start..=end)?.iter().collect();
|
||||
|
||||
+7
-40
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||
let clean = clean.trim_matches('\'').to_string();
|
||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
||||
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
||||
let Some(kind) = FileKind::from_path(path) else {
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
||||
};
|
||||
save_bytes_as(wb, path, kind)
|
||||
@@ -234,12 +234,12 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
|
||||
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
|
||||
let sheet = wb.active_sheet();
|
||||
let sheet = wb.active_sheet;
|
||||
match kind {
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
FileKind::Xlsx => {
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
@@ -448,11 +448,6 @@ impl std::fmt::Write for ClipboardHtml {
|
||||
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
|
||||
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
|
||||
/// Rows × columns of one web-page table. Save as Web Page writes every kept row's full used
|
||||
/// column span into one growing String, so a sparse sheet with a lone far-right cell (or a
|
||||
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
|
||||
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
@@ -484,17 +479,6 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.height()
|
||||
)));
|
||||
}
|
||||
// The table is one growing String: every kept row writes a `<td>` for the whole used
|
||||
// column span (unless the sheet has no row that is that wide). A tall, wide sheet
|
||||
// (or a sparse one with a lone far-right cell) would be gigabytes before finishing,
|
||||
// so the rectangle itself is bounded (#2387).
|
||||
if u64::from(r.height()) * u64::from(r.width()) > HTML_EXPORT_MAX_CELLS {
|
||||
return Err(EngineError::Other(format!(
|
||||
"the sheet is too tall × wide for a web page ({} × {} cells, at most {HTML_EXPORT_MAX_CELLS}). Save as .xlsx or CSV instead.",
|
||||
r.height(),
|
||||
r.width()
|
||||
)));
|
||||
}
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -770,7 +754,7 @@ mod tests {
|
||||
fn save_fails_upfront_when_the_temp_name_is_taken() {
|
||||
// Another local user who can write the directory must not be able to point the
|
||||
// save's predictable temporary name at their own file: creation is exclusive.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-temp-name-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join("gridcraft-fs-safety");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim.txt");
|
||||
@@ -788,7 +772,7 @@ mod tests {
|
||||
#[test]
|
||||
fn save_keeps_the_destination_access_restrictions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-permissions-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join("gridcraft-fs-safety");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("private.xlsx");
|
||||
@@ -808,7 +792,7 @@ mod tests {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn open_refuses_directories_and_unknowable_lengths() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fs-safety-open-{}", std::process::id()));
|
||||
let dir = std::env::temp_dir().join("gridcraft-fs-safety");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
assert!(read_file(dir.to_str().unwrap()).is_err(), "a directory is not a regular file");
|
||||
@@ -816,23 +800,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_refuses_an_outsized_grid_instead_of_growing_forever() {
|
||||
// #2387: the used rectangle alone bounds the output (every row writes the whole
|
||||
// column span), so a sparse sheet with one cell at A1 and another far right
|
||||
// 300 rows down is 4.9M cells → refused before a byte of `<td>` is written.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::Text("start".into()));
|
||||
sh.set_value(CellRef::new(300, 16383), gridcraft_core::Value::Text("corner".into()));
|
||||
let err = to_html(&wb, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("too tall × wide"), "{err}");
|
||||
// A normal sheet is fine.
|
||||
let mut ok = Workbook::new();
|
||||
ok.sheet_mut(0).unwrap().set_value(CellRef::new(5, 2), gridcraft_core::Value::Text("t".into()));
|
||||
assert!(to_html(&ok, 0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_embeds_images_as_data_uris() {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
+13
-83
@@ -23,7 +23,6 @@ pub mod record;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
pub mod tables;
|
||||
pub mod windows;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -40,7 +39,6 @@ pub use gridcraft_formula as formula;
|
||||
pub use gridcraft_model as model;
|
||||
pub use gridcraft_numfmt as numfmt;
|
||||
pub use selection::Selection;
|
||||
pub use windows::{Target, TargetGuard, WindowState, WindowView};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum EngineError {
|
||||
@@ -182,8 +180,6 @@ pub struct DocState {
|
||||
pub path: Option<String>,
|
||||
pub saved: Arc<Workbook>,
|
||||
pub revision: u64,
|
||||
/// Identifies this open workbook for the rest of the session: it never changes while the
|
||||
/// workbook is open (saving, renaming, reordering) and is never reused for another one.
|
||||
pub uid: u64,
|
||||
pub title: String,
|
||||
/// Per-sheet selection memory.
|
||||
@@ -203,9 +199,6 @@ pub struct DocState {
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
|
||||
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
|
||||
pub go_to_recent: Vec<String>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -273,17 +266,6 @@ impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
/// A workbook as its file saved it: the dependency graph is built but nothing is
|
||||
/// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
|
||||
/// is as for [`DocState::new`].
|
||||
pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.rebuild(&wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
let wb = Arc::new(wb);
|
||||
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
|
||||
@@ -303,21 +285,20 @@ impl DocState {
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust,
|
||||
go_to_recent: vec![],
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!Arc::ptr_eq(&self.wb, &self.saved)
|
||||
}
|
||||
pub fn sheet_index(&self) -> usize {
|
||||
self.wb.active_sheet()
|
||||
self.wb.active_sheet
|
||||
}
|
||||
/// Records the view state that is saved with the file (`sheetView topLeftCell`, the active
|
||||
/// cell): the first visible cell of the scrolling area per sheet index in `tops`, and the
|
||||
/// active sheet's active cell. View state never makes the workbook dirty.
|
||||
pub fn store_view(&mut self, tops: &[(usize, CellRef)]) {
|
||||
let active = self.selection.active;
|
||||
let si = self.wb.active_sheet();
|
||||
let si = self.wb.active_sheet;
|
||||
let changed = tops.iter().any(|(i, c)| self.wb.sheets.get(*i).is_some_and(|sh| sh.view_top_left != *c))
|
||||
|| self.wb.sheets.get(si).is_some_and(|sh| sh.view_active != active);
|
||||
if !changed {
|
||||
@@ -366,27 +347,6 @@ pub struct Prefs {
|
||||
pub user_dictionary: Vec<String>,
|
||||
/// View › Show › Formula Bar (an application option in Excel, not saved in the file).
|
||||
pub formula_bar: bool,
|
||||
/// The statistics the status bar shows for a selection (right-click the status bar).
|
||||
pub status_bar: StatusBarStats,
|
||||
}
|
||||
|
||||
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
|
||||
/// Average, Count and Sum by default.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct StatusBarStats {
|
||||
pub average: bool,
|
||||
pub count: bool,
|
||||
pub numerical_count: bool,
|
||||
pub minimum: bool,
|
||||
pub maximum: bool,
|
||||
pub sum: bool,
|
||||
}
|
||||
|
||||
impl Default for StatusBarStats {
|
||||
fn default() -> Self {
|
||||
StatusBarStats { average: true, count: true, numerical_count: false, minimum: false, maximum: false, sum: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Prefs {
|
||||
@@ -401,7 +361,6 @@ impl Default for Prefs {
|
||||
r1c1: false,
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -413,9 +372,6 @@ pub enum UiRequest {
|
||||
Message(String),
|
||||
/// Start editing the active cell (F2) with optional initial text.
|
||||
EditCell(Option<String>),
|
||||
/// Put a defined name at the caret of the cell being edited, in place of selected text; with
|
||||
/// no edit under way, start editing the active cell with `=` and the name.
|
||||
InsertName(String),
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
@@ -429,15 +385,7 @@ pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
/// Index in `docs` of the active window's workbook.
|
||||
active: usize,
|
||||
/// Windows on the open workbooks ([`windows`]); every workbook has at least one.
|
||||
windows: Vec<WindowState>,
|
||||
active_window: Option<u64>,
|
||||
/// Last window id handed out.
|
||||
next_window: u64,
|
||||
/// Window activation counter (most recently used order).
|
||||
activation: u64,
|
||||
pub clipboard: Option<Clipboard>,
|
||||
pub prefs: Prefs,
|
||||
pub mode: Mode,
|
||||
@@ -460,6 +408,8 @@ pub struct Session {
|
||||
pub recorder: Option<record::Recorder>,
|
||||
/// How many `execute` calls are running (0 between top-level commands).
|
||||
depth: u32,
|
||||
/// Windows hidden with View › Hide.
|
||||
pub hidden_windows: Vec<usize>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
pub draw_tool: String,
|
||||
pub draw_color: String,
|
||||
@@ -495,24 +445,14 @@ impl Session {
|
||||
pub fn active_index(&self) -> usize {
|
||||
self.active
|
||||
}
|
||||
/// Makes the workbook at `i` active, in its most recently active window (unless the active
|
||||
/// window already shows it).
|
||||
pub fn set_active(&mut self, i: usize) {
|
||||
let Some(uid) = self.docs.get(i).map(|d| d.uid) else { return };
|
||||
if self.active == i && self.active_window.and_then(|w| self.window(w)).is_some_and(|w| w.doc_uid == uid) {
|
||||
return;
|
||||
}
|
||||
if let Some(w) = self.doc_window(uid) {
|
||||
self.activate_window(w);
|
||||
if i < self.docs.len() {
|
||||
self.active = i;
|
||||
}
|
||||
}
|
||||
pub fn active(&self) -> Option<&DocState> {
|
||||
self.docs.get(self.active)
|
||||
}
|
||||
/// Where the open workbook with this [`DocState::uid`] is in [`Session::documents`].
|
||||
pub fn document_index(&self, uid: u64) -> Option<usize> {
|
||||
self.docs.iter().position(|d| d.uid == uid)
|
||||
}
|
||||
pub fn active_mut(&mut self) -> Option<&mut DocState> {
|
||||
self.docs.get_mut(self.active)
|
||||
}
|
||||
@@ -522,24 +462,17 @@ impl Session {
|
||||
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
/// Opens a workbook in a new window, which becomes the active one.
|
||||
pub fn add_document(&mut self, d: DocState) -> usize {
|
||||
let uid = d.uid;
|
||||
self.docs.push(d);
|
||||
let w = self.push_window(uid);
|
||||
self.activate_window(w);
|
||||
self.docs.len() - 1
|
||||
self.active = self.docs.len() - 1;
|
||||
self.active
|
||||
}
|
||||
/// Closes a workbook and its windows; the most recently active remaining window comes forward.
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
if i < self.docs.len() {
|
||||
let d = self.docs.remove(i);
|
||||
self.groups.retain(|g| g.doc != d.uid);
|
||||
self.windows.retain(|w| w.doc_uid != d.uid);
|
||||
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
|
||||
self.activate_recent();
|
||||
} else if let Some(di) = self.active_window.and_then(|w| self.window(w)).and_then(|w| self.document_index(w.doc_uid)) {
|
||||
self.active = di;
|
||||
if self.active >= self.docs.len() {
|
||||
self.active = self.docs.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -623,7 +556,7 @@ impl Session {
|
||||
};
|
||||
// The recorder keeps top-level calls with the place they ran in.
|
||||
let place = (self.depth == 0 && self.recorder.is_some()).then(|| match self.active() {
|
||||
Some(d) => (d.uid, d.wb.active_sheet(), d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
|
||||
Some(d) => (d.uid, d.wb.active_sheet, d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(), d.selection.clone()),
|
||||
None => (0, 0, String::new(), Selection::default()),
|
||||
});
|
||||
let doc_uid = before.as_ref().map(|(uid, ..)| *uid);
|
||||
@@ -639,11 +572,8 @@ impl Session {
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg = p
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".into());
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||
// command opened.
|
||||
self.groups.retain(|g| g.id < first_group);
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
//! Outline groups (Data ▸ Group) of a sheet's rows or columns: the runs of lines at or above
|
||||
//! each outline level, as the grid draws them (brackets, − / + buttons, level buttons) and the
|
||||
//! Show / Hide Detail commands act on them. Summary rows sit below their detail and summary
|
||||
//! columns right of it, or above / left of it when the sheet's outline settings say so
|
||||
//! ([`Sheet::summary_below`], [`Sheet::summary_right`]).
|
||||
//! columns right of it (Excel's defaults; the model has no setting for the other way round).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gridcraft_model::{LineInfo, Sheet};
|
||||
use gridcraft_model::LineInfo;
|
||||
|
||||
/// The deepest outline level XLSX allows.
|
||||
pub const MAX_LEVEL: u8 = 7;
|
||||
|
||||
/// One group: lines `start..=end` at outline level `level` or deeper, with its summary line
|
||||
/// just after `end`, or just before `start` when `summary_before` is set.
|
||||
/// just after `end`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OutlineGroup {
|
||||
pub level: u8,
|
||||
@@ -20,43 +19,25 @@ pub struct OutlineGroup {
|
||||
pub end: u32,
|
||||
/// Every detail line is hidden: the button shows +.
|
||||
pub collapsed: bool,
|
||||
/// The summary line is above (rows) or left of (columns) the detail.
|
||||
pub summary_before: bool,
|
||||
}
|
||||
|
||||
impl OutlineGroup {
|
||||
/// The summary line, where the − / + button sits.
|
||||
pub fn summary(&self) -> u32 {
|
||||
if self.summary_before { self.start.saturating_sub(1) } else { self.end.saturating_add(1) }
|
||||
}
|
||||
/// Whether the summary line exists: a group that starts at the first line (summary before)
|
||||
/// or ends at the last one (summary after) has none.
|
||||
pub fn has_summary(&self) -> bool {
|
||||
!self.contains(self.summary())
|
||||
self.end.saturating_add(1)
|
||||
}
|
||||
pub fn contains(&self, line: u32) -> bool {
|
||||
line >= self.start && line <= self.end
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the sheet's summary rows (`rows`) or summary columns sit before their detail.
|
||||
pub fn summary_before(sh: &Sheet, rows: bool) -> bool {
|
||||
if rows { !sh.summary_below } else { !sh.summary_right }
|
||||
}
|
||||
|
||||
/// The deepest outline level used by these lines (0 = no outline).
|
||||
pub fn max_level(lines: &BTreeMap<u32, LineInfo>) -> u8 {
|
||||
lines.values().map(|l| l.outline.min(MAX_LEVEL)).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Every group, with its summary line after the detail; see [`groups_with`].
|
||||
/// Every group, ordered by where it ends (inner groups before the group around them).
|
||||
pub fn groups(lines: &BTreeMap<u32, LineInfo>) -> Vec<OutlineGroup> {
|
||||
groups_with(lines, false)
|
||||
}
|
||||
|
||||
/// Every group, ordered by where it ends (inner groups before the group around them), with its
|
||||
/// summary line before the detail when `summary_before` is set.
|
||||
pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec<OutlineGroup> {
|
||||
let mut out = Vec::new();
|
||||
// Open group per level (index level - 1): start line and whether all its lines are hidden.
|
||||
let mut open: [Option<(u32, bool)>; MAX_LEVEL as usize] = [None; MAX_LEVEL as usize];
|
||||
@@ -66,7 +47,7 @@ pub fn groups_with(lines: &BTreeMap<u32, LineInfo>, summary_before: bool) -> Vec
|
||||
if let Some(slot) = open.get_mut(level as usize - 1)
|
||||
&& let Some((start, hidden)) = slot.take()
|
||||
{
|
||||
out.push(OutlineGroup { level, start, end, collapsed: hidden, summary_before });
|
||||
out.push(OutlineGroup { level, start, end, collapsed: hidden });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -121,9 +102,9 @@ mod tests {
|
||||
assert_eq!(
|
||||
g,
|
||||
vec![
|
||||
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false, summary_before: false },
|
||||
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false, summary_before: false },
|
||||
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true, summary_before: false },
|
||||
OutlineGroup { level: 2, start: 5, end: 6, collapsed: false },
|
||||
OutlineGroup { level: 1, start: 4, end: 7, collapsed: false },
|
||||
OutlineGroup { level: 1, start: 11, end: 12, collapsed: true },
|
||||
]
|
||||
);
|
||||
assert_eq!(g[1].summary(), 8);
|
||||
@@ -148,22 +129,5 @@ mod tests {
|
||||
assert_eq!(g.len(), 7);
|
||||
assert!(g.iter().all(|g| g.start == u32::MAX - 1 && g.end == u32::MAX));
|
||||
assert_eq!(g[0].summary(), u32::MAX);
|
||||
assert!(!g[0].has_summary());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_before_the_detail() {
|
||||
// Rows 5..8 at level 1 with 6..7 at level 2: the summaries are rows 4 and 5.
|
||||
let l = lines(&[(4, 1, false), (5, 2, false), (6, 2, false), (7, 1, false), (0, 1, false)]);
|
||||
let g = groups_with(&l, true);
|
||||
let at = |level: u8, start: u32| g.iter().find(|g| g.level == level && g.start == start).copied();
|
||||
assert_eq!(at(1, 4).map(|g| (g.summary(), g.has_summary())), Some((3, true)));
|
||||
assert_eq!(at(2, 5).map(|g| (g.summary(), g.has_summary())), Some((4, true)));
|
||||
// A group on the first line has no line before it.
|
||||
assert_eq!(at(1, 0).map(|g| (g.summary(), g.has_summary())), Some((0, false)));
|
||||
let mut sh = Sheet::new("S");
|
||||
assert!(!summary_before(&sh, true) && !summary_before(&sh, false));
|
||||
sh.summary_below = false;
|
||||
assert!(summary_before(&sh, true) && !summary_before(&sh, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,12 +29,6 @@ pub struct PageGrid {
|
||||
pub gap: f64,
|
||||
/// Print scale (1.0 = 100%): screen sizes of cells are `zoom × scale`.
|
||||
pub scale: f32,
|
||||
/// Left and right margins of headers and footers in layout units (the side margins, or a
|
||||
/// fixed 0.7 inch, at most a quarter of the page width, with Align with Page Margins off).
|
||||
pub hf_sides: [f64; 2],
|
||||
/// Layout units per sheet pixel of header and footer text: 1 with Scale with Document on,
|
||||
/// otherwise `1 / scale`, so the text keeps its point size on paper.
|
||||
pub hf_text_k: f32,
|
||||
/// Paper size in points (after orientation), for the rulers.
|
||||
pub paper_pt: (f32, f32),
|
||||
pub row_bands: Vec<(u32, u32)>,
|
||||
@@ -71,8 +65,6 @@ impl PageGrid {
|
||||
let s = f64::from(lay.scale.clamp(0.1, 4.0));
|
||||
let to_layout = |pt: f32| f64::from(pt) / f64::from(PX_TO_PT) / s;
|
||||
let margins = lay.margins.map(to_layout);
|
||||
let (hf_left, hf_right) = lay.hf_sides();
|
||||
let hf_text_k = lay.hf_text_scale() / s as f32;
|
||||
let band_range = |bands: &[(u32, u32)], lo: u32, hi: u32| {
|
||||
let first = bands.iter().position(|b| b.1 >= lo).unwrap_or(0);
|
||||
let last = bands.iter().rposition(|b| b.0 <= hi).unwrap_or(first).max(first);
|
||||
@@ -87,8 +79,6 @@ impl PageGrid {
|
||||
margins,
|
||||
gap: f64::from(PAGE_GAP) / s,
|
||||
scale: lay.scale,
|
||||
hf_sides: [to_layout(hf_left), to_layout(hf_right)],
|
||||
hf_text_k,
|
||||
paper_pt: (lay.page_w, lay.page_h),
|
||||
rows_complete: row_bands.last().is_some_and(|b| b.1 >= MAX_ROWS - 1),
|
||||
cols_complete: col_bands.last().is_some_and(|b| b.1 >= MAX_COLS - 1),
|
||||
@@ -184,12 +174,11 @@ impl PageGrid {
|
||||
}
|
||||
|
||||
/// The header or footer section under the point: the top or bottom margin of a page, split
|
||||
/// into thirds between the header and footer side margins.
|
||||
/// into thirds between the side margins.
|
||||
pub fn hf_zone_at(&self, x: f64, y: f64) -> Option<((usize, usize), HfZone)> {
|
||||
let (i, j) = self.page_at(x, y)?;
|
||||
let (px, py, _, _) = self.page_rect(i, j);
|
||||
let [_, _, t, b, ..] = self.margins;
|
||||
let [l, r] = self.hf_sides;
|
||||
let [l, r, t, b, ..] = self.margins;
|
||||
let footer = if y < py + t {
|
||||
false
|
||||
} else if y >= py + self.page_h - b {
|
||||
@@ -312,31 +301,6 @@ mod tests {
|
||||
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
|
||||
let mut wb = wb_with(10, 3);
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.print.scale = 50;
|
||||
sh.print.margins = [2.0, 2.0, 0.75, 0.75, 0.3, 0.3];
|
||||
}
|
||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
||||
let (px, py, _, _) = g.page_rect(0, 0);
|
||||
// Both on: the sections sit between the 2-inch side margins and the text scales with the cells.
|
||||
assert_eq!(g.hf_sides, [g.margins[0], g.margins[1]]);
|
||||
assert!((g.hf_text_k - 1.0).abs() < 1e-6);
|
||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(2));
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.print.hf_scale_with_doc = false;
|
||||
sh.print.hf_align_with_margins = false;
|
||||
}
|
||||
let g = PageGrid::build(&wb, 0, 50, 20).unwrap();
|
||||
// Both off: 0.7 inch (67.2 sheet pixels, 134.4 layout units at 50%) on each side, and the
|
||||
// text keeps its point size on paper.
|
||||
assert!(g.hf_sides.iter().all(|s| (s - 134.4).abs() < 1e-3), "{:?}", g.hf_sides);
|
||||
assert!((g.hf_text_k - 2.0).abs() < 1e-6);
|
||||
assert_eq!(g.hf_zone_at(px + 1000.0, py + 3.0).map(|z| z.1.section), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_scale_and_hostile_settings_stay_bounded() {
|
||||
let mut wb = wb_with(3, 3);
|
||||
|
||||
+18
-43
@@ -11,10 +11,7 @@ use std::collections::{HashMap, HashSet};
|
||||
use gridcraft_core::date::datetime_from_serial;
|
||||
pub use gridcraft_core::quote_sheet;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||
use gridcraft_model::{
|
||||
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
|
||||
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
|
||||
};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
@@ -819,30 +816,11 @@ pub const NOT_A_VALUE_CELL: &str = "select a value cell in a PivotTable";
|
||||
/// on the sheet is no longer what the source computes: the cell's records would be another cell's.
|
||||
pub fn detail_records(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, row: u32, col: u32) -> Result<Option<(Source, Vec<usize>)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: Some((row as usize, col as usize)), hit: None, paths: Some(Vec::new()), cells: None };
|
||||
let mut probe = Probe { at: (row as usize, col as usize), hit: None, paths: Vec::new() };
|
||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
// Checked first: in a stale report even a cell that computes as a label may show a value.
|
||||
check_current(wb, pt, pivot_sheet, &out)?;
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records =
|
||||
probe.paths.unwrap_or_default().into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
}
|
||||
|
||||
/// The cells of the report grid (relative to its top-left) where [`detail_records`] finds
|
||||
/// records: its value, subtotal and grand total cells, from one computation of the report. The
|
||||
/// same errors as [`detail_records`].
|
||||
pub fn detail_cells(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<HashSet<(u32, u32)>, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
let mut probe = Probe { at: None, hit: None, paths: None, cells: Some(HashSet::new()) };
|
||||
let out = compute_from(wb, pt, &src, Some(&mut probe))?;
|
||||
check_current(wb, pt, pivot_sheet, &out)?;
|
||||
Ok(probe.cells.unwrap_or_default().into_iter().map(|(r, c)| (r as u32, c as u32)).collect())
|
||||
}
|
||||
|
||||
/// An error when the report on the sheet is not what `out` computes. Excel reads its PivotTable
|
||||
/// cache; without one, the report written by the last refresh must still be what the source gives.
|
||||
fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Output) -> Result<(), String> {
|
||||
// Excel reads its PivotTable cache; without one, the report written by the last refresh
|
||||
// must still be what the source gives. Checked first: in a stale report even a cell that
|
||||
// computes as a label may show a value.
|
||||
let sh = wb.sheet(pivot_sheet).ok_or("There's no such sheet.")?;
|
||||
let current = pt.last_range.is_some_and(|r| {
|
||||
r.height() == out.height().max(1)
|
||||
@@ -856,17 +834,17 @@ fn check_current(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize, out: &Outpu
|
||||
if !current {
|
||||
return Err("The PivotTable report is out of date. Refresh it first.".into());
|
||||
}
|
||||
Ok(())
|
||||
let Some((rp, cp)) = probe.hit else { return Ok(None) };
|
||||
let records = probe.paths.into_iter().filter(|(_, r, c)| r.starts_with(&rp) && c.starts_with(&cp)).map(|(i, _, _)| i).collect();
|
||||
Ok(Some((src, records)))
|
||||
}
|
||||
|
||||
/// What [`detail_records`] and [`detail_cells`] ask of a report computation: the row and column
|
||||
/// item paths of the value cell at `at`, the paths of every record the filters let through (when
|
||||
/// `paths` is set), and every value cell (when `cells` is set).
|
||||
/// What [`detail_records`] asks of a report computation: the row and column item paths of the
|
||||
/// value cell at `at`, and the paths of every record the filters let through.
|
||||
struct Probe {
|
||||
at: Option<(usize, usize)>,
|
||||
at: (usize, usize),
|
||||
hit: Option<(Vec<u32>, Vec<u32>)>,
|
||||
paths: Option<Vec<(usize, Vec<u32>, Vec<u32>)>>,
|
||||
cells: Option<HashSet<(usize, usize)>>,
|
||||
paths: Vec<(usize, Vec<u32>, Vec<u32>)>,
|
||||
}
|
||||
|
||||
fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<&mut Probe>) -> Result<Output, String> {
|
||||
@@ -981,8 +959,8 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
if let Some(paths) = probe.as_deref_mut().and_then(|pr| pr.paths.as_mut()) {
|
||||
paths.push((ri, rp.clone(), cp.clone()));
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
pr.paths.push((ri, rp.clone(), cp.clone()));
|
||||
}
|
||||
row_set.insert(rp);
|
||||
col_set.insert(cp);
|
||||
@@ -1348,13 +1326,10 @@ fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut probe: Option<
|
||||
_ => raw,
|
||||
},
|
||||
};
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
if pr.at == Some((r, l + j)) {
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
}
|
||||
if let Some(cells) = pr.cells.as_mut() {
|
||||
cells.insert((r, l + j));
|
||||
}
|
||||
if let Some(pr) = probe.as_deref_mut()
|
||||
&& pr.at == (r, l + j)
|
||||
{
|
||||
pr.hit = Some((rp.to_vec(), cp.to_vec()));
|
||||
}
|
||||
g.set(r, l + j, shown, ck.1, 0, data_fmt.get(*vi).cloned().flatten());
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn build(name: &str) -> Option<Workbook> {
|
||||
return None;
|
||||
}
|
||||
let mut wb = (*s.doc().ok()?.wb).clone();
|
||||
wb.set_active_sheet(0);
|
||||
wb.active_sheet = 0;
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.view_active = gridcraft_core::CellRef::default();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
|
||||
use gridcraft_model::*;
|
||||
|
||||
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
||||
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
||||
|
||||
+4
-516
@@ -193,7 +193,7 @@ fn chart_category_axis_from_blank_corner_and_select_data() {
|
||||
assert_eq!(series(&s), [(Some("Sheet1!$D$2:$D$4".to_string()), "Sheet1!$E$2:$E$4".to_string())]);
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet(), sh.charts.last().unwrap());
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet, sh.charts.last().unwrap());
|
||||
assert_eq!(data.categories, ["2020", "2021", "2022"]);
|
||||
// The last series can't be removed; "" clears the labels.
|
||||
s.execute("chart.selectData", json!({"categories": "", "removeSeries": [0]})).unwrap();
|
||||
@@ -941,86 +941,6 @@ fn fill_without_formatting_extends_the_series_and_keeps_the_target_formats() {
|
||||
assert_eq!([v(&s, "B3"), v(&s, "B4")], [Value::from("Product 2"), Value::from("On backorder")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_days_weekdays_months_and_years_step_a_date_by_that_unit() {
|
||||
let mut s = s();
|
||||
let serials = |s: &Session, cells: &[&str]| cells.iter().map(|c| v(s, c)).collect::<Vec<_>>();
|
||||
let n = |xs: &[f64]| xs.iter().map(|x| Value::Number(*x)).collect::<Vec<_>>();
|
||||
// Friday 1/30/2026 is serial 46052.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/30/2026"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(46052.0), "setup: a date");
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "days"})).unwrap();
|
||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46053.0, 46054.0, 46055.0]));
|
||||
// Fill Weekdays skips Saturday 1/31 and Sunday 2/1.
|
||||
s.execute("edit.autoFill", json!({"source": "A1", "target": "A1:A4", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["A2", "A3", "A4"]), n(&[46055.0, 46056.0, 46057.0]));
|
||||
// From Saturday 1/31, the first weekday is Monday 2/2.
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46055.0, 46056.0]));
|
||||
// Fill Months keeps the day, clamped to the month's last day: Feb 28, Mar 31, Apr 30.
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B4", "mode": "months"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3", "B4"]), n(&[46081.0, 46112.0, 46142.0]));
|
||||
s.execute("edit.autoFill", json!({"source": "B1", "target": "B1:B3", "mode": "years"})).unwrap();
|
||||
assert_eq!(serials(&s, &["B2", "B3"]), n(&[46418.0, 46783.0]));
|
||||
// Two dates step by their difference in the unit: 1/15 and 3/15 by two months.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "1/15/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "3/15/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "C1:C2", "target": "C1:C4", "mode": "months"})).unwrap();
|
||||
assert_eq!(serials(&s, &["C3", "C4"]), n(&[46157.0, 46218.0]));
|
||||
// Monday 2/2 and Wednesday 2/4 step by two weekdays: Friday 2/6, Tuesday 2/10.
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "2/2/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "2/4/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "D1:D2", "target": "D1:D4", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["D3", "D4"]), n(&[46059.0, 46063.0]));
|
||||
// Upward from Monday 2/2: Friday 1/30, Thursday 1/29.
|
||||
s.execute("cell.set", json!({"cell": "E5", "input": "2/2/2026"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "E5", "target": "E3:E5", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(serials(&s, &["E4", "E3"]), n(&[46052.0, 46051.0]));
|
||||
// A number that isn't a date fills as a plain drag does: a lone number copies.
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "7"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "F1", "target": "F1:F2", "mode": "days"})).unwrap();
|
||||
assert_eq!(v(&s, "F2"), Value::Number(7.0));
|
||||
// An upper-case date format is a date: 1/31/2026 as YYYY-MM-DD fills by months.
|
||||
s.execute("cell.set", json!({"cell": "G1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "G1", "code": "YYYY-MM-DD"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "G1", "target": "G1:G2", "mode": "months"})).unwrap();
|
||||
assert_eq!(v(&s, "G2"), Value::Number(46081.0));
|
||||
// A date with a time keeps the time: Fill Months and Fill Weekdays, a plain drag of two dates
|
||||
// a month apart, and the Series dialog by months.
|
||||
s.execute("cell.set", json!({"cell": "H1", "input": "1/31/2026 12:00"})).unwrap();
|
||||
assert_eq!(v(&s, "H1"), Value::Number(46053.5), "setup: a date and time");
|
||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "months"})).unwrap();
|
||||
assert_eq!(v(&s, "H2"), Value::Number(46081.5));
|
||||
s.execute("edit.autoFill", json!({"source": "H1", "target": "H1:H2", "mode": "weekdays"})).unwrap();
|
||||
assert_eq!(v(&s, "H2"), Value::Number(46055.5));
|
||||
s.execute("cell.set", json!({"cell": "I1", "input": "1/15/2026 6:00"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "I2", "input": "2/15/2026 6:00"})).unwrap();
|
||||
s.execute("edit.autoFill", json!({"source": "I1:I2", "target": "I1:I3"})).unwrap();
|
||||
assert_eq!(v(&s, "I3"), Value::Number(46096.25));
|
||||
s.execute("cell.set", json!({"cell": "J1", "input": "1/31/2026 12:00"})).unwrap();
|
||||
s.execute("edit.fillSeries", json!({"range": "J1:J2", "type": "date", "dateUnit": "month"})).unwrap();
|
||||
assert_eq!(v(&s, "J2"), Value::Number(46081.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_number_in_a_date_format_offers_the_date_fills() {
|
||||
let mut s = s();
|
||||
let dates = |s: &Session, a: &str| crate::fill::starts_with_date(&s.doc().unwrap().wb, 0, gridcraft_core::RangeRef::parse(a).unwrap());
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1/31/2026"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "7"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A2", "code": "0 \"days\""})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "46053"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "YYYY-MM-DD"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "=A1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "13:30"})).unwrap();
|
||||
assert!(dates(&s, "A1") && dates(&s, "A3"), "m/d/yyyy and YYYY-MM-DD are dates");
|
||||
assert!(!dates(&s, "A2"), "quoted text in a number format is not a date field");
|
||||
assert!(!dates(&s, "A4"), "a formula is not offered");
|
||||
assert!(!dates(&s, "A5"), "a time is not a date");
|
||||
assert!(!dates(&s, "A6"), "an empty cell is not a date");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_delete_cells_adjust() {
|
||||
let formula = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["formula"].clone();
|
||||
@@ -1818,7 +1738,7 @@ fn slicer_finds_a_named_table_on_another_sheet() {
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "name": "Sales"})).unwrap();
|
||||
s.execute("home.insertSheet", json!({"name": "Report"})).unwrap();
|
||||
let report = s.doc().unwrap().wb.sheet_index("Report").unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), report);
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, report);
|
||||
|
||||
// With another sheet active, `table` still names the table, and the slicer goes on the table's sheet.
|
||||
let r = s.execute("insert.slicer", json!({"table": "Sales", "column": "Region", "at": "D2"})).unwrap();
|
||||
@@ -2000,69 +1920,6 @@ fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
|
||||
assert_eq!(v(&s, "B6"), Value::Empty);
|
||||
}
|
||||
|
||||
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_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
|
||||
// under each of its cells.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "B2:E2"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "F2").as_deref(), Some("SUM(B2:E2)"));
|
||||
assert_eq!(v(&s, "F2"), Value::Number(10.0));
|
||||
assert!(gridcraft_core::RangeRef::parse("B3:F3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "nothing under the row");
|
||||
assert_eq!(s.doc().unwrap().undo.len(), 2, "one undo step");
|
||||
// A row whose last cell is empty takes its total there.
|
||||
s.execute("range.setValues", json!({"range": "B4", "values": [[5, 6, 7]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "B4:E4"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "E4").as_deref(), Some("SUM(B4:D4)"));
|
||||
assert_eq!(v(&s, "F4"), Value::Empty);
|
||||
// AVERAGE and the rest go the same way.
|
||||
s.execute("formulas.autoSum", json!({"range": "B2:E2", "function": "average"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "F2").as_deref(), Some("AVERAGE(B2:E2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_over_a_whole_row_totals_it_after_the_data() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A2", "values": [[1, 2, 3]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "2:2"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "D2").as_deref(), Some("SUM(A2:C2)"));
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.used_range().unwrap().a1(), "A2:D2", "not out to the last column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_fills_a_selections_empty_last_column_and_row() {
|
||||
// As in Excel: an empty last column takes each row's total, an empty last row each column's,
|
||||
// and with both the corner takes the total of the block.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "A1:C2"})).unwrap();
|
||||
assert_eq!((formula_at(&s, "C1").as_deref(), formula_at(&s, "C2").as_deref()), (Some("SUM(A1:B1)"), Some("SUM(A2:B2)")));
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(3.0), Value::Number(7.0)));
|
||||
assert!(gridcraft_core::RangeRef::parse("A3:C3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "no totals under the rows");
|
||||
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
let steps = s.doc().unwrap().undo.len();
|
||||
s.execute("formulas.autoSum", json!({"range": "E1:G3"})).unwrap();
|
||||
for (cell, formula, value) in
|
||||
[("E3", "SUM(E1:E2)", 4.0), ("F3", "SUM(F1:F2)", 6.0), ("G1", "SUM(E1:F1)", 3.0), ("G2", "SUM(E2:F2)", 7.0), ("G3", "SUM(E1:F2)", 10.0)]
|
||||
{
|
||||
assert_eq!(formula_at(&s, cell).as_deref(), Some(formula), "{cell}");
|
||||
assert_eq!(v(&s, cell), Value::Number(value), "{cell}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().undo.len(), steps + 1, "one undo step");
|
||||
|
||||
// Neither empty: totals below each column, as before.
|
||||
s.execute("range.setValues", json!({"range": "I1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "I1:J2"})).unwrap();
|
||||
assert_eq!((formula_at(&s, "I3").as_deref(), formula_at(&s, "J3").as_deref()), (Some("SUM(I1:I2)"), Some("SUM(J1:J2)")));
|
||||
assert_eq!(v(&s, "K1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_delete_cells_never_shift_part_of_a_table() {
|
||||
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
|
||||
@@ -2774,10 +2631,7 @@ fn every_command_survives_empty_params() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", json!({"sample": "sales"})).unwrap();
|
||||
for spec in crate::command_specs() {
|
||||
if matches!(
|
||||
spec.id,
|
||||
"file.close" | "window.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture"
|
||||
) {
|
||||
if matches!(spec.id, "file.close" | "file.open" | "file.save" | "file.saveAs" | "file.exportCsv" | "file.exportHtml" | "insert.picture") {
|
||||
continue;
|
||||
}
|
||||
let _ = s.execute(spec.id, json!({}));
|
||||
@@ -2930,42 +2784,6 @@ fn calculation_options_enable_iterative_calculation() {
|
||||
near(&s, "A1", 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_checking_quotes_sheet_names_in_circular_references() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "Bob's Q1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=B2+1"})).unwrap();
|
||||
let r = s.execute("formulas.errorChecking", json!({})).unwrap();
|
||||
assert_eq!(r["circular"], json!(["'Bob''s Q1'!B2"]));
|
||||
// The reference reads back in a formula and in Go To.
|
||||
let reference = r["circular"][0].as_str().unwrap().to_string();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": format!("=ROW({reference})")})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(2.0));
|
||||
s.execute("edit.goTo", json!({"reference": reference})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.wb.active_sheet(), d.selection.active.a1()), (1, "B2".to_string()));
|
||||
}
|
||||
|
||||
/// Undo and redo put cells back without calculating them; the circular references listed are
|
||||
/// those of the restored cells.
|
||||
#[test]
|
||||
fn undo_and_redo_list_the_circular_references_of_the_restored_cells() {
|
||||
let mut s = s();
|
||||
let listed = |s: &Session| s.doc().unwrap().calc.circular.iter().map(|(si, c)| (*si, c.a1())).collect::<Vec<_>>();
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "=A5+1"})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A5".to_string())]);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(listed(&s), vec![(0, "A6".to_string())]);
|
||||
// Undoing the loop itself lists none.
|
||||
s.execute("edit.undo", json!({"steps": 2})).unwrap();
|
||||
assert!(listed(&s).is_empty());
|
||||
}
|
||||
|
||||
fn chart_data(s: &mut Session) {
|
||||
s.execute(
|
||||
"range.setValues",
|
||||
@@ -3449,19 +3267,6 @@ fn absolute_objects_ignore_sort_and_structural_edits() {
|
||||
assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap()));
|
||||
}
|
||||
|
||||
/// Deleting a row removes the sparkline located on it; later ones shift up.
|
||||
#[test]
|
||||
fn deleting_a_row_removes_its_sparklines() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
|
||||
s.execute("insert.sparkline", json!({"range": "A2:C2", "location": "E2"})).unwrap();
|
||||
s.execute("insert.sparkline", json!({"range": "A3:C3", "location": "E3"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap();
|
||||
let got: Vec<(String, String)> =
|
||||
s.doc().unwrap().wb.active().unwrap().sparklines.iter().map(|sp| (sp.cell.to_string(), sp.source.clone())).collect();
|
||||
assert_eq!(got, vec![("E2".to_string(), "A2:C2".to_string())]);
|
||||
}
|
||||
|
||||
/// A "move but don't size" object follows its row across a sort but keeps its size.
|
||||
#[test]
|
||||
fn move_only_objects_follow_but_do_not_resize() {
|
||||
@@ -3854,35 +3659,6 @@ fn notes_comments_and_links_survive_a_json_save() {
|
||||
assert_eq!(after.cells, before.cells);
|
||||
}
|
||||
|
||||
/// Writing a note or a comment on a cell that has one of the same kind edits its text: the
|
||||
/// replies, the resolved state, the dates and the note's box stay. `comment` replaced the whole
|
||||
/// thread, so editing a comment dropped its replies.
|
||||
#[test]
|
||||
fn editing_a_note_or_a_comment_keeps_its_thread() {
|
||||
let mut s = s();
|
||||
s.execute("review.newComment", json!({"cell": "B2", "text": "first", "author": "Ada"})).unwrap();
|
||||
s.execute("review.replyComment", json!({"cell": "B2", "text": "reply", "author": "Bob"})).unwrap();
|
||||
s.execute("review.resolveComment", json!({"cell": "B2", "resolved": true})).unwrap();
|
||||
s.execute("review.newComment", json!({"cell": "B2", "text": "edited"})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Ada"})).unwrap();
|
||||
s.execute("review.showNote", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "changed"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let thread = &sh.comments[&CellRef::parse("B2").unwrap()];
|
||||
assert_eq!((thread.author.as_str(), thread.text.as_str()), ("Ada", "edited"));
|
||||
assert_eq!(thread.replies, vec![("Bob".to_string(), "reply".to_string())]);
|
||||
assert!(thread.resolved && thread.threaded);
|
||||
let note = &sh.comments[&CellRef::parse("A1").unwrap()];
|
||||
assert_eq!((note.author.as_str(), note.text.as_str()), ("Ada", "changed"));
|
||||
assert!(note.visible && !note.threaded);
|
||||
// A note written over a comment replaces it.
|
||||
s.execute("review.newNote", json!({"cell": "B2", "text": "plain"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let replaced = &d.wb.active().unwrap().comments[&CellRef::parse("B2").unwrap()];
|
||||
assert!(!replaced.threaded && replaced.replies.is_empty());
|
||||
}
|
||||
|
||||
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
|
||||
/// pictures were removed but the block's merges and notes were not, so one merged block became
|
||||
/// two (an empty one left at the old place) and a note ended up on both the old and the new
|
||||
@@ -4209,7 +3985,7 @@ fn line_sizes_and_freeze_panes_take_a_sheet() {
|
||||
assert!(data.cols.get(&4).is_some_and(|c| c.hidden));
|
||||
assert_eq!(data.default_col_width, 80.0);
|
||||
assert_eq!(sheet(&s, 0), before, "the active sheet is untouched");
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet, 0);
|
||||
|
||||
s.execute("view.freezePanes", json!({"cell": "Data!B3"})).unwrap();
|
||||
assert_eq!(sheet(&s, 1).freeze, Some((2, 1)));
|
||||
@@ -4828,292 +4604,4 @@ fn html_export_without_pictures_or_charts_has_no_fixed_geometry() {
|
||||
let html = crate::io::to_html(&s.doc().unwrap().wb, 0).unwrap();
|
||||
assert!(!html.contains("<colgroup>") && !html.contains("table-layout") && !html.contains("<svg") && !html.contains("<img"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_windows_lists_workbooks_by_stable_id_and_switches_to_one() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "first"})).unwrap();
|
||||
let book1 = s.doc().unwrap().uid;
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
let book3 = s.doc().unwrap().uid;
|
||||
let list = s.execute("view.switchWindows", json!({})).unwrap();
|
||||
let rows = list.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!((rows[0]["doc"].as_u64(), rows[0]["title"].as_str(), rows[0]["dirty"].as_bool()), (Some(book1), Some("Book1"), Some(true)));
|
||||
assert_eq!((rows[2]["dirty"].as_bool(), rows[2]["active"].as_bool()), (Some(false), Some(true)));
|
||||
|
||||
// Closing a workbook shifts the indexes but not the ids.
|
||||
s.execute("window.activate", json!({"index": 1})).unwrap();
|
||||
s.execute("file.close", json!({"force": true})).unwrap();
|
||||
assert_eq!(s.document_index(book3), Some(1));
|
||||
let list = s.execute("view.switchWindows", json!({"doc": book1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().uid, book1);
|
||||
assert_eq!(v(&s, "A1"), Value::text("first"));
|
||||
assert_eq!(list[0]["active"].as_bool(), Some(true));
|
||||
s.execute("file.save", json!({"path": std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id()))})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().uid, book1, "saving keeps the id");
|
||||
let _ = std::fs::remove_file(std::env::temp_dir().join(format!("gridcraft-uid-{}.xlsx", std::process::id())));
|
||||
|
||||
assert!(s.execute("view.switchWindows", json!({"doc": book3 + 1000})).is_err());
|
||||
assert!(s.execute("view.switchWindows", json!({"doc": "Book1"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().uid, book1, "a bad id leaves the active workbook alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_window_shares_the_workbook_and_closes_window_by_window() {
|
||||
let mut s = s();
|
||||
let book = s.doc().unwrap().uid;
|
||||
assert_eq!(s.windows().len(), 1, "a new workbook opens in one window");
|
||||
let first = s.active_window().unwrap();
|
||||
assert_eq!(s.window_title(first).as_deref(), Some("Book1"));
|
||||
|
||||
let r = s.execute("view.newWindow", json!({})).unwrap();
|
||||
let second = r["window"].as_u64().unwrap();
|
||||
assert_eq!(s.documents().len(), 1, "no copy of the workbook");
|
||||
assert_eq!((s.windows().len(), s.active_window()), (2, Some(second)));
|
||||
assert_eq!(s.window_title(first).as_deref(), Some("Book1:1"));
|
||||
assert_eq!(s.window_title(second).as_deref(), Some("Book1:2"));
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
|
||||
// An edit in window 2 is in window 1's workbook, and saving from window 2 saves it.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "shared"})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-windows-{}.xlsx", std::process::id()));
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
s.execute("window.activate", json!({"window": first})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().uid, v(&s, "A1")), (book, Value::text("shared")));
|
||||
assert!(!s.doc().unwrap().is_dirty(), "saved from the other window");
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), path.to_str());
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Closing one window keeps the workbook and renumbers the titles; the last one closes it.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "unsaved"})).unwrap();
|
||||
let r = s.execute("file.close", json!({})).unwrap();
|
||||
assert_eq!((r["closed"].as_bool(), r["document"].as_bool()), (Some(true), Some(false)), "no prompt for one of two windows");
|
||||
assert_eq!((s.windows().len(), s.active_window()), (1, Some(second)));
|
||||
assert_eq!(s.window_title(second).as_deref(), Some(s.doc().unwrap().display_title().as_str()));
|
||||
let r = s.execute("window.close", json!({})).unwrap();
|
||||
assert_eq!(r["closed"].as_bool(), Some(false), "the last window asks about unsaved changes");
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
s.execute("window.close", json!({"force": true})).unwrap();
|
||||
assert!(s.documents().is_empty() && s.windows().is_empty() && s.active_window().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_windows_swaps_their_views_without_dirtying_the_workbook() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-window-views-{}.xlsx", std::process::id()));
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
|
||||
let first = s.active_window().unwrap();
|
||||
let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "D4"})).unwrap();
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
|
||||
s.execute("window.activate", json!({"window": first})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (0, "B2:C3".to_string()));
|
||||
assert!(!d.is_dirty(), "a window's sheet and selection are not document changes");
|
||||
s.execute("view.switchWindows", json!({"window": second})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((d.wb.active_sheet(), d.selection.a1()), (1, "D4".to_string()));
|
||||
assert!(!d.is_dirty());
|
||||
|
||||
// A routed command acts in its window and leaves the active one showing.
|
||||
s.execute_in(crate::Target::Window(first), "cell.set", json!({"cell": "A1", "input": "in window 1"})).unwrap();
|
||||
assert_eq!(s.active_window(), Some(second));
|
||||
assert_eq!(s.doc().unwrap().wb.sheets[0].value(CellRef::parse("A1").unwrap()), Value::text("in window 1"));
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "D4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_swaps_share_the_workbook_and_save_records_the_latest_window_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-window-arc-{}.xlsx", std::process::id()));
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B2"})).unwrap();
|
||||
let first = s.active_window().unwrap();
|
||||
let second = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C3:D4"})).unwrap();
|
||||
|
||||
// Swapping windows, as the interface does before drawing each one, never copies the workbook.
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for _ in 0..3 {
|
||||
s.show_window(first);
|
||||
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (0, "B2".to_string()));
|
||||
s.activate_window(second);
|
||||
assert_eq!((s.doc().unwrap().wb.active_sheet(), s.doc().unwrap().selection.a1()), (1, "C3:D4".to_string()));
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
}
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &d.wb), "a window swap or sheet switch copied the workbook");
|
||||
assert!(!d.is_dirty());
|
||||
|
||||
// A command run in window 2 edits window 2's sheet at its selection.
|
||||
s.execute("cell.set", json!({"input": "two"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!(d.wb.sheets[1].value(CellRef::parse("C3").unwrap()), Value::text("two"));
|
||||
assert_eq!(d.wb.sheets[0].value(CellRef::parse("B2").unwrap()), Value::Empty);
|
||||
s.show_window(first);
|
||||
assert_eq!(s.doc().unwrap().wb.active_sheet(), 0);
|
||||
|
||||
// The most recently active window is window 2: the file records its sheet.
|
||||
s.execute_in(crate::Target::Window(second), "file.save", json!({"path": path})).unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(t.doc().unwrap().wb.active_sheet(), 1);
|
||||
s.activate_window(first);
|
||||
s.execute("file.save", json!({"path": path})).unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(t.doc().unwrap().wb.active_sheet(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_all_picks_the_visible_windows_active_first() {
|
||||
let mut s = s();
|
||||
let book1 = s.active_window().unwrap();
|
||||
let book1b = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
let book2 = s.active_window().unwrap();
|
||||
let r = s.execute("view.arrangeAll", json!({})).unwrap();
|
||||
assert_eq!(r, json!({"arrange": "tiled", "windows": [book2, book1, book1b]}));
|
||||
s.execute("window.activate", json!({"window": book1b})).unwrap();
|
||||
s.execute("view.hideWindow", json!({"window": book2})).unwrap();
|
||||
let r = s.execute("view.arrangeAll", json!({"arrange": "cascade", "activeWorkbook": true})).unwrap();
|
||||
assert_eq!(r, json!({"arrange": "cascade", "windows": [book1b, book1]}));
|
||||
assert!(s.execute("view.arrangeAll", json!({"arrange": "diagonal"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_unhide_and_switch_by_window_id() {
|
||||
let mut s = s();
|
||||
let book1 = s.doc().unwrap().uid;
|
||||
let w1 = s.active_window().unwrap();
|
||||
let w2 = s.execute("view.newWindow", json!({})).unwrap()["window"].as_u64().unwrap();
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
let w3 = s.active_window().unwrap();
|
||||
s.execute("view.hideWindow", json!({})).unwrap();
|
||||
assert_eq!(s.active_window(), Some(w2), "the most recent visible window comes forward");
|
||||
let list = s.execute("window.list", json!({})).unwrap();
|
||||
let rows = list.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 3);
|
||||
assert_eq!((rows[0]["window"].as_u64(), rows[0]["title"].as_str(), rows[0]["doc"].as_u64()), (Some(w1), Some("Book1:1"), Some(book1)));
|
||||
assert_eq!((rows[2]["title"].as_str(), rows[2]["hidden"].as_bool()), (Some("Book2"), Some(true)));
|
||||
assert_eq!(rows[1]["active"].as_bool(), Some(true));
|
||||
s.execute("view.unhideWindow", json!({})).unwrap();
|
||||
assert_eq!(s.active_window(), Some(w3));
|
||||
assert!(!s.window(w3).unwrap().hidden);
|
||||
// A workbook id goes to that workbook's most recent window.
|
||||
s.execute("view.switchWindows", json!({"doc": book1})).unwrap();
|
||||
assert_eq!(s.active_window(), Some(w2));
|
||||
assert!(s.execute("window.activate", json!({"window": 999})).is_err());
|
||||
assert_eq!(s.active_window(), Some(w2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_replaces_an_untouched_blank_workbook_and_its_windows() {
|
||||
let mut s = s();
|
||||
s.execute("view.newWindow", json!({})).unwrap();
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-open-blank-{}.csv", std::process::id()));
|
||||
std::fs::write(&path, "a,b\n1,2\n").unwrap();
|
||||
s.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
assert_eq!(s.windows().len(), 1, "the blank workbook's windows closed with it");
|
||||
assert_eq!(s.windows()[0].doc_uid, s.doc().unwrap().uid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_permission_on_a_protected_sheet_cannot_unlock_cells() {
|
||||
// Formatting may be allowed on a protected sheet; the permission can't serve as a key to
|
||||
// content edits, so the format cells may not drop a cell's `locked` flag.
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "hi"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"formatCells": true})).unwrap();
|
||||
s.execute("home.fontName", json!({"name": "Arial"})).unwrap();
|
||||
let err = s.execute("home.lockCell", json!({"range": "B2", "on": false})).expect_err("unlock through formatting");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "B2", "input": "x"})).is_err(), "B2 stays locked");
|
||||
// The same calls run once the sheet is unprotected.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "B2"), Value::text("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_checks_the_protection_of_the_named_sheet() {
|
||||
// The move's protection checks must authorize the sheet it modifies, not whichever sheet
|
||||
// happens to be active while the command names another one.
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({"name": "P"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let err = s.execute("sheet.moveColumns", json!({"sheet": "P", "cols": "B:C", "before": 4})).expect_err("moving columns on a protected sheet");
|
||||
assert!(err.to_string().contains("protected"), "{err}");
|
||||
let pv: Vec<Value> = {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = &d.wb.sheets[d.wb.sheet_index("P").unwrap()];
|
||||
(0..4).map(|c| sh.value(CellRef::new(0, c as u32))).collect()
|
||||
};
|
||||
assert_eq!(pv, vec![Value::number(1.0), Value::number(2.0), Value::number(3.0), Value::number(4.0)]);
|
||||
// The active, unprotected sheet moves fine.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["A", "B", "C", "D"]]})).unwrap();
|
||||
s.execute("sheet.moveColumns", json!({"cols": "B:B", "before": 4})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::text("A"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_switch_windows() {
|
||||
// Stored scripts are scoped to their workbook: a step that activates another window would
|
||||
// point every later step at another open document, so it is refused before any step runs.
|
||||
let mut s = s();
|
||||
s.execute("automate.newScript", json!({"name": "t", "commands": [{"command": "window.activate", "params": {"index": 0}}]})).unwrap();
|
||||
s.execute("automate.trustScripts", json!({"trusted": true})).unwrap();
|
||||
let err = s.execute("automate.runScript", json!({"name": "t"})).expect_err("script switching windows");
|
||||
assert!(err.to_string().contains("window.activate"), "{err}");
|
||||
s.execute("automate.newScript", json!({"name": "sw", "commands": [{"command": "view.switchWindows", "params": {"doc": 1}}]})).unwrap();
|
||||
let err = s.execute("automate.runScript", json!({"name": "sw"})).expect_err("script switching windows from the View tab");
|
||||
assert!(err.to_string().contains("view.switchWindows"), "{err}");
|
||||
// A benign script runs.
|
||||
s.execute("automate.newScript", json!({"name": "ok", "commands": [{"command": "cell.set", "params": {"cell": "A1", "input": "=1+1"}}]})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "ok"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
||||
}
|
||||
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=10"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
// The values a file would have saved, not the ones the formulas give.
|
||||
for (a, n) in [("B1", 99.0), ("C1", 7.0)] {
|
||||
let sheet = std::sync::Arc::make_mut(&mut wb.sheets[0]);
|
||||
let c = CellRef::parse(a).unwrap();
|
||||
let mut cell = sheet.cells.get(c).unwrap().clone();
|
||||
cell.value = Value::Number(n);
|
||||
sheet.set_cell(c, cell);
|
||||
}
|
||||
let mut saved = Session::new();
|
||||
saved.add_document(crate::DocState::from_saved(wb, None, "saved".into(), crate::Trust::FromFile));
|
||||
assert_eq!(v(&saved, "B1"), Value::Number(99.0));
|
||||
saved.execute("cell.set", json!({"cell": "A1", "input": "3"})).unwrap();
|
||||
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
|
||||
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ fn the_recorder_keeps_top_level_calls_with_where_they_ran() {
|
||||
assert_eq!(recorded_ids(&s), ["cell.set", "view.customViews", "view.customViews"]);
|
||||
let first = &s.recorder.as_ref().unwrap().steps[0];
|
||||
let d = s.doc().unwrap();
|
||||
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet()));
|
||||
assert_eq!((first.doc, first.sheet), (d.uid, d.wb.active_sheet));
|
||||
assert_eq!(first.sheet_name, d.wb.active().unwrap().name);
|
||||
assert_eq!(first.selection.a1(), "B2:C3", "the selection before the command ran");
|
||||
assert_eq!(first.params, json!({"cell": "B2", "input": "10"}));
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
//! Windows on open workbooks.
|
||||
//!
|
||||
//! Every open workbook has one or more windows (View › New Window adds another on the same
|
||||
//! workbook). A window has its own selection, active sheet and per-sheet selections. The active
|
||||
//! window's view lives in its [`DocState`] (`selection`, `sheet_selections`) and in the workbook's
|
||||
//! active sheet ([`gridcraft_model::workbook::Workbook::active_sheet`]), where every command reads
|
||||
//! it; each other window keeps its view in [`WindowState`] until it is activated. Swapping a view
|
||||
//! in or out never copies the workbook or makes it dirty: the active sheet is set in place through
|
||||
//! the shared `Arc`, and selections live outside the workbook.
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::{DocState, EngineError, Result, Selection, Session};
|
||||
|
||||
/// The view state one window keeps while another window on its workbook is showing.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct WindowView {
|
||||
pub selection: Selection,
|
||||
pub sheet: usize,
|
||||
/// The active sheet's name, so a sheet inserted, deleted or moved meanwhile in another
|
||||
/// window still finds it.
|
||||
pub sheet_name: String,
|
||||
pub sheet_selections: Vec<Selection>,
|
||||
}
|
||||
|
||||
/// One window on an open workbook.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WindowState {
|
||||
/// Identifies the window for the rest of the session; never reused.
|
||||
pub id: u64,
|
||||
/// The [`DocState::uid`] of the workbook it shows.
|
||||
pub doc_uid: u64,
|
||||
/// View › Hide.
|
||||
pub hidden: bool,
|
||||
/// `None` while this window's view is the one in the document (the active window, or the
|
||||
/// workbook's most recently active window); exactly one window per workbook has `None`.
|
||||
view: Option<WindowView>,
|
||||
/// Activation order: higher was active more recently.
|
||||
last_active: u64,
|
||||
}
|
||||
|
||||
impl WindowView {
|
||||
fn capture(d: &DocState) -> WindowView {
|
||||
WindowView {
|
||||
selection: d.selection.clone(),
|
||||
sheet: d.wb.active_sheet(),
|
||||
sheet_name: d.wb.sheets.get(d.wb.active_sheet()).map(|s| s.name.clone()).unwrap_or_default(),
|
||||
sheet_selections: d.sheet_selections.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(self, d: &mut DocState) {
|
||||
let sheets = &d.wb.sheets;
|
||||
let at = if sheets.get(self.sheet).is_some_and(|s| s.name == self.sheet_name) {
|
||||
self.sheet
|
||||
} else {
|
||||
sheets.iter().position(|s| s.name == self.sheet_name).unwrap_or(self.sheet)
|
||||
};
|
||||
d.wb.set_active_sheet(at.min(sheets.len().saturating_sub(1)));
|
||||
d.selection = self.selection;
|
||||
d.sheet_selections = self.sheet_selections;
|
||||
}
|
||||
}
|
||||
|
||||
/// Which window a command from a script or agent acts on.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// The active window.
|
||||
#[default]
|
||||
Active,
|
||||
/// A window by [`WindowState::id`].
|
||||
Window(u64),
|
||||
/// A workbook by [`DocState::uid`]: its most recently active window.
|
||||
Doc(u64),
|
||||
}
|
||||
|
||||
impl Target {
|
||||
/// Reads `window` or `doc` from a JSON object (control channel envelopes, MCP arguments).
|
||||
/// Neither present is [`Target::Active`]; a value that isn't an id is an error.
|
||||
pub fn from_json(p: &Json) -> Result<Target> {
|
||||
let id = |k: &str| -> Result<Option<u64>> {
|
||||
match p.get(k) {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| EngineError::Other(format!("`{k}` must be a window or workbook id number"))),
|
||||
}
|
||||
};
|
||||
Ok(match (id("window")?, id("doc")?) {
|
||||
(Some(w), _) => Target::Window(w),
|
||||
(None, Some(d)) => Target::Doc(d),
|
||||
(None, None) => Target::Active,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`Session::leave_target`] restores.
|
||||
#[derive(Debug)]
|
||||
#[must_use]
|
||||
pub struct TargetGuard {
|
||||
id: u64,
|
||||
back: Option<u64>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// The open windows, in the order they were opened.
|
||||
pub fn windows(&self) -> &[WindowState] {
|
||||
&self.windows
|
||||
}
|
||||
|
||||
/// The active window's id (`None` with no workbook open).
|
||||
pub fn active_window(&self) -> Option<u64> {
|
||||
self.active_window
|
||||
}
|
||||
|
||||
pub fn window(&self, id: u64) -> Option<&WindowState> {
|
||||
self.windows.iter().find(|w| w.id == id)
|
||||
}
|
||||
|
||||
/// A workbook's windows in opening order.
|
||||
fn windows_of(&self, doc: u64) -> impl Iterator<Item = &WindowState> {
|
||||
self.windows.iter().filter(move |w| w.doc_uid == doc)
|
||||
}
|
||||
|
||||
/// The window title: the workbook's name when it has one window, "Name:1", "Name:2"… in
|
||||
/// opening order when it has several.
|
||||
pub fn window_title(&self, id: u64) -> Option<String> {
|
||||
let w = self.window(id)?;
|
||||
let d = self.docs.iter().find(|d| d.uid == w.doc_uid)?;
|
||||
let name = d.display_title();
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
for x in self.windows_of(w.doc_uid) {
|
||||
n += 1;
|
||||
if x.id == id {
|
||||
at = n;
|
||||
}
|
||||
}
|
||||
Some(if n > 1 { format!("{name}:{at}") } else { name })
|
||||
}
|
||||
|
||||
/// The most recently active window of a workbook, preferring one that isn't hidden.
|
||||
pub fn doc_window(&self, doc: u64) -> Option<u64> {
|
||||
self.windows_of(doc).max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id)
|
||||
}
|
||||
|
||||
/// Resolves a [`Target`] to a window id.
|
||||
pub fn target_window(&self, t: Target) -> Result<u64> {
|
||||
match t {
|
||||
Target::Active => self.active_window.ok_or(EngineError::NoDocument),
|
||||
Target::Window(id) => self.window(id).map(|w| w.id).ok_or_else(|| EngineError::Other(format!("no open window has id {id}"))),
|
||||
Target::Doc(uid) => self.doc_window(uid).ok_or_else(|| EngineError::Other(format!("no open workbook has id {uid}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push_window(&mut self, doc_uid: u64) -> u64 {
|
||||
self.next_window = self.next_window.saturating_add(1);
|
||||
let id = self.next_window;
|
||||
self.windows.push(WindowState { id, doc_uid, hidden: false, view: None, last_active: 0 });
|
||||
id
|
||||
}
|
||||
|
||||
/// Makes a window active, swapping its view into its workbook. Unknown ids are ignored.
|
||||
pub fn activate_window(&mut self, id: u64) {
|
||||
self.enter_window(id, true);
|
||||
}
|
||||
|
||||
/// Makes a window the one commands act on while the interface draws it, without counting as
|
||||
/// an activation: the most recently active window of each workbook stays the same.
|
||||
pub fn show_window(&mut self, id: u64) {
|
||||
self.enter_window(id, false);
|
||||
}
|
||||
|
||||
fn enter_window(&mut self, id: u64, recent: bool) {
|
||||
let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
|
||||
let Some(w) = self.windows.get(wi) else { return };
|
||||
let doc = w.doc_uid;
|
||||
if w.view.is_some()
|
||||
&& let Some(di) = self.document_index(doc)
|
||||
{
|
||||
let live = self.windows.iter().position(|x| x.doc_uid == doc && x.view.is_none());
|
||||
let incoming = self.windows.get_mut(wi).and_then(|w| w.view.take());
|
||||
if let Some(d) = self.docs.get_mut(di) {
|
||||
if let Some(li) = live
|
||||
&& let Some(lw) = self.windows.get_mut(li)
|
||||
{
|
||||
lw.view = Some(WindowView::capture(d));
|
||||
}
|
||||
if let Some(v) = incoming {
|
||||
v.apply(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
if recent {
|
||||
self.activation = self.activation.saturating_add(1);
|
||||
let stamp = self.activation;
|
||||
if let Some(w) = self.windows.get_mut(wi) {
|
||||
w.last_active = stamp;
|
||||
}
|
||||
}
|
||||
self.active_window = Some(id);
|
||||
if let Some(di) = self.document_index(doc) {
|
||||
self.active = di;
|
||||
}
|
||||
}
|
||||
|
||||
/// Activates the most recently active window that isn't hidden, else any window, else none.
|
||||
pub(crate) fn activate_recent(&mut self) {
|
||||
let next = self.windows.iter().max_by_key(|w| (!w.hidden, w.last_active)).map(|w| w.id);
|
||||
match next {
|
||||
Some(id) => self.activate_window(id),
|
||||
None => {
|
||||
self.active_window = None;
|
||||
self.active = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View › New Window: another window on the active window's workbook, showing what it shows.
|
||||
pub fn new_window(&mut self) -> Result<u64> {
|
||||
let current = self.active_window.ok_or(EngineError::NoDocument)?;
|
||||
let doc = self.window(current).map(|w| w.doc_uid).ok_or(EngineError::NoDocument)?;
|
||||
let view = self.doc().map(WindowView::capture)?;
|
||||
// The new window takes over the live view (identical at this point); the old one keeps a copy.
|
||||
if let Some(w) = self.windows.iter_mut().find(|w| w.doc_uid == doc && w.view.is_none()) {
|
||||
w.view = Some(view);
|
||||
}
|
||||
let id = self.push_window(doc);
|
||||
self.activate_window(id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Closes one window. The workbook stays open while it has another window; closing its last
|
||||
/// window closes the workbook (unsaved changes are the caller's to ask about).
|
||||
pub fn close_window(&mut self, id: u64) {
|
||||
let Some(wi) = self.windows.iter().position(|w| w.id == id) else { return };
|
||||
let doc = self.windows.get(wi).map(|w| w.doc_uid).unwrap_or_default();
|
||||
if self.windows_of(doc).count() <= 1 {
|
||||
if let Some(di) = self.document_index(doc) {
|
||||
self.close_document(di);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let closed = self.windows.remove(wi);
|
||||
if closed.view.is_none() {
|
||||
// Its view was the one in the document: the workbook's next window takes it over.
|
||||
if let Some(next) = self.windows.iter().filter(|w| w.doc_uid == doc).max_by_key(|w| w.last_active).map(|w| w.id) {
|
||||
let incoming = self.windows.iter_mut().find(|w| w.id == next).and_then(|w| w.view.take());
|
||||
if let Some(v) = incoming
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == doc)
|
||||
{
|
||||
v.apply(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.active_window == Some(id) {
|
||||
self.activate_recent();
|
||||
}
|
||||
}
|
||||
|
||||
/// View › Hide on a window; the most recent visible window comes forward.
|
||||
pub fn hide_window(&mut self, id: u64) {
|
||||
let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
|
||||
w.hidden = true;
|
||||
if self.active_window == Some(id) && self.windows.iter().any(|w| !w.hidden) {
|
||||
self.activate_recent();
|
||||
}
|
||||
}
|
||||
|
||||
/// View › Unhide: shows a hidden window and activates it.
|
||||
pub fn unhide_window(&mut self, id: u64) {
|
||||
let Some(w) = self.windows.iter_mut().find(|w| w.id == id) else { return };
|
||||
w.hidden = false;
|
||||
self.activate_window(id);
|
||||
}
|
||||
|
||||
/// The most recently hidden window.
|
||||
pub fn last_hidden_window(&self) -> Option<u64> {
|
||||
self.windows.iter().filter(|w| w.hidden).max_by_key(|w| w.last_active).map(|w| w.id)
|
||||
}
|
||||
|
||||
/// The windows as scripts and the Switch Windows lists see them:
|
||||
/// `[{window, doc, index, title, dirty, active, hidden}]`, `index` being the workbook's
|
||||
/// position in [`Session::documents`].
|
||||
pub fn window_list(&self) -> Json {
|
||||
Json::Array(
|
||||
self.windows
|
||||
.iter()
|
||||
.filter_map(|w| {
|
||||
let index = self.document_index(w.doc_uid)?;
|
||||
let d = self.docs.get(index)?;
|
||||
Some(json!({
|
||||
"window": w.id,
|
||||
"doc": w.doc_uid,
|
||||
"index": index,
|
||||
"title": self.window_title(w.id).unwrap_or_default(),
|
||||
"dirty": d.is_dirty(),
|
||||
"active": self.active_window == Some(w.id),
|
||||
"hidden": w.hidden,
|
||||
}))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs `f` with the target window active, then brings back the window that was active, so a
|
||||
/// script acting on another window doesn't change what the user is looking at. When `f`
|
||||
/// itself switched windows (`window.activate`, `file.new`, a close), its choice stands.
|
||||
pub fn with_target<T>(&mut self, t: Target, f: impl FnOnce(&mut Session) -> T) -> Result<T> {
|
||||
let guard = self.enter_target(t)?;
|
||||
let r = f(self);
|
||||
self.leave_target(guard);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The first half of [`Session::with_target`], for callers whose closure can't borrow the
|
||||
/// session alone (the UI runs commands through its app): activates the target window and
|
||||
/// returns what [`Session::leave_target`] needs to bring the previous one back.
|
||||
pub fn enter_target(&mut self, t: Target) -> Result<Option<TargetGuard>> {
|
||||
if t == Target::Active {
|
||||
return Ok(None);
|
||||
}
|
||||
let id = self.target_window(t)?;
|
||||
let back = self.active_window;
|
||||
self.activate_window(id);
|
||||
Ok(Some(TargetGuard { id, back }))
|
||||
}
|
||||
|
||||
pub fn leave_target(&mut self, guard: Option<TargetGuard>) {
|
||||
let Some(TargetGuard { id, back: Some(back) }) = guard else { return };
|
||||
if self.active_window == Some(id) && back != id && self.window(back).is_some() {
|
||||
self.activate_window(back);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::execute`] on a target window.
|
||||
pub fn execute_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
|
||||
self.with_target(t, |s| s.execute(id, params))?
|
||||
}
|
||||
|
||||
/// [`Session::execute_headless`] on a target window.
|
||||
pub fn execute_headless_in(&mut self, t: Target, id: &str, params: Json) -> Result<Json> {
|
||||
self.with_target(t, |s| s.execute_headless(id, params))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that take `window`/`doc` as their own parameters rather than as a target.
|
||||
pub fn is_window_command(id: &str) -> bool {
|
||||
id.starts_with("window.") || matches!(id, "view.newWindow" | "view.hideWindow" | "view.unhideWindow" | "view.switchWindows")
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
//! The 100% stacked area chart: `insert.chart` and `chart.set` take type `area` with subtype
|
||||
//! `stacked100`.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_model::ChartKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "North", "South"], ["Q1", "10", "30"], ["Q2", "30", "10"]]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn kinds(s: &Session) -> Vec<ChartKind> {
|
||||
s.doc().unwrap().wb.sheet(0).unwrap().charts.iter().map(|c| c.kind).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_chart_makes_a_hundred_percent_stacked_area() {
|
||||
let mut s = session();
|
||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked100"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_set_changes_a_chart_to_a_hundred_percent_stacked_area_and_back() {
|
||||
let mut s = session();
|
||||
s.execute("insert.chart", json!({"range": "A1:C3", "type": "area", "subtype": "stacked"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
||||
s.execute("chart.set", json!({"type": "area", "subtype": "stacked100"})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked100]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(kinds(&s), [ChartKind::AreaStacked]);
|
||||
}
|
||||
@@ -59,8 +59,7 @@ const EXCLUDED: &[(&str, &str)] = &[
|
||||
("file.exportPdf", "renders a PDF: slow, and may write files"),
|
||||
("file.print", "renders a PDF: slow"),
|
||||
("window.activate", "switches documents"),
|
||||
("window.close", "closes the document under test"),
|
||||
("view.newWindow", "opens another window, whose view swaps with the first"),
|
||||
("view.newWindow", "opens another document"),
|
||||
("insert.picture", "reads files"),
|
||||
("pageLayout.background", "reads files"),
|
||||
("formulas.calculationOptions", "manual and iterative calculation differ from a full recalc by design"),
|
||||
@@ -233,7 +232,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(fail(step, kind, e)),
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(fail(step, kind, format!("panicked: {msg}")))
|
||||
}
|
||||
}
|
||||
@@ -373,8 +372,8 @@ fn structure_problem(wb: &Workbook, sel: &gridcraft_engine::Selection) -> Option
|
||||
if wb.sheets.is_empty() {
|
||||
return Some("no sheets left".into());
|
||||
}
|
||||
if wb.active_sheet() >= wb.sheets.len() {
|
||||
return Some(format!("active sheet {} of {}", wb.active_sheet(), wb.sheets.len()));
|
||||
if wb.active_sheet >= wb.sheets.len() {
|
||||
return Some(format!("active sheet {} of {}", wb.active_sheet, wb.sheets.len()));
|
||||
}
|
||||
let mut tables = BTreeSet::new();
|
||||
for (i, sh) in wb.sheets.iter().enumerate() {
|
||||
@@ -490,7 +489,7 @@ fn watch_list(wb: &Workbook) -> Option<&str> {
|
||||
/// reset: undo restores view state as it was, but switching sheets isn't an undoable edit.
|
||||
fn canonical(wb: &Workbook) -> Workbook {
|
||||
let mut wb = wb.clone();
|
||||
wb.set_active_sheet(0);
|
||||
wb.active_sheet = 0;
|
||||
wb.names.retain(|n| n.name != WATCH_NAME);
|
||||
for sh in wb.sheets.iter_mut() {
|
||||
let sh = Arc::make_mut(sh);
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
//! A font a formatting command derives from the theme's body font keeps following it (#2517).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn styles_xml(session: &Session) -> String {
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&session.doc().unwrap().wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_body_font_keeps_minor_scheme() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
session.execute("home.fontSize", json!({"range": "B1", "size": 14})).unwrap();
|
||||
let xml = styles_xml(&session);
|
||||
let fonts = xml.split("<fonts").nth(1).unwrap().split("</fonts>").next().unwrap();
|
||||
let fonts: Vec<&str> = fonts.split("<font>").skip(1).collect();
|
||||
assert!(fonts.len() >= 3, "{xml}");
|
||||
for f in &fonts {
|
||||
assert!(f.contains("<scheme val=\"minor\"/>"), "every body-font font follows the theme: {f}");
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ fn next_stays_on_the_last_visible_sheet() {
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet(), 1);
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ fn previous_stays_on_the_first_visible_sheet() {
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet(), 2);
|
||||
assert_eq!(doc.wb.active_sheet, 2);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,10 @@ fn previous_stays_on_the_first_visible_sheet() {
|
||||
fn navigation_skips_hidden_sheets_in_both_directions() {
|
||||
let mut session = workbook(&[1, 2], 0);
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet(), 3);
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet(), 0);
|
||||
assert_eq!(doc.wb.active_sheet, 0);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ fn navigation_with_one_visible_sheet_is_a_noop() {
|
||||
for command in ["sheet.previous", "sheet.next"] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet(), 1);
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,6 @@ fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
|
||||
}
|
||||
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet(), expected);
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
mod autofit_filter_buttons;
|
||||
mod cf_formula_validate;
|
||||
mod chart_area_stacked100;
|
||||
mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
@@ -11,7 +10,6 @@ mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
mod extreme_selection_moves;
|
||||
mod font_scheme_minor;
|
||||
mod format_as_table_restyle;
|
||||
mod hidden_sheet_navigation;
|
||||
mod ink_roundtrip;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
|
||||
use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
||||
use crate::ast::*;
|
||||
|
||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
|
||||
@@ -338,16 +338,12 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
push(&mut out, Tok::Sheet(w, None), j + 1);
|
||||
i = j + 1;
|
||||
} else if ch(j) == Some(':') && {
|
||||
// Sheet1:Sheet3!A1 — look ahead for word then '!'.
|
||||
// Sheet1:Sheet3!A1 — look ahead for word then '!'
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
k += 1;
|
||||
}
|
||||
// `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span.
|
||||
let repeats_sheet = is_range_end(&w)
|
||||
&& matches!(out.last(), Some(Token { tok: Tok::Sheet(p, None), .. })
|
||||
if chars.get(j + 1..k).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::<String>(), p)));
|
||||
k > j + 1 && ch(k) == Some('!') && !repeats_sheet
|
||||
k > j + 1 && ch(k) == Some('!')
|
||||
} {
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
|
||||
+12
-14
@@ -23,20 +23,18 @@ pub fn same_name(a: &str, b: &str) -> bool {
|
||||
if a.is_ascii() && b.is_ascii() {
|
||||
return a.eq_ignore_ascii_case(b);
|
||||
}
|
||||
a == b || fold_name(a) == fold_name(b)
|
||||
}
|
||||
|
||||
/// The case-folded form of a name: two names are [`same_name`] when their folded forms are equal.
|
||||
pub fn fold_name(s: &str) -> String {
|
||||
s.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(|c| match c {
|
||||
'ß' => ['s', 's'].into_iter().take(2),
|
||||
'ſ' => ['s', 's'].into_iter().take(1),
|
||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||
c => [c, c].into_iter().take(1),
|
||||
})
|
||||
.collect()
|
||||
let fold = |s: &str| -> String {
|
||||
s.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.flat_map(|c| match c {
|
||||
'ß' => ['s', 's'].into_iter().take(2),
|
||||
'ſ' => ['s', 's'].into_iter().take(1),
|
||||
'ς' => ['σ', 'σ'].into_iter().take(1),
|
||||
c => [c, c].into_iter().take(1),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
a == b || fold(a) == fold(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
||||
use gridcraft_core::{CellError, MAX_ROWS};
|
||||
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
use crate::ast::*;
|
||||
use crate::lexer::{Tok, Token, tokenize_locale};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::fmt::Write;
|
||||
|
||||
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
use crate::ast::*;
|
||||
|
||||
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
||||
|
||||
|
||||
@@ -5,15 +5,6 @@ fn rt(src: &str) -> String {
|
||||
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_end_repeating_sheet_name_round_trips() {
|
||||
assert_eq!(rt("'a'!A1:'a'!B2"), "a!A1:a!B2");
|
||||
assert_eq!(rt("Sheet1!A1:Sheet1!B2"), "Sheet1!A1:Sheet1!B2");
|
||||
assert_eq!(rt("SUM(a!A1:a!B2)"), "SUM(a!A1:a!B2)");
|
||||
// Sheet names match without regard to case, as Excel's do.
|
||||
assert_eq!(rt("Sheet1!A1:SHEET1!B2"), "Sheet1!A1:SHEET1!B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sheet_qualified_name_keeps_quotes() {
|
||||
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
|
||||
@@ -512,9 +503,3 @@ fn number_literal_too_large_for_f64_is_a_parse_error() {
|
||||
}
|
||||
assert!(parse("1E+308").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_span_and_cell_start_still_lex_as_span() {
|
||||
assert_eq!(rt("SUM(Sheet1:Sheet3!A1)"), "SUM(Sheet1:Sheet3!A1)");
|
||||
assert_eq!(rt("SUM(Q1:Sheet3!A1)"), "SUM('Q1:Sheet3'!A1)");
|
||||
}
|
||||
|
||||
@@ -123,60 +123,6 @@ impl Criterion {
|
||||
Criterion { op, operand, bare_empty: false }
|
||||
}
|
||||
|
||||
fn matches_blank(&self, v: &Value) -> bool {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_number(&self, v: &Value, n: f64) -> bool {
|
||||
let cell = match v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_text(&self, v: &Value, pat: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_cut_text(&self, v: &Value, kept: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cell value satisfies the criterion.
|
||||
pub(crate) fn matches(&self, v: &Value) -> bool {
|
||||
let v = match v {
|
||||
@@ -187,8 +133,31 @@ impl Criterion {
|
||||
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
|
||||
}
|
||||
match &self.operand {
|
||||
Operand::Blank => self.matches_blank(&v),
|
||||
Operand::Number(n) => self.matches_number(&v, *n),
|
||||
Operand::Blank => {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match &v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
Operand::Number(n) => {
|
||||
let cell = match &v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
Operand::Bool(b) => match &v {
|
||||
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
|
||||
_ => self.op == Op::Ne,
|
||||
@@ -197,8 +166,27 @@ impl Criterion {
|
||||
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
|
||||
_ => self.op == Op::Ne,
|
||||
},
|
||||
Operand::Text(pat) => self.matches_text(&v, pat),
|
||||
Operand::CutText(kept) => self.matches_cut_text(&v, kept),
|
||||
Operand::Text(pat) => match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match &v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match &v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
},
|
||||
Operand::CutText(kept) => match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,27 +264,15 @@ pub(crate) fn has_wildcards(pattern: &str) -> bool {
|
||||
pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
let mut budget = MAX_STEPS;
|
||||
match_from(&p, &t, true, &mut budget).is_some()
|
||||
match_from(&p, &t, true).is_some()
|
||||
}
|
||||
|
||||
/// Steps one `match_from` call may take. An honest match against Excel's 32,767-character
|
||||
/// cell ceiling costs a few hundred thousand; a hostile one (star churn) is capped instead
|
||||
/// of quadratic — a call that spends more gives up and reports no match, quiet degradation
|
||||
/// like the other work caps on far-past-realistic input.
|
||||
const MAX_STEPS: u64 = 1 << 26;
|
||||
|
||||
/// Greedy-free matcher: returns the matched length of `t` when `p` matches a prefix of `t`
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish,
|
||||
/// quadratic at worst). `(budget, whole)`: the budget is spent one per loop step; a call that
|
||||
/// would spend more than it has gives up and reports no match — quiet degradation, as with the
|
||||
/// other work caps on far-past-realistic input (an honest match against Excel's 32,767-char
|
||||
/// cell ceiling costs a few hundred thousand steps).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<usize> {
|
||||
/// (or all of `t` when `whole`). Iterative with backtracking on the last star (linear-ish).
|
||||
fn match_from(p: &[Tok], t: &[char], whole: bool) -> Option<usize> {
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
loop {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
if pi == p.len() && (!whole || ti == t.len()) {
|
||||
return Some(ti);
|
||||
}
|
||||
@@ -331,32 +307,10 @@ fn match_from(p: &[Tok], t: &[char], whole: bool, budget: &mut u64) -> Option<us
|
||||
pub(crate) fn wildcard_find(pattern: &str, text: &str, start: usize) -> Option<usize> {
|
||||
let p = tokenize(pattern);
|
||||
let t = lower_chars(text);
|
||||
// A leading run of stars never matters for a prefix match (it consumes nothing), so
|
||||
// dropping it lets the anchor below skip linearly through the text (#2382).
|
||||
let skip = p.iter().take_while(|tk| matches!(tk, Tok::Star)).count();
|
||||
let p = &p[skip.min(p.len())..];
|
||||
if p.is_empty() {
|
||||
return if start <= t.len() { Some(start) } else { None };
|
||||
}
|
||||
// The first token that consumes a character anchors the scan: a literal skips straight
|
||||
// to its occurrences, and after leading `?`s each attempt only pays for the `?` run and
|
||||
// one suffix try. Every attempt shares one budget, so the total work stays flat.
|
||||
let anys = p.iter().take_while(|tk| matches!(tk, Tok::Any)).count();
|
||||
let anchor = p.get(anys);
|
||||
let mut budget = MAX_STEPS;
|
||||
let mut i = start;
|
||||
while i < t.len() {
|
||||
let hit = match (anchor, t.get(i + anys)) {
|
||||
(Some(Tok::Char(c)), Some(rest)) => *rest == *c && match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
(Some(Tok::Char(_)), None) => break, // the anchor char no longer exists ahead
|
||||
_ => match_from(p, &t[i..], false, &mut budget).is_some(),
|
||||
};
|
||||
if hit {
|
||||
return Some(i);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
(start..t.len()).find(|&i| t.get(i..).is_some_and(|rest| match_from(&p, rest, false).is_some()))
|
||||
}
|
||||
|
||||
/// Exact-or-wildcard equality used by MATCH/VLOOKUP/XLOOKUP exact modes: text with wildcards
|
||||
|
||||
@@ -123,16 +123,8 @@ fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
fn isoweek(sys: DateSystem, s: i64) -> R<i64> {
|
||||
let iso_wd = (weekday0(sys, s) + 6) % 7 + 1; // Monday = 1
|
||||
let thursday = s - iso_wd as i64 + 4;
|
||||
let first = match sys {
|
||||
DateSystem::D1900 => 1,
|
||||
DateSystem::D1904 => 0,
|
||||
};
|
||||
let jan1 = if thursday < first {
|
||||
// The week belongs to the year before the calendar starts.
|
||||
first - 365
|
||||
} else {
|
||||
ymd_serial(sys, dt(sys, thursday as f64)?.year as i64, 1, 1)?
|
||||
};
|
||||
let y = if thursday < 1 { 1900 } else { dt(sys, thursday as f64)?.year as i64 };
|
||||
let jan1 = ymd_serial(sys, y, 1, 1)?;
|
||||
Ok((thursday - jan1).div_euclid(7) + 1)
|
||||
}
|
||||
|
||||
@@ -449,14 +441,18 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R<f6
|
||||
Ok(match basis {
|
||||
0 => {
|
||||
let (mut a, mut b) = (d1.day as i64, d2.day as i64);
|
||||
if last_of_feb(&d1) && last_of_feb(&d2) {
|
||||
b = 30;
|
||||
}
|
||||
if last_of_feb(&d1) || a == 31 {
|
||||
if a == 31 && b == 31 {
|
||||
a = 30;
|
||||
}
|
||||
if a == 30 && b == 31 {
|
||||
b = 30;
|
||||
} else if a == 31 {
|
||||
a = 30;
|
||||
} else if a == 30 && b == 31 {
|
||||
b = 30;
|
||||
} else if last_of_feb(&d1) && last_of_feb(&d2) {
|
||||
a = 30;
|
||||
b = 30;
|
||||
} else if last_of_feb(&d1) {
|
||||
a = 30;
|
||||
}
|
||||
((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0
|
||||
}
|
||||
@@ -815,13 +811,6 @@ mod tests {
|
||||
close(ev("YEARFRAC", vec![s, n(d(2012.0, 1.0, 1.0))]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yearfrac_basis0_feb_end_to_31() {
|
||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
||||
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_lifting() {
|
||||
let v = ev("YEAR", vec![av(row(&[43831.0, 46302.0]))]);
|
||||
@@ -855,19 +844,6 @@ mod tests {
|
||||
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isoweek_at_calendar_start() {
|
||||
let iso = crate::lookup("ISOWEEKNUM").unwrap();
|
||||
let wk = crate::lookup("WEEKNUM").unwrap();
|
||||
let mut ctx = TestCtx::default();
|
||||
close(crate::call(iso, &[n(1.0)], &mut ctx), 52.0);
|
||||
close(crate::call(wk, &[n(1.0), n(21.0)], &mut ctx), 52.0);
|
||||
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
|
||||
for s in [0.0, 1.0, 2.0] {
|
||||
close(crate::call(iso, &[n(s)], &mut ctx), 53.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_arguments_use_the_workbook_date_system() {
|
||||
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
|
||||
|
||||
@@ -710,12 +710,7 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
|
||||
im1!(imln, |z: Cx| z.ln());
|
||||
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
|
||||
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
|
||||
im1!(imsqrt, |z: Cx| {
|
||||
let m = z.abs();
|
||||
let re = ((m + z.re) / 2.0).sqrt();
|
||||
let im = ((m - z.re) / 2.0).sqrt();
|
||||
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
|
||||
});
|
||||
im1!(imsqrt, |z: Cx| z.powf(0.5));
|
||||
im1!(imsin, |z: Cx| Ok(z.sin()));
|
||||
im1!(imcos, |z: Cx| Ok(z.cos()));
|
||||
im1!(imtan, |z: Cx| z.sin().div(z.cos()));
|
||||
@@ -747,7 +742,7 @@ fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R<Cx>) -> R<Value> {
|
||||
fmt_complex(f(x, y)?, s.unwrap_or('i'))
|
||||
}
|
||||
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, Cx::div)
|
||||
im_binary(a, |x, y| x.div(y))
|
||||
}
|
||||
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, |x, y| Ok(x.sub(y)))
|
||||
@@ -1276,10 +1271,6 @@ mod tests {
|
||||
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
|
||||
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
|
||||
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
|
||||
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
|
||||
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
|
||||
is_text(ev("IMSQRT", vec![t("4")]), "2");
|
||||
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
|
||||
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
|
||||
cx(ev("IMPOWER", vec![t("2+3i"), n(0.5)]), 1.674_149_228_035_540_2, 0.895_977_476_129_838);
|
||||
cx(ev("IMSIN", vec![t("3+4i")]), 3.853_738_037_919_377, -27.016_813_258_003_93);
|
||||
|
||||
@@ -278,7 +278,7 @@ fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
|
||||
Aggregate::Max => xs.iter().copied().reduce(f64::max),
|
||||
Aggregate::Min => xs.iter().copied().reduce(f64::min),
|
||||
Aggregate::Median => {
|
||||
xs.sort_by(f64::total_cmp);
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
let k = xs.len() / 2;
|
||||
let hi = xs.get(k).copied()?;
|
||||
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
|
||||
@@ -415,7 +415,7 @@ impl Timeline {
|
||||
return None;
|
||||
}
|
||||
let mut sorted = times.to_vec();
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
sorted.dedup();
|
||||
let axis = detect_axis(&sorted, sys).ok()?;
|
||||
let last = axis.position(*sorted.last()?)?.round();
|
||||
|
||||
@@ -201,7 +201,7 @@ impl FnSpec {
|
||||
pub(crate) const VAR: usize = usize::MAX;
|
||||
|
||||
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&str, usize>)> = OnceLock::new();
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&'static str, usize>)> = OnceLock::new();
|
||||
REG.get_or_init(|| {
|
||||
let mut v: Vec<FnSpec> = Vec::new();
|
||||
for list in [
|
||||
|
||||
@@ -918,9 +918,7 @@ fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rounding noise in the inverse scales with 1/scale of the input matrix.
|
||||
let noise = 1e-15 / scale;
|
||||
let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect();
|
||||
let inv = inv.into_iter().map(|x| if x.abs() < 1e-15 { 0.0 } else { x }).collect();
|
||||
to_value_array(n, n, inv)
|
||||
}
|
||||
|
||||
@@ -1624,17 +1622,6 @@ mod tests {
|
||||
is_text(ev2("ROMAN", 99.0, 4.0), "IC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minverse_scale_relative() {
|
||||
let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]);
|
||||
let r = rows_of(&big);
|
||||
close_tol(r[0][0].clone(), 1e-20, 1e-25);
|
||||
close_tol(r[1][1].clone(), 1e-20, 1e-25);
|
||||
let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]);
|
||||
let r = rows_of(&small);
|
||||
close_tol(r[0][0].clone(), 1e20, 1e10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrices() {
|
||||
let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]);
|
||||
|
||||
@@ -43,7 +43,7 @@ fn var_p(x: &[f64]) -> R<f64> {
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||
v.sort_by(f64::total_cmp);
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v
|
||||
}
|
||||
|
||||
@@ -256,9 +256,6 @@ fn intercept(y: &[f64], x: &[f64]) -> R<f64> {
|
||||
|
||||
fn trunc_digits(r: f64, sig: f64) -> f64 {
|
||||
let f = 10f64.powi(sig as i32);
|
||||
if !f.is_finite() {
|
||||
return r;
|
||||
}
|
||||
((r * f) + 1e-9).floor() / f
|
||||
}
|
||||
|
||||
@@ -2489,11 +2486,6 @@ mod tests {
|
||||
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7);
|
||||
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381);
|
||||
close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3);
|
||||
let t = c(&[1.0, 2.0, 3.0]);
|
||||
close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0);
|
||||
close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -523,7 +523,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
let mut j = t.len();
|
||||
while j > 0 {
|
||||
let best =
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((j - l, j));
|
||||
@@ -537,7 +537,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < t.len() {
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((i, i + l));
|
||||
@@ -588,7 +588,7 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
||||
if delims.iter().all(Vec::is_empty) {
|
||||
if delims.iter().all(|d| d.is_empty()) {
|
||||
return vec![s.to_vec()];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
@@ -748,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < c.len() {
|
||||
let ch = c[i];
|
||||
if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) {
|
||||
if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
|
||||
let mut j = i + 1;
|
||||
while c.get(j).is_some_and(char::is_ascii_digit) {
|
||||
while c.get(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||
j += 1;
|
||||
}
|
||||
out.push_str("${");
|
||||
|
||||
@@ -25,22 +25,14 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024;
|
||||
/// A running GridCraft app, reached through its loopback control port.
|
||||
pub struct Remote {
|
||||
addr: String,
|
||||
token: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app
|
||||
/// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port.
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
Self::connect_with_token(addr, token)
|
||||
}
|
||||
|
||||
/// Like [`Remote::connect`] with a token the caller already has.
|
||||
pub fn connect_with_token(addr: &str, token: String) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
@@ -59,9 +51,7 @@ impl Remote {
|
||||
s.set_read_timeout(Some(IO_TIMEOUT)).ok();
|
||||
s.set_write_timeout(Some(IO_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
let mut conn = (BufReader::new(read), s);
|
||||
Self::authenticate(&mut conn, &self.token)?;
|
||||
self.conn = Some(conn);
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
@@ -70,25 +60,6 @@ impl Remote {
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// The handshake every connection starts with: the app closes one that doesn't send the token first.
|
||||
fn authenticate(conn: &mut (BufReader<TcpStream>, TcpStream), token: &str) -> std::io::Result<()> {
|
||||
let (reader, writer) = conn;
|
||||
writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut reply = String::new();
|
||||
let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?;
|
||||
let ok = n > 0 && serde_json::from_str::<Value>(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
//! Authentication of the control channel (#777).
|
||||
//!
|
||||
//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:<port>`, can
|
||||
//! reach a loopback port. So the first line of every connection must be
|
||||
//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The
|
||||
//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it
|
||||
//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Environment variable holding a token chosen by the user. The app uses it instead of making
|
||||
/// one (and writes no file); clients use it instead of reading the file.
|
||||
pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN";
|
||||
/// The method of the handshake line.
|
||||
pub const AUTH_METHOD: &str = "auth";
|
||||
/// Shortest token the app accepts from the user (a made one has 256 bits).
|
||||
pub const MIN_TOKEN_LEN: usize = 16;
|
||||
/// Longest first line the app reads from a peer that hasn't authenticated yet.
|
||||
pub const MAX_AUTH_LINE: usize = 4096;
|
||||
|
||||
/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal
|
||||
/// at once (the length of a token isn't secret).
|
||||
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
// Keep the optimiser from turning the loop into an early exit.
|
||||
std::hint::black_box(diff) == 0
|
||||
}
|
||||
|
||||
/// A made token as lowercase hex (two characters per byte).
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len().saturating_mul(2));
|
||||
for b in bytes {
|
||||
s.push(char::from(DIGITS[usize::from(b >> 4)]));
|
||||
s.push(char::from(DIGITS[usize::from(b & 15)]));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The handshake line a client sends first.
|
||||
pub fn auth_line(id: u64, token: &str) -> String {
|
||||
json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
|
||||
}
|
||||
|
||||
/// Check a connection's first line. `Ok` is the reply to send before serving further requests;
|
||||
/// `Err` is the reply for a refused connection, which the caller sends and then closes. The
|
||||
/// refusal is the same whatever was wrong, so it tells a stranger nothing.
|
||||
pub fn check_first_line(line: &str, token: &str) -> Result<Value, Value> {
|
||||
let refused = || json!({"ok": false, "error": "authentication required"});
|
||||
let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?;
|
||||
if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
|
||||
return Err(refused());
|
||||
}
|
||||
let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or("");
|
||||
// Always compare, so a missing token costs the same as a wrong one.
|
||||
if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() {
|
||||
Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}}))
|
||||
} else {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the app writes the token it made for `port`, and where clients look for it: a folder
|
||||
/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows
|
||||
/// `%USERPROFILE%\.gridcraft`), file `control-<port>.token`. `None` when no such folder is known.
|
||||
pub fn token_path(port: u16) -> Option<PathBuf> {
|
||||
let dir = if cfg!(windows) {
|
||||
PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft")
|
||||
} else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
|
||||
PathBuf::from(run).join("gridcraft")
|
||||
} else {
|
||||
PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft")
|
||||
};
|
||||
Some(dir.join(format!("control-{port}.token")))
|
||||
}
|
||||
|
||||
/// The port of a control address (`127.0.0.1:7979` → 7979).
|
||||
fn port_of(addr: &str) -> Option<u16> {
|
||||
addr.rsplit(':').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote.
|
||||
pub fn client_token(addr: &str) -> Result<String, String> {
|
||||
if let Ok(t) = std::env::var(TOKEN_ENV)
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Ok(t.trim().to_string());
|
||||
}
|
||||
let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
|
||||
_ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix),
|
||||
/// an older file is replaced, and the new one is created readable by the user only (mode 0600).
|
||||
/// Returns the path.
|
||||
pub fn write_token_file(port: u16, token: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?;
|
||||
let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
// Replace rather than open: a leftover file may have looser permissions, or be a link.
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(&path)?;
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! An in-process engine session that answers the engine-level control-channel methods itself.
|
||||
|
||||
use gridcraft_engine::{Session, Target};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
@@ -33,9 +33,9 @@ impl Headless {
|
||||
Self { session }
|
||||
}
|
||||
|
||||
fn execute(&mut self, target: Target, id: &str, params: Value) -> Result<Value, String> {
|
||||
fn execute(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
// Programmatic calls never open dialogs; anything a command queued for a UI is dropped.
|
||||
self.session.execute_headless_in(target, id, params).map_err(|e| e.to_string())
|
||||
self.session.execute_headless(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands(&self, params: &Value) -> Value {
|
||||
@@ -68,28 +68,14 @@ impl Backend for Headless {
|
||||
if !(p.is_object() || p.is_null()) {
|
||||
return Err(format!("engine.execute: `params` of `{id}` must be an object"));
|
||||
}
|
||||
let target = Target::from_json(¶ms).map_err(|e| e.to_string())?;
|
||||
self.execute(target, &id, p)
|
||||
self.execute(&id, p)
|
||||
}
|
||||
"engine.commands" => Ok(self.commands(¶ms)),
|
||||
"engine.journal" => Ok(Value::Array(self.session.journal.iter().map(|(id, p)| json!({"command": id, "params": p})).collect())),
|
||||
m if m.starts_with("ui.") || m.starts_with("app.screenshot") => Err(format!("{m}: {NO_UI}")),
|
||||
// Read-only engine methods (`document.inspect`, `sheet.read`, `cell.get`, …) are
|
||||
// commands with the same id.
|
||||
// Their params may name a `window` or `doc` to act in, except for the window commands,
|
||||
// whose own params those are.
|
||||
m if gridcraft_engine::find_command(m).is_some() => {
|
||||
if gridcraft_engine::windows::is_window_command(m) {
|
||||
return self.execute(Target::Active, m, params);
|
||||
}
|
||||
let target = Target::from_json(¶ms).map_err(|e| e.to_string())?;
|
||||
let mut params = params;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("window");
|
||||
o.remove("doc");
|
||||
}
|
||||
self.execute(target, m, params)
|
||||
}
|
||||
m if gridcraft_engine::find_command(m).is_some() => self.execute(m, params),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
pub mod control_auth;
|
||||
mod headless;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
+2
-78
@@ -106,7 +106,6 @@ fn tools_list_has_schemas() {
|
||||
"redo",
|
||||
"screenshot",
|
||||
"list_functions",
|
||||
"list_windows",
|
||||
] {
|
||||
assert!(names.contains(&want), "missing tool {want}");
|
||||
}
|
||||
@@ -367,11 +366,6 @@ fn remote_backend_over_tcp() {
|
||||
let stream = stream.unwrap();
|
||||
let mut w = stream.try_clone().unwrap();
|
||||
let mut r = BufReader::new(stream);
|
||||
// Every connection starts with the token, as the app requires.
|
||||
let mut first = String::new();
|
||||
r.read_line(&mut first).unwrap();
|
||||
let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap();
|
||||
w.write_all(format!("{hello}\n").as_bytes()).unwrap();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
@@ -391,7 +385,7 @@ fn remote_backend_over_tcp() {
|
||||
});
|
||||
let addr = control_addr(&port.to_string());
|
||||
assert_eq!(addr, format!("127.0.0.1:{port}"));
|
||||
let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap()));
|
||||
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
|
||||
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0));
|
||||
// Second call reconnects transparently.
|
||||
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
|
||||
@@ -400,49 +394,7 @@ fn remote_backend_over_tcp() {
|
||||
assert!(msg.contains("unknown command"), "{msg}");
|
||||
drop(s);
|
||||
handle.join().unwrap();
|
||||
assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_auth_handshake_and_compare() {
|
||||
use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex};
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let ok = check_first_line(&auth_line(7, token), token).unwrap();
|
||||
assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true)));
|
||||
// Wrong or missing token, another method, and non-JSON are all refused the same way.
|
||||
let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err();
|
||||
for bad in [
|
||||
auth_line(1, ""),
|
||||
r#"{"id":1,"method":"auth","params":{}}"#.into(),
|
||||
r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(),
|
||||
"POST / HTTP/1.1".into(),
|
||||
String::new(),
|
||||
] {
|
||||
assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}");
|
||||
}
|
||||
// An empty configured token never matches an empty given one.
|
||||
assert!(check_first_line(&auth_line(1, ""), "").is_err());
|
||||
assert!(constant_time_eq(b"abc", b"abc"));
|
||||
assert!(!constant_time_eq(b"abc", b"abd"));
|
||||
assert!(!constant_time_eq(b"abc", b"abcd"));
|
||||
assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_token_file_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Unique port per run so parallel tests don't share a file; the folder is the real one.
|
||||
let port = 40_000 + (std::process::id() % 20_000) as u16;
|
||||
let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
// A looser leftover is replaced, not reused.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
|
||||
crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert!(Remote::connect("127.0.0.1:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -596,31 +548,3 @@ fn invalid_sort_keys_are_invalid_params() {
|
||||
}
|
||||
ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_argument_routes_tools_to_that_window() {
|
||||
let mut s = server();
|
||||
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "book one"}));
|
||||
ok(&mut s, "new_workbook", json!({}));
|
||||
let windows = ok(&mut s, "list_windows", json!({}));
|
||||
let rows = windows.as_array().unwrap();
|
||||
assert_eq!(rows.len(), 2, "{windows}");
|
||||
let (w1, book1) = (rows[0]["window"].as_u64().unwrap(), rows[0]["doc"].as_u64().unwrap());
|
||||
assert_eq!((rows[0]["title"].as_str(), rows[1]["active"].as_bool()), (Some("Book1"), Some(true)));
|
||||
|
||||
// Book2 stays active; reads and writes go to Book1's window.
|
||||
ok(&mut s, "set_cell", json!({"cell": "B1", "input": "=A1", "window": w1}));
|
||||
let got = ok(&mut s, "get_cell", json!({"cell": "B1", "doc": book1}));
|
||||
assert!(got.to_string().contains("book one"), "{got}");
|
||||
let book2 = ok(&mut s, "read_range", json!({"range": "A1:B1"}));
|
||||
assert!(!book2.to_string().contains("book one"), "{book2}");
|
||||
let info = ok(&mut s, "inspect_workbook", json!({"window": w1}));
|
||||
assert!(info.to_string().contains("Book1"), "{info}");
|
||||
assert!(fails(&mut s, "get_cell", json!({"cell": "A1", "window": 999})).contains("no open window"));
|
||||
|
||||
// A second window on Book1 gets a numbered title.
|
||||
ok(&mut s, "execute_command", json!({"command": "view.newWindow", "window": w1}));
|
||||
let titles: Vec<String> =
|
||||
ok(&mut s, "list_windows", json!({})).as_array().unwrap().iter().map(|r| r["title"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(titles, ["Book1:1", "Book2", "Book1:2"]);
|
||||
}
|
||||
|
||||
+2
-62
@@ -106,26 +106,8 @@ const BORDER_PRESETS: &[&str] = &[
|
||||
"diagonalUp",
|
||||
];
|
||||
|
||||
/// Tools that don't act on one workbook, so they take no `window`/`doc` target.
|
||||
const UNTARGETED: &[&str] = &["list_commands", "list_functions", "list_windows", "new_workbook", "open_workbook", "screenshot"];
|
||||
|
||||
/// All tools, in `tools/list` order. Tools that act on a workbook take an optional `window` or
|
||||
/// `doc` id (from list_windows) and default to the active window.
|
||||
/// All tools, in `tools/list` order.
|
||||
pub fn tool_definitions() -> Vec<Value> {
|
||||
let mut tools = base_tool_definitions();
|
||||
for t in &mut tools {
|
||||
if UNTARGETED.iter().any(|n| t["name"] == *n) {
|
||||
continue;
|
||||
}
|
||||
if let Some(props) = t["inputSchema"]["properties"].as_object_mut() {
|
||||
props.insert("window".into(), int("Window id from list_windows (default: the active window)"));
|
||||
props.insert("doc".into(), int("Workbook id from list_windows: its most recent window (default: the active window)"));
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
fn base_tool_definitions() -> Vec<Value> {
|
||||
let empty = || obj(json!({}), &[]);
|
||||
vec![
|
||||
// ----- generic -----
|
||||
@@ -143,14 +125,6 @@ fn base_tool_definitions() -> Vec<Value> {
|
||||
),
|
||||
false,
|
||||
),
|
||||
tool(
|
||||
"list_windows",
|
||||
"List windows",
|
||||
"List the open windows: window id, workbook (`doc`) id, title (\"Book1\", or \"Book1:2\" for a workbook's second \
|
||||
window), unsaved changes, active and hidden. Pass a `window` or `doc` id to other tools to act there.",
|
||||
empty(),
|
||||
true,
|
||||
),
|
||||
tool(
|
||||
"list_commands",
|
||||
"List commands",
|
||||
@@ -634,7 +608,6 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<Value, String>
|
||||
exec(b, cmd, p)
|
||||
}
|
||||
"list_commands" => b.call("engine.commands", pick(a, &["search"])),
|
||||
"list_windows" => exec(b, "window.list", json!({})),
|
||||
"inspect_workbook" => b.call("document.inspect", json!({})),
|
||||
"read_range" => b.call("sheet.read", pick(a, &["range", "sheet", "formulas", "formatted"])),
|
||||
"write_range" => exec(b, "range.setValues", pick(a, &["range", "values"])),
|
||||
@@ -665,35 +638,6 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<Value, String>
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends every engine call of one tool to the window its `window`/`doc` argument names.
|
||||
struct Targeted<'a> {
|
||||
inner: &'a mut dyn Backend,
|
||||
target: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Backend for Targeted<'_> {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let envelope = if method == "engine.execute" {
|
||||
params
|
||||
} else if gridcraft_engine::find_command(method).is_some() {
|
||||
json!({"command": method, "params": params})
|
||||
} else {
|
||||
return self.inner.call(method, params);
|
||||
};
|
||||
let mut envelope = envelope;
|
||||
if let Some(o) = envelope.as_object_mut() {
|
||||
o.extend(self.target.clone());
|
||||
}
|
||||
self.inner.call("engine.execute", envelope)
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
self.inner.has_ui()
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
self.inner.describe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one tool. Failures become `isError` results with a readable message.
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
let empty = Map::new();
|
||||
@@ -702,11 +646,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
Value::Null => &empty,
|
||||
_ => return ToolResult::error("tool arguments must be a JSON object"),
|
||||
};
|
||||
let target: Map<String, Value> =
|
||||
["window", "doc"].iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect();
|
||||
let r =
|
||||
if target.is_empty() || UNTARGETED.contains(&name) { dispatch(b, name, a) } else { dispatch(&mut Targeted { inner: b, target }, name, a) };
|
||||
match r {
|
||||
match dispatch(b, name, a) {
|
||||
Ok(v) => ToolResult::json(&v),
|
||||
Err(e) => ToolResult::error(format!("{name}: {e}")),
|
||||
}
|
||||
|
||||
@@ -410,7 +410,6 @@ pub enum ChartKind {
|
||||
Doughnut,
|
||||
Area,
|
||||
AreaStacked,
|
||||
AreaStacked100,
|
||||
Scatter,
|
||||
ScatterLines,
|
||||
Bubble,
|
||||
@@ -1045,9 +1044,6 @@ pub struct PivotTable {
|
||||
pub values: Vec<PivotValue>,
|
||||
pub filters: Vec<PivotFilter>,
|
||||
pub layout: PivotLayout,
|
||||
/// PivotTable Options › Data › Enable show details: Show Details lists a value cell's
|
||||
/// source records only while this is on.
|
||||
pub enable_show_details: bool,
|
||||
/// Grand total for each row (the "Grand Total" column), like Excel's "On for Rows".
|
||||
pub grand_totals_rows: bool,
|
||||
/// Grand total for each column (the "Grand Total" row), like Excel's "On for Columns".
|
||||
@@ -1091,7 +1087,6 @@ impl Default for PivotTable {
|
||||
values: vec![],
|
||||
filters: vec![],
|
||||
layout: PivotLayout::Compact,
|
||||
enable_show_details: true,
|
||||
grand_totals_rows: true,
|
||||
grand_totals_cols: true,
|
||||
subtotals: true,
|
||||
|
||||
@@ -56,8 +56,6 @@ pub struct PreservedSheet {
|
||||
pub rel_id: String,
|
||||
/// How many worksheets come before it: its position among the model's sheets.
|
||||
pub before: usize,
|
||||
/// Its `sheetId` in the source file.
|
||||
pub sheet_id: u32,
|
||||
}
|
||||
|
||||
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
|
||||
|
||||
@@ -7,10 +7,7 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::{
|
||||
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
|
||||
Validation,
|
||||
};
|
||||
use crate::features::*;
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
|
||||
@@ -55,10 +52,6 @@ impl SheetViewMode {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
/// The `sheetId` the file gave the sheet, written back on save so that what refers to the
|
||||
/// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub file_id: u32,
|
||||
pub cells: CellStore,
|
||||
/// Static pictures are sparse rich content. Ordinary cells carry no picture pointer.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")]
|
||||
@@ -87,13 +80,6 @@ pub struct Sheet {
|
||||
/// outline itself stays.
|
||||
#[serde(default = "yes")]
|
||||
pub show_outline_symbols: bool,
|
||||
/// Outline summary rows sit below their detail, and summary columns right of it (Data ▸
|
||||
/// Outline ▸ Settings; off puts them above / left). Saved as `outlinePr summaryBelow` and
|
||||
/// `summaryRight`.
|
||||
#[serde(default = "yes")]
|
||||
pub summary_below: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub summary_right: bool,
|
||||
pub right_to_left: bool,
|
||||
pub zoom: u16,
|
||||
pub cond_formats: Vec<CondFormat>,
|
||||
@@ -149,7 +135,6 @@ impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
file_id: 0,
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
spill: BTreeMap::new(),
|
||||
@@ -164,8 +149,6 @@ impl Sheet {
|
||||
visibility: Visibility::Visible,
|
||||
show_gridlines: true,
|
||||
show_outline_symbols: true,
|
||||
summary_below: true,
|
||||
summary_right: true,
|
||||
show_headings: true,
|
||||
show_formulas: false,
|
||||
show_zeros: true,
|
||||
@@ -630,11 +613,8 @@ mod tests {
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
mod picture_map {
|
||||
use crate::cell::CellPicture;
|
||||
use gridcraft_core::CellRef;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use super::*;
|
||||
use serde::{Deserializer, Serializer};
|
||||
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
map.iter().collect::<Vec<_>>().serialize(serializer)
|
||||
}
|
||||
|
||||
@@ -193,14 +193,6 @@ pub struct Font {
|
||||
/// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
|
||||
#[serde(default)]
|
||||
pub family: Option<u8>,
|
||||
/// The font follows the theme's body font (`<scheme val="minor"/>`) while its face is the
|
||||
/// theme's minor font. Fonts derived from it (bold, a colour, a size) keep the link.
|
||||
#[serde(default = "default_minor")]
|
||||
pub minor: bool,
|
||||
}
|
||||
|
||||
fn default_minor() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Eq for Font {}
|
||||
@@ -208,7 +200,7 @@ impl Hash for Font {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.name.hash(h);
|
||||
self.size.to_bits().hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family, self.minor).hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +217,6 @@ impl Default for Font {
|
||||
vert: VertAlign::Baseline,
|
||||
charset: None,
|
||||
family: None,
|
||||
minor: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Workbooks.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_formula::Expr;
|
||||
@@ -148,8 +147,7 @@ pub struct Workbook {
|
||||
pub calc: CalcSettings,
|
||||
#[serde(default, skip_serializing_if = "is_all")]
|
||||
pub show_objects: ShowObjects,
|
||||
/// Read and set with [`Workbook::active_sheet`] and [`Workbook::set_active_sheet`].
|
||||
pub active_sheet: ActiveSheet,
|
||||
pub active_sheet: usize,
|
||||
pub props: DocProps,
|
||||
/// Workbook structure protection.
|
||||
pub protected_structure: bool,
|
||||
@@ -207,57 +205,6 @@ impl Default for Workbook {
|
||||
}
|
||||
}
|
||||
|
||||
/// The active sheet index of a [`Workbook`].
|
||||
///
|
||||
/// Every other part of a workbook value is immutable once shared (the engine keeps versions as
|
||||
/// `Arc<Workbook>` and tells unsaved changes by pointer). The active sheet is the exception: it
|
||||
/// is the view state of the window showing the workbook, and several windows on one workbook
|
||||
/// swap their own sheet into it whenever one of them becomes the one commands act on. Keeping it
|
||||
/// in an atomic lets that swap happen through the shared `Arc` without cloning the workbook.
|
||||
/// Copies (undo versions, a cloned workbook) take the value at the time of the copy.
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
#[serde(from = "usize", into = "usize")]
|
||||
pub struct ActiveSheet(AtomicUsize);
|
||||
|
||||
impl ActiveSheet {
|
||||
pub fn get(&self) -> usize {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
pub fn set(&self, i: usize) {
|
||||
self.0.store(i, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl From<usize> for ActiveSheet {
|
||||
fn from(i: usize) -> ActiveSheet {
|
||||
ActiveSheet(AtomicUsize::new(i))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ActiveSheet> for usize {
|
||||
fn from(a: ActiveSheet) -> usize {
|
||||
a.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for ActiveSheet {
|
||||
fn clone(&self) -> ActiveSheet {
|
||||
ActiveSheet::from(self.get())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for ActiveSheet {
|
||||
fn eq(&self, other: &ActiveSheet) -> bool {
|
||||
self.get() == other.get()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ActiveSheet {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.get().fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Workbook {
|
||||
/// A new workbook with one sheet, "Sheet1".
|
||||
pub fn new() -> Workbook {
|
||||
@@ -269,7 +216,7 @@ impl Workbook {
|
||||
date_system: DateSystem::D1900,
|
||||
calc: CalcSettings::default(),
|
||||
show_objects: ShowObjects::All,
|
||||
active_sheet: ActiveSheet::default(),
|
||||
active_sheet: 0,
|
||||
props: DocProps::default(),
|
||||
protected_structure: false,
|
||||
structure_password: None,
|
||||
@@ -292,17 +239,8 @@ impl Workbook {
|
||||
pub fn sheet_index(&self, name: &str) -> Option<usize> {
|
||||
self.sheets.iter().position(|s| s.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
/// The index of the sheet the workbook's window shows (the file's active tab).
|
||||
pub fn active_sheet(&self) -> usize {
|
||||
self.active_sheet.get()
|
||||
}
|
||||
/// Changes the active sheet in place, also through a shared `Arc<Workbook>`: it is view state,
|
||||
/// so switching sheets or windows neither copies the workbook nor makes it a new version.
|
||||
pub fn set_active_sheet(&self, i: usize) {
|
||||
self.active_sheet.set(i);
|
||||
}
|
||||
pub fn active(&self) -> Option<&Sheet> {
|
||||
self.sheet(self.active_sheet())
|
||||
self.sheet(self.active_sheet)
|
||||
}
|
||||
/// A fresh sheet name `SheetN` not in use.
|
||||
pub fn next_sheet_name(&self) -> String {
|
||||
|
||||
@@ -525,7 +525,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
let s = if k + 1 == count {
|
||||
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
dc.get(k).map(ToString::to_string).unwrap_or_default()
|
||||
dc.get(k).map(|c| c.to_string()).unwrap_or_default()
|
||||
};
|
||||
let s = if s.is_empty() {
|
||||
match toks.get(ti) {
|
||||
|
||||
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
|
||||
'ž' => 'z',
|
||||
'Œ' => 'W',
|
||||
'œ' => 'm',
|
||||
'‘'..='‚' => '\'',
|
||||
'“'..='„' => '"',
|
||||
'‘' | '’' | '‚' => '\'',
|
||||
'“' | '”' | '„' => '"',
|
||||
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
|
||||
'\u{a0}' => ' ',
|
||||
'·' | '•' => '.',
|
||||
|
||||
@@ -9,15 +9,10 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||
# craft-libs dependency uses (see the root Cargo.toml).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
|
||||
# harness (and a host with no symbol font) renders them as tofu.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -36,14 +31,12 @@ epaint_default_fonts = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
sys-locale = { workspace = true }
|
||||
craft-window = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Read the browser's preferred language for the first-run web interface language.
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator", "Performance"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -12,10 +12,6 @@ use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[path = "src/craft_fonts_policy.rs"]
|
||||
mod craft_fonts_policy;
|
||||
|
||||
/// Build script: reads the contributor JSON/TOML and writes generated `credits.rs`, then emits `craft_fonts.rs`; missing or malformed optional inputs warn, while required mode reports build errors.
|
||||
fn main() {
|
||||
// Read when the script runs: `env!` would keep the path from when the script was compiled,
|
||||
// which goes stale if the checkout moves (and a missing file reruns the script every build).
|
||||
@@ -31,107 +27,6 @@ fn main() {
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
emit_craft_fonts();
|
||||
}
|
||||
|
||||
/// The optional craft-fonts build input (<https://github.com/storytold/craft-fonts>, recipe from
|
||||
/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=<absolute checkout>` the permitted fonts in
|
||||
/// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset,
|
||||
/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1` (exactly
|
||||
/// `1`; any other value leaves required mode off), which turns a missing checkout, a missing
|
||||
/// required face or a malformed row into a build error.
|
||||
///
|
||||
/// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo
|
||||
/// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces
|
||||
/// stay in the shared repository for other consumers but are filtered here. A web build embeds
|
||||
/// only the explicit `WEB_ALLOWED` faces (no CJK collection). Native builds additionally opt into
|
||||
/// craft-fonts' `fonts/simplified-chinese-manifest.txt` (the OFL LXGW face DeckCraft uses): it is
|
||||
/// optional for ordinary builds and required for `CRAFT_FONTS_REQUIRED=1` native builds.
|
||||
fn emit_craft_fonts() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let required = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref());
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut src = String::new();
|
||||
let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};");
|
||||
src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) {
|
||||
Some(dir) => match craft_fonts(&dir, wasm, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
},
|
||||
None if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED=1 requires CRAFT_FONTS_DIR (an absolute craft-fonts checkout)");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed.
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let rows = craft_fonts_policy::parse_manifest(&text)?;
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
if required {
|
||||
craft_fonts_policy::check_required(&rows)?;
|
||||
}
|
||||
let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? };
|
||||
for row in rows.iter().chain(&chinese) {
|
||||
if craft_fonts_policy::barred(&row.family) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) {
|
||||
let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = row.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let family = &row.family;
|
||||
let style = &row.style;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({path:?}) }},",
|
||||
scripts.join(", "),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The opt-in native Simplified Chinese rows. Older craft-fonts checkouts may lack the manifest, so
|
||||
/// an ordinary build warns and carries on; a required acceptance build fails rather than passing
|
||||
/// Chinese coverage only because the face was silently absent.
|
||||
fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
let manifest = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let optional = |e: String| -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
if required {
|
||||
Err(format!("{}: {e}", manifest.display()))
|
||||
} else {
|
||||
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", manifest.display());
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
match std::fs::read_to_string(&manifest) {
|
||||
Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| {
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
craft_fonts_policy::check_chinese_required(&rows)?;
|
||||
Ok(rows)
|
||||
}) {
|
||||
Ok(rows) => Ok(rows),
|
||||
Err(e) => optional(e),
|
||||
},
|
||||
Err(e) => optional(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
|
||||
@@ -122,7 +122,7 @@ fn main() {
|
||||
app.open_dialog(name, p);
|
||||
}
|
||||
// Page Layout view: edit the first page's centre header (or footer) section.
|
||||
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet(), s)));
|
||||
let active = app.session.active().and_then(|d| d.wb.active().cloned().map(|s| (d.wb.active_sheet, s)));
|
||||
if let (Some(part), Some((si, sh))) = (&hf_edit, active) {
|
||||
let zone = gridcraft_engine::paper::HfZone { footer: part == "footer", section: 1 };
|
||||
gridcraft_ui_egui::paper_view::begin(&mut app, si, &sh, (0, 0), zone);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user