mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 16:30:50 -06:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb397a3c1 | ||
|
|
ef927e2e71 | ||
|
|
6558f26ba8 | ||
|
|
9172516aa8 | ||
|
|
824a6fffd2 | ||
|
|
9c6dbfdfab | ||
|
|
e9430daa9a | ||
|
|
7ddcb800ea | ||
|
|
5579ec9a65 | ||
|
|
7c5f417a5d | ||
|
|
f988c2843e | ||
|
|
9f4ca54e9a | ||
|
|
94b292d5a8 | ||
|
|
23e09713a9 | ||
|
|
5119fb5f51 | ||
|
|
b5acb39356 | ||
|
|
9a094696fe | ||
|
|
57d7ea9bc3 | ||
|
|
e8f2becf4a | ||
|
|
ad6aca08bf | ||
|
|
b78afc9b3c | ||
|
|
3bec72f643 | ||
|
|
6ed56683bf | ||
|
|
9ac06dd8cc | ||
|
|
8b96d30c2b | ||
|
|
c1cc8264fe | ||
|
|
52b00fe8f8 | ||
|
|
0151f43af8 | ||
|
|
11b55e0c71 | ||
|
|
8e816dff1e | ||
|
|
745e937b0b | ||
|
|
0524c04756 | ||
|
|
6c478475e4 | ||
|
|
cd51fe110d | ||
|
|
b3a3590db6 | ||
|
|
ec7ee8d4ea | ||
|
|
84d4f6aa84 | ||
|
|
e82e91b451 | ||
|
|
113aeff07c | ||
|
|
44e9ddad45 | ||
|
|
4fbd1dc337 | ||
|
|
58aa00bae4 | ||
|
|
14ab0f8ee3 | ||
|
|
2d56f1b936 | ||
|
|
c03c61fd05 | ||
|
|
44c3a36ea1 | ||
|
|
1d7206ebdd | ||
|
|
fcbbfa5142 | ||
|
|
c6c54c57e6 | ||
|
|
44c663aea2 | ||
|
|
0cb1745597 | ||
|
|
b36a3a9b8b | ||
|
|
c01e46619a |
Generated
+109
-49
@@ -195,6 +195,15 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.7.0"
|
||||
@@ -211,11 +220,17 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -948,7 +963,7 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"skrifa",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -977,7 +992,7 @@ dependencies = [
|
||||
"verifysign",
|
||||
"winreg",
|
||||
"winsafe",
|
||||
"zip",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1115,6 +1130,17 @@ version = "0.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dify"
|
||||
version = "0.8.0"
|
||||
@@ -1425,7 +1451,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"self_cell",
|
||||
"skrifa 0.44.0",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"unicode-bidi",
|
||||
"unicode-general-category",
|
||||
@@ -1452,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1631,9 +1657,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "font-types"
|
||||
version = "0.12.7"
|
||||
version = "0.12.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
|
||||
checksum = "6c92d9081c7ab3bd83ef1eecdfad090f3d966e3ce74106779664114cfe1a6274"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -1844,7 +1870,7 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
"log",
|
||||
"peniko",
|
||||
"skrifa 0.44.0",
|
||||
"skrifa",
|
||||
"smallvec",
|
||||
"vello_common",
|
||||
]
|
||||
@@ -1998,9 +2024,9 @@ dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
"log",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2034,7 +2060,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"web-time",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2114,7 +2140,7 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.48.0",
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"web-sys",
|
||||
@@ -2149,11 +2175,11 @@ dependencies = [
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.42.0",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
"zip 3.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2204,7 +2230,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"read-fonts 0.41.0",
|
||||
"read-fonts",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
@@ -3481,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3937,7 +3963,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4010,9 +4036,9 @@ checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "ratex-font"
|
||||
version = "0.1.15"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4373503a1068c9f7d07862583bbd897ac4d2bdf89d42ac8c65870c33135bf6f"
|
||||
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
"ratex-types",
|
||||
@@ -4061,9 +4087,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ratex-types"
|
||||
version = "0.1.15"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae81208192f9898cbd2424aa42fd4472bd79c44a52092673b101cdca72c89b7c"
|
||||
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -4117,19 +4143,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.45.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4edc8db0d972de88e53a085b1f01fb3fbdae7bb0cb7a0ce2b2f8285a412fe3ef"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"foldhash 0.2.0",
|
||||
"font-types",
|
||||
"once_cell",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
@@ -4335,7 +4348,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4394,7 +4407,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4463,6 +4476,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4858,17 +4882,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.41.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "skrifa"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5213b4825bac3587046330052d61bfac7bd3bd0d203c758d7c2ef726fcea034"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"read-fonts 0.45.1",
|
||||
"read-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5005,6 +5019,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5138,7 +5158,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5234,6 +5254,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -6106,7 +6151,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6353,6 +6398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6805,6 +6851,20 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "9.1.0"
|
||||
|
||||
+5
-5
@@ -40,8 +40,8 @@ chrono = { version = "0.4.45", default-features = false, features = ["std", "clo
|
||||
web-time = "1.1.0"
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.42"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
@@ -52,8 +52,8 @@ image = { version = "0.25", default-features = false, features = ["png", "jpeg"]
|
||||
# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release.
|
||||
ratex-parser = "=0.1.14"
|
||||
ratex-layout = "=0.1.14"
|
||||
ratex-types = "=0.1.15"
|
||||
ratex-font = "=0.1.15"
|
||||
ratex-types = "=0.1.14"
|
||||
ratex-font = "=0.1.14"
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
@@ -66,7 +66,7 @@ arboard = { version = "3.6", default-features = false }
|
||||
sys-locale = "0.3"
|
||||
# Font parser egui's epaint uses; the UI validates system font files with it before handing them
|
||||
# to egui, which panics on a file it can't parse. Same version as epaint's, so no second copy.
|
||||
skrifa = { version = "0.48", default-features = false, features = ["std"] }
|
||||
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
|
||||
|
||||
@@ -274,3 +274,34 @@ 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.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
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.
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
|
||||
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
|
||||
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
|
||||
| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
|
||||
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
|
||||
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
|
||||
@@ -254,6 +257,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
|
||||
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
|
||||
| 2026-11-02 | minor | Progress log: Python cells as data |
|
||||
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
|
||||
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
|
||||
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
|
||||
|
||||
@@ -24,9 +24,13 @@ 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]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[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).
|
||||
|
||||
@@ -135,6 +135,10 @@ impl Calc {
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
}
|
||||
} else if cell.py.is_some() {
|
||||
// A Python cell lays out its kept result, so it is calculated like a formula
|
||||
// that reads nothing: its spill is blocked and unblocked like a formula's.
|
||||
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,11 +210,29 @@ impl Calc {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
None if python => {
|
||||
self.graph.insert(k, Node { areas: Vec::new(), dynamic: false });
|
||||
// New code has no result yet: the spill the cell had (its old result's, or a
|
||||
// formula's) goes, and what read it is recalculated, as for a formula
|
||||
// replaced by a constant.
|
||||
if !spills
|
||||
&& let Some(sh) = wb.sheet_mut(k.0)
|
||||
&& let Some(r) = sh.spill_ranges.remove(&k.1)
|
||||
{
|
||||
sh.clear_spill(r);
|
||||
cleared.push((k.0, r));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.graph.remove(k);
|
||||
// A formula replaced by a constant (or cleared) takes its spilled values with it.
|
||||
@@ -338,11 +360,15 @@ impl Calc {
|
||||
exprs.insert(*k, e);
|
||||
}
|
||||
}
|
||||
let python: HashMap<Key, Value> = dirty
|
||||
.iter()
|
||||
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
|
||||
.collect();
|
||||
let now = self.now();
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.calculate(*k);
|
||||
@@ -547,6 +573,48 @@ pub(crate) mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
|
||||
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
|
||||
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
|
||||
let n = 1_000usize;
|
||||
let data: Vec<Value> = (0..n * n).map(|i| Value::Number(i as f64)).collect();
|
||||
let array = gridcraft_core::Array::new(n, n, data.clone()).unwrap();
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let anchor = CellRef::new(0, 0);
|
||||
let py = gridcraft_model::PyCode::new("big");
|
||||
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
|
||||
let area = RangeRef::new(anchor, CellRef::new(n as u32 - 1, n as u32 - 1));
|
||||
sh.spill_ranges.insert(anchor, area);
|
||||
for (i, c) in area.iter().enumerate().skip(1) {
|
||||
sh.spill.insert(c, data[i].clone());
|
||||
}
|
||||
let mut python = HashMap::default();
|
||||
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
|
||||
(wb, python)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_beside_a_large_spill_walks_none_of_it() {
|
||||
let (wb, python) = big_python_spill();
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
|
||||
assert_eq!(host.cell_value(0, CellRef::new(0, 0)), Value::Number(0.0));
|
||||
// Beside the spill (column ALM, the 1,001st), over the rows it covers.
|
||||
let beside = RangeRef::parse("ALM1:ALM1000").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert!(host.range_values(0, beside).iter().all(Value::is_empty));
|
||||
assert!(host.visit_range(0, beside, &mut |_| true), "a disjoint read takes the fast path");
|
||||
assert_eq!(host.spill_visits, 0, "nothing of the spill is walked");
|
||||
// A read inside it still sees the spilled values, walking only the cells it reads.
|
||||
let inside = RangeRef::parse("B2:C3").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert_eq!(host.range_values(0, inside), [1_001.0, 1_002.0, 2_001.0, 2_002.0].map(Value::Number).to_vec());
|
||||
assert!(host.spill_visits <= 8, "{}", host.spill_visits);
|
||||
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
|
||||
@@ -45,6 +45,10 @@ pub(super) struct PassHost<'a> {
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
pub(super) suspensions: usize,
|
||||
/// Spilled values looked at by range reads (tests check that a read outside a large spill
|
||||
/// looks at none of it).
|
||||
#[cfg(test)]
|
||||
pub(super) spill_visits: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
@@ -64,6 +68,10 @@ pub(super) struct PassHost<'a> {
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
|
||||
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
|
||||
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
|
||||
python: HashMap<Key, Value>,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -87,6 +95,8 @@ impl<'a> PassHost<'a> {
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
#[cfg(test)]
|
||||
spill_visits: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
@@ -96,8 +106,17 @@ impl<'a> PassHost<'a> {
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
|
||||
/// are pending until laid out, so every read path calculates them before reading them.
|
||||
pub(super) fn with_python(mut self, python: HashMap<Key, Value>) -> Self {
|
||||
self.pending.extend(python.keys().copied());
|
||||
self.python = python;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
@@ -111,7 +130,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
let own = sh.spill_ranges.get(&anchor);
|
||||
let spilled_over = |c: CellRef| {
|
||||
!own.is_some_and(|o| o.contains(c))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| {
|
||||
// A formula or Python cell recalculated in this pass lays its array out again.
|
||||
*o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o))
|
||||
})
|
||||
};
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
@@ -135,6 +157,42 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
/// The area an array anchored at `anchor` covers.
|
||||
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end)
|
||||
}
|
||||
|
||||
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
|
||||
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
|
||||
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
|
||||
/// Returns how many values were looked at.
|
||||
fn stored_spill_in<'s>(sheet: &'s gridcraft_model::Sheet, range: RangeRef, f: &mut dyn FnMut(CellRef, &'s Value) -> bool) -> usize {
|
||||
let mut seen = 0usize;
|
||||
if sheet.spill.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if (range.height() as usize) < sheet.spill.len() {
|
||||
for row in range.start.row..=range.end.row {
|
||||
for (c, v) in sheet.spill.range(CellRef::new(row, range.start.col)..=CellRef::new(row, range.end.col)) {
|
||||
seen += 1;
|
||||
if !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (c, v) in sheet.spill.range(range.start..=range.end) {
|
||||
seen += 1;
|
||||
if range.contains(*c) && !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
impl PassHost<'_> {
|
||||
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
|
||||
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
|
||||
@@ -188,6 +246,9 @@ impl PassHost<'_> {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if let Some(result) = self.python.get(&k).cloned() {
|
||||
return self.python_result(k, result);
|
||||
}
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
@@ -305,6 +366,36 @@ impl PassHost<'_> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A Python cell's kept result as its value: an array spills from the cell unless it is
|
||||
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
|
||||
/// crafted file holds one), which shows `#SPILL!`.
|
||||
fn python_result(&mut self, k: Key, result: Value) -> Value {
|
||||
self.pending.remove(&k);
|
||||
let v = match result {
|
||||
Value::Array(a) => {
|
||||
let sane = a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
|
||||
if sane {
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
}
|
||||
v => v,
|
||||
};
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A formula's previous value for iterative calculation: from the last iteration, else the
|
||||
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
|
||||
fn previous(&self, k: Key) -> Value {
|
||||
@@ -445,26 +536,29 @@ impl Host for PassHost<'_> {
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
let mut out = vec![Value::Empty; h * w];
|
||||
let Some(sh) = self.wb.sheet(sheet) else { return out };
|
||||
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
|
||||
// `compute`, constants are copied straight from the store.
|
||||
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
|
||||
// through `compute`, constants are copied straight from the store.
|
||||
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
formulas.push((i, c));
|
||||
} else if let Some(slot) = out.get_mut(i) {
|
||||
*slot = cell.value.clone();
|
||||
}
|
||||
}
|
||||
for (c, v) in sh.spill.range(range.start..=range.end) {
|
||||
if range.contains(*c) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
let _seen = stored_spill_in(sh, range, &mut |c, v| {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
true
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
for (i, c) in formulas {
|
||||
let v = self.cell_value(sheet, c);
|
||||
@@ -473,26 +567,31 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
if !self.spills.is_empty() {
|
||||
let new: Vec<(usize, Value)> = self
|
||||
.spills
|
||||
.iter()
|
||||
.filter(|(a, _)| a.0 == sheet)
|
||||
.flat_map(|(a, arr)| {
|
||||
let mut v = Vec::new();
|
||||
for r in 0..arr.rows {
|
||||
for cc in 0..arr.cols {
|
||||
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
|
||||
if c != a.1 && range.contains(c) {
|
||||
v.push((
|
||||
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
|
||||
arr.get(r, cc).cloned().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
// Only the part of each array inside the range is walked: a large array beside a small
|
||||
// read costs nothing.
|
||||
let mut new: Vec<(usize, Value)> = Vec::new();
|
||||
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
|
||||
let area = array_area(a.1, arr);
|
||||
if !area.intersects(&range) {
|
||||
continue;
|
||||
}
|
||||
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
|
||||
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
|
||||
for row in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
let c = CellRef::new(row, col);
|
||||
if c == a.1 {
|
||||
continue;
|
||||
}
|
||||
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
|
||||
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += new.len();
|
||||
}
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
@@ -516,21 +615,31 @@ impl Host for PassHost<'_> {
|
||||
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
let wb = self.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return false };
|
||||
// Formula cells first, in row-major order like `range_values`: reading one calculates it
|
||||
// and the pass remembers the value, so the walk below gets the same value again without
|
||||
// this method holding any.
|
||||
// Formula and Python cells first, in row-major order like `range_values`: reading one
|
||||
// calculates it (a Python cell lays out its result) and the pass remembers the value, so
|
||||
// the walk below gets the same value again without this method holding any.
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
let _ = self.cell_value(sheet, c);
|
||||
}
|
||||
}
|
||||
// Spilled values sit between the stored cells (and the formulas just calculated may have
|
||||
// produced some): leave such blocks to `range_values`.
|
||||
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
|
||||
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
|
||||
let mut stored = false;
|
||||
let _seen = stored_spill_in(sh, range, &mut |_, _| {
|
||||
stored = true;
|
||||
false
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
if arrays || stored {
|
||||
return false;
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2312,3 +2312,63 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
use gridcraft_model::python::{PyCode, PyOutput};
|
||||
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
|
||||
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
|
||||
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
|
||||
(0, c(at))
|
||||
}
|
||||
|
||||
fn column(values: &[f64]) -> Value {
|
||||
let data = values.iter().map(|v| Value::Number(*v)).collect();
|
||||
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
|
||||
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
|
||||
// it in the pass; each must lay it out first rather than read its old value, 1.
|
||||
let build = |t: &mut T| {
|
||||
t.set("B2", "10");
|
||||
t.set("B3", "100");
|
||||
t.set("A1", "=B1");
|
||||
t.set("A2", "=MAX(B1:B3)");
|
||||
t.set("A3", "=SUM(B1:B3)");
|
||||
t.set("A4", "=COUNTA(B1:B3)");
|
||||
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
|
||||
};
|
||||
let check = |t: &T, how: &str| {
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
|
||||
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
|
||||
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
|
||||
assert_eq!(t.num("A4"), 3.0, "{how}");
|
||||
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
|
||||
};
|
||||
|
||||
// A full recalculation.
|
||||
let mut t = T::new();
|
||||
build(&mut t);
|
||||
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
check(&t, "full");
|
||||
|
||||
// An incremental one: B1 becomes a Python cell after its readers.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "1");
|
||||
build(&mut t);
|
||||
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
|
||||
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.cells_changed(&mut t.wb, &[k]);
|
||||
check(&t, "incremental");
|
||||
|
||||
// Unblocked, the readers see the new array.
|
||||
t.set("B2", "");
|
||||
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ pub enum CellError {
|
||||
Calc,
|
||||
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
|
||||
Circ,
|
||||
/// A Python cell's code raised an error, or Python stopped.
|
||||
Python,
|
||||
/// A Python cell is waiting to run.
|
||||
Busy,
|
||||
/// A Python cell ran longer than the time limit.
|
||||
Timeout,
|
||||
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl CellError {
|
||||
pub const ALL: [CellError; 10] = [
|
||||
pub const ALL: [CellError; 14] = [
|
||||
CellError::Null,
|
||||
CellError::Div0,
|
||||
CellError::Value,
|
||||
@@ -35,6 +43,10 @@ impl CellError {
|
||||
CellError::GettingData,
|
||||
CellError::Spill,
|
||||
CellError::Calc,
|
||||
CellError::Python,
|
||||
CellError::Busy,
|
||||
CellError::Timeout,
|
||||
CellError::Blocked,
|
||||
];
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -49,9 +61,16 @@ impl CellError {
|
||||
CellError::Spill => "#SPILL!",
|
||||
CellError::Calc => "#CALC!",
|
||||
CellError::Circ => "#CIRC!",
|
||||
CellError::Python => "#PYTHON!",
|
||||
CellError::Busy => "#BUSY!",
|
||||
CellError::Timeout => "#TIMEOUT!",
|
||||
CellError::Blocked => "#BLOCKED!",
|
||||
}
|
||||
}
|
||||
/// `ERROR.TYPE` number.
|
||||
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
|
||||
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
|
||||
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
|
||||
/// and may change if Excel's numbers become known.
|
||||
pub fn code(&self) -> u32 {
|
||||
match self {
|
||||
CellError::Null => 1,
|
||||
@@ -65,6 +84,10 @@ impl CellError {
|
||||
CellError::Spill => 9,
|
||||
CellError::Calc => 14,
|
||||
CellError::Circ => 15,
|
||||
CellError::Blocked => 11,
|
||||
CellError::Python => 19,
|
||||
CellError::Busy => 20,
|
||||
CellError::Timeout => 21,
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CellError> {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod python;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(python::specs());
|
||||
v.extend(slicer::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
@@ -522,7 +524,7 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||
let filled = after.cell(c).is_some_and(gridcraft_model::Cell::has_content);
|
||||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||
continue;
|
||||
}
|
||||
@@ -538,13 +540,18 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
|
||||
/// is not something the person edits).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => a.value == b.value,
|
||||
(None, None) => match (&a.py, &b.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
|
||||
@@ -1788,7 +1788,7 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json>
|
||||
{
|
||||
for row in rect.start.row..=rect.end.row {
|
||||
for (c, cell) in sh.cells.row(row, rect.start.col, rect.end.col) {
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
if cell.has_content() {
|
||||
occupied.push(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
@@ -1816,13 +1816,13 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json>
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for c in &occupied {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
|
||||
}
|
||||
let mut touched = occupied.clone();
|
||||
for ((r, c), v) in &g.cells {
|
||||
let cell = CellRef::new(at.row + r, at.col + c);
|
||||
let style = pct_styles.get(&cell).copied().unwrap_or_else(|| sh.style_id(cell));
|
||||
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style, py: None });
|
||||
touched.push(cell);
|
||||
}
|
||||
for c in touched {
|
||||
|
||||
@@ -146,7 +146,16 @@ pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let cell = match crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb) {
|
||||
Ok(cell) => cell,
|
||||
// `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept
|
||||
// as text rather than lost.
|
||||
Err(_) if gridcraft_model::python::is_py_formula(&t) => {
|
||||
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
Some(Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style, py: None })
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
|
||||
@@ -35,7 +35,7 @@ use go_to::go_to_special;
|
||||
#[cfg(test)]
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links};
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -132,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -267,6 +267,7 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
cell.py = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
/// Typing `=PY(` doesn't make a Python cell; this says what does.
|
||||
pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…).";
|
||||
|
||||
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
|
||||
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
|
||||
pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
@@ -21,14 +24,18 @@ pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
if input.is_empty() {
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
let c = Cell { value: Value::Empty, formula: None, style, py: None };
|
||||
return Ok(if c.is_blank() { None } else { Some(c) });
|
||||
}
|
||||
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
|
||||
if fmt_is_text {
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None }));
|
||||
}
|
||||
if is_formula_input(input) {
|
||||
let is_formula = is_formula_input(input);
|
||||
if is_formula && gridcraft_model::python::is_py_formula(input) {
|
||||
return Err(EngineError::Other(PY_FORMULA.into()));
|
||||
}
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
check_arg_counts(&expr)?;
|
||||
@@ -79,7 +86,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
if input.contains('\n') && !wb.styles.get(style).align.wrap {
|
||||
style = wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style, py: None }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
@@ -147,7 +154,7 @@ pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The array takes the whole range: other cells in it lose their contents.
|
||||
for c in range.iter().skip(1).take(1_000_000) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) {
|
||||
let style = old.style;
|
||||
sh.set_cell(c, Cell { style, ..Cell::default() });
|
||||
}
|
||||
@@ -239,10 +246,14 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell {
|
||||
value: Value::Bool(*b),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
// Text as given: no date or number reading, and a leading `'` is part of the
|
||||
// text rather than the marker that makes it text. A formula is still a
|
||||
// formula, which is what the ticket asks for and what `=` means in a cell.
|
||||
@@ -258,7 +269,7 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
|
||||
}
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
|
||||
@@ -92,7 +92,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
|
||||
let mut n = 0;
|
||||
@@ -135,6 +136,30 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
// A Python cell's text is its code: the replacement edits the code and the cell
|
||||
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
|
||||
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
|
||||
if let Some(old) = old_py {
|
||||
let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) };
|
||||
if py.code == old.code {
|
||||
continue;
|
||||
}
|
||||
if !py.fits() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Its old spill is left to the recalculation, which removes it and
|
||||
// recalculates what read it.
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) },
|
||||
);
|
||||
cx.touch(si, c);
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
@@ -149,6 +174,9 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if skipped > 0 {
|
||||
return Ok(json!({"replaced": n, "skipped": skipped}));
|
||||
}
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let v = sheet.value(c);
|
||||
let ok = match kind {
|
||||
"blanks" => v.is_empty(),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()),
|
||||
"comments" | "notes" => sheet.comments.contains_key(&c),
|
||||
"errors" => v.is_error(),
|
||||
"numbers" => v.is_number(),
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
moved_formulas = taken;
|
||||
}
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
@@ -152,7 +153,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
@@ -186,6 +187,33 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
|
||||
// cells its `xl()` references named on its original sheet, as a moved formula does:
|
||||
// they are qualified with that sheet before the workbook-wide move adjustment below.
|
||||
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(Some(py));
|
||||
}
|
||||
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Some(gridcraft_formula::print(&e))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
}
|
||||
None => py,
|
||||
}))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
@@ -194,6 +222,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new.py = None;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
@@ -202,6 +231,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
@@ -214,6 +244,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
@@ -222,6 +253,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
@@ -232,7 +264,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
@@ -292,7 +328,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
|
||||
cx.structural = true;
|
||||
}
|
||||
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
|
||||
@@ -316,7 +352,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
||||
// (a cut moves its arrays itself).
|
||||
@@ -356,6 +392,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
}
|
||||
}
|
||||
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
||||
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
@@ -385,3 +422,78 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
|
||||
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
|
||||
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
|
||||
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
|
||||
/// result is kept while its references still name the same data (a rename, a move); a reference
|
||||
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
|
||||
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
|
||||
/// recalculation removes its spill. A rewrite that would
|
||||
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
|
||||
/// the whole edit, which then changes nothing: see [`check_python_fits`].
|
||||
pub(crate) fn rewrite_python_refs(
|
||||
wb: &mut gridcraft_model::Workbook,
|
||||
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
|
||||
) -> Result<()> {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let host = sh.name.clone();
|
||||
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
let mut lost = false;
|
||||
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let expr = gridcraft_formula::parse(reference).ok()?;
|
||||
let before = gridcraft_formula::print(&expr);
|
||||
let after = gridcraft_formula::print(&f(&host, expr));
|
||||
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
|
||||
(after != before).then_some(after)
|
||||
});
|
||||
if let Some(code) = changed {
|
||||
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
|
||||
new_code.push((c, code, lost));
|
||||
}
|
||||
}
|
||||
if new_code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, code, lost) in new_code {
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Some(py) = cell.py.as_mut()
|
||||
{
|
||||
let py = std::sync::Arc::make_mut(py);
|
||||
py.code = code;
|
||||
if lost {
|
||||
py.output = None;
|
||||
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
|
||||
/// for any other form (a name, an error).
|
||||
fn area_shape(reference: &str) -> Option<(u32, u32)> {
|
||||
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
|
||||
Some((r.height(), r.width()))
|
||||
}
|
||||
|
||||
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
|
||||
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
|
||||
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
|
||||
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
|
||||
if py.fits() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(format!(
|
||||
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
|
||||
gridcraft_formula::quote_sheet(sheet),
|
||||
at.a1(),
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& cell.formula.is_none()
|
||||
&& cell.py.is_none()
|
||||
{
|
||||
// An empty checkbox cell counts as FALSE.
|
||||
let next = match cell.value {
|
||||
|
||||
@@ -138,7 +138,7 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
|
||||
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
|
||||
if !opened_dialog || changed {
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(id, st["params"].clone(), &result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
|
||||
let mut sheet = Sheet::new(sheet_name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
|
||||
let mut cell = Cell::formula(Formula::new(&text));
|
||||
|
||||
@@ -656,8 +656,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -674,8 +673,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -692,7 +690,10 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let blocked = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||
// A Python cell's result is blocked and unblocked like a formula's.
|
||||
.filter(|(_, x)| {
|
||||
(x.formula.is_some() || x.py.is_some()) && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill)
|
||||
});
|
||||
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||
}
|
||||
cx.changed.extend(touched);
|
||||
|
||||
@@ -635,7 +635,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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 });
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The header row pushes the data down one row: never off the end of the sheet.
|
||||
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
@@ -325,7 +325,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
@@ -476,7 +476,10 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
|
||||
let c = CellRef::new(row, t.range.start.col + i as u32);
|
||||
if let Some(code) = col.totals.subtotal_code() {
|
||||
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
|
||||
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c), py: None },
|
||||
);
|
||||
} else if let Some(l) = &col.totals_label {
|
||||
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
|
||||
} else {
|
||||
@@ -1323,7 +1326,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style, py: None });
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
@@ -137,6 +137,13 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
"comment": sh.comments.get(&c),
|
||||
"hyperlink": sh.hyperlinks.get(&c),
|
||||
"python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({
|
||||
"code": py.code,
|
||||
"output": if py.object { "object" } else { "value" },
|
||||
"typeName": py.output.as_ref().map(|o| o.type_name.as_str()),
|
||||
"preview": py.output.as_ref().map(|o| o.preview.as_str()),
|
||||
"stale": py.stale,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,10 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, h) in src.headers.iter().enumerate() {
|
||||
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(0, c as u32),
|
||||
Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default(), py: None },
|
||||
);
|
||||
}
|
||||
for (r, i) in records.iter().enumerate() {
|
||||
let Some(rec) = src.rows.get(*i) else { continue };
|
||||
@@ -264,7 +267,7 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style, py: None });
|
||||
}
|
||||
}
|
||||
let name = sh.name.clone();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Python in cells: making and editing Python cells, and their return type. This build stores
|
||||
//! the code and the last result a file cached; it doesn't run Python, so a cell with new or
|
||||
//! changed code shows `#BLOCKED!`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"python.set",
|
||||
"Set Python Code",
|
||||
[],
|
||||
None,
|
||||
"{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"python.output",
|
||||
"Python Output",
|
||||
[],
|
||||
Some("Ctrl+Alt+Shift+M"),
|
||||
"{cell?, mode?: object|value} sets the return type of a Python cell (default: the active cell): `object` (a Python object) or `value` (an Excel value). Without `mode` it switches to the other one. The cell keeps its code; a cached result of the old return type goes, as with `python.set`, so the cell shows #BLOCKED! until it runs",
|
||||
has_doc,
|
||||
output
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = match cell_param_on(s, p, "cell", sheet)? {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
Ok((sheet, at))
|
||||
}
|
||||
|
||||
fn existing(s: &Session, sheet: usize, at: CellRef) -> Result<Option<PyCode>> {
|
||||
Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned())
|
||||
}
|
||||
|
||||
fn output_mode(cmd: &str, p: &Json, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => return Ok(None),
|
||||
Some(Json::String(_)) => {}
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` is `object` or `value`"))),
|
||||
}
|
||||
match str_param(p, key) {
|
||||
None => Ok(None),
|
||||
Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)),
|
||||
Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)),
|
||||
Some(m) => Err(bad(cmd, format!("`{key}` is `object` or `value`, not `{m}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let code = match p.get("code") {
|
||||
Some(Json::String(c)) => c.as_str(),
|
||||
Some(_) => return Err(bad("python.set", "`code` must be text")),
|
||||
None => return Err(bad("python.set", "missing `code`")),
|
||||
};
|
||||
let mode = output_mode("python.set", p, "output")?;
|
||||
let old = existing(s, sheet, at)?;
|
||||
let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false);
|
||||
store(s, "python.set", sheet, at, PyCode { object, ..PyCode::new(code) }, old)?;
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
|
||||
/// Writes `py` into the cell, which held `old`: what `python.set` and `python.output` share. The
|
||||
/// code must fit in one cell and a table header takes none; a locked cell on a protected sheet is
|
||||
/// refused by the edit itself.
|
||||
fn store(s: &mut Session, cmd: &str, sheet: usize, at: CellRef, mut py: PyCode, old: Option<PyCode>) -> Result<()> {
|
||||
if !py.fits() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"),
|
||||
));
|
||||
}
|
||||
// A table header is the column's name, never a formula or code (as in Excel).
|
||||
let header =
|
||||
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at)));
|
||||
if header {
|
||||
return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1())));
|
||||
}
|
||||
// Unchanged code with the same return type keeps its result (and its value); new code, or a
|
||||
// new return type, has none until it runs: a result of the other type would show a value and
|
||||
// preview that don't belong to the cell.
|
||||
let keep = old.filter(|o| o.code == py.code && o.object == py.object);
|
||||
let value = match &keep {
|
||||
Some(o) => {
|
||||
py.output = o.output.clone();
|
||||
py.stale = o.stale;
|
||||
None
|
||||
}
|
||||
None => Some(gridcraft_model::python::BLOCKED),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(at);
|
||||
let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty));
|
||||
// A spill the cell had (a formula's, or the old result's) is left to the recalculation,
|
||||
// which removes it and recalculates what read it.
|
||||
sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) });
|
||||
cx.touch(sheet, at);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `python.output`: the cell's return type. The code stays; a cached result belongs to the old
|
||||
/// return type, so it goes, as with `python.set` and a new return type: the cell shows
|
||||
/// `#BLOCKED!` until it runs, and its old spill goes with its readers recalculated.
|
||||
fn output(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let Some(old) = existing(s, sheet, at)? else {
|
||||
return Err(EngineError::Other(format!("{} isn't a Python cell, so it has no Python output to change", at.a1())));
|
||||
};
|
||||
let object = output_mode("python.output", p, "mode")?.unwrap_or(!old.object);
|
||||
let mode = |object: bool| if object { "object" } else { "value" };
|
||||
if object != old.object {
|
||||
let py = PyCode { code: old.code.clone(), object, output: None, stale: false };
|
||||
store(s, "python.output", sheet, at, py, Some(old))?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "output": mode(object), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
@@ -217,10 +217,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
|
||||
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
|
||||
/// source for a structural edit on `target`.
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
|
||||
cx.edits.push((target.to_string(), e.clone()));
|
||||
let wb = &mut cx.wb;
|
||||
super::edit::rewrite_all_formulas(wb, target, e);
|
||||
super::edit::rewrite_all_formulas(wb, target, e)?;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
@@ -246,6 +246,7 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
@@ -303,7 +304,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -368,7 +369,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -420,7 +421,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.cells.shift_cols(before, count as i64);
|
||||
shift_sheet_features(sh, Axis::Cols, before, count, true);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count })?;
|
||||
|
||||
let mut touched = Vec::new();
|
||||
{
|
||||
@@ -458,14 +459,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for cell in touched {
|
||||
cx.touch(sheet, cell);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination })?;
|
||||
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_cols(delete_at + count, -(count as i64));
|
||||
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count })?;
|
||||
*cx.sel = crate::Selection {
|
||||
active: CellRef::new(active_row, final_start),
|
||||
anchor: CellRef::new(active_row, final_start),
|
||||
@@ -496,7 +497,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
@@ -510,7 +511,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
shift_cell_features(sh, &e);
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -545,7 +546,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shift_cell_features(sh, &e);
|
||||
}
|
||||
// References into the deleted cells become #REF!; the rest move.
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order))?;
|
||||
// Chart series into the deleted sheet become #REF!, like formulas.
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
|
||||
cx.wb.names.retain(|n| n.scope != Some(i));
|
||||
@@ -133,6 +134,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name))?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -150,6 +152,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(src) = cx.wb.sheets.get(i).cloned() else { return Err(bad("sheet.move", "no such sheet")) };
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
// The copy is a sheet of its own, not the one it was copied from.
|
||||
sh.uid = gridcraft_model::SheetUid::fresh();
|
||||
let base = sh.name.clone();
|
||||
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
|
||||
let name = (2..)
|
||||
|
||||
@@ -303,7 +303,7 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style, py: None });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
|
||||
@@ -263,7 +263,8 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
vars.retain(|c| seen.insert(*c));
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) {
|
||||
// Python cells count as formulas: a solution written over one would lose its code.
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
|
||||
@@ -107,12 +107,12 @@ fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refused, unless `overwrite`, when a cell of the model's column holds a value or a formula
|
||||
/// (a model saved there before included): Save Model then asks before it replaces data, as
|
||||
/// Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
|
||||
/// Refused, unless `overwrite`, when a cell of the model's column holds a value, a formula or
|
||||
/// Python code (a model saved there before included): Save Model then asks before it replaces
|
||||
/// data, as Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
|
||||
/// question.
|
||||
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
|
||||
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()));
|
||||
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(Cell::has_content));
|
||||
match taken {
|
||||
Some(c) if !overwrite => Err(EngineError::Other(format!(
|
||||
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
|
||||
@@ -176,7 +176,7 @@ pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
for c in &stale {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
|
||||
}
|
||||
for c in cells.into_iter().chain(stale) {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
@@ -352,7 +352,7 @@ pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 155.0), (6, 140.0), (7, 140.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
|
||||
@@ -213,7 +213,7 @@ impl Trial {
|
||||
for (si, c, v) in sets {
|
||||
if let Some(sh) = wb.sheet_mut(*si) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None });
|
||||
keys.push((*si, *c));
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,8 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !has_formula(ssi, set) {
|
||||
return Err(EngineError::Other("Cell must contain a formula.".into()));
|
||||
}
|
||||
if has_formula(csi, chg) {
|
||||
// A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code.
|
||||
if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) {
|
||||
return Err(EngineError::Other("Cell must contain a value.".into()));
|
||||
}
|
||||
let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
@@ -342,7 +343,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(csi)?;
|
||||
let style = sh.style_id(chg);
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None });
|
||||
cx.touch(csi, chg);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -422,7 +423,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, v) in out {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
cx.touch(si, c);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
(to, Cell { value: sh.value(src), formula: None, style, py: None })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
Some(Cell { value: Value::Number(n), formula: None, style, py: None })
|
||||
}
|
||||
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
@@ -245,7 +245,7 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
|
||||
@@ -90,14 +90,14 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
if reads_back(&iso) { iso } else { raw }
|
||||
}
|
||||
|
||||
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
||||
/// protected sheet. A spilled cell follows its anchor.
|
||||
/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted
|
||||
/// Hidden on a protected sheet. A spilled cell follows its anchor.
|
||||
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
if !sheet.is_protected() {
|
||||
return false;
|
||||
}
|
||||
let at = match sheet.cell(c) {
|
||||
Some(x) if x.formula.is_some() => c,
|
||||
Some(x) if x.formula.is_some() || x.py.is_some() => c,
|
||||
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
||||
Some((anchor, _)) => *anchor,
|
||||
None => return false,
|
||||
|
||||
@@ -171,10 +171,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
cell.value = Value::Empty;
|
||||
}
|
||||
} else if let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
} {
|
||||
} else if cell.py.is_none()
|
||||
&& let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
}
|
||||
{
|
||||
cell.value = v;
|
||||
}
|
||||
cell.style = style;
|
||||
@@ -296,7 +298,8 @@ impl Pattern {
|
||||
|
||||
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())) {
|
||||
// Formulas and Python cells copy.
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Pattern::Copy;
|
||||
}
|
||||
// Numbers.
|
||||
@@ -598,7 +601,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
break;
|
||||
}
|
||||
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style, py: None }));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -657,7 +660,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, out) in &writes {
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style, py: None });
|
||||
}
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
+130
-1
@@ -118,13 +118,142 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names.
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
|
||||
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
|
||||
/// [`enforce_python_limits`].
|
||||
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
|
||||
warnings.extend(crate::macros::load(&mut wb));
|
||||
warnings.extend(adopt_python(&mut wb));
|
||||
warnings.extend(enforce_python_limits(&mut wb));
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its
|
||||
/// Python cells (each one is also limited to [`gridcraft_model::python::MAX_RESULT_CELLS`]).
|
||||
pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a
|
||||
/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets
|
||||
/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most
|
||||
/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value, and the
|
||||
/// values they spilled stay as plain values. Returns the warnings.
|
||||
pub fn adopt_python(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut budget = MAX_ADOPTED_RESULT_CELLS;
|
||||
let mut skipped = 0usize;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let formulas: Vec<(CellRef, gridcraft_model::PyCode)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| gridcraft_model::PyCode::from_formula(&f.text)).map(|p| (c, p)))
|
||||
.collect();
|
||||
let max = gridcraft_model::python::MAX_RESULT_CELLS as u64;
|
||||
let spills: Vec<(CellRef, RangeRef)> = sh
|
||||
.spill_ranges
|
||||
.iter()
|
||||
.filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none()))
|
||||
.map(|(a, r)| (*a, *r))
|
||||
.collect();
|
||||
if formulas.is_empty() && spills.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, py) in formulas {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = None;
|
||||
cell.py = Some(std::sync::Arc::new(py));
|
||||
}
|
||||
}
|
||||
for (anchor, range) in spills {
|
||||
let Some(left) = budget.checked_sub(range.count()) else {
|
||||
skipped += 1;
|
||||
// The cell keeps only its own value; the values it spilled stay as plain values, so
|
||||
// nothing shows as a spill that no result holds, and nothing the file showed is lost.
|
||||
sh.settle_spill(anchor);
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let data: Vec<gridcraft_core::Value> =
|
||||
range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect();
|
||||
let Some(array) = gridcraft_core::Array::new(range.height() as usize, range.width() as usize, data) else { continue };
|
||||
if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output =
|
||||
Some(gridcraft_model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if skipped == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![format!(
|
||||
"The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) have no result until they run again; the values they spilled were kept as plain values."
|
||||
)]
|
||||
}
|
||||
|
||||
/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's
|
||||
/// 8,192-character formula keeps only its cached value, and a kept result that is not a
|
||||
/// well-formed array of at most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells is dropped
|
||||
/// (the cell keeps its value). Either way the values the cell spilled stay as plain values
|
||||
/// ([`gridcraft_model::Sheet::settle_spill`]). Returns the warnings.
|
||||
pub fn enforce_python_limits(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut long = Vec::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let mut over = Vec::new();
|
||||
let mut bad_output = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
if !py.fits() {
|
||||
long.push(format!("{}!{}", sh.name, c.a1()));
|
||||
over.push(c);
|
||||
} else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) {
|
||||
bad_output.push(c);
|
||||
}
|
||||
}
|
||||
if over.is_empty() && bad_output.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dropped: Vec<CellRef> = over.iter().chain(&bad_output).copied().collect();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for c in over {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.py = None;
|
||||
}
|
||||
}
|
||||
for c in bad_output {
|
||||
if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output = None;
|
||||
}
|
||||
}
|
||||
// A dropped result (or code dropped to its value) leaves the values it spilled as plain
|
||||
// values, as the file showed them, with no live spill.
|
||||
for c in dropped {
|
||||
sh.settle_spill(c);
|
||||
}
|
||||
}
|
||||
let Some(first) = long.first() else { return vec![] };
|
||||
let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() };
|
||||
vec![format!(
|
||||
"A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.",
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)]
|
||||
}
|
||||
|
||||
/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at
|
||||
/// most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells.
|
||||
fn sane_python_output(v: &gridcraft_core::Value) -> bool {
|
||||
match v {
|
||||
gridcraft_core::Value::Array(a) => {
|
||||
a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, as they are.
|
||||
fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
|
||||
@@ -461,17 +461,17 @@ fn csv_writes_displayed_text() {
|
||||
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cells = [
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }),
|
||||
];
|
||||
for (a, c) in cells {
|
||||
sh.set_cell(CellRef::parse(a).unwrap(), c);
|
||||
|
||||
@@ -795,7 +795,7 @@ impl Session {
|
||||
// A toggle (Bold) is recorded with the state it set, as Excel records
|
||||
// `Bold = True`: playing it back sets that state rather than flipping it again.
|
||||
let params = match &result {
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r),
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(¶ms), r),
|
||||
_ => cmd::journal_params(¶ms),
|
||||
};
|
||||
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
|
||||
|
||||
@@ -614,7 +614,10 @@ mod tests {
|
||||
let mut wb = wb();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\"")));
|
||||
sh.set_cell(CellRef::new(1, 2), gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(1, 2),
|
||||
gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default(), py: None },
|
||||
);
|
||||
sh.show_formulas = true;
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
|
||||
@@ -1237,26 +1237,39 @@ pub(crate) fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, O
|
||||
(pairs, spare.collect())
|
||||
}
|
||||
|
||||
/// Only a formula's cached value changed (a recalculation, not a write).
|
||||
/// Only a formula's (or a Python cell's) cached value changed (a recalculation, not a write): a
|
||||
/// Python cell whose spill a step blocked shows #SPILL! with its code untouched.
|
||||
fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object && b.style == a.style,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Python code is content, as a formula is, whatever the cell shows.
|
||||
fn has_content(c: &Cell) -> bool {
|
||||
c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
c.formula.is_some() || c.py.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
}
|
||||
|
||||
/// Whether a write changed what the person entered: the formula, the Python code and its return
|
||||
/// type, or the value. A Python cell replaced by its own cached value, or by other code that
|
||||
/// shows the same `#BLOCKED!`, has changed.
|
||||
fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text != y.text,
|
||||
(None, None) => b.value != a.value,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code != y.code || x.object != y.object,
|
||||
(None, None) => b.value != a.value,
|
||||
_ => true,
|
||||
},
|
||||
_ => true,
|
||||
},
|
||||
(Some(b), None) => has_content(b),
|
||||
|
||||
@@ -228,7 +228,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
}
|
||||
if !replace {
|
||||
for (c, cell) in sh.cells.iter_range(area) {
|
||||
if (!cell.value.is_empty() || cell.formula.is_some()) && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
if cell.has_content() && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
return Err(format!(
|
||||
"There's already data in {}!{}. Do you want to replace it? (Run again with replace: true to overwrite.)",
|
||||
quote_sheet(&sh.name),
|
||||
@@ -278,7 +278,10 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
if oc.value.is_empty() && id == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
cells.push((CellRef::new(origin.row + ri as u32, origin.col + ci as u32), Cell { value: oc.value.clone(), formula: None, style: id }));
|
||||
cells.push((
|
||||
CellRef::new(origin.row + ri as u32, origin.col + ci as u32),
|
||||
Cell { value: oc.value.clone(), formula: None, style: id, py: None },
|
||||
));
|
||||
}
|
||||
}
|
||||
// Column widths that fit the labels.
|
||||
|
||||
@@ -74,15 +74,21 @@ impl Default for Recorder {
|
||||
|
||||
/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel
|
||||
/// records `Bold = True`: playing the step back sets that state rather than flipping it again.
|
||||
pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json {
|
||||
if let Some(on @ Json::Bool(_)) = result.get("on") {
|
||||
/// `python.output` without `mode` is a toggle too: it keeps the return type it set (the result's
|
||||
/// `output`) as its `mode`.
|
||||
pub(crate) fn with_toggle_state(id: &str, mut params: Json, result: &Json) -> Json {
|
||||
let state = match id {
|
||||
"python.output" => result.get("output").filter(|o| o.is_string()).map(|o| ("mode", o)),
|
||||
_ => result.get("on").filter(|o| o.is_boolean()).map(|o| ("on", o)),
|
||||
};
|
||||
if let Some((key, state)) = state {
|
||||
if params.is_null() {
|
||||
params = Json::Object(serde_json::Map::new());
|
||||
}
|
||||
if let Json::Object(p) = &mut params
|
||||
&& !p.contains_key("on")
|
||||
&& p.get(key).is_none_or(Json::is_null)
|
||||
{
|
||||
p.insert("on".into(), on.clone());
|
||||
p.insert(key.into(), state.clone());
|
||||
}
|
||||
}
|
||||
params
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
|
||||
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
||||
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(gridcraft_model::Cell::has_content) || sheet.spill.contains_key(&c);
|
||||
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
||||
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
||||
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
||||
|
||||
@@ -5246,6 +5246,9 @@ fn a_script_cannot_switch_windows() {
|
||||
mod equation;
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod python_cells;
|
||||
mod python_commands;
|
||||
mod python_files;
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Python cells under edits: what keeps, moves, rewrites or removes their code.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
/// Makes `cell` on sheet `sheet` a Python cell with `code` and a cached value.
|
||||
fn plant(s: &mut Session, sheet: usize, cell: &str, code: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let sh = wb.sheet_mut(sheet).unwrap();
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
let style = sh.style_id(at);
|
||||
sh.set_cell(at, Cell { value: Value::Number(1.0), formula: None, style, py: Some(Arc::new(PyCode::new(code))) });
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
fn code(s: &Session, sheet: usize, cell: &str) -> Option<String> {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.sheet(sheet).unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_ref()).map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_py_formulas_points_to_python_set() {
|
||||
let mut s = s();
|
||||
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=PY(\"1+1\",0)"})).unwrap_err();
|
||||
assert!(e.to_string().contains("python.set"), "{e}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "A1", "input": "=py"})).is_err());
|
||||
// Other functions starting with PY are not affected.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=PI()"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_or_clearing_a_python_cell_removes_its_code() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
plant(&mut s, 0, "A2", "2 + 2");
|
||||
plant(&mut s, 0, "A3", "3 + 3");
|
||||
let got = s.execute("cell.get", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(got["input"], "1 + 1");
|
||||
assert_eq!(got["python"]["code"], "1 + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "hello"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
s.execute("edit.clearFormats", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2").as_deref(), Some("2 + 2"), "clearing formats keeps the code");
|
||||
s.execute("edit.clearContents", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2"), None);
|
||||
s.execute("edit.clearAll", json!({"range": "A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3"), None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("3 + 3"), "undo brings the code back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_fill_and_sort_carry_the_code_and_paste_values_drops_it() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "xl('B1') * 2");
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "C1").as_deref(), Some("xl('B1') * 2"), "the code is copied as it is");
|
||||
s.execute("edit.pasteSpecial", json!({"at": "D1", "what": "values"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "D1"), None);
|
||||
assert_eq!(super::v(&s, "D1"), Value::Number(1.0), "the cached value is pasted");
|
||||
s.execute("edit.fillDown", json!({"range": "A1:A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("xl('B1') * 2"));
|
||||
// Sorting moves Python cells with their rows.
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[3], [1], [2]]})).unwrap();
|
||||
plant(&mut s, 0, "F1", "'three'");
|
||||
plant(&mut s, 0, "F2", "'one'");
|
||||
s.execute("data.sort", json!({"range": "E1:F3", "keys": [{"column": "E"}]})).unwrap();
|
||||
assert_eq!(code(&s, 0, "F1").as_deref(), Some("'one'"));
|
||||
assert_eq!(code(&s, 0, "F3").as_deref(), Some("'three'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xl_references_follow_inserts_moves_and_renames() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "C5", &format!("a = xl(\"A1:A3\")\nb = xl('{other}!B2', headers=True)\nc = xl(dynamic)\n# xl(\"A1\") stays in comments"));
|
||||
s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap();
|
||||
let c = code(&s, 0, "C6").unwrap();
|
||||
assert!(c.starts_with("a = xl(\"A1:A4\")\n"), "{c}");
|
||||
assert!(c.contains(&format!("xl('{other}!B2', headers=True)")), "{c}");
|
||||
assert!(c.contains("# xl(\"A1\") stays"), "{c}");
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().contains("xl('Data!B2', headers=True)"));
|
||||
// Cutting the cells a reference names moves the reference with them.
|
||||
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"H1:H4\")"), "{:?}", code(&s, 0, "C6"));
|
||||
// Deleting the referenced cells leaves #REF!, which fails when the code runs.
|
||||
// The selection is elsewhere, so only the named column goes.
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "H:H"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"#REF!\")"), "{:?}", code(&s, 0, "C6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_to_a_name_that_needs_quotes_rewrites_single_quoted_references() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "A1", &format!("a = xl('{other}!B2')\nb = xl(\"{other}!C3\")"));
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Q4 2026"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"'Q4 2026'!B2\")\nb = xl(\"'Q4 2026'!C3\")");
|
||||
// A name with both quotes in it: the literal's own quote is escaped.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Bob's \"Q4\""})).unwrap();
|
||||
let c = code(&s, 0, "A1").unwrap();
|
||||
assert_eq!(c, "a = xl(\"'Bob''s \\\"Q4\\\"'!B2\")\nb = xl(\"'Bob''s \\\"Q4\\\"'!C3\")");
|
||||
let refs: Vec<Option<String>> = gridcraft_model::python::xl_calls(&c).into_iter().map(|x| x.reference).collect();
|
||||
assert_eq!(refs, [Some("'Bob''s \"Q4\"'!B2".to_string()), Some("'Bob''s \"Q4\"'!C3".to_string())]);
|
||||
// And back to a plain name.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"Data!B2\")\nb = xl(\"Data!C3\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_to_another_sheet_keeps_references_on_the_source_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let (src, dest) = {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
(wb.sheet(0).unwrap().name.clone(), wb.sheet(1).unwrap().name.clone())
|
||||
};
|
||||
plant(&mut s, 0, "A1", "a = xl(\"B5\")\nb = xl('A2')");
|
||||
plant(&mut s, 0, "A2", "1");
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
// B5 stays on the source sheet; A2 moved with the cut, so it follows to the destination.
|
||||
assert_eq!(code(&s, 1, "C1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
// Within one sheet nothing is qualified.
|
||||
s.execute("edit.cut", json!({"range": "C1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "E1"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "E1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
// A copy to another sheet still carries the code as it is.
|
||||
plant(&mut s, 0, "A9", "xl(\"B5\")");
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A9"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A9"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "A9").as_deref(), Some("xl(\"B5\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_seek_and_solver_refuse_to_change_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "2");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
let e = s.execute("data.goalSeek", json!({"set": "B1", "to": 10, "changing": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("must contain a value"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"), "the code is kept");
|
||||
// The set cell must be a formula: a Python cell isn't one this build can recalculate.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap();
|
||||
assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 10, "changing": "C1"})).is_err());
|
||||
let e = s.execute("data.solver", json!({"objective": "B1", "goal": "max", "variables": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("constants"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_keeps_a_py_piece_as_text() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7"));
|
||||
s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap();
|
||||
// The default text qualifier takes the quotes away, as in Excel.
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared");
|
||||
assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_cells_survive_native_json() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "B2", "x = 1\nx");
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let json = serde_json::to_string(&*wb).unwrap();
|
||||
let back: gridcraft_model::Workbook = serde_json::from_str(&json).unwrap();
|
||||
let cell = back.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).unwrap();
|
||||
assert_eq!(cell.py.as_ref().unwrap().code, "x = 1\nx");
|
||||
// Cells written before Python existed have no `py` field.
|
||||
let plain: Cell = serde_json::from_value(json!({"value": {"t": "Number", "v": 2.0}, "formula": null, "style": 0})).unwrap();
|
||||
assert!(plain.py.is_none());
|
||||
assert!(!serde_json::to_string(&plain).unwrap().contains("py"), "no field is written for ordinary cells");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_errors_parse_and_have_error_type_numbers() {
|
||||
use gridcraft_core::CellError;
|
||||
for (text, e) in
|
||||
[("#PYTHON!", CellError::Python), ("#BUSY!", CellError::Busy), ("#TIMEOUT!", CellError::Timeout), ("#BLOCKED!", CellError::Blocked)]
|
||||
{
|
||||
assert_eq!(CellError::parse(text), Some(e));
|
||||
assert_eq!(e.as_str(), text);
|
||||
}
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=ERROR.TYPE(#BLOCKED!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Number(11.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=ISERROR(#PYTHON!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "formulas"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
assert_eq!(r["selection"], "A1:A2", "{r}");
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "constants"})).unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["selection"], "A3", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() {
|
||||
let mut s = s();
|
||||
// F2 shows 1, its cached value: a step that enters 1 there still replaces the code.
|
||||
plant(&mut s, 0, "F2", "1");
|
||||
// H2's code shows #BLOCKED!, as any other new code there would.
|
||||
s.execute("python.set", json!({"cell": "H2", "code": "'a'"})).unwrap();
|
||||
// J2's result spills over J2:J3. A step that writes J3 blocks it, which doesn't write J2.
|
||||
s.execute("python.set", json!({"cell": "J2", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("J2").unwrap()).unwrap();
|
||||
let data = vec![Value::Number(5.0), Value::Number(6.0)];
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, data).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "J3"), Value::Number(6.0));
|
||||
super::macros::put(
|
||||
&mut s,
|
||||
json!({"name": "Py", "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "F2", "input": "1"}},
|
||||
{"command": "python.set", "params": {"cell": "H2", "code": "'b'"}},
|
||||
{"command": "cell.set", "params": {"cell": "J3", "input": "x"}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Py"})).unwrap();
|
||||
assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["F2", "H2", "J3"]), "{c}");
|
||||
assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["F2", "H2"]), "{c}");
|
||||
let e = s.execute("macro.run", json!({"name": "Py", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_edits_a_python_cells_code_and_keeps_it_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "x = 1\nx");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).object = true;
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
let r = s.execute("edit.replace", json!({"what": "x", "with": "y"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("y = 1\ny"));
|
||||
let d = s.doc().unwrap();
|
||||
let cell = d.wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert!(cell.py.as_ref().unwrap().object, "the return type is kept");
|
||||
assert_eq!(cell.value, Value::Error(gridcraft_core::CellError::Blocked));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("x = 1\nx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_leaves_python_code_that_would_no_longer_fit() {
|
||||
let mut s = s();
|
||||
let long = "a".repeat(gridcraft_model::python::MAX_FORMULA_CHARS / 2);
|
||||
plant(&mut s, 0, "A1", &long);
|
||||
let r = s.execute("edit.replace", json!({"what": "a", "with": "bb"})).unwrap();
|
||||
assert_eq!(r["replaced"], 0);
|
||||
assert_eq!(r["skipped"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_hides_its_code() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
let hidden = |s: &Session, a: &str| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
crate::display::formula_hidden(&d.wb, sh, CellRef::parse(a).unwrap())
|
||||
};
|
||||
assert!(!hidden(&s, "A1"), "an unprotected sheet hides nothing");
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(hidden(&s, "A1"), "Hidden on a protected sheet hides the code, as it hides a formula");
|
||||
assert!(!hidden(&s, "B1"));
|
||||
// Editing it is refused where a formula's would be: the cell is locked.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "2"})).is_err());
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("secret = 1\nsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_merge_keeps_only_the_upper_left_cells_python_code() {
|
||||
let mut s = s();
|
||||
for (cmd, row) in [("home.mergeAcross", 1), ("home.mergeCells", 2), ("home.mergeCenter", 3)] {
|
||||
plant(&mut s, 0, &format!("A{row}"), "'kept'");
|
||||
plant(&mut s, 0, &format!("B{row}"), "'merged away'");
|
||||
s.execute(cmd, json!({"range": format!("A{row}:B{row}")})).unwrap();
|
||||
assert_eq!(code(&s, 0, &format!("A{row}")).as_deref(), Some("'kept'"), "{cmd}");
|
||||
assert_eq!(code(&s, 0, &format!("B{row}")), None, "{cmd}: the hidden cell is no Python cell");
|
||||
assert_eq!(super::v(&s, &format!("B{row}")), Value::Empty, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmerging_lets_a_blocked_python_result_spill_again() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Number(6.0));
|
||||
s.execute("home.mergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill), "a merged cell blocks the spill");
|
||||
s.execute("home.unmergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A1"), super::v(&s, "A2")), (Value::Number(5.0), Value::Number(6.0)), "it spills again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_model_asks_before_it_writes_over_a_python_cell_that_shows_nothing() {
|
||||
let mut s = s();
|
||||
for (cell, input) in [("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
// H2 is a Python cell whose cached value is empty: its code is still content.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(
|
||||
CellRef::parse("H2").unwrap(),
|
||||
Cell { value: Value::Empty, formula: None, style: Default::default(), py: Some(Arc::new(PyCode::new("None"))) },
|
||||
);
|
||||
}
|
||||
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
|
||||
let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_err().to_string();
|
||||
assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "H2").as_deref(), Some("None"), "nothing was written");
|
||||
}
|
||||
|
||||
/// A1 is a Python cell whose kept result `[5, 6]` spills over A1:A2, and B1 reads A2.
|
||||
pub(super) fn spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A2)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[7, 8]"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty, "new code has no result yet");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
// The same code again keeps its result, and its spill.
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A2"), super::v(&s, "B1")), (Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_in_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
let r = s.execute("edit.replace", json!({"what": "[5,", "with": "[7,"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty);
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_pasting_over_or_blocking_a_python_spill_recalculates_its_readers() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("edit.clearContents", json!({"range": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "clear");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "9"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "paste");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("home.mergeCells", json!({"range": "A2:A3"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "merge");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0), "undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "redo");
|
||||
}
|
||||
|
||||
/// `prefix` and a comment padded to the longest code that still fits in one cell.
|
||||
fn at_the_limit(prefix: &str) -> String {
|
||||
let with = |n: usize| format!("{prefix}\n#{}", "x".repeat(n));
|
||||
let (mut lo, mut hi) = (0, gridcraft_model::python::MAX_FORMULA_CHARS);
|
||||
while lo < hi {
|
||||
let mid = (lo + hi).div_ceil(2);
|
||||
if PyCode::new(&with(mid)).fits() { lo = mid } else { hi = mid - 1 }
|
||||
}
|
||||
with(lo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_make_python_code_too_long_is_refused() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
let refused = |e: crate::EngineError, cell: &str| {
|
||||
let e = e.to_string();
|
||||
assert!(e.contains(&format!("!{cell} longer than Excel's 8192-character")) && e.contains("nothing was changed"), "{e}");
|
||||
};
|
||||
// A sheet rename that lengthens a reference.
|
||||
let a1 = at_the_limit(&format!("a = xl(\"{other}!B2\")"));
|
||||
plant(&mut s, 0, "A1", &a1);
|
||||
refused(s.execute("sheet.rename", json!({"sheet": 1, "name": "A much longer sheet name"})).unwrap_err(), "A1");
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(1).unwrap().name, other);
|
||||
// One of the same length is made.
|
||||
let same = "Z".repeat(other.chars().count());
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": same})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1.replace(&other, &same));
|
||||
// Code short of the limit still follows each edit.
|
||||
plant(&mut s, 0, "A7", "d = xl(\"Z1\")");
|
||||
s.execute("selection.set", json!({"range": "B10"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "Y:Y"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A7").unwrap(), "d = xl(\"AA1\")");
|
||||
// An inserted column that turns Z1 into AA1.
|
||||
let a3 = at_the_limit("b = xl(\"Z1\")");
|
||||
plant(&mut s, 0, "A3", &a3);
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "kept"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
refused(s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap_err(), "A3");
|
||||
assert_eq!(code(&s, 0, "A3").unwrap(), a3);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::parse("D1").unwrap()), Value::text("kept"));
|
||||
// A cut to another sheet, which qualifies B9 with the source sheet.
|
||||
let a5 = at_the_limit("c = xl(\"B9\")");
|
||||
plant(&mut s, 0, "A5", &a5);
|
||||
s.execute("edit.cut", json!({"range": "A5"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
refused(s.execute("edit.paste", json!({"at": "C1"})).unwrap_err(), "C1");
|
||||
assert_eq!(code(&s, 0, "A5").unwrap(), a5);
|
||||
assert_eq!(code(&s, 1, "C1"), None);
|
||||
}
|
||||
|
||||
/// Makes `cell` a Python cell whose cached value is empty, as a file's `PY` formula with no cached
|
||||
/// value reads.
|
||||
fn plant_empty(s: &mut Session, cell: &str) {
|
||||
plant(s, 0, cell, "x = 1");
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
if let Some(c) = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse(cell).unwrap()) {
|
||||
c.value = Value::Empty;
|
||||
}
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_code_with_an_empty_value_counts_as_content() {
|
||||
// A report or a PivotTable asks before replacing it.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 1], ["West", 2], ["East", 3]]})).unwrap();
|
||||
plant_empty(&mut s, "D2");
|
||||
let report = json!({"tool": "Descriptive Statistics", "range": "B1:B4", "labels": true, "output": "D1"});
|
||||
assert!(s.execute("data.analysisToolPak", report).is_err(), "the report asks for overwrite");
|
||||
plant_empty(&mut s, "H4");
|
||||
let pivot = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]}));
|
||||
assert!(pivot.is_err_and(|e| e.to_string().contains("already data")));
|
||||
assert_eq!((code(&s, 0, "D2"), code(&s, 0, "H4")), (Some("x = 1".into()), Some("x = 1".into())), "the code is kept");
|
||||
// Cells aren't pushed off the sheet over it.
|
||||
plant_empty(&mut s, "K1048576");
|
||||
assert!(s.execute("home.insertCells", json!({"range": "K1", "shift": "down"})).is_err());
|
||||
assert_eq!(code(&s, 0, "K1048576"), Some("x = 1".into()));
|
||||
// A table without a header row doesn't push it off the sheet either.
|
||||
s.execute("range.setValues", json!({"range": "X1", "values": [[1], [2]]})).unwrap();
|
||||
plant_empty(&mut s, "X1048576");
|
||||
assert!(s.execute("insert.table", json!({"range": "X1:X2", "header": false})).is_err());
|
||||
assert_eq!(code(&s, 0, "X1048576"), Some("x = 1".into()));
|
||||
// It is part of the data region.
|
||||
s.execute("range.setValues", json!({"range": "P1", "values": [[1], [""], [3]]})).unwrap();
|
||||
plant_empty(&mut s, "P2");
|
||||
let sh = s.doc().unwrap().wb.sheet(0).unwrap().clone();
|
||||
assert_eq!(crate::selection::current_region(&sh, CellRef::parse("P1").unwrap()).a1(), "P1:P3");
|
||||
// Pasted into part of a legacy array, it is refused like any content.
|
||||
s.execute("selection.set", json!({"range": "R1:R3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||
plant_empty(&mut s, "T1");
|
||||
s.execute("edit.copy", json!({"range": "T1"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "R2"})).is_err_and(|e| e.to_string().contains("part of an array")));
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The python.* commands.
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
|
||||
fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() {
|
||||
let mut s = s();
|
||||
let r = set(&mut s, "B1", "xl('A1') + 1");
|
||||
assert_eq!(r["cell"], "B1");
|
||||
assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked));
|
||||
let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap();
|
||||
assert_eq!(got["python"]["code"], "xl('A1') + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
assert_eq!(got["input"], "xl('A1') + 1");
|
||||
// The active cell by default.
|
||||
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
s.execute("python.set", json!({"code": "2"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2");
|
||||
// One undo step takes the cell back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_code_keeps_its_cached_result() {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
// As a file would have cached it.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap();
|
||||
let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap());
|
||||
py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true });
|
||||
cell.value = Value::Number(42.0);
|
||||
}
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result");
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object");
|
||||
set(&mut s, "A1", "21 * 3");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_refuses_bad_params_long_code_headers_and_protected_cells() {
|
||||
let mut s = s();
|
||||
for p in [
|
||||
json!({"cell": "A1"}),
|
||||
json!({"cell": "A1", "code": 5}),
|
||||
json!({"cell": "not a cell", "code": "1"}),
|
||||
json!({"cell": "A1", "code": "1", "output": "sideways"}),
|
||||
] {
|
||||
assert!(s.execute("python.set", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// The limit is the stored PY(…) formula's 8,192 characters.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err());
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap();
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err();
|
||||
assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}");
|
||||
// A table header is the column's name.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err());
|
||||
assert_eq!(v(&s, "D1"), Value::text("Score"));
|
||||
set(&mut s, "D2", "1");
|
||||
// A locked cell on a protected sheet refuses it.
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_over_a_spilling_formula_takes_the_spill_away() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty());
|
||||
}
|
||||
|
||||
/// A cached result as a file would have kept it on the cell: `[[1], [2], [3]]`, a list.
|
||||
fn cache_list(s: &mut crate::Session, cell: &str) {
|
||||
let at = gridcraft_core::CellRef::parse(cell).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let py = std::sync::Arc::make_mut(sh.cells.get_mut(at).unwrap().py.as_mut().unwrap());
|
||||
let arr = gridcraft_core::Array::new(3, 1, vec![1.0.into(), 2.0.into(), 3.0.into()]).map(Value::from).unwrap();
|
||||
py.output = Some(gridcraft_model::PyOutput { value: arr, type_name: "list".into(), preview: "[1, 2, 3]".into(), convertible: true });
|
||||
}
|
||||
|
||||
/// A1 holds `[1, 2, 3]` with its cached list spilled over A1:A3 as Excel values; B1 reads A3.
|
||||
fn cached_spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
cache_list(&mut s, "A1");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
s
|
||||
}
|
||||
|
||||
fn output_of(s: &mut crate::Session, cell: &str) -> serde_json::Value {
|
||||
s.execute("cell.get", json!({"cell": cell})).unwrap()["python"]["output"].clone()
|
||||
}
|
||||
|
||||
fn undo_depth(s: &mut crate::Session) -> Option<usize> {
|
||||
s.execute("history.list", json!({})).unwrap()["undo"].as_array().map(Vec::len)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_switches_the_return_type_and_drops_a_result_of_the_other_type() {
|
||||
let mut s = cached_spill_with_a_reader();
|
||||
// The mode it already has changes nothing and adds no undo step.
|
||||
let undo = undo_depth(&mut s);
|
||||
let r = s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(r["output"], "value");
|
||||
assert_eq!(undo_depth(&mut s), undo);
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0), "the kept result still spills");
|
||||
// Without `mode` it switches. The cached list is an Excel value's result: as with python.set
|
||||
// and a new return type, the cell has no result until it runs, and its old spill goes with
|
||||
// its readers recalculated.
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let r = s.execute("python.output", json!({})).unwrap();
|
||||
assert_eq!(r["output"], "object");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty, "the old spill goes");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "and what read it is recalculated");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["code"], "[1, 2, 3]", "the code stays");
|
||||
// The same as python.set with the same code and the other return type.
|
||||
let mut by_set = cached_spill_with_a_reader();
|
||||
by_set.execute("python.set", json!({"cell": "A1", "code": "[1, 2, 3]", "output": "object"})).unwrap();
|
||||
for c in ["A1", "A3", "B1"] {
|
||||
assert_eq!(v(&s, c), v(&by_set, c), "{c}");
|
||||
}
|
||||
assert_eq!(output_of(&mut s, "A1"), output_of(&mut by_set, "A1"));
|
||||
// Switching back brings no result back: the dropped one belonged to the old type.
|
||||
s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
// Each switch is one undo step, and undoing the first brings the result and its spill back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
// A cell with no result keeps showing #BLOCKED!.
|
||||
set(&mut s, "C1", "x = 1");
|
||||
s.execute("python.output", json!({"cell": "C1"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(output_of(&mut s, "C1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_table_headers_and_locked_cells_as_set_does() {
|
||||
let mut s = s();
|
||||
// A Python cell in a table's header row (as a file could hold one): python.set refuses to
|
||||
// put code there, and python.output refuses to change it.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(gridcraft_core::CellRef::parse("D1").unwrap()).unwrap();
|
||||
cell.py = Some(std::sync::Arc::new(gridcraft_model::PyCode::new("'Score'")));
|
||||
}
|
||||
let e = s.execute("python.output", json!({"cell": "D1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("table header"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "D1"), "value");
|
||||
// A locked cell on a protected sheet refuses it; an unlocked one formatted Hidden takes it,
|
||||
// as it takes python.set.
|
||||
set(&mut s, "F1", "1");
|
||||
set(&mut s, "G1", "2");
|
||||
s.execute("home.formatCells", json!({"range": "G1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.output", json!({"cell": "F1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "F1"), "value");
|
||||
s.execute("python.set", json!({"cell": "G1", "code": "2"})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "G1"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "G1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_cells_without_python_and_bad_modes() {
|
||||
let mut s = s();
|
||||
// Empty params: the active cell, which holds no Python code.
|
||||
let e = s.execute("python.output", json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't a Python cell"), "{e}");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap();
|
||||
assert!(s.execute("python.output", json!({"cell": "B1"})).is_err());
|
||||
set(&mut s, "A1", "1");
|
||||
for p in [json!({"cell": "A1", "mode": "sideways"}), json!({"cell": "A1", "mode": 1}), json!({"cell": "nowhere"})] {
|
||||
assert!(s.execute("python.output", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "value");
|
||||
// python.set refuses a return type that isn't text too, rather than ignoring it.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "1", "output": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_switch_keeps_the_return_type_it_set() {
|
||||
// python.output without `mode` switches, so a recording keeps the return type the switch set
|
||||
// (as Bold records `on`): played back on a cell that already has it, nothing switches back.
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
let m = s.execute("macro.get", json!({"name": "Rec"})).unwrap();
|
||||
let m = crate::macros::parse_macro(&m).unwrap();
|
||||
let steps = crate::macros::command_steps(&m.steps);
|
||||
let step = steps.iter().find(|(_, id, _)| *id == "python.output").map(|(_, _, p)| (*p).clone());
|
||||
assert_eq!(step.and_then(|p| p.get("mode").cloned()), Some(json!("object")));
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
}
|
||||
// A run inside a script is recorded the same way.
|
||||
s.execute("automate.newScript", json!({"name": "Switch", "commands": [{"command": "python.output", "params": {"cell": "A1"}}]})).unwrap();
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Switch"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec2"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec2", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Python cells in files: native JSON, and xlsx as `_xlfn._xlws.PY(index,0|1)` with the code in
|
||||
//! the Python part ([MS-XLSX] 2.1.23, 2.3.10) and cached values. Nothing here runs Python: results
|
||||
//! are cached ones, as a file would hold them.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, PyOutput, Workbook};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{Session, Trust};
|
||||
|
||||
fn py(s: &mut Session, cell: &str, code: &str) {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap();
|
||||
}
|
||||
|
||||
/// Gives a Python cell a result, as running it (or a file's cache) would.
|
||||
fn cache(s: &mut Session, cell: &str, value: Value, type_name: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let c = sh.cells.get_mut(CellRef::parse(cell).unwrap()).unwrap();
|
||||
let p = Arc::make_mut(c.py.as_mut().unwrap());
|
||||
p.output = Some(PyOutput { value, type_name: type_name.into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
|
||||
fn array(rows: usize, cols: usize, data: Vec<Value>) -> Value {
|
||||
Value::Array(Arc::new(Array::new(rows, cols, data).unwrap()))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
let r = s.execute("file.saveBytes", json!({"format": format})).unwrap();
|
||||
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) {
|
||||
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
fn sheet_xml(bytes: &[u8]) -> String {
|
||||
part(bytes, "xl/worksheets/sheet1.xml")
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to each XML part (by name), and `add` added.
|
||||
fn with_parts(bytes: &[u8], edit: impl Fn(&str, &str) -> String, add: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if let Ok(text) = std::str::from_utf8(&data) {
|
||||
data = edit(&name, text).into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
for (name, text) in add {
|
||||
out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to the first sheet's XML.
|
||||
fn with_sheet_xml(bytes: &[u8], edit: impl Fn(&str) -> String) -> Vec<u8> {
|
||||
with_parts(bytes, |name, text| if name == "xl/worksheets/sheet1.xml" { edit(text) } else { text.to_string() }, &[])
|
||||
}
|
||||
|
||||
/// `xml` with its `<sheetData>` replaced by `data`.
|
||||
fn replace_sheet_data(xml: &str, data: &str) -> String {
|
||||
match (xml.find("<sheetData"), xml.find("</sheetData>"), xml.find("<sheetData/>")) {
|
||||
(Some(a), Some(b), _) => format!("{}{data}{}", &xml[..a], &xml[b + "</sheetData>".len()..]),
|
||||
(_, _, Some(a)) => format!("{}{data}{}", &xml[..a], &xml[a + "<sheetData/>".len()..]),
|
||||
_ => panic!("no sheetData in {xml}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty workbook's package with `data` as its first sheet's cells and, when `scripts` is
|
||||
/// given, a Python part holding them (the relationship and the content type included), as
|
||||
/// [MS-XLSX] 2.1.23 describes it.
|
||||
fn excel_shaped(data: &str, scripts: Option<&[&str]>) -> Vec<u8> {
|
||||
let mut s = s();
|
||||
let plain = save(&mut s, "xlsx");
|
||||
let Some(scripts) = scripts else { return with_sheet_xml(&plain, |xml| replace_sheet_data(xml, data)) };
|
||||
let codes: String = scripts.iter().map(|c| format!("<pythonScript><code>{c}</code></pythonScript>")).collect();
|
||||
let python = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><environmentDefinition id=\"{{00000000-0000-0000-0000-000000000000}}\"/><pythonScripts>{codes}</pythonScripts></python>"
|
||||
);
|
||||
const REL: &str =
|
||||
"<Relationship Id=\"rIdPy\" Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python/python1.xml\"/>";
|
||||
const TYPE: &str = "<Override PartName=\"/xl/python/python1.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>";
|
||||
let edit = |name: &str, text: &str| match name {
|
||||
"xl/worksheets/sheet1.xml" => replace_sheet_data(text, data),
|
||||
"xl/_rels/workbook.xml.rels" => text.replace("</Relationships>", &format!("{REL}</Relationships>")),
|
||||
"[Content_Types].xml" => text.replace("</Types>", &format!("{TYPE}</Types>")),
|
||||
_ => text.to_string(),
|
||||
};
|
||||
with_parts(&plain, edit, &[("xl/python/python1.xml", &python)])
|
||||
}
|
||||
|
||||
fn code(s: &Session, cell: &str) -> Option<PyCode> {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_deref()).cloned()
|
||||
}
|
||||
|
||||
/// A workbook with a value, a formula over a Python cell, a spilling table, an object, long code
|
||||
/// with quotes and line breaks, and an error.
|
||||
fn build() -> (Session, String) {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
|
||||
py(&mut s, "B1", "xl('A1') * 2");
|
||||
cache(&mut s, "B1", Value::Number(42.0), "int");
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "=B1+1"})).unwrap();
|
||||
py(&mut s, "C1", "[[1, 'two'], [3, \"four\"]]");
|
||||
cache(&mut s, "C1", array(2, 2, vec![1.0.into(), Value::text("two"), 3.0.into(), Value::text("four")]), "list");
|
||||
s.execute("python.set", json!({"cell": "E1", "code": "[1, 2]", "output": "object"})).unwrap();
|
||||
cache(&mut s, "E1", array(2, 1, vec![1.0.into(), 2.0.into()]), "list");
|
||||
let long = format!("# {}\ntext = \"say \\\"hi\\\"\"\nlen(text)", "x".repeat(7_900));
|
||||
assert!(PyCode::new(&long).fits());
|
||||
py(&mut s, "F1", &long);
|
||||
cache(&mut s, "F1", Value::Number(8.0), "int");
|
||||
py(&mut s, "G1", "1/0");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap();
|
||||
sh.cells.get_mut(CellRef::parse("G1").unwrap()).unwrap().value = Value::Error(CellError::Python);
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "E1"), Value::text("list"), "a Python object shows its type");
|
||||
assert_eq!(v(&s, "E2"), Value::Empty, "and doesn't spill");
|
||||
(s, long)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_stores_the_code_in_the_python_part_with_cached_values() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let xml = sheet_xml(&bytes);
|
||||
// The formula names the cell's script by index, in the order the cells are written: B1, C1,
|
||||
// E1, F1, G1.
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,0)</f><v>42</v>"), "{xml}");
|
||||
assert!(xml.contains(">_xlfn._xlws.PY(2,1)</f>"), "the Python object cell has return type 1: {xml}");
|
||||
assert!(!xml.contains("xl('A1')"), "the code is not in the sheet");
|
||||
let python = part(&bytes, "xl/python.xml");
|
||||
assert!(
|
||||
python.contains("<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><pythonScripts><pythonScript><code>xl('A1') * 2</code></pythonScript>"),
|
||||
"{python}"
|
||||
);
|
||||
assert_eq!(python.matches("<pythonScript>").count(), 5);
|
||||
assert!(
|
||||
part(&bytes, "[Content_Types].xml").contains("<Override PartName=\"/xl/python.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>")
|
||||
);
|
||||
assert!(
|
||||
part(&bytes, "xl/_rels/workbook.xml.rels")
|
||||
.contains("Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python.xml\"")
|
||||
);
|
||||
assert!(xml.contains("t=\"array\" ref=\"C1:D2\""), "the spill is saved like a dynamic array's: {xml}");
|
||||
assert!(xml.contains("#VALUE!") && !xml.contains("#PYTHON!"), "Python errors are cached as #VALUE!");
|
||||
|
||||
open(&mut s, "book.xlsx", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// Cached values show, spills included, and the code is back.
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
assert_eq!(code(&s, "B1").unwrap().code, "xl('A1') * 2");
|
||||
assert!(code(&s, "E1").unwrap().object);
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long, "long code with quotes and line breaks survives");
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Value));
|
||||
// A blocker typed into the spill area gives #SPILL!; clearing it spills again, without
|
||||
// running anything.
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Spill));
|
||||
s.execute("edit.clearContents", json!({"range": "D2"})).unwrap();
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
// Saved again, the file holds the same formulas and scripts.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(sheet_xml(&again).contains("<f>_xlfn._xlws.PY(0,0)</f>"));
|
||||
assert_eq!(part(&again, "xl/python.xml"), python);
|
||||
// A workbook without Python cells has no Python part.
|
||||
let mut plain = super::s();
|
||||
assert!(!save(&mut plain, "xlsx").windows(10).any(|w| w == b"python.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_authored_python_cells_are_read_as_python() {
|
||||
// A workbook as [MS-XLSX] 2.3.10 describes it: `_xlfn._xlws.PY(index,0|1,params…)` naming a
|
||||
// script of the Python part, whose code names the parameters as `%Px%`, with a spilled result
|
||||
// cached.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A2\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"B1\" t=\"str\"><f>_xlfn._xlws.PY(1,1,C1:C3)</f><v>DataFrame</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c></row></sheetData>";
|
||||
let bytes = excel_shaped(data, Some(&["[1, 2]", "df = xl(\"%P2%\", headers=True)\ndf.describe()"]));
|
||||
let mut s = s();
|
||||
open(&mut s, "excel.xlsx", &bytes);
|
||||
let a1 = code(&s, "A1").expect("an Excel Python cell is a Python cell");
|
||||
assert_eq!(a1.code, "[1, 2]");
|
||||
assert!(!a1.object);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1.0), Value::Number(2.0)), "its spilled values are kept");
|
||||
let cell = s.doc().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().clone();
|
||||
assert!(cell.formula.is_none());
|
||||
let b1 = code(&s, "B1").unwrap();
|
||||
assert_eq!(b1.code, "df = xl(\"C1:C3\", headers=True)\ndf.describe()", "a parameter is put back as its reference");
|
||||
assert!(b1.object);
|
||||
assert_eq!(v(&s, "B1"), Value::text("DataFrame"));
|
||||
// Saved by GridCraft, the code goes back into a Python part, the reference as text.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(part(&again, "xl/python.xml").contains("<code>df = xl(\"C1:C3\", headers=True)\ndf.describe()</code>"));
|
||||
// The string-literal form (other applications, files GridCraft wrote before) still reads.
|
||||
let literal = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(\"1 + 1\",1)</f><v>2</v></c></row></sheetData>";
|
||||
open(&mut s, "literal.xlsx", &excel_shaped(literal, None));
|
||||
assert_eq!(code(&s, "A1").map(|p| (p.code, p.object)), Some(("1 + 1".to_string(), true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_py_formula_naming_a_missing_script_keeps_only_its_value() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(3,0)</f><v>6</v></c></row></sheetData>";
|
||||
for (name, bytes) in [("one.xlsx", excel_shaped(data, Some(&["5"]))), ("none.xlsx", excel_shaped(data, None))] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none() && b1.formula.is_none(), "{name}: no code to keep, and no formula #NAME? would replace");
|
||||
assert_eq!(b1.value, Value::Number(6.0), "{name}");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{name}: {warnings:?}");
|
||||
}
|
||||
let (wb, _) = crate::io::open_bytes("one.xlsx", &excel_shaped(data, Some(&["5"]))).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
}
|
||||
|
||||
/// `bytes` with the part `name` stored uncompressed and one byte of its data changed, so the
|
||||
/// archive is intact but reading the part fails its checksum.
|
||||
fn with_damaged_part(bytes: &[u8], name: &str, marker: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let entry = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
let options = if entry == name { options.compression_method(zip::CompressionMethod::Stored) } else { options };
|
||||
out.start_file(entry, options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let mut zipped = out.finish().unwrap().into_inner();
|
||||
let at = zipped.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("the marker is stored as it is");
|
||||
zipped[at + 1] ^= 0x20;
|
||||
zipped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><v>9</v></c></row></sheetData>";
|
||||
let good = excel_shaped(data, Some(&["5"]));
|
||||
// The part's data fails its checksum (a damaged archive entry).
|
||||
let damaged = with_damaged_part(&good, "xl/python/python1.xml", "<pythonScripts>");
|
||||
// The part is not well-formed XML, before its first script.
|
||||
let broken = |at: &'static str, with: &'static str| {
|
||||
with_parts(&good, move |name, text| if name == "xl/python/python1.xml" { text.replace(at, with) } else { text.to_string() }, &[])
|
||||
};
|
||||
let malformed = broken("<pythonScripts>", "<pythonScripts><!-- never closed");
|
||||
for (name, bytes) in [("damaged.xlsx", damaged), ("malformed.xlsx", malformed)] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert_eq!(a1.value, Value::Number(5.0), "{name}: the cached value is kept");
|
||||
assert!(a1.py.is_none() && a1.formula.is_none(), "{name}: as for a missing script, only the value");
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(9.0), "{name}: the rest of the sheet is read");
|
||||
let part = warnings.iter().filter(|w| w.contains("Python part is damaged")).count();
|
||||
assert_eq!(part, 1, "{name}: {warnings:?}");
|
||||
}
|
||||
// Damaged after its first script, the part keeps what it read before the damage.
|
||||
let (wb, warnings) = crate::io::open_bytes("late.xlsx", &broken("</pythonScripts>", "<!-- never closed")).unwrap();
|
||||
let a1 = wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().clone();
|
||||
assert_eq!(a1.py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("Python part is damaged")).count(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
|
||||
// A1's cached result spills over A1:A3; C1 is an ordinary dynamic array with a stale cache.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"C1\" cm=\"1\"><f t=\"array\" ref=\"C1:C3\">SEQUENCE(3)</f><v>1</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c><c r=\"C2\"><v>9</v></c></row>\
|
||||
<row r=\"3\"><c r=\"A3\"><v>3</v></c><c r=\"C3\"><v>9</v></c></row></sheetData>";
|
||||
let missing = excel_shaped(data, None);
|
||||
let damaged = with_damaged_part(&excel_shaped(data, Some(&["[1, 2, 3]"])), "xl/python/python1.xml", "<pythonScripts>");
|
||||
for (name, bytes) in [("missing.xlsx", missing), ("damaged.xlsx", damaged)] {
|
||||
let mut s = s();
|
||||
open(&mut s, name, &bytes);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
for a in ["A1", "A2", "A3"] {
|
||||
let cell = sh.cell(CellRef::parse(a).unwrap()).unwrap_or_else(|| panic!("{name}: {a} is stored"));
|
||||
assert!(cell.py.is_none() && cell.formula.is_none(), "{name}: {a} is a plain value");
|
||||
}
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "{name}: no live spill");
|
||||
// The ordinary formula fills its spill again, over the stale cached values.
|
||||
assert_eq!((v(&s, "C2"), v(&s, "C3")), (Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
assert!(sh.cell(CellRef::parse("C2").unwrap()).is_none(), "{name}: C2 is the formula's spill, not a cell");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_whose_blank_cells_have_no_records_is_still_a_spill() {
|
||||
// A1's result spills over A1:A3, and the file holds no records for the blank A2 and A3.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c></row></sheetData>";
|
||||
let mut s = s();
|
||||
open(&mut s, "sparse.xlsx", &excel_shaped(data, Some(&["[1, None, None]"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(sh.spill_ranges.get(&a1), Some(&RangeRef::parse("A1:A3").unwrap()), "the range is recorded at its anchor");
|
||||
assert_eq!(sh.spill_anchor(CellRef::parse("A3").unwrap()), Some(a1));
|
||||
let kept = sh.cell(a1).and_then(|c| c.py.as_ref()).and_then(|p| p.output.as_ref()).map(|o| o.value.clone());
|
||||
assert_eq!(kept, Some(array(3, 1, vec![1.0.into(), Value::Empty, Value::Empty])), "the result is rebuilt over the whole range");
|
||||
// A formula entered in the spill takes the area, as with any spill.
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_range_larger_than_a_result_keeps_its_values_as_plain_values() {
|
||||
// C1:D600000 is 1,200,000 cells, more than a result may have but within what the reader
|
||||
// tracks; its cached values are kept as the file shows them, and nothing spills.
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let crafted = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:D600000\""));
|
||||
assert_ne!(crafted, bytes);
|
||||
open(&mut s, "crafted.xlsx", &crafted);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 2)), "{:?}", sh.spill_ranges);
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
let d2 = sh.cell(CellRef::parse("D2").unwrap()).expect("D2 is stored");
|
||||
assert!(d2.py.is_none() && d2.formula.is_none(), "a plain value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_spills_read_past_the_scan_budget_keep_their_values_as_plain_values() {
|
||||
// A1's result spills over A1:A5000. Four thousand ordinary dynamic arrays on row 1 make each
|
||||
// later plain cell's range lookup cost 4,001 checks, so the reader's scan budget runs out
|
||||
// part way down column A, and the rest of the spill is read as plain cells.
|
||||
let arrays: String = (1..=4_000u32)
|
||||
.map(|c| {
|
||||
let at = CellRef::new(0, c).a1();
|
||||
let below = CellRef::new(1, c).a1();
|
||||
format!("<c r=\"{at}\" cm=\"1\"><f t=\"array\" ref=\"{at}:{below}\">SEQUENCE(2)</f><v>1</v></c>")
|
||||
})
|
||||
.collect();
|
||||
let rows: String = (2..=5_000u32).map(|r| format!("<row r=\"{r}\"><c r=\"A{r}\"><v>{r}</v></c></row>")).collect();
|
||||
let data = format!(
|
||||
"<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A5000\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>{arrays}</row>{rows}</sheetData>"
|
||||
);
|
||||
let mut s = s();
|
||||
open(&mut s, "budget.xlsx", &excel_shaped(&data, Some(&["list(range(1, 5001))"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "no live spill");
|
||||
assert!(code(&s, "A1").is_some_and(|p| p.output.is_none()), "no result is rebuilt");
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A5000")), (Value::Number(2.0), Value::Number(5000.0)), "the values stay");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0), "the cell keeps its value, not #SPILL!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_data_python_code_reads_drops_its_kept_result() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "3"})).unwrap();
|
||||
py(&mut s, "C5", "xl(\"A1:A2\") * 2");
|
||||
cache(&mut s, "C5", array(2, 1, vec![2.0.into(), 4.0.into()]), "list");
|
||||
py(&mut s, "E5", "xl(\"A1:A3\")");
|
||||
cache(&mut s, "E5", Value::Number(3.0), "int");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C6"), Value::Number(4.0));
|
||||
// A move keeps the result: the reference still names the same data.
|
||||
s.execute("home.insertRows", json!({"rows": "1"})).unwrap();
|
||||
assert!(code(&s, "C6").is_some_and(|p| p.output.is_some() && !p.code.contains("A1:A2")), "{:?}", code(&s, "C6"));
|
||||
assert_eq!(v(&s, "C7"), Value::Number(4.0));
|
||||
assert!(code(&s, "E6").is_some_and(|p| p.output.is_some()));
|
||||
// Deleting the rows C6 reads leaves `#REF!`, and deleting part of E6's range shrinks it: in
|
||||
// both, the kept result no longer belongs to the code.
|
||||
s.execute("home.deleteRows", json!({"rows": "2:3"})).unwrap();
|
||||
let c4 = code(&s, "C4").expect("the Python cell moved up to C4");
|
||||
assert!(c4.code.contains("#REF!") && c4.output.is_none(), "{c4:?}");
|
||||
assert_eq!(v(&s, "C4"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "C5"), Value::Empty, "its spill is gone");
|
||||
let e4 = code(&s, "E4").expect("the Python cell moved up to E4");
|
||||
assert!(!e4.code.contains("#REF!") && e4.output.is_none(), "{e4:?}");
|
||||
assert_eq!(v(&s, "E4"), Value::Error(CellError::Blocked));
|
||||
// Rows inserted inside a range grow it: also new data.
|
||||
py(&mut s, "G1", "xl(\"A1:A2\")");
|
||||
cache(&mut s, "G1", Value::Number(2.0), "int");
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
assert!(code(&s, "G1").is_some_and(|p| p.output.is_none()), "{:?}", code(&s, "G1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_measured_after_its_escapes_are_decoded() {
|
||||
let max = gridcraft_model::python::MAX_FORMULA_CHARS;
|
||||
// 2,000 tabs written as `_x0009_`: 14,000 characters in the file, 2,004 as code, which fits.
|
||||
let escaped = format!("x = 1{}end", "_x0009_".repeat(2_000));
|
||||
assert!(escaped.chars().count() > max);
|
||||
// Code longer than a cell may hold once decoded is still refused.
|
||||
let long = format!("{}{}", "_x0009_".repeat(10), "y".repeat(max));
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(1,0)</f><v>2</v></c></row></sheetData>";
|
||||
let (wb, warnings) = crate::io::open_bytes("escapes.xlsx", &excel_shaped(data, Some(&[&escaped, &long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.clone());
|
||||
assert_eq!(a1, Some(format!("x = 1{}end", "\t".repeat(2_000))), "read in full");
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "too long once decoded: only the value");
|
||||
assert_eq!(b1.value, Value::Number(2.0));
|
||||
assert!(warnings.iter().any(|w| w.contains("B1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_cells_naming_one_long_script_take_from_one_budget() {
|
||||
// 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy.
|
||||
let long = "x".repeat(8_000);
|
||||
let mut data = String::from("<sheetData>");
|
||||
for r in 1..=20_000 {
|
||||
data.push_str(&format!("<row r=\"{r}\"><c r=\"A{r}\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c></row>"));
|
||||
}
|
||||
data.push_str("</sheetData>");
|
||||
let (wb, warnings) = crate::io::open_bytes("many.xlsx", &excel_shaped(&data, Some(&[&long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let with_code = sh.cells.iter().filter(|(_, c)| c.py.is_some()).count();
|
||||
assert_eq!(with_code, 64 * 1024 * 1024 / 8_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
assert_eq!(sh.value(CellRef::parse("A20000").unwrap()), Value::Number(1.0), "the others keep their values");
|
||||
}
|
||||
|
||||
/// Plants a cached spill over `range` (anchored at its start) with `value` at `at`, as a file
|
||||
/// read leaves it.
|
||||
fn cached_spill(sh: &mut gridcraft_model::Sheet, range: RangeRef, spilled: &[(CellRef, Value)]) {
|
||||
sh.spill_ranges.insert(range.start, range);
|
||||
for (c, v) in spilled {
|
||||
sh.spill.insert(*c, v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The value stored in the cell itself (not a spilled one), with whether it holds code or a
|
||||
/// formula.
|
||||
fn stored(sh: &gridcraft_model::Sheet, c: CellRef) -> (Value, bool) {
|
||||
sh.cell(c).map(|x| (x.value.clone(), x.py.is_some() || x.formula.is_some())).unwrap_or((Value::Empty, false))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_cut_by_the_budget_keeps_its_cached_spill_as_values() {
|
||||
// Five Python cells each with a cached 1,000 x 1,000 spill: the budget rebuilds four results;
|
||||
// the fifth cell keeps only its own value, and the values it spilled stay as plain values.
|
||||
let mut wb = Workbook::new();
|
||||
let bold = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
// A cell with its own content, and a formatted empty one, inside the spill.
|
||||
sh.set_cell(CellRef::new(2, col), Cell::value(Value::text("own")));
|
||||
sh.set_cell(CellRef::new(3, col), Cell::styled(Value::Empty, bold));
|
||||
let spilled = [
|
||||
(CellRef::new(1, col), Value::Number(7.0)),
|
||||
(CellRef::new(2, col), Value::Number(9.0)),
|
||||
(CellRef::new(3, col), Value::Number(6.0)),
|
||||
(CellRef::new(999, col + 999), Value::Number(8.0)),
|
||||
];
|
||||
cached_spill(sh, RangeRef::new(anchor, CellRef::new(999, col + 999)), &spilled);
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let mut cut = 0;
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
let py = sh.cell(anchor).and_then(|c| c.py.clone()).expect("the code is kept");
|
||||
let shown =
|
||||
[sh.value(CellRef::new(1, col)), sh.value(CellRef::new(2, col)), sh.value(CellRef::new(3, col)), sh.value(CellRef::new(999, col + 999))];
|
||||
assert_eq!(shown, [Value::Number(7.0), Value::text("own"), Value::Number(6.0), Value::Number(8.0)], "every result shows the same values");
|
||||
if py.output.is_some() {
|
||||
assert!(sh.spill_ranges.contains_key(&anchor), "a result within the budget keeps its spill");
|
||||
continue;
|
||||
}
|
||||
cut += 1;
|
||||
assert!(!sh.spill_ranges.contains_key(&anchor), "no live spill");
|
||||
assert!(sh.spill.range(anchor..=CellRef::new(999, col + 999)).all(|(c, _)| c.col < col || c.col > col + 999), "no spilled values left");
|
||||
assert_eq!(stored(sh, anchor), (Value::Number(1.0), true), "the anchor keeps its value and its code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, col)), (Value::Number(7.0), false), "a spilled value is a plain value now");
|
||||
assert_eq!(stored(sh, CellRef::new(999, col + 999)), (Value::Number(8.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(2, col)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(sh.style_id(CellRef::new(3, col)), bold, "nor a format");
|
||||
}
|
||||
assert_eq!(cut, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_too_long_for_a_cell_keeps_its_whole_cached_result_as_values() {
|
||||
let long = PyCode::new(&"x".repeat(gridcraft_model::python::MAX_FORMULA_CHARS));
|
||||
assert!(!long.fits());
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
// A1 spills over A1:B2; D1's own value is empty.
|
||||
sh.set_cell(CellRef::new(0, 0), Cell { py: Some(Arc::new(long.clone())), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.set_cell(CellRef::new(1, 1), Cell::value(Value::text("own")));
|
||||
let spilled = [(CellRef::new(0, 1), Value::Number(2.0)), (CellRef::new(1, 0), Value::Number(3.0)), (CellRef::new(1, 1), Value::Number(4.0))];
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1)), &spilled);
|
||||
sh.set_cell(CellRef::new(0, 3), Cell { py: Some(Arc::new(long)), ..Cell::default() });
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 3), CellRef::new(1, 3)), &[(CellRef::new(1, 3), Value::Number(5.0))]);
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert!(sh.spill_ranges.is_empty() && sh.spill.is_empty(), "no live spill: {:?}", sh.spill_ranges);
|
||||
assert_eq!(stored(sh, CellRef::new(0, 0)), (Value::Number(1.0), false), "the anchor keeps only its value");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 1)), (Value::Number(2.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 0)), (Value::Number(3.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 1)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 3)), (Value::Empty, false), "an empty anchor stays empty, without code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, 3)), (Value::Number(5.0), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_json_keeps_python_cells_and_their_results() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
open(&mut s, "book.json", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Python), "the native format keeps the exact error");
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long);
|
||||
let e1 = code(&s, "E1").unwrap();
|
||||
assert!(e1.object);
|
||||
assert_eq!(e1.output.unwrap().type_name, "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spill_range_claiming_the_whole_sheet_is_dropped_on_open() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let evil = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:XFD1048576\""));
|
||||
assert_ne!(evil, bytes);
|
||||
// Walking the claimed range would take hours; the test finishing is the check.
|
||||
open(&mut s, "evil.xlsx", &evil);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.spill_ranges.values().all(|r| r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64), "{:?}", sh.spill_ranges);
|
||||
// Edits afterwards don't walk the claimed range either.
|
||||
s.execute("cell.set", json!({"cell": "H9", "input": "1"})).unwrap();
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0), "the anchor keeps its cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilt_results_have_a_budget_across_the_workbook() {
|
||||
// Five Python cells each claiming a 1,000 x 1,000 spill: more than the budget in all.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let anchor = CellRef::new(0, i * 1_000);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(999, i * 1_000 + 999)));
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let rebuilt = sh.cells.iter().filter(|(_, c)| c.py.as_ref().is_some_and(|p| p.output.is_some())).count();
|
||||
assert_eq!(rebuilt as u64, crate::io::MAX_ADOPTED_RESULT_CELLS / 1_000_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_python_content_in_files_is_capped() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
// Make the Python cells hostile: code over the limit, and a result whose shape claims far
|
||||
// more cells than it holds (the array fields are public, so a crafted file can say anything).
|
||||
let mut wb: Workbook = serde_json::from_slice(&bytes).unwrap();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let b1 = sh.cells.get_mut(CellRef::parse("B1").unwrap()).unwrap();
|
||||
Arc::make_mut(b1.py.as_mut().unwrap()).code = "y".repeat(9_000);
|
||||
let c1 = sh.cells.get_mut(CellRef::parse("C1").unwrap()).unwrap();
|
||||
let out = Arc::make_mut(c1.py.as_mut().unwrap()).output.as_mut().unwrap();
|
||||
out.value = Value::Array(Arc::new(Array { rows: 100_000, cols: 100_000, data: vec![Value::Number(1.0)] }));
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("evil.json", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "code over the formula limit is dropped");
|
||||
assert_eq!(b1.value, Value::Number(42.0), "the cached value stays");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python cell")), "{warnings:?}");
|
||||
let c1 = sh.cell(CellRef::parse("C1").unwrap()).unwrap();
|
||||
assert!(c1.py.as_ref().unwrap().output.is_none(), "a malformed result is dropped");
|
||||
open(&mut s, "evil.json", &bytes);
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0));
|
||||
|
||||
// PY formulas from other formats become Python cells; non-literal PY calls stay formulas.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(CellRef::new(0, 0), Cell::formula(gridcraft_model::Formula::new("=PY(\"1+\"&\"1\",1)")));
|
||||
sh.set_cell(CellRef::new(1, 0), Cell::formula(gridcraft_model::Formula::new("=PY(A1,0)")));
|
||||
sh.set_cell(CellRef::new(2, 0), Cell::formula(gridcraft_model::Formula::new("=_xlfn._xlws.PY(\"2\",0)")));
|
||||
crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert_eq!(a1.py.as_ref().map(|p| (p.code.as_str(), p.object)), Some(("1+1", true)));
|
||||
assert!(a1.formula.is_none());
|
||||
assert!(sh.cell(CellRef::new(1, 0)).unwrap().py.is_none());
|
||||
assert_eq!(sh.cell(CellRef::new(2, 0)).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_result_never_spills() {
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "x");
|
||||
let bad = Array { rows: 4_000_000_000, cols: 4_000_000_000, data: vec![] };
|
||||
cache(&mut s, "A1", Value::Array(Arc::new(bad)), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn py_formulas_over_the_size_limit_keep_only_their_value() {
|
||||
let lits = |n: usize| {
|
||||
let mut v = vec![format!("\"{}\"", "x".repeat(255)); n / 255];
|
||||
if !n.is_multiple_of(255) {
|
||||
v.push(format!("\"{}\"", "x".repeat(n % 255)));
|
||||
}
|
||||
v.join("&")
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = |text: String| Cell { value: Value::text("cached"), ..Cell::formula(gridcraft_model::Formula::new(&text)) };
|
||||
sh.set_cell(CellRef::new(0, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_080))));
|
||||
sh.set_cell(CellRef::new(1, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_079))));
|
||||
let xlsx = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("long.xlsx", &xlsx).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let over = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert!(over.py.is_none() && over.formula.is_none(), "8,080 characters of code is over the limit");
|
||||
assert_eq!(over.value, Value::text("cached"));
|
||||
let within = sh.cell(CellRef::new(1, 0)).unwrap();
|
||||
assert_eq!(within.py.as_ref().map(|p| p.code.chars().count()), Some(8_079));
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_python_result_keeps_its_spill_as_values() {
|
||||
// A1's code is over the limit and B1's result is malformed; each had spilled down to row 3,
|
||||
// and C1's well-formed result keeps its spill.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let out = |value: Value| Some(PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
let col = array(3, 1, vec![Value::Number(7.0), Value::Number(8.0), Value::Number(9.0)]);
|
||||
let bad = Value::Array(Arc::new(Array { rows: 3, cols: 1, data: vec![Value::Number(7.0)] }));
|
||||
for (at, code, value) in [("A1", "y".repeat(9_000), col.clone()), ("B1", "b".into(), bad), ("C1", "c".into(), col)] {
|
||||
let at = CellRef::parse(at).unwrap();
|
||||
let py = PyCode { output: out(value), ..PyCode::new(&code) };
|
||||
sh.set_cell(at, Cell { value: Value::Number(7.0), formula: None, style: Default::default(), py: Some(Arc::new(py)) });
|
||||
let r = RangeRef::new(at, CellRef::new(2, at.col));
|
||||
sh.spill_ranges.insert(at, r);
|
||||
for c in r.iter().skip(1) {
|
||||
sh.spill.insert(c, Value::Number(8.0));
|
||||
}
|
||||
}
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (anchor, below) in [("A1", "A2"), ("B1", "B3")] {
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::parse(anchor).unwrap()), "{anchor}'s spill range stays");
|
||||
let cell = sh.cell(CellRef::parse(below).unwrap()).unwrap_or_else(|| panic!("{below} is a plain value"));
|
||||
assert_eq!((&cell.value, cell.py.is_none() && cell.formula.is_none()), (&Value::Number(8.0), true), "{below} keeps {anchor}'s value");
|
||||
assert_eq!(sh.value(CellRef::parse(anchor).unwrap()), Value::Number(7.0), "{anchor} keeps its value");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::parse("C2").unwrap()), Value::Number(8.0), "a sound result keeps its spill");
|
||||
// A save writes the kept values as plain values, and the sound spill as a spill.
|
||||
let xml = sheet_xml(&gridcraft_xlsx::write_xlsx(&wb).unwrap());
|
||||
assert!(xml.contains("<c r=\"A2\"><v>8</v></c>") && xml.contains("<c r=\"B3\"><v>8</v></c>"), "{xml}");
|
||||
assert!(xml.contains("r=\"C2\""), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_entered_in_a_python_spill_takes_the_area_as_a_formula_would() {
|
||||
// A1's cached array spills over A1:A3; =SEQUENCE(2) entered in A2 is not blocked by that old
|
||||
// spill (the Python cell is laid out again in the same pass), so it spills and A1 is blocked.
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "[1, 2, 3]");
|
||||
cache(&mut s, "A1", array(3, 1, vec![10.0.into(), 20.0.into(), 30.0.into()]), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(30.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=SEQUENCE(2)"})).unwrap();
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0)));
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut fresh = (**wb).clone();
|
||||
gridcraft_calc::Calc::new().recalc_all(&mut fresh);
|
||||
for c in ["A1", "A2", "A3"] {
|
||||
let c = CellRef::parse(c).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(c), fresh.active().unwrap().value(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_return_type_drops_the_kept_result() {
|
||||
// Same code, the other return type: the result belongs to the old type, so it goes.
|
||||
let mut s = super::python_cells::spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "object"})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(a1.object && a1.output.is_none(), "no result of the other type is kept");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A2"), Value::Empty, "no spill");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill_ranges.is_empty());
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "the reader of the old spill is recalculated");
|
||||
let xml = sheet_xml(&save(&mut s, "xlsx"));
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,1)</f><v>#VALUE!</v>") && !xml.contains("r=\"A2\""), "no old cached value is written: {xml}");
|
||||
// Undo brings the result back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(!a1.object && a1.output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
// Same code, same return type: the result is kept as before.
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "value"})).unwrap();
|
||||
assert!(code(&s, "A1").unwrap().output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
@@ -1135,3 +1135,29 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() {
|
||||
assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula");
|
||||
assert_eq!(trust(&s), Trust::FromFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_until_it_is_copied_or_read_from_a_file() {
|
||||
let mut s = s();
|
||||
let uids = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|sh| sh.uid.get()).collect::<Vec<_>>();
|
||||
let first = uids(&s)[0];
|
||||
// Renamed, moved, deleted and brought back by undo: the same sheet.
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("sheet.rename", json!({"sheet": "Sheet1", "name": "Data"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 1})).unwrap();
|
||||
let data = s.doc().unwrap().wb.sheet_index("Data").unwrap();
|
||||
assert_eq!(uids(&s)[data], first);
|
||||
s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap();
|
||||
assert!(!uids(&s).contains(&first));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(uids(&s).contains(&first), "undo brings the same sheet back");
|
||||
// A copy, or a new sheet of the same name, is another sheet.
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap();
|
||||
let all = uids(&s);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all.iter().collect::<std::collections::BTreeSet<_>>().len(), 3, "{all:?}");
|
||||
// Read from a file: new sheets.
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (read, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
assert!(read.sheets.iter().all(|sh| !all.contains(&sh.uid.get())));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_engine::calc::Calc;
|
||||
use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook};
|
||||
use gridcraft_engine::{EngineError, Session, command_specs, find_command, io};
|
||||
@@ -433,7 +433,12 @@ fn value_diff(a: &Workbook, b: &Workbook, what: &str) -> Option<String> {
|
||||
}
|
||||
for c in keys {
|
||||
let (va, vb) = (x.value(c), y.value(c));
|
||||
if !same_value(&va, &vb) {
|
||||
// A file caches the Python errors as #VALUE! (they aren't error codes of the file
|
||||
// format), in a Python cell and in the formulas that read one; reading it back runs
|
||||
// nothing.
|
||||
let python_error = matches!(va, Value::Error(CellError::Python | CellError::Busy | CellError::Timeout | CellError::Blocked))
|
||||
&& vb == Value::Error(CellError::Value);
|
||||
if !same_value(&va, &vb) && !python_error {
|
||||
let f = x.cell(c).and_then(|c| c.formula.as_ref()).map(|f| format!(" (formula ={})", f.text)).unwrap_or_default();
|
||||
return Some(format!("{}!{c}{f}: {va:?} in memory, {vb:?} after {what}", x.name));
|
||||
}
|
||||
@@ -787,6 +792,8 @@ const CURATED: &[(&str, usize)] = &[
|
||||
("formulas.defineName", 2),
|
||||
("formulas.deleteName", 1),
|
||||
("edit.replace", 1),
|
||||
("python.set", 3),
|
||||
("python.output", 2),
|
||||
];
|
||||
|
||||
fn generate(rng: &mut Rng, s: &Session) -> Step {
|
||||
@@ -820,6 +827,33 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json {
|
||||
p
|
||||
}
|
||||
"range.setValues" => json!({"range": near_cell(rng).a1(), "values": values_2d(rng, wb)}),
|
||||
"python.set" => {
|
||||
// Code with xl() references (which follow structural edits), quotes and line breaks;
|
||||
// sometimes long enough to meet the formula limit.
|
||||
let mut code = match rng.below(5) {
|
||||
0 => format!("xl(\"{}\") + 1", near_range(rng).a1()),
|
||||
1 => format!("a = xl('{}')\nb = \"q\\\"uote\"\na", near_cell(rng).a1()),
|
||||
2 => "[[1, 2], [3, 4]]".to_string(),
|
||||
3 => String::new(),
|
||||
_ => "\"".repeat(rng.int(1, 5000) as usize),
|
||||
};
|
||||
if rng.chance(5) {
|
||||
code.push_str(&"x".repeat(8_100));
|
||||
}
|
||||
let mut p = json!({"cell": near_cell(rng).a1(), "code": code});
|
||||
if rng.chance(30) {
|
||||
p["output"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"python.output" => {
|
||||
// Mostly the cells python.set writes to; a missing mode switches.
|
||||
let mut p = json!({"cell": near_cell(rng).a1()});
|
||||
if rng.chance(50) {
|
||||
p["mode"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"range.fill" => json!({"range": near_range(rng).a1(), "input": input(rng, wb)}),
|
||||
"selection.set" => {
|
||||
let mut p = json!({"range": range(rng)});
|
||||
|
||||
@@ -489,7 +489,8 @@ fn pathological_source_at_the_cap_finishes_in_bounded_time() {
|
||||
fill("\\pu{", "10^", "}"),
|
||||
fill("\\pu{", "kJ//", "}"),
|
||||
];
|
||||
let bound = Duration::from_secs(10);
|
||||
// A CI regression threshold with headroom for shared-runner variance, not a runtime deadline.
|
||||
let bound = Duration::from_secs(15);
|
||||
let mut slow = Vec::new();
|
||||
for s in &sources {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -6,6 +6,7 @@ use gridcraft_core::{RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::python::PyCode;
|
||||
use crate::style::StyleId;
|
||||
|
||||
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
|
||||
@@ -60,25 +61,45 @@ pub struct CellPicture {
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Cell {
|
||||
/// Constant value, or the cached result of the formula.
|
||||
/// Constant value, or the cached result of the formula or Python code.
|
||||
pub value: Value,
|
||||
pub formula: Option<Arc<Formula>>,
|
||||
pub style: StyleId,
|
||||
/// Python code, for a Python cell (which has no formula).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub py: Option<Arc<PyCode>>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn value(v: Value) -> Cell {
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT }
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
pub fn formula(f: Formula) -> Cell {
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
/// No value, no formula and the default style: storing it is pointless.
|
||||
/// A value cell with this style.
|
||||
pub fn styled(v: Value, style: StyleId) -> Cell {
|
||||
Cell { value: v, formula: None, style, py: None }
|
||||
}
|
||||
/// No value, no formula, no Python code and the default style: storing it is pointless.
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT && self.py.is_none()
|
||||
}
|
||||
/// What the formula bar shows.
|
||||
/// Whether the cell holds content (a value, a formula or Python code), formats aside.
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.value.is_empty() || self.formula.is_some() || self.py.is_some()
|
||||
}
|
||||
/// Removes the content (value, formula and Python code), keeping the style.
|
||||
pub fn clear_content(&mut self) {
|
||||
self.value = Value::Empty;
|
||||
self.formula = None;
|
||||
self.py = None;
|
||||
}
|
||||
/// What the formula bar shows: a formula, a Python cell's code, or the value.
|
||||
pub fn input_text(&self) -> String {
|
||||
if let Some(py) = &self.py {
|
||||
return py.code.clone();
|
||||
}
|
||||
if let Some(f) = &self.formula {
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod features;
|
||||
pub mod macros;
|
||||
pub mod password;
|
||||
pub mod preserved;
|
||||
pub mod python;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
@@ -21,7 +22,8 @@ pub use features::*;
|
||||
pub use macros::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use python::{PyCode, PyOutput};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Python cells: the code a cell holds, its last result, and the text forms around it (the
|
||||
//! `PY("…",0|1)` formula of the function's syntax, and the `xl("…")` references inside it). An
|
||||
//! xlsx file keeps the code in its Python part instead (see the xlsx crate).
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is
|
||||
/// written as the PY formula of the function's syntax ([`PyCode::to_file_formula`]): quotes are
|
||||
/// doubled and every 255 characters add `"&"`, so the limit on the code itself depends on what the
|
||||
/// code holds.
|
||||
pub const MAX_FORMULA_CHARS: usize = 8_192;
|
||||
|
||||
/// The most cells a Python result may have (a crafted file can claim any size).
|
||||
pub const MAX_RESULT_CELLS: usize = 1_000_000;
|
||||
|
||||
/// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`).
|
||||
pub const FILE_PREFIX: &str = "_xlfn._xlws.";
|
||||
|
||||
/// Characters in one string literal of a `PY(…)` formula (Excel's limit for a literal).
|
||||
const CHUNK_CHARS: usize = 255;
|
||||
|
||||
/// A Python cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyCode {
|
||||
pub code: String,
|
||||
/// Show the result as a Python object (true) or convert it to cell values (false).
|
||||
pub object: bool,
|
||||
/// The last result, when the code ran without an error.
|
||||
pub output: Option<PyOutput>,
|
||||
/// The cell's inputs changed while calculation was manual: its value is out of date until
|
||||
/// the workbook is calculated.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A Python result kept on the cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyOutput {
|
||||
/// The result as cells (an array spills), or its preview as text when it has no cell form.
|
||||
pub value: Value,
|
||||
/// Python's name for the result's type.
|
||||
pub type_name: String,
|
||||
/// The result's `repr`, shortened.
|
||||
pub preview: String,
|
||||
/// Whether `value` is the result converted to cells.
|
||||
pub convertible: bool,
|
||||
}
|
||||
|
||||
impl PyCode {
|
||||
pub fn new(code: &str) -> PyCode {
|
||||
PyCode { code: normalize_code(code), ..PyCode::default() }
|
||||
}
|
||||
|
||||
/// What the cell shows for its result: cell values (an array spills from the cell), or, for a
|
||||
/// Python object, its type name. `None` before the code has run.
|
||||
pub fn result_value(&self) -> Option<Value> {
|
||||
let out = self.output.as_ref()?;
|
||||
let scalar = !matches!(out.value, Value::Array(_)) && out.convertible;
|
||||
Some(if self.object && !scalar { Value::text(out.type_name.as_str()) } else { out.value.clone() })
|
||||
}
|
||||
|
||||
/// The cell as a formula (without `=`): `PY("…"&"…",0)`, with the code in string literals of
|
||||
/// at most 255 characters and the return type Excel documents: 0 for an Excel value, 1 for a
|
||||
/// Python object.
|
||||
pub fn to_formula(&self) -> String {
|
||||
let chunks = chunk(&self.code, CHUNK_CHARS);
|
||||
let lits: Vec<String> = chunks.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect();
|
||||
let lits = if lits.is_empty() { "\"\"".to_string() } else { lits.join("&") };
|
||||
format!("PY({lits},{})", u8::from(self.object))
|
||||
}
|
||||
|
||||
/// [`PyCode::to_formula`] with Excel's `_xlfn._xlws.` prefix: the string-literal form
|
||||
/// [`PyCode::from_formula`] reads from files that hold it, and what the size limit measures.
|
||||
/// An xlsx file names the code by index instead ([MS-XLSX] 2.3.10.2).
|
||||
pub fn to_file_formula(&self) -> String {
|
||||
format!("{FILE_PREFIX}{}", self.to_formula())
|
||||
}
|
||||
|
||||
/// Whether the code fits in a PY formula of at most [`MAX_FORMULA_CHARS`] characters.
|
||||
pub fn fits(&self) -> bool {
|
||||
// Code longer than the limit can't fit; this also bounds the work for hostile input.
|
||||
self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none()
|
||||
}
|
||||
|
||||
/// Reads a `PY(…)` formula (with or without `=`, and with or without the `_xlfn.` and
|
||||
/// `_xlws.` prefixes): string literals joined by `&`, optionally followed by `,0` or `,1`.
|
||||
/// `None` for anything else.
|
||||
pub fn from_formula(text: &str) -> Option<PyCode> {
|
||||
let body = py_body(text);
|
||||
let rest = body.get(..3).filter(|h| h.eq_ignore_ascii_case("PY(")).and_then(|_| body.get(3..))?;
|
||||
let mut chars = rest.chars().peekable();
|
||||
let mut code = String::new();
|
||||
loop {
|
||||
skip_space(&mut chars);
|
||||
if chars.next()? != '"' {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
code.push('"');
|
||||
}
|
||||
'"' => break,
|
||||
c => code.push(c),
|
||||
}
|
||||
}
|
||||
skip_space(&mut chars);
|
||||
match chars.next()? {
|
||||
'&' => continue,
|
||||
',' => {
|
||||
skip_space(&mut chars);
|
||||
let object = match chars.next()? {
|
||||
'0' => false,
|
||||
'1' => true,
|
||||
_ => return None,
|
||||
};
|
||||
skip_space(&mut chars);
|
||||
return (chars.next()? == ')' && chars.all(char::is_whitespace)).then(|| PyCode { object, ..PyCode::new(&code) });
|
||||
}
|
||||
')' => return chars.all(char::is_whitespace).then(|| PyCode::new(&code)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` without a leading `=`, spaces and the `_xlfn.` and `_xlws.` prefixes files put before
|
||||
/// newer functions (in any letter case).
|
||||
fn py_body(text: &str) -> &str {
|
||||
let mut body = text.trim().trim_start_matches('=').trim_start();
|
||||
for prefix in ["_xlfn.", "_xlws."] {
|
||||
if body.get(..prefix.len()).is_some_and(|h| h.eq_ignore_ascii_case(prefix)) {
|
||||
body = body.get(prefix.len()..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn skip_space(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Line breaks as `\n`.
|
||||
pub fn normalize_code(code: &str) -> String {
|
||||
code.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
|
||||
/// `s` in pieces of at most `n` characters.
|
||||
fn chunk(s: &str, n: usize) -> Vec<String> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
chars.chunks(n.max(1)).map(|c| c.iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Whether `text` (a formula, with or without `=`) is a `PY(` call: such formulas are Python cells,
|
||||
/// set with `python.set`.
|
||||
pub fn is_py_formula(text: &str) -> bool {
|
||||
let body = py_body(text);
|
||||
body.get(..3).is_some_and(|h| h.eq_ignore_ascii_case("PY(")) || body.eq_ignore_ascii_case("PY")
|
||||
}
|
||||
|
||||
/// An `xl()` call in Python code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct XlCall {
|
||||
/// The reference text, when the first argument is a plain string literal.
|
||||
pub reference: Option<String>,
|
||||
/// Byte range of the literal's contents (between the quotes) in the code.
|
||||
pub span: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
|
||||
/// The `xl(…)` calls in `code`, skipping comments and other string literals. A call whose first
|
||||
/// argument isn't a plain string literal has no reference: the cells it reads are only known when
|
||||
/// it runs.
|
||||
pub fn xl_calls(code: &str) -> Vec<XlCall> {
|
||||
let bytes = code.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
match b {
|
||||
b'#' => {
|
||||
// A comment runs to the end of the line.
|
||||
while bytes.get(i).is_some_and(|c| *c != b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'"' | b'\'' => i = skip_string(bytes, i),
|
||||
b'x' if bytes.get(i + 1) == Some(&b'l') && (i == 0 || bytes.get(i - 1).is_some_and(|p| !is_ident(*p) && *p != b'.')) => {
|
||||
let mut j = i + 2;
|
||||
if bytes.get(j).is_some_and(|c| is_ident(*c)) {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
while bytes.get(j).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
j += 1;
|
||||
}
|
||||
if bytes.get(j) != Some(&b'(') {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while bytes.get(j).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
j += 1;
|
||||
}
|
||||
let call = match bytes.get(j) {
|
||||
Some(&q @ (b'"' | b'\'')) => {
|
||||
let start = j + 1;
|
||||
let end = skip_string(bytes, j);
|
||||
// A plain literal: closed on its own line, not part of an expression
|
||||
// (`xl("A" + n)`), not a triple-quoted string, and escaping nothing but a
|
||||
// quote or a backslash (`\'`, `\"`, `\\`), which a sheet name may need.
|
||||
let close = end.saturating_sub(1);
|
||||
let mut k = end;
|
||||
while bytes.get(k).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
k += 1;
|
||||
}
|
||||
let plain = close > start
|
||||
&& bytes.get(close) == Some(&q)
|
||||
&& bytes.get(start) != Some(&q)
|
||||
&& matches!(bytes.get(k), Some(b')' | b','));
|
||||
i = end;
|
||||
match code.get(start..close).filter(|_| plain).and_then(unescape) {
|
||||
Some(t) => XlCall { reference: Some(t), span: Some(start..close) },
|
||||
None => XlCall { reference: None, span: None },
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
i = j;
|
||||
XlCall { reference: None, span: None }
|
||||
}
|
||||
};
|
||||
out.push(call);
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_ident(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
|
||||
}
|
||||
|
||||
/// The index just past the string literal starting at `at` (single, double or triple quoted).
|
||||
fn skip_string(bytes: &[u8], at: usize) -> usize {
|
||||
let Some(&q) = bytes.get(at) else { return at.saturating_add(1) };
|
||||
let triple = bytes.get(at + 1) == Some(&q) && bytes.get(at + 2) == Some(&q);
|
||||
let mut i = if triple { at + 3 } else { at + 1 };
|
||||
while let Some(&c) = bytes.get(i) {
|
||||
if c == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c == q {
|
||||
if !triple {
|
||||
return i + 1;
|
||||
}
|
||||
if bytes.get(i + 1) == Some(&q) && bytes.get(i + 2) == Some(&q) {
|
||||
return i + 3;
|
||||
}
|
||||
} else if c == b'\n' && !triple {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The text of a plain literal's contents: `\'`, `\"` and `\\` stand for the character after the
|
||||
/// backslash. `None` for any other escape or a line break, which make the literal not plain.
|
||||
fn unescape(raw: &str) -> Option<String> {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\n' => return None,
|
||||
'\\' => match chars.next()? {
|
||||
e @ ('\\' | '\'' | '"') => out.push(e),
|
||||
_ => return None,
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `text` as a plain literal that was delimited by `quote`: its delimiter (`quote`, or the other
|
||||
/// quote when `text` holds `quote` but not the other one, so `xl('Sheet2!B2')` becomes
|
||||
/// `xl("'Q4 2026'!B2")` on a rename) and its contents, with backslashes escaped, and the
|
||||
/// delimiter too when `text` holds both quotes. `None` for a line break, which no literal on
|
||||
/// one line holds.
|
||||
fn literal(text: &str, quote: char) -> Option<(char, String)> {
|
||||
if text.contains('\n') {
|
||||
return None;
|
||||
}
|
||||
let other = if quote == '\'' { '"' } else { '\'' };
|
||||
let delim = if text.contains(quote) && !text.contains(other) { other } else { quote };
|
||||
let mut body = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if c == '\\' || c == delim {
|
||||
body.push('\\');
|
||||
}
|
||||
body.push(c);
|
||||
}
|
||||
Some((delim, body))
|
||||
}
|
||||
|
||||
/// Rewrites the reference of every `xl("…")` literal in `code` with `f`; `None` from `f` leaves
|
||||
/// it. Returns the new code when anything changed. A new reference holding the literal's quote
|
||||
/// (a sheet name that needs quotes, `'Q4 2026'!B2`) gets the other quote as its delimiter, or an
|
||||
/// escaped quote when it holds both, so it never ends the string early.
|
||||
pub fn rewrite_xl(code: &str, mut f: impl FnMut(&str) -> Option<String>) -> Option<String> {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut last = 0usize;
|
||||
let mut changed = false;
|
||||
for call in xl_calls(code) {
|
||||
let (Some(reference), Some(span)) = (call.reference, call.span) else { continue };
|
||||
let Some(new) = f(&reference).filter(|n| *n != reference) else { continue };
|
||||
let open = span.start.saturating_sub(1);
|
||||
let Some(quote) = code.get(open..span.start).and_then(|q| q.chars().next()) else { continue };
|
||||
let Some((delim, body)) = literal(&new, quote) else { continue };
|
||||
// The literal with its quotes: the delimiter may change.
|
||||
out.push_str(code.get(last..open).unwrap_or_default());
|
||||
out.push(delim);
|
||||
out.push_str(&body);
|
||||
out.push(delim);
|
||||
last = span.end.saturating_add(1);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
out.push_str(code.get(last..).unwrap_or_default());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The value a Python cell shows when it can't run: `#BLOCKED!`.
|
||||
pub const BLOCKED: Value = Value::Error(CellError::Blocked);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formula_round_trip_with_chunks_and_quotes() {
|
||||
let code = format!("s = \"quoted\"\n{}\nlen(s)", "x".repeat(600));
|
||||
let py = PyCode { object: true, ..PyCode::new(&code) };
|
||||
let f = py.to_formula();
|
||||
assert!(f.starts_with("PY(\"s = \"\"quoted\"\"\n"));
|
||||
assert!(f.ends_with(",1)"));
|
||||
assert_eq!(f.matches("\"&\"").count(), 2, "{f}");
|
||||
let back = PyCode::from_formula(&format!("={f}")).unwrap();
|
||||
assert_eq!(back.code, code);
|
||||
assert!(back.object);
|
||||
assert_eq!(PyCode::from_formula("_xlfn.PY(\"1+1\")").unwrap().code, "1+1");
|
||||
// Excel writes `_xlfn._xlws.PY`; the reader takes it in any letter case.
|
||||
assert_eq!(PyCode::from_formula("=_xlfn._xlws.PY(\"1+1\",1)").unwrap().code, "1+1");
|
||||
assert_eq!(PyCode::from_formula("_XLFN._XLWS.py(\"2\",0)").unwrap().code, "2");
|
||||
assert_eq!(PyCode::from_formula("_xlws.PY(\"3\",0)").unwrap().code, "3");
|
||||
assert!(is_py_formula("=_xlfn._xlws.PY(\"1\",0)"));
|
||||
assert_eq!(py.to_file_formula(), format!("_xlfn._xlws.{f}"));
|
||||
assert_eq!(PyCode::from_formula(&py.to_file_formula()).unwrap(), PyCode::from_formula(&f).unwrap());
|
||||
assert_eq!(PyCode::from_formula(" py( \"a\" & \"b\" , 0 ) ").unwrap().code, "ab");
|
||||
assert_eq!(PyCode::new("a\r\nb").code, "a\nb");
|
||||
for bad in ["PY(A1)", "PY(\"a\",2)", "PY(\"a\"", "PY(\"a\")+1", "SUM(1)", "PY(\"a\"&B1)", "PY(", ""] {
|
||||
assert!(PyCode::from_formula(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(is_py_formula("=PY(\"1\")") && is_py_formula("=py") && !is_py_formula("=PYTHON(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_limit_counts_the_encoded_formula() {
|
||||
// Plain code: the limit is the formula's, so it is a little under 8,192 characters of code.
|
||||
// `_xlfn._xlws.PY(` + 31 literals of 255 + 30 `&` + `&"…"` + `,0)`: 8,079 characters of
|
||||
// code give a formula of exactly 8,192.
|
||||
let at = PyCode::new(&"y".repeat(8_079));
|
||||
assert_eq!(at.to_file_formula().chars().count(), MAX_FORMULA_CHARS);
|
||||
assert!(at.fits());
|
||||
assert!(!PyCode::new(&"y".repeat(8_080)).fits());
|
||||
assert_eq!(PyCode::from_formula(&at.to_file_formula()).unwrap().code, at.code);
|
||||
// Quotes are doubled in the file, so 7,000 characters with 1,200 of them don't fit.
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
assert!(PyCode::new("ed).to_file_formula().chars().count() > MAX_FORMULA_CHARS);
|
||||
assert!(!PyCode::new("ed).fits());
|
||||
// Hostile sizes are refused without encoding them.
|
||||
assert!(!PyCode::new(&"y".repeat(4_000_000)).fits());
|
||||
assert!(PyCode::new("").fits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_values() {
|
||||
let arr = gridcraft_core::Array::new(2, 1, vec![1.0.into(), 2.0.into()]).map(Value::from).unwrap();
|
||||
let out = PyOutput { value: arr.clone(), type_name: "list".into(), preview: "[1, 2]".into(), convertible: true };
|
||||
let mut py = PyCode { output: Some(out), ..PyCode::new("[1, 2]") };
|
||||
assert_eq!(py.result_value(), Some(arr));
|
||||
py.object = true;
|
||||
assert_eq!(py.result_value(), Some(Value::text("list")));
|
||||
py.output = Some(PyOutput { value: Value::Number(3.0), type_name: "int".into(), preview: "3".into(), convertible: true });
|
||||
assert_eq!(py.result_value(), Some(Value::Number(3.0)), "scalars show as values in object mode too");
|
||||
py.output = None;
|
||||
assert_eq!(py.result_value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_xl_literals() {
|
||||
let code = "a = xl(\"A1:B2\", headers=True)\n# xl('C1') in a comment\ns = \"xl('D1')\"\nb = xl( 'Sheet2!E5' )\nc = xl(name)\nd = df.xl('F1')\nxlx('G1')\ne = xl(\"A\" + \"1\")";
|
||||
let calls = xl_calls(code);
|
||||
let refs: Vec<Option<&str>> = calls.iter().map(|c| c.reference.as_deref()).collect();
|
||||
assert_eq!(refs, vec![Some("A1:B2"), Some("Sheet2!E5"), None, None]);
|
||||
let new = rewrite_xl(code, |r| (r == "A1:B2").then(|| "A2:B3".to_string())).unwrap();
|
||||
assert!(new.starts_with("a = xl(\"A2:B3\", headers=True)"));
|
||||
assert_eq!(new.len(), code.len());
|
||||
assert_eq!(rewrite_xl(code, |_| None), None);
|
||||
// A replacement can't break out of its string: a quote in it changes the delimiter, or is
|
||||
// escaped when it holds both quotes, and the result reads back as the same reference.
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q'!A1".into())), Some("xl(\"'Q'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('Sheet2!B2', headers=True)", |_| Some("'Q4 2026'!B2".into())), Some("xl(\"'Q4 2026'!B2\", headers=True)".into()));
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q\"4'!A1".into())), Some("xl(\"'Q\\\"4'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("'Q\"4'!A1".into())), Some("xl('\\'Q\"4\\'!A1')".into()));
|
||||
for (delim, new) in [("'", "'Q4 2026'!B2"), ("'", "'Q\"4'!A1"), ("\"", "'Q\"4'!A1"), ("\"", "'a\\b'!A1"), ("'", "B2")] {
|
||||
let code = format!("x = xl({delim}A1{delim}) + 1");
|
||||
let out = rewrite_xl(&code, |_| Some(new.to_string())).unwrap();
|
||||
assert_eq!(xl_calls(&out)[0].reference.as_deref(), Some(new), "{out}");
|
||||
assert!(out.ends_with(") + 1"), "{out}");
|
||||
}
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("A\n1".into())), None, "a line break can't go in a literal");
|
||||
// Escaped quotes and backslashes read as the characters; any other escape is not plain.
|
||||
assert_eq!(xl_calls("xl('Bob\\'s!A1')")[0].reference.as_deref(), Some("Bob's!A1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\\\1\")")[0].reference.as_deref(), Some("A\\1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\n1\")")[0].reference, None);
|
||||
assert_eq!(xl_calls("xl(\"A1\\\")")[0].reference, None, "an escaped closing quote leaves the literal open");
|
||||
// Unclosed and odd input doesn't loop or panic.
|
||||
for odd in ["xl(", "xl(\"", "xl('''A1''')", "x", "xl", "\"unterminated", "xl ( \"A1\" )", "é xl('A1')"] {
|
||||
let _ = xl_calls(odd);
|
||||
let _ = rewrite_xl(odd, |_| Some("B2".into()));
|
||||
}
|
||||
assert_eq!(xl_calls("xl ( \"A1\" )")[0].reference.as_deref(), Some("A1"));
|
||||
assert_eq!(xl_calls("xl('''A1''')")[0].reference, None);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -52,9 +53,44 @@ impl SheetViewMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's identity while the app runs, for what must follow one sheet (an open cell draft): a
|
||||
/// sheet made new (added, inserted, copied, read from a file or from JSON) gets a fresh one, and it
|
||||
/// stays with the sheet through renames, moves and the copies undo keeps. Never saved.
|
||||
/// It is not content: any two compare equal, so comparing sheets compares what they hold; compare
|
||||
/// identities with [`SheetUid::get`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SheetUid(u64);
|
||||
|
||||
/// The next [`SheetUid`]; 0 is never given out.
|
||||
static NEXT_SHEET_UID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
impl SheetUid {
|
||||
pub fn fresh() -> SheetUid {
|
||||
SheetUid(NEXT_SHEET_UID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
pub fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SheetUid {
|
||||
fn default() -> SheetUid {
|
||||
SheetUid::fresh()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SheetUid {
|
||||
fn eq(&self, _: &SheetUid) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]). Not in JSON or the file.
|
||||
#[serde(skip)]
|
||||
pub uid: SheetUid,
|
||||
/// 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)]
|
||||
@@ -149,6 +185,7 @@ impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
uid: SheetUid::fresh(),
|
||||
file_id: 0,
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
@@ -202,6 +239,42 @@ impl Sheet {
|
||||
self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor)
|
||||
}
|
||||
|
||||
/// Removes the spilled values inside `r`. The work is bounded by the values stored, not by
|
||||
/// the size of `r`, so a range from a file that claims the whole sheet costs no more than the
|
||||
/// values it holds.
|
||||
pub fn clear_spill(&mut self, r: RangeRef) {
|
||||
if r.count() <= self.spill.len() as u64 {
|
||||
for c in r.iter() {
|
||||
self.spill.remove(&c);
|
||||
}
|
||||
} else {
|
||||
self.spill.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the spill anchored at `anchor` into plain values, for a Python cell that keeps only
|
||||
/// its value: the range is forgotten, and each spilled value in it becomes its cell's value,
|
||||
/// unless the cell holds content of its own (its format is kept either way). The work is
|
||||
/// bounded by the spilled values stored, not by the size of the range.
|
||||
pub fn settle_spill(&mut self, anchor: CellRef) {
|
||||
let Some(r) = self.spill_ranges.remove(&anchor) else { return };
|
||||
let inside: Vec<CellRef> = self.spill.range(r.start..=r.end).map(|(c, _)| *c).filter(|c| r.contains(*c)).collect();
|
||||
for c in inside {
|
||||
let Some(v) = self.spill.remove(&c) else { continue };
|
||||
if v.is_empty() || c == anchor {
|
||||
continue;
|
||||
}
|
||||
match self.cells.get_mut(c) {
|
||||
Some(cell) if cell.has_content() => {}
|
||||
Some(cell) => cell.value = v,
|
||||
None => {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell::styled(v, style));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating object with workbook-unique id `id` on this sheet.
|
||||
pub fn object_ref(&self, id: u32) -> Option<ObjRef> {
|
||||
let kind = if self.charts.iter().any(|o| o.id == id) {
|
||||
@@ -338,7 +411,7 @@ impl Sheet {
|
||||
}
|
||||
pub fn set_value(&mut self, c: CellRef, v: Value) {
|
||||
let style = self.style_id(c);
|
||||
self.set_cell(c, Cell { value: v, formula: None, style });
|
||||
self.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
/// Replaces content, removing any picture at this address. Style-only edits use set_style.
|
||||
pub fn set_cell(&mut self, c: CellRef, cell: Cell) {
|
||||
@@ -357,7 +430,7 @@ impl Sheet {
|
||||
/// Pictures retain a scalar fallback for calculation and unsupported consumers.
|
||||
pub fn set_picture(&mut self, c: CellRef, picture: Arc<CellPicture>) {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style });
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style, py: None });
|
||||
self.cell_pictures.insert(c, picture);
|
||||
}
|
||||
pub fn input_text(&self, c: CellRef) -> String {
|
||||
@@ -557,6 +630,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::cell::Formula;
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() {
|
||||
let a = Sheet::new("A");
|
||||
let b = Sheet::new("A");
|
||||
assert_ne!(a.uid.get(), b.uid.get(), "two new sheets");
|
||||
assert_ne!(a.uid.get(), 0);
|
||||
// A clone (an undo snapshot, a rename through Arc::make_mut) is the same sheet.
|
||||
let mut renamed = a.clone();
|
||||
renamed.name = "B".into();
|
||||
assert_eq!(renamed.uid.get(), a.uid.get());
|
||||
// Not content: equal sheets compare equal whatever their identity.
|
||||
assert_eq!(a, b);
|
||||
// Not in JSON: a sheet read back is a new one.
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
assert!(!json.contains("uid"), "{json}");
|
||||
let read: Sheet = serde_json::from_str(&json).unwrap();
|
||||
assert_ne!(read.uid.get(), a.uid.get());
|
||||
assert_eq!(read, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_index_matches_linear() {
|
||||
let mut s = Sheet::new("S");
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::cell::Cell;
|
||||
const BAND: u32 = 64;
|
||||
|
||||
fn has_value(cell: &Cell) -> bool {
|
||||
!cell.value.is_empty() || cell.formula.is_some()
|
||||
cell.has_content()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//!
|
||||
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
|
||||
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr]
|
||||
//! [--pane name] [--object id] [--dialog 'name={json}'] [--hf-edit header|footer] out.png`
|
||||
//! [--pane name] [--object id] [--dialog 'name={json}'] [--refedit key=A1:B2] [--hf-edit header|footer] out.png`
|
||||
//!
|
||||
//! `--refedit key=range` collapses the open dialog to its reference box `key`, then selects
|
||||
//! `range` on the grid, as a person picking a range for that box does.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
@@ -22,6 +25,7 @@ fn main() {
|
||||
let mut dialog: Option<String> = None;
|
||||
let mut language: Option<String> = None;
|
||||
let mut hf_edit: Option<String> = None;
|
||||
let mut refedit: Option<String> = None;
|
||||
let mut out = "snapshot.png".to_string();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -68,6 +72,10 @@ fn main() {
|
||||
i += 1;
|
||||
dialog = args.get(i).cloned();
|
||||
}
|
||||
"--refedit" => {
|
||||
i += 1;
|
||||
refedit = args.get(i).cloned();
|
||||
}
|
||||
"--hf-edit" => {
|
||||
i += 1;
|
||||
hf_edit = args.get(i).cloned();
|
||||
@@ -134,6 +142,15 @@ fn main() {
|
||||
// The workbook's installed fonts bind once the font scan is done; frames past the first few
|
||||
// apply them, so the snapshot shows them as the app does.
|
||||
harness.run_steps(14);
|
||||
if let Some((key, range)) = refedit.as_deref().and_then(|r| r.split_once('=')) {
|
||||
let app = harness.state_mut();
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
d.collapse_to(app, key);
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
app.session.execute("selection.set", json!({"range": range})).expect("select");
|
||||
harness.run_steps(8);
|
||||
}
|
||||
let img = harness.render().expect("render");
|
||||
img.save(&out).expect("save png");
|
||||
eprintln!("wrote {out} ({}x{})", img.width(), img.height());
|
||||
|
||||
@@ -14,6 +14,8 @@ use super::{
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod editing;
|
||||
#[cfg(test)]
|
||||
pub(crate) use editing::{DRAFT_CODE_HIDDEN, DRAFT_SHEET_GONE};
|
||||
mod files;
|
||||
mod fonts;
|
||||
mod input;
|
||||
@@ -221,7 +223,9 @@ impl SheetApp {
|
||||
} else {
|
||||
ribbon::show(self, ui);
|
||||
widgets::notice_bar(self, ui);
|
||||
if self.session.prefs.formula_bar {
|
||||
// Python mode edits only in the formula bar: it shows while a Python edit is open,
|
||||
// also when the preference hides it.
|
||||
if self.session.prefs.formula_bar || self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
formula_bar::show(self, ui);
|
||||
}
|
||||
if self.ui.status_bar {
|
||||
|
||||
@@ -246,6 +246,21 @@ impl SheetApp {
|
||||
self.open_dialog(name, p.clone());
|
||||
Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())}))
|
||||
}
|
||||
"ui.pythonMode" => {
|
||||
// `{"on": true}` starts Python mode on the active cell (or switches the edit in
|
||||
// progress to it), `{"on": false}` leaves it; without `on` it switches. A start
|
||||
// that is refused (a locked cell, a cell inside a spill, hidden code) is an error
|
||||
// with the reason, not `{"on": false}`.
|
||||
let on = self.editor.as_ref().is_some_and(|e| e.python);
|
||||
let want = p.get("on").and_then(Json::as_bool).unwrap_or(!on);
|
||||
if want != on {
|
||||
self.toggle_python_mode();
|
||||
if want && !self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
return Some(Err(self.python_mode_refused()));
|
||||
}
|
||||
}
|
||||
Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)}))
|
||||
}
|
||||
"ui.message.dismiss" => {
|
||||
self.message = None;
|
||||
Ok(Json::Null)
|
||||
@@ -293,11 +308,16 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.follow_open_draft();
|
||||
}
|
||||
|
||||
/// Whether the active sheet of the active document is protected (drives the Protect/Unprotect
|
||||
/// Sheet labels and actions).
|
||||
/// Sheet labels and actions). While a dialog's reference boxes have picked, it is the dialog's
|
||||
/// own sheet: Protect Sheet ends the pick and acts on that sheet, so the label says so too.
|
||||
pub fn active_sheet_protected(&self) -> bool {
|
||||
if let Some(protected) = self.dialog.as_ref().and_then(|d| d.home_sheet_protected(self)) {
|
||||
return protected;
|
||||
}
|
||||
self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -306,6 +326,20 @@ impl SheetApp {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
// A protection action taken while a reference box picks on another sheet is for the
|
||||
// dialog's sheet: the pick ends, showing that sheet again, before the action is chosen.
|
||||
if matches!(name, "protectSheet" | "unprotectSheet")
|
||||
&& let Some(mut d) = self.dialog.take()
|
||||
{
|
||||
let error =
|
||||
if d.home_sheet_deleted(self) { Some(dialogs::SHEET_DELETED.into()) } else { d.end_pick(self).err().map(|e| clean_error(&e)) };
|
||||
if let Some(error) = error {
|
||||
self.message = Some((d.title.clone(), error));
|
||||
self.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
// Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet
|
||||
// on a protected sheet. A sheet without a password is unprotected straight away.
|
||||
let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name };
|
||||
@@ -360,7 +394,16 @@ impl SheetApp {
|
||||
"Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(),
|
||||
));
|
||||
}
|
||||
other => self.dialog = dialogs::Dialog::open(self, other, params),
|
||||
other => {
|
||||
// A dialog replaced while one of its reference boxes picks (a ribbon command
|
||||
// opening another dialog) first puts the sheet and selection from before picking
|
||||
// back, as closing it does.
|
||||
if let Some(mut old) = self.dialog.take() {
|
||||
// The draft was committed above, so the sheet can be shown again.
|
||||
let _ = old.end_pick(self);
|
||||
}
|
||||
self.dialog = dialogs::Dialog::open(self, other, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::{SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms};
|
||||
|
||||
/// Why a draft whose sheet was deleted while it was open is dropped.
|
||||
pub(crate) const DRAFT_SHEET_GONE: &str = "The sheet of the cell being edited was deleted, so the entry was discarded.";
|
||||
|
||||
/// Why a draft of Python code that sheet protection hid while it was open is dropped.
|
||||
pub(crate) const DRAFT_CODE_HIDDEN: &str = "Sheet protection now hides the code of the cell being edited, so the entry was discarded.";
|
||||
|
||||
impl SheetApp {
|
||||
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
|
||||
pub fn begin_edit(&mut self, text: Option<String>, from_formula_bar: bool) {
|
||||
@@ -28,6 +34,22 @@ impl SheetApp {
|
||||
if text.is_none() && sh.cell_pictures.contains_key(&at) {
|
||||
return;
|
||||
}
|
||||
// Editing a Python cell edits its code, in Python mode; typing replaces it as in Excel.
|
||||
if text.is_none()
|
||||
&& let Some(py) = sh.cell(at).and_then(|c| c.py.as_deref())
|
||||
{
|
||||
// Code hidden on a protected sheet isn't loaded into an edit, and a blank edit would
|
||||
// write over it on commit: no edit starts. Typing still replaces the cell.
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return;
|
||||
}
|
||||
let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, py.code.clone()).on_sheet(&d.wb);
|
||||
ed.py_cell = true;
|
||||
ed.window = self.session.active_window();
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
return;
|
||||
}
|
||||
let locale = self.ui.language.formula_locale();
|
||||
let current = if sh.cell_pictures.contains_key(&at) {
|
||||
String::new()
|
||||
@@ -41,7 +63,7 @@ impl SheetApp {
|
||||
Some(t) => (t, true),
|
||||
None => (current.clone(), false),
|
||||
};
|
||||
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar);
|
||||
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar).on_sheet(&d.wb);
|
||||
ed.window = self.session.active_window();
|
||||
ed.locale = locale;
|
||||
if replace && self.ui.autocomplete && !ed.is_formula() {
|
||||
@@ -96,10 +118,23 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool {
|
||||
let Some(ed) = self.editor.take() else { return true };
|
||||
// Python code goes in through `python.set`, whichever way the edit is committed (the
|
||||
// formula bar's Enter button, a click on the sheet).
|
||||
if self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
let ok = self.commit_python_edit();
|
||||
if ok && (dr != 0 || dc != 0) {
|
||||
self.move_after_enter(dr, dc);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
let Some(mut ed) = self.editor.take() else { return true };
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
if !self.follow_draft_sheet(&mut ed) {
|
||||
return true;
|
||||
}
|
||||
let text = match &ed.completion {
|
||||
Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(),
|
||||
// A code edit commits the code as typed, whatever a completion holds.
|
||||
Some(full) if !ed.python && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(),
|
||||
_ => ed.text.clone(),
|
||||
};
|
||||
// F2 then Enter (or a file command) on an untouched draft: nothing to write.
|
||||
@@ -153,11 +188,9 @@ impl SheetApp {
|
||||
let unchanged = !fill_selection
|
||||
&& !array
|
||||
&& text.is_empty()
|
||||
&& self
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.sheet(ed.sheet))
|
||||
.is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty));
|
||||
&& self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| {
|
||||
sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.py.is_none() && c.value == gridcraft_engine::core::Value::Empty)
|
||||
});
|
||||
let r = if unchanged {
|
||||
Ok(json!({}))
|
||||
} else if fill_selection {
|
||||
@@ -182,6 +215,60 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a command, keeps the open draft on its own sheet: a sheet inserted, deleted or moved
|
||||
/// before it (an undo or a macro too) changes which sheet sits at its index. The draft is then
|
||||
/// drawn, pointed from and committed on its sheet, and the point-mode anchor, held by sheet
|
||||
/// index, is let go. A draft is dropped at once with a message when its sheet is gone, or when
|
||||
/// it holds Python code that sheet protection now hides (a draft in Python mode, or one that
|
||||
/// began on a Python cell's code): neither editor shows hidden code, as the Python editor
|
||||
/// window closes then too. A draft typed over a Python cell holds only what was typed, so it
|
||||
/// stays open.
|
||||
pub(crate) fn follow_open_draft(&mut self) {
|
||||
let Some(ed) = self.editor.as_ref() else { return };
|
||||
let Some(d) = self.session.documents().iter().find(|d| d.uid == ed.doc) else { return };
|
||||
let order = editor::sheet_order(&d.wb);
|
||||
let moved = ed.sheet_uid != 0 && order != ed.sheet_order;
|
||||
let found = if moved { ed.sheet_in(&d.wb) } else { Some(ed.sheet) };
|
||||
let hidden = found
|
||||
.and_then(|i| d.wb.sheet(i))
|
||||
.is_some_and(|sh| (ed.python || ed.py_cell) && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell));
|
||||
let why = match found {
|
||||
None => Some(DRAFT_SHEET_GONE),
|
||||
Some(_) if hidden => Some(DRAFT_CODE_HIDDEN),
|
||||
Some(_) => None,
|
||||
};
|
||||
if let Some(why) = why {
|
||||
self.editor = None;
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
self.message = Some(("GridCraft".into(), why.into()));
|
||||
return;
|
||||
}
|
||||
if let (true, Some(sheet), Some(ed)) = (moved, found, self.editor.as_mut()) {
|
||||
ed.sheet = sheet;
|
||||
ed.sheet_order = order;
|
||||
ed.point_anchor = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a draft being committed at its sheet, found by identity: commands (a sheet inserted
|
||||
/// or deleted before it, a move, an undo, a macro) change which sheet sits at its index while
|
||||
/// it is open. A draft whose sheet is gone is dropped with a message, as one whose cell
|
||||
/// protection locks is, rather than being entered on whichever sheet took its place; `false`
|
||||
/// then.
|
||||
fn follow_draft_sheet(&mut self, ed: &mut editor::EditState) -> bool {
|
||||
let Some(d) = self.session.active() else { return true };
|
||||
match ed.sheet_in(&d.wb) {
|
||||
Some(sheet) => {
|
||||
ed.sheet = sheet;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.message = Some(("GridCraft".into(), DRAFT_SHEET_GONE.into()));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs
|
||||
/// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel.
|
||||
pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool {
|
||||
@@ -196,6 +283,125 @@ impl SheetApp {
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
}
|
||||
|
||||
/// Starts editing the active cell in Python mode, in the formula bar: its code, or no code
|
||||
/// yet for a cell that isn't a Python cell. A cell inside a spill isn't edited, as with the
|
||||
/// formula bar's other entry paths.
|
||||
pub fn begin_python_edit(&mut self) {
|
||||
if formula_bar::active_spill_anchor(self).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.commit_text_box_edit() {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
let Some(d) = self.session.active() else { return };
|
||||
let Some(sh) = d.wb.active() else { return };
|
||||
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
// A locked cell on a protected sheet is refused before a draft exists, as `begin_edit`
|
||||
// refuses it (#2945).
|
||||
if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) {
|
||||
self.message = Some(("GridCraft".into(), m));
|
||||
return;
|
||||
}
|
||||
let py = sh.cell(at).and_then(|c| c.py.as_deref());
|
||||
// A Python cell's code hidden on a protected sheet stays out of view, and Python mode
|
||||
// doesn't start on it with no code, which a commit would write over the hidden code.
|
||||
if py.is_some() && gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return;
|
||||
}
|
||||
let code = py.map(|py| py.code.clone()).unwrap_or_default();
|
||||
// The draft belongs to this window, as `begin_edit`'s do: switching windows commits it here.
|
||||
let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, code).on_sheet(&d.wb);
|
||||
ed.py_cell = py.is_some();
|
||||
ed.window = self.session.active_window();
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+Shift+P (and the PY badge): Python mode on or off. An edit in progress keeps its
|
||||
/// text; with no edit, Python mode starts on the active cell.
|
||||
pub fn toggle_python_mode(&mut self) {
|
||||
if self.editor.as_ref().is_some_and(|ed| !ed.python && self.draft_in_spill(ed)) {
|
||||
return;
|
||||
}
|
||||
match self.editor.as_mut() {
|
||||
Some(ed) if ed.python => ed.leave_python(),
|
||||
Some(ed) => ed.enter_python(),
|
||||
None => return self.begin_python_edit(),
|
||||
}
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
|
||||
/// Whether a draft's cell lies inside another cell's spill. A draft typed there stays an
|
||||
/// ordinary entry: Python mode starts on no cell inside a spill, as the formula bar's entry
|
||||
/// paths don't.
|
||||
pub(crate) fn draft_in_spill(&self, ed: &editor::EditState) -> bool {
|
||||
let doc = self.session.documents().iter().find(|d| d.uid == ed.doc);
|
||||
doc.and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| sh.spill_anchor(ed.cell).is_some())
|
||||
}
|
||||
|
||||
/// Why Python mode didn't start where a command asked for it: the cell lies inside another
|
||||
/// cell's spill, it is locked on a protected sheet, or its code is hidden there.
|
||||
pub(crate) fn python_mode_refused(&self) -> String {
|
||||
let in_spill = match self.editor.as_ref() {
|
||||
Some(ed) => self.draft_in_spill(ed),
|
||||
None => formula_bar::active_spill_anchor(self).is_some(),
|
||||
};
|
||||
if in_spill {
|
||||
return "Python mode doesn't start on a cell inside another cell's spill.".into();
|
||||
}
|
||||
let locked = self.session.active().and_then(|d| {
|
||||
let at = d.wb.active().and_then(|sh| sh.merge_at(d.selection.active)).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
self.session.locked_cell_message(d.wb.active_sheet(), at)
|
||||
});
|
||||
locked.unwrap_or_else(|| "Python mode can't start on this cell: its code is hidden on a protected sheet.".into())
|
||||
}
|
||||
|
||||
/// Commits a Python mode edit through `python.set`, on the sheet the edit started on; with no
|
||||
/// code left, a Python cell is cleared through `cell.set`. When the command refuses (code
|
||||
/// longer than a cell holds), its message shows and the edit stays open with the code as
|
||||
/// typed.
|
||||
pub fn commit_python_edit(&mut self) -> bool {
|
||||
let Some(mut ed) = self.editor.take() else { return true };
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
if !self.follow_draft_sheet(&mut ed) {
|
||||
return true;
|
||||
}
|
||||
// The code of a Python cell committed unchanged: nothing to write (no undo step, the
|
||||
// workbook stays clean), as with F2 then Enter on any other cell.
|
||||
let code = gridcraft_engine::model::python::normalize_code(&ed.text);
|
||||
let old = self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).and_then(|sh| sh.cell(ed.cell)).and_then(|c| c.py.as_deref());
|
||||
let python_cell = old.is_some();
|
||||
if old.is_some_and(|py| py.code == code) {
|
||||
return true;
|
||||
}
|
||||
// A draft on a cell sheet protection now locks can never be entered: it is dropped with
|
||||
// the warning, as `commit_edit_here` drops an ordinary one (#2945).
|
||||
if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) {
|
||||
self.message = Some(("GridCraft".into(), m));
|
||||
return true;
|
||||
}
|
||||
// No code at all: a Python cell is cleared through cell.set, as an empty entry clears any
|
||||
// other cell (code of spaces is still code). On another cell there is nothing to write.
|
||||
let r = match (ed.text.is_empty(), python_cell) {
|
||||
(true, false) => return true,
|
||||
(true, true) => self.session.run("cell.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "input": ""})),
|
||||
(false, _) => self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": ed.text})),
|
||||
};
|
||||
match r {
|
||||
Ok(_) => {
|
||||
self.after_engine();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter/Tab move inside a multi-cell selection, otherwise to the next cell.
|
||||
pub fn move_after_enter(&mut self, dr: i64, dc: i64) {
|
||||
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
|
||||
|
||||
@@ -410,6 +410,8 @@ pub struct Applied {
|
||||
/// The keyboard events held back for the next pass, in order: the editor's first key after
|
||||
/// an event the text field acts on, and every keyboard event after that key.
|
||||
pub held: Vec<egui::Event>,
|
||||
/// The caller's key the pass stopped at (see [`apply_in_order_until`]).
|
||||
pub stop: Option<egui::Event>,
|
||||
}
|
||||
|
||||
/// Applies the pass's input to `text` in the order it came. A pass can carry several events
|
||||
@@ -426,6 +428,22 @@ pub struct Applied {
|
||||
/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret),
|
||||
/// updated.
|
||||
pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied {
|
||||
apply_in_order_until(events, text, sel, lang, &|_| false)
|
||||
}
|
||||
|
||||
/// [`apply_in_order`], stopping at the first event `stop` picks: a key the caller acts on itself
|
||||
/// (commit, cancel, a mode switch). It counts as one of the editor's keys for the order: the
|
||||
/// typing before it is applied first, and after an event the text field acts on it is held back
|
||||
/// for the next pass with the rest. Once the pass stops (`Applied::stop`), the keyboard events
|
||||
/// after the stop key are dropped: the caller acts on the text as it was when the key was
|
||||
/// pressed, and what followed it was typed after it.
|
||||
pub fn apply_in_order_until(
|
||||
events: &mut Vec<egui::Event>,
|
||||
text: &mut String,
|
||||
sel: &mut (usize, usize),
|
||||
lang: Lang,
|
||||
stop: &dyn Fn(&egui::Event) -> bool,
|
||||
) -> Applied {
|
||||
let mut out = Applied::default();
|
||||
// An event the field acts on has gone by: the editor's keys after it wait for the next pass.
|
||||
let mut field_seen = false;
|
||||
@@ -433,10 +451,25 @@ pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mu
|
||||
let mut holding = false;
|
||||
let mut keep = Vec::with_capacity(events.len());
|
||||
for ev in std::mem::take(events) {
|
||||
if out.stop.is_some() {
|
||||
if !is_keyboard(&ev) {
|
||||
keep.push(ev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if holding && is_keyboard(&ev) {
|
||||
out.held.push(ev);
|
||||
continue;
|
||||
}
|
||||
if is_keyboard(&ev) && stop(&ev) {
|
||||
if field_seen {
|
||||
holding = true;
|
||||
out.held.push(ev);
|
||||
} else {
|
||||
out.stop = Some(ev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match classify(&ev) {
|
||||
Class::Edit(_) if field_seen => {
|
||||
holding = true;
|
||||
@@ -546,18 +579,91 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
|
||||
/// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]).
|
||||
pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option<Range<usize>>) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font()));
|
||||
let max_lines = max_lines.clamp(1, 200);
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
let options = FieldOptions { max_height: row_h * max_lines as f32, mark: mark.clone(), ..FieldOptions::new(lang) };
|
||||
response = Some(code_field(ui, id, text, &options).response);
|
||||
});
|
||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
||||
}
|
||||
|
||||
/// How a [`code_field`] is laid out, and the keys its caller takes.
|
||||
pub struct FieldOptions<'a> {
|
||||
pub lang: Lang,
|
||||
/// The field's width (`f32::INFINITY`: all there is).
|
||||
pub width: f32,
|
||||
/// The height shown before it scrolls; the scroll area follows the caret.
|
||||
pub max_height: f32,
|
||||
/// Whether it takes input. A dialog or message box over the field has the keyboard.
|
||||
pub interactive: bool,
|
||||
/// Keys the caller acts on itself, in input order (see [`apply_in_order_until`]).
|
||||
pub stop: &'a dyn Fn(&egui::Event) -> bool,
|
||||
/// Esc stays with the field (for a caller that stops at it) instead of releasing the focus.
|
||||
pub keep_escape: bool,
|
||||
/// Puts the caret here (a char index) before the pass's input: the caller changed the text.
|
||||
pub caret: Option<usize>,
|
||||
/// The field's name for screen readers.
|
||||
pub name: &'a str,
|
||||
/// A byte range underlined in the error colour (see [`underline`]).
|
||||
pub mark: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl FieldOptions<'_> {
|
||||
pub fn new(lang: Lang) -> FieldOptions<'static> {
|
||||
FieldOptions {
|
||||
lang,
|
||||
width: f32::INFINITY,
|
||||
max_height: 100.0,
|
||||
interactive: true,
|
||||
stop: &|_| false,
|
||||
keep_escape: false,
|
||||
caret: None,
|
||||
// The editor is a generic code field, so the name is not part of the UI's translated
|
||||
// strings unless the caller gives one.
|
||||
name: "Code Editor",
|
||||
mark: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`code_field`] laid out and did.
|
||||
pub struct FieldOutput {
|
||||
pub response: Response,
|
||||
/// The caller's key the pass stopped at, if any.
|
||||
pub stop: Option<egui::Event>,
|
||||
/// Laid-out rows of the text.
|
||||
pub lines: usize,
|
||||
/// The caret on screen, while the field has the focus.
|
||||
pub caret: Option<egui::Rect>,
|
||||
/// The caret and the other end of the selection (char indices).
|
||||
pub selection: (usize, usize),
|
||||
}
|
||||
|
||||
/// The code editor's text field without a frame: monospace text with syntax colours, Enter that
|
||||
/// keeps the indentation, Tab and Shift+Tab, applied in input order (see [`apply_in_order`]).
|
||||
pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldOptions<'_>) -> FieldOutput {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let lang = options.lang;
|
||||
// The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in
|
||||
// input order with the typing around it, before the text field sees the rest; what had to
|
||||
// wait for the field in the previous pass comes first.
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let focused = options.interactive && ui.memory(|m| m.has_focus(id));
|
||||
let held = take_held(ui, id, focused);
|
||||
let mut edited = None;
|
||||
let mut stop = None;
|
||||
let count = text.chars().count();
|
||||
if let Some(caret) = options.caret {
|
||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let caret = caret.min(count);
|
||||
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret))));
|
||||
state.store(ui.ctx(), id);
|
||||
edited = Some(Edit { anchor: caret, caret });
|
||||
}
|
||||
if focused {
|
||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let count = text.chars().count();
|
||||
let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count));
|
||||
let applied = ui.input_mut(|i| {
|
||||
if !held.is_empty() {
|
||||
@@ -565,7 +671,7 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La
|
||||
events.append(&mut i.events);
|
||||
i.events = events;
|
||||
}
|
||||
apply_in_order(&mut i.events, text, &mut sel, lang)
|
||||
apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop)
|
||||
});
|
||||
if applied.edited {
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1))));
|
||||
@@ -575,46 +681,52 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La
|
||||
if !applied.held.is_empty() {
|
||||
hold(ui, id, applied.held);
|
||||
}
|
||||
stop = applied.stop;
|
||||
}
|
||||
let syntax = t.syntax;
|
||||
let plain = t.text;
|
||||
let danger = t.danger;
|
||||
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
|
||||
if let Some(m) = mark.clone() {
|
||||
if let Some(m) = options.mark.clone() {
|
||||
underline(&mut job, m, danger);
|
||||
}
|
||||
ui.fonts_mut(|f| f.layout_job(job))
|
||||
};
|
||||
let frame_h = row_h * max_lines as f32 + 8.0;
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape };
|
||||
let scrolled =
|
||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(options.max_height.max(1.0)).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let out = TextEdit::multiline(text)
|
||||
.id(id)
|
||||
.font(font.clone())
|
||||
.code_editor()
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_width(options.width)
|
||||
.desired_rows(1)
|
||||
.lock_focus(true)
|
||||
.interactive(options.interactive)
|
||||
.event_filter(filter)
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
if let Some(e) = edited {
|
||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret, None);
|
||||
}
|
||||
// The field has no visible label; name it for screen readers and the strict
|
||||
// kittest accessibility check. The editor is a generic code field, so the name is
|
||||
// not part of the UI's translated strings.
|
||||
let mut r = out.response.response.accessible_name("Code Editor");
|
||||
if edited.is_some() {
|
||||
r.mark_changed();
|
||||
}
|
||||
response = Some(r);
|
||||
out
|
||||
});
|
||||
});
|
||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
||||
let out = scrolled.inner;
|
||||
// The field has no visible label; name it for screen readers and the strict kittest
|
||||
// accessibility check.
|
||||
let mut response = out.response.response.accessible_name(options.name);
|
||||
if edited.is_some() {
|
||||
response.mark_changed();
|
||||
}
|
||||
let selection = out.cursor_range.map(|r| (r.primary.index.into(), r.secondary.index.into())).unwrap_or((count, count));
|
||||
let caret = out
|
||||
.cursor_range
|
||||
.map(|r| out.galley.pos_from_cursor(r.primary).translate(out.galley_pos.to_vec2()))
|
||||
.filter(|_| response.has_focus())
|
||||
.map(|r| r.intersect(scrolled.inner_rect.expand(1.0)));
|
||||
FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -336,6 +336,41 @@ fn tab_and_enter_under_modifiers_never_reach_the_field() {
|
||||
assert!(out.held.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_key_ends_the_pass_in_input_order() {
|
||||
let stop = |ev: &egui::Event| matches!(ev, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command);
|
||||
// The typing before it is applied; the keys after it are dropped, other input stays.
|
||||
let mut events = vec![
|
||||
egui::Event::Text("a".into()),
|
||||
key(Key::Enter, Modifiers::COMMAND),
|
||||
egui::Event::Text("b".into()),
|
||||
egui::Event::PointerMoved(egui::Pos2::ZERO),
|
||||
key(Key::Tab, Modifiers::NONE),
|
||||
];
|
||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(s, "a");
|
||||
assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND)));
|
||||
assert_eq!(events, vec![egui::Event::PointerMoved(egui::Pos2::ZERO)]);
|
||||
assert!(out.held.is_empty());
|
||||
// After a key the field acts on, it waits for the next pass with the editor's keys
|
||||
// before it, so the edit it ends includes them.
|
||||
let mut events = vec![key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)];
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(out.stop, None);
|
||||
assert_eq!(events, vec![key(Key::A, Modifiers::COMMAND)]);
|
||||
assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]);
|
||||
// Next pass: the Tab, then the stop.
|
||||
let mut events = out.held;
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(s, "a ");
|
||||
assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND)));
|
||||
// Without a stop the editor behaves as before: the caller's key is the field's.
|
||||
let mut events = vec![key(Key::Enter, Modifiers::COMMAND)];
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert_eq!((out.stop, events.len()), (None, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() {
|
||||
let mut events = vec![
|
||||
@@ -424,3 +459,24 @@ fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().0, " a\n b");
|
||||
}
|
||||
|
||||
/// Whether a frame drew text with an underline.
|
||||
fn draws_underline(mark: Option<std::ops::Range<usize>>) -> bool {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut text = String::from("x^{2");
|
||||
code_editor_marked(ui, egui::Id::unique("marked"), &mut text, Lang::Latex, 3, mark.clone());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().any(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => t.galley.job.sections.iter().any(|sec| sec.format.underline.width > 0.0),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marked_editor_underlines_its_mark() {
|
||||
// The equation editor marks an error's position; the code field draws it.
|
||||
assert!(draws_underline(Some(3..4)));
|
||||
assert!(!draws_underline(None));
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
|
||||
"window": [r.width(), r.height()],
|
||||
"grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"scroll": [v.scroll.x, v.scroll.y],
|
||||
"editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})),
|
||||
"editor": app.editor.as_ref().map(|e| editor_json(app, e)),
|
||||
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
|
||||
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
|
||||
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
|
||||
@@ -162,6 +162,52 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
|
||||
})
|
||||
}
|
||||
|
||||
/// The open edit, for `ui.inspect`. The draft is left out (`text` is null, `hidden` true) while
|
||||
/// its cell's code is hidden on a protected sheet, checked now: a protection command earlier in
|
||||
/// the same batch runs before the next frame. That holds for a draft in Python mode, and for a
|
||||
/// draft that began on a Python cell's code, since leaving Python mode keeps the code as an
|
||||
/// ordinary draft; a draft typed over a Python cell holds only what was typed. The sheet is the
|
||||
/// draft's own, found by identity: a sheet inserted, deleted or moved in the same batch puts
|
||||
/// another at its index. With its sheet gone, such a draft is left out too.
|
||||
fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json {
|
||||
let code = e.python || e.py_cell;
|
||||
let hidden = code
|
||||
&& app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) {
|
||||
Some(sh) => gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell),
|
||||
None => true,
|
||||
});
|
||||
let text = if hidden { Json::Null } else { json!(e.text) };
|
||||
json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python})
|
||||
}
|
||||
|
||||
/// `ui.dialog.cancel`: closes the open dialog as its Cancel button does, going back to the sheet
|
||||
/// and selection from before a reference box picked.
|
||||
fn dialog_cancel(app: &mut SheetApp) -> Outcome {
|
||||
// The macro step editor's draft, overlays and debug run go with its own close.
|
||||
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
|
||||
return match crate::macro_editor::close_now(app) {
|
||||
Ok(()) => ok(Json::Null),
|
||||
Err(e) => err(e),
|
||||
};
|
||||
}
|
||||
// Back to the sheet and selection from before a reference box picked, as Cancel does.
|
||||
// When the dialog's sheet can't be shown again (a text-box draft on the picked sheet
|
||||
// could not be saved), the dialog stays and the script gets the reason; the window's
|
||||
// Cancel closes it and shows the reason, since a person can see the message.
|
||||
// A dialog whose own sheet was deleted (found by identity, so a new sheet given its
|
||||
// name does not count) replies `{"sheetDeleted": true}`: the sheet shown is not the
|
||||
// dialog's, and a script stops before running the dialog's command itself.
|
||||
let mut deleted = false;
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
deleted = d.home_sheet_deleted(app);
|
||||
if let Err(e) = d.end_pick(app) {
|
||||
app.dialog = Some(d);
|
||||
return err(e);
|
||||
}
|
||||
}
|
||||
ok(if deleted { json!({"sheetDeleted": true}) } else { Json::Null })
|
||||
}
|
||||
|
||||
/// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind
|
||||
/// is found from the id when left out, and must match when given); no `id` clears the selection.
|
||||
/// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`.
|
||||
@@ -218,6 +264,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("app.checkForUpdates", "Check for Updates…", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
||||
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
||||
@@ -290,8 +337,21 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
None => err("no dialog is open"),
|
||||
},
|
||||
"ui.dialog.confirm" => match app.dialog.take() {
|
||||
Some(d) => match d.command {
|
||||
Some(mut d) => match d.command {
|
||||
Some(cmd) => {
|
||||
// References without a sheet name mean the sheet the dialog was opened on,
|
||||
// as OK in the window has it: refused once that sheet is deleted, else shown
|
||||
// again before the command runs.
|
||||
if d.home_sheet_deleted(app) {
|
||||
app.dialog = Some(d);
|
||||
return err(crate::dialogs::SHEET_DELETED);
|
||||
}
|
||||
if let Err(e) = d.end_pick(app) {
|
||||
// The dialog's sheet can't be shown again (a text-box draft on the picked
|
||||
// sheet could not be saved): the command stays unrun, the dialog open.
|
||||
app.dialog = Some(d);
|
||||
return err(e);
|
||||
}
|
||||
let params = crate::dialogs::build_params(&d);
|
||||
let home = app.ui_window();
|
||||
let r = app.run(cmd, params);
|
||||
@@ -307,17 +367,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
},
|
||||
None => err("no dialog is open"),
|
||||
},
|
||||
"ui.dialog.cancel" => {
|
||||
// The macro step editor's draft, overlays and debug run go with its own close.
|
||||
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
|
||||
return match crate::macro_editor::close_now(app) {
|
||||
Ok(()) => ok(Json::Null),
|
||||
Err(e) => err(e),
|
||||
};
|
||||
}
|
||||
app.dialog = None;
|
||||
ok(Json::Null)
|
||||
}
|
||||
"ui.dialog.cancel" => dialog_cancel(app),
|
||||
"ui.object.select" => select_object(app, p),
|
||||
"ui.message.dismiss" => {
|
||||
app.message = None;
|
||||
|
||||
@@ -977,3 +977,280 @@ fn synthetic_text_key_and_move_producers_honour_the_queue_budget() {
|
||||
assert_eq!(reply["ok"], false, "{reply}");
|
||||
assert_eq!(app.synthetic.iter().map(Vec::len).sum::<usize>(), MAX_QUEUED_SYNTHETIC_EVENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_ui_commands_are_listed() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search": "python"})) else {
|
||||
panic!("command discovery must return immediately");
|
||||
};
|
||||
let commands = reply["result"].as_array().cloned().unwrap_or_default();
|
||||
let listed = [("ui.pythonMode", "{on?: bool} (toggles without it) → {on}; a refused start is an error")];
|
||||
for (id, docs) in listed {
|
||||
let command = commands.iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("engine.commands lists {id}"));
|
||||
assert_eq!(command["ui"], true, "{id}");
|
||||
assert_eq!(command["params"], docs, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `command` through the control channel, as one line of a batch: no frame runs after it.
|
||||
fn execute(app: &mut SheetApp, ctx: &egui::Context, command: &str, params: Json) {
|
||||
let Outcome::Done(reply) = handle(app, ctx, "engine.execute", &json!({"command": command, "params": params})) else {
|
||||
panic!("{command} answers at once");
|
||||
};
|
||||
assert_eq!(reply["ok"], true, "{command}: {reply}");
|
||||
}
|
||||
|
||||
fn inspected(app: &mut SheetApp, ctx: &egui::Context) -> Json {
|
||||
let Outcome::Done(reply) = handle(app, ctx, "ui.inspect", &json!({})) else { panic!("ui.inspect answers at once") };
|
||||
reply["result"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_on_code_hidden_in_the_same_batch_shows_no_code() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
let before = inspected(&mut app, &ctx);
|
||||
assert_eq!(before["editor"]["text"], "secret = 41\nsecret + 1", "unprotected, the draft shows its code");
|
||||
// Protected on the session itself, so no app command drops the draft first: `ui.inspect`
|
||||
// still leaves the code out, checked when it is called.
|
||||
app.session.execute("review.protectSheet", json!({})).unwrap();
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.python), "the draft is still open");
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["text"], Json::Null);
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
// Out of Python mode the draft still holds the hidden cell's code.
|
||||
if let Some(e) = app.editor.as_mut() {
|
||||
e.leave_python();
|
||||
}
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft holds the code");
|
||||
let left = inspected(&mut app, &ctx);
|
||||
assert!(!left.to_string().contains("secret"), "{left}");
|
||||
assert_eq!(left["editor"]["hidden"], true);
|
||||
}
|
||||
|
||||
/// Answers one control request that must answer at once with `ok`.
|
||||
fn call(app: &mut SheetApp, ctx: &egui::Context, method: &str, params: Json) {
|
||||
let Outcome::Done(reply) = handle(app, ctx, method, ¶ms) else { panic!("{method} answers at once") };
|
||||
assert_eq!(reply["ok"], true, "{method}: {reply}");
|
||||
}
|
||||
|
||||
/// A Python draft of hidden code at B2 on Sheet1, protected after the draft opened, with an
|
||||
/// unprotected sheet `Other` after it when `other` is set.
|
||||
fn hidden_python_draft(other: bool) -> (SheetApp, egui::Context) {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap();
|
||||
if other {
|
||||
session.execute("home.insertSheet", json!({"name": "Other", "before": 1})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
}
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
// On the session itself, so the draft stays open for `ui.inspect` to be checked against.
|
||||
app.session.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"]["hidden"], true, "hidden on its own sheet");
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_shows_no_code_when_another_sheet_takes_its_index_in_the_same_batch() {
|
||||
let (mut app, ctx) = hidden_python_draft(false);
|
||||
// An unprotected sheet now sits at the draft's old index, in the same batch.
|
||||
app.session.execute("home.insertSheet", json!({"name": "New", "before": 0})).unwrap();
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["text"], Json::Null);
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_shows_no_code_once_its_sheet_is_deleted_in_the_same_batch() {
|
||||
let (mut app, ctx) = hidden_python_draft(true);
|
||||
// The draft's own sheet goes, and the unprotected `Other` takes its index.
|
||||
app.session.execute("home.deleteSheet", json!({"sheet": "Sheet1"})).unwrap();
|
||||
assert_eq!(app.session.active().map(|d| d.wb.sheets.len()), Some(1));
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
// The next app command (an undo that brings the protected sheet back) drops the draft, since
|
||||
// its code is hidden there; deleting a draft's sheet is covered by
|
||||
// `a_draft_whose_sheet_is_deleted_is_dropped_with_a_message`.
|
||||
execute(&mut app, &ctx, "edit.undo", json!({}));
|
||||
assert!(app.editor.is_none(), "the draft is dropped");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN));
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null);
|
||||
}
|
||||
|
||||
/// Sheet `name`'s A1: its input text and whether it is a Python cell.
|
||||
fn a1(app: &SheetApp, name: &str) -> Option<(String, bool)> {
|
||||
let d = app.session.active()?;
|
||||
let sh = d.wb.sheet(d.wb.sheet_index(name)?)?;
|
||||
sh.cell(gridcraft_engine::core::CellRef::new(0, 0)).map(|c| (c.input_text(), c.py.is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_is_committed_on_its_own_sheet_after_a_sheet_is_inserted_before_it() {
|
||||
// An ordinary draft and a Python draft on Sheet1!A1, then a sheet inserted before Sheet1.
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"}));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "New", "before": 0}));
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
assert!(app.editor.is_none(), "python {python}");
|
||||
assert_eq!(a1(&app, "New"), None, "python {python}: nothing lands on the inserted sheet");
|
||||
let got = a1(&app, "Sheet1");
|
||||
if python {
|
||||
assert_eq!(got.map(|g| g.1), Some(true), "the code lands on the draft's own sheet");
|
||||
} else {
|
||||
assert_eq!(got, Some(("42".to_string(), false)), "the entry lands on the draft's own sheet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_whose_sheet_is_deleted_is_dropped_with_a_message() {
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1}));
|
||||
execute(&mut app, &ctx, "sheet.activate", json!({"sheet": 0}));
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"}));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.deleteSheet", json!({"sheet": "Sheet1"}));
|
||||
// Dropped once the command is done, not left drawn on the sheet in its place.
|
||||
assert!(app.editor.is_none(), "python {python}: the draft is dropped");
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null, "python {python}");
|
||||
assert_eq!(a1(&app, "Other"), None, "python {python}: nothing lands on the sheet in its place");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_SHEET_GONE), "python {python}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_draft_follows_its_sheet_when_a_sheet_is_inserted_before_it() {
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=SUM("}));
|
||||
}
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
ed.point_anchor = Some((0, gridcraft_engine::core::CellRef::parse("C3").unwrap()));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Before", "before": 0}));
|
||||
let ed = app.editor.as_ref().expect("the draft stays open");
|
||||
assert_eq!(ed.sheet, 1, "python {python}: the draft is on Sheet1, now at index 1, so it is drawn there");
|
||||
assert_eq!(ed.point_anchor, None, "python {python}: the anchor's sheet index no longer names its sheet");
|
||||
// A rename changes no index: nothing is let go.
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
ed.point_anchor = Some((1, gridcraft_engine::core::CellRef::parse("C3").unwrap()));
|
||||
}
|
||||
execute(&mut app, &ctx, "sheet.rename", json!({"sheet": "Before", "name": "First"}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.sheet == 1 && e.point_anchor.is_some()), "python {python}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_of_python_code_that_protection_hides_is_dropped_with_a_message() {
|
||||
// In Python mode, and after leaving it (an ordinary draft of a Python cell's code).
|
||||
for leave in [false, true] {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap();
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2:C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
if leave {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": false}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft of the code");
|
||||
}
|
||||
execute(&mut app, &ctx, "review.protectSheet", json!({}));
|
||||
assert!(app.editor.is_none(), "leave {leave}: neither editor keeps hidden code on screen");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN), "leave {leave}");
|
||||
assert!(!inspected(&mut app, &ctx).to_string().contains("secret"));
|
||||
}
|
||||
// An ordinary formula draft on a hidden cell stays open, as on main.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "C2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=2+2"}));
|
||||
execute(&mut app, &ctx, "review.protectSheet", json!({}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.text == "=2+2"), "a formula draft is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands() {
|
||||
// An unlocked, hidden Python cell on a protected sheet: typing replaces it, as in Excel. The
|
||||
// draft holds only what was typed, so a command neither drops it nor hides it from ui.inspect.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("review.protectSheet", json!({})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "7"}));
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.text == "7"), "the typed entry stays open");
|
||||
assert!(app.message.is_none(), "{:?}", app.message);
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert_eq!(after["editor"]["text"], "7");
|
||||
assert_eq!(after["editor"]["hidden"], false);
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
let b2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 1)).cloned());
|
||||
assert_eq!(b2.map(|c| (c.input_text(), c.py.is_some())), Some(("7".to_string(), false)), "the typed entry replaces the Python cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_mode_does_not_start_on_a_draft_typed_inside_a_spill() {
|
||||
// Typing on a spill child is an ordinary entry; switching that draft to Python mode would let
|
||||
// Ctrl+Enter write code into a cell another cell's result spills over, which every other way
|
||||
// into Python mode refuses.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "5"}));
|
||||
let Outcome::Done(reply) = handle(&mut app, &ctx, "engine.execute", &json!({"command": "ui.pythonMode", "params": {"on": true}})) else {
|
||||
panic!("ui.pythonMode answers at once");
|
||||
};
|
||||
assert_eq!(reply["ok"], false, "the start is refused: {reply}");
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text == "5"), "the draft stays an ordinary entry");
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
let a2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 0)).cloned());
|
||||
assert_eq!(a2.map(|c| (c.input_text(), c.py.is_some())), Some(("5".to_string(), false)));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod format_cells;
|
||||
mod insert_function;
|
||||
mod names;
|
||||
mod paste;
|
||||
mod ref_edit;
|
||||
mod show;
|
||||
mod spelling;
|
||||
mod zoom;
|
||||
@@ -34,13 +35,17 @@ use data::{abs, data_validation_fields, remove_duplicates_range, validation_defa
|
||||
use find::go_to_entries;
|
||||
pub(crate) use names::insert_name;
|
||||
use names::pasteable_names;
|
||||
pub(crate) use ref_edit::SHEET_DELETED;
|
||||
pub(crate) use show::build_params;
|
||||
pub use show::show;
|
||||
use zoom::zoom_preset;
|
||||
|
||||
/// A form field. `Ref` is a range reference that can also be picked by selecting on the grid
|
||||
/// (a RefEdit box).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Field {
|
||||
Text { key: &'static str, label: &'static str },
|
||||
Ref { key: &'static str, label: &'static str },
|
||||
Password { key: &'static str, label: &'static str },
|
||||
Number { key: &'static str, label: &'static str },
|
||||
Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> },
|
||||
@@ -74,6 +79,8 @@ pub struct Dialog {
|
||||
pick: Option<RefPick>,
|
||||
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
|
||||
original_link_text: Option<String>,
|
||||
/// The state of the dialog's reference boxes (RefEdit).
|
||||
refs: ref_edit::RefBoxes,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
@@ -127,6 +134,39 @@ impl Dialog {
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// The reference box the dialog is collapsed to, if any.
|
||||
pub fn collapsed(&self) -> Option<&str> {
|
||||
self.refs.collapsed.as_deref()
|
||||
}
|
||||
|
||||
/// Collapses the dialog to its reference box `key` and starts picking into it, as the box's
|
||||
/// collapse button does.
|
||||
pub fn collapse_to(&mut self, app: &SheetApp, key: &str) {
|
||||
ref_edit::begin_pick(app, self, key);
|
||||
self.refs.collapsed = Some(key.to_string());
|
||||
}
|
||||
|
||||
/// Ends picking into the dialog's reference boxes and puts back the sheet and selection from
|
||||
/// before picking began, as closing the dialog does. Fails when the dialog's sheet could not
|
||||
/// be shown again (a text-box draft on the picked sheet could not be saved): a dialog about
|
||||
/// to run its command must then stay open instead.
|
||||
pub(crate) fn end_pick(&mut self, app: &mut SheetApp) -> Result<(), String> {
|
||||
ref_edit::end_pick(app, self)
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// that sheet exists (the sheet [`Self::end_pick`] shows again); `None` otherwise.
|
||||
pub(crate) fn home_sheet_protected(&self, app: &SheetApp) -> Option<bool> {
|
||||
ref_edit::home_sheet_protected(app, self)
|
||||
}
|
||||
|
||||
/// Whether the sheet the dialog was opened on was deleted while its reference boxes picked:
|
||||
/// OK is then refused with [`SHEET_DELETED`], since references without a sheet name have
|
||||
/// nothing to refer to.
|
||||
pub(crate) fn home_sheet_deleted(&mut self, app: &SheetApp) -> bool {
|
||||
ref_edit::home_sheet_deleted(app, self)
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
@@ -187,6 +227,7 @@ impl Dialog {
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
refs: ref_edit::RefBoxes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +251,7 @@ impl Dialog {
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
refs: ref_edit::RefBoxes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, ref_edit};
|
||||
use super::show::{build_params, form_fields, ok_cancel};
|
||||
use super::{Dialog, Field, Json, Map, SheetApp, json, vec2};
|
||||
|
||||
pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing"));
|
||||
/// Data › What-If Analysis › Goal Seek. OK runs `data.goalSeek` here; Cancel clears `open`, as
|
||||
/// the shared buttons do, so cancelling is never taken for a confirmation.
|
||||
pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
egui::Grid::new("gs").show(ui, |ui| {
|
||||
ui.label("Set cell:");
|
||||
ui.text_edit_singleline(&mut set);
|
||||
let l = ui.label("Set cell:");
|
||||
ref_edit(app, ui, d, "set", RefLabel::By(l.id), 200.0);
|
||||
ui.end_row();
|
||||
ui.label("To value:");
|
||||
ui.text_edit_singleline(&mut to);
|
||||
let l = ui.label("To value:");
|
||||
let mut to = d.values.get("to").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(170.0)).labelled_by(l.id).changed() {
|
||||
d.values.insert("to".into(), json!(to));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("By changing cell:");
|
||||
ui.text_edit_singleline(&mut changing);
|
||||
let l = ui.label("By changing cell:");
|
||||
ref_edit(app, ui, d, "changing", RefLabel::By(l.id), 200.0);
|
||||
ui.end_row();
|
||||
});
|
||||
d.values.insert("set".into(), json!(set.clone()));
|
||||
d.values.insert("to".into(), json!(to.clone()));
|
||||
d.values.insert("changing".into(), json!(changing.clone()));
|
||||
let mut open = true;
|
||||
ok_cancel(ui, confirm, &mut open);
|
||||
let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let (set, to, changing) = (get("set"), get("to"), get("changing"));
|
||||
ok_cancel(ui, confirm, open);
|
||||
if *confirm && home_sheet_deleted(app, d) {
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
*confirm = false;
|
||||
}
|
||||
if *confirm && let Err(e) = end_pick(app, d) {
|
||||
// The dialog's sheet can't be shown again (a draft on the picked sheet could not be
|
||||
// saved): the cells would be read against the wrong sheet, so Goal Seek doesn't run.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
*confirm = false;
|
||||
}
|
||||
if *confirm {
|
||||
match app.run("data.goalSeek", json!({"set": set, "to": to.parse::<f64>().unwrap_or(0.0), "changing": changing})) {
|
||||
Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())),
|
||||
Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
if !open {
|
||||
*confirm = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
|
||||
@@ -36,8 +45,8 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
use Field::*;
|
||||
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
|
||||
let labels = || Check { key: "labels", label: "Labels in first row" };
|
||||
let range = || Text { key: "range", label: "Input Range:" };
|
||||
let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }];
|
||||
let range = || Ref { key: "range", label: "Input Range:" };
|
||||
let two = || vec![Ref { key: "range1", label: "Variable 1 Range:" }, Ref { key: "range2", label: "Variable 2 Range:" }];
|
||||
match tool {
|
||||
"descriptive" => vec![
|
||||
range(),
|
||||
@@ -50,7 +59,7 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
],
|
||||
"histogram" => vec![
|
||||
range(),
|
||||
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||||
Ref { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||||
labels(),
|
||||
Check { key: "pareto", label: "Pareto (sorted histogram)" },
|
||||
Check { key: "cumulative", label: "Cumulative Percentage" },
|
||||
@@ -69,8 +78,8 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
Text { key: "alpha", label: "Alpha:" },
|
||||
],
|
||||
"regression" => vec![
|
||||
Text { key: "inputY", label: "Input Y Range:" },
|
||||
Text { key: "inputX", label: "Input X Range:" },
|
||||
Ref { key: "inputY", label: "Input Y Range:" },
|
||||
Ref { key: "inputX", label: "Input X Range:" },
|
||||
labels(),
|
||||
Check { key: "constantZero", label: "Constant is Zero" },
|
||||
Text { key: "confidence", label: "Confidence Level (%):" },
|
||||
@@ -262,19 +271,36 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di
|
||||
// The tool's options scroll in a short window (the patterned Random Number Generation form
|
||||
// is taller than 600 points); the output options and OK below always stay in view.
|
||||
let room = (ui.ctx().content_rect().height() - TOOLPAK_CHROME).max(120.0);
|
||||
egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(ui, d));
|
||||
egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(app, ui, d));
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Output options").strong());
|
||||
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
|
||||
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(to == choice, text_label).clicked() {
|
||||
let r = ui.radio(to == choice, text_label);
|
||||
if r.clicked() {
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
// Another output option ends picking into Output Range, so a later selection on
|
||||
// the grid doesn't choose it again.
|
||||
if key != "output" && d.refs.target.as_deref() == Some("output") {
|
||||
d.refs.target = None;
|
||||
}
|
||||
}
|
||||
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
|
||||
d.values.insert(key.into(), json!(t));
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
if key == "output" {
|
||||
let before = d.values.get(key).cloned();
|
||||
let was_target = d.refs.target.as_deref() == Some(key);
|
||||
ref_edit(app, ui, d, key, RefLabel::By(r.id), 190.0);
|
||||
// Clicking into the box, typing or picking an output range chooses it; only a
|
||||
// change does, so an option chosen later stays chosen.
|
||||
if d.refs.target.as_deref() == Some(key) && (!was_target || d.values.get(key) != before.as_ref()) {
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
} else {
|
||||
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
|
||||
d.values.insert(key.into(), json!(t));
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -377,6 +403,23 @@ fn clear_solver_options(d: &mut Dialog) {
|
||||
d.values.remove("requireBounds");
|
||||
}
|
||||
|
||||
/// Before one of Solver's commands other than Solve runs from the form (Reset All, Load, Save,
|
||||
/// Replace): the dialog's sheet must be there and shown again, as `dialogs::show` requires for
|
||||
/// Solve, since the commands act on the active sheet and read references without a sheet name
|
||||
/// against it. Shows the reason and answers `false` when the command must not run: the sheet
|
||||
/// was deleted, or it cannot be shown again (a draft on the picked sheet could not be saved).
|
||||
fn at_home(app: &mut SheetApp, d: &mut Dialog) -> bool {
|
||||
if home_sheet_deleted(app, d) {
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = end_pick(app, d) {
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Save Model from the Solver Parameters form: writes the form's model down from `range`. A
|
||||
/// range that holds data is replaced only with `overwrite`; without it the form asks first.
|
||||
fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) {
|
||||
@@ -410,8 +453,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Set Objective:");
|
||||
edit(ui, d, "objective", 200.0, "Set Objective:");
|
||||
let l = ui.label("Set Objective:");
|
||||
ref_edit(app, ui, d, "objective", RefLabel::By(l.id), 230.0);
|
||||
});
|
||||
let goal = text(d, "goal");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -423,8 +466,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
edit(ui, d, "value", 80.0, "Value Of:");
|
||||
});
|
||||
ui.label("By Changing Variable Cells:");
|
||||
edit(ui, d, "variables", 420.0, "By Changing Variable Cells:");
|
||||
let l = ui.label("By Changing Variable Cells:");
|
||||
ref_edit(app, ui, d, "variables", RefLabel::By(l.id), 420.0);
|
||||
ui.label("Subject to the Constraints:");
|
||||
let list: Vec<String> = d
|
||||
.values
|
||||
@@ -443,7 +486,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference");
|
||||
ref_edit(app, ui, d, "newLhs", RefLabel::Named("Constraint Cell Reference"), 130.0);
|
||||
let op = text(d, "newOp");
|
||||
craft_ui::ComboBox::from_id_salt("solverOp")
|
||||
.selected_text(op.clone())
|
||||
@@ -458,7 +501,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
.response
|
||||
.accessible_name(lang.tr("Constraint Operator"));
|
||||
if !matches!(op.as_str(), "int" | "bin") {
|
||||
edit(ui, d, "newRhs", 110.0, "Constraint Value");
|
||||
ref_edit(app, ui, d, "newRhs", RefLabel::Named("Constraint Value"), 130.0);
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let lhs = text(d, "newLhs");
|
||||
@@ -513,7 +556,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Reset All").clicked() {
|
||||
if ui.button("Reset All").clicked() && at_home(app, d) {
|
||||
match app.run("data.solverReset", json!({})) {
|
||||
Ok(_) => {
|
||||
for (k, v) in [
|
||||
@@ -539,6 +582,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
}
|
||||
let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false);
|
||||
// Hiding the area ends picking into Model Range, as a box that is not drawn is no longer
|
||||
// picked into (`ref_edit::window_shown`).
|
||||
if ui.selectable_label(shown, "Load/Save").clicked() {
|
||||
d.values.insert("modelArea".into(), json!(!shown));
|
||||
}
|
||||
@@ -548,11 +593,11 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
});
|
||||
if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Model Range:");
|
||||
edit(ui, d, "modelRange", 140.0, "Model Range:");
|
||||
let l = ui.label("Model Range:");
|
||||
ref_edit(app, ui, d, "modelRange", RefLabel::By(l.id), 170.0);
|
||||
let range = text(d, "modelRange");
|
||||
let ready = !range.trim().is_empty();
|
||||
if ui.add_enabled(ready, egui::Button::new("Load")).clicked() {
|
||||
if ui.add_enabled(ready, egui::Button::new("Load")).clicked() && at_home(app, d) {
|
||||
d.values.remove("askModelOverwrite");
|
||||
match app.run("data.solverLoadModel", json!({"from": range.trim()})) {
|
||||
Ok(r) => {
|
||||
@@ -570,7 +615,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(ready, egui::Button::new("Save")).clicked() {
|
||||
if ui.add_enabled(ready, egui::Button::new("Save")).clicked() && at_home(app, d) {
|
||||
save_solver_model(app, d, &range, false);
|
||||
}
|
||||
});
|
||||
@@ -583,7 +628,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
} else if let Some(asked) = asked {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(format!("{asked} already holds data. Replace it with the model?"));
|
||||
if ui.button("Replace").clicked() {
|
||||
if ui.button("Replace").clicked() && at_home(app, d) {
|
||||
save_solver_model(app, d, &asked, true);
|
||||
}
|
||||
if ui.button("Don't Replace").clicked() {
|
||||
|
||||
@@ -150,15 +150,15 @@ pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec<Field> {
|
||||
fields
|
||||
}
|
||||
|
||||
pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
pub(super) fn data_validation_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
|
||||
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
|
||||
d.fields = data_validation_fields(kind, operator);
|
||||
form_fields(ui, d);
|
||||
form_fields(app, ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
pub(super) fn advanced_filter_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
use Field::*;
|
||||
let copy = d.values.get("action").and_then(Json::as_str) == Some("copy");
|
||||
d.fields = vec![
|
||||
@@ -174,7 +174,7 @@ pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &
|
||||
d.fields.push(Text { key: "copyTo", label: "Copy to:" });
|
||||
}
|
||||
d.fields.push(Check { key: "unique", label: "Unique rows only" });
|
||||
form_fields(ui, d);
|
||||
form_fields(app, ui, d);
|
||||
// Constrain the button row: the shared right-to-left layout otherwise
|
||||
// consumes the window's remaining height instead of fitting the buttons.
|
||||
ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open));
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Reference boxes (Excel's RefEdit): text fields that take a range by selection on the grid, with
|
||||
//! a button that collapses the dialog to the box while the person selects.
|
||||
|
||||
use gridcraft_engine::model::{Sheet, SheetUid, Workbook};
|
||||
|
||||
use super::data::abs;
|
||||
use super::{Dialog, Icon, Json, SheetApp, json, theme, vec2};
|
||||
|
||||
/// What a dialog's reference boxes are doing: which one grid selections fill, and whether the
|
||||
/// dialog is collapsed to one of them.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct RefBoxes {
|
||||
/// The reference box that grid selections are written into.
|
||||
pub(super) target: Option<String>,
|
||||
/// The selection last written into the target (or present when picking began); only a
|
||||
/// different selection replaces what the box holds.
|
||||
seen: String,
|
||||
/// The sheet shown when `seen` was taken: changing sheets only refreshes `seen`, since the
|
||||
/// other sheet's own selection is not a pick.
|
||||
seen_sheet: String,
|
||||
/// The dialog's own sheet, from the first pick until the dialog closes, and the selection to
|
||||
/// put back when it runs its command or closes.
|
||||
home: Option<Home>,
|
||||
/// The target took a selection from the grid and takes the keyboard back once the pointer
|
||||
/// is released, so typing still edits the box.
|
||||
refocus: bool,
|
||||
/// The target box was drawn this frame. A box the dialog stops showing (another view of the
|
||||
/// dialog, a hidden area, a constraint operator that takes no value) is no longer picked
|
||||
/// into, so a selection made meanwhile does not land in it once it is shown again (see
|
||||
/// [`window_shown`]).
|
||||
target_drawn: bool,
|
||||
/// The reference box the dialog is collapsed to.
|
||||
pub(super) collapsed: Option<String>,
|
||||
/// Where the expanded dialog was last drawn: the collapsed dialog keeps its top-left corner,
|
||||
/// and the expanded one comes back where it was.
|
||||
rect: Option<egui::Rect>,
|
||||
/// The dialog was drawn collapsed last frame.
|
||||
was_collapsed: bool,
|
||||
/// A text field other than the collapsed box (the Name Box, say) owned the keyboard when
|
||||
/// the last frame ended: its keys are its own, not the collapsed picker's (see
|
||||
/// [`place_window`]). Kept from the frame before because egui drops the focus for Escape
|
||||
/// before anything is drawn, so the frame of the Escape no longer says who had it.
|
||||
other_field: bool,
|
||||
}
|
||||
|
||||
/// Where picking began: the dialog's own sheet, kept for as long as the dialog is open (a failed
|
||||
/// OK leaves it open, and a later OK still needs to know whether that sheet is there), and the
|
||||
/// selection to put back once.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Home {
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]): fresh for every sheet added,
|
||||
/// inserted, copied or read, and kept through renames, moves, undo and redo. The sheet is
|
||||
/// found by it whatever happened meanwhile ([`home_sheet`]): a sheet given its name or a
|
||||
/// copy of it is never taken for it, and an undo that brings it back makes it the dialog's
|
||||
/// again.
|
||||
uid: SheetUid,
|
||||
/// The ranges and active cell from before picking began on the dialog's sheet, put back once
|
||||
/// when the dialog runs its command or closes.
|
||||
selection: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// The dialog's own sheet in `wb`, or `None` once it was deleted.
|
||||
fn home_sheet<'a>(wb: &'a Workbook, home: &Home) -> Option<&'a Sheet> {
|
||||
wb.sheets.iter().map(|s| &**s).find(|s| s.uid.get() == home.uid.get())
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// that sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
|
||||
pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
|
||||
let home = d.refs.home.as_ref()?;
|
||||
app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(Sheet::is_protected)
|
||||
}
|
||||
|
||||
/// The message OK shows once the dialog's own sheet is gone.
|
||||
pub(crate) const SHEET_DELETED: &str =
|
||||
"The sheet this dialog was opened on was deleted. Cancel, then open the dialog again on the sheet it should use.";
|
||||
|
||||
/// Whether the sheet the dialog was opened on has been deleted while it picked: its references
|
||||
/// without a sheet name have nothing to refer to, so OK is refused with [`SHEET_DELETED`].
|
||||
pub(super) fn home_sheet_deleted(app: &SheetApp, d: &mut Dialog) -> bool {
|
||||
d.refs.home.as_ref().is_some_and(|h| app.session.active().is_none_or(|doc| home_sheet(&doc.wb, h).is_none()))
|
||||
}
|
||||
|
||||
/// How a reference box is named for screen readers.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RefLabel<'a> {
|
||||
/// By the label drawn beside it.
|
||||
By(egui::Id),
|
||||
/// By an English name, translated (a box without a label of its own).
|
||||
Named(&'a str),
|
||||
}
|
||||
|
||||
/// The egui id of the reference box `key`, the same wherever the box is drawn.
|
||||
fn ref_edit_id(key: &str) -> egui::Id {
|
||||
egui::Id::unique("gridcraft.ref_edit").with(key)
|
||||
}
|
||||
|
||||
/// Whether the reference box `key` of dialog `dialog` takes several areas joined by commas.
|
||||
/// Solver's variable cells do; every other box takes one cell or range, which its command parses
|
||||
/// as a single reference.
|
||||
fn takes_several_areas(dialog: &str, key: &str) -> bool {
|
||||
dialog == "solver" && key == "variables"
|
||||
}
|
||||
|
||||
/// The grid selection as a reference, with the sheet name when the selection is on another sheet
|
||||
/// than the one the dialog was opened on: every area joined by commas when `several`, otherwise
|
||||
/// only the area being selected (the last one).
|
||||
fn selection_ref(app: &SheetApp, home: Option<&Home>, several: bool) -> String {
|
||||
let Some(doc) = app.session.active() else { return String::new() };
|
||||
let current = [doc.selection.current()];
|
||||
let areas = if several { doc.selection.ranges.as_slice() } else { ¤t[..] }.iter().map(abs);
|
||||
match doc.wb.active() {
|
||||
// Once the dialog's own sheet is deleted, every reference carries its sheet's name.
|
||||
Some(sh) if home.is_some_and(|h| h.uid.get() != sh.uid.get()) => {
|
||||
let sheet = gridcraft_engine::formula::quote_sheet(&sh.name);
|
||||
areas.map(|a| format!("{sheet}!{a}")).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
_ => areas.collect::<Vec<_>>().join(","),
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the dialog's window before it is drawn, and returns the reference box the dialog is
|
||||
/// collapsed to. Escape or Enter expands a collapsed dialog again, rather than closing it or
|
||||
/// pressing OK, and the arrow keys select on the grid, unless another text field (the Name Box)
|
||||
/// owns the keyboard: its keys stay its own, as they were before the dialog collapsed.
|
||||
/// Collapsed, the dialog shrinks to the box where its top-left corner was, leaving the sheet
|
||||
/// free for selecting; expanded again, it comes back where it was.
|
||||
pub(super) fn place_window<'a>(
|
||||
app: &mut SheetApp,
|
||||
ctx: &egui::Context,
|
||||
d: &mut Dialog,
|
||||
window: egui::Window<'a>,
|
||||
window_id: egui::Id,
|
||||
) -> (egui::Window<'a>, Option<String>) {
|
||||
if let Some(key) = d.refs.collapsed.as_deref() {
|
||||
let other_field = d.refs.other_field || other_field_focused(ctx, key);
|
||||
// Escape and Enter are used up either way: left in the frame's input, the dialog would
|
||||
// read them as its own and close or confirm. When another field owned them, that field
|
||||
// (drawn before the dialogs) has read them already, and nothing more happens here.
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) | i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
|
||||
if !other_field {
|
||||
d.refs.collapsed = None;
|
||||
}
|
||||
} else if !other_field {
|
||||
arrows_move_selection(app, ctx);
|
||||
}
|
||||
}
|
||||
let collapsed = d.refs.collapsed.clone();
|
||||
if collapsed.is_none()
|
||||
&& d.refs.was_collapsed
|
||||
&& let Some(rect) = d.refs.rect
|
||||
&& let Some(mut state) = egui::AreaState::load(ctx, window_id)
|
||||
{
|
||||
// The window's stored position wins over `current_pos` for a window moved by its title
|
||||
// bar, so the expanded position is written into its state.
|
||||
state.pivot = egui::Align2::CENTER_CENTER;
|
||||
state.pivot_pos = Some(rect.center());
|
||||
ctx.memory_mut(|m| m.areas_mut().set_state(egui::LayerId::new(egui::Order::Middle, window_id), state));
|
||||
}
|
||||
let window = match (&collapsed, d.refs.rect) {
|
||||
(Some(_), Some(rect)) => window.pivot(egui::Align2::LEFT_TOP).fixed_pos(rect.min),
|
||||
_ => window,
|
||||
};
|
||||
(window, collapsed)
|
||||
}
|
||||
|
||||
/// Whether a text field other than the reference box `key` has the keyboard.
|
||||
fn other_field_focused(ctx: &egui::Context, key: &str) -> bool {
|
||||
ctx.text_edit_focused() && ctx.memory(|m| m.focused()) != Some(ref_edit_id(key))
|
||||
}
|
||||
|
||||
/// While the dialog is collapsed, the arrow keys move the selection on the grid, Shift+arrow
|
||||
/// extends it and Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data, as they do with no
|
||||
/// dialog open: the box follows the selection as it does for the mouse. The keys are taken out
|
||||
/// of the frame's input before the box's text field reads them, so they don't move its caret
|
||||
/// instead; expanded, they edit the box.
|
||||
fn arrows_move_selection(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp};
|
||||
let mut moves = Vec::new();
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|e| match e {
|
||||
egui::Event::Key { key: key @ (ArrowUp | ArrowDown | ArrowLeft | ArrowRight), pressed: true, modifiers, .. } => {
|
||||
moves.push((*key, *modifiers));
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
});
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Arrows follow the screen: on a right-to-left sheet Left goes to the next column.
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.right_to_left);
|
||||
for (key, m) in moves {
|
||||
let (dr, dc) = match key {
|
||||
ArrowUp => (-1, 0),
|
||||
ArrowDown => (1, 0),
|
||||
ArrowLeft => (0, if rtl { 1 } else { -1 }),
|
||||
_ => (0, if rtl { -1 } else { 1 }),
|
||||
};
|
||||
let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active });
|
||||
let _ = app.run("selection.move", json!({"dr": dr, "dc": dc, "extend": m.shift, "jump": m.command}));
|
||||
app.grid.ensure_visible = true;
|
||||
app.grid.reveal_moving_end = m.shift;
|
||||
app.grid.reveal_from = from;
|
||||
}
|
||||
}
|
||||
|
||||
/// After the dialog's window is drawn: remembers where the expanded window is, and ends picking
|
||||
/// into a reference box when the box was not drawn (the dialog stopped showing it) or when
|
||||
/// another text field of the dialog takes the keyboard. A collapsed dialog shows no other field,
|
||||
/// so its box keeps picking whatever else is clicked (the Name Box, the formula bar, the
|
||||
/// ribbon): only Escape, Enter or the expand button end that state.
|
||||
pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool, rect: Option<egui::Rect>) {
|
||||
if !collapsed {
|
||||
d.refs.rect = rect;
|
||||
}
|
||||
d.refs.was_collapsed = collapsed;
|
||||
d.refs.other_field = d.refs.collapsed.as_deref().is_some_and(|key| other_field_focused(ctx, key));
|
||||
// The box compares the grid selection with the one it last saw only while it is drawn: a
|
||||
// hidden box would take a selection made meanwhile when it comes back.
|
||||
if !std::mem::take(&mut d.refs.target_drawn) {
|
||||
d.refs.target = None;
|
||||
}
|
||||
if !collapsed && ctx.text_edit_focused() && d.refs.target.as_deref().is_none_or(|key| !ctx.memory(|m| m.has_focus(ref_edit_id(key)))) {
|
||||
d.refs.target = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the reference box `key` the one grid selections fill. The first pick remembers the
|
||||
/// sheet and selection, which are put back when the dialog runs its command or closes.
|
||||
pub(super) fn begin_pick(app: &SheetApp, d: &mut Dialog, key: &str) {
|
||||
if let Some(doc) = app.session.active()
|
||||
&& let Some(sheet) = doc.wb.active()
|
||||
{
|
||||
let ranges: Vec<String> = doc.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
let selection = Some((ranges.join(","), doc.selection.active.a1()));
|
||||
match d.refs.home.as_mut() {
|
||||
None => d.refs.home = Some(Home { uid: sheet.uid, selection }),
|
||||
// Picking again after a failed OK: the selection to put back is the one now on the
|
||||
// dialog's sheet.
|
||||
Some(home) if home.selection.is_none() && home.uid.get() == sheet.uid.get() => {
|
||||
home.selection = selection;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
d.refs.target = Some(key.to_string());
|
||||
d.refs.refocus = false;
|
||||
d.refs.seen = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
d.refs.seen_sheet = active_sheet_name(app);
|
||||
}
|
||||
|
||||
/// The name of the sheet shown in the active workbook.
|
||||
fn active_sheet_name(app: &SheetApp) -> String {
|
||||
app.session.active().and_then(|doc| doc.wb.active()).map(|sh| sh.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Stops picking and goes back to the dialog's own sheet, putting back the selection from before
|
||||
/// picking began (once), unless that sheet was deleted: the selection is then left where it is.
|
||||
/// The sheet itself is kept until the dialog closes.
|
||||
///
|
||||
/// The commands go through [`SheetApp::run`], as the ribbon's do: showing the sheet first saves
|
||||
/// a text-box draft left open on the sheet picked on, and the UI's after-command pass runs.
|
||||
///
|
||||
/// Fails when the sheet could not be shown again (the draft could not be saved): the person
|
||||
/// stays with their text, the selection waits for the next time the dialog lets go, and a
|
||||
/// dialog about to run its command must not, since references without a sheet name would be
|
||||
/// read against the sheet still shown.
|
||||
pub(super) fn end_pick(app: &mut SheetApp, d: &mut Dialog) -> Result<(), String> {
|
||||
d.refs.target = None;
|
||||
d.refs.collapsed = None;
|
||||
let Some(home) = d.refs.home.as_mut() else { return Ok(()) };
|
||||
let Some(sheet) = app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(|s| s.name.clone()) else { return Ok(()) };
|
||||
// After an OK that failed, the selection is already back and only the sheet is shown again,
|
||||
// so references without a sheet name mean it.
|
||||
if !active_sheet_name(app).eq_ignore_ascii_case(&sheet) {
|
||||
app.run("sheet.activate", json!({"sheet": sheet}))?;
|
||||
}
|
||||
if let Some((range, active)) = home.selection.take() {
|
||||
let _ = app.run("selection.set", json!({"range": range, "active": active}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A reference box (RefEdit): a text field that the grid selection fills while it is the box
|
||||
/// being picked into, and a button that collapses the dialog to the box or expands it again.
|
||||
pub(super) fn ref_edit(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: RefLabel<'_>, width: f32) {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let collapsed = d.refs.collapsed.as_deref() == Some(key);
|
||||
ui.horizontal(|ui| {
|
||||
let id = ref_edit_id(key);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).id(id).desired_width(width - 30.0));
|
||||
let r = match label {
|
||||
RefLabel::By(label) => r.labelled_by(label),
|
||||
RefLabel::Named(name) => r.accessible_name(lang.tr(name)),
|
||||
};
|
||||
// F4 cycles the anchors of the reference in the box, as in Excel's reference boxes.
|
||||
if r.has_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F4)) {
|
||||
s = cycle_anchors(&s);
|
||||
}
|
||||
if r.gained_focus() {
|
||||
begin_pick(app, d, key);
|
||||
} else if d.refs.target.as_deref() == Some(key) {
|
||||
let sel = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
let sheet = active_sheet_name(app);
|
||||
if sheet != d.refs.seen_sheet {
|
||||
// Another sheet was shown: its selection is where that sheet was left, not a pick.
|
||||
d.refs.seen = sel;
|
||||
d.refs.seen_sheet = sheet;
|
||||
} else if sel != d.refs.seen {
|
||||
// The person selected on the grid: the selection becomes the reference.
|
||||
s = sel.clone();
|
||||
d.refs.seen = sel;
|
||||
d.refs.refocus = true;
|
||||
}
|
||||
if d.refs.refocus && !ui.input(|i| i.pointer.any_down()) {
|
||||
d.refs.refocus = false;
|
||||
r.request_focus();
|
||||
}
|
||||
}
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
let (icon, tip) = if collapsed { (Icon::Expand, "Expand dialog") } else { (Icon::Collapse, "Collapse dialog") };
|
||||
let b = crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0));
|
||||
let name = lang.tr(tip).to_string();
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
if b.clicked() {
|
||||
if collapsed {
|
||||
d.refs.collapsed = None;
|
||||
} else {
|
||||
d.collapse_to(app, key);
|
||||
}
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
});
|
||||
if d.refs.target.as_deref() == Some(key) {
|
||||
d.refs.target_drawn = true;
|
||||
}
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
|
||||
/// The anchors of one part of a reference (`$A$1`, `A`, `$3`): its column letters and row digits,
|
||||
/// and whether each carries a `$`.
|
||||
struct Anchors {
|
||||
letters: String,
|
||||
digits: String,
|
||||
col_abs: bool,
|
||||
row_abs: bool,
|
||||
}
|
||||
|
||||
/// Reads `part` as a cell (`A1`), a column (`A`) or a row (`3`) with its anchors: at most three
|
||||
/// letters and seven digits, nothing else. A `$` alone before the digits of a row anchors the row.
|
||||
fn anchors(part: &str) -> Option<Anchors> {
|
||||
let (col_abs, rest) = match part.strip_prefix('$') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, part),
|
||||
};
|
||||
let letters: String = rest.chars().take_while(char::is_ascii_alphabetic).collect();
|
||||
let rest = rest.get(letters.len()..)?;
|
||||
let (row_abs, rest) = match rest.strip_prefix('$') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, rest),
|
||||
};
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
if !rest.get(digits.len()..)?.is_empty() || letters.len() > 3 || digits.len() > 7 || (letters.is_empty() && digits.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
if letters.is_empty() {
|
||||
// `$3`: the one `$` belongs to the row; `$$3` is not a reference.
|
||||
if col_abs && row_abs {
|
||||
return None;
|
||||
}
|
||||
return Some(Anchors { letters, digits, col_abs: false, row_abs: col_abs || row_abs });
|
||||
}
|
||||
Some(Anchors { letters, digits, col_abs, row_abs })
|
||||
}
|
||||
|
||||
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
|
||||
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
|
||||
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
|
||||
/// quoted state, so a doubled one toggles it twice.
|
||||
fn split_areas(reference: &str) -> Vec<&str> {
|
||||
let mut areas = Vec::new();
|
||||
let (mut start, mut quoted) = (0, false);
|
||||
for (i, c) in reference.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
areas.push(reference.get(start..i).unwrap_or_default());
|
||||
start = i.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
areas.push(reference.get(start..).unwrap_or_default());
|
||||
areas
|
||||
}
|
||||
|
||||
/// Excel's F4 in a reference box: `A1` → `$A$1` → `A$1` → `$A1` → `A1`, every part of every
|
||||
/// area moving together from the first part's anchors (`A1:B2,D4` → `$A$1:$B$2,$D$4`), sheet
|
||||
/// names kept; whole columns and rows have one anchor each (`A:A` → `$A:$A` → `A:A`). Text that
|
||||
/// isn't a reference comes back as it is.
|
||||
pub(crate) fn cycle_anchors(reference: &str) -> String {
|
||||
let mut areas: Vec<(&str, Vec<Anchors>)> = Vec::new();
|
||||
for area in split_areas(reference) {
|
||||
let area = area.trim();
|
||||
let (prefix, body) = match area.rsplit_once('!') {
|
||||
Some((sheet, body)) => (area.get(..sheet.len() + 1).unwrap_or_default(), body),
|
||||
None => ("", area),
|
||||
};
|
||||
let parts: Vec<&str> = body.split(':').collect();
|
||||
if parts.len() > 2 {
|
||||
return reference.to_string();
|
||||
}
|
||||
let Some(parts) = parts.iter().map(|p| anchors(p)).collect::<Option<Vec<_>>>() else { return reference.to_string() };
|
||||
areas.push((prefix, parts));
|
||||
}
|
||||
let Some(first) = areas.first().and_then(|(_, parts)| parts.first()) else { return reference.to_string() };
|
||||
let (col_abs, row_abs) = if first.letters.is_empty() {
|
||||
(!first.row_abs, !first.row_abs)
|
||||
} else if first.digits.is_empty() {
|
||||
(!first.col_abs, !first.col_abs)
|
||||
} else {
|
||||
match (first.col_abs, first.row_abs) {
|
||||
(false, false) => (true, true),
|
||||
(true, true) => (false, true),
|
||||
(false, true) => (true, false),
|
||||
(true, false) => (false, false),
|
||||
}
|
||||
};
|
||||
let anchor = |on: bool, text: &str| if on && !text.is_empty() { format!("${text}") } else { text.to_string() };
|
||||
areas
|
||||
.iter()
|
||||
.map(|(prefix, parts)| {
|
||||
let body: Vec<String> = parts.iter().map(|a| format!("{}{}", anchor(col_abs, &a.letters), anchor(row_abs, &a.digits))).collect();
|
||||
format!("{prefix}{}", body.join(":"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cycle_anchors, split_areas};
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
|
||||
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
|
||||
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
|
||||
assert_eq!(split_areas(""), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_keeps_a_comma_inside_a_quoted_sheet_name() {
|
||||
assert_eq!(cycle_anchors("'Sales, West'!$A$1"), "'Sales, West'!A$1");
|
||||
assert_eq!(cycle_anchors("'O''Brien, Jr'!A1:B2,D4"), "'O''Brien, Jr'!$A$1:$B$2,$D$4");
|
||||
assert_eq!(cycle_anchors("B2:B4,D1"), "$B$2:$B$4,$D$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_cycles_cells_ranges_columns_and_rows() {
|
||||
assert_eq!(cycle_anchors("A1"), "$A$1");
|
||||
assert_eq!(cycle_anchors("$A$1"), "A$1");
|
||||
assert_eq!(cycle_anchors("A$1"), "$A1");
|
||||
assert_eq!(cycle_anchors("$A1"), "A1");
|
||||
assert_eq!(cycle_anchors("A1:B2"), "$A$1:$B$2");
|
||||
assert_eq!(cycle_anchors("$A$1:$B$2"), "A$1:B$2");
|
||||
// Every area follows the first part, and sheet names stay as typed.
|
||||
assert_eq!(cycle_anchors("'O''Brien Rates'!A2:A4, D4"), "'O''Brien Rates'!$A$2:$A$4,$D$4");
|
||||
assert_eq!(cycle_anchors("Sheet1!$A$1:B2"), "Sheet1!A$1:B$2");
|
||||
assert_eq!(cycle_anchors("A:A"), "$A:$A");
|
||||
assert_eq!(cycle_anchors("$A:$A"), "A:A");
|
||||
assert_eq!(cycle_anchors("1:3"), "$1:$3");
|
||||
assert_eq!(cycle_anchors("$1:$3"), "1:3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_leaves_text_that_is_not_a_reference() {
|
||||
for text in ["", "hello", "A1:hello", "ABCD1", "A12345678", "$$1", "$$A1", "A1:B2:C3", "Sheet1!", "A1,", "=A1"] {
|
||||
assert_eq!(cycle_anchors(text), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use super::format_cells::format_cells;
|
||||
use super::insert_function::insert_function;
|
||||
use super::names::{name_manager, paste_name};
|
||||
use super::paste::{paste_special, paste_special_shortcuts};
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, place_window, ref_edit, window_shown};
|
||||
use super::spelling::spelling;
|
||||
use super::zoom::zoom;
|
||||
use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2};
|
||||
@@ -157,8 +158,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"pasteSpecial" => 480.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window_id = egui::Id::unique("dialog").with(&d.name);
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
.id(egui::Id::unique("dialog").with(&d.name))
|
||||
.id(window_id)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
@@ -171,12 +173,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"advancedFilter" => window.default_height(0.0),
|
||||
_ => window,
|
||||
};
|
||||
window.show(ctx, |ui| {
|
||||
let (window, collapsed) = place_window(app, ctx, &mut d, window, window_id);
|
||||
let shown = window.show(ctx, |ui| {
|
||||
if let Some(key) = &collapsed {
|
||||
let title = d.title.clone();
|
||||
ref_edit(app, ui, &mut d, key, RefLabel::Named(&title), (width - 40.0).min(380.0));
|
||||
return;
|
||||
}
|
||||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"dataValidation" => data_validation_form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"advancedFilter" => advanced_filter_form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
|
||||
"find" => find(app, ui, &mut d),
|
||||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||||
@@ -188,7 +196,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"equation" => crate::equation::editor(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||||
@@ -365,9 +373,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
_ => form(ui, &mut d, &mut confirm, &mut open),
|
||||
_ => form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
}
|
||||
});
|
||||
window_shown(ctx, &mut d, collapsed.is_some(), shown.map(|r| r.response.rect));
|
||||
// Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since
|
||||
// the native shell has no browser integration of its own.
|
||||
if let Some(open_url) = &app.services.open_url {
|
||||
@@ -398,6 +407,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) {
|
||||
// References without a sheet name would land on whichever sheet is shown now.
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Err(e) = end_pick(app, &mut d) {
|
||||
// References without a sheet name mean the sheet the dialog was opened on: when it can't
|
||||
// be shown again (a text-box draft on the picked sheet could not be saved), the command
|
||||
// would read them against the wrong sheet, so it doesn't run and the dialog stays.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Some(cmd) = d.command {
|
||||
let params = build_params(&d);
|
||||
match app.run(cmd, params) {
|
||||
@@ -424,6 +445,19 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
} else if confirm {
|
||||
open = false;
|
||||
}
|
||||
if !open
|
||||
&& !confirm
|
||||
&& let Err(e) = end_pick(app, &mut d)
|
||||
{
|
||||
// Cancelled or closed: back to the sheet and selection from before picking. A confirmed
|
||||
// OK went back before its command ran, and stays where the command left things (Data
|
||||
// Analysis shows the report sheet it added). When the dialog's sheet can't be shown
|
||||
// again (a text-box draft on the picked sheet could not be saved), Cancel still closes
|
||||
// the dialog, since there is no command left to protect: the reason is shown, and the
|
||||
// draft and the selection stay where they are for the person to deal with. A script's
|
||||
// `ui.dialog.cancel` gets the error instead and keeps the dialog, so it can act on it.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
}
|
||||
if app.ui_window() != home {
|
||||
// A command run from the dialog switched windows: whatever it opened shows in the new
|
||||
// window, and this dialog (or the one it returns to) stays in its own.
|
||||
@@ -470,8 +504,8 @@ pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool)
|
||||
});
|
||||
}
|
||||
|
||||
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
form_fields(ui, d);
|
||||
fn form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
form_fields(app, ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
@@ -517,7 +551,7 @@ fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
pub(super) fn form_fields(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Field labels, choices and notes are English source strings, translated as they are drawn.
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
@@ -546,6 +580,12 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Field::Ref { key, label } => {
|
||||
ui.vertical(|ui| {
|
||||
let l = ui.label(lang.tr(label));
|
||||
ref_edit(app, ui, d, key, RefLabel::By(l.id), 320.0);
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||||
ui.vertical(|ui| {
|
||||
|
||||
@@ -13,7 +13,19 @@ pub struct EditState {
|
||||
/// The window it was typed in: switching to another window, even on the same workbook,
|
||||
/// commits it there first (`None` until the app sets it).
|
||||
pub window: Option<u64>,
|
||||
/// The sheet's index when the draft was last resolved; [`EditState::sheet_in`] finds it again
|
||||
/// by `sheet_uid` after sheets are inserted, deleted or moved.
|
||||
pub sheet: usize,
|
||||
/// The sheet's identity while the app runs (`model::SheetUid`), recorded when the draft is
|
||||
/// created ([`EditState::on_sheet`]); 0 (never given out) when none was.
|
||||
pub sheet_uid: u64,
|
||||
/// The draft began on a Python cell, with its code as the text. Kept so the code stays out of
|
||||
/// view once the cell can't be looked at, its sheet gone.
|
||||
pub py_cell: bool,
|
||||
/// The order of the workbook's sheets ([`sheet_order`]) when the draft was last resolved: a
|
||||
/// change after a command points the draft at its sheet again and lets go of
|
||||
/// `point_anchor`, which holds a sheet index.
|
||||
pub sheet_order: u64,
|
||||
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
|
||||
pub locale: gridcraft_engine::formula::FormulaLocale,
|
||||
pub cell: CellRef,
|
||||
@@ -47,6 +59,15 @@ pub struct EditState {
|
||||
/// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone
|
||||
/// (no undo step, the workbook stays clean). `None` for an edit started by typing.
|
||||
pub original: Option<String>,
|
||||
/// Python mode: the text is a Python cell's code, edited in the formula bar's code editor and
|
||||
/// committed with Ctrl+Enter (Cmd+Enter on macOS) through `python.set`.
|
||||
pub python: bool,
|
||||
}
|
||||
|
||||
/// A digest of the order of `wb`'s sheets by identity: it changes when a sheet is inserted,
|
||||
/// deleted or moved, not when one is renamed or edited.
|
||||
pub fn sheet_order(wb: &gridcraft_engine::model::Workbook) -> u64 {
|
||||
wb.sheets.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, sh| (h ^ sh.uid.get()).wrapping_mul(0x0000_0100_0000_01b3))
|
||||
}
|
||||
|
||||
impl EditState {
|
||||
@@ -57,6 +78,9 @@ impl EditState {
|
||||
doc,
|
||||
window: None,
|
||||
sheet,
|
||||
sheet_uid: 0,
|
||||
py_cell: false,
|
||||
sheet_order: 0,
|
||||
locale: Default::default(),
|
||||
cell,
|
||||
text,
|
||||
@@ -75,13 +99,79 @@ impl EditState {
|
||||
caret_to_end: true,
|
||||
completion: None,
|
||||
original,
|
||||
python: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit of a cell's Python code, in the formula bar.
|
||||
pub fn python(doc: u64, sheet: usize, cell: CellRef, code: String) -> EditState {
|
||||
let mut ed = EditState::new(doc, sheet, cell, code, false, true);
|
||||
ed.python = true;
|
||||
ed
|
||||
}
|
||||
|
||||
/// Records the identity of the draft's sheet, from the workbook it is typed in.
|
||||
pub fn on_sheet(mut self, wb: &gridcraft_engine::model::Workbook) -> EditState {
|
||||
self.sheet_uid = wb.sheet(self.sheet).map_or(0, |sh| sh.uid.get());
|
||||
self.sheet_order = sheet_order(wb);
|
||||
self
|
||||
}
|
||||
|
||||
/// The draft's sheet's index now, found by its identity, since commands insert, delete and
|
||||
/// move sheets under an open draft; `None` once the sheet is gone (or none was recorded).
|
||||
pub fn sheet_in(&self, wb: &gridcraft_engine::model::Workbook) -> Option<usize> {
|
||||
if self.sheet_uid == 0 {
|
||||
return None;
|
||||
}
|
||||
if wb.sheet(self.sheet).is_some_and(|sh| sh.uid.get() == self.sheet_uid) {
|
||||
return Some(self.sheet);
|
||||
}
|
||||
wb.sheets.iter().position(|sh| sh.uid.get() == self.sheet_uid)
|
||||
}
|
||||
|
||||
/// Whether the text is the `=PY` that Tab turns into Python mode, as in Excel (`=PY(` too,
|
||||
/// in any letter case).
|
||||
pub fn is_py_trigger(&self) -> bool {
|
||||
let t = self.text.trim();
|
||||
!self.python && (t.eq_ignore_ascii_case("=PY") || t.eq_ignore_ascii_case("=PY("))
|
||||
}
|
||||
|
||||
/// Switches the edit to Python mode, in the formula bar. The `=PY` trigger becomes empty code;
|
||||
/// other text is kept as the code, so the shortcut pressed mid-edit loses nothing.
|
||||
pub fn enter_python(&mut self) {
|
||||
if self.is_py_trigger() {
|
||||
self.text.clear();
|
||||
}
|
||||
self.python = true;
|
||||
self.from_formula_bar = true;
|
||||
self.enter_mode = false;
|
||||
self.point = None;
|
||||
self.point_cell = None;
|
||||
self.point_anchor = None;
|
||||
self.autocomplete.clear();
|
||||
self.completion = None;
|
||||
self.caret = self.text.chars().count();
|
||||
self.anchor = self.caret;
|
||||
self.caret_to_end = false;
|
||||
self.request_focus = true;
|
||||
self.sync_caret = true;
|
||||
}
|
||||
|
||||
/// Leaves Python mode: the edit goes on as an ordinary entry of the same text.
|
||||
pub fn leave_python(&mut self) {
|
||||
self.python = false;
|
||||
self.caret = self.text.chars().count();
|
||||
self.anchor = self.caret;
|
||||
self.request_focus = true;
|
||||
self.sync_caret = true;
|
||||
}
|
||||
|
||||
/// Whether the text is a formula (never in Python mode: there it is code).
|
||||
pub fn is_formula(&self) -> bool {
|
||||
self.text.starts_with('=')
|
||||
|| (self.text.starts_with('+') && self.text.len() > 1)
|
||||
|| (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit()))
|
||||
!self.python
|
||||
&& (self.text.starts_with('=')
|
||||
|| (self.text.starts_with('+') && self.text.len() > 1)
|
||||
|| (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit())))
|
||||
}
|
||||
|
||||
fn byte_caret(&self) -> usize {
|
||||
@@ -157,7 +247,7 @@ impl EditState {
|
||||
/// Updates function autocomplete for the word before the caret.
|
||||
pub fn update_autocomplete(&mut self, names: &[String]) {
|
||||
self.autocomplete.clear();
|
||||
if !self.is_formula() {
|
||||
if self.python || !self.is_formula() {
|
||||
return;
|
||||
}
|
||||
let before = self.text.get(..self.byte_caret()).unwrap_or("");
|
||||
@@ -231,7 +321,7 @@ impl EditState {
|
||||
|
||||
/// The function call the caret is inside and the argument index (for the hint tooltip).
|
||||
pub fn current_function(&self) -> Option<(String, usize)> {
|
||||
if !self.is_formula() {
|
||||
if self.python || !self.is_formula() {
|
||||
return None;
|
||||
}
|
||||
let before = self.text.get(..self.byte_caret())?;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget.
|
||||
//! The formula bar (Name Box, ✕ ✓ fx, the PY badge, formula field) and the shared cell editor
|
||||
//! widget. In Python mode the field is the code editor.
|
||||
|
||||
use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::code_editor::{self, FieldOptions, Lang};
|
||||
use crate::editor::formula_refs;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -100,6 +102,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
layout.buttons = cancel.rect.union(fx.rect);
|
||||
if let Some(badge) = python_badge(app, ui, &t) {
|
||||
layout.buttons = layout.buttons.union(badge);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// The formula field fills the rest, as tall as the bar.
|
||||
let avail = ui.available_width() - 24.0;
|
||||
@@ -114,10 +119,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let info = if app.editor.is_some() {
|
||||
editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height())
|
||||
} else {
|
||||
// Show the active cell's content; clicking starts editing in the bar.
|
||||
// Show the active cell's content (a Python cell's code in the code editor's
|
||||
// colours); clicking starts editing in the bar.
|
||||
let text = active_input(app);
|
||||
let color = if read_only { t.text_disabled } else { t.text };
|
||||
let galley = child.painter().layout(text, font.clone(), color, inner.width());
|
||||
let galley = if active_python(app).is_some() && !read_only {
|
||||
let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width());
|
||||
child.fonts_mut(|f| f.layout_job(job))
|
||||
} else {
|
||||
child.painter().layout(text, font.clone(), color, inner.width())
|
||||
};
|
||||
let lines = galley.rows.len();
|
||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
||||
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col));
|
||||
@@ -156,6 +167,16 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co
|
||||
sh.spill_anchor(at)
|
||||
}
|
||||
|
||||
/// The active cell's Python cell (itself, or the anchor whose result spills over it): its sheet,
|
||||
/// cell and code.
|
||||
pub(crate) fn active_python(app: &SheetApp) -> Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc<gridcraft_engine::model::PyCode>)> {
|
||||
let d = app.session.active()?;
|
||||
let sh = d.wb.active()?;
|
||||
let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
let at = sh.spill_anchor(a).unwrap_or(a);
|
||||
sh.cell(at).and_then(|c| c.py.clone()).map(|py| (d.wb.active_sheet(), at, py))
|
||||
}
|
||||
|
||||
fn active_input(app: &SheetApp) -> String {
|
||||
let Some(d) = app.session.active() else { return String::new() };
|
||||
let Some(sh) = d.wb.active() else { return String::new() };
|
||||
@@ -163,6 +184,14 @@ fn active_input(app: &SheetApp) -> String {
|
||||
if let Some(picture) = sh.cell_pictures.get(&a) {
|
||||
return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) };
|
||||
}
|
||||
// Python code is shown as it is: it isn't a formula to translate or a value to format. Like
|
||||
// a formula, it stays out of view in a Hidden cell on a protected sheet.
|
||||
if let Some((_, at, py)) = active_python(app) {
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return String::new();
|
||||
}
|
||||
return py.code.clone();
|
||||
}
|
||||
if let Some(anchor) = sh.spill_anchor(a) {
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) {
|
||||
return String::new();
|
||||
@@ -173,7 +202,8 @@ fn active_input(app: &SheetApp) -> String {
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if let Some(c) = sh.cell(a) {
|
||||
if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
|
||||
// A Hidden cell on a protected sheet shows neither its formula nor its Python code.
|
||||
if (c.formula.is_some() || c.py.is_some()) && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
|
||||
return String::new();
|
||||
}
|
||||
return if let Some(f) = &c.formula {
|
||||
@@ -187,6 +217,66 @@ fn active_input(app: &SheetApp) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+Shift+P, and Cmd+Option+Shift+P on macOS: Python mode on or off.
|
||||
pub fn is_python_shortcut(ev: &egui::Event) -> bool {
|
||||
matches!(ev, egui::Event::Key { key: Key::P, pressed: true, modifiers: m, .. } if m.shift && m.alt && (m.ctrl || m.command || m.mac_cmd))
|
||||
}
|
||||
|
||||
/// Takes Ctrl+Alt+Shift+P out of this pass's input; whether it was there.
|
||||
fn take_python_shortcut(i: &mut egui::InputState) -> bool {
|
||||
let found = i.events.iter().any(is_python_shortcut);
|
||||
if found {
|
||||
i.events.retain(|e| !is_python_shortcut(e));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// What a key in Python mode does, besides editing the code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PythonKey {
|
||||
/// Ctrl+Enter (Cmd+Enter on macOS): put the code in the cell.
|
||||
Commit,
|
||||
/// Esc: cancel the edit.
|
||||
Cancel,
|
||||
/// Ctrl+Alt+Shift+P: leave Python mode.
|
||||
Leave,
|
||||
}
|
||||
|
||||
fn python_key(ev: &egui::Event) -> Option<PythonKey> {
|
||||
match ev {
|
||||
egui::Event::Key { key: Key::Enter, pressed: true, modifiers: m, .. } if (m.ctrl || m.command || m.mac_cmd) && !m.alt => {
|
||||
Some(PythonKey::Commit)
|
||||
}
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, .. } => Some(PythonKey::Cancel),
|
||||
_ if is_python_shortcut(ev) => Some(PythonKey::Leave),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The PY badge beside fx: shown in Python mode and on a selected Python cell. Clicking it
|
||||
/// switches Python mode, as Ctrl+Alt+Shift+P does. Returns its rect when shown.
|
||||
fn python_badge(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Option<Rect> {
|
||||
let show = match &app.editor {
|
||||
Some(ed) => ed.python,
|
||||
None => active_python(app).is_some(),
|
||||
};
|
||||
if !show {
|
||||
return None;
|
||||
}
|
||||
let text = ui.painter().layout_no_wrap("PY".into(), theme::ui_bold(11.0), t.on_badge);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(text.size().x + 12.0, 24.0), Sense::click());
|
||||
let pill = Rect::from_center_size(rect.center(), vec2(rect.width(), 18.0));
|
||||
ui.painter().rect_filled(pill, 4.0, t.badge_py);
|
||||
ui.painter().galley(pill.center() - text.size() / 2.0, text, t.on_badge);
|
||||
let label = app.ui.language.tr("Python Mode").to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
let resp = resp.on_hover_text(format!("{label} (Ctrl+Alt+Shift+P)"));
|
||||
if resp.clicked() {
|
||||
app.toggle_python_mode();
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down.
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
||||
let id = egui::Id::unique("gridcraft.name_box");
|
||||
@@ -291,12 +381,19 @@ fn show_edit<'t>(
|
||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows
|
||||
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
|
||||
if app.editor.as_ref().is_some_and(|e| e.python) {
|
||||
// Python mode edits in the formula bar only.
|
||||
if id != egui::Id::unique("gridcraft.formula_bar") {
|
||||
return EditorInfo::default();
|
||||
}
|
||||
return python_editor(app, ui, id, width, max_height);
|
||||
}
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar"));
|
||||
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none()));
|
||||
let EditorKeyOutcome { action, arrow, cancel } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus);
|
||||
let EditorKeyOutcome { action, arrow, cancel, to_python } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus);
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -389,8 +486,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
}
|
||||
if resp.changed() {
|
||||
ed.completion = None;
|
||||
// Python code is never completed from the column's text values.
|
||||
if app.ui.autocomplete
|
||||
&& !ed.is_formula()
|
||||
&& !ed.python
|
||||
&& !entry_shrank(chars_before, &ed.text)
|
||||
&& ed.caret == ed.text.chars().count()
|
||||
&& let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet))
|
||||
@@ -419,6 +518,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine });
|
||||
let lines = output.galley.rows.len().max(1);
|
||||
let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2()));
|
||||
if to_python && !app.draft_in_spill(&ed) {
|
||||
ed.enter_python();
|
||||
app.editor = Some(ed);
|
||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||
return EditorInfo { lines, caret };
|
||||
}
|
||||
app.editor = Some(ed);
|
||||
if cancel {
|
||||
app.cancel_edit();
|
||||
@@ -438,6 +543,8 @@ struct EditorKeyOutcome {
|
||||
action: Option<EditorAction>,
|
||||
arrow: bool,
|
||||
cancel: bool,
|
||||
/// Ctrl+Alt+Shift+P, or `=PY` then Tab: the edit turns into Python code.
|
||||
to_python: bool,
|
||||
}
|
||||
|
||||
fn editor_shortcuts(
|
||||
@@ -448,14 +555,17 @@ fn editor_shortcuts(
|
||||
mine: bool,
|
||||
has_focus: bool,
|
||||
) -> EditorKeyOutcome {
|
||||
let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false };
|
||||
let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false, to_python: false };
|
||||
// A message or dialog owns Enter and Escape until it closes.
|
||||
if !has_focus || !mine || app.message.is_some() || app.dialog.is_some() {
|
||||
return outcome;
|
||||
}
|
||||
let (action, cancel) = editor_confirm_keys(ui, ed);
|
||||
// Ctrl+Alt+Shift+P turns the edit into Python code.
|
||||
outcome.to_python = ui.input_mut(take_python_shortcut);
|
||||
let (action, cancel, py_trigger) = editor_confirm_keys(ui, ed);
|
||||
outcome.action = action;
|
||||
outcome.cancel = cancel;
|
||||
outcome.to_python |= py_trigger;
|
||||
let (action, arrow) = editor_arrow_keys(app, ui, ed, outcome.action);
|
||||
outcome.action = action;
|
||||
outcome.arrow = arrow;
|
||||
@@ -463,7 +573,9 @@ fn editor_shortcuts(
|
||||
outcome
|
||||
}
|
||||
|
||||
fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option<EditorAction>, bool) {
|
||||
/// Enter, Tab, Esc and their variants: the move to make, whether the edit is cancelled, and
|
||||
/// whether `=PY` then Tab starts Python mode.
|
||||
fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option<EditorAction>, bool, bool) {
|
||||
let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| {
|
||||
let m = i.modifiers;
|
||||
let alt_enter = i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter);
|
||||
@@ -482,7 +594,11 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) ->
|
||||
ed.caret_to_end = false;
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
let mut action = if !ed.autocomplete.is_empty() && tab {
|
||||
// `=PY` then Tab starts Python mode, as in Excel.
|
||||
let py_trigger = tab && !shift && ed.is_py_trigger();
|
||||
let mut action = if py_trigger {
|
||||
None
|
||||
} else if !ed.autocomplete.is_empty() && tab {
|
||||
ed.accept_autocomplete();
|
||||
None
|
||||
} else if enter {
|
||||
@@ -509,7 +625,7 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) ->
|
||||
} else {
|
||||
true
|
||||
};
|
||||
(action, cancel)
|
||||
(action, cancel, py_trigger)
|
||||
}
|
||||
|
||||
fn editor_arrow_keys(
|
||||
@@ -603,6 +719,57 @@ fn editor_other_shortcuts(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, e
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula bar in Python mode: the code editor (monospace, Python colours). Its keys are
|
||||
/// applied in input order with the typing: Enter starts a new line with the indentation, Tab and
|
||||
/// Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits through `python.set`, Esc cancels and
|
||||
/// Ctrl+Alt+Shift+P leaves Python mode.
|
||||
fn python_editor(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, width: f32, max_height: f32) -> EditorInfo {
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
// A message box (the code was refused) or a dialog over the edit has the keyboard; the
|
||||
// editor takes it back when it closes.
|
||||
let blocked = app.message.is_some() || app.dialog.is_some();
|
||||
let mut caret = None;
|
||||
if blocked {
|
||||
ed.request_focus = true;
|
||||
} else {
|
||||
// Focus left on nothing (a click on the sheet's chrome, a closed message box whose modal
|
||||
// layer held it one more pass) comes back to the code.
|
||||
if std::mem::take(&mut ed.request_focus) || ui.memory(|m| m.focused()).is_none() {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
if std::mem::take(&mut ed.sync_caret) {
|
||||
caret = Some(ed.caret);
|
||||
}
|
||||
}
|
||||
let name = app.ui.language.tr("Formula Bar").to_string();
|
||||
let stop = |ev: &egui::Event| python_key(ev).is_some();
|
||||
let options = FieldOptions {
|
||||
width,
|
||||
max_height,
|
||||
interactive: !blocked,
|
||||
stop: &stop,
|
||||
keep_escape: true,
|
||||
caret,
|
||||
name: &name,
|
||||
..FieldOptions::new(Lang::Python)
|
||||
};
|
||||
let out = code_editor::code_field(ui, id, &mut ed.text, &options);
|
||||
if !blocked && caret.is_none() {
|
||||
(ed.caret, ed.anchor) = out.selection;
|
||||
}
|
||||
let info = EditorInfo { lines: out.lines, caret: out.caret };
|
||||
app.editor = Some(ed);
|
||||
match out.stop.as_ref().and_then(python_key) {
|
||||
Some(PythonKey::Commit) => {
|
||||
app.commit_python_edit();
|
||||
}
|
||||
Some(PythonKey::Cancel) => app.cancel_edit(),
|
||||
Some(PythonKey::Leave) => app.toggle_python_mode(),
|
||||
None => {}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||
let caret_b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len());
|
||||
for (a, b, ..) in formula_refs(&ed.text) {
|
||||
@@ -753,6 +920,23 @@ mod spill_tests {
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code() {
|
||||
let mut app = app();
|
||||
app.run("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap();
|
||||
app.run("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap();
|
||||
set(&mut app, "C1", "=1+1");
|
||||
app.run("home.formatCells", json!({"range": "A1:C1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
app.run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true, "hidden": false}}})).unwrap();
|
||||
select(&mut app, "A1");
|
||||
assert_eq!(active_input(&app), "secret = 1\nsecret", "an unprotected sheet hides nothing");
|
||||
app.run("review.protectSheet", json!({})).unwrap();
|
||||
for (cell, text) in [("A1", ""), ("B1", "'shown'"), ("C1", "")] {
|
||||
select(&mut app, cell);
|
||||
assert_eq!(active_input(&app), text, "{cell}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
/// A disabled spill formula paints disabled and does not edit; clicking an active formula starts formula-bar editing in both light and dark themes, with accessibility checks.
|
||||
fn formula_bar_click_and_theme_presentation() {
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Kontrola chyb",
|
||||
"Trace Error" => "Sledovat chybu",
|
||||
"Circular References" => "Cyklické odkazy",
|
||||
"Python Mode" => "Režim Pythonu",
|
||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||
"Watch Window" => "Okno sledování",
|
||||
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
||||
@@ -570,6 +571,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Zastavit",
|
||||
"New Macro" => "Nové makro",
|
||||
"Show" => "Zobrazit",
|
||||
"Collapse dialog" => "Sbalit dialogové okno",
|
||||
"Expand dialog" => "Rozbalit dialogové okno",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Fehlerüberprüfung",
|
||||
"Trace Error" => "Spur zum Fehler",
|
||||
"Circular References" => "Zirkelbezüge",
|
||||
"Python Mode" => "Python-Modus",
|
||||
"Evaluate Formula" => "Formel auswerten",
|
||||
"Watch Window" => "Überwachungsfenster",
|
||||
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
||||
@@ -486,6 +487,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Beenden",
|
||||
"New Macro" => "Neues Makro",
|
||||
"Show" => "Anzeigen",
|
||||
"Collapse dialog" => "Dialogfeld reduzieren",
|
||||
"Expand dialog" => "Dialogfeld erweitern",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Έλεγχος σφαλμάτων",
|
||||
"Trace Error" => "Εντοπισμός σφάλματος",
|
||||
"Circular References" => "Κυκλικές αναφορές",
|
||||
"Python Mode" => "Λειτουργία Python",
|
||||
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
||||
"Watch Window" => "Παράθυρο παρακολούθησης",
|
||||
"Calculation\nOptions" => "Επιλογές\nυπολογισμού",
|
||||
@@ -993,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Καταγραφή",
|
||||
"New Macro" => "Νέα μακροεντολή",
|
||||
"Show" => "Εμφάνιση",
|
||||
"Collapse dialog" => "Σύμπτυξη παραθύρου διαλόγου",
|
||||
"Expand dialog" => "Ανάπτυξη παραθύρου διαλόγου",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Comprobación de errores",
|
||||
"Trace Error" => "Rastrear error",
|
||||
"Circular References" => "Referencias circulares",
|
||||
"Python Mode" => "Modo Python",
|
||||
"Evaluate Formula" => "Evaluar fórmula",
|
||||
"Watch Window" => "Ventana Inspección",
|
||||
"Calculation\nOptions" => "Opciones\nde cálculo",
|
||||
@@ -492,6 +493,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Detener",
|
||||
"New Macro" => "Nueva macro",
|
||||
"Show" => "Mostrar",
|
||||
"Collapse dialog" => "Contraer diálogo",
|
||||
"Expand dialog" => "Expandir diálogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Vérification des erreurs",
|
||||
"Trace Error" => "Repérer une erreur",
|
||||
"Circular References" => "Références circulaires",
|
||||
"Python Mode" => "Mode Python",
|
||||
"Evaluate Formula" => "Évaluation de formule",
|
||||
"Watch Window" => "Fenêtre Espion",
|
||||
"Calculation\nOptions" => "Options de\ncalcul",
|
||||
@@ -464,6 +465,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Arrêter",
|
||||
"New Macro" => "Nouvelle macro",
|
||||
"Show" => "Afficher",
|
||||
"Collapse dialog" => "Réduire la boîte de dialogue",
|
||||
"Expand dialog" => "Développer la boîte de dialogue",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Controllo errori",
|
||||
"Trace Error" => "Individua errore",
|
||||
"Circular References" => "Riferimenti circolari",
|
||||
"Python Mode" => "Modalità Python",
|
||||
"Evaluate Formula" => "Valuta formula",
|
||||
"Watch Window" => "Finestra controlli",
|
||||
"Calculation\nOptions" => "Opzioni di\ncalcolo",
|
||||
@@ -996,6 +997,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Registrazione",
|
||||
"New Macro" => "Nuova macro",
|
||||
"Show" => "Mostra",
|
||||
"Collapse dialog" => "Comprimi finestra di dialogo",
|
||||
"Expand dialog" => "Espandi finestra di dialogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Error Checking", "エラーチェック"),
|
||||
("Trace Error", "エラーのトレース"),
|
||||
("Circular References", "循環参照"),
|
||||
("Python Mode", "Python モード"),
|
||||
("Evaluate Formula", "数式の検証"),
|
||||
("Watch Window", "ウォッチ ウィンドウ"),
|
||||
("Calculation\nOptions", "計算方法の設定"),
|
||||
@@ -1001,6 +1002,8 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
||||
("Tab Color", "シートの見出しの色"),
|
||||
("Unhide…", "再表示…"),
|
||||
("Unprotect Sheet…", "シート保護の解除…"),
|
||||
("Collapse dialog", "ダイアログを折りたたむ"),
|
||||
("Expand dialog", "ダイアログを展開する"),
|
||||
];
|
||||
|
||||
/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "오류 검사",
|
||||
"Trace Error" => "오류 추적",
|
||||
"Circular References" => "순환 참조",
|
||||
"Python Mode" => "Python 모드",
|
||||
"Evaluate Formula" => "수식 계산",
|
||||
"Watch Window" => "감시 창",
|
||||
"Calculation\nOptions" => "계산\n옵션",
|
||||
@@ -460,6 +461,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "중지",
|
||||
"New Macro" => "새 매크로",
|
||||
"Show" => "표시",
|
||||
"Collapse dialog" => "대화 상자 축소",
|
||||
"Expand dialog" => "대화 상자 확장",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Foutopsporing",
|
||||
"Trace Error" => "Fout traceren",
|
||||
"Circular References" => "Kringverwijzingen",
|
||||
"Python Mode" => "Python-modus",
|
||||
"Evaluate Formula" => "Formule evalueren",
|
||||
"Watch Window" => "Bewakingsvenster",
|
||||
"Calculation\nOptions" => "Berekenings-\nopties",
|
||||
@@ -539,6 +540,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Stoppen",
|
||||
"New Macro" => "Nieuwe macro",
|
||||
"Show" => "Weergeven",
|
||||
"Collapse dialog" => "Dialoogvenster samenvouwen",
|
||||
"Expand dialog" => "Dialoogvenster uitvouwen",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Sprawdzanie błędów",
|
||||
"Trace Error" => "Śledź błąd",
|
||||
"Circular References" => "Odwołania cykliczne",
|
||||
"Python Mode" => "Tryb Python",
|
||||
"Evaluate Formula" => "Szacuj formułę",
|
||||
"Watch Window" => "Okno czujki",
|
||||
"Calculation\nOptions" => "Opcje\nobliczania",
|
||||
@@ -987,6 +988,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Rejestrowanie",
|
||||
"New Macro" => "Nowe makro",
|
||||
"Show" => "Pokaż",
|
||||
"Collapse dialog" => "Zwiń okno dialogowe",
|
||||
"Expand dialog" => "Rozwiń okno dialogowe",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Verificação de Erros",
|
||||
"Trace Error" => "Rastrear Erro",
|
||||
"Circular References" => "Referências Circulares",
|
||||
"Python Mode" => "Modo Python",
|
||||
"Evaluate Formula" => "Avaliar Fórmula",
|
||||
"Watch Window" => "Janela de Inspeção",
|
||||
"Calculation\nOptions" => "Opções de\nCálculo",
|
||||
@@ -993,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Gravando",
|
||||
"New Macro" => "Nova Macro",
|
||||
"Show" => "Mostrar",
|
||||
"Collapse dialog" => "Recolher caixa de diálogo",
|
||||
"Expand dialog" => "Expandir caixa de diálogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Verificare erori",
|
||||
"Trace Error" => "Trasare eroare",
|
||||
"Circular References" => "Referințe circulare",
|
||||
"Python Mode" => "Mod Python",
|
||||
"Evaluate Formula" => "Evaluare formulă",
|
||||
"Watch Window" => "Fereastră de urmărire",
|
||||
"Calculation\nOptions" => "Opțiuni de\ncalcul",
|
||||
@@ -1049,6 +1050,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Înregistrare",
|
||||
"New Macro" => "Macrocomandă nouă",
|
||||
"Show" => "Afișare",
|
||||
"Collapse dialog" => "Restrângere casetă de dialog",
|
||||
"Expand dialog" => "Extindere casetă de dialog",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Проверка ошибок",
|
||||
"Trace Error" => "Источник ошибки",
|
||||
"Circular References" => "Циклические ссылки",
|
||||
"Python Mode" => "Режим Python",
|
||||
"Evaluate Formula" => "Вычислить формулу",
|
||||
"Watch Window" => "Окно контрольных значений",
|
||||
"Calculation\nOptions" => "Параметры\nвычислений",
|
||||
@@ -462,6 +463,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Остановить",
|
||||
"New Macro" => "Новый макрос",
|
||||
"Show" => "Показать",
|
||||
"Collapse dialog" => "Свернуть диалоговое окно",
|
||||
"Expand dialog" => "Развернуть диалоговое окно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Provera grešaka",
|
||||
"Trace Error" => "Prati grešku",
|
||||
"Circular References" => "Kružne reference",
|
||||
"Python Mode" => "Python režim",
|
||||
"Evaluate Formula" => "Proceni formulu",
|
||||
"Watch Window" => "Prozor za praćenje",
|
||||
"Calculation\nOptions" => "Opcije\nizračunavanja",
|
||||
@@ -459,6 +460,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Zaustavi",
|
||||
"New Macro" => "Novi makro",
|
||||
"Show" => "Prikaži",
|
||||
"Collapse dialog" => "Skupi dijalog",
|
||||
"Expand dialog" => "Proširi dijalog",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Hata Denetimi",
|
||||
"Trace Error" => "Hatayı İzle",
|
||||
"Circular References" => "Döngüsel Başvurular",
|
||||
"Python Mode" => "Python Modu",
|
||||
"Evaluate Formula" => "Formülü Değerlendir",
|
||||
"Watch Window" => "Gözcü Penceresi",
|
||||
"Calculation\nOptions" => "Hesaplama\nSeçenekleri",
|
||||
@@ -429,6 +430,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Durdur",
|
||||
"New Macro" => "Yeni Makro",
|
||||
"Show" => "Göster",
|
||||
"Collapse dialog" => "İletişim kutusunu daralt",
|
||||
"Expand dialog" => "İletişim kutusunu genişlet",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Перевірка помилок",
|
||||
"Trace Error" => "Джерело помилки",
|
||||
"Circular References" => "Циклічні посилання",
|
||||
"Python Mode" => "Режим Python",
|
||||
"Evaluate Formula" => "Обчислити формулу",
|
||||
"Watch Window" => "Окно контрольних значень",
|
||||
"Calculation\nOptions" => "Параметри\nобчислень",
|
||||
@@ -991,6 +992,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Запис",
|
||||
"New Macro" => "Новий макрос",
|
||||
"Show" => "Показати",
|
||||
"Collapse dialog" => "Згорнути діалогове вікно",
|
||||
"Expand dialog" => "Розгорнути діалогове вікно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Kiểm tra lỗi",
|
||||
"Trace Error" => "Truy tìm lỗi",
|
||||
"Circular References" => "Tham chiếu vòng",
|
||||
"Python Mode" => "Chế độ Python",
|
||||
"Evaluate Formula" => "Đánh giá công thức",
|
||||
"Watch Window" => "Cửa sổ theo dõi",
|
||||
"Calculation\nOptions" => "Tùy chọn\ntính toán",
|
||||
@@ -431,6 +432,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Dừng",
|
||||
"New Macro" => "Macro Mới",
|
||||
"Show" => "Hiện",
|
||||
"Collapse dialog" => "Thu gọn hộp thoại",
|
||||
"Expand dialog" => "Mở rộng hộp thoại",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "错误检查",
|
||||
"Trace Error" => "追踪错误",
|
||||
"Circular References" => "循环引用",
|
||||
"Python Mode" => "Python 模式",
|
||||
"Evaluate Formula" => "公式求值",
|
||||
"Watch Window" => "监视窗口",
|
||||
"Calculation\nOptions" => "计算\n选项",
|
||||
@@ -452,6 +453,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "停止",
|
||||
"New Macro" => "新建宏",
|
||||
"Show" => "显示",
|
||||
"Collapse dialog" => "折叠对话框",
|
||||
"Expand dialog" => "展开对话框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Shortcut {
|
||||
DropDown,
|
||||
/// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out.
|
||||
Zoom(i32),
|
||||
/// Ctrl+Alt+Shift+P: edit the active cell as Python code (Python mode).
|
||||
PythonMode,
|
||||
}
|
||||
|
||||
fn run(id: &'static str) -> Shortcut {
|
||||
@@ -219,6 +221,8 @@ fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
|
||||
Equals | Plus if ctrl_alt => Shortcut::Zoom(10),
|
||||
Minus if ctrl_alt && !mac => Shortcut::Zoom(-10),
|
||||
F9 if ctrl_alt => run("formulas.calculateNow"),
|
||||
// Python in cells (Cmd+Option on macOS too).
|
||||
P if ctrl_alt && s => Shortcut::PythonMode,
|
||||
R if ctrl_alt && mac => run("view.collapseRibbon"),
|
||||
Num0 if ctrl_alt && mac => borders("outside"),
|
||||
Minus if ctrl_alt && mac => borders("none"),
|
||||
@@ -342,6 +346,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
|
||||
app.grid.reveal_from = from;
|
||||
}
|
||||
Shortcut::Edit => app.begin_edit(None, false),
|
||||
Shortcut::PythonMode => app.toggle_python_mode(),
|
||||
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
|
||||
Shortcut::DropDown => {
|
||||
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) {
|
||||
);
|
||||
});
|
||||
sep(ui);
|
||||
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
|
||||
let protected = app.active_sheet_protected();
|
||||
let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false);
|
||||
control_keytip(app, ui, &__k, "dialog:protectSheet");
|
||||
if __k.clicked() {
|
||||
|
||||
@@ -114,6 +114,13 @@ pub struct Tokens {
|
||||
pub sheet_macro_footprint: Color32,
|
||||
pub sheet_macro_conflict: Color32,
|
||||
pub sheet_macro_target: Color32,
|
||||
/// The PY badge in the formula bar (Python mode) and its text.
|
||||
pub badge_py: Color32,
|
||||
pub on_badge: Color32,
|
||||
/// The PY tag on a Python cell and its text. They follow the sheet, so they are the same in
|
||||
/// both themes.
|
||||
pub sheet_py_tag: Color32,
|
||||
pub on_sheet_py_tag: Color32,
|
||||
/// Trace arrows on the sheet: precedents and dependents, and the links Trace Error follows
|
||||
/// between cells showing an error. Sheet colours.
|
||||
pub sheet_trace_arrow: Color32,
|
||||
@@ -172,6 +179,8 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
|
||||
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
|
||||
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
|
||||
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
|
||||
/// The PY tag on Python cells: a deep green, white text on it.
|
||||
const SHEET_PY_TAG: Color32 = rgb(0x1E6E4E);
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Tokens {
|
||||
@@ -251,6 +260,10 @@ impl Tokens {
|
||||
sheet_macro_footprint: SHEET_MACRO_FOOTPRINT,
|
||||
sheet_macro_conflict: SHEET_MACRO_CONFLICT,
|
||||
sheet_macro_target: SHEET_MACRO_TARGET,
|
||||
badge_py: pick(dark, SHEET_PY_TAG, rgb(0x5CBF94)),
|
||||
on_badge: pick(dark, Color32::WHITE, rgb(0x0B1F17)),
|
||||
sheet_py_tag: SHEET_PY_TAG,
|
||||
on_sheet_py_tag: Color32::WHITE,
|
||||
sheet_trace_arrow: SHEET_TRACE_ARROW,
|
||||
sheet_trace_error: SHEET_TRACE_ERROR,
|
||||
}
|
||||
@@ -412,6 +425,11 @@ mod contrast_tests {
|
||||
add("text on current step", t.text, t.accent_soft, TEXT);
|
||||
add("current step arrow", t.accent, t.accent_soft, GLYPH);
|
||||
add("error marker on window", t.danger, t.window, GLYPH);
|
||||
add("PY badge text", t.on_badge, t.badge_py, TEXT);
|
||||
add("PY badge on window", t.badge_py, t.window, GLYPH);
|
||||
add("PY badge on input", t.badge_py, t.input_bg, GLYPH);
|
||||
add("PY tag text", t.on_sheet_py_tag, t.sheet_py_tag, TEXT);
|
||||
add("PY tag on sheet", t.sheet_py_tag, t.grid_bg, GLYPH);
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ mod pivot_show_details_menu;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod protected_cell;
|
||||
mod python_cell_edit;
|
||||
mod python_hidden;
|
||||
mod python_mode;
|
||||
mod ref_edit;
|
||||
mod ribbon_layout;
|
||||
mod right_to_left;
|
||||
mod scroll_position;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Editing a Python cell's content (F2, the formula bar) edits its code, in Python mode; typing
|
||||
//! over it replaces it, as typing over a formula does.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "A1", "code": "1 + 1", "output": "object"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1200.0, 800.0)), crate::common::app(session), 4)
|
||||
}
|
||||
|
||||
fn text(h: &mut Harness, text: &str) {
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness, key: Key) {
|
||||
key_with(h, key, Modifiers::NONE);
|
||||
}
|
||||
|
||||
/// Ctrl+Enter (Cmd+Enter on macOS): commits Python mode, where Enter starts a new line.
|
||||
fn commit(h: &mut Harness) {
|
||||
key_with(h, Key::Enter, Modifiers::COMMAND);
|
||||
}
|
||||
|
||||
/// A1's code and return type (true: a Python object), or `None` when it isn't a Python cell.
|
||||
fn python(h: &Harness) -> Option<(String, bool)> {
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
sh.cell(CellRef::parse("A1").unwrap()).and_then(|c| c.py.as_deref()).map(|p| (p.code.clone(), p.object))
|
||||
}
|
||||
|
||||
fn select_a1(h: &mut Harness) {
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_edits_a_python_cells_code_and_typing_replaces_it() {
|
||||
let mut h = harness();
|
||||
let undo_depth = |h: &Harness| h.state().session.active().unwrap().undo.len();
|
||||
let before = undo_depth(&h);
|
||||
|
||||
// F2 and a commit without a change writes nothing.
|
||||
key(&mut h, Key::F2);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| (e.text.as_str(), e.python)), Some(("1 + 1", true)));
|
||||
commit(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(undo_depth(&h), before, "an unchanged edit is no undo step");
|
||||
assert_eq!(python(&h), Some(("1 + 1".into(), true)));
|
||||
|
||||
// A change is new code for the same Python cell, which keeps its return type.
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
text(&mut h, " + 2");
|
||||
commit(&mut h);
|
||||
assert!(h.state().editor.is_none(), "{:?}", h.state().message);
|
||||
assert_eq!(python(&h), Some(("1 + 1 + 2".into(), true)), "the cell stays a Python cell with the new code");
|
||||
|
||||
// Typing over the cell replaces it.
|
||||
select_a1(&mut h);
|
||||
text(&mut h, "5");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(python(&h), None);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_all_the_code_clears_the_cell() {
|
||||
let mut h = harness();
|
||||
let a1 = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap());
|
||||
// Code of spaces is still code, for the same Python cell.
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text = " ".into();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert_eq!(python(&h), Some((" ".into(), true)));
|
||||
// No code at all clears the cell, as an empty entry clears any other cell.
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(python(&h), None, "the cell is no longer a Python cell");
|
||||
assert_eq!(a1(&h), Value::Empty);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(python(&h), Some((" ".into(), true)), "one undo step brings the code back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_all_the_code_clears_a_python_cell_with_no_cached_value() {
|
||||
let mut h = harness();
|
||||
// A Python cell from a file that cached no value (the value is empty, the code is not).
|
||||
{
|
||||
let d = h.state_mut().session.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let i = wb.active_sheet();
|
||||
let sh = wb.sheet_mut(i).unwrap();
|
||||
let at = CellRef::parse("A1").unwrap();
|
||||
let mut cell = sh.cell(at).cloned().unwrap();
|
||||
cell.value = Value::Empty;
|
||||
sh.set_cell(at, cell);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
}
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(python(&h), None, "the code is removed, not kept as an unchanged empty cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_edit_is_never_completed_from_the_column() {
|
||||
let mut h = harness();
|
||||
// A2 holds text that starts with A1's code: typing at the end of the code would offer it.
|
||||
h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "1 + 1 and more"})).unwrap();
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
text(&mut h, " ");
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.completion.clone()), None, "no completion while code is edited");
|
||||
commit(&mut h);
|
||||
assert_eq!(python(&h), Some(("1 + 1 ".into(), true)), "the typed code is kept");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! A Python cell formatted Hidden on a protected sheet keeps its code out of view, as a hidden
|
||||
//! formula does: the formula bar shows nothing, and F2, a double-click, the formula bar,
|
||||
//! Ctrl+Alt+Shift+P and the PY badge start no edit that would show the code, or
|
||||
//! that would write blank code over it.
|
||||
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::Harness;
|
||||
|
||||
const CODE: &str = "secret_total = 41\nsecret_total";
|
||||
|
||||
/// A Python cell at B2, selected, with its protection `hidden` flag, on a sheet protected or not.
|
||||
fn harness(hidden: bool, protected: bool) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("python.set", json!({"cell": "B2", "code": CODE})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"locked": false, "hidden": hidden}}})).unwrap();
|
||||
if protected {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(s), 4)
|
||||
}
|
||||
|
||||
fn send(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, key: Key, modifiers: Modifiers) {
|
||||
send(
|
||||
h,
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
|
||||
|
||||
/// Two clicks on B2 in a row: a double-click on the grid.
|
||||
fn double_click_b2(h: &mut Harness) {
|
||||
let at = {
|
||||
let app = h.state();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse("B2").unwrap()).center()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
for event in [
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
] {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The open edit's text, and whether it is in Python mode.
|
||||
fn editor(h: &Harness) -> Option<(String, bool)> {
|
||||
h.state().editor.as_ref().map(|e| (e.text.clone(), e.python))
|
||||
}
|
||||
|
||||
fn bar_shows_code(h: &Harness) -> bool {
|
||||
h.query_by_label_contains("secret_total").is_some()
|
||||
}
|
||||
|
||||
fn stored_code(h: &Harness) -> Option<String> {
|
||||
let d = h.state().session.active()?;
|
||||
d.wb.active()?.cell(CellRef::parse("B2")?)?.py.as_ref().map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
fn cancel(h: &mut Harness) {
|
||||
h.state_mut().cancel_edit();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code_in_the_formula_bar() {
|
||||
let h = harness(true, true);
|
||||
assert!(!bar_shows_code(&h), "the formula bar shows nothing for hidden code");
|
||||
assert!(h.query_by_label("Python Mode").is_some(), "the PY badge still marks a Python cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_a_double_click_and_the_formula_bar_start_no_edit_on_hidden_code() {
|
||||
let mut h = harness(true, true);
|
||||
press(&mut h, Key::F2, Modifiers::NONE);
|
||||
assert_eq!(editor(&h), None, "F2");
|
||||
double_click_b2(&mut h);
|
||||
assert_eq!(editor(&h), None, "a double-click");
|
||||
h.state_mut().begin_edit(None, true);
|
||||
assert_eq!(editor(&h), None, "a click in the formula bar");
|
||||
assert!(!bar_shows_code(&h));
|
||||
assert_eq!(stored_code(&h).as_deref(), Some(CODE), "the code is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_mode_starts_no_edit_on_hidden_code() {
|
||||
let mut h = harness(true, true);
|
||||
press(&mut h, Key::P, CTRL_ALT_SHIFT);
|
||||
assert_eq!(editor(&h), None, "Ctrl+Alt+Shift+P");
|
||||
h.get_by_label("Python Mode").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(editor(&h), None, "the PY badge");
|
||||
let refused = h.state_mut().run("ui.pythonMode", json!({"on": true})).expect_err("ui.pythonMode");
|
||||
assert!(refused.contains("hidden") && !refused.contains("secret"), "{refused}");
|
||||
assert_eq!(editor(&h), None, "ui.pythonMode");
|
||||
assert!(!bar_shows_code(&h));
|
||||
// Ctrl+Enter with no edit open writes nothing over the code.
|
||||
press(&mut h, Key::Enter, Modifiers::COMMAND);
|
||||
assert_eq!(stored_code(&h).as_deref(), Some(CODE));
|
||||
}
|
||||
|
||||
/// The same cell, unprotected or not hidden, shows and edits its code.
|
||||
#[test]
|
||||
fn the_code_shows_and_edits_when_the_sheet_is_unprotected_or_the_cell_not_hidden() {
|
||||
for (hidden, protected) in [(true, false), (false, true)] {
|
||||
let what = format!("hidden {hidden}, protected {protected}");
|
||||
let mut h = harness(hidden, protected);
|
||||
assert!(bar_shows_code(&h), "{what}: the formula bar shows the code");
|
||||
press(&mut h, Key::F2, Modifiers::NONE);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: F2");
|
||||
cancel(&mut h);
|
||||
double_click_b2(&mut h);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: a double-click");
|
||||
cancel(&mut h);
|
||||
press(&mut h, Key::P, CTRL_ALT_SHIFT);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: Ctrl+Alt+Shift+P");
|
||||
cancel(&mut h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Python mode in the formula bar: entering and leaving it, its keys (applied in input order, also
|
||||
//! several in one frame), committing through `python.set`, and the PY badge.
|
||||
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellError, CellRef, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::Harness;
|
||||
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(session), 4)
|
||||
}
|
||||
|
||||
fn mac_harness(session: Session) -> Harness {
|
||||
crate::common::os_harness(session, egui::os::OperatingSystem::Mac)
|
||||
}
|
||||
|
||||
fn press(key: Key, modifiers: Modifiers) -> [Event; 2] {
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
}
|
||||
|
||||
/// Sends `events` in one frame, then lets the app settle.
|
||||
fn send(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
send(h, [Event::Text(text.into())]);
|
||||
}
|
||||
|
||||
const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
|
||||
const CMD_OPTION_SHIFT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: true, command: true };
|
||||
|
||||
fn shortcut(h: &mut Harness) {
|
||||
send(h, press(Key::P, CTRL_ALT_SHIFT));
|
||||
}
|
||||
|
||||
fn ctrl_enter(h: &mut Harness) {
|
||||
send(h, press(Key::Enter, Modifiers::COMMAND));
|
||||
}
|
||||
|
||||
/// Whether an edit is open, and in Python mode.
|
||||
fn mode(h: &Harness) -> Option<bool> {
|
||||
h.state().editor.as_ref().map(|e| e.python)
|
||||
}
|
||||
|
||||
fn text(h: &Harness) -> String {
|
||||
h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn code(h: &Harness, cell: &str) -> Option<String> {
|
||||
let d = h.state().session.active()?;
|
||||
d.wb.active()?.cell(CellRef::parse(cell)?)?.py.as_ref().map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
fn value(h: &Harness, cell: &str) -> Value {
|
||||
let d = h.state().session.active().unwrap();
|
||||
d.wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcut_enters_and_leaves_python_mode_and_esc_cancels() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true), "Ctrl+Alt+Shift+P starts Python mode on the active cell");
|
||||
let ed = h.state().editor.as_ref().unwrap();
|
||||
assert!(ed.from_formula_bar, "Python mode edits in the formula bar");
|
||||
assert_eq!(ed.cell, CellRef::new(0, 0));
|
||||
h.check_accessibility();
|
||||
type_text(&mut h, "x = 1");
|
||||
assert_eq!(text(&h), "x = 1");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(false), "the shortcut again leaves Python mode");
|
||||
assert_eq!(text(&h), "x = 1", "and the edit keeps its text");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None, "Esc cancels the edit");
|
||||
assert_eq!(code(&h, "A1"), None, "and changes nothing");
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mac_shortcut_is_cmd_option_shift_p_and_cmd_enter_commits() {
|
||||
let mut h = mac_harness(blank());
|
||||
send(&mut h, press(Key::P, CMD_OPTION_SHIFT));
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, "1 + 1");
|
||||
send(&mut h, press(Key::Enter, Modifiers::MAC_CMD | Modifiers::COMMAND));
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("1 + 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_py_then_tab_enters_python_mode_as_in_excel() {
|
||||
let mut h = harness(blank());
|
||||
type_text(&mut h, "=PY");
|
||||
assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry in the cell");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true), "=PY then Tab is Python mode");
|
||||
assert_eq!(text(&h), "", "the trigger is not code");
|
||||
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
|
||||
let active = h.state().session.active().map(|d| d.selection.active);
|
||||
assert_eq!(active, Some(CellRef::new(0, 0)), "Tab did not move on");
|
||||
type_text(&mut h, "len('abc')");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("len('abc')"));
|
||||
assert_eq!(value(&h, "A1"), Value::Error(CellError::Blocked), "this build stores the code and doesn't run it");
|
||||
// Any other entry still commits with Tab.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "=PI");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None);
|
||||
}
|
||||
|
||||
/// A cell inside a spill: typing there is an ordinary entry, as on any spill child, and neither
|
||||
/// `=PY` then Tab nor the shortcut turns that draft into code for the cell, as Python mode starts
|
||||
/// on no cell inside a spill.
|
||||
#[test]
|
||||
fn a_draft_typed_inside_a_spill_does_not_enter_python_mode() {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
type_text(&mut h, "=PY");
|
||||
assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(false), "=PY then Tab");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(false), "the shortcut");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A2"), None, "no code is written into the spill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_tab_edit_the_code_and_ctrl_enter_commits_through_python_set() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "C3"})).unwrap();
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "for i in range(3):");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
type_text(&mut h, "print(i)");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
send(&mut h, press(Key::Tab, Modifiers::SHIFT));
|
||||
type_text(&mut h, "x");
|
||||
assert_eq!(text(&h), "for i in range(3):\n print(i)\nx", "Enter keeps the indentation, Shift+Tab takes it back");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(text(&h), "for i in range(3):\n print(i)\nx ");
|
||||
assert_eq!(mode(&h), Some(true), "Enter and Tab stay in the edit");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\nx "));
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(2, 2)), "Ctrl+Enter stays on the cell");
|
||||
// One undo takes the code away.
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&h, "C3"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_keys_in_one_frame_are_applied_in_order() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "a");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
type_text(&mut h, "b");
|
||||
// Select all, then Tab, in one frame: Tab indents the selected lines rather than replacing
|
||||
// them (the #456 review regression).
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::NONE));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), " a\n b");
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::SHIFT));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), "a\nb", "Shift+Tab after select-all dedents every line");
|
||||
// A keystroke sequence as the platform sends it: a key before each text, then Enter.
|
||||
send(&mut h, press(Key::End, Modifiers::COMMAND));
|
||||
let mut events = Vec::new();
|
||||
for (k, c) in [(Key::I, "i"), (Key::F, "f"), (Key::Space, " "), (Key::Y, "y")] {
|
||||
events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
events.push(Event::Text(c.into()));
|
||||
}
|
||||
events.push(Event::Key { key: Key::Colon, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
events.push(Event::Text(":".into()));
|
||||
events.extend(press(Key::Enter, Modifiers::NONE));
|
||||
events.push(Event::Text("z".into()));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), "a\nbif y:\n z");
|
||||
// Select all, Tab and Ctrl+Enter in one frame: the commit waits for the Tab.
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::NONE));
|
||||
events.extend(press(Key::Enter, Modifiers::COMMAND));
|
||||
send(&mut h, events);
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some(" a\n bif y:\n z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_typed_after_the_commit_key_in_the_same_frame_are_not_committed() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
let mut events = vec![Event::Text("1".into())];
|
||||
events.extend(press(Key::Enter, Modifiers::COMMAND));
|
||||
events.push(Event::Text("2".into()));
|
||||
send(&mut h, events);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("1"));
|
||||
assert_eq!(value(&h, "B1"), Value::Empty);
|
||||
assert!(h.state().editor.is_none(), "the text after the commit key starts nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_too_long_for_a_cell_shows_the_commands_message_and_keeps_the_code() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
let long = "y".repeat(8_080);
|
||||
if let Some(ed) = h.state_mut().editor.as_mut() {
|
||||
ed.text = long.clone();
|
||||
ed.caret = long.chars().count();
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
h.run_steps(2);
|
||||
ctrl_enter(&mut h);
|
||||
let message = h.state().message.clone().map(|m| m.1).unwrap_or_default();
|
||||
assert!(message.contains("8192"), "the command's message: {message}");
|
||||
assert_eq!(mode(&h), Some(true), "the edit stays open in Python mode");
|
||||
assert_eq!(text(&h), long, "with the code as typed");
|
||||
assert_eq!(code(&h, "A1"), None);
|
||||
// Keys go to the message box, not the code, until it closes.
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
assert!(h.state().message.is_none(), "Enter closed the message box");
|
||||
assert_eq!(text(&h), long, "and added no line to the code");
|
||||
// The editor has the keyboard back: shortening the code commits it.
|
||||
send(&mut h, press(Key::Backspace, Modifiers::NONE));
|
||||
assert_eq!(text(&h).len(), 8_079);
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A1").map(|c| c.len()), Some(8_079));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_python_cell_edits_its_code_and_typing_replaces_it() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "B2", "code": "x = 1\nx"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// The formula bar shows the code, with the PY badge.
|
||||
assert!(h.query_by_label("Python Mode").is_some(), "the PY badge shows on a selected Python cell");
|
||||
send(&mut h, press(Key::F2, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true), "F2 on a Python cell is Python mode");
|
||||
assert_eq!(text(&h), "x = 1\nx");
|
||||
// The badge leaves Python mode, as the shortcut does.
|
||||
h.get_by_label("Python Mode").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(mode(&h), Some(false));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None);
|
||||
assert!(h.query_by_label("Python Mode").is_some());
|
||||
// Typing replaces the cell, as in Excel.
|
||||
type_text(&mut h, "7");
|
||||
assert_eq!(mode(&h), Some(false));
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
assert_eq!(code(&h, "B2"), None);
|
||||
assert_eq!(value(&h, "B2"), Value::Number(7.0));
|
||||
// No badge on an ordinary cell.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Python Mode").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_python_mode_switches_from_the_control_channel_without_a_dialog() {
|
||||
let mut h = harness(blank());
|
||||
let r = h.state_mut().run("ui.pythonMode", json!({})).unwrap();
|
||||
assert_eq!(r["on"], true);
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], true, "already on");
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false);
|
||||
assert!(h.state().dialog.is_none() && h.state().message.is_none());
|
||||
h.state_mut().cancel_edit();
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false, "off with no edit does nothing");
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_ui_python_mode_start_is_an_error_with_the_reason() {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "C1", "code": "secret = 1"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "C1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// A locked cell (B1), a cell inside a spill (A2), code hidden on a protected sheet (C1), and a
|
||||
// draft typed inside a spill: each is refused with an error, by `on: true` and by a switch.
|
||||
for (cell, typed, says) in [("B1", None, "protected"), ("A2", None, "spill"), ("C1", None, "hidden"), ("A2", Some("5"), "spill")] {
|
||||
h.state_mut().cancel_edit();
|
||||
h.state_mut().run("ui.message.dismiss", json!({})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"cell": cell})).unwrap();
|
||||
if let Some(text) = typed {
|
||||
h.state_mut().begin_edit(Some(text.into()), false);
|
||||
}
|
||||
for params in [json!({"on": true}), json!({})] {
|
||||
let e = h.state_mut().run("ui.pythonMode", params.clone()).expect_err(cell);
|
||||
assert!(e.contains(says), "{cell} {params}: {e}");
|
||||
assert_ne!(mode(&h), Some(true), "{cell}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks the middle of `a1` on screen.
|
||||
fn click_cell(h: &mut Harness, a1: &str) {
|
||||
let at = {
|
||||
let app = h.state();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse(a1).unwrap()).center()
|
||||
};
|
||||
for event in [
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
] {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enter_button_and_a_click_on_the_sheet_commit_the_code_through_python_set() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "'=SUM(1)'");
|
||||
h.get_by_label("Enter").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("'=SUM(1)'"), "the code, not a formula or text");
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "2");
|
||||
click_cell(&mut h, "D4");
|
||||
assert_eq!(code(&h, "A2").as_deref(), Some("2"));
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(3, 3)));
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formula_bar_shows_while_python_mode_is_open_when_the_preference_hides_it() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.prefs.formula_bar = false;
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, "x = 1");
|
||||
assert_eq!(text(&h), "x = 1", "the code editor is drawn and takes the typing");
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("x = 1"));
|
||||
assert!(!h.state().session.prefs.formula_bar, "the preference stays as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_no_code_clears_a_python_cell_and_writes_nothing_elsewhere() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "1 + 1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "5"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// A Python cell whose code is deleted is cleared, as an empty entry clears any other cell.
|
||||
shortcut(&mut h);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none(), "{:?}", h.state().message);
|
||||
assert_eq!(code(&h, "A1"), None);
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
// Python mode on a value cell, committed with no code: the value stays.
|
||||
h.state_mut().run("selection.set", json!({"range": "A2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(text(&h), "");
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(code(&h, "A2"), None);
|
||||
assert_eq!(value(&h, "A2"), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_belongs_to_the_window_it_was_typed_in() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run_or_alert("view.newWindow", json!({}));
|
||||
h.run_steps(2);
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
assert_ne!(first, second);
|
||||
// Both ways into Python mode record the window, as an ordinary edit does.
|
||||
shortcut(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap();
|
||||
send(&mut h, press(Key::F2, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second));
|
||||
// A script brings window 1 forward; the draft is committed in its own window, and when its
|
||||
// workbook refuses it (too long for a cell), that window comes back with the draft open.
|
||||
h.state_mut().editor.as_mut().unwrap().text = "y".repeat(9_000);
|
||||
h.state_mut().session.activate_window(first);
|
||||
assert!(!h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert_eq!(h.state().session.active_window(), Some(second));
|
||||
assert_eq!(mode(&h), Some(true), "the draft is still open");
|
||||
}
|
||||
|
||||
/// A locked cell on a protected sheet: Python mode is refused before a draft exists, and a draft
|
||||
/// whose cell sheet protection locks while it is open is dropped with the warning, as an ordinary
|
||||
/// edit's is, so no draft is left that can never be entered and blocks Save (#2945).
|
||||
#[test]
|
||||
fn python_mode_leaves_no_draft_on_a_locked_cell() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "1"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": false}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let mut h = harness(s);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), None, "no draft on the locked A1");
|
||||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")), "{:?}", h.state().message);
|
||||
h.state_mut().run("ui.message.dismiss", json!({})).unwrap();
|
||||
// The unlocked B1 edits; then protection locks it while the draft is open.
|
||||
h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, " + 1");
|
||||
h.state_mut().run("review.unprotectSheet", json!({})).unwrap();
|
||||
h.state_mut().run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true}}})).unwrap();
|
||||
h.state_mut().run("review.protectSheet", json!({})).unwrap();
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(mode(&h), None, "the draft is dropped, not left open");
|
||||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")));
|
||||
assert_eq!(code(&h, "B1").as_deref(), Some("2"));
|
||||
assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user