mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-25 19:26:50 -06:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
935a0edaf6 | ||
|
|
0870b1ad1c | ||
|
|
14dd23db6c | ||
|
|
ad10881cc3 | ||
|
|
ee7cbf32ab | ||
|
|
2fbbed33bc | ||
|
|
1a36d9db07 | ||
|
|
41769f1d16 | ||
|
|
3b65b9af58 | ||
|
|
4eb2fb5c8e | ||
|
|
0665476b35 | ||
|
|
189c3cdb0f | ||
|
|
674e1c05a3 | ||
|
|
0469b51c93 | ||
|
|
4a44dec500 | ||
|
|
0f5227ddde | ||
|
|
97ec7653aa | ||
|
|
27e96a9a58 | ||
|
|
2c81adf94f | ||
|
|
9413c57e2c | ||
|
|
bd7b867a1f | ||
|
|
80ea166d7d | ||
|
|
cb28634140 | ||
|
|
39a56ee902 | ||
|
|
0697abbbbc | ||
|
|
6208f15d29 | ||
|
|
25d6a3a44f | ||
|
|
1871541948 | ||
|
|
990aa1315a | ||
|
|
c9b9907c60 | ||
|
|
802c782360 | ||
|
|
85b6f564f9 | ||
|
|
d07639c7c4 | ||
|
|
64a78a7210 | ||
|
|
80e2041159 | ||
|
|
960c64c3e4 | ||
|
|
14e73dd0e9 | ||
|
|
4548bab2e3 | ||
|
|
3e6eac4222 | ||
|
|
85e8f7909e | ||
|
|
0cf9476241 | ||
|
|
a2aa72c3cb | ||
|
|
a75d633d68 | ||
|
|
3ef94601d5 | ||
|
|
6bdde35133 | ||
|
|
b5bf14db98 | ||
|
|
4c0cc09505 | ||
|
|
b4487663aa | ||
|
|
92d2b9894f | ||
|
|
86064077f1 | ||
|
|
276cd0fe78 | ||
|
|
6cb3220164 | ||
|
|
14415f410d | ||
|
|
a80c7ff570 | ||
|
|
1062604fee | ||
|
|
ac890b839a | ||
|
|
1da640abcc | ||
|
|
f1cb4f00f4 | ||
|
|
5bd6cc3d04 | ||
|
|
392fb29727 | ||
|
|
fef1f4ddb1 |
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# The same craft-fonts commit as release.yml and freebsd.yml, so the gate tests the fonts that ship.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -29,4 +29,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-title: GridCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -43,7 +43,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
@@ -8,6 +8,10 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
|
||||
@@ -42,7 +42,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p gridcraft -- --sample sales --control 7979` (sample workbook + control channel).
|
||||
- Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Drive it: `gridcraft-cli send 7979 <method> '<json>'` (reads the token file the app printed on stderr), or JSON lines on `127.0.0.1:7979` whose first line is `{"id":0,"method":"auth","params":{"token":"…"}}` (token: `$XDG_RUNTIME_DIR/gridcraft/control-7979.token`, or your own `GRIDCRAFT_CONTROL_TOKEN`), e.g. `{"id":1,"method":"engine.execute","params":{"command":"cell.set","params":{"cell":"B2","input":"=SUM(A1:A9)"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- **Offscreen UI render** (no window, from a source checkout): `cargo run -p gridcraft-ui-egui --example snapshot -- --sample sales out.png`.
|
||||
- Headless: `gridcraft-cli eval '=SUM(1,2,3)'`, `gridcraft-cli run --in book.xlsx --cmd 'home.bold={"range":"A1:C1"}' --out book.xlsx`, `gridcraft-cli mcp`.
|
||||
- **For UI work, look at the result** (snapshot PNG) and compare with `plan/excel/observed-ui.md`.
|
||||
|
||||
Generated
+2003
-222
File diff suppressed because it is too large
Load Diff
+28
-9
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/gridcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -24,6 +24,7 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -34,8 +35,8 @@ log = "0.4"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.42"
|
||||
zip = { version = "9", 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 }
|
||||
@@ -43,19 +44,19 @@ sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
# The desktop's preferred language, for the first-run interface language (native only).
|
||||
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.44", default-features = false, features = ["std"] }
|
||||
skrifa = { version = "0.48", default-features = false, features = ["std"] }
|
||||
# Real font bytes for tests (Ubuntu Light, compiled into epaint).
|
||||
epaint_default_fonts = "0.36"
|
||||
epaint_default_fonts = "0.37"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -79,3 +80,21 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
[patch.crates-io]
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -12,5 +12,265 @@ Third-party material
|
||||
This product bundles third-party material under its own licenses, listed below. The per-file
|
||||
list (author, source, license) is in ATTRIBUTION.md.
|
||||
|
||||
None: every bundled asset is original to GridCraft. Builds made with craft-fonts
|
||||
(CRAFT_FONTS_DIR) embed the fonts listed in ATTRIBUTION.md under the SIL Open Font License 1.1.
|
||||
GridCraft's original assets and the UI's bundled egui font inputs are listed in ATTRIBUTION.md.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR) also embed the fonts listed there under the SIL Open
|
||||
Font License 1.1.
|
||||
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
Hack-Regular.ttf is provided by epaint_default_fonts and is egui's bundled monospace face. The
|
||||
work is Copyright 2018 Source Foundry Authors and is distributed under the MIT License and the
|
||||
Bitstream Vera License. The DejaVu work it derives from was committed to the public domain.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://github.com/source-foundry/Hack
|
||||
The following text is reproduced verbatim from Hack-Regular.txt:
|
||||
|
||||
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
|
||||
|
||||
The work in the DejaVu project was committed to the public domain.
|
||||
|
||||
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Source Foundry Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
BITSTREAM VERA LICENSE
|
||||
|
||||
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
|
||||
|
||||
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
|
||||
|
||||
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
|
||||
|
||||
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
|
||||
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
NotoEmoji-Regular.ttf is provided by epaint_default_fonts and is enabled by egui's
|
||||
monochrome_emoji_fonts feature. It is distributed under the SIL Open Font License, Version 1.1.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://fonts.google.com/noto/specimen/Noto+Emoji
|
||||
The following text is reproduced verbatim from OFL.txt:
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Ubuntu-Light.ttf is provided by epaint_default_fonts and is egui's default proportional face. It
|
||||
is distributed under the Ubuntu Font Licence, Version 1.0.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Upstream: https://design.ubuntu.com/font
|
||||
The following text is reproduced verbatim from UFL.txt:
|
||||
|
||||
-------------------------------
|
||||
UBUNTU FONT LICENCE Version 1.0
|
||||
-------------------------------
|
||||
|
||||
PREAMBLE
|
||||
This licence allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely. The fonts, including any derivative works, can be
|
||||
bundled, embedded, and redistributed provided the terms of this licence
|
||||
are met. The fonts and derivatives, however, cannot be released under
|
||||
any other licence. The requirement for fonts to remain under this
|
||||
licence does not require any document created using the fonts or their
|
||||
derivatives to be published under this licence, as long as the primary
|
||||
purpose of the document is not to be a vehicle for the distribution of
|
||||
the fonts.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this licence and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as received under this licence.
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to
|
||||
a new environment.
|
||||
|
||||
"Copyright Holder(s)" refers to all individuals and companies who have a
|
||||
copyright ownership of the Font Software.
|
||||
|
||||
"Substantially Changed" refers to Modified Versions which can be easily
|
||||
identified as dissimilar to the Font Software by users of the Font
|
||||
Software comparing the Original Version with the Modified Version.
|
||||
|
||||
To "Propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification and with or without charging
|
||||
a redistribution fee), making available to the public, and in some
|
||||
countries other activities as well.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
This licence does not grant any rights under trademark law and all such
|
||||
rights are reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to propagate the Font Software, subject to
|
||||
the below conditions:
|
||||
|
||||
1) Each copy of the Font Software must contain the above copyright
|
||||
notice and this licence. These can be included either as stand-alone
|
||||
text files, human-readable headers or in the appropriate machine-
|
||||
readable metadata fields within text or binary files as long as those
|
||||
fields can be easily viewed by the user.
|
||||
|
||||
2) The font name complies with the following:
|
||||
(a) The Original Version must retain its name, unmodified.
|
||||
(b) Modified Versions which are Substantially Changed must be renamed to
|
||||
avoid use of the name of the Original Version or similar names entirely.
|
||||
(c) Modified Versions which are not Substantially Changed must be
|
||||
renamed to both (i) retain the name of the Original Version and (ii) add
|
||||
additional naming elements to distinguish the Modified Version from the
|
||||
Original Version. The name of such Modified Versions must be the name of
|
||||
the Original Version, with "derivative X" where X represents the name of
|
||||
the new work, appended to that name.
|
||||
|
||||
3) The name(s) of the Copyright Holder(s) and any contributor to the
|
||||
Font Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except (i) as required by this licence, (ii) to
|
||||
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
|
||||
their explicit written permission.
|
||||
|
||||
4) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this licence, and must not be distributed
|
||||
under any other licence. The requirement for fonts to remain under this
|
||||
licence does not affect any document created using the Font Software,
|
||||
except any version of the Font Software extracted from a document
|
||||
created using the Font Software may only be distributed under this
|
||||
licence.
|
||||
|
||||
TERMINATION
|
||||
This licence becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
+6
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (accounting negatives such as `$(1,234)` read as numbers; INDEX with one index on a 2-D reference is #REF! as in Excel; earlier: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
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,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
|
||||
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
|
||||
@@ -155,6 +156,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | File › Options › Formulas: Maximum Iterations and Maximum Change fields for iterative calculation, disabled while it is off, with Excel's limits (1 to 32767, 0 or more) |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
@@ -173,6 +175,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
@@ -235,6 +238,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
@@ -256,6 +260,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
|
||||
@@ -318,7 +318,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
@@ -333,7 +333,7 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
pub fn check_bytes(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| check_inner(path, bytes, opts)));
|
||||
guarded.unwrap_or_else(|p| {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
FileReport { path: path.to_string(), error: Some(format!("internal error while checking: {msg}")), ..Default::default() }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -195,7 +195,18 @@ fn pretty(v: &Value) -> String {
|
||||
|
||||
/// A number as a person would type it: `6`, `0.5`, `1e+300`.
|
||||
fn number_text(n: f64) -> String {
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { format!("{n}") }
|
||||
if n.fract() == 0.0 && n.abs() < 1e15 {
|
||||
format!("{}", n as i64)
|
||||
} else if n.is_finite() && (n.abs() >= 1e15 || n.abs() < 1e-5) {
|
||||
// Rust's `Display` never uses exponent form; `{:e}` omits the `+` on positive exponents.
|
||||
let s = format!("{n:e}");
|
||||
match s.split_once('e') {
|
||||
Some((m, e)) if !e.starts_with('-') => format!("{m}e+{e}"),
|
||||
_ => s,
|
||||
}
|
||||
} else {
|
||||
format!("{n}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A computed value (as returned by `formulas.evaluate` / `sheet.read`) as plain text.
|
||||
@@ -606,6 +617,11 @@ mod tests {
|
||||
fn value_texts() {
|
||||
assert_eq!(value_text(&json!(6.0)), "6");
|
||||
assert_eq!(value_text(&json!(0.25)), "0.25");
|
||||
assert_eq!(value_text(&json!(1e300)), "1e+300");
|
||||
assert_eq!(value_text(&json!(-1.5e300)), "-1.5e+300");
|
||||
assert_eq!(value_text(&json!(1e-20)), "1e-20");
|
||||
assert_eq!(value_text(&json!(1e-300)), "1e-300");
|
||||
assert_eq!(value_text(&json!(0.0001)), "0.0001");
|
||||
assert_eq!(value_text(&json!(true)), "TRUE");
|
||||
assert_eq!(value_text(&json!({"error": "#DIV/0!"})), "#DIV/0!");
|
||||
assert_eq!(value_text(&json!([[1.0, 2.0], [3.0, "x"]])), "1\t2\n3\tx");
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
|
||||
//! `localStorage` (`prefs`).
|
||||
//! notes and comments. `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab
|
||||
//! undrawn for a host that embeds the sheet in its own window (see `Chrome`). Preferences (theme,
|
||||
//! interface size, editing options…) are kept in `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -8,6 +8,7 @@ use wasm_bindgen::JsValue;
|
||||
const CANVAS_ID: &str = "gridcraft_canvas";
|
||||
const LOADING_ID: &str = "gridcraft_loading";
|
||||
|
||||
/// Starts the eframe web runner for the GridCraft canvas. It adjusts the WebGPU device limits to the adapter's, optionally selects the WebGL backend from the query string, and builds the shell with the requested sample or host bridge, browser language rules, stored preferences and the loading/error page state.
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -60,8 +61,21 @@ pub fn start() {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services);
|
||||
app.ui.system_theme = true;
|
||||
// Follow the browser's language, except a CJK one: the web build has no system
|
||||
// CJK fonts (none are bundled), so that interface would render as tofu.
|
||||
// `?chrome=none|notitlebar|nofiletab`: a host that embeds the sheet in its own window and owns the files
|
||||
// can leave the title bar and/or the File tab undrawn (`Chrome`, #1877). An unknown value keeps
|
||||
// everything, as the default does.
|
||||
if let Some(chrome) = query_param("chrome") {
|
||||
match chrome.as_str() {
|
||||
"none" => app.chrome = gridcraft_ui_egui::Chrome { title_bar: false, file_tab: false },
|
||||
"notitlebar" => app.chrome.title_bar = false,
|
||||
"nofiletab" => app.chrome.file_tab = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Follow the browser's language, except a CJK one. CJK coverage comes from
|
||||
// optional craft-fonts input, not a guaranteed web font set, so a CJK browser
|
||||
// cannot be assumed to render localized labels without tofu. Users can still
|
||||
// choose a language explicitly.
|
||||
let browser = gridcraft_ui_egui::i18n::Language::system();
|
||||
if !matches!(
|
||||
browser,
|
||||
@@ -156,8 +170,9 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
/// Forwards a browser frame's raw input to the shared app before eframe handles it.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
@@ -352,7 +367,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
js_sys::Reflect::get(&window, &JsValue::from_str("showSaveFilePicker")).ok().and_then(|v| v.dyn_into().ok());
|
||||
|
||||
if let Some(picker_fn) = show_picker_fn {
|
||||
let ext = std::path::Path::new(name).extension().and_then(|e| e.to_str()).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("xlsx").to_ascii_lowercase();
|
||||
let dot_ext = format!(".{ext}");
|
||||
let mime = mime_for(name);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ workspace = true
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -25,9 +25,13 @@ chrono = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
# The control channel's token (OS random numbers); already in the tree.
|
||||
getrandom = "0.4"
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
|
||||
arboard = { workspace = true, features = ["wayland-data-control"] }
|
||||
|
||||
@@ -1,40 +1,85 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! The first line of a connection must authenticate with the app's token (#777; see
|
||||
//! `gridcraft_mcp::control_auth`). A connection that doesn't, or later sends a line that isn't
|
||||
//! JSON, is answered once and closed.
|
||||
//!
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use gridcraft_mcp::control_auth;
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
/// How long a new connection may take to send its token.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
|
||||
/// else 256 random bits from the operating system, written to a file only the user can read.
|
||||
fn make_token(port: u16, chosen: Option<String>) -> std::io::Result<String> {
|
||||
if let Some(t) = chosen.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
|
||||
if t.len() < control_auth::MIN_TOKEN_LEN {
|
||||
return Err(std::io::Error::other(format!("the control token must have at least {} characters", control_auth::MIN_TOKEN_LEN)));
|
||||
}
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port} requires the token you set (clients: GRIDCRAFT_CONTROL_TOKEN)");
|
||||
return Ok(t);
|
||||
}
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes).map_err(|e| std::io::Error::other(format!("no random numbers for the control token: {e}")))?;
|
||||
let token = control_auth::hex(&bytes);
|
||||
let path = control_auth::write_token_file(port, &token)?;
|
||||
eprintln!("gridcraft: control channel on 127.0.0.1:{port}; its token is in {} (gridcraft-cli reads it)", path.display());
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let rx = serve_listener(listener, ctx)?;
|
||||
let token: Arc<str> = make_token(port, chosen_token)?.into();
|
||||
let rx = serve_listener(listener, token, ctx)?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
|
||||
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
|
||||
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
|
||||
let mut first = String::new();
|
||||
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
|
||||
let verdict = match read {
|
||||
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
|
||||
_ => Err(json!({"ok": false, "error": "authentication required"})),
|
||||
};
|
||||
let allowed = verdict.is_ok();
|
||||
let reply = verdict.unwrap_or_else(|e| e);
|
||||
let _ = writeln!(out, "{reply}");
|
||||
let _ = out.set_read_timeout(None);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -103,10 +148,13 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
@@ -154,21 +202,29 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
/// Authenticate a new connection, send `input`, and read reply lines (after the
|
||||
/// authentication reply) until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
// A read timeout ends the list too: the server kept the connection open.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
let mut replies: Vec<Json> = BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect();
|
||||
assert_eq!(replies.first().map(|r| r["ok"].clone()), Some(json!(true)), "{replies:?}");
|
||||
replies.remove(0);
|
||||
replies
|
||||
}
|
||||
|
||||
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
(addr, serve_listener(listener, egui::Context::default()).unwrap())
|
||||
(addr, serve_listener(listener, TOKEN.into(), egui::Context::default()).unwrap())
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
@@ -194,8 +250,11 @@ mod tests {
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut auth_reply = String::new();
|
||||
r.read_line(&mut auth_reply).unwrap();
|
||||
for id in [1, 2] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
@@ -221,4 +280,75 @@ mod tests {
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
/// A server on a loopback port with a stand-in UI that echoes each method.
|
||||
fn spawn() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "result": req.method}));
|
||||
}
|
||||
});
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn lines(addr: std::net::SocketAddr, send: &str) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(send.as_bytes()).unwrap();
|
||||
// Replies up to the server closing the connection.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_opens_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(format!("{auth}\n{{\"id\":2,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["ok"], json!(true));
|
||||
line.clear();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Json = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].clone(), v["result"].clone()), (json!(2), json!("ping")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_but_the_token_first_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let wrong = control_auth::auth_line(1, "ffffffffffffffffffffffffffffffff");
|
||||
for first in [wrong.as_str(), "{\"id\":1,\"method\":\"app.save\",\"params\":{\"path\":\"/tmp/x\"}}", "POST / HTTP/1.1", ""] {
|
||||
// The second request is never served: one refusal, then end of stream.
|
||||
let got = lines(addr, &format!("{first}\n{{\"id\":2,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 1, "{first}: {got:?}");
|
||||
assert_eq!(got[0]["ok"], json!(false), "{first}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unparsable_line_after_auth_closes_the_connection() {
|
||||
let addr = spawn();
|
||||
let auth = control_auth::auth_line(1, TOKEN);
|
||||
let got = lines(addr, &format!("{auth}\nnot json\n{{\"id\":3,\"method\":\"ping\"}}\n"));
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
assert_eq!(make_token(1, Some(format!(" {TOKEN} "))).unwrap(), TOKEN);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! GridCraft desktop app.
|
||||
//!
|
||||
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
|
||||
//! Usage: `gridcraft [--control <port> [--control-token <token>]] [--sample <name>] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
|
||||
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! The first line of each connection must carry the token (`docs/control-protocol.md`).
|
||||
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -40,13 +41,13 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.2, &mut self.0, ctx);
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
self.0.poll_printing(ctx);
|
||||
if ctx.input(|input| input.viewport().close_requested()) && self.0.printing.busy() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Forwards raw input to the shared app, then opens every non-empty dropped file path as a workbook.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
// Files dropped on the window open as workbooks.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
|
||||
@@ -180,11 +181,11 @@ fn services() -> Services {
|
||||
})),
|
||||
pick_save: Some(Box::new(|suggested: &str| {
|
||||
let path = std::path::Path::new(suggested);
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let stem = path.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
// The suggested name's type (an export asks for .pdf, .csv or .html) comes first.
|
||||
const TYPES: [(&str, &str); 4] =
|
||||
[("Excel Workbook (.xlsx)", "xlsx"), ("CSV UTF-8 (.csv)", "csv"), ("Web Page (.html)", "html"), ("PDF (.pdf)", "pdf")];
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).filter(|e| TYPES.iter().any(|(_, x)| x == e));
|
||||
let ext = ext.unwrap_or_else(|| "xlsx".to_string());
|
||||
let mut dialog = rfd::FileDialog::new().set_file_name(format!("{stem}.{ext}"));
|
||||
for (label, x) in TYPES.iter().filter(|(_, x)| *x == ext).chain(TYPES.iter().filter(|(_, x)| *x != ext)) {
|
||||
@@ -251,11 +252,20 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
}));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("GridCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if args.iter().any(|a| a == "--version" || a == "-V") {
|
||||
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut control_token: Option<String> = std::env::var("GRIDCRAFT_CONTROL_TOKEN").ok();
|
||||
let mut sample: Option<String> = None;
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut i = 0;
|
||||
@@ -265,6 +275,10 @@ fn main() -> eframe::Result<()> {
|
||||
i += 1;
|
||||
control_port = args.get(i).and_then(|p| p.parse().ok());
|
||||
}
|
||||
"--control-token" => {
|
||||
i += 1;
|
||||
control_token = args.get(i).cloned();
|
||||
}
|
||||
"--sample" => {
|
||||
i += 1;
|
||||
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
|
||||
@@ -299,6 +313,7 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.start_update_check();
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
@@ -352,7 +367,7 @@ fn main() -> eframe::Result<()> {
|
||||
};
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, app.ui.dark, app.ui.language);
|
||||
if let Some(port) = control_port {
|
||||
match control_server::start(port, cc.egui_ctx.clone()) {
|
||||
match control_server::start(port, control_token.clone(), cc.egui_ctx.clone()) {
|
||||
Ok(rx) => app.control_rx = Some(rx),
|
||||
Err(e) => log::error!("control channel on port {port}: {e}"),
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
(
|
||||
"Help",
|
||||
vec![
|
||||
("Check for Updates…", "app.checkForUpdates", None),
|
||||
("Agent Control (MCP)…", "dialog:agents", None),
|
||||
("Contributors…", "dialog:about:Contributors", None),
|
||||
("GridCraft on getartcraft.com", "url:https://getartcraft.com/apps/gridcraft", None),
|
||||
@@ -337,6 +338,16 @@ mod tests {
|
||||
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = tree();
|
||||
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
|
||||
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
|
||||
assert_eq!(item.1, "app.checkForUpdates");
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run(item.1, serde_json::json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use gridcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a printer-discovery closure that enumerates `craft_print::printers()` on a named background thread and sends the mapped list through a channel.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
@@ -17,7 +21,7 @@ pub fn discover() -> Discover {
|
||||
static JOBS: std::sync::Mutex<Vec<std::thread::JoinHandle<()>>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
pub fn finish_pending_jobs() {
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(|poison| poison.into_inner()));
|
||||
let workers = std::mem::take(&mut *JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner));
|
||||
for worker in workers {
|
||||
if worker.join().is_err() {
|
||||
log::error!("the print worker stopped unexpectedly during shutdown");
|
||||
@@ -45,7 +49,7 @@ pub fn submit() -> Submit {
|
||||
let _ = sender.send(craft_print::submit(&bytes, &job).map_err(|error| error.to_string()));
|
||||
});
|
||||
if let Ok(worker) = worker {
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(|poison| poison.into_inner());
|
||||
let mut jobs = JOBS.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
jobs.retain(|job| !job.is_finished());
|
||||
jobs.push(worker);
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
if !horizontal {
|
||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
||||
let y2labels = y2.map(|a| a.labels());
|
||||
let y2labels = y2.map(ValAxis::labels);
|
||||
let right = match (&y2labels, &cat) {
|
||||
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||
|
||||
@@ -77,7 +77,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
||||
ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
categories: cats.iter().map(ToString::to_string).collect(),
|
||||
series: vec![
|
||||
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
||||
b,
|
||||
|
||||
@@ -246,7 +246,7 @@ fn is_r1c1_token(name: &str) -> bool {
|
||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||
chars.next();
|
||||
letters += 1;
|
||||
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
while chars.peek().is_some_and(char::is_ascii_digit) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,9 @@ pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
|
||||
if frac_ms >= 86_400_000 {
|
||||
frac_ms -= 86_400_000;
|
||||
day += 1;
|
||||
if day > max_serial(sys) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (y, m, d, weekday) = match sys {
|
||||
DateSystem::D1900 => {
|
||||
@@ -221,6 +224,14 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serial_rounding_into_next_day_past_max_is_none() {
|
||||
for sys in [DateSystem::D1900, DateSystem::D1904] {
|
||||
let serial = max_serial(sys) as f64 + 0.9999999995;
|
||||
assert_eq!(datetime_from_serial(sys, serial), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
|
||||
@@ -245,7 +245,7 @@ impl Value {
|
||||
Value::Bool(b) => Ok(if *b { "TRUE".into() } else { "FALSE".into() }),
|
||||
Value::Text(t) => Ok(t.to_string()),
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_text()).unwrap_or(Ok(String::new())),
|
||||
Value::Array(a) => a.data.first().map(Value::to_text).unwrap_or(Ok(String::new())),
|
||||
}
|
||||
}
|
||||
/// Coercion to a boolean as `IF` does it.
|
||||
@@ -264,7 +264,7 @@ impl Value {
|
||||
}
|
||||
}
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_bool()).unwrap_or(Ok(false)),
|
||||
Value::Array(a) => a.data.first().map(Value::to_bool).unwrap_or(Ok(false)),
|
||||
}
|
||||
}
|
||||
/// Display text with General formatting (for logs, CSV and tests).
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
|
||||
|
||||
/// The conditional look of one cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
|
||||
@@ -941,7 +941,7 @@ fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("Nothing to import.".into()));
|
||||
}
|
||||
let name = str_param(p, "sheet").map(str::to_string).or_else(|| {
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(|f| f.to_str()).map(|f| f.chars().take(31).collect()))
|
||||
str_param(p, "path").and_then(|x| std::path::Path::new(x).file_stem().and_then(std::ffi::OsStr::to_str).map(|f| f.chars().take(31).collect()))
|
||||
});
|
||||
let r = s.execute("home.insertSheet", json!({"name": name})).or_else(|_| s.execute("home.insertSheet", json!({})))?;
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
|
||||
@@ -69,9 +69,9 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
let title = if imported {
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(|n| n.to_str()).unwrap_or("Book"))
|
||||
format!("{} (imported).xlsx", std::path::Path::new(&name).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book"))
|
||||
} else {
|
||||
std::path::Path::new(&name).file_name().and_then(|n| n.to_str()).unwrap_or("Book").to_string()
|
||||
std::path::Path::new(&name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Book").to_string()
|
||||
};
|
||||
// An import has no save target: ordinary Save must ask for a new destination. Neither does a
|
||||
// workbook decrypted with its password, so Save never replaces the encrypted original with
|
||||
@@ -114,7 +114,7 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
// A PDF is a print of the sheet (Page Setup applies), not a copy of the workbook: the
|
||||
// document keeps its file and its unsaved changes.
|
||||
if std::path::Path::new(&path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
if std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("pdf")) {
|
||||
return super::print::export_pdf(s, &json!({"path": path}));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
|
||||
@@ -105,20 +105,53 @@ fn auto_sum(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
None => d.selection.current(),
|
||||
};
|
||||
if !sel.is_single() {
|
||||
// A block: totals below each column (and to the right when a blank column is included).
|
||||
// Columns past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), and all totals are one edit (one undo step).
|
||||
let Some(last_col) = sh.cells.used_range().map(|u| u.end.col.min(sel.end.col)) else { return ok() };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| sh.value(CellRef::new(sel.end.row, c)).is_empty());
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
if out_row >= gridcraft_core::MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||
// A selection: totals go in its empty last row or column, as Excel's do, and otherwise
|
||||
// past it: to the right of a single row, below each column of anything taller. Columns
|
||||
// past the sheet's data get no total (a whole-row selection doesn't write 16,384
|
||||
// formulas), nor do rows past it, and all totals are one edit (one undo step).
|
||||
let Some(used) = sh.cells.used_range() else { return ok() };
|
||||
let empty = |row: u32, col: u32| sh.value(CellRef::new(row, col)).is_empty();
|
||||
let sum = |from: CellRef, to: CellRef| format!("={func}({})", RangeRef::new(from, to).a1());
|
||||
let last_col = used.end.col.min(sel.end.col);
|
||||
let mut writes: Vec<(CellRef, String)> = Vec::new();
|
||||
if sel.start.row == sel.end.row {
|
||||
// One row: its total goes in its last cell when that is empty, else just after it.
|
||||
let row = sel.start.row;
|
||||
let end_col = if sel.is_full_rows() { last_col } else { sel.end.col };
|
||||
let (data_end, out) = if end_col > sel.start.col && empty(row, end_col) { (end_col - 1, end_col) } else { (end_col, end_col + 1) };
|
||||
if out >= gridcraft_core::MAX_COLS {
|
||||
return Err(EngineError::Other("There's no room to the right of the selection for the total.".into()));
|
||||
}
|
||||
writes.push((CellRef::new(row, out), sum(CellRef::new(row, sel.start.col), CellRef::new(row, data_end))));
|
||||
} else {
|
||||
// An empty last column takes each row's total; rows past the data get none.
|
||||
let rows_end = sel.end.row.min(used.end.row);
|
||||
let right_blank = !sel.is_full_rows() && sel.end.col > sel.start.col && (sel.start.row..=rows_end).all(|r| empty(r, sel.end.col));
|
||||
let data_end_col = if right_blank { sel.end.col - 1 } else { last_col };
|
||||
let last_row_blank = (sel.start.col..=last_col).all(|c| empty(sel.end.row, c));
|
||||
let (data_end_row, out_row) = if last_row_blank { (sel.end.row.saturating_sub(1), sel.end.row) } else { (sel.end.row, sel.end.row + 1) };
|
||||
// Each column's total, below it: unless only the last column is there to take totals.
|
||||
if last_row_blank || !right_blank {
|
||||
if out_row >= gridcraft_core::MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the selection for the totals.".into()));
|
||||
}
|
||||
for c in sel.start.col..=data_end_col.min(last_col) {
|
||||
writes.push((CellRef::new(out_row, c), sum(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c))));
|
||||
}
|
||||
}
|
||||
if right_blank {
|
||||
for r in sel.start.row..=data_end_row.min(rows_end) {
|
||||
writes.push((CellRef::new(r, sel.end.col), sum(CellRef::new(r, sel.start.col), CellRef::new(r, data_end_col))));
|
||||
}
|
||||
// Both empty: the corner takes the total of the whole block.
|
||||
if last_row_blank {
|
||||
writes.push((
|
||||
CellRef::new(sel.end.row, sel.end.col),
|
||||
sum(CellRef::new(sel.start.row, sel.start.col), CellRef::new(data_end_row, data_end_col)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let writes: Vec<(CellRef, String)> = (sel.start.col..=last_col)
|
||||
.map(|c| {
|
||||
(CellRef::new(out_row, c), format!("={func}({})", RangeRef::new(CellRef::new(sel.start.row, c), CellRef::new(data_end_row, c)).a1()))
|
||||
})
|
||||
.collect();
|
||||
let sheet = d.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
for (c, f) in &writes {
|
||||
|
||||
@@ -1317,9 +1317,20 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
let author = str_param(p, "author").map(str::to_string).unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
let named = str_param(p, "author").map(str::to_string);
|
||||
let author = named.clone().unwrap_or_else(|| s.prefs.user_name.clone());
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
let comments = &mut cx.sheet_mut(sheet)?.comments;
|
||||
// Writing a note on a cell that has one edits it, as Excel's Edit Note does: the box, the
|
||||
// replies, the dates and who wrote it stay. A note replaces a comment and vice versa.
|
||||
if let Some(c) = comments.get_mut(&at).filter(|c| c.threaded == threaded) {
|
||||
c.text = text.clone();
|
||||
if let Some(author) = named.clone() {
|
||||
c.author = author;
|
||||
}
|
||||
return Ok(json!({"cell": at.a1()}));
|
||||
}
|
||||
comments.insert(
|
||||
at,
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
test_commands().iter().find(|c| c.id == id).copied()
|
||||
}
|
||||
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&'static CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
static TEST_COMMANDS: std::sync::Mutex<Vec<&CommandSpec>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
/// Test-only: register an extra command so UI integration tests can prove a pending
|
||||
/// keytip becomes live the moment its command lands (see crates/ui-egui/tests/keytips.rs).
|
||||
|
||||
@@ -253,7 +253,7 @@ fn find_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
}
|
||||
|
||||
fn area_param(cmd: &str, v: Option<&str>) -> Result<&'static str> {
|
||||
match v.map(|x| x.to_ascii_lowercase()) {
|
||||
match v.map(str::to_ascii_lowercase) {
|
||||
Some(a) if a == "rows" || a == "row" => Ok("rows"),
|
||||
Some(a) if a == "columns" || a == "column" || a == "cols" => Ok("columns"),
|
||||
Some(a) if a == "values" || a == "value" || a == "data" => Ok("values"),
|
||||
|
||||
@@ -617,7 +617,7 @@ pub fn expand_header_footer(spec: &str, cx: &HfContext) -> [HfSection; 3] {
|
||||
i += rest.chars().count() + 1;
|
||||
}
|
||||
d if d.is_ascii_digit() => {
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(|c| c.is_ascii_digit())).take(3).collect();
|
||||
let digits: String = std::iter::once(d).chain(chars[i..].iter().copied().take_while(char::is_ascii_digit)).take(3).collect();
|
||||
i += digits.len() - 1;
|
||||
if let Ok(v) = digits.parse::<f32>() {
|
||||
sec.size = Some(v.clamp(1.0, 409.0));
|
||||
|
||||
@@ -7,7 +7,10 @@ use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Sheet, Visibility};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, Ctx, EngineError, PROTECTED, Result, Session, bad, bool_param, cell_param_on, cmd, edit, has_clipboard, has_doc, ok, parse_lines,
|
||||
str_param, target_range, target_sheet, u32_param,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -203,11 +206,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
if let Some(c) = map_cell(sp.cell) {
|
||||
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
|
||||
Some(c) => {
|
||||
sp.cell = c;
|
||||
true
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa);
|
||||
}
|
||||
@@ -270,6 +275,29 @@ fn cancel_copy_mode(s: &mut Session) {
|
||||
}
|
||||
}
|
||||
|
||||
fn inherit_line_format(sh: &mut Sheet, axis: Axis, at: u32, count: u32) {
|
||||
if at == 0 {
|
||||
return;
|
||||
}
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u32, limit: u32) -> Result<()> {
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
@@ -279,13 +307,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
check_push_off_sheet(s, sheet, axis, at, count, limit)?;
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
@@ -297,15 +319,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, true);
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
if at > 0 {
|
||||
let prev = if axis == Axis::Rows { sh.rows.get(&(at - 1)).copied() } else { sh.cols.get(&(at - 1)).copied() };
|
||||
if let Some(info) = prev {
|
||||
for i in at..at + count {
|
||||
let map = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
map.insert(i, gridcraft_model::LineInfo { hidden: false, outline: 0, collapsed: false, ..info });
|
||||
}
|
||||
}
|
||||
}
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
cx.structural = true;
|
||||
|
||||
@@ -28,8 +28,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, DefinedName, Sheet, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::check_arrays;
|
||||
use super::whatif::Trial;
|
||||
use super::*;
|
||||
use super::{CommandSpec, Ctx, DocState, EngineError, Result, Session, bad, bool_param, cmd, edit, f64_param, has_doc, str_param};
|
||||
use gridcraft_core::split_sheet;
|
||||
|
||||
const CMD: &str = "data.solver";
|
||||
const REPORT_CMD: &str = "data.solverReport";
|
||||
@@ -1705,6 +1707,26 @@ fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
fn format_constraint_row(sh: &Sheet, c: &Constraint, cell: CellRef, v: f64, rhs: &[f64], k: usize) -> Vec<Value> {
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
let num = |v: f64| if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) };
|
||||
vec![Value::text(abs_ref(cell)), Value::text(cell_name(sh, cell)), num(v), Value::text(formula), Value::text(status), num(slack)]
|
||||
}
|
||||
|
||||
fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
let m = inp.model;
|
||||
let mut trial = Trial::new(d);
|
||||
@@ -1768,29 +1790,7 @@ fn answer_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Lines {
|
||||
Rhs::None => vec![],
|
||||
};
|
||||
for (k, (cell, v)) in c.lhs.iter().zip(&lhs).enumerate() {
|
||||
let (formula, status, slack) = match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => (format!("{}=Integer", abs_ref(*cell)), "Binding", 0.0),
|
||||
(Rel::Bin, _) => (format!("{}=Binary", abs_ref(*cell)), "Binding", 0.0),
|
||||
(rel, rhs_cells) => {
|
||||
let b = rhs.get(k).or_else(|| rhs.first()).copied().unwrap_or(f64::NAN);
|
||||
let rhs_text = match rhs_cells {
|
||||
Rhs::Cells(cells) => cells.get(k).or_else(|| cells.first()).map(|c| abs_ref(*c)).unwrap_or_default(),
|
||||
_ => gridcraft_core::number_to_text(b),
|
||||
};
|
||||
let slack = (v - b).abs();
|
||||
let binding = rel == Rel::Eq || slack <= PRECISION * (1.0 + b.abs());
|
||||
let slack = if binding { 0.0 } else { slack };
|
||||
(format!("{}{}{rhs_text}", abs_ref(*cell), rel.text()), if binding { "Binding" } else { "Not Binding" }, slack)
|
||||
}
|
||||
};
|
||||
rows.push(vec![
|
||||
Value::text(abs_ref(*cell)),
|
||||
Value::text(cell_name(sh, *cell)),
|
||||
num(*v),
|
||||
Value::text(formula),
|
||||
Value::text(status),
|
||||
num(slack),
|
||||
]);
|
||||
rows.push(format_constraint_row(sh, c, *cell, *v, &rhs, k));
|
||||
}
|
||||
}
|
||||
table(&mut out, r + 1, &["Cell", "Name", "Cell Value", "Formula", "Status", "Slack"], rows);
|
||||
|
||||
@@ -527,9 +527,9 @@ fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
||||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||||
let chars: Vec<char> = t.chars().collect();
|
||||
let end = chars.iter().rposition(|c| c.is_ascii_digit())?;
|
||||
let end = chars.iter().rposition(char::is_ascii_digit)?;
|
||||
let mut start = end;
|
||||
while start > 0 && chars.get(start - 1).is_some_and(|c| c.is_ascii_digit()) {
|
||||
while start > 0 && chars.get(start - 1).is_some_and(char::is_ascii_digit) {
|
||||
start -= 1;
|
||||
}
|
||||
let digits: String = chars.get(start..=end)?.iter().collect();
|
||||
|
||||
@@ -209,7 +209,7 @@ fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Sheet1");
|
||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||
let clean = clean.trim_matches('\'').to_string();
|
||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||
@@ -226,7 +226,7 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
||||
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
||||
let Some(kind) = FileKind::from_path(path) else {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path);
|
||||
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
||||
};
|
||||
save_bytes_as(wb, path, kind)
|
||||
@@ -239,7 +239,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
FileKind::Xlsx => {
|
||||
let book = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or_default();
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
|
||||
@@ -269,6 +269,17 @@ impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
/// A workbook as its file saved it: the dependency graph is built but nothing is
|
||||
/// evaluated, so every value is the one the file holds until an edit reaches it. `trust`
|
||||
/// is as for [`DocState::new`].
|
||||
pub fn from_saved(wb: Workbook, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.rebuild(&wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
let wb = Arc::new(wb);
|
||||
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
|
||||
@@ -601,8 +612,11 @@ impl Session {
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let msg =
|
||||
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
|
||||
let msg = p
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| p.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".into());
|
||||
// Restore the workbook and its history as they were, and drop the undo groups the
|
||||
// command opened.
|
||||
self.groups.retain(|g| g.id < first_group);
|
||||
|
||||
@@ -11,7 +11,10 @@ use std::collections::{HashMap, HashSet};
|
||||
use gridcraft_core::date::datetime_from_serial;
|
||||
pub use gridcraft_core::quote_sheet;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{
|
||||
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
|
||||
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
|
||||
};
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `TableStyleLight1…21 / Medium1…28 / Dark1…11` names so files round-trip).
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::*;
|
||||
use gridcraft_model::{BorderLine, BorderStyle, Cell, Color, Fill, Formula, PatternType, Style, Table, Workbook};
|
||||
|
||||
/// (family, number) from a style name, e.g. `TableStyleMedium2` → ("Medium", 2).
|
||||
pub fn parse_style_name(name: &str) -> (&'static str, u32) {
|
||||
|
||||
@@ -2000,6 +2000,69 @@ fn autosum_over_whole_rows_is_one_undo_step_within_the_data() {
|
||||
assert_eq!(v(&s, "B6"), Value::Empty);
|
||||
}
|
||||
|
||||
fn formula_at(s: &Session, a: &str) -> Option<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(a).unwrap()).and_then(|c| c.formula.as_ref().map(|f| f.text.clone()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_over_a_row_totals_it_to_the_right() {
|
||||
// As in Excel: a row selected and AutoSum puts one total to its right, not a one-cell total
|
||||
// under each of its cells.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "B2", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "B2:E2"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "F2").as_deref(), Some("SUM(B2:E2)"));
|
||||
assert_eq!(v(&s, "F2"), Value::Number(10.0));
|
||||
assert!(gridcraft_core::RangeRef::parse("B3:F3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "nothing under the row");
|
||||
assert_eq!(s.doc().unwrap().undo.len(), 2, "one undo step");
|
||||
// A row whose last cell is empty takes its total there.
|
||||
s.execute("range.setValues", json!({"range": "B4", "values": [[5, 6, 7]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "B4:E4"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "E4").as_deref(), Some("SUM(B4:D4)"));
|
||||
assert_eq!(v(&s, "F4"), Value::Empty);
|
||||
// AVERAGE and the rest go the same way.
|
||||
s.execute("formulas.autoSum", json!({"range": "B2:E2", "function": "average"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "F2").as_deref(), Some("AVERAGE(B2:E2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_over_a_whole_row_totals_it_after_the_data() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A2", "values": [[1, 2, 3]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "2:2"})).unwrap();
|
||||
assert_eq!(formula_at(&s, "D2").as_deref(), Some("SUM(A2:C2)"));
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.used_range().unwrap().a1(), "A2:D2", "not out to the last column");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autosum_fills_a_selections_empty_last_column_and_row() {
|
||||
// As in Excel: an empty last column takes each row's total, an empty last row each column's,
|
||||
// and with both the corner takes the total of the block.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "A1:C2"})).unwrap();
|
||||
assert_eq!((formula_at(&s, "C1").as_deref(), formula_at(&s, "C2").as_deref()), (Some("SUM(A1:B1)"), Some("SUM(A2:B2)")));
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(3.0), Value::Number(7.0)));
|
||||
assert!(gridcraft_core::RangeRef::parse("A3:C3").unwrap().iter().all(|c| v(&s, &c.a1()) == Value::Empty), "no totals under the rows");
|
||||
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
let steps = s.doc().unwrap().undo.len();
|
||||
s.execute("formulas.autoSum", json!({"range": "E1:G3"})).unwrap();
|
||||
for (cell, formula, value) in
|
||||
[("E3", "SUM(E1:E2)", 4.0), ("F3", "SUM(F1:F2)", 6.0), ("G1", "SUM(E1:F1)", 3.0), ("G2", "SUM(E2:F2)", 7.0), ("G3", "SUM(E1:F2)", 10.0)]
|
||||
{
|
||||
assert_eq!(formula_at(&s, cell).as_deref(), Some(formula), "{cell}");
|
||||
assert_eq!(v(&s, cell), Value::Number(value), "{cell}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().undo.len(), steps + 1, "one undo step");
|
||||
|
||||
// Neither empty: totals below each column, as before.
|
||||
s.execute("range.setValues", json!({"range": "I1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("formulas.autoSum", json!({"range": "I1:J2"})).unwrap();
|
||||
assert_eq!((formula_at(&s, "I3").as_deref(), formula_at(&s, "J3").as_deref()), (Some("SUM(I1:I2)"), Some("SUM(J1:J2)")));
|
||||
assert_eq!(v(&s, "K1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_delete_cells_never_shift_part_of_a_table() {
|
||||
// Found by the command-sequence fuzzer: Delete Cells moved part of a table's cells (so two
|
||||
@@ -3383,6 +3446,19 @@ fn absolute_objects_ignore_sort_and_structural_edits() {
|
||||
assert_eq!(image_cell(&s, pinned), Some(CellRef::parse("A3").unwrap()));
|
||||
}
|
||||
|
||||
/// Deleting a row removes the sparkline located on it; later ones shift up.
|
||||
#[test]
|
||||
fn deleting_a_row_removes_its_sparklines() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]})).unwrap();
|
||||
s.execute("insert.sparkline", json!({"range": "A2:C2", "location": "E2"})).unwrap();
|
||||
s.execute("insert.sparkline", json!({"range": "A3:C3", "location": "E3"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "2:2"})).unwrap();
|
||||
let got: Vec<(String, String)> =
|
||||
s.doc().unwrap().wb.active().unwrap().sparklines.iter().map(|sp| (sp.cell.to_string(), sp.source.clone())).collect();
|
||||
assert_eq!(got, vec![("E2".to_string(), "A2:C2".to_string())]);
|
||||
}
|
||||
|
||||
/// A "move but don't size" object follows its row across a sort but keeps its size.
|
||||
#[test]
|
||||
fn move_only_objects_follow_but_do_not_resize() {
|
||||
@@ -3775,6 +3851,35 @@ fn notes_comments_and_links_survive_a_json_save() {
|
||||
assert_eq!(after.cells, before.cells);
|
||||
}
|
||||
|
||||
/// Writing a note or a comment on a cell that has one of the same kind edits its text: the
|
||||
/// replies, the resolved state, the dates and the note's box stay. `comment` replaced the whole
|
||||
/// thread, so editing a comment dropped its replies.
|
||||
#[test]
|
||||
fn editing_a_note_or_a_comment_keeps_its_thread() {
|
||||
let mut s = s();
|
||||
s.execute("review.newComment", json!({"cell": "B2", "text": "first", "author": "Ada"})).unwrap();
|
||||
s.execute("review.replyComment", json!({"cell": "B2", "text": "reply", "author": "Bob"})).unwrap();
|
||||
s.execute("review.resolveComment", json!({"cell": "B2", "resolved": true})).unwrap();
|
||||
s.execute("review.newComment", json!({"cell": "B2", "text": "edited"})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "note", "author": "Ada"})).unwrap();
|
||||
s.execute("review.showNote", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("review.newNote", json!({"cell": "A1", "text": "changed"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
let thread = &sh.comments[&CellRef::parse("B2").unwrap()];
|
||||
assert_eq!((thread.author.as_str(), thread.text.as_str()), ("Ada", "edited"));
|
||||
assert_eq!(thread.replies, vec![("Bob".to_string(), "reply".to_string())]);
|
||||
assert!(thread.resolved && thread.threaded);
|
||||
let note = &sh.comments[&CellRef::parse("A1").unwrap()];
|
||||
assert_eq!((note.author.as_str(), note.text.as_str()), ("Ada", "changed"));
|
||||
assert!(note.visible && !note.threaded);
|
||||
// A note written over a comment replaces it.
|
||||
s.execute("review.newNote", json!({"cell": "B2", "text": "plain"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let replaced = &d.wb.active().unwrap().comments[&CellRef::parse("B2").unwrap()];
|
||||
assert!(!replaced.threaded && replaced.replies.is_empty());
|
||||
}
|
||||
|
||||
/// A cut moves the block's notes, hyperlinks and merged areas with it. The source's cells and
|
||||
/// pictures were removed but the block's merges and notes were not, so one merged block became
|
||||
/// two (an empty one left at the old place) and a note ended up on both the old and the new
|
||||
@@ -4777,3 +4882,26 @@ fn a_script_cannot_switch_windows() {
|
||||
}
|
||||
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
fn a_saved_workbook_keeps_its_values_until_an_edit_reaches_them() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=10"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
// The values a file would have saved, not the ones the formulas give.
|
||||
for (a, n) in [("B1", 99.0), ("C1", 7.0)] {
|
||||
let sheet = std::sync::Arc::make_mut(&mut wb.sheets[0]);
|
||||
let c = CellRef::parse(a).unwrap();
|
||||
let mut cell = sheet.cells.get(c).unwrap().clone();
|
||||
cell.value = Value::Number(n);
|
||||
sheet.set_cell(c, cell);
|
||||
}
|
||||
let mut saved = Session::new();
|
||||
saved.add_document(crate::DocState::from_saved(wb, None, "saved".into(), crate::Trust::FromFile));
|
||||
assert_eq!(v(&saved, "B1"), Value::Number(99.0));
|
||||
saved.execute("cell.set", json!({"cell": "A1", "input": "3"})).unwrap();
|
||||
assert_eq!(v(&saved, "B1"), Value::Number(6.0));
|
||||
assert_eq!(v(&saved, "C1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ fn guarded<T>(step: usize, kind: &'static str, f: impl FnOnce() -> Result<T, Str
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
Ok(Err(e)) => Err(fail(step, kind, e)),
|
||||
Err(p) => {
|
||||
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
let msg = p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
Err(fail(step, kind, format!("panicked: {msg}")))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! A font a formatting command derives from the theme's body font keeps following it (#2517).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn styles_xml(session: &Session) -> String {
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&session.doc().unwrap().wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
zip.by_name("xl/styles.xml").unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derived_body_font_keeps_minor_scheme() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
session.execute("home.fontSize", json!({"range": "B1", "size": 14})).unwrap();
|
||||
let xml = styles_xml(&session);
|
||||
let fonts = xml.split("<fonts").nth(1).unwrap().split("</fonts>").next().unwrap();
|
||||
let fonts: Vec<&str> = fonts.split("<font>").skip(1).collect();
|
||||
assert!(fonts.len() >= 3, "{xml}");
|
||||
for f in &fonts {
|
||||
assert!(f.contains("<scheme val=\"minor\"/>"), "every body-font font follows the theme: {f}");
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
mod extreme_selection_moves;
|
||||
mod font_scheme_minor;
|
||||
mod format_as_table_restyle;
|
||||
mod hidden_sheet_navigation;
|
||||
mod ink_roundtrip;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use gridcraft_core::{CellError, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, Expr, RefKind, Reference, SheetSel, clamp_anchor};
|
||||
|
||||
/// Copy/fill: relative parts move by `(dr, dc)`. References pushed off the sheet become `#REF!`.
|
||||
pub fn shift_relative(e: Expr, dr: i64, dc: i64) -> Expr {
|
||||
|
||||
@@ -338,12 +338,16 @@ pub fn tokenize_locale(src: &str, locale: crate::FormulaLocale) -> Result<Vec<To
|
||||
push(&mut out, Tok::Sheet(w, None), j + 1);
|
||||
i = j + 1;
|
||||
} else if ch(j) == Some(':') && {
|
||||
// Sheet1:Sheet3!A1 — look ahead for word then '!'
|
||||
// Sheet1:Sheet3!A1 — look ahead for word then '!'.
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
k += 1;
|
||||
}
|
||||
k > j + 1 && ch(k) == Some('!')
|
||||
// `Sheet1!A1:Sheet1!B2` is a range whose second end repeats the sheet, not a span.
|
||||
let repeats_sheet = is_range_end(&w)
|
||||
&& matches!(out.last(), Some(Token { tok: Tok::Sheet(p, None), .. })
|
||||
if chars.get(j + 1..k).is_some_and(|s| crate::same_name(&s.iter().map(|c| c.1).collect::<String>(), p)));
|
||||
k > j + 1 && ch(k) == Some('!') && !repeats_sheet
|
||||
} {
|
||||
let mut k = j + 1;
|
||||
while ch(k).is_some_and(|c| is_word_char(c) && !(array_depth > 0 && c == locale.array_column_separator())) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use gridcraft_core::addr::{letters_to_col, parse_a1_prefix};
|
||||
use gridcraft_core::{CellError, MAX_ROWS};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
use crate::lexer::{Tok, Token, tokenize_locale};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::fmt::Write;
|
||||
|
||||
use gridcraft_core::{CellRef, col_to_letters, number_to_text};
|
||||
|
||||
use crate::ast::*;
|
||||
use crate::ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, UnOp};
|
||||
|
||||
pub use gridcraft_core::{quote_sheet, sheet_needs_quotes as needs_quotes};
|
||||
|
||||
|
||||
@@ -5,6 +5,15 @@ fn rt(src: &str) -> String {
|
||||
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_end_repeating_sheet_name_round_trips() {
|
||||
assert_eq!(rt("'a'!A1:'a'!B2"), "a!A1:a!B2");
|
||||
assert_eq!(rt("Sheet1!A1:Sheet1!B2"), "Sheet1!A1:Sheet1!B2");
|
||||
assert_eq!(rt("SUM(a!A1:a!B2)"), "SUM(a!A1:a!B2)");
|
||||
// Sheet names match without regard to case, as Excel's do.
|
||||
assert_eq!(rt("Sheet1!A1:SHEET1!B2"), "Sheet1!A1:SHEET1!B2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sheet_qualified_name_keeps_quotes() {
|
||||
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
|
||||
@@ -503,3 +512,9 @@ fn number_literal_too_large_for_f64_is_a_parse_error() {
|
||||
}
|
||||
assert!(parse("1E+308").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_d_span_and_cell_start_still_lex_as_span() {
|
||||
assert_eq!(rt("SUM(Sheet1:Sheet3!A1)"), "SUM(Sheet1:Sheet3!A1)");
|
||||
assert_eq!(rt("SUM(Q1:Sheet3!A1)"), "SUM('Q1:Sheet3'!A1)");
|
||||
}
|
||||
|
||||
@@ -123,6 +123,60 @@ impl Criterion {
|
||||
Criterion { op, operand, bare_empty: false }
|
||||
}
|
||||
|
||||
fn matches_blank(&self, v: &Value) -> bool {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_number(&self, v: &Value, n: f64) -> bool {
|
||||
let cell = match v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_text(&self, v: &Value, pat: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_cut_text(&self, v: &Value, kept: &str) -> bool {
|
||||
match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a cell value satisfies the criterion.
|
||||
pub(crate) fn matches(&self, v: &Value) -> bool {
|
||||
let v = match v {
|
||||
@@ -133,31 +187,8 @@ impl Criterion {
|
||||
return matches!(&v, Value::Empty) || matches!(&v, Value::Text(t) if t.is_empty());
|
||||
}
|
||||
match &self.operand {
|
||||
Operand::Blank => {
|
||||
let blank = matches!(v, Value::Empty);
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match &v {
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
Operand::Number(n) => {
|
||||
let cell = match &v {
|
||||
Value::Number(x) => Some(*x),
|
||||
// Numeric text matches only for equality (COUNTIF("1") counts text "1").
|
||||
Value::Text(t) if matches!(self.op, Op::Eq | Op::Ne) => parse_number_text(t),
|
||||
_ => None,
|
||||
};
|
||||
match cell {
|
||||
Some(x) => cmp_op(self.op, compare_numbers(x, *n)),
|
||||
None => self.op == Op::Ne,
|
||||
}
|
||||
}
|
||||
Operand::Blank => self.matches_blank(&v),
|
||||
Operand::Number(n) => self.matches_number(&v, *n),
|
||||
Operand::Bool(b) => match &v {
|
||||
Value::Bool(x) => cmp_op(self.op, x.cmp(b)),
|
||||
_ => self.op == Op::Ne,
|
||||
@@ -166,27 +197,8 @@ impl Criterion {
|
||||
Value::Error(x) => cmp_op(self.op, x.code().cmp(&e.code())),
|
||||
_ => self.op == Op::Ne,
|
||||
},
|
||||
Operand::Text(pat) => match self.op {
|
||||
Op::Eq | Op::Ne => {
|
||||
// Blank cells hold no text: `"*"` skips them and `"<>*"` counts them.
|
||||
let cell_text = match &v {
|
||||
Value::Text(t) => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let m = cell_text.is_some_and(|t| wildcard_match(pat, &t));
|
||||
if self.op == Op::Eq { m } else { !m }
|
||||
}
|
||||
_ => match &v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, pat)),
|
||||
_ => false,
|
||||
},
|
||||
},
|
||||
Operand::CutText(kept) => match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
},
|
||||
Operand::Text(pat) => self.matches_text(&v, pat),
|
||||
Operand::CutText(kept) => self.matches_cut_text(&v, kept),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,8 +123,16 @@ fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
fn isoweek(sys: DateSystem, s: i64) -> R<i64> {
|
||||
let iso_wd = (weekday0(sys, s) + 6) % 7 + 1; // Monday = 1
|
||||
let thursday = s - iso_wd as i64 + 4;
|
||||
let y = if thursday < 1 { 1900 } else { dt(sys, thursday as f64)?.year as i64 };
|
||||
let jan1 = ymd_serial(sys, y, 1, 1)?;
|
||||
let first = match sys {
|
||||
DateSystem::D1900 => 1,
|
||||
DateSystem::D1904 => 0,
|
||||
};
|
||||
let jan1 = if thursday < first {
|
||||
// The week belongs to the year before the calendar starts.
|
||||
first - 365
|
||||
} else {
|
||||
ymd_serial(sys, dt(sys, thursday as f64)?.year as i64, 1, 1)?
|
||||
};
|
||||
Ok((thursday - jan1).div_euclid(7) + 1)
|
||||
}
|
||||
|
||||
@@ -441,18 +449,14 @@ pub(crate) fn yearfrac_core(sys: DateSystem, s: i64, e: i64, basis: i64) -> R<f6
|
||||
Ok(match basis {
|
||||
0 => {
|
||||
let (mut a, mut b) = (d1.day as i64, d2.day as i64);
|
||||
if a == 31 && b == 31 {
|
||||
a = 30;
|
||||
if last_of_feb(&d1) && last_of_feb(&d2) {
|
||||
b = 30;
|
||||
} else if a == 31 {
|
||||
}
|
||||
if last_of_feb(&d1) || a == 31 {
|
||||
a = 30;
|
||||
} else if a == 30 && b == 31 {
|
||||
}
|
||||
if a == 30 && b == 31 {
|
||||
b = 30;
|
||||
} else if last_of_feb(&d1) && last_of_feb(&d2) {
|
||||
a = 30;
|
||||
b = 30;
|
||||
} else if last_of_feb(&d1) {
|
||||
a = 30;
|
||||
}
|
||||
((d2.year as i64 - d1.year as i64) * 360 + (d2.month as i64 - d1.month as i64) * 30 + b - a) as f64 / 360.0
|
||||
}
|
||||
@@ -811,6 +815,13 @@ mod tests {
|
||||
close(ev("YEARFRAC", vec![s, n(d(2012.0, 1.0, 1.0))]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yearfrac_basis0_feb_end_to_31() {
|
||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
||||
close(ev("YEARFRAC", vec![n(d(2020.0, 2.0, 29.0)), n(d(2020.0, 3.0, 31.0)), n(0.0)]), 30.0 / 360.0);
|
||||
close(ev("YEARFRAC", vec![n(d(2021.0, 2.0, 28.0)), n(d(2021.0, 2.0, 28.0)), n(0.0)]), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_lifting() {
|
||||
let v = ev("YEAR", vec![av(row(&[43831.0, 46302.0]))]);
|
||||
@@ -844,6 +855,19 @@ mod tests {
|
||||
is_err(crate::call(spec, &[t("2/29/1900")], &mut ctx), CellError::Value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isoweek_at_calendar_start() {
|
||||
let iso = crate::lookup("ISOWEEKNUM").unwrap();
|
||||
let wk = crate::lookup("WEEKNUM").unwrap();
|
||||
let mut ctx = TestCtx::default();
|
||||
close(crate::call(iso, &[n(1.0)], &mut ctx), 52.0);
|
||||
close(crate::call(wk, &[n(1.0), n(21.0)], &mut ctx), 52.0);
|
||||
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
|
||||
for s in [0.0, 1.0, 2.0] {
|
||||
close(crate::call(iso, &[n(s)], &mut ctx), 53.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_arguments_use_the_workbook_date_system() {
|
||||
let mut ctx = TestCtx { sys: gridcraft_core::DateSystem::D1904, ..TestCtx::default() };
|
||||
|
||||
@@ -710,7 +710,12 @@ im1!(imexp, |z: Cx| Ok(z.exp()));
|
||||
im1!(imln, |z: Cx| z.ln());
|
||||
im1!(imlog10, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_10, l.im / std::f64::consts::LN_10)));
|
||||
im1!(imlog2, |z: Cx| z.ln().map(|l| Cx::new(l.re / std::f64::consts::LN_2, l.im / std::f64::consts::LN_2)));
|
||||
im1!(imsqrt, |z: Cx| z.powf(0.5));
|
||||
im1!(imsqrt, |z: Cx| {
|
||||
let m = z.abs();
|
||||
let re = ((m + z.re) / 2.0).sqrt();
|
||||
let im = ((m - z.re) / 2.0).sqrt();
|
||||
Ok(Cx::new(re, if z.im < 0.0 { -im } else { im }))
|
||||
});
|
||||
im1!(imsin, |z: Cx| Ok(z.sin()));
|
||||
im1!(imcos, |z: Cx| Ok(z.cos()));
|
||||
im1!(imtan, |z: Cx| z.sin().div(z.cos()));
|
||||
@@ -742,7 +747,7 @@ fn im_binary(a: &[Arg], f: fn(Cx, Cx) -> R<Cx>) -> R<Value> {
|
||||
fmt_complex(f(x, y)?, s.unwrap_or('i'))
|
||||
}
|
||||
fn imdiv(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, |x, y| x.div(y))
|
||||
im_binary(a, Cx::div)
|
||||
}
|
||||
fn imsub(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
im_binary(a, |x, y| Ok(x.sub(y)))
|
||||
@@ -1271,6 +1276,10 @@ mod tests {
|
||||
cx(ev("IMLOG2", vec![t("3+4i")]), 2.321_928_094_887_362, 1.337_804_212_450_976_8);
|
||||
cx(ev("IMSQRT", vec![t("1+i")]), 1.098_684_113_467_809_9, 0.455_089_860_562_227_3);
|
||||
cx(ev("IMSQRT", vec![t("-4")]), 0.0, 2.0);
|
||||
is_text(ev("IMSQRT", vec![t("-4")]), "2i");
|
||||
is_text(ev("IMSQRT", vec![t("-9")]), "3i");
|
||||
is_text(ev("IMSQRT", vec![t("4")]), "2");
|
||||
is_text(ev("IMSQRT", vec![t("-2i")]), "1-i");
|
||||
is_text(ev("IMPOWER", vec![t("2+3i"), n(3.0)]), "-46+9i");
|
||||
cx(ev("IMPOWER", vec![t("2+3i"), n(0.5)]), 1.674_149_228_035_540_2, 0.895_977_476_129_838);
|
||||
cx(ev("IMSIN", vec![t("3+4i")]), 3.853_738_037_919_377, -27.016_813_258_003_93);
|
||||
|
||||
@@ -278,7 +278,7 @@ fn aggregate(kind: Aggregate, xs: &mut [f64]) -> Option<f64> {
|
||||
Aggregate::Max => xs.iter().copied().reduce(f64::max),
|
||||
Aggregate::Min => xs.iter().copied().reduce(f64::min),
|
||||
Aggregate::Median => {
|
||||
xs.sort_by(|a, b| a.total_cmp(b));
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let k = xs.len() / 2;
|
||||
let hi = xs.get(k).copied()?;
|
||||
if xs.len() % 2 == 1 { Some(hi) } else { Some((xs.get(k.checked_sub(1)?).copied()? + hi) / 2.0) }
|
||||
@@ -415,7 +415,7 @@ impl Timeline {
|
||||
return None;
|
||||
}
|
||||
let mut sorted = times.to_vec();
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
sorted.sort_by(f64::total_cmp);
|
||||
sorted.dedup();
|
||||
let axis = detect_axis(&sorted, sys).ok()?;
|
||||
let last = axis.position(*sorted.last()?)?.round();
|
||||
|
||||
@@ -201,7 +201,7 @@ impl FnSpec {
|
||||
pub(crate) const VAR: usize = usize::MAX;
|
||||
|
||||
fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&'static str, usize>)> = OnceLock::new();
|
||||
static REG: OnceLock<(Vec<FnSpec>, HashMap<&str, usize>)> = OnceLock::new();
|
||||
REG.get_or_init(|| {
|
||||
let mut v: Vec<FnSpec> = Vec::new();
|
||||
for list in [
|
||||
|
||||
@@ -918,7 +918,9 @@ fn minverse(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let inv = inv.into_iter().map(|x| if x.abs() < 1e-15 { 0.0 } else { x }).collect();
|
||||
// Rounding noise in the inverse scales with 1/scale of the input matrix.
|
||||
let noise = 1e-15 / scale;
|
||||
let inv = inv.into_iter().map(|x| if x.abs() < noise { 0.0 } else { x }).collect();
|
||||
to_value_array(n, n, inv)
|
||||
}
|
||||
|
||||
@@ -1622,6 +1624,17 @@ mod tests {
|
||||
is_text(ev2("ROMAN", 99.0, 4.0), "IC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minverse_scale_relative() {
|
||||
let big = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e20), nv(0.0)], vec![nv(0.0), nv(1e20)]]))]);
|
||||
let r = rows_of(&big);
|
||||
close_tol(r[0][0].clone(), 1e-20, 1e-25);
|
||||
close_tol(r[1][1].clone(), 1e-20, 1e-25);
|
||||
let small = ev("MINVERSE", vec![av(arr(vec![vec![nv(1e-20), nv(0.0)], vec![nv(0.0), nv(1e-20)]]))]);
|
||||
let r = rows_of(&small);
|
||||
close_tol(r[0][0].clone(), 1e20, 1e10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrices() {
|
||||
let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]);
|
||||
|
||||
@@ -43,7 +43,7 @@ fn var_p(x: &[f64]) -> R<f64> {
|
||||
}
|
||||
|
||||
fn sorted(mut v: Vec<f64>) -> Vec<f64> {
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
v.sort_by(f64::total_cmp);
|
||||
v
|
||||
}
|
||||
|
||||
@@ -256,6 +256,9 @@ fn intercept(y: &[f64], x: &[f64]) -> R<f64> {
|
||||
|
||||
fn trunc_digits(r: f64, sig: f64) -> f64 {
|
||||
let f = 10f64.powi(sig as i32);
|
||||
if !f.is_finite() {
|
||||
return r;
|
||||
}
|
||||
((r * f) + 1e-9).floor() / f
|
||||
}
|
||||
|
||||
@@ -2486,6 +2489,11 @@ mod tests {
|
||||
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(7.0)]), 0.7);
|
||||
close(ev("PERCENTRANK.EXC", vec![e9.clone(), n(5.43)]), 0.381);
|
||||
close(ev("PERCENTRANK.EXC", vec![e9, n(5.43), n(1.0)]), 0.3);
|
||||
let t = c(&[1.0, 2.0, 3.0]);
|
||||
close(ev("PERCENTRANK", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(2.0), n(400.0)]), 0.5);
|
||||
close(ev("PERCENTRANK.INC", vec![t.clone(), n(1.0), n(400.0)]), 0.0);
|
||||
close(ev("PERCENTRANK.EXC", vec![t, n(2.0), n(400.0)]), 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -523,7 +523,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
let mut j = t.len();
|
||||
while j > 0 {
|
||||
let best =
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((j - l, j));
|
||||
@@ -537,7 +537,7 @@ fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < t.len() {
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(Vec::len).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((i, i + l));
|
||||
@@ -588,7 +588,7 @@ fn textafter(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char>> {
|
||||
if delims.iter().all(|d| d.is_empty()) {
|
||||
if delims.iter().all(Vec::is_empty) {
|
||||
return vec![s.to_vec()];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
@@ -748,9 +748,9 @@ fn convert_replacement(r: &str) -> String {
|
||||
let mut i = 0;
|
||||
while i < c.len() {
|
||||
let ch = c[i];
|
||||
if ch == '$' && c.get(i + 1).is_some_and(|d| d.is_ascii_digit()) {
|
||||
if ch == '$' && c.get(i + 1).is_some_and(char::is_ascii_digit) {
|
||||
let mut j = i + 1;
|
||||
while c.get(j).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while c.get(j).is_some_and(char::is_ascii_digit) {
|
||||
j += 1;
|
||||
}
|
||||
out.push_str("${");
|
||||
|
||||
@@ -25,14 +25,22 @@ const MAX_REPLY: u64 = 256 * 1024 * 1024;
|
||||
/// A running GridCraft app, reached through its loopback control port.
|
||||
pub struct Remote {
|
||||
addr: String,
|
||||
token: String,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening.
|
||||
/// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. The app
|
||||
/// requires a token (#777): `GRIDCRAFT_CONTROL_TOKEN`, else the file the app wrote for this port.
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
|
||||
let token = crate::control_auth::client_token(addr).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
Self::connect_with_token(addr, token)
|
||||
}
|
||||
|
||||
/// Like [`Remote::connect`] with a token the caller already has.
|
||||
pub fn connect_with_token(addr: &str, token: String) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), token, conn: None, next_id: 1 };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
@@ -51,7 +59,9 @@ impl Remote {
|
||||
s.set_read_timeout(Some(IO_TIMEOUT)).ok();
|
||||
s.set_write_timeout(Some(IO_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
let mut conn = (BufReader::new(read), s);
|
||||
Self::authenticate(&mut conn, &self.token)?;
|
||||
self.conn = Some(conn);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
@@ -60,6 +70,25 @@ impl Remote {
|
||||
Err(last)
|
||||
}
|
||||
|
||||
/// The handshake every connection starts with: the app closes one that doesn't send the token first.
|
||||
fn authenticate(conn: &mut (BufReader<TcpStream>, TcpStream), token: &str) -> std::io::Result<()> {
|
||||
let (reader, writer) = conn;
|
||||
writer.write_all(crate::control_auth::auth_line(0, token).as_bytes())?;
|
||||
writer.write_all(b"\n")?;
|
||||
writer.flush()?;
|
||||
let mut reply = String::new();
|
||||
let n = std::io::Read::take(reader, 64 * 1024).read_line(&mut reply)?;
|
||||
let ok = n > 0 && serde_json::from_str::<Value>(reply.trim()).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"the app refused the control token (is GRIDCRAFT_CONTROL_TOKEN or the app's token file for this port out of date?)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.reconnect()?;
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Authentication of the control channel (#777).
|
||||
//!
|
||||
//! Anything on the machine, and a web page that POSTs `text/plain` to `127.0.0.1:<port>`, can
|
||||
//! reach a loopback port. So the first line of every connection must be
|
||||
//! `{"id":…,"method":"auth","params":{"token":"…"}}`, with the secret the app made at start. The
|
||||
//! app keeps it in memory and writes it to a file only the user can read (or the user chooses it
|
||||
//! with `GRIDCRAFT_CONTROL_TOKEN`); [`Remote`](crate::Remote) reads it from the same places.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Environment variable holding a token chosen by the user. The app uses it instead of making
|
||||
/// one (and writes no file); clients use it instead of reading the file.
|
||||
pub const TOKEN_ENV: &str = "GRIDCRAFT_CONTROL_TOKEN";
|
||||
/// The method of the handshake line.
|
||||
pub const AUTH_METHOD: &str = "auth";
|
||||
/// Shortest token the app accepts from the user (a made one has 256 bits).
|
||||
pub const MIN_TOKEN_LEN: usize = 16;
|
||||
/// Longest first line the app reads from a peer that hasn't authenticated yet.
|
||||
pub const MAX_AUTH_LINE: usize = 4096;
|
||||
|
||||
/// Compare two secrets without stopping at the first difference. Unequal lengths are unequal
|
||||
/// at once (the length of a token isn't secret).
|
||||
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
// Keep the optimiser from turning the loop into an early exit.
|
||||
std::hint::black_box(diff) == 0
|
||||
}
|
||||
|
||||
/// A made token as lowercase hex (two characters per byte).
|
||||
pub fn hex(bytes: &[u8]) -> String {
|
||||
const DIGITS: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut s = String::with_capacity(bytes.len().saturating_mul(2));
|
||||
for b in bytes {
|
||||
s.push(char::from(DIGITS[usize::from(b >> 4)]));
|
||||
s.push(char::from(DIGITS[usize::from(b & 15)]));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The handshake line a client sends first.
|
||||
pub fn auth_line(id: u64, token: &str) -> String {
|
||||
json!({"id": id, "method": AUTH_METHOD, "params": {"token": token}}).to_string()
|
||||
}
|
||||
|
||||
/// Check a connection's first line. `Ok` is the reply to send before serving further requests;
|
||||
/// `Err` is the reply for a refused connection, which the caller sends and then closes. The
|
||||
/// refusal is the same whatever was wrong, so it tells a stranger nothing.
|
||||
pub fn check_first_line(line: &str, token: &str) -> Result<Value, Value> {
|
||||
let refused = || json!({"ok": false, "error": "authentication required"});
|
||||
let msg: Value = serde_json::from_str(line.trim()).map_err(|_| refused())?;
|
||||
if msg.get("method").and_then(Value::as_str) != Some(AUTH_METHOD) {
|
||||
return Err(refused());
|
||||
}
|
||||
let given = msg.get("params").and_then(|p| p.get("token")).and_then(Value::as_str).unwrap_or("");
|
||||
// Always compare, so a missing token costs the same as a wrong one.
|
||||
if constant_time_eq(given.as_bytes(), token.as_bytes()) && !token.is_empty() {
|
||||
Ok(json!({"id": msg.get("id").cloned().unwrap_or(Value::Null), "ok": true, "result": {"authenticated": true}}))
|
||||
} else {
|
||||
Err(refused())
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the app writes the token it made for `port`, and where clients look for it: a folder
|
||||
/// only the user can open (`$XDG_RUNTIME_DIR/gridcraft`, else `~/.cache/gridcraft`; on Windows
|
||||
/// `%USERPROFILE%\.gridcraft`), file `control-<port>.token`. `None` when no such folder is known.
|
||||
pub fn token_path(port: u16) -> Option<PathBuf> {
|
||||
let dir = if cfg!(windows) {
|
||||
PathBuf::from(std::env::var_os("USERPROFILE").filter(|v| !v.is_empty())?).join(".gridcraft")
|
||||
} else if let Some(run) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
|
||||
PathBuf::from(run).join("gridcraft")
|
||||
} else {
|
||||
PathBuf::from(std::env::var_os("HOME").filter(|v| !v.is_empty())?).join(".cache").join("gridcraft")
|
||||
};
|
||||
Some(dir.join(format!("control-{port}.token")))
|
||||
}
|
||||
|
||||
/// The port of a control address (`127.0.0.1:7979` → 7979).
|
||||
fn port_of(addr: &str) -> Option<u16> {
|
||||
addr.rsplit(':').next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The token a client uses for `addr`: [`TOKEN_ENV`] if set, else the file the app wrote.
|
||||
pub fn client_token(addr: &str) -> Result<String, String> {
|
||||
if let Ok(t) = std::env::var(TOKEN_ENV)
|
||||
&& !t.trim().is_empty()
|
||||
{
|
||||
return Ok(t.trim().to_string());
|
||||
}
|
||||
let path = port_of(addr).and_then(token_path).ok_or_else(|| format!("no control token: set {TOKEN_ENV}"))?;
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(t) if !t.trim().is_empty() => Ok(t.trim().to_string()),
|
||||
_ => Err(format!("no control token: set {TOKEN_ENV}, or start the app on this machine and user (it writes {})", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `token` to [`token_path`] for the app: the folder is made private (mode 0700 on Unix),
|
||||
/// an older file is replaced, and the new one is created readable by the user only (mode 0600).
|
||||
/// Returns the path.
|
||||
pub fn write_token_file(port: u16, token: &str) -> std::io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let path = token_path(port).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user folder for the control token"))?;
|
||||
let dir = path.parent().ok_or_else(|| std::io::Error::other("token path has no folder"))?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Only our own folder: leave a shared one (`$XDG_RUNTIME_DIR` itself) as it is.
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
// Replace rather than open: a leftover file may have looser permissions, or be a link.
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(&path)?;
|
||||
f.write_all(token.as_bytes())?;
|
||||
f.write_all(b"\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
pub mod control_auth;
|
||||
mod headless;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
+49
-2
@@ -366,6 +366,11 @@ fn remote_backend_over_tcp() {
|
||||
let stream = stream.unwrap();
|
||||
let mut w = stream.try_clone().unwrap();
|
||||
let mut r = BufReader::new(stream);
|
||||
// Every connection starts with the token, as the app requires.
|
||||
let mut first = String::new();
|
||||
r.read_line(&mut first).unwrap();
|
||||
let hello = crate::control_auth::check_first_line(&first, "secret-token-0123456789").unwrap();
|
||||
w.write_all(format!("{hello}\n").as_bytes()).unwrap();
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if r.read_line(&mut line).unwrap_or(0) == 0 {
|
||||
@@ -385,7 +390,7 @@ fn remote_backend_over_tcp() {
|
||||
});
|
||||
let addr = control_addr(&port.to_string());
|
||||
assert_eq!(addr, format!("127.0.0.1:{port}"));
|
||||
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
|
||||
let mut s = Server::new(Box::new(Remote::connect_with_token(&addr, "secret-token-0123456789".into()).unwrap()));
|
||||
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=1+1"}))["result"], json!(2.0));
|
||||
// Second call reconnects transparently.
|
||||
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "7"}));
|
||||
@@ -394,7 +399,49 @@ fn remote_backend_over_tcp() {
|
||||
assert!(msg.contains("unknown command"), "{msg}");
|
||||
drop(s);
|
||||
handle.join().unwrap();
|
||||
assert!(Remote::connect("127.0.0.1:1").is_err());
|
||||
assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_auth_handshake_and_compare() {
|
||||
use crate::control_auth::{auth_line, check_first_line, constant_time_eq, hex};
|
||||
let token = "0123456789abcdef0123456789abcdef";
|
||||
let ok = check_first_line(&auth_line(7, token), token).unwrap();
|
||||
assert_eq!((ok["id"].clone(), ok["ok"].clone()), (json!(7), json!(true)));
|
||||
// Wrong or missing token, another method, and non-JSON are all refused the same way.
|
||||
let wrong = check_first_line(&auth_line(1, "0123456789abcdef0123456789abcdee"), token).unwrap_err();
|
||||
for bad in [
|
||||
auth_line(1, ""),
|
||||
r#"{"id":1,"method":"auth","params":{}}"#.into(),
|
||||
r#"{"id":1,"method":"app.save","params":{"path":"/tmp/x","token":"0123456789abcdef0123456789abcdef"}}"#.into(),
|
||||
"POST / HTTP/1.1".into(),
|
||||
String::new(),
|
||||
] {
|
||||
assert_eq!(check_first_line(&bad, token).unwrap_err(), wrong, "{bad}");
|
||||
}
|
||||
// An empty configured token never matches an empty given one.
|
||||
assert!(check_first_line(&auth_line(1, ""), "").is_err());
|
||||
assert!(constant_time_eq(b"abc", b"abc"));
|
||||
assert!(!constant_time_eq(b"abc", b"abd"));
|
||||
assert!(!constant_time_eq(b"abc", b"abcd"));
|
||||
assert_eq!(hex(&[0x00, 0xab, 0xff]), "00abff");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn control_token_file_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
// Unique port per run so parallel tests don't share a file; the folder is the real one.
|
||||
let port = 40_000 + (std::process::id() % 20_000) as u16;
|
||||
let path = crate::control_auth::write_token_file(port, "tok-0123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::metadata(path.parent().unwrap()).unwrap().permissions().mode() & 0o777, 0o700);
|
||||
// A looser leftover is replaced, not reused.
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o666)).unwrap();
|
||||
crate::control_auth::write_token_file(port, "tok-1123456789abcdef").unwrap();
|
||||
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap().trim(), "tok-1123456789abcdef");
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,6 +56,8 @@ pub struct PreservedSheet {
|
||||
pub rel_id: String,
|
||||
/// How many worksheets come before it: its position among the model's sheets.
|
||||
pub before: usize,
|
||||
/// Its `sheetId` in the source file.
|
||||
pub sheet_id: u32,
|
||||
}
|
||||
|
||||
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
|
||||
|
||||
@@ -7,7 +7,10 @@ use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::*;
|
||||
use crate::features::{
|
||||
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
|
||||
Validation,
|
||||
};
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
|
||||
@@ -52,6 +55,10 @@ impl SheetViewMode {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
/// The `sheetId` the file gave the sheet, written back on save so that what refers to the
|
||||
/// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub file_id: u32,
|
||||
pub cells: CellStore,
|
||||
/// Static pictures are sparse rich content. Ordinary cells carry no picture pointer.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty", with = "picture_map")]
|
||||
@@ -142,6 +149,7 @@ impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
file_id: 0,
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
spill: BTreeMap::new(),
|
||||
@@ -622,8 +630,11 @@ mod tests {
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
mod picture_map {
|
||||
use super::*;
|
||||
use serde::{Deserializer, Serializer};
|
||||
use crate::cell::CellPicture;
|
||||
use gridcraft_core::CellRef;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
pub fn serialize<S: Serializer>(map: &BTreeMap<CellRef, Arc<CellPicture>>, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
map.iter().collect::<Vec<_>>().serialize(serializer)
|
||||
}
|
||||
|
||||
@@ -193,6 +193,14 @@ pub struct Font {
|
||||
/// Font family class (1 roman, 2 swiss, 3 modern…); `None` is swiss, the default.
|
||||
#[serde(default)]
|
||||
pub family: Option<u8>,
|
||||
/// The font follows the theme's body font (`<scheme val="minor"/>`) while its face is the
|
||||
/// theme's minor font. Fonts derived from it (bold, a colour, a size) keep the link.
|
||||
#[serde(default = "default_minor")]
|
||||
pub minor: bool,
|
||||
}
|
||||
|
||||
fn default_minor() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Eq for Font {}
|
||||
@@ -200,7 +208,7 @@ impl Hash for Font {
|
||||
fn hash<H: Hasher>(&self, h: &mut H) {
|
||||
self.name.hash(h);
|
||||
self.size.to_bits().hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family).hash(h);
|
||||
(self.bold, self.italic, self.underline, self.strike, self.color, self.vert, self.charset, self.family, self.minor).hash(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,6 +225,7 @@ impl Default for Font {
|
||||
vert: VertAlign::Baseline,
|
||||
charset: None,
|
||||
family: None,
|
||||
minor: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
let s = if k + 1 == count {
|
||||
dc.get(k..).map(|r| r.iter().collect::<String>()).unwrap_or_default()
|
||||
} else {
|
||||
dc.get(k).map(|c| c.to_string()).unwrap_or_default()
|
||||
dc.get(k).map(ToString::to_string).unwrap_or_default()
|
||||
};
|
||||
let s = if s.is_empty() {
|
||||
match toks.get(ti) {
|
||||
|
||||
@@ -99,8 +99,8 @@ pub(crate) fn fold(c: char) -> Option<char> {
|
||||
'ž' => 'z',
|
||||
'Œ' => 'W',
|
||||
'œ' => 'm',
|
||||
'‘' | '’' | '‚' => '\'',
|
||||
'“' | '”' | '„' => '"',
|
||||
'‘'..='‚' => '\'',
|
||||
'“'..='„' => '"',
|
||||
'–' | '€' | '£' | '¥' | '¢' | '¤' => '0',
|
||||
'\u{a0}' => ' ',
|
||||
'·' | '•' => '.',
|
||||
|
||||
@@ -9,10 +9,15 @@ description = "GridCraft egui frontend (Excel-style UI); swappable — nothing b
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" }
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the same reviewed immutable
|
||||
# craft-libs revision as the desktop's craft-print dependency.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
# this feature. The UI draws ✕, ◀, ↘ and 📋 as text; without these faces the strict kittest
|
||||
# harness (and a host with no symbol font) renders them as tofu.
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -31,12 +36,13 @@ epaint_default_fonts = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true, features = ["test-support"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
# The desktop's preferred language for the first-run interface language.
|
||||
sys-locale = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# Read the browser's preferred language for the first-run web interface language.
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Navigator", "Performance"] }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -12,6 +12,10 @@ use std::fmt::Write as _;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
#[path = "src/craft_fonts_policy.rs"]
|
||||
mod craft_fonts_policy;
|
||||
|
||||
/// Build script: reads the contributor JSON/TOML and writes generated `credits.rs`, then emits `craft_fonts.rs`; missing or malformed optional inputs warn, while required mode reports build errors.
|
||||
fn main() {
|
||||
// Read when the script runs: `env!` would keep the path from when the script was compiled,
|
||||
// which goes stale if the checkout moves (and a missing file reruns the script every build).
|
||||
@@ -27,6 +31,107 @@ fn main() {
|
||||
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||
println!("cargo::warning=writing {}: {e}", out.display());
|
||||
}
|
||||
emit_craft_fonts();
|
||||
}
|
||||
|
||||
/// The optional craft-fonts build input (<https://github.com/storytold/craft-fonts>, recipe from
|
||||
/// its `docs/integration.md`). With `CRAFT_FONTS_DIR=<absolute checkout>` the permitted fonts in
|
||||
/// its `fonts/manifest.txt` are embedded as `CRAFT_FONTS` for `theme`'s fallback chains; unset,
|
||||
/// the table is empty. Release and acceptance builds also set `CRAFT_FONTS_REQUIRED=1` (exactly
|
||||
/// `1`; any other value leaves required mode off), which turns a missing checkout, a missing
|
||||
/// required face or a malformed row into a build error.
|
||||
///
|
||||
/// GridCraft never copies font bytes into this repository and never reads a fixture from Cargo
|
||||
/// configuration: the only input is the environment variable. Barred Adobe/Source/Noto CJK faces
|
||||
/// stay in the shared repository for other consumers but are filtered here. A web build embeds
|
||||
/// only the explicit `WEB_ALLOWED` faces (no CJK collection). Native builds additionally opt into
|
||||
/// craft-fonts' `fonts/simplified-chinese-manifest.txt` (the OFL LXGW face DeckCraft uses): it is
|
||||
/// optional for ordinary builds and required for `CRAFT_FONTS_REQUIRED=1` native builds.
|
||||
fn emit_craft_fonts() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let required = craft_fonts_policy::required_mode(std::env::var("CRAFT_FONTS_REQUIRED").ok().as_deref());
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut src = String::new();
|
||||
let _ = writeln!(src, "pub static CRAFT_FONTS_REQUIRED: bool = {required};");
|
||||
src.push_str("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
match std::env::var_os("CRAFT_FONTS_DIR").map(std::path::PathBuf::from) {
|
||||
Some(dir) => match craft_fonts(&dir, wasm, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
},
|
||||
None if required => {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED=1 requires CRAFT_FONTS_DIR (an absolute craft-fonts checkout)");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest row GridCraft may embed.
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let rows = craft_fonts_policy::parse_manifest(&text)?;
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
if required {
|
||||
craft_fonts_policy::check_required(&rows)?;
|
||||
}
|
||||
let chinese = if wasm { Vec::new() } else { chinese_font_rows(dir, required)? };
|
||||
for row in rows.iter().chain(&chinese) {
|
||||
if craft_fonts_policy::barred(&row.family) {
|
||||
println!("cargo::warning=craft-fonts: not embedding {} {} (repository asset policy)", row.family, row.style);
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for row in craft_fonts_policy::embeddable(&rows, wasm).into_iter().chain(craft_fonts_policy::embeddable(&chinese, wasm)) {
|
||||
let path = dir.join(&row.file).canonicalize().map_err(|e| format!("{}: {e}", row.file))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = row.scripts.iter().map(|s| format!("{s:?}")).collect();
|
||||
let family = &row.family;
|
||||
let style = &row.style;
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({path:?}) }},",
|
||||
scripts.join(", "),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The opt-in native Simplified Chinese rows. Older craft-fonts checkouts may lack the manifest, so
|
||||
/// an ordinary build warns and carries on; a required acceptance build fails rather than passing
|
||||
/// Chinese coverage only because the face was silently absent.
|
||||
fn chinese_font_rows(dir: &std::path::Path, required: bool) -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
let manifest = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let optional = |e: String| -> Result<Vec<craft_fonts_policy::ManifestRow>, String> {
|
||||
if required {
|
||||
Err(format!("{}: {e}", manifest.display()))
|
||||
} else {
|
||||
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", manifest.display());
|
||||
Ok(Vec::new())
|
||||
}
|
||||
};
|
||||
match std::fs::read_to_string(&manifest) {
|
||||
Ok(text) => match craft_fonts_policy::parse_manifest(&text).and_then(|rows| {
|
||||
craft_fonts_policy::check_declared_scripts(&rows)?;
|
||||
craft_fonts_policy::check_chinese_required(&rows)?;
|
||||
Ok(rows)
|
||||
}) {
|
||||
Ok(rows) => Ok(rows),
|
||||
Err(e) => optional(e),
|
||||
},
|
||||
Err(e) => optional(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
|
||||
+130
-38
@@ -258,9 +258,10 @@ fn save(app: &mut SheetApp, out: &mut Outcome) {
|
||||
|
||||
// ---------------------------------------------------------------- navigation
|
||||
|
||||
/// Allocates and paints one Backstage navigation row: selected or hover fill, icon, label and button semantics. Returns the row's click response.
|
||||
fn nav_item(ui: &mut Ui, icon: Icon, label: &str, selected: bool, text: Color32, sel: Color32) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, selected, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
|
||||
let p = ui.painter();
|
||||
if selected {
|
||||
p.rect_filled(rect, 0.0, sel);
|
||||
@@ -297,7 +298,7 @@ fn dim(ui: &mut Ui, text: &str) {
|
||||
fn tile(ui: &mut Ui, label: &str, sample: Option<usize>) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(150.0, 140.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let p = ui.painter();
|
||||
let thumb = Rect::from_min_size(rect.min, vec2(rect.width(), 104.0));
|
||||
p.rect_filled(thumb, 4.0, t.grid_bg);
|
||||
@@ -352,8 +353,8 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
/// File name and folder of a recent path, split as text.
|
||||
fn split_path(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(|f| f.to_str()).unwrap_or("").to_string();
|
||||
let name = p.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(path).to_string();
|
||||
let folder = p.parent().and_then(std::path::Path::to_str).unwrap_or("").to_string();
|
||||
(name, folder)
|
||||
}
|
||||
|
||||
@@ -388,7 +389,7 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Ou
|
||||
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
@@ -514,7 +515,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 56.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, title));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
|
||||
let p = ui.painter();
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
@@ -529,7 +530,7 @@ fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Respo
|
||||
fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(96.0, 88.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 6.0, if resp.hovered() { t.hover } else { t.ribbon });
|
||||
p.rect_stroke(rect, 6.0, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.ribbon_border }), StrokeKind::Inside);
|
||||
@@ -589,6 +590,7 @@ pub fn protect_workbook(app: &mut SheetApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the workbook title, path, sheet count and structure protection, plus editable document properties. Committing a changed field after it loses focus runs `file.properties`.
|
||||
fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
@@ -643,13 +645,13 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
ui.end_row();
|
||||
for (i, (key, label)) in PROPERTIES.iter().enumerate() {
|
||||
let (Some(draft), Some(saved)) = (bs.props.get_mut(i), current.get(i)) else { continue };
|
||||
ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
let label = ui.label(RichText::new(lang.tr(label)).color(t.text_dim));
|
||||
let id = ui.make_persistent_id(("backstage_prop", *key));
|
||||
// Show the workbook's value unless the field is being edited (undo, another window).
|
||||
if !ui.ctx().memory(|m| m.has_focus(id)) {
|
||||
draft.clone_from(saved);
|
||||
}
|
||||
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0));
|
||||
let resp = ui.add(egui::TextEdit::singleline(draft).id(id).desired_width(360.0)).labelled_by(label.id);
|
||||
if resp.lost_focus() && draft != saved {
|
||||
let mut params = serde_json::Map::new();
|
||||
params.insert((*key).to_string(), json!(draft.clone()));
|
||||
@@ -660,6 +662,7 @@ fn info(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the Save As type picker and action: xlsx opens the `saveAs` dialog, other types export, and the page closes when the save/export succeeds (or a web download handles it).
|
||||
fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
dim(ui, lang.tr("Save the workbook under another name or in another folder."));
|
||||
@@ -667,14 +670,19 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
|
||||
let fmt = bs.save_format;
|
||||
let current = SAVE_TYPES.iter().find(|(ext, _)| *ext == fmt).map_or("Excel Workbook (*.xlsx)", |(_, label)| *label);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(lang.tr("Save as type"));
|
||||
egui::ComboBox::from_id_salt("backstage_save_type").width(280.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
|
||||
for (ext, label) in SAVE_TYPES {
|
||||
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
|
||||
bs.save_format = ext;
|
||||
let label = ui.label(lang.tr("Save as type"));
|
||||
egui::ComboBox::from_id_salt("backstage_save_type")
|
||||
.width(280.0)
|
||||
.selected_text(lang.tr(current))
|
||||
.show_ui(ui, |ui| {
|
||||
for (ext, label) in SAVE_TYPES {
|
||||
if ui.selectable_label(fmt == ext, lang.tr(label)).clicked() {
|
||||
bs.save_format = ext;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
// Desktop: the system Save dialog. Web: the file downloads.
|
||||
@@ -696,6 +704,7 @@ fn save_as(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcom
|
||||
dim(ui, lang.tr("A CSV file, web page or PDF is a copy: the workbook keeps its own file."));
|
||||
}
|
||||
|
||||
/// Builds the print scope and fit-to-page parameters, caches `file.printPreview` per document revision/options, and draws page thumbnails with navigation plus the print settings and Print action.
|
||||
fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
@@ -728,14 +737,19 @@ fn print(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome)
|
||||
}
|
||||
ui.add_space(16.0);
|
||||
let scope = if bs.print_all { "Print Entire Workbook" } else { "Print Active Sheets" };
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope").width(230.0).selected_text(lang.tr(scope)).show_ui(ui, |ui| {
|
||||
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
|
||||
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
|
||||
bs.print_all = all;
|
||||
bs.preview_page = 0;
|
||||
egui::ComboBox::from_id_salt("backstage_print_scope")
|
||||
.width(230.0)
|
||||
.selected_text(lang.tr(scope))
|
||||
.show_ui(ui, |ui| {
|
||||
for (all, label) in [(false, "Print Active Sheets"), (true, "Print Entire Workbook")] {
|
||||
if ui.selectable_label(bs.print_all == all, lang.tr(label)).clicked() {
|
||||
bs.print_all = all;
|
||||
bs.preview_page = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Print What"));
|
||||
ui.add_space(8.0);
|
||||
if ui.checkbox(&mut bs.fit_to_page, lang.tr("Fit Sheet on One Page")).changed() {
|
||||
bs.preview_page = 0;
|
||||
@@ -888,7 +902,7 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
||||
_ => return false,
|
||||
};
|
||||
let title = app.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
if let Some(pick) = &app.services.pick_save {
|
||||
let Some(path) = pick(&format!("{stem}.{fmt}")) else { return false };
|
||||
// The picker may suggest a workbook extension; the export writes the chosen format.
|
||||
@@ -935,33 +949,48 @@ pub fn export_as(app: &mut SheetApp, fmt: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel's File › Options page: General (display theme, interface language and Show formula bar),
|
||||
/// Editing (AutoComplete) and Save (the autosave checkbox toggling `app.ui.autosave`), plus Formulas
|
||||
/// (calculation mode and iterative calculation) when a document is active. Theme, language, formula
|
||||
/// bar, AutoComplete and calculation changes run their commands through `run_or_alert`; calculation
|
||||
/// settings are saved with the workbook.
|
||||
fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "Options|General");
|
||||
egui::Grid::new("backstage_options_general").num_columns(2).spacing(vec2(18.0, 10.0)).show(ui, |ui| {
|
||||
ui.label(lang.tr("Display theme"));
|
||||
let label = ui.label(lang.tr("Display theme"));
|
||||
let mode = app.ui.theme_mode();
|
||||
let current = match mode {
|
||||
"system" => "System",
|
||||
"dark" => "Dark",
|
||||
_ => "Light",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("backstage_theme").width(160.0).selected_text(lang.tr(current)).show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
app.run_or_alert("view.theme", json!({"mode": value}));
|
||||
egui::ComboBox::from_id_salt("backstage_theme")
|
||||
.width(160.0)
|
||||
.selected_text(lang.tr(current))
|
||||
.show_ui(ui, |ui| {
|
||||
for (value, label) in [("system", "System"), ("light", "Light"), ("dark", "Dark")] {
|
||||
if ui.selectable_label(mode == value, lang.tr(label)).clicked() {
|
||||
app.run_or_alert("view.theme", json!({"mode": value}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
ui.end_row();
|
||||
ui.label(lang.tr("Interface language"));
|
||||
egui::ComboBox::from_id_salt("backstage_language").width(160.0).selected_text(lang.name()).show_ui(ui, |ui| {
|
||||
for l in i18n::Language::for_picker() {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
app.run_or_alert("app.language.set", json!({"language": l.code()}));
|
||||
let label = ui.label(lang.tr("Interface language"));
|
||||
egui::ComboBox::from_id_salt("backstage_language")
|
||||
.width(160.0)
|
||||
.selected_text(lang.name())
|
||||
.show_ui(ui, |ui| {
|
||||
for l in i18n::Language::for_picker() {
|
||||
if ui.selectable_label(l == lang, l.name()).clicked() {
|
||||
app.run_or_alert("app.language.set", json!({"language": l.code()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -1000,10 +1029,73 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if ui.checkbox(&mut iterative, lang.tr("Enable iterative calculation")).changed() {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"iterative": iterative}));
|
||||
}
|
||||
// Maximum Iterations and Maximum Change apply only while iteration is on, as in Excel.
|
||||
ui.add_enabled_ui(iterative, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(24.0);
|
||||
egui::Grid::new("backstage_options_iteration").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
let label = ui.label(lang.tr("Maximum Iterations:"));
|
||||
let id = ui.make_persistent_id("backstage_max_iterations");
|
||||
// Text left in the field when iteration is switched off (it loses focus) is dropped.
|
||||
let committed = crate::widgets::committed_text(ui, id, &calc.max_iterations.to_string(), Some(80.0));
|
||||
if let Some(response) = ui.ctx().read_response(id) {
|
||||
response.labelled_by(label.id);
|
||||
}
|
||||
if let Some(text) = committed.filter(|_| iterative) {
|
||||
match parse_max_iterations(&text) {
|
||||
Some(n) if n != calc.max_iterations => {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"maxIterations": n}));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => refuse(app, ui, "Maximum Iterations must be a whole number from 1 to 32767."),
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
let label = ui.label(lang.tr("Maximum Change:"));
|
||||
let id = ui.make_persistent_id("backstage_max_change");
|
||||
let committed = crate::widgets::committed_text(ui, id, &calc.max_change.to_string(), Some(80.0));
|
||||
if let Some(response) = ui.ctx().read_response(id) {
|
||||
response.labelled_by(label.id);
|
||||
}
|
||||
if let Some(text) = committed.filter(|_| iterative) {
|
||||
match parse_max_change(&text, lang.formula_locale()) {
|
||||
Some(d) if d != calc.max_change => {
|
||||
app.run_or_alert("formulas.calculationOptions", json!({"maxChange": d}));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => refuse(app, ui, "Maximum Change must be a number of 0 or more."),
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
});
|
||||
// TODO: add "Set precision as displayed" here once `formulas.calculationOptions` takes
|
||||
// `precisionAsDisplayed` (PR #1366); the model already stores it.
|
||||
ui.add_space(6.0);
|
||||
dim(ui, lang.tr("Calculation settings are saved with the workbook."));
|
||||
}
|
||||
|
||||
/// Refuses a typed setting with a message, leaving the workbook as it was. The Enter that
|
||||
/// committed the field is consumed so it doesn't also dismiss the message box in this frame.
|
||||
fn refuse(app: &mut SheetApp, ui: &mut Ui, text: &str) {
|
||||
ui.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter));
|
||||
app.message = Some(("GridCraft".into(), app.ui.language.tr(text).into()));
|
||||
}
|
||||
|
||||
/// Maximum Iterations as typed: a whole number from 1 to 32767, or `None` (Excel refuses others).
|
||||
fn parse_max_iterations(text: &str) -> Option<u32> {
|
||||
let n = text.trim().parse::<u32>().ok()?;
|
||||
(1..=gridcraft_engine::model::CalcSettings::MAX_ITERATIONS).contains(&n).then_some(n)
|
||||
}
|
||||
|
||||
/// Maximum Change as typed: a finite number of 0 or more, or `None`. The interface's decimal
|
||||
/// separator is accepted as well as a dot.
|
||||
fn parse_max_change(text: &str, locale: gridcraft_engine::formula::FormulaLocale) -> Option<f64> {
|
||||
let d = text.trim().replace(locale.decimal_separator(), ".").parse::<f64>().ok()?;
|
||||
(d.is_finite() && d >= 0.0).then_some(d)
|
||||
}
|
||||
|
||||
/// The platform the app was built for, e.g. "macOS (aarch64)"; the web build reports "Web".
|
||||
fn platform() -> String {
|
||||
let os = match std::env::consts::OS {
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::theme::{self, Tokens};
|
||||
/// from the selection outline. Labels stay available to accessibility tools.
|
||||
pub(crate) fn preset_button(ui: &mut Ui, label: &str, preset: &str) -> Response {
|
||||
let response = ui.add(egui::Button::new("").frame(false).min_size(vec2(254.0, 26.0)));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let rect = Rect::from_center_size(pos2(response.rect.left() + 16.0, response.rect.center().y), vec2(20.0, 16.0));
|
||||
let edges = [
|
||||
@@ -55,7 +55,7 @@ pub(crate) fn sample(ui: &mut Ui, borders: &Borders, theme: &Theme) {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Preview");
|
||||
let (area, response) = ui.allocate_exact_size(vec2(248.0, 104.0), Sense::hover());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, "Border preview"));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, "Border preview"));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(area, 4.0, t.grid_bg);
|
||||
ui.painter().rect_stroke(area, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
|
||||
@@ -116,13 +116,14 @@ fn fitted_rect(cell: Rect, size: [usize; 2]) -> Option<Rect> {
|
||||
Some(Rect::from_center_size(cell.center(), source * scale))
|
||||
}
|
||||
|
||||
/// Draws a cached cell picture fitted inside the cell and clipped to it, or paints the picture fallback icon when no texture is available.
|
||||
pub(crate) fn paint(painter: &Painter, cell: Rect, picture: &Arc<CellPicture>) {
|
||||
if cell.width() <= 0.0 || cell.height() <= 0.0 || !cell.intersects(painter.clip_rect()) {
|
||||
return;
|
||||
}
|
||||
// Context-local handles must never leak between offscreen, native or browser
|
||||
// egui contexts, even when a workbook shares its Arc payload with another view.
|
||||
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.cell_pictures")).clone());
|
||||
let cache = painter.ctx().data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("gridcraft.cell_pictures")).clone());
|
||||
let texture = cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner).texture(painter.ctx(), picture);
|
||||
let painter = painter.with_clip_rect(cell.intersect(painter.clip_rect()));
|
||||
if let Some(texture) = texture {
|
||||
|
||||
@@ -551,7 +551,10 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
|
||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret, None);
|
||||
}
|
||||
let mut r = out.response.response;
|
||||
// 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();
|
||||
}
|
||||
@@ -667,17 +670,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Shift+Tab removes four leading spaces, while a plain Tab still inserts four spaces at the caret.
|
||||
fn shift_tab_in_the_editor_dedents_rather_than_indents() {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, text: &mut String| {
|
||||
if ui.ctx().cumulative_pass_nr() == 0 {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ui.ctx().set_fonts(fonts);
|
||||
ui.ctx().set_fonts(theme::pinned_font_definitions());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
@@ -694,14 +695,12 @@ mod tests {
|
||||
assert_eq!(h.state(), " x ");
|
||||
}
|
||||
|
||||
/// Gives the context the editor's monospace family (the test has no craft fonts).
|
||||
/// Gives the context the deterministic font set, including the editor's monospace family.
|
||||
fn install_mono(ctx: &egui::Context) {
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
let mono = fonts.families.get(&egui::FontFamily::Monospace).cloned().unwrap_or_default();
|
||||
fonts.families.insert(egui::FontFamily::Name(theme::MONO.into()), mono);
|
||||
ctx.set_fonts(fonts);
|
||||
ctx.set_fonts(theme::pinned_font_definitions());
|
||||
}
|
||||
|
||||
/// Builds a 400x300 code-editor harness with pinned mono fonts, focus, two passes and an accessibility check.
|
||||
fn editor_harness(text: &str) -> egui_kittest::Harness<'static, String> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
|ui, text: &mut String| {
|
||||
@@ -709,13 +708,14 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, text, Lang::Python, 5);
|
||||
},
|
||||
text.to_string(),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.check_accessibility();
|
||||
h
|
||||
}
|
||||
|
||||
@@ -869,9 +869,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Events held by the previous pass are applied once in the next pass; older held events are dropped and cleared.
|
||||
fn held_back_events_land_in_the_next_pass_only() {
|
||||
let mut h = editor_harness("");
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
let held = |pass, c: &str| Held { pass, events: vec![egui::Event::Text(c.into())] };
|
||||
// What the pass just completed held back comes in with the next one.
|
||||
let last = h.ctx.cumulative_pass_nr() - 1;
|
||||
@@ -886,6 +887,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Held keyboard input does not land in the next focused text field once the code editor has lost focus.
|
||||
fn held_back_events_go_nowhere_once_the_editor_lost_the_focus() {
|
||||
// Typed into the editor, they must not land in whatever has the focus next.
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 300.0)).build_ui_state(
|
||||
@@ -894,10 +896,10 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let (code, other) = (egui::Id::new("code"), egui::Id::new("other"));
|
||||
let (code, other) = (egui::Id::unique("code"), egui::Id::unique("other"));
|
||||
ui.memory_mut(|m| m.request_focus(if s.2 { code } else { other }));
|
||||
code_editor(ui, code, &mut s.0, Lang::Python, 5);
|
||||
ui.add(TextEdit::singleline(&mut s.1).id(other));
|
||||
ui.add(TextEdit::singleline(&mut s.1).id(other)).accessible_name("Other field");
|
||||
},
|
||||
(String::new(), String::new(), true),
|
||||
);
|
||||
@@ -911,6 +913,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A discard-triggered repeat pass receives held keys with the selection made by the first pass.
|
||||
fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
|
||||
// When a widget asks egui to discard and repeat the pass, the repeat pass gets the
|
||||
// held-back keys, with the selection the field made in the first pass.
|
||||
@@ -920,7 +923,7 @@ mod tests {
|
||||
install_mono(ui.ctx());
|
||||
return;
|
||||
}
|
||||
let id = egui::Id::new("code");
|
||||
let id = egui::Id::unique("code");
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
code_editor(ui, id, &mut s.0, Lang::Python, 5);
|
||||
if std::mem::take(&mut s.1) {
|
||||
|
||||
+1802
-79
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
//! craft-fonts build policy shared by `build.rs` and the UI crate.
|
||||
//!
|
||||
//! GridCraft stays a clean-room application: the shared repository may keep Adobe, Source Han and
|
||||
//! Noto CJK faces for other consumers, but GridCraft never embeds them. The permitted faces are
|
||||
//! matched by exact family and style, and their manifest script tags are asserted instead of
|
||||
//! trusting the free-form parser in `docs/integration.md`.
|
||||
//!
|
||||
//! The file is compiled twice: by `build.rs` for the full parser/comparison/embedding path (via
|
||||
//! `#[path]`), and by this crate, where `theme` only needs the barred-family filter and the web
|
||||
//! allowlist. The rest of the policy is exercised by this file's own tests, so it is not dead
|
||||
//! code to remove.
|
||||
#![allow(dead_code)]
|
||||
|
||||
/// Families GridCraft must never embed: the repository asset policy excludes Adobe/Source faces
|
||||
/// and the Noto CJK collection. There is deliberately no broad `Noto Sans` prefix: the permitted
|
||||
/// `Noto Sans Symbols` and `Noto Sans Symbols 2` faces start with it.
|
||||
pub const BARRED_PREFIXES: &[&str] = &["Adobe", "Source Han", "Source Sans", "Source Serif", "Noto Sans CJK", "Noto Serif CJK"];
|
||||
|
||||
/// The faces GridCraft's production font set relies on, with their exact manifest script tags.
|
||||
/// Every release/acceptance build that sets `CRAFT_FONTS_REQUIRED=1` must contain all of them.
|
||||
pub const REQUIRED_FACES: &[(&str, &str, &[&str])] = &[
|
||||
("BIZ UDPGothic", "Regular", &["Jpan", "Latn"]),
|
||||
("BIZ UDPGothic", "Bold", &["Jpan", "Latn"]),
|
||||
("NanumGothic", "Regular", &["Hang", "Kore"]),
|
||||
("Noto Sans Symbols", "Regular", &["Zsym"]),
|
||||
("Noto Sans Symbols 2", "Regular", &["Zsym"]),
|
||||
];
|
||||
|
||||
/// The native-only Simplified Chinese face from craft-fonts' separate
|
||||
/// `fonts/simplified-chinese-manifest.txt` (the same opt-in DeckCraft uses). It is OFL and not
|
||||
/// barred by the asset policy, but it never enters a web bundle; older native checkouts may omit
|
||||
/// the manifest, so only `CRAFT_FONTS_REQUIRED=1` builds demand it.
|
||||
pub const CHINESE_FACES: &[(&str, &str, &[&str])] = &[("LXGW WenKai Lite", "Regular", &["Hani", "Hans"])];
|
||||
|
||||
/// The explicit web allowlist. Browser builds embed only these faces; the Noto CJK collection and
|
||||
/// the document-only faces stay off the wasm bundle. `Noto Sans Symbols` supplies `⌃`, and
|
||||
/// `Noto Sans Symbols 2` supplies `✕`, `◀` and `✓`; `📋` still comes from egui's bundled emoji.
|
||||
pub const WEB_ALLOWED: &[(&str, &str)] = &[
|
||||
("BIZ UDPGothic", "Regular"),
|
||||
("BIZ UDPGothic", "Bold"),
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
];
|
||||
|
||||
/// One validated `fonts/manifest.txt` row. Only the fields GridCraft consumes are kept.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ManifestRow {
|
||||
pub family: String,
|
||||
pub style: String,
|
||||
pub file: String,
|
||||
pub scripts: Vec<String>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
/// Whether the `CRAFT_FONTS_REQUIRED` value enables required mode. Only exactly `1` does:
|
||||
/// `0`, an empty value and other words are not accepted as an implicit yes.
|
||||
pub fn required_mode(value: Option<&str>) -> bool {
|
||||
value == Some("1")
|
||||
}
|
||||
|
||||
/// Whether `family` is barred by GridCraft's asset policy.
|
||||
pub fn barred(family: &str) -> bool {
|
||||
BARRED_PREFIXES.iter().any(|prefix| family.starts_with(prefix))
|
||||
}
|
||||
|
||||
/// Whether a web build may embed this exact family and style.
|
||||
pub fn web_allowed(family: &str, style: &str) -> bool {
|
||||
WEB_ALLOWED.iter().any(|(f, s)| *f == family && *s == style)
|
||||
}
|
||||
|
||||
/// The exact script tags a required face must declare.
|
||||
pub fn required_scripts(family: &str, style: &str) -> Option<&'static [&'static str]> {
|
||||
REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).find(|(f, s, _)| *f == family && *s == style).map(|(_, _, scripts)| *scripts)
|
||||
}
|
||||
|
||||
/// Parse the craft-fonts eight-field manifest. Comments and blank lines are skipped; every other
|
||||
/// line must be complete and contain a valid SHA-256. A malformed row is an error, never a
|
||||
/// silently ignored font.
|
||||
pub fn parse_manifest(text: &str) -> Result<Vec<ManifestRow>, String> {
|
||||
let mut rows = Vec::new();
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, licence, licence_file, sha256, source] = fields.as_slice() else {
|
||||
return Err(format!("manifest line {}: expected 8 fields separated by \" | \": {line}", number + 1));
|
||||
};
|
||||
if [family, style, file, scripts, licence, licence_file, sha256, source].iter().any(|f| f.is_empty()) {
|
||||
return Err(format!("manifest line {}: an empty field: {line}", number + 1));
|
||||
}
|
||||
if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("manifest line {}: sha256 must be 64 hex digits: {sha256}", number + 1));
|
||||
}
|
||||
let scripts: Vec<String> = scripts.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
|
||||
if scripts.is_empty() || scripts.iter().any(|tag| tag.len() != 4 || !tag.chars().all(|c| c.is_ascii_alphabetic())) {
|
||||
return Err(format!("manifest line {}: script tags must be ISO 15924 four-letter tags: {line}", number + 1));
|
||||
}
|
||||
rows.push(ManifestRow {
|
||||
family: (*family).to_string(),
|
||||
style: (*style).to_string(),
|
||||
file: (*file).to_string(),
|
||||
scripts,
|
||||
sha256: (*sha256).to_string(),
|
||||
});
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Check the script tags of every face GridCraft knows. The craft-fonts parser accepts arbitrary
|
||||
/// strings, so a face relabelled without the scripts GridCraft relies on is rejected here.
|
||||
pub fn check_declared_scripts(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
for row in rows {
|
||||
if let Some(expected) = required_scripts(&row.family, &row.style) {
|
||||
if row.scripts.iter().map(String::as_str).ne(expected.iter().copied()) {
|
||||
return Err(format!(
|
||||
"{} {} must declare scripts {} but the manifest says {}",
|
||||
row.family,
|
||||
row.style,
|
||||
expected.join(","),
|
||||
row.scripts.join(",")
|
||||
));
|
||||
}
|
||||
} else if REQUIRED_FACES.iter().chain(CHINESE_FACES.iter()).any(|(family, _, _)| *family == row.family) {
|
||||
return Err(format!("{} has an unexpected style {:?}; GridCraft only permits the exact required faces", row.family, row.style));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every required face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` builds.
|
||||
pub fn check_required(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
|
||||
let missing: Vec<String> = REQUIRED_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
|
||||
if missing.is_empty() { Ok(()) } else { Err(format!("the craft-fonts manifest is missing required faces: {}", missing.join(", "))) }
|
||||
}
|
||||
|
||||
/// Every native-only Chinese face must be present. Used only for `CRAFT_FONTS_REQUIRED=1` native
|
||||
/// builds; ordinary builds may use an older craft-fonts checkout without the opt-in manifest.
|
||||
pub fn check_chinese_required(rows: &[ManifestRow]) -> Result<(), String> {
|
||||
let present = |family: &str, style: &str| rows.iter().any(|row| row.family == family && row.style == style);
|
||||
let missing: Vec<String> = CHINESE_FACES.iter().filter(|(f, s, _)| !present(f, s)).map(|(f, s, _)| format!("{f} {s}")).collect();
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("the simplified-Chinese craft-fonts manifest is missing required faces: {}", missing.join(", ")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The rows this build should embed, in manifest order. Barred families are always excluded; a
|
||||
/// web build additionally keeps only [`WEB_ALLOWED`].
|
||||
pub fn embeddable(rows: &[ManifestRow], wasm: bool) -> Vec<&ManifestRow> {
|
||||
rows.iter().filter(|row| !barred(&row.family) && (!wasm || web_allowed(&row.family, &row.style))).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const GOOD: &str = "NanumGothic | Regular | fonts/nanum-gothic/NanumGothic-Regular.ttf | Hang,Kore | OFL-1.1 | fonts/nanum-gothic/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/nanum";
|
||||
|
||||
#[test]
|
||||
/// Asserts the exact web allowlist and that every entry is unbarred, required and not a CJK collection face.
|
||||
fn the_web_allowlist_is_exact_and_never_barred() {
|
||||
assert_eq!(
|
||||
WEB_ALLOWED,
|
||||
&[
|
||||
("BIZ UDPGothic", "Regular"),
|
||||
("BIZ UDPGothic", "Bold"),
|
||||
("NanumGothic", "Regular"),
|
||||
("Noto Sans Symbols", "Regular"),
|
||||
("Noto Sans Symbols 2", "Regular"),
|
||||
]
|
||||
);
|
||||
for (family, _) in WEB_ALLOWED {
|
||||
assert!(!barred(family), "{family} must not be barred");
|
||||
assert!(REQUIRED_FACES.iter().any(|(f, _, _)| f == family), "{family} must be a required face");
|
||||
}
|
||||
assert!(!WEB_ALLOWED.iter().any(|(family, _)| family.contains("CJK")), "the web allowlist must contain no CJK collection face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Adobe/Source and Noto CJK families are barred, while the permitted Noto Symbols, BIZ and Nanum faces are not.
|
||||
fn the_barred_prefixes_keep_the_permitted_noto_symbol_faces() {
|
||||
for family in ["Adobe Source Sans", "Source Han Sans", "Source Sans 3", "Source Serif 4", "Noto Sans CJK SC", "Noto Serif CJK JP"] {
|
||||
assert!(barred(family), "{family} must be barred");
|
||||
}
|
||||
for family in ["Noto Sans Symbols", "Noto Sans Symbols 2", "BIZ UDPGothic", "NanumGothic"] {
|
||||
assert!(!barred(family), "{family} must be permitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Only the exact string `"1"` enables required mode; absent and all other values leave it off.
|
||||
fn only_the_value_one_enables_required_mode() {
|
||||
assert!(!required_mode(None));
|
||||
for value in ["", "0", "00", "01", "false", "true", "yes"] {
|
||||
assert!(!required_mode(Some(value)), "{value:?} must not enable required mode");
|
||||
}
|
||||
assert!(required_mode(Some("1")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A complete eight-field row yields the expected family, style and script list.
|
||||
fn a_complete_manifest_row_parses() {
|
||||
let rows = parse_manifest(GOOD).unwrap_or_default();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows.first().map(|r| (r.family.as_str(), r.style.as_str())), Some(("NanumGothic", "Regular")));
|
||||
assert_eq!(rows.first().map(|r| r.scripts.clone()), Some(vec!["Hang".to_string(), "Kore".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Missing fields, an empty file field, a bad hash and a non-ISO script tag are all rejected.
|
||||
fn malformed_rows_are_errors() {
|
||||
for bad in [
|
||||
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore",
|
||||
"NanumGothic | Regular | | Hang,Kore | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
|
||||
"NanumGothic | Regular | fonts/x.ttf | Hang,Kore | OFL-1.1 | fonts/OFL.txt | notahash | https://example.invalid",
|
||||
"NanumGothic | Regular | fonts/x.ttf | Korean | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid",
|
||||
] {
|
||||
assert!(parse_manifest(bad).is_err(), "must reject {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// One row cannot satisfy every required face; script order and exact tags are checked, and a required family in an unlisted style is rejected.
|
||||
fn required_faces_and_script_tags_are_checked() {
|
||||
let rows = parse_manifest(GOOD).unwrap_or_default();
|
||||
assert!(check_required(&rows).is_err(), "one row cannot satisfy every required face");
|
||||
assert!(check_declared_scripts(&rows).is_ok());
|
||||
let relabelled = parse_manifest(&GOOD.replace("Hang,Kore", "Kore,Hang")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&relabelled).is_err(), "script order and exact tags are asserted");
|
||||
let unexpected_style = parse_manifest(&GOOD.replace("Regular", "Bold")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&unexpected_style).is_err(), "a required family in an unlisted style is rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// LXGW is permitted but native-only; its script order and style are checked, and a required native build must include it.
|
||||
fn the_native_chinese_face_is_permitted_and_checked() {
|
||||
let chinese = "LXGW WenKai Lite | Regular | fonts/lxgw-wenkai-lite/LXGWWenKaiLite-Regular.ttf | Hani,Hans | OFL-1.1 | fonts/lxgw-wenkai-lite/OFL.txt | f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea | https://example.invalid/lxgw";
|
||||
let rows = parse_manifest(chinese).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&rows).is_ok());
|
||||
assert!(check_chinese_required(&rows).is_ok());
|
||||
assert!(!barred("LXGW WenKai Lite"), "the OFL Chinese face is permitted");
|
||||
assert!(!web_allowed("LXGW WenKai Lite", "Regular"), "the Chinese face is native-only");
|
||||
let relabelled = parse_manifest(&chinese.replace("Hani,Hans", "Hans,Hani")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&relabelled).is_err(), "the Chinese script tags are asserted");
|
||||
let unexpected_style = parse_manifest(&chinese.replace("Regular", "Bold")).unwrap_or_default();
|
||||
assert!(check_declared_scripts(&unexpected_style).is_err(), "an unlisted Chinese style is rejected");
|
||||
assert!(check_chinese_required(&[]).is_err(), "a required native build needs the Chinese face");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Native embedding excludes the barred CJK face; web embedding keeps only allowlisted faces.
|
||||
fn web_embedding_keeps_only_the_allowlist() {
|
||||
let manifest = format!(
|
||||
"{GOOD}\nNoto Sans CJK SC | Regular | fonts/cjk.otf | Hans,Latn | OFL-1.1 | fonts/OFL.txt | 76f45ef4a6bcff344c837c95a7dcc26e017e38b5846d5ae0cdcb5b86be2e2d31 | https://example.invalid/cjk"
|
||||
);
|
||||
let rows = parse_manifest(&manifest).unwrap_or_default();
|
||||
let native: Vec<&str> = embeddable(&rows, false).iter().map(|r| r.family.as_str()).collect();
|
||||
let web: Vec<&str> = embeddable(&rows, true).iter().map(|r| r.family.as_str()).collect();
|
||||
assert_eq!(native, ["NanumGothic"], "the barred CJK face is never embedded natively");
|
||||
assert_eq!(web, ["NanumGothic"], "the web build keeps only allowlisted faces");
|
||||
}
|
||||
}
|
||||
@@ -212,7 +212,7 @@ impl Default for View {
|
||||
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Show");
|
||||
|
||||
@@ -221,8 +221,8 @@ impl Dialog {
|
||||
"rowHeight",
|
||||
"Row Height",
|
||||
"home.rowHeight",
|
||||
vec![Number { key: "height", label: "Row height (pixels):" }],
|
||||
json!({"height": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row))).unwrap_or(20.0)}),
|
||||
vec![Number { key: "points", label: "Row height (points):" }],
|
||||
json!({"points": app.session.active().and_then(|d| d.wb.active().map(|s| s.row_height(d.selection.active.row) as f64 * 0.75)).unwrap_or(15.0)}),
|
||||
),
|
||||
"columnWidth" => Dialog::form(
|
||||
"columnWidth",
|
||||
@@ -343,7 +343,7 @@ impl Dialog {
|
||||
// The file rides along in the values (`path` on the desktop, `name` and `base64`
|
||||
// on the web) and goes back to `file.open` with the password.
|
||||
let source = p("path").as_str().or(p("name").as_str()).map(str::to_string).unwrap_or_default();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(|n| n.to_str()).unwrap_or("This workbook").to_string();
|
||||
let file = std::path::Path::new(&source).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("This workbook").to_string();
|
||||
// An empty password from the start, so OK on the untouched field says "incorrect"
|
||||
// rather than asking for the password again.
|
||||
let mut values = params.clone();
|
||||
@@ -1251,6 +1251,7 @@ pub(crate) fn build_params(d: &Dialog) -> Json {
|
||||
v
|
||||
}
|
||||
|
||||
/// Takes and draws the active dialog, dispatches its body by name, consumes picture-inbox results, runs OK/confirm commands, handles Esc/close and restores the back-dialog stack.
|
||||
pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
if d.name == "pasteSpecial" {
|
||||
@@ -1271,7 +1272,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
_ => 380.0,
|
||||
};
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
.id(egui::Id::new("dialog").with(&d.name))
|
||||
.id(egui::Id::unique("dialog").with(&d.name))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
@@ -1369,7 +1370,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
d.values.remove("base64");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(|name| name.to_str()).unwrap_or("");
|
||||
let alt = std::path::Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("");
|
||||
d.values.insert("alt".into(), json!(alt));
|
||||
d.values.insert("path".into(), json!(path));
|
||||
}
|
||||
@@ -1775,7 +1776,7 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
match f {
|
||||
Field::Text { key, label } => {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut s = d
|
||||
.values
|
||||
.get(key)
|
||||
@@ -1783,9 +1784,9 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
.unwrap_or_default();
|
||||
let multiline = key == "text";
|
||||
let r = if multiline {
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0))
|
||||
ui.add(egui::TextEdit::multiline(&mut s).desired_rows(4).desired_width(320.0)).labelled_by(label.id)
|
||||
} else {
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0))
|
||||
ui.add(egui::TextEdit::singleline(&mut s).desired_width(320.0)).labelled_by(label.id)
|
||||
};
|
||||
take_first_focus(d, &r);
|
||||
if r.changed() {
|
||||
@@ -1799,9 +1800,10 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
Field::Password { key, label } => {
|
||||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||||
ui.vertical(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or_default().to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).password(!d.show_password).desired_width(320.0)).labelled_by(label.id).changed()
|
||||
{
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
ui.checkbox(&mut d.show_password, lang.tr("Show password"));
|
||||
@@ -1809,11 +1811,11 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
Field::Number { key, label } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(lang.tr(label));
|
||||
let label = ui.label(lang.tr(label));
|
||||
let mut n = d.values.get(key).and_then(Json::as_f64).unwrap_or(0.0);
|
||||
// Typed values count straight away: otherwise Enter or OK in the same frame
|
||||
// confirms the old value, because a number field only commits when it loses focus.
|
||||
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true));
|
||||
let r = ui.add(egui::DragValue::new(&mut n).speed(0.5).update_while_editing(true)).labelled_by(label.id);
|
||||
take_first_focus(d, &r);
|
||||
if r.changed() {
|
||||
d.values.insert(key.into(), json!(n));
|
||||
@@ -1822,17 +1824,22 @@ fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
Field::Choice { key, label, options } => {
|
||||
ui.vertical(|ui| {
|
||||
ui.label(egui::RichText::new(lang.tr(label)).strong());
|
||||
let field_label = ui.label(egui::RichText::new(lang.tr(label)).strong());
|
||||
let cur = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if d.name == "dataValidation" {
|
||||
let selected = options.iter().find(|(val, _)| *val == cur).map(|(_, text)| lang.tr(text)).unwrap_or("");
|
||||
egui::ComboBox::from_id_salt(("dataValidation", key)).selected_text(selected).width(300.0).show_ui(ui, |ui| {
|
||||
for (val, text) in options {
|
||||
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
egui::ComboBox::from_id_salt(("dataValidation", key))
|
||||
.selected_text(selected)
|
||||
.width(300.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (val, text) in options {
|
||||
if ui.selectable_label(cur == val, lang.tr(text)).clicked() {
|
||||
d.set_value(key, json!(val));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(field_label.id);
|
||||
} else {
|
||||
for (val, text) in options {
|
||||
if ui.radio(cur == val, lang.tr(text)).clicked() {
|
||||
@@ -1924,6 +1931,7 @@ fn macros(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the six-tab Format Cells editor (number, alignment, font, border, fill, protection); the Number tab shows a sample preview, and the style draft updates.
|
||||
fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
// `d.tab` stays the English name (it is what `open_dialog` and tests pass); only the drawn
|
||||
// labels are translated. The Fill tab is keyed by context: the Alignment tab's "Fill" is a
|
||||
@@ -1963,8 +1971,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
let preview = app.session.active().map(|doc| gridcraft_engine::display::format(&sample, &code, &doc.wb).text).unwrap_or_default();
|
||||
cols[1].label(egui::RichText::new(preview).font(theme::ui_font(15.0)));
|
||||
cols[1].add_space(8.0);
|
||||
cols[1].label(lang.tr("Type (custom format code):"));
|
||||
cols[1].text_edit_singleline(&mut code);
|
||||
let label = cols[1].label(lang.tr("Type (custom format code):"));
|
||||
cols[1].text_edit_singleline(&mut code).labelled_by(label.id);
|
||||
for c in [
|
||||
"0",
|
||||
"0.00",
|
||||
@@ -2100,8 +2108,8 @@ fn format_cells(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm:
|
||||
] {
|
||||
let label = lang.tr(label);
|
||||
let response = ui.selectable_label(slot.style == s, label);
|
||||
response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::SelectableLabel, ui.is_enabled(), format!("{n} {label}")));
|
||||
let selected = slot.style == s;
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), selected, format!("{n} {label}")));
|
||||
if response.clicked() {
|
||||
slot.style = s;
|
||||
}
|
||||
@@ -2218,6 +2226,7 @@ fn function_category_label(id: &str) -> &str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the Find/Replace form with case, whole-cell and workbook options, requests field focus once, and runs find/replace with the current parameters.
|
||||
fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
for tab in ["Find", "Replace"] {
|
||||
@@ -2229,8 +2238,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let mut what = d.values.get("what").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let mut with = d.values.get("with").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Find what:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0));
|
||||
let label = ui.label("Find what:");
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut what).desired_width(260.0)).labelled_by(label.id);
|
||||
// Like Excel, the cursor starts in "Find what" - asked for once, so clicking another field still works.
|
||||
if !d.values.contains_key("focused") {
|
||||
r.request_focus();
|
||||
@@ -2239,8 +2248,8 @@ fn find(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
});
|
||||
if d.tab == "Replace" {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Replace with:");
|
||||
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0));
|
||||
let label = ui.label("Replace with:");
|
||||
ui.add(egui::TextEdit::singleline(&mut with).desired_width(244.0)).labelled_by(label.id);
|
||||
});
|
||||
}
|
||||
let mut case = d.values.get("matchCase").and_then(Json::as_bool).unwrap_or(false);
|
||||
@@ -2363,7 +2372,9 @@ fn go_to_entries(app: &SheetApp) -> Vec<String> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Draws a virtualized name/reference list and editable reference field, links to Go To Special, and confirms with `edit.goTo`.
|
||||
fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let entries: Vec<String> =
|
||||
d.result.as_ref().and_then(Json::as_array).map(|a| a.iter().filter_map(Json::as_str).map(str::to_owned).collect()).unwrap_or_default();
|
||||
let current = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
@@ -2382,8 +2393,8 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
||||
});
|
||||
let mut r = d.values.get("reference").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Reference:");
|
||||
ui.text_edit_singleline(&mut r).request_focus();
|
||||
let label = ui.label(lang.tr("Reference:"));
|
||||
ui.text_edit_singleline(&mut r).labelled_by(label.id).request_focus();
|
||||
});
|
||||
d.values.insert("reference".into(), json!(r.clone()));
|
||||
if ui.button("Special…").clicked() {
|
||||
@@ -2401,7 +2412,9 @@ fn go_to(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bo
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the sort-level editor: header toggle, column/by/order controls, add/remove level, and `data.sort` on confirmation.
|
||||
fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let lang = app.ui.language;
|
||||
let mut header = d.values.get("header").and_then(Json::as_bool).unwrap_or(true);
|
||||
ui.checkbox(&mut header, "My list has headers");
|
||||
d.values.insert("header".into(), json!(header));
|
||||
@@ -2432,16 +2445,20 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
let mut remove = None;
|
||||
for (i, lv) in levels.iter_mut().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(if i == 0 { "Sort by" } else { "Then by" });
|
||||
let label = ui.label(lang.tr(if i == 0 { "Sort by" } else { "Then by" }));
|
||||
let mut col = lv["column"].as_str().unwrap_or("A").to_string();
|
||||
if columns.is_empty() {
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut col).desired_width(50.0)).labelled_by(label.id);
|
||||
} else {
|
||||
egui::ComboBox::from_id_salt(("sort_col", i)).selected_text(col.clone()).show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt(("sort_col", i))
|
||||
.selected_text(col.clone())
|
||||
.show_ui(ui, |ui| {
|
||||
for (letter, label) in &columns {
|
||||
ui.selectable_value(&mut col, letter.clone(), label.as_str());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
}
|
||||
lv["column"] = json!(col.to_ascii_uppercase());
|
||||
let mut by = lv["by"].as_str().unwrap_or("values").to_string();
|
||||
@@ -2455,13 +2472,16 @@ fn sort(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut boo
|
||||
ui.selectable_value(&mut by, "values".into(), "Cell Values");
|
||||
ui.selectable_value(&mut by, "cellColor".into(), "Cell Color");
|
||||
ui.selectable_value(&mut by, "fontColor".into(), "Font Color");
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Sort On"));
|
||||
lv["by"] = json!(by);
|
||||
let mut desc = lv["order"].as_str() == Some("desc");
|
||||
ui.radio_value(&mut desc, false, "A to Z");
|
||||
ui.radio_value(&mut desc, true, "Z to A");
|
||||
lv["order"] = json!(if desc { "desc" } else { "asc" });
|
||||
if ui.small_button("✕").clicked() {
|
||||
if crate::widgets::icon_button(ui, Icon::Close, crate::theme::Tokens::get(ui.ctx()).text_dim, "Remove level", vec2(18.0, 18.0)).clicked()
|
||||
{
|
||||
remove = Some(i);
|
||||
}
|
||||
});
|
||||
@@ -2746,12 +2766,13 @@ fn recommended_charts(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, con
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws chart range, series keep/remove and category-range controls and runs `chart.selectData` when something changed.
|
||||
fn select_data(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str| {
|
||||
ui.label(label);
|
||||
let label = ui.label(label);
|
||||
let mut v = text(d, key);
|
||||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut v).desired_width(420.0)).labelled_by(label.id).changed() {
|
||||
d.values.insert(key.into(), json!(v));
|
||||
}
|
||||
};
|
||||
@@ -3094,16 +3115,17 @@ fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confi
|
||||
/// Data › Solver: the Solver Parameters form. Solve runs `data.solver` through the dialog's
|
||||
/// command; the result opens Solver Results.
|
||||
fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let text = |d: &Dialog, k: &str| d.values.get(k).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())).unwrap_or_default();
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32| {
|
||||
let edit = |ui: &mut egui::Ui, d: &mut Dialog, k: &str, width: f32, label: &str| {
|
||||
let mut t = text(d, k);
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).changed() {
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(width)).accessible_name(lang.tr(label)).changed() {
|
||||
d.values.insert(k.into(), json!(t));
|
||||
}
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Set Objective:");
|
||||
edit(ui, d, "objective", 200.0);
|
||||
edit(ui, d, "objective", 200.0, "Set Objective:");
|
||||
});
|
||||
let goal = text(d, "goal");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -3113,10 +3135,10 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
d.values.insert("goal".into(), json!(g));
|
||||
}
|
||||
}
|
||||
edit(ui, d, "value", 80.0);
|
||||
edit(ui, d, "value", 80.0, "Value Of:");
|
||||
});
|
||||
ui.label("By Changing Variable Cells:");
|
||||
edit(ui, d, "variables", 420.0);
|
||||
edit(ui, d, "variables", 420.0, "By Changing Variable Cells:");
|
||||
ui.label("Subject to the Constraints:");
|
||||
let list: Vec<String> = d
|
||||
.values
|
||||
@@ -3135,17 +3157,22 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
edit(ui, d, "newLhs", 110.0);
|
||||
edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference");
|
||||
let op = text(d, "newOp");
|
||||
egui::ComboBox::from_id_salt("solverOp").selected_text(op.clone()).width(60.0).show_ui(ui, |ui| {
|
||||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||||
if ui.selectable_label(op == o, o).clicked() {
|
||||
d.values.insert("newOp".into(), json!(o));
|
||||
egui::ComboBox::from_id_salt("solverOp")
|
||||
.selected_text(op.clone())
|
||||
.width(60.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for o in ["<=", "=", ">=", "int", "bin"] {
|
||||
if ui.selectable_label(op == o, o).clicked() {
|
||||
d.values.insert("newOp".into(), json!(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Constraint Operator"));
|
||||
if !matches!(op.as_str(), "int" | "bin") {
|
||||
edit(ui, d, "newRhs", 110.0);
|
||||
edit(ui, d, "newRhs", 110.0, "Constraint Value");
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let lhs = text(d, "newLhs");
|
||||
@@ -3178,13 +3205,17 @@ fn solver(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool
|
||||
"evolutionary" => "Evolutionary",
|
||||
_ => "GRG Nonlinear",
|
||||
};
|
||||
egui::ComboBox::from_id_salt("solverMethod").selected_text(label(&method)).show_ui(ui, |ui| {
|
||||
for m in ["grg", "simplex", "evolutionary"] {
|
||||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||||
d.values.insert("method".into(), json!(m));
|
||||
egui::ComboBox::from_id_salt("solverMethod")
|
||||
.selected_text(label(&method))
|
||||
.show_ui(ui, |ui| {
|
||||
for m in ["grg", "simplex", "evolutionary"] {
|
||||
if ui.selectable_label(method == m, label(m)).clicked() {
|
||||
d.values.insert("method".into(), json!(m));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.accessible_name(lang.tr("Select a Solving Method:"));
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(match method.as_str() {
|
||||
|
||||
@@ -142,10 +142,11 @@ fn decode(data: &[u8], side: u32) -> Option<(egui::ColorImage, bool)> {
|
||||
Some((egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()), complete))
|
||||
}
|
||||
|
||||
/// Returns the context-local shared cache of floating-picture textures.
|
||||
fn cache(ctx: &egui::Context) -> Arc<Mutex<Cache>> {
|
||||
// Context-local, like cell pictures: texture handles never leak between offscreen, native
|
||||
// and browser contexts.
|
||||
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::new("gridcraft.floating_pictures")).clone())
|
||||
ctx.data_mut(|data| data.get_temp_mut_or_default::<Arc<Mutex<Cache>>>(egui::Id::unique("gridcraft.floating_pictures")).clone())
|
||||
}
|
||||
|
||||
/// Drops the textures of workbooks that are no longer open. Called every frame the grid paints.
|
||||
|
||||
@@ -78,7 +78,7 @@ pub fn system_font_dirs() -> Vec<PathBuf> {
|
||||
/// Weight implied by a style name (`"Bold"` → 700, `"Light"` → 300).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn style_weight(style: &str) -> f32 {
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
const TABLE: &[(&str, f32)] = &[
|
||||
("hairline", 100.0),
|
||||
("thin", 100.0),
|
||||
@@ -107,7 +107,7 @@ impl Face {
|
||||
/// Whether a style name reads as italic or oblique.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn style_italic(style: &str) -> bool {
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect();
|
||||
let s: String = style.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect();
|
||||
s.contains("italic") || s.contains("oblique")
|
||||
}
|
||||
|
||||
@@ -165,8 +165,10 @@ pub fn scan_dirs(dirs: &[PathBuf]) -> Vec<Face> {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
let is_font =
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
||||
let is_font = path
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|e| matches!(e.to_ascii_lowercase().as_str(), "ttf" | "otf" | "ttc" | "otc"));
|
||||
if !is_font || std::fs::metadata(&path).map(|m| m.len() > MAX_FONT_FILE).unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
@@ -366,12 +368,12 @@ fn bound() -> &'static std::sync::RwLock<HashSet<String>> {
|
||||
|
||||
/// Record the role families a freshly built [`egui::FontDefinitions`] bound.
|
||||
pub fn set_bound(keys: impl IntoIterator<Item = String>) {
|
||||
*bound().write().unwrap_or_else(|e| e.into_inner()) = keys.into_iter().collect();
|
||||
*bound().write().unwrap_or_else(std::sync::PoisonError::into_inner) = keys.into_iter().collect();
|
||||
}
|
||||
|
||||
/// Is this exact role family bound to font data?
|
||||
pub fn is_bound(role: &str) -> bool {
|
||||
bound().read().unwrap_or_else(|e| e.into_inner()).contains(role)
|
||||
bound().read().unwrap_or_else(std::sync::PoisonError::into_inner).contains(role)
|
||||
}
|
||||
|
||||
/// Serializes tests that mutate the process-wide bound-family registry.
|
||||
|
||||
@@ -47,8 +47,9 @@ impl Default for BarLayout {
|
||||
}
|
||||
|
||||
impl BarLayout {
|
||||
/// Returns the stable widget id used for the formula-bar layout state.
|
||||
pub fn id() -> egui::Id {
|
||||
egui::Id::new("formula_bar.layout")
|
||||
egui::Id::unique("formula_bar.layout")
|
||||
}
|
||||
|
||||
/// Last frame's layout.
|
||||
@@ -64,6 +65,7 @@ pub struct EditorInfo {
|
||||
pub caret: Option<Rect>,
|
||||
}
|
||||
|
||||
/// Draws the formula bar: line-count-driven height, Name Box, cancel/enter/Fx buttons, the editable field, muted read-only spill text and the expansion chevron.
|
||||
pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::ui_font(13.0);
|
||||
@@ -73,7 +75,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
// at MAX_LINES. It eases only when that target changes.
|
||||
let lines = if app.ui.formula_bar_expanded { MAX_LINES } else { last.lines.clamp(1, MAX_LINES) };
|
||||
let target = one_line(row_h) + (lines - 1) as f32 * row_h;
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::new("formula_bar.height"), target, EASE_SECONDS);
|
||||
let h = ui.ctx().animate_value_with_time(egui::Id::unique("formula_bar.height"), target, EASE_SECONDS);
|
||||
let mut layout = BarLayout { height: h, ..Default::default() };
|
||||
egui::Panel::top("formula_bar")
|
||||
.exact_size(h)
|
||||
@@ -106,7 +108,7 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect_stroke(field, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let inner = field.shrink2(vec2(6.0, 3.0));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner));
|
||||
let id = egui::Id::new("gridcraft.formula_bar");
|
||||
let id = egui::Id::unique("gridcraft.formula_bar");
|
||||
// A spill child shows its anchor's formula muted and read-only.
|
||||
let read_only = active_spill_anchor(app).is_some();
|
||||
let info = if app.editor.is_some() {
|
||||
@@ -185,15 +187,21 @@ fn active_input(app: &SheetApp) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// 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::new("gridcraft.name_box");
|
||||
let id = egui::Id::unique("gridcraft.name_box");
|
||||
let shown = app.name_box_text();
|
||||
let mut text = app.name_box.clone().unwrap_or(shown.clone());
|
||||
let rect = Rect::from_min_size(ui.cursor().min, vec2(118.0, 24.0));
|
||||
ui.painter().rect_filled(rect, 4.0, t.input_bg);
|
||||
ui.painter().rect_stroke(rect, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(rect.min + vec2(6.0, 3.0), vec2(96.0, 18.0))));
|
||||
let resp = child.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0));
|
||||
// The box has no visible label of its own: screen readers and the strict accessibility check
|
||||
// need a name (egui 0.37 makes unnamed text inputs a test failure).
|
||||
let accessible_name = app.ui.language.tr("Name Box").to_string();
|
||||
let resp = child
|
||||
.add(TextEdit::singleline(&mut text).id(id).frame(egui::Frame::NONE).font(theme::ui_font(13.0)).desired_width(96.0))
|
||||
.accessible_name(accessible_name);
|
||||
if resp.gained_focus() {
|
||||
app.name_box = Some(shown.clone());
|
||||
}
|
||||
@@ -284,7 +292,7 @@ fn show_edit<'t>(
|
||||
/// `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 {
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::new("gridcraft.formula_bar"));
|
||||
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()));
|
||||
@@ -412,7 +420,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let on_sheet = id == egui::Id::new("gridcraft.cell_editor");
|
||||
let on_sheet = id == egui::Id::unique("gridcraft.cell_editor");
|
||||
let cell_text_color = if on_sheet { t.cell_text } else { t.text };
|
||||
let font2 = font.clone();
|
||||
let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
@@ -467,7 +475,9 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
})
|
||||
.inner
|
||||
};
|
||||
let resp = output.response.clone();
|
||||
// Both editors are text inputs without a visible label next to them: name the one being shown.
|
||||
let accessible_name = (if on_sheet { app.ui.language.tr("Cell Editor") } else { app.ui.language.tr("Formula Bar") }).to_string();
|
||||
let resp = output.response.response.clone().accessible_name(accessible_name);
|
||||
if synced {
|
||||
if ed.request_focus {
|
||||
resp.request_focus();
|
||||
@@ -486,7 +496,7 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
output.state.store(ui.ctx(), id);
|
||||
} else if resp.clicked() || resp.gained_focus() {
|
||||
// Clicking into the other editor moves editing there.
|
||||
ed.from_formula_bar = id == egui::Id::new("gridcraft.formula_bar");
|
||||
ed.from_formula_bar = id == egui::Id::unique("gridcraft.formula_bar");
|
||||
ed.enter_mode = false;
|
||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
@@ -540,8 +550,11 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
ui.painter().rect_filled(r, 0.0, t.accent);
|
||||
ui.painter().galley(pos, g, t.on_accent);
|
||||
}
|
||||
// Autocomplete list and argument hint under the editor.
|
||||
if mine && ui.is_rect_visible(resp.rect) && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
// Autocomplete list and argument hint under the editor. Decide visibility from the area the
|
||||
// editor was allotted, not from its response: egui 0.37 gives every text field a
|
||||
// `spacing.interact_size.y` floor, so a one-line cell editor's response can reach below a short
|
||||
// cell and touch the visible quadrant even while the cell itself is hidden under a frozen row.
|
||||
if mine && ui.max_rect().intersect(ui.clip_rect()).is_positive() && (!ed.autocomplete.is_empty() || ed.current_function().is_some()) {
|
||||
let below = resp.rect.left_bottom() + vec2(0.0, 4.0);
|
||||
let language = app.ui.language;
|
||||
// Areas have their own clip rect: keep cell helpers inside the worksheet.
|
||||
@@ -768,6 +781,7 @@ mod spill_tests {
|
||||
check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true);
|
||||
}
|
||||
#[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() {
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
for dark in [false, true] {
|
||||
@@ -788,6 +802,9 @@ mod spill_tests {
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
// `run_steps` does not enforce the 0.37 strict policy; run it explicitly so the
|
||||
// formula bar's accessible names are actually tested.
|
||||
h.check_accessibility();
|
||||
let disabled = Tokens::get(&h.ctx).text_disabled;
|
||||
assert!(h.output().shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t)
|
||||
if t.galley.job.text == "=SEQUENCE(3)" && t.galley.job.sections.iter().all(|section| section.format.color == disabled))));
|
||||
@@ -798,6 +815,7 @@ mod spill_tests {
|
||||
h.run_steps(3);
|
||||
h.get_by_label("=SEQUENCE(3)").click();
|
||||
h.run_steps(3);
|
||||
h.check_accessibility();
|
||||
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
|
||||
}
|
||||
}
|
||||
|
||||
+227
-38
@@ -115,6 +115,26 @@ pub struct FillTag {
|
||||
pub undo_len: usize,
|
||||
}
|
||||
|
||||
/// Trace Precedents / Trace Dependents arrows: they stay on the cell they were traced from, on its
|
||||
/// sheet, while the selection moves. They go away when anything changes the workbook
|
||||
/// (`revision`): Excel removes them on edits to the traced formula and on inserted, deleted or
|
||||
/// moved cells, this also on a new value in a precedent.
|
||||
pub struct Trace {
|
||||
pub uid: u64,
|
||||
pub revision: u64,
|
||||
pub sheet: usize,
|
||||
pub cell: CellRef,
|
||||
/// The command's result: `{"sheet", "range"}` precedents or `{"sheet", "cell"}` dependents.
|
||||
pub items: serde_json::Value,
|
||||
}
|
||||
|
||||
impl Trace {
|
||||
/// A trace of the active cell, just run.
|
||||
pub fn new(app: &SheetApp, items: serde_json::Value) -> Option<Trace> {
|
||||
app.session.active().map(|d| Trace { uid: d.uid, revision: d.revision, sheet: d.wb.active_sheet, cell: d.selection.active, items })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct GridState {
|
||||
pub drag: Drag,
|
||||
@@ -150,7 +170,7 @@ pub struct GridState {
|
||||
pub last_border: String,
|
||||
pub last_fill: String,
|
||||
pub last_font_color: String,
|
||||
pub trace: Option<serde_json::Value>,
|
||||
pub trace: Option<Trace>,
|
||||
pub renaming_tab: Option<usize>,
|
||||
pub rename_text: String,
|
||||
pub list_picker: Option<CellRef>,
|
||||
@@ -711,9 +731,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
// Keep the active cell (or the moving end of an extension) visible after keyboard moves.
|
||||
if app.grid.ensure_visible {
|
||||
app.grid.ensure_visible = false;
|
||||
let cell = if std::mem::take(&mut app.grid.reveal_moving_end) { sel.moving_end() } else { sel.active };
|
||||
let moving = std::mem::take(&mut app.grid.reveal_moving_end);
|
||||
let cell = if moving { sel.moving_end() } else { sel.active };
|
||||
let from = app.grid.reveal_from.take();
|
||||
let target = app.editor.as_ref().and_then(|e| e.point_cell).unwrap_or(cell);
|
||||
// A whole column's moving end sits on the sheet's last row (a whole row's on its last
|
||||
// column): when an extension moves it only across, the view keeps its place the other way.
|
||||
// An extension that reaches the last row itself (Ctrl+Shift+Down past the data) still
|
||||
// follows it.
|
||||
let extended = (moving && target == cell).then(|| sel.current());
|
||||
let before = view.scroll;
|
||||
if paper {
|
||||
crate::paper_view::ensure_covers(app, &wb, si, target);
|
||||
geo = make_geo(app, view.scroll);
|
||||
@@ -741,6 +768,12 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
} else if from.is_some_and(|f| f.col >= geo.fc) {
|
||||
view.scroll.x = 0.0;
|
||||
}
|
||||
if extended.is_some_and(|r| r.is_full_cols()) && from.is_some_and(|f| f.row == cell.row) {
|
||||
view.scroll.y = before.y;
|
||||
}
|
||||
if extended.is_some_and(|r| r.is_full_rows()) && from.is_some_and(|f| f.col == cell.col) {
|
||||
view.scroll.x = before.x;
|
||||
}
|
||||
view.scroll = view.scroll.max(egui::Vec2::ZERO);
|
||||
geo = make_geo(app, view.scroll);
|
||||
}
|
||||
@@ -904,7 +937,7 @@ fn vertical_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, sh: &Sheet, geo: &G
|
||||
let last = sh.used_range().map(|r| r.end.row.saturating_add(10)).unwrap_or(100).clamp(100, MAX_ROWS - 1);
|
||||
let content = geo.scroll_top_of(sh, last);
|
||||
let extent = (content - visible).max(scroll).max(0.0).min(limit);
|
||||
let id = ui.id().with("vscroll");
|
||||
let id = ui.scope_id().with("vscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
@@ -957,7 +990,7 @@ pub(crate) fn horizontal_scrollbar(app: &mut SheetApp, ui: &mut egui::Ui, track:
|
||||
let last = sh.used_range().map(|r| r.end.col.saturating_add(10)).unwrap_or(26).clamp(26, MAX_COLS - 1);
|
||||
let content = geo.scroll_left_of(sh, last);
|
||||
let extent = (content - visible).max(scroll).max(0.0);
|
||||
let id = ui.id().with("hscroll");
|
||||
let id = ui.scope_id().with("hscroll");
|
||||
let resp = ui.interact(track, id, Sense::click_and_drag());
|
||||
let drag_id = id.with("extent");
|
||||
if resp.drag_started() {
|
||||
@@ -2200,9 +2233,8 @@ fn interact(app: &mut SheetApp, ui: &mut egui::Ui, resp: &egui::Response, geo: &
|
||||
}
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
app.selected_chart = None;
|
||||
}
|
||||
app.selected_chart = None;
|
||||
}
|
||||
// A right-click on a chart, picture, shape or slicer opens the object's menu, not the cell's
|
||||
// under it; a right press that wobbles into a drag counts too (#2067).
|
||||
@@ -2909,7 +2941,7 @@ fn hidden_row_edge(geo: &Geo, sh: &Sheet, y: f32) -> Option<u32> {
|
||||
/// has no such shape. Black on a white outline, like the system cursors.
|
||||
fn paint_split_cursor(ctx: &egui::Context, at: Pos2, cols: bool) {
|
||||
ctx.set_cursor_icon(CursorIcon::None);
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("gridcraft.split_cursor")));
|
||||
let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("gridcraft.split_cursor")));
|
||||
// Drawn for columns (bars vertical); rows swap the axes.
|
||||
let pt = |x: f32, y: f32| if cols { pos2(at.x + x, at.y + y) } else { pos2(at.x + y, at.y + x) };
|
||||
let arrow = |dir: f32| vec![pt(dir * 10.0, 0.0), pt(dir * 6.0, -4.0), pt(dir * 6.0, 4.0)];
|
||||
@@ -2998,7 +3030,7 @@ fn select_ranges(app: &mut SheetApp, r: RangeRef, add: bool, active: CellRef) {
|
||||
/// Grid keyboard handling when no text field has focus.
|
||||
fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
|
||||
// Focus left on an editor that no longer exists goes back to the grid.
|
||||
let stale = [egui::Id::new("gridcraft.cell_editor"), egui::Id::new("gridcraft.formula_bar"), crate::text_box::editor_id()];
|
||||
let stale = [egui::Id::unique("gridcraft.cell_editor"), egui::Id::unique("gridcraft.formula_bar"), crate::text_box::editor_id()];
|
||||
let editing = app.editor.is_some() || app.text_box_editor.is_some();
|
||||
if !editing && ctx.memory(|m| m.focused()).is_some_and(|f| stale.contains(&f)) {
|
||||
resp.request_focus();
|
||||
@@ -3043,6 +3075,14 @@ fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo:
|
||||
app.copy_to_clipboard(ctx, "edit.cut");
|
||||
}
|
||||
egui::Event::Paste(text) => app.paste_external(text),
|
||||
// GridCraft has no image-clipboard pipeline and no cell-picture insert-from-clipboard
|
||||
// command. Choosing to ignore `PasteImage` is explicit: inserting the bytes as a
|
||||
// floating picture without a chosen anchor/size would be a new feature, not parity.
|
||||
// A text representation, when the clipboard has one, still arrives as `Paste` above.
|
||||
egui::Event::PasteImage(_) => {}
|
||||
// PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
|
||||
// insert text at, so a middle click on a cell is deliberately not a paste.
|
||||
egui::Event::MiddleClickPaste { .. } => {}
|
||||
egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
|
||||
egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
|
||||
app.begin_edit(Some(text), false);
|
||||
@@ -3151,6 +3191,7 @@ fn editor_width(text_w: f32, cell_w: f32, size: f32) -> f32 {
|
||||
if text_w + 14.0 <= cell_w { cell_w } else { text_w + editor_slack(size) }
|
||||
}
|
||||
|
||||
/// Draws the in-cell editor, growing and wrapping with its text, mirroring for right-to-left sheets, choosing contrasting caret ink and clipping cells hidden under frozen panes.
|
||||
fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet, wb: &Workbook) {
|
||||
let Some(ed) = app.editor.as_ref() else { return };
|
||||
if ed.sheet != wb.active_sheet {
|
||||
@@ -3193,18 +3234,29 @@ fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet,
|
||||
pos2(if cell.col >= geo.fc { split.x } else { geo.cells.left() }, if cell.row >= geo.fr { split.y } else { geo.cells.top() }),
|
||||
pos2(if cell.col >= geo.fc { geo.cells.right() } else { split.x }, if cell.row >= geo.fr { geo.cells.bottom() } else { split.y }),
|
||||
);
|
||||
let clip = ui.clip_rect().intersect(quadrant);
|
||||
// A cell scrolled entirely under a frozen row/column keeps its edit (Escape still works),
|
||||
// but neither the cell nor its helper may paint. Test the cell's own visible quadrant and
|
||||
// give the shared editor an empty clip when the cell is hidden: a one-line cell editor's
|
||||
// field can be larger than the cell (egui 0.37 gives text fields a minimum height), so its
|
||||
// own max_rect is not a reliable "is the cell on screen" test at the frozen boundary. The
|
||||
// pre-wrap cell rect must also be positive: a hidden row/column has zero height/width, but
|
||||
// Wrap Text can expand the editor below/right of it, which must not paint over the next line.
|
||||
let cell_visible = rect.width() > 0.0 && rect.height() > 0.0;
|
||||
// `erect` and `ui.clip_rect()` are in screen coordinates; `quadrant` is logical until mirrored.
|
||||
let screen_quadrant = geo.mirror_rect(quadrant);
|
||||
let on_screen = cell_visible && erect.intersect(screen_quadrant).is_positive() && !geo.hidden_under_frozen(sh, cell);
|
||||
let clip = if on_screen { ui.clip_rect().intersect(screen_quadrant) } else { Rect::NOTHING };
|
||||
let painter = ui.painter().with_clip_rect(clip);
|
||||
painter.rect_filled(erect, 0.0, fill);
|
||||
painter.rect_stroke(erect.expand(1.0), 0.0, Stroke::new(2.0, Tokens::get(ui.ctx()).sel_border), StrokeKind::Middle);
|
||||
let id = egui::Id::new("gridcraft.cell_editor");
|
||||
let id = egui::Id::unique("gridcraft.cell_editor");
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(erect.shrink2(vec2(2.0, 1.0))));
|
||||
child.set_clip_rect(clip);
|
||||
// The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
|
||||
// theme, on the white paper): pick black or white against the fill, like Excel.
|
||||
child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
|
||||
crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("gridcraft.cell_editor.rect"), erect));
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));
|
||||
}
|
||||
|
||||
/// Test the popup itself, not whether the pointer is over any part of the app.
|
||||
@@ -3224,6 +3276,7 @@ fn filter_table_range(sh: &Sheet, col: u32, row: u32) -> Option<RangeRef> {
|
||||
sh.tables.iter().find(|t| t.range.start.row == row && col >= t.range.start.col && col <= t.range.end.col).map(|t| t.range)
|
||||
}
|
||||
|
||||
/// Draws the filter-header popup: sort, search/select-all, apply/clear, a lazily installed table autofilter, new-open search focus and dismissal.
|
||||
fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
|
||||
let Some((col, row, at)) = app.grid.filter_menu else { return };
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -3252,16 +3305,16 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
}
|
||||
let wb = app.session.active().map(|d| d.wb.clone()).unwrap_or(wb);
|
||||
let values = gridcraft_engine::cmd::data::filter_values(&wb, si, col);
|
||||
let key = egui::Id::new(("filter_sel", col));
|
||||
let search_key = egui::Id::new(("filter_search", col));
|
||||
let buttons_key = egui::Id::new(("filter_buttons", col));
|
||||
let key = egui::Id::unique(("filter_sel", col));
|
||||
let search_key = egui::Id::unique(("filter_search", col));
|
||||
let buttons_key = egui::Id::unique(("filter_buttons", col));
|
||||
// Enter is the menu's OK, as in Excel: taken before the search box (which would only drop its
|
||||
// focus) and the value checkboxes (which would toggle) see it. A focused button keeps it as its click.
|
||||
let buttons: Vec<egui::Id> = ui.ctx().data_mut(|d| d.get_temp(buttons_key)).unwrap_or_default();
|
||||
let on_button = ui.ctx().memory(|m| m.focused()).is_some_and(|f| buttons.contains(&f));
|
||||
let enter = !on_button && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(egui::Id::new("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let area = egui::Area::new(egui::Id::unique("filter_menu")).fixed_pos(at + vec2(-180.0, 10.0)).order(egui::Order::Foreground);
|
||||
let response = area.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_width(230.0);
|
||||
@@ -3341,10 +3394,11 @@ fn filter_menu(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws and dispatches the cell context menu, including Show Details when offered, clipboard and insert/format actions; closes on Esc or an outside press.
|
||||
fn context_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let Some(at) = app.grid.context_menu else { return };
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let resp = egui::Area::new(egui::Id::unique("cell_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let insert_copied = insert_copied_item(app);
|
||||
@@ -3513,8 +3567,8 @@ fn fill_options_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(button, egui::Id::new("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Auto Fill Options")));
|
||||
let resp = ui.interact(button, egui::Id::unique("gridcraft.fill_options_tag"), Sense::click()).on_hover_text(lang.tr("Auto Fill Options"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Auto Fill Options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(button, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
@@ -3561,7 +3615,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let mut close = false;
|
||||
let mut items = fill_modes(app, src);
|
||||
items.extend([("-", ""), ("Series…", "ui:series")]);
|
||||
let response = egui::Area::new(egui::Id::new("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("fill_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
@@ -3592,7 +3646,7 @@ fn fill_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
fn object_menu(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let Some((at, kind, id)) = app.grid.object_menu else { return };
|
||||
let mut close = false;
|
||||
let resp = egui::Area::new(egui::Id::new("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let resp = egui::Area::new(egui::Id::unique("object_context")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(200.0);
|
||||
let mut items = vec![("Delete", "object.delete")];
|
||||
@@ -3649,8 +3703,8 @@ fn error_check_tag(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &Sheet)
|
||||
return;
|
||||
}
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let resp = ui.interact(tag, egui::Id::new("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, lang.tr("Error checking options")));
|
||||
let resp = ui.interact(tag, egui::Id::unique("gridcraft.error_check_tag"), Sense::click()).on_hover_text(lang.tr("Number Stored as Text"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, lang.tr("Error checking options")));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(tag, 3.0, if resp.hovered() { t.hover } else { t.window });
|
||||
@@ -3715,6 +3769,7 @@ pub fn validation_arrow(sh: &Sheet, geo: &Geo, c: CellRef) -> Option<Rect> {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// Paints the validation arrow and input message, notes, the hyperlink tooltip and trace precedent/dependent arrows.
|
||||
pub(crate) fn paint_overlays(
|
||||
app: &mut SheetApp,
|
||||
ui: &egui::Ui,
|
||||
@@ -3744,15 +3799,18 @@ pub(crate) fn paint_overlays(
|
||||
{
|
||||
let r = geo.cell_rect(sh, sel.active);
|
||||
let (at, pivot) = (geo.mirror_pos(pos2(r.left() + 8.0, r.bottom() + 6.0)), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
|
||||
ui.set_max_width(220.0);
|
||||
if !dv.input_title.is_empty() {
|
||||
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
|
||||
}
|
||||
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
|
||||
});
|
||||
});
|
||||
egui::Area::new(egui::Id::unique("dv_input")).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(Color32::from_rgb(0xFF, 0xFF, 0xE1)).show(ui, |ui| {
|
||||
ui.set_max_width(220.0);
|
||||
if !dv.input_title.is_empty() {
|
||||
ui.label(egui::RichText::new(&dv.input_title).strong().color(Color32::BLACK));
|
||||
}
|
||||
ui.label(egui::RichText::new(&dv.input_message).color(Color32::BLACK));
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
// Notes: shown on hover (or always when marked visible).
|
||||
let hover = app.grid.hover_cell;
|
||||
@@ -3769,7 +3827,7 @@ pub(crate) fn paint_overlays(
|
||||
let at = pos2(r.right() + 10.0, r.top());
|
||||
p.line_segment([r.right_top(), at + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::from_gray(90)));
|
||||
let (at, pivot) = (geo.mirror_pos(at), if geo.rtl { Align2::RIGHT_TOP } else { Align2::LEFT_TOP });
|
||||
egui::Area::new(egui::Id::new(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
egui::Area::new(egui::Id::unique(("note", c.row, c.col))).fixed_pos(at).pivot(pivot).order(egui::Order::Tooltip).interactable(false).show(
|
||||
ui.ctx(),
|
||||
|ui| {
|
||||
egui::Frame::popup(ui.style()).fill(if cm.threaded { Color32::WHITE } else { Color32::from_rgb(0xFF, 0xFF, 0xE1) }).show(ui, |ui| {
|
||||
@@ -3789,16 +3847,18 @@ pub(crate) fn paint_overlays(
|
||||
&& app.editor.is_none()
|
||||
{
|
||||
let tip = h.tooltip.clone().unwrap_or_else(|| format!("{}\nClick once to follow. Click and hold to select this cell.", h.target));
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::unique("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(tip);
|
||||
});
|
||||
}
|
||||
// Trace precedents / dependents arrows.
|
||||
if let Some(trace) = app.grid.trace.clone()
|
||||
&& let Some(items) = trace.as_array()
|
||||
if let Some(trace) = &app.grid.trace
|
||||
&& app.session.active().is_some_and(|d| d.uid == trace.uid && d.revision == trace.revision)
|
||||
&& trace.sheet == si
|
||||
&& let Some(items) = trace.items.as_array()
|
||||
{
|
||||
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
|
||||
let active = geo.cell_rect(sh, sel.active).center();
|
||||
let active = geo.cell_rect(sh, trace.cell).center();
|
||||
for it in items {
|
||||
if it["sheet"].as_str().is_some_and(|s| !s.eq_ignore_ascii_case(&sh.name)) {
|
||||
continue;
|
||||
@@ -3834,6 +3894,7 @@ fn list_picker_height(n: usize, row_h: f32, gap: f32) -> f32 {
|
||||
rows * row_h + (rows - 1.0) * gap
|
||||
}
|
||||
|
||||
/// Draws the validation list popup, choosing above/below and left/right placement for RTL, and applies the clicked item with `cell.set`.
|
||||
fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool) {
|
||||
let Some(c) = app.grid.list_picker else { return };
|
||||
let Some(d) = app.session.active() else { return };
|
||||
@@ -3858,7 +3919,7 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
(true, true) => (geo.mirror_pos(r.left_top()), Align2::RIGHT_BOTTOM),
|
||||
};
|
||||
let mut close = false;
|
||||
let response = egui::Area::new(egui::Id::new("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("dv_list")).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(2.0).show(ui, |ui| {
|
||||
ui.set_min_width(r.width().max(100.0));
|
||||
egui::ScrollArea::vertical().max_height(height).min_scrolled_height(height).show(ui, |ui| {
|
||||
@@ -3879,10 +3940,11 @@ fn list_picker(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, was_open: bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws row/column header context menus with insert/delete, format, size, hide/unhide and group actions, plus Insert Copied Cells when available.
|
||||
fn header_menu(app: &mut SheetApp, ui: &mut egui::Ui, was_open: bool) {
|
||||
let Some((at, rows)) = app.grid.header_menu else { return };
|
||||
let mut close = false;
|
||||
let response = egui::Area::new(egui::Id::new("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
let response = egui::Area::new(egui::Id::unique("header_menu")).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.set_min_width(190.0);
|
||||
let insert_copied = insert_copied_item(app);
|
||||
@@ -4584,3 +4646,130 @@ mod outline_band_tests {
|
||||
assert!(outline_bands(Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(1.0, 1.0)), 2.0, clip).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod trace_arrow_tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
SheetApp::new(s, Default::default())
|
||||
}
|
||||
|
||||
fn select(app: &mut SheetApp, cell: &str) {
|
||||
app.run("selection.set", json!({"cell": cell})).unwrap();
|
||||
}
|
||||
|
||||
/// What the ribbon's Trace Precedents / Trace Dependents buttons do.
|
||||
fn trace(app: &mut SheetApp, id: &str) {
|
||||
let r = app.run(id, json!({})).unwrap();
|
||||
app.grid.trace = Trace::new(app, r);
|
||||
}
|
||||
|
||||
/// The arrows `paint_overlays` draws on the active sheet, as (tail, head) points.
|
||||
fn arrows(app: &mut SheetApp) -> (Geo, Vec<(Pos2, Pos2)>) {
|
||||
let d = app.session.active().unwrap();
|
||||
let (wb, si, sel) = (d.wb.clone(), d.wb.active_sheet, d.selection.clone());
|
||||
let sh = wb.sheet(si).unwrap();
|
||||
let geo = Geo::new(sh, Rect::from_min_size(pos2(0.0, 0.0), vec2(1200.0, 800.0)), vec2(0.0, 0.0));
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let p = ui.painter().clone();
|
||||
paint_overlays(app, ui, &p, &geo, &wb, si, sh, &sel);
|
||||
});
|
||||
// No renderer consumes the font atlas upload here.
|
||||
out.textures_delta.clear();
|
||||
let blue = Color32::from_rgb(0x1F, 0x5F, 0xC9);
|
||||
let mut lines = Vec::new();
|
||||
for s in out.shapes {
|
||||
if let egui::Shape::LineSegment { points, stroke } = s.shape
|
||||
&& stroke.color == blue
|
||||
{
|
||||
lines.push((points[0], points[1]));
|
||||
}
|
||||
}
|
||||
(geo, lines)
|
||||
}
|
||||
|
||||
fn center(app: &SheetApp, geo: &Geo, cell: &str) -> Pos2 {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = d.wb.sheet(d.wb.active_sheet).unwrap();
|
||||
geo.cell_rect(sh, CellRef::parse(cell).unwrap()).center()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precedent_arrows_stay_on_the_traced_cell_when_the_selection_moves() {
|
||||
let mut app = app();
|
||||
for (c, v) in [("B2", "1"), ("B3", "2"), ("D2", "1"), ("D3", "2"), ("F7", "=B2+B3+SUM(D2:D3)")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
select(&mut app, "F7");
|
||||
trace(&mut app, "formulas.tracePrecedents");
|
||||
select(&mut app, "H7");
|
||||
let (geo, lines) = arrows(&mut app);
|
||||
assert_eq!(lines.len(), 3, "B2, B3 and D2:D3: {lines:?}");
|
||||
let f7 = center(&app, &geo, "F7");
|
||||
assert!(lines.iter().all(|&(_, head)| head == f7), "every arrow points at F7, not at the active H7: {lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependent_arrows_stay_on_the_traced_cell_when_the_selection_moves() {
|
||||
let mut app = app();
|
||||
for (c, v) in [("B2", "1"), ("D4", "=B2*2"), ("E6", "=B2+1")] {
|
||||
app.run("cell.set", json!({"cell": c, "input": v})).unwrap();
|
||||
}
|
||||
select(&mut app, "B2");
|
||||
trace(&mut app, "formulas.traceDependents");
|
||||
select(&mut app, "C9");
|
||||
let (geo, lines) = arrows(&mut app);
|
||||
assert_eq!(lines.len(), 2, "D4 and E6: {lines:?}");
|
||||
let b2 = center(&app, &geo, "B2");
|
||||
assert!(lines.iter().all(|&(tail, _)| tail == b2), "every arrow starts at B2, not at the active C9: {lines:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_stay_on_the_sheet_of_the_traced_cell() {
|
||||
let mut app = app();
|
||||
app.run("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
app.run("cell.set", json!({"sheet": "Sheet1", "cell": "B2", "input": "1"})).unwrap();
|
||||
app.run("sheet.activate", json!({"sheet": "Two"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "F7", "input": "=Sheet1!B2"})).unwrap();
|
||||
select(&mut app, "F7");
|
||||
trace(&mut app, "formulas.tracePrecedents");
|
||||
// The precedent lives on Sheet1, but the arrow belongs to Two's F7.
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
assert!(arrows(&mut app).1.is_empty(), "no arrow on Sheet1 from B2 to where F7 would be");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_a_row_removes_the_arrows() {
|
||||
let mut app = app();
|
||||
app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
|
||||
select(&mut app, "F7");
|
||||
trace(&mut app, "formulas.tracePrecedents");
|
||||
assert_eq!(arrows(&mut app).1.len(), 1);
|
||||
app.run("home.insertRows", json!({"rows": "4"})).unwrap();
|
||||
assert!(arrows(&mut app).1.is_empty(), "F7 is now F8: the old arrows would point at the wrong cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_stay_in_their_workbook() {
|
||||
let mut app = app();
|
||||
app.session.new_workbook();
|
||||
// Two books with the same cells and the same revision: only the document tells them apart.
|
||||
for book in [1, 0] {
|
||||
app.session.set_active(book);
|
||||
app.run("cell.set", json!({"cell": "B2", "input": "1"})).unwrap();
|
||||
app.run("cell.set", json!({"cell": "F7", "input": "=B2"})).unwrap();
|
||||
}
|
||||
select(&mut app, "F7");
|
||||
trace(&mut app, "formulas.tracePrecedents");
|
||||
assert_eq!(arrows(&mut app).1.len(), 1);
|
||||
app.session.set_active(1);
|
||||
let revisions: Vec<u64> = app.session.documents().iter().map(|d| d.revision).collect();
|
||||
assert_eq!(revisions[0], revisions[1]);
|
||||
assert!(arrows(&mut app).1.is_empty(), "Book1's arrows are not drawn on Book2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ pub fn saved_language(ui_json: &serde_json::Value) -> Option<Language> {
|
||||
/// egui context-data key holding the language of the frame being drawn. Widgets that only get a
|
||||
/// `Ui` (not the app) read it to translate their labels; [`set_current`] writes it once per frame.
|
||||
fn lang_key() -> egui::Id {
|
||||
egui::Id::new("gridcraft.language")
|
||||
egui::Id::unique("gridcraft.language")
|
||||
}
|
||||
|
||||
/// The language the current frame is drawing in. `En` when unset.
|
||||
@@ -374,6 +374,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen-reader names for the formula-bar controls must be translated in every catalog
|
||||
/// (no silent English fallback) and stay single-line labels.
|
||||
#[test]
|
||||
fn accessible_control_names_are_translated_in_every_catalog() {
|
||||
for key in ["Name Box", "Cell Editor", "Font Size", "Sheet Name", "Text Box"] {
|
||||
for language in Language::ALL {
|
||||
let shown = language.tr(key);
|
||||
assert!(!shown.contains('\n'), "{}: {key:?} is not a single-line label: {shown:?}", language.code());
|
||||
if language != Language::En {
|
||||
assert_ne!(shown, key, "{} falls back to English for {key:?}", language.code());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_never_show_the_context_prefix() {
|
||||
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Czech translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Czech translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Vložit",
|
||||
@@ -187,6 +188,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Náhled\nkonců stránek",
|
||||
"Page\nLayout" => "Rozložení\nstránky",
|
||||
"Formula Bar" => "Řádek vzorců",
|
||||
"Name Box" => "Pole názvu",
|
||||
"Cell Editor" => "Editor buňky",
|
||||
"Font Size" => "Velikost písma",
|
||||
"Gridlines" => "Mřížka",
|
||||
"Headings" => "Záhlaví",
|
||||
"Zoom" => "Lupa",
|
||||
@@ -331,6 +335,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Automaticky ukládat sešity .xlsx po každé změně",
|
||||
"Workbook calculation" => "Výpočet sešitu",
|
||||
"Enable iterative calculation" => "Povolit iterativní výpočet",
|
||||
"Maximum Iterations:" => "Maximální počet iterací:",
|
||||
"Maximum Change:" => "Maximální změna:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "Maximální počet iterací musí být celé číslo od 1 do 32767.",
|
||||
"Maximum Change must be a number of 0 or more." => "Maximální změna musí být číslo 0 nebo větší.",
|
||||
"Calculation settings are saved with the workbook." => "Nastavení výpočtů se ukládá se sešitem.",
|
||||
"100%" => "100 %",
|
||||
"Zoom to\nSelection" => "Přiblížit\nna výběr",
|
||||
@@ -416,6 +424,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Další\nfunkce",
|
||||
"Sheet Options|View" => "Zobrazit",
|
||||
"Print" => "Tisknout",
|
||||
"Print What" => "Co tisknout",
|
||||
|
||||
// Additional runtime labels and tooltips, grouped by source file.
|
||||
// formula_bar.rs (alphabetical keys).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! German translations for the interface strings in i18n.rs. Labels follow the wording German
|
||||
//! spreadsheet users know; anything absent falls back to English.
|
||||
|
||||
/// Returns the German translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Einfügen",
|
||||
@@ -191,6 +192,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
|
||||
"Page\nLayout" => "Seiten-\nlayout",
|
||||
"Formula Bar" => "Formelleiste",
|
||||
"Name Box" => "Namensfeld",
|
||||
"Cell Editor" => "Zelleneditor",
|
||||
"Font Size" => "Schriftgröße",
|
||||
"Gridlines" => "Gitternetzlinien",
|
||||
"Headings" => "Überschriften",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -336,6 +340,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => ".xlsx-Arbeitsmappen nach jeder Änderung automatisch speichern",
|
||||
"Workbook calculation" => "Arbeitsmappenberechnung",
|
||||
"Enable iterative calculation" => "Iterative Berechnung aktivieren",
|
||||
"Maximum Iterations:" => "Maximale Iterationszahl:",
|
||||
"Maximum Change:" => "Maximale Änderung:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "Die maximale Iterationszahl muss eine ganze Zahl von 1 bis 32767 sein.",
|
||||
"Maximum Change must be a number of 0 or more." => "Die maximale Änderung muss eine Zahl größer oder gleich 0 sein.",
|
||||
"Calculation settings are saved with the workbook." => "Berechnungseinstellungen werden mit der Arbeitsmappe gespeichert.",
|
||||
"Interface size" => "Größe der Benutzeroberfläche",
|
||||
"Auto" => "Automatisch",
|
||||
@@ -424,6 +432,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Dunkel",
|
||||
"Sheet Options|View" => "Anzeige",
|
||||
"Print" => "Drucken",
|
||||
"Print What" => "Was drucken",
|
||||
// grid.rs cell and row/column header context menus.
|
||||
"Cut" => "Ausschneiden",
|
||||
"Copy" => "Kopieren",
|
||||
@@ -452,6 +461,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Nächstes Blatt",
|
||||
"All sheets" => "Alle Blätter",
|
||||
"New sheet (⇧F11)" => "Neues Blatt (⇧F11)",
|
||||
"Text Box" => "Textfeld",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! Command ids, cell references and formula identifiers are not translated.
|
||||
//! Source labels are kept verbatim and unmatched labels fall back to English.
|
||||
|
||||
/// Returns the Greek translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Επικόλληση",
|
||||
@@ -185,6 +186,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Προεπισκόπηση\nαλλαγών σελίδας",
|
||||
"Page\nLayout" => "Διάταξη\nσελίδας",
|
||||
"Formula Bar" => "Γραμμή τύπων",
|
||||
"Name Box" => "Πλαίσιο ονόματος",
|
||||
"Cell Editor" => "Επεξεργαστής κελιού",
|
||||
"Font Size" => "Μέγεθος γραμματοσειράς",
|
||||
"Gridlines" => "Γραμμές πλέγματος",
|
||||
"Headings" => "Επικεφαλίδες",
|
||||
"Zoom" => "Μεγέθυνση",
|
||||
@@ -396,6 +400,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Σκοτεινό",
|
||||
"Sheet Options|View" => "Προβολή",
|
||||
"Print" => "Εκτύπωση",
|
||||
"Print What" => "Τι θα εκτυπωθεί",
|
||||
"PivotTable Fields" => "Πεδία συγκεντρωτικού πίνακα",
|
||||
"Choose fields to add to report:" => "Επιλέξτε πεδία για προσθήκη στην αναφορά:",
|
||||
"Use a field's menu to move it between areas:" => "Χρησιμοποιήστε το μενού ενός πεδίου για να το μετακινήσετε μεταξύ περιοχών:",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! wording (sentence case, «Inmovilizar paneles», «Combinar y centrar»); anything absent falls back
|
||||
//! to English. Formula names and separators are localized separately (`formula_locale`).
|
||||
|
||||
/// Returns the Spanish translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Pegar",
|
||||
@@ -190,6 +191,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Vista previa de\nsalto de página",
|
||||
"Page\nLayout" => "Diseño\nde página",
|
||||
"Formula Bar" => "Barra de fórmulas",
|
||||
"Name Box" => "Cuadro de nombre",
|
||||
"Cell Editor" => "Editor de celda",
|
||||
"Font Size" => "Tamaño de fuente",
|
||||
"Gridlines" => "Líneas de cuadrícula",
|
||||
"Headings" => "Encabezados",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -333,6 +337,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Guardar automáticamente los libros .xlsx tras cada cambio",
|
||||
"Workbook calculation" => "Cálculo del libro",
|
||||
"Enable iterative calculation" => "Habilitar cálculo iterativo",
|
||||
"Maximum Iterations:" => "Iteraciones máximas:",
|
||||
"Maximum Change:" => "Cambio máximo:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "Las iteraciones máximas deben ser un número entero de 1 a 32767.",
|
||||
"Maximum Change must be a number of 0 or more." => "El cambio máximo debe ser un número igual o mayor que 0.",
|
||||
"Calculation settings are saved with the workbook." => "La configuración de cálculo se guarda con el libro.",
|
||||
"Interface size" => "Tamaño de la interfaz",
|
||||
"Auto" => "Automático",
|
||||
@@ -421,6 +429,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Oscuro",
|
||||
"Sheet Options|View" => "Ver",
|
||||
"Print" => "Imprimir",
|
||||
"Print What" => "Qué imprimir",
|
||||
"PivotTable Fields" => "Campos de tabla dinámica",
|
||||
"Choose fields to add to report:" => "Seleccione los campos para agregar al informe:",
|
||||
"Use a field's menu to move it between areas:" => "Use el menú de un campo para moverlo entre áreas:",
|
||||
@@ -468,6 +477,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Hoja siguiente",
|
||||
"All sheets" => "Todas las hojas",
|
||||
"New sheet (⇧F11)" => "Nueva hoja (⇧F11)",
|
||||
"Text Box" => "Cuadro de texto",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! French translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the French translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Different First Page" => "Première page différente",
|
||||
@@ -213,6 +214,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Aperçu des sauts\nde page",
|
||||
"Page\nLayout" => "Mise en\npage",
|
||||
"Formula Bar" => "Barre de formule",
|
||||
"Name Box" => "Zone de nom",
|
||||
"Cell Editor" => "Éditeur de cellule",
|
||||
"Font Size" => "Taille de police",
|
||||
"Gridlines" => "Quadrillage",
|
||||
"Headings" => "En-têtes",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -311,6 +315,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Autres\nfonctions",
|
||||
"Sheet Options|View" => "Affichage",
|
||||
"Print" => "Impression",
|
||||
"Print What" => "Quoi imprimer",
|
||||
"Click to add data" => "Cliquez pour ajouter des données",
|
||||
"Add header" => "Ajouter un en-tête",
|
||||
"Add footer" => "Ajouter un pied de page",
|
||||
@@ -420,6 +425,12 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Enregistrer automatiquement les classeurs .xlsx après chaque modification",
|
||||
"Workbook calculation" => "Calcul du classeur",
|
||||
"Enable iterative calculation" => "Activer le calcul itératif",
|
||||
"Maximum Iterations:" => "Nombre maximal d'itérations :",
|
||||
"Maximum Change:" => "Écart maximal :",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => {
|
||||
"Le nombre maximal d'itérations doit être un entier compris entre 1 et 32767."
|
||||
}
|
||||
"Maximum Change must be a number of 0 or more." => "L'écart maximal doit être un nombre supérieur ou égal à 0.",
|
||||
"Calculation settings are saved with the workbook." => "Les paramètres de calcul sont enregistrés avec le classeur.",
|
||||
"Insert Sheet" => "Insérer une feuille",
|
||||
"Rename" => "Renommer",
|
||||
@@ -438,6 +449,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Feuille suivante",
|
||||
"All sheets" => "Toutes les feuilles",
|
||||
"New sheet (⇧F11)" => "Nouvelle feuille (⇧F11)",
|
||||
"Text Box" => "Zone de texte",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! wording (sentence case, «Blocca riquadri», «Unisci e allinea al centro»); anything absent falls
|
||||
//! back to English.
|
||||
|
||||
/// Returns the Italian translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Incolla",
|
||||
@@ -205,6 +206,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Anteprima interruzioni\ndi pagina",
|
||||
"Page\nLayout" => "Layout\ndi pagina",
|
||||
"Formula Bar" => "Barra della formula",
|
||||
"Name Box" => "Casella nome",
|
||||
"Cell Editor" => "Editor di cella",
|
||||
"Font Size" => "Dimensione carattere",
|
||||
"Gridlines" => "Griglia",
|
||||
"Headings" => "Intestazioni",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -302,6 +306,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Scuro",
|
||||
"Sheet Options|View" => "Visualizza",
|
||||
"Print" => "Stampa",
|
||||
"Print What" => "Cosa stampare",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Fai clic per aggiungere dati",
|
||||
"Add header" => "Aggiungi intestazione",
|
||||
|
||||
@@ -210,6 +210,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Page Break\nPreview", "改ページ プレビュー"),
|
||||
("Page\nLayout", "ページ レイアウト"),
|
||||
("Formula Bar", "数式バー"),
|
||||
("Name Box", "名前ボックス"),
|
||||
("Cell Editor", "セル エディター"),
|
||||
("Font Size", "フォント サイズ"),
|
||||
("Gridlines", "目盛線"),
|
||||
("Headings", "見出し"),
|
||||
("Zoom", "ズーム"),
|
||||
@@ -307,6 +310,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("More\nFunctions", "その他の関数"),
|
||||
("Sheet Options|View", "画面"),
|
||||
("Print", "印刷"),
|
||||
("Print What", "印刷する内容"),
|
||||
("Click to add data", "クリックしてデータを追加"),
|
||||
("Add header", "ヘッダーの追加"),
|
||||
("Add footer", "フッターの追加"),
|
||||
@@ -414,6 +418,10 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Save .xlsx workbooks automatically after every change", "変更のたびに .xlsx ブックを自動的に保存する"),
|
||||
("Workbook calculation", "ブックの計算"),
|
||||
("Enable iterative calculation", "反復計算を行う"),
|
||||
("Maximum Iterations:", "最大反復回数:"),
|
||||
("Maximum Change:", "変化の最大値:"),
|
||||
("Maximum Iterations must be a whole number from 1 to 32767.", "最大反復回数には 1 から 32767 までの整数を指定してください。"),
|
||||
("Maximum Change must be a number of 0 or more.", "変化の最大値には 0 以上の数値を指定してください。"),
|
||||
("Calculation settings are saved with the workbook.", "計算の設定はブックと共に保存されます。"),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Korean translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Korean translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "붙여넣기",
|
||||
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "페이지 나누기\n미리 보기",
|
||||
"Page\nLayout" => "페이지\n레이아웃",
|
||||
"Formula Bar" => "수식 입력줄",
|
||||
"Name Box" => "이름 상자",
|
||||
"Cell Editor" => "셀 편집기",
|
||||
"Font Size" => "글꼴 크기",
|
||||
"Gridlines" => "눈금선",
|
||||
"Headings" => "머리글",
|
||||
"Zoom" => "확대/축소",
|
||||
@@ -307,6 +311,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "어둡게",
|
||||
"Sheet Options|View" => "화면",
|
||||
"Print" => "인쇄",
|
||||
"Print What" => "인쇄할 내용",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "클릭하여 데이터 추가",
|
||||
"Add header" => "머리글 추가",
|
||||
@@ -420,6 +425,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "변경할 때마다 .xlsx 통합 문서를 자동으로 저장",
|
||||
"Workbook calculation" => "통합 문서 계산",
|
||||
"Enable iterative calculation" => "반복 계산 사용",
|
||||
"Maximum Iterations:" => "최대 반복 횟수:",
|
||||
"Maximum Change:" => "변화 한도:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "최대 반복 횟수는 1에서 32767 사이의 정수여야 합니다.",
|
||||
"Maximum Change must be a number of 0 or more." => "변화 한도는 0 이상의 숫자여야 합니다.",
|
||||
"Calculation settings are saved with the workbook." => "계산 설정은 통합 문서와 함께 저장됩니다.",
|
||||
"Insert Sheet" => "시트 삽입",
|
||||
"Rename" => "이름 바꾸기",
|
||||
@@ -436,6 +445,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "다음 시트",
|
||||
"All sheets" => "모든 시트",
|
||||
"New sheet (⇧F11)" => "새 시트 (⇧F11)",
|
||||
"Text Box" => "텍스트 상자",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Dutch translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Dutch translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Plakken",
|
||||
@@ -206,6 +207,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Pagina-einde-\nweergave",
|
||||
"Page\nLayout" => "Pagina-\nindeling",
|
||||
"Formula Bar" => "Formulebalk",
|
||||
"Name Box" => "Naamvak",
|
||||
"Cell Editor" => "Celeditor",
|
||||
"Font Size" => "Tekengrootte",
|
||||
"Gridlines" => "Celrasterlijnen",
|
||||
"Headings" => "Kopteksten",
|
||||
"Zoom" => "Zoomen",
|
||||
@@ -302,6 +306,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Meer\nfuncties",
|
||||
"Sheet Options|View" => "Weergeven",
|
||||
"Print" => "Afdrukken",
|
||||
"Print What" => "Wat afdrukken",
|
||||
"Click to add data" => "Klik om gegevens toe te voegen",
|
||||
"Add header" => "Koptekst toevoegen",
|
||||
"Add footer" => "Voettekst toevoegen",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// Returns the Polish translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Wklej",
|
||||
@@ -201,6 +202,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Podgląd\npodziału stron",
|
||||
"Page\nLayout" => "Układ\nstrony",
|
||||
"Formula Bar" => "Pasek formuły",
|
||||
"Name Box" => "Pole nazwy",
|
||||
"Cell Editor" => "Edytor komórki",
|
||||
"Font Size" => "Rozmiar czcionki",
|
||||
"Gridlines" => "Linie siatki",
|
||||
"Headings" => "Nagłówki",
|
||||
"Zoom" => "Powiększenie",
|
||||
@@ -298,6 +302,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Ciemny",
|
||||
"Sheet Options|View" => "Wyświetl",
|
||||
"Print" => "Drukuj",
|
||||
"Print What" => "Co drukować",
|
||||
"Click to add data" => "Kliknij, aby dodać dane",
|
||||
"Add header" => "Dodaj nagłówek",
|
||||
"Add footer" => "Dodaj stopkę",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Brazilian Portuguese translations for the interface strings in i18n.rs. Labels follow Excel's
|
||||
//! pt-BR wording; anything absent falls back to English.
|
||||
|
||||
/// Returns the Portuguese translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Colar",
|
||||
@@ -205,6 +206,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Visualização\nde Quebras de Página",
|
||||
"Page\nLayout" => "Layout\nde Página",
|
||||
"Formula Bar" => "Barra de Fórmulas",
|
||||
"Name Box" => "Caixa de nome",
|
||||
"Cell Editor" => "Editor de célula",
|
||||
"Font Size" => "Tamanho da fonte",
|
||||
"Gridlines" => "Linhas de Grade",
|
||||
"Headings" => "Títulos",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -303,6 +307,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Escuro",
|
||||
"Sheet Options|View" => "Exibir",
|
||||
"Print" => "Imprimir",
|
||||
"Print What" => "O que imprimir",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Clique para adicionar dados",
|
||||
"Add header" => "Adicionar cabeçalho",
|
||||
@@ -413,6 +418,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Salvar pastas de trabalho .xlsx automaticamente após cada alteração",
|
||||
"Workbook calculation" => "Cálculo da pasta de trabalho",
|
||||
"Enable iterative calculation" => "Habilitar cálculo iterativo",
|
||||
"Maximum Iterations:" => "Número Máximo de Iterações:",
|
||||
"Maximum Change:" => "Alteração Máxima:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "O número máximo de iterações deve ser um número inteiro de 1 a 32767.",
|
||||
"Maximum Change must be a number of 0 or more." => "A alteração máxima deve ser um número maior ou igual a 0.",
|
||||
"Calculation settings are saved with the workbook." => "As configurações de cálculo são salvas com a pasta de trabalho.",
|
||||
"OK" => "OK",
|
||||
"Cancel" => "Cancelar",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! usual in Romanian software ("Salvare", "Inserare…") and messages address the user formally.
|
||||
//! An AI-assisted draft (Claude) for a Romanian-speaking contributor, pending native-speaker review.
|
||||
|
||||
/// Returns the Romanian translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Lipire",
|
||||
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Examinare\nsfârșituri de pagină",
|
||||
"Page\nLayout" => "Aspect\npagină",
|
||||
"Formula Bar" => "Bară de formule",
|
||||
"Name Box" => "Casetă de nume",
|
||||
"Cell Editor" => "Editor de celule",
|
||||
"Font Size" => "Dimensiune font",
|
||||
"Gridlines" => "Linii de grilă",
|
||||
"Headings" => "Titluri",
|
||||
"Zoom" => "Zoom",
|
||||
@@ -305,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Întunecată",
|
||||
"Sheet Options|View" => "Vizualizare",
|
||||
"Print" => "Imprimare",
|
||||
"Print What" => "Ce imprimați",
|
||||
"Click to add data" => "Faceți clic pentru a adăuga date",
|
||||
"Add header" => "Adăugare antet",
|
||||
"Add footer" => "Adăugare subsol",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Russian translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Russian translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Вставить",
|
||||
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц",
|
||||
"Page\nLayout" => "Разметка\nстраницы",
|
||||
"Formula Bar" => "Строка формул",
|
||||
"Name Box" => "Поле имени",
|
||||
"Cell Editor" => "Редактор ячейки",
|
||||
"Font Size" => "Размер шрифта",
|
||||
"Gridlines" => "Сетка",
|
||||
"Headings" => "Заголовки",
|
||||
"Zoom" => "Масштаб",
|
||||
@@ -307,6 +311,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Тёмная",
|
||||
"Sheet Options|View" => "На экране",
|
||||
"Print" => "Печать",
|
||||
"Print What" => "Что печатать",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Щелкните, чтобы добавить данные",
|
||||
"Add header" => "Добавить верхний колонтитул",
|
||||
@@ -420,6 +425,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Автоматически сохранять книги .xlsx после каждого изменения",
|
||||
"Workbook calculation" => "Вычисления в книге",
|
||||
"Enable iterative calculation" => "Включить итеративные вычисления",
|
||||
"Maximum Iterations:" => "Предельное число итераций:",
|
||||
"Maximum Change:" => "Максимальное изменение:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "Предельное число итераций должно быть целым числом от 1 до 32767.",
|
||||
"Maximum Change must be a number of 0 or more." => "Максимальное изменение должно быть числом не меньше 0.",
|
||||
"Calculation settings are saved with the workbook." => "Параметры вычислений сохраняются вместе с книгой.",
|
||||
"Insert Sheet" => "Вставить лист",
|
||||
"Rename" => "Переименовать",
|
||||
@@ -438,6 +447,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следующий лист",
|
||||
"All sheets" => "Все листы",
|
||||
"New sheet (⇧F11)" => "Новый лист (⇧F11)",
|
||||
"Text Box" => "Надпись",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Serbian (Latin script, ekavian) translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Serbian translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Nalepi",
|
||||
@@ -203,6 +204,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Pregled preloma\nstranica",
|
||||
"Page\nLayout" => "Izgled\nstranice",
|
||||
"Formula Bar" => "Traka formule",
|
||||
"Name Box" => "Polje imena",
|
||||
"Cell Editor" => "Uređivač ćelije",
|
||||
"Font Size" => "Veličina fonta",
|
||||
"Gridlines" => "Linije mreže",
|
||||
"Headings" => "Zaglavlja",
|
||||
"Zoom" => "Uvećanje",
|
||||
@@ -301,6 +305,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Još\nfunkcija",
|
||||
"Sheet Options|View" => "Prikaz",
|
||||
"Print" => "Štampaj",
|
||||
"Print What" => "Šta štampati",
|
||||
"Click to add data" => "Kliknite da dodate podatke",
|
||||
"Add header" => "Dodaj zaglavlje",
|
||||
"Add footer" => "Dodaj podnožje",
|
||||
@@ -412,6 +417,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "Automatski sačuvaj .xlsx radne sveske posle svake izmene",
|
||||
"Workbook calculation" => "Izračunavanje radne sveske",
|
||||
"Enable iterative calculation" => "Omogući iterativno izračunavanje",
|
||||
"Maximum Iterations:" => "Maksimalan broj iteracija:",
|
||||
"Maximum Change:" => "Maksimalna promena:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "Maksimalan broj iteracija mora biti ceo broj od 1 do 32767.",
|
||||
"Maximum Change must be a number of 0 or more." => "Maksimalna promena mora biti broj veći ili jednak 0.",
|
||||
"Calculation settings are saved with the workbook." => "Podešavanja izračunavanja se čuvaju sa radnom sveskom.",
|
||||
"Left Margin" => "Leva margina",
|
||||
"Right Margin" => "Desna margina",
|
||||
@@ -436,6 +445,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "Следећи лист",
|
||||
"All sheets" => "Сви листови",
|
||||
"New sheet (⇧F11)" => "Нови лист (⇧F11)",
|
||||
"Text Box" => "Текстуално поље",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Turkish translations for the interface strings in i18n.rs. Labels follow the wording Turkish
|
||||
//! spreadsheet users know; anything absent falls back to English.
|
||||
|
||||
/// Returns the Turkish translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Yapıştır",
|
||||
@@ -188,6 +189,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Sayfa Sonu\nÖnizleme",
|
||||
"Page\nLayout" => "Sayfa\nDüzeni",
|
||||
"Formula Bar" => "Formül Çubuğu",
|
||||
"Name Box" => "Ad kutusu",
|
||||
"Cell Editor" => "Hücre düzenleyici",
|
||||
"Font Size" => "Yazı tipi boyutu",
|
||||
"Gridlines" => "Kılavuz Çizgileri",
|
||||
"Headings" => "Başlıklar",
|
||||
"Zoom" => "Yakınlaştır",
|
||||
@@ -410,6 +414,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Koyu",
|
||||
"Sheet Options|View" => "Görünüm",
|
||||
"Print" => "Yazdır",
|
||||
"Print What" => "Yazdırılacaklar",
|
||||
"Text Box" => "Metin Kutusu",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Ukrainian translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Ukrainian translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Вставити",
|
||||
@@ -203,6 +204,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Попередній перегляд\nрозривів сторінок",
|
||||
"Page\nLayout" => "Розмітка\nсторінки",
|
||||
"Formula Bar" => "Рядок формул",
|
||||
"Name Box" => "Поле імені",
|
||||
"Cell Editor" => "Редактор клітинки",
|
||||
"Font Size" => "Розмір шрифту",
|
||||
"Gridlines" => "Сітка",
|
||||
"Headings" => "Заголовки",
|
||||
"Zoom" => "Масштаб",
|
||||
@@ -300,6 +304,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "Темна",
|
||||
"Sheet Options|View" => "На екрані",
|
||||
"Print" => "Друк",
|
||||
"Print What" => "Що друкувати",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Клацніть, щоб додати дані",
|
||||
"Add header" => "Додати верхній колонтитул",
|
||||
@@ -421,7 +426,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Do you want to save the changes you made to {title}?" => "Do you want to save the changes you made to {title}?",
|
||||
"Save a copy as .{format}" => "Save a copy as .{format}",
|
||||
"Select a chart first." => "Select a chart first.",
|
||||
"Text Box" => "Text Box",
|
||||
"Text Box" => "Напис",
|
||||
"Select Data" => "Select Data",
|
||||
"Row Height" => "Row Height",
|
||||
"Column Width" => "Column Width",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Vietnamese translations for the interface strings in i18n.rs. Labels follow the wording
|
||||
//! Vietnamese spreadsheet users know; anything absent falls back to English.
|
||||
|
||||
/// Returns the Vietnamese translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Dán",
|
||||
@@ -209,6 +210,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "Xem trước\nngắt trang",
|
||||
"Page\nLayout" => "Bố trí\ntrang",
|
||||
"Formula Bar" => "Thanh công thức",
|
||||
"Name Box" => "Hộp tên",
|
||||
"Cell Editor" => "Trình soạn thảo ô",
|
||||
"Font Size" => "Cỡ chữ",
|
||||
"Gridlines" => "Đường lưới",
|
||||
"Headings" => "Tiêu đề hàng/cột",
|
||||
"Zoom" => "Thu phóng",
|
||||
@@ -305,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"More\nFunctions" => "Hàm\nkhác",
|
||||
"Sheet Options|View" => "Xem",
|
||||
"Print" => "In",
|
||||
"Print What" => "Nội dung in",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "Bấm để thêm dữ liệu",
|
||||
"Add header" => "Thêm đầu trang",
|
||||
@@ -412,6 +417,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Workbook calculation" => "Tính toán sổ làm việc",
|
||||
"Enable iterative calculation" => "Bật tính toán lặp",
|
||||
"Calculation settings are saved with the workbook." => "Thiết lập tính toán được lưu cùng sổ làm việc.",
|
||||
"Text Box" => "Hộp văn bản",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Simplified Chinese translations for the interface strings in i18n.rs.
|
||||
|
||||
/// Returns the Chinese translation for `text`, or `None` so callers fall back to English.
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "粘贴",
|
||||
@@ -208,6 +209,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Page Break\nPreview" => "分页符\n预览",
|
||||
"Page\nLayout" => "页面\n布局",
|
||||
"Formula Bar" => "编辑栏",
|
||||
"Name Box" => "名称框",
|
||||
"Cell Editor" => "单元格编辑器",
|
||||
"Font Size" => "字号",
|
||||
"Gridlines" => "网格线",
|
||||
"Headings" => "标题",
|
||||
"Zoom" => "缩放",
|
||||
@@ -305,6 +309,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Dark" => "深色",
|
||||
"Sheet Options|View" => "屏幕",
|
||||
"Print" => "打印",
|
||||
"Print What" => "打印内容",
|
||||
// Page Layout view and the Header & Footer tab.
|
||||
"Click to add data" => "单击可添加数据",
|
||||
"Add header" => "添加页眉",
|
||||
@@ -412,6 +417,10 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save .xlsx workbooks automatically after every change" => "每次更改后自动保存 .xlsx 工作簿",
|
||||
"Workbook calculation" => "工作簿计算",
|
||||
"Enable iterative calculation" => "启用迭代计算",
|
||||
"Maximum Iterations:" => "最多迭代次数:",
|
||||
"Maximum Change:" => "最大误差:",
|
||||
"Maximum Iterations must be a whole number from 1 to 32767." => "最多迭代次数必须是 1 到 32767 之间的整数。",
|
||||
"Maximum Change must be a number of 0 or more." => "最大误差必须是大于或等于 0 的数字。",
|
||||
"Calculation settings are saved with the workbook." => "计算设置随工作簿一起保存。",
|
||||
"Insert Sheet" => "插入工作表",
|
||||
"Rename" => "重命名",
|
||||
@@ -428,6 +437,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Next sheet" => "下一个工作表",
|
||||
"All sheets" => "所有工作表",
|
||||
"New sheet (⇧F11)" => "新建工作表 (⇧F11)",
|
||||
"Text Box" => "文本框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,6 +211,17 @@ impl Palette {
|
||||
}
|
||||
}
|
||||
|
||||
/// The code-drawn icon that stands in for a character the current fonts cannot draw (`✕` and
|
||||
/// `✓` have no bundled egui face). Interface chrome uses this instead of painting a tofu box; a
|
||||
/// workbook cell still needs a real glyph because its characters are user data.
|
||||
pub fn fallback_for_symbol(c: char) -> Option<Icon> {
|
||||
match c {
|
||||
'✕' => Some(Icon::Close),
|
||||
'✓' => Some(Icon::Check),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws `icon` into `rect` (square-ish), in colour `c`, with the current theme's palette.
|
||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, c: Color32) {
|
||||
paint_with(p, rect, icon, c, &Palette::of(&Tokens::get(p.ctx())));
|
||||
|
||||
+524
-64
@@ -12,6 +12,7 @@ mod cell_pictures;
|
||||
pub mod chartview;
|
||||
pub mod code_editor;
|
||||
pub mod control;
|
||||
mod craft_fonts_policy;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
pub mod editor;
|
||||
@@ -34,6 +35,8 @@ pub mod selection_glide;
|
||||
pub mod tabs;
|
||||
pub mod text_box;
|
||||
pub mod theme;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
@@ -261,13 +264,23 @@ pub struct SheetApp {
|
||||
pub services: Services,
|
||||
pub printing: printing::State,
|
||||
print_feedback_seen: Option<printing::Feedback>,
|
||||
/// Synthetic input from agents, one batch per frame (drags need several frames).
|
||||
/// Synthetic input from agents, one batch per step (drags need several steps).
|
||||
pub synthetic: std::collections::VecDeque<Vec<egui::Event>>,
|
||||
/// The front synthetic batch was injected into `RawInput` but no `ui` pass has applied it
|
||||
/// yet. eframe retains that unconsumed input across hidden logic-only ticks; the flag makes
|
||||
/// the hook offer it once and asks for a hidden paint until a UI pass applies it.
|
||||
pub(crate) synthetic_pending: bool,
|
||||
/// `cumulative_pass_nr` when the front batch was last injected. A completed egui pass that
|
||||
/// did not apply it (the font gate's early return) consumed the events with `begin_pass`
|
||||
/// without reaching input handling, so the hook must offer the same step again.
|
||||
pub(crate) synthetic_injected_at_pass: Option<u64>,
|
||||
pub control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||||
pub grid: grid::GridState,
|
||||
pub perf: Perf,
|
||||
pub fonts_ready: bool,
|
||||
fonts_set: bool,
|
||||
/// Test harness set: rebuilds use the deterministic pinned font set instead of the host scan.
|
||||
prepared_fonts: bool,
|
||||
/// Workbook fonts bound into the current font set; a change rebinds (installed fonts only).
|
||||
fonts_bound: Vec<String>,
|
||||
/// The (doc uid, revision) `fonts_bound` was computed for, so the style table is walked only
|
||||
@@ -288,6 +301,16 @@ pub struct SheetApp {
|
||||
/// Transient ribbon keyboard navigation; never saved with UI preferences.
|
||||
pub keytips: keytips::KeyTips,
|
||||
pub(crate) shots: control::Shots,
|
||||
/// Monotonic epoch of [`control::CaptureGeometry`]: any resize/DPI/zoom/surface change bumps
|
||||
/// it, including a change back to an earlier value, so a capture taken in another layout is
|
||||
/// never saved against the current geometry.
|
||||
pub(crate) capture_epoch: u64,
|
||||
/// The geometry `capture_epoch` describes; `None` before the first frame.
|
||||
pub(crate) capture_geometry: Option<control::CaptureGeometry>,
|
||||
/// The recorded geometry came from a native `RawInput` frame (see `raw_input_hook`). When
|
||||
/// false (a bare test/embedder that never runs the hook) capture code falls back to egui's
|
||||
/// current input instead of reusing a stale recorded value.
|
||||
pub(crate) capture_geometry_from_raw: bool,
|
||||
/// Chart selected on the sheet (id).
|
||||
pub selected_chart: Option<u32>,
|
||||
/// The system clipboard's text when GridCraft last copied without publishing to it (a
|
||||
@@ -305,9 +328,13 @@ pub struct SheetApp {
|
||||
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
|
||||
/// window close goes through.
|
||||
pub allow_close: bool,
|
||||
/// The update check, the startup prompt and a running self-update (desktop only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
}
|
||||
|
||||
impl SheetApp {
|
||||
/// Constructs a `SheetApp` with default UI, grid, printing, control and screenshot state and the caller's session and services.
|
||||
pub fn new(session: Session, services: Services) -> SheetApp {
|
||||
SheetApp {
|
||||
session,
|
||||
@@ -325,6 +352,8 @@ impl SheetApp {
|
||||
printing: Default::default(),
|
||||
print_feedback_seen: None,
|
||||
synthetic: Default::default(),
|
||||
synthetic_pending: false,
|
||||
synthetic_injected_at_pass: None,
|
||||
control_rx: None,
|
||||
grid: grid::GridState {
|
||||
last_border: "bottom".into(),
|
||||
@@ -335,6 +364,7 @@ impl SheetApp {
|
||||
perf: Perf::default(),
|
||||
fonts_ready: false,
|
||||
fonts_set: false,
|
||||
prepared_fonts: false,
|
||||
fonts_bound: Vec::new(),
|
||||
fonts_seen: (0, 0),
|
||||
fonts_rebind: false,
|
||||
@@ -345,12 +375,17 @@ impl SheetApp {
|
||||
name_box: None,
|
||||
keytips: keytips::KeyTips::default(),
|
||||
shots: control::Shots::default(),
|
||||
capture_epoch: 0,
|
||||
capture_geometry: None,
|
||||
capture_geometry_from_raw: false,
|
||||
selected_chart: None,
|
||||
clipboard_text_at_copy: None,
|
||||
cut_text: None,
|
||||
tab_run: None,
|
||||
started: now_ms(),
|
||||
allow_close: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +404,21 @@ impl SheetApp {
|
||||
theme::apply(ctx, dark);
|
||||
}
|
||||
|
||||
/// Test-harness setup: mark a deterministic font set (see
|
||||
/// [`theme::pinned_font_definitions`]) as already installed, so the first `logic` pass neither
|
||||
/// starts the host font scan nor replaces the harness's pinned set. Production uses
|
||||
/// [`Self::setup_context`] instead.
|
||||
#[doc(hidden)]
|
||||
pub fn use_prepared_fonts(&mut self) {
|
||||
self.prepared_fonts = true;
|
||||
self.fonts_ready = true;
|
||||
self.fonts_set = true;
|
||||
self.fonts_rebind = false;
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
self.fonts_bound = self.used_fonts();
|
||||
self.fonts_seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
}
|
||||
|
||||
/// The font names the active workbook uses: every style's font plus the theme's heading/body
|
||||
/// fonts. These are the families [`fonts`] binds an installed file for.
|
||||
pub fn used_fonts(&self) -> Vec<String> {
|
||||
@@ -567,7 +617,7 @@ impl SheetApp {
|
||||
return;
|
||||
}
|
||||
let title = self.session.active().map(|d| d.display_title()).unwrap_or_else(|| "Book1".into());
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
|
||||
let stem = std::path::Path::new(&title).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Book1").to_string();
|
||||
let Some(pick) = &self.services.pick_save else { return };
|
||||
let Some(path) = pick(&format!("{stem}.pdf")) else { return };
|
||||
// The picker may suggest a workbook extension; the export writes a PDF.
|
||||
@@ -623,6 +673,10 @@ impl SheetApp {
|
||||
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
|
||||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||||
let r = match id {
|
||||
"app.checkForUpdates" | "help.checkForUpdates" => {
|
||||
self.check_for_updates();
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.printSettings" => printing::settings(&mut self.printing, p, self.services.printers.as_ref()),
|
||||
"ui.printSubmit" => self.submit_print(p.clone()),
|
||||
"ui.ribbonTab" => {
|
||||
@@ -1100,6 +1154,8 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
/// Terminal feedback also reaches the normal window when printing starts through control.
|
||||
/// Polls printing feedback. Called once per frame from [`Self::logic`]; the direct calls in
|
||||
/// `tests/native_print.rs` deliberately bypass the lifecycle to test this helper alone.
|
||||
pub fn poll_printing(&mut self, ctx: &egui::Context) {
|
||||
self.printing.poll(ctx);
|
||||
if self.printing.message == self.print_feedback_seen {
|
||||
@@ -1160,55 +1216,119 @@ impl SheetApp {
|
||||
|
||||
/// Per-frame logic (control channel, screenshots). Call before `ui`.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if self.session.text_width.is_none() {
|
||||
let ctx = ctx.clone();
|
||||
self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact)));
|
||||
}
|
||||
self.ensure_text_width(ctx);
|
||||
// A keytip control request lives one frame: the ribbon consumes it during `ui`, and
|
||||
// anything that never reached a control (collapsed ribbon, tab switch) is dropped.
|
||||
self.ui.keytip_invoke = None;
|
||||
// Floating pictures' textures go with their workbook, even when no sheet is painted.
|
||||
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
|
||||
floating_pictures::forget_closed(ctx, &open);
|
||||
if self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||||
self.apply_font_state(ctx);
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
}
|
||||
self.sync_theme_preference(ctx);
|
||||
self.sync_input_options(ctx);
|
||||
// The geometry captures are bound to. Native frames recorded it from their fresh
|
||||
// `RawInput` in `raw_input_hook` (including hidden logic-only ticks); the epoch is
|
||||
// monotonic, so a resize back to the original values is still a new epoch and old pixels
|
||||
// are never saved with new geometry.
|
||||
let capture_geometry = control::current_capture_geometry(self, ctx);
|
||||
control::note_capture_geometry(self, capture_geometry);
|
||||
// Drain before admission: completed or timed-out captures release their slots and
|
||||
// reservation first, so a new request is never refused for budget that is about to free.
|
||||
// Screenshot results, deadlines and geometry validation belong to `logic`; `ui` only
|
||||
// issues the capture command (after every widget, so a command cannot be missed).
|
||||
control::drain_screenshots(self, ctx);
|
||||
control::poll(self, ctx);
|
||||
// Printing feedback is polled once per frame, here, so hidden windows and logic-only
|
||||
// frames still see worker completion. `ui` and the desktop app no longer poll it.
|
||||
self.poll_printing(ctx);
|
||||
self.guard_window_close(ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::tick(self, ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
self.poll_async_opens();
|
||||
self.autosave(ctx);
|
||||
self.update_title(ctx);
|
||||
self.request_hidden_paint(ctx);
|
||||
if self.session.active().is_none() {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the text-width closure once, on the first frame.
|
||||
fn ensure_text_width(&mut self, ctx: &egui::Context) {
|
||||
if self.session.text_width.is_some() {
|
||||
return;
|
||||
}
|
||||
let ctx = ctx.clone();
|
||||
self.session.text_width = Some(std::sync::Arc::new(move |text, font, exact| theme::cell_text_width(&ctx, text, font, exact)));
|
||||
}
|
||||
|
||||
/// Applies the font gates: a language Han-order change, startup/prepared fonts and workbook
|
||||
/// font binding. Every branch keeps its one-frame paint gate.
|
||||
fn apply_font_state(&mut self, ctx: &egui::Context) {
|
||||
// A pending synthetic step owns the next real `ui` pass: `ui` returns at the font gate
|
||||
// before it handles input, so a font change requested now would consume the step without
|
||||
// applying it. Delay the change one frame; the hidden-paint request keeps it coming.
|
||||
let defer_font_change = self.synthetic_pending || !self.synthetic.is_empty();
|
||||
if !defer_font_change && self.fonts_han.is_some_and(|han| han != theme::HanOrder::of(self.ui.language)) {
|
||||
self.fonts_ready = false;
|
||||
self.fonts_set = false;
|
||||
}
|
||||
if !self.fonts_ready {
|
||||
// New fonts apply from the next frame on: paint nothing until then.
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
} else {
|
||||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||||
self.fonts_set = true;
|
||||
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
|
||||
self.fonts_bound.clear();
|
||||
self.fonts_seen = (0, 0);
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.start_font_setup(ctx);
|
||||
} else if self.fonts_rebind {
|
||||
// A font-set change made last frame is active now.
|
||||
self.fonts_rebind = false;
|
||||
} else if fonts::scan_done() {
|
||||
// Bind the workbook's installed fonts once the background scan has finished. Adding
|
||||
// fonts re-applies the font set, which egui activates at the next pass, so skip this
|
||||
// frame's paint (same one-frame cost as startup). Only when the workbook changed.
|
||||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
if seen != self.fonts_seen {
|
||||
let used = self.used_fonts();
|
||||
self.fonts_seen = seen;
|
||||
if used != self.fonts_bound {
|
||||
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
|
||||
self.fonts_bound = used;
|
||||
self.fonts_rebind = true;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
} else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() {
|
||||
self.rebind_workbook_fonts(ctx);
|
||||
}
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
}
|
||||
|
||||
/// Applies the pending font set. New fonts apply from the next frame on: paint nothing until
|
||||
/// then. Startup always proceeds: without it a queued step would be re-offered forever.
|
||||
fn start_font_setup(&mut self, ctx: &egui::Context) {
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
return;
|
||||
}
|
||||
if self.prepared_fonts {
|
||||
ctx.set_fonts(theme::pinned_font_definitions());
|
||||
theme::apply(ctx, self.ui.dark);
|
||||
} else {
|
||||
SheetApp::setup_context_for_language(ctx, self.ui.dark, self.ui.language);
|
||||
}
|
||||
self.fonts_set = true;
|
||||
// The rebuilt set has no workbook fonts yet: bind them again once it is active.
|
||||
self.fonts_bound.clear();
|
||||
self.fonts_seen = (0, 0);
|
||||
self.fonts_han = Some(theme::HanOrder::of(self.ui.language));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Binds the workbook's installed fonts once the background scan has finished. Adding fonts
|
||||
/// re-applies the font set, which egui activates at the next pass, so skip this frame's paint
|
||||
/// (same one-frame cost as startup). Only when the workbook changed.
|
||||
fn rebind_workbook_fonts(&mut self, ctx: &egui::Context) {
|
||||
let seen = self.session.active().map(|d| (d.uid, d.revision)).unwrap_or((0, 0));
|
||||
if seen == self.fonts_seen {
|
||||
return;
|
||||
}
|
||||
let used = self.used_fonts();
|
||||
self.fonts_seen = seen;
|
||||
if used == self.fonts_bound {
|
||||
return;
|
||||
}
|
||||
ctx.set_fonts(theme::font_definitions_with(self.ui.language, &used));
|
||||
self.fonts_bound = used;
|
||||
self.fonts_rebind = true;
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Follows the desktop/system theme preference and records the effective theme.
|
||||
fn sync_theme_preference(&mut self, ctx: &egui::Context) {
|
||||
let preference = match (
|
||||
self.ui.system_theme,
|
||||
ctx.system_theme().or_else(|| self.desktop_dark.map(|d| if d { egui::Theme::Dark } else { egui::Theme::Light })),
|
||||
@@ -1222,6 +1342,10 @@ impl SheetApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
self.effective_dark = ctx.theme() == egui::Theme::Dark;
|
||||
}
|
||||
|
||||
/// Keeps egui's own keyboard zoom and quit shortcuts off and applies GridCraft's zoom factor.
|
||||
fn sync_input_options(&mut self, ctx: &egui::Context) {
|
||||
// Interface size: the preference owns the zoom factor, so egui's own Ctrl/Cmd +/-/0 zoom
|
||||
// keys stay off (they would also swallow GridCraft's shortcuts).
|
||||
if ctx.options(|o| o.zoom_with_keyboard) {
|
||||
@@ -1238,9 +1362,10 @@ impl SheetApp {
|
||||
ctx.set_zoom_factor(zoom);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
control::poll(self, ctx);
|
||||
self.guard_window_close(ctx);
|
||||
// Files read asynchronously (web file picker, dropped files).
|
||||
}
|
||||
|
||||
/// Opens files that arrived asynchronously (web file picker, dropped files).
|
||||
fn poll_async_opens(&mut self) {
|
||||
let arrived: Vec<(String, Vec<u8>)> = self
|
||||
.services
|
||||
.inbox
|
||||
@@ -1263,14 +1388,101 @@ impl SheetApp {
|
||||
}
|
||||
self.after_engine();
|
||||
}
|
||||
if self.session.active().is_none() {
|
||||
self.session.new_workbook();
|
||||
}
|
||||
|
||||
/// AutoSave: save workbooks that have a file shortly after they change. This decision lives in
|
||||
/// `logic` so a hidden or logic-only frame still saves; the monotonic timestamp check keeps a
|
||||
/// forced hidden UI pass from saving the same change twice.
|
||||
fn autosave(&mut self, ctx: &egui::Context) {
|
||||
let dirty_xlsx = self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")));
|
||||
let now = crate::monotonic_ms();
|
||||
let autosave_due =
|
||||
self.ui.autosave && self.editor.is_none() && self.text_box_editor.is_none() && now - self.grid.last_autosave > 2000.0 && dirty_xlsx;
|
||||
if autosave_due {
|
||||
self.grid.last_autosave = now;
|
||||
self.store_view_state();
|
||||
let _ = self.session.run("file.save", json!({}));
|
||||
} else if self.ui.autosave && dirty_xlsx {
|
||||
// Keep checking even when no input arrives while the window is hidden.
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if let Some(mut batch) = self.synthetic.pop_front() {
|
||||
raw.events.append(&mut batch);
|
||||
/// Look for a newer release once, in the background, as the desktop app starts (a no-op on the
|
||||
/// web). A found release opens the update prompt; nothing is shown when GridCraft is current.
|
||||
pub fn start_update_check(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::start(self, false);
|
||||
}
|
||||
|
||||
/// Help ▸ Check for Updates…: look now, and say so when GridCraft is up to date or the check fails.
|
||||
pub fn check_for_updates(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::start(self, true);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.message = Some(("GridCraft".into(), "The web version is always the latest release.".into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Window title, next to the close/autosave state: `ViewportCommand::Title` is valid outside a
|
||||
/// pass and a control-channel rename while hidden updates it on return.
|
||||
fn update_title(&mut self, ctx: &egui::Context) {
|
||||
let Some(d) = self.session.active() else { return };
|
||||
let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" });
|
||||
if self.grid.last_title.as_deref() != Some(title.as_str()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.grid.last_title = Some(title);
|
||||
}
|
||||
}
|
||||
|
||||
/// A queued/settling capture with a capturable size and an unapplied synthetic step both need
|
||||
/// a real `ui` pass. Once a capture is issued, its `Screenshot` command's own `wants_paint`
|
||||
/// paints the hidden window; after that a full hidden pass every frame only wastes CPU. A
|
||||
/// minimized request has no size, so no hidden pass can reach issuance: the restore pass issues
|
||||
/// it instead. Native eframe honours `RequestPaintWhileHidden`; the web runner does not (hidden
|
||||
/// tabs only run `run_logic`), so a hidden web tab queues the work until it is shown again.
|
||||
/// This is a shared eframe web follow-up, not a GridCraft-local workaround, and the control
|
||||
/// protocol documents the limitation.
|
||||
fn request_hidden_paint(&self, ctx: &egui::Context) {
|
||||
if !(self.shots.needs_hidden_paint() || self.synthetic_pending || !self.synthetic.is_empty()) {
|
||||
return;
|
||||
}
|
||||
// The web runner has no `RequestPaintWhileHidden`; sending it there only logs an
|
||||
// unhandled command. A visible tab still gets the repaint below; a hidden tab keeps the
|
||||
// work queued (see the shared eframe follow-up in docs/egui-037-local-followups.md).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Records capture geometry from the fresh raw frame, then offers exactly one pending synthetic batch and tracks whether it was applied.
|
||||
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Capture geometry from this frame's real input before any synthetic event is added. This
|
||||
// runs on every native frame, including hidden/minimized logic-only ticks, so a resize,
|
||||
// DPI/zoom change, Wayland frame without `inner_rect`, or minimize/restore is recorded
|
||||
// even though egui's last UI pass still describes the old layout.
|
||||
let geometry = control::CaptureGeometry::from_raw_input(raw, ctx);
|
||||
control::note_capture_geometry(self, geometry);
|
||||
self.capture_geometry_from_raw = true;
|
||||
// One unapplied gesture step at a time. eframe retains an unconsumed `RawInput` for the
|
||||
// next pass and for a hidden logic-only tick, so the batch is offered once and survives
|
||||
// until a `ui` pass applies it (which pops it). Press and release stay distinct batches.
|
||||
//
|
||||
// A completed egui pass may have consumed the injected events without applying them: the
|
||||
// font gate returns before input handling, but `begin_pass` has already taken the events.
|
||||
// `cumulative_pass_nr` only advances when a pass ends, so a newer value with the batch
|
||||
// still pending means exactly that, and the step is offered again for the next real pass.
|
||||
if self.synthetic_pending {
|
||||
let consumed_by_an_unapplied_pass = self.synthetic_injected_at_pass.is_some_and(|pass| ctx.cumulative_pass_nr() > pass);
|
||||
if !consumed_by_an_unapplied_pass {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(batch) = self.synthetic.front() {
|
||||
raw.events.extend(batch.iter().cloned());
|
||||
self.synthetic_pending = true;
|
||||
self.synthetic_injected_at_pass = Some(ctx.cumulative_pass_nr());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1283,7 +1495,6 @@ impl SheetApp {
|
||||
/// Lays out the whole window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
self.poll_printing(&ctx);
|
||||
if !self.fonts_ready || self.fonts_rebind {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
@@ -1292,6 +1503,8 @@ impl SheetApp {
|
||||
text_box::before_ui(self, &ctx);
|
||||
// The language the widgets translate with this frame (see `i18n::current`).
|
||||
i18n::set_current(&ctx, self.ui.language);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_ui::show(self, &ctx);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
// A host can turn the File tab off while Backstage is already open: close it.
|
||||
if !self.chrome.file_tab {
|
||||
@@ -1329,7 +1542,6 @@ impl SheetApp {
|
||||
widgets::message_box(self, &ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
control::issue_screenshots(self, &ctx);
|
||||
control::collect_screenshots(self, &ctx);
|
||||
if !ctx.input(|i| i.focused) {
|
||||
// Nothing else to do.
|
||||
}
|
||||
@@ -1342,24 +1554,14 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
self.perf.last = now;
|
||||
// AutoSave: save workbooks that have a file shortly after they change.
|
||||
if self.ui.autosave
|
||||
&& self.editor.is_none()
|
||||
&& self.text_box_editor.is_none()
|
||||
&& now - self.grid.last_autosave > 2000.0
|
||||
&& self.session.active().is_some_and(|d| d.is_dirty() && d.path.as_deref().is_some_and(|p| p.ends_with(".xlsx")))
|
||||
{
|
||||
self.grid.last_autosave = now;
|
||||
self.store_view_state();
|
||||
let _ = self.session.run("file.save", json!({}));
|
||||
}
|
||||
// Window title.
|
||||
if let Some(d) = self.session.active() {
|
||||
let title = format!("{}{}", d.display_title(), if d.is_dirty() { " — Edited" } else { "" });
|
||||
if self.grid.last_title.as_deref() != Some(title.as_str()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
|
||||
self.grid.last_title = Some(title);
|
||||
}
|
||||
// The step is applied only after a completed UI pass. A discarded or repeated pass of the
|
||||
// same frame must not offer it again (grid sizing calls `ui` more than once per frame);
|
||||
// provenance is the injected pass number, so only a pass that missed input handling is
|
||||
// re-offered by `raw_input_hook`.
|
||||
if self.synthetic_pending {
|
||||
self.synthetic.pop_front();
|
||||
self.synthetic_pending = false;
|
||||
self.synthetic_injected_at_pass = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1460,10 +1662,268 @@ pub fn clean_error(e: &str) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
/// Wall-clock milliseconds since the Unix epoch, for user-visible timestamps (Backstage's "last
|
||||
/// changed", toasts). It can jump when the system clock is adjusted, so it must not drive
|
||||
/// deadlines or pacing; use [`monotonic_ms`] for those.
|
||||
pub fn now_ms() -> f64 {
|
||||
gridcraft_engine::core::date::unix_now_ms().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Monotonic milliseconds since the first call, for screenshot deadlines and AutoSave pacing.
|
||||
///
|
||||
/// Deliberately separate from [`now_ms`]: a wall-clock adjustment must not extend (or expire) a
|
||||
/// screenshot deadline or save a workbook twice. Native uses [`std::time::Instant`]; wasm uses the
|
||||
/// browser's `performance.now()`, which is monotonic too.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::disallowed_methods)] // the std clock is read only off wasm
|
||||
pub fn monotonic_ms() -> f64 {
|
||||
use std::sync::OnceLock;
|
||||
static START: OnceLock<std::time::Instant> = OnceLock::new();
|
||||
START.get_or_init(std::time::Instant::now).elapsed().as_secs_f64() * 1000.0
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// Web monotonic clock: the browser's `performance.now()`, or 0 when it is unavailable.
|
||||
pub fn monotonic_ms() -> f64 {
|
||||
web_sys::window().and_then(|window| window.performance()).map_or(0.0, |performance| performance.now())
|
||||
}
|
||||
|
||||
pub fn cell(r: u32, c: u32) -> CellRef {
|
||||
CellRef::new(r, c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests {
|
||||
use super::*;
|
||||
|
||||
/// Builds a key-A press or release event without modifiers.
|
||||
fn key_event(pressed: bool) -> egui::Event {
|
||||
egui::Event::Key { key: egui::Key::A, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// Returns a test app with a new workbook and default services.
|
||||
fn app() -> SheetApp {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
SheetApp::new(session, Services::default())
|
||||
}
|
||||
|
||||
/// One headless pass; a test has no renderer for the texture atlas delta (0.37 panics when it
|
||||
/// is dropped unapplied).
|
||||
fn ui_pass(ctx: &egui::Context, raw: egui::RawInput, f: impl FnMut(&mut egui::Ui)) -> egui::FullOutput {
|
||||
let mut out = ctx.run_ui(raw, f);
|
||||
out.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Only one synthetic step is offered, is not duplicated by a hidden logic-only tick, and press/release remain distinct batches.
|
||||
fn a_hidden_logic_tick_keeps_one_unapplied_synthetic_step() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic.push_back(vec![key_event(false)]);
|
||||
|
||||
let mut first = egui::RawInput::default();
|
||||
app.raw_input_hook(&ctx, &mut first);
|
||||
assert_eq!(first.events.len(), 1, "only one step is offered");
|
||||
assert!(app.synthetic_pending);
|
||||
assert_eq!(app.synthetic.len(), 2, "a logic-only frame does not consume the step");
|
||||
|
||||
// eframe carries the unconsumed `RawInput` into the next pass, so the hook offers the step
|
||||
// only once; a hidden logic-only tick does not duplicate it or offer the release.
|
||||
let mut second = egui::RawInput::default();
|
||||
app.raw_input_hook(&ctx, &mut second);
|
||||
assert!(second.events.is_empty(), "an unapplied step is not injected a second time");
|
||||
assert_eq!(app.synthetic.len(), 2, "press and release stay distinct batches");
|
||||
}
|
||||
|
||||
/// Returns raw input with a screen rect at the given size.
|
||||
fn raw_with_size(size: egui::Vec2) -> egui::RawInput {
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A font-gated early-returning pass does not lose the queued step; the hook offers it again and a later real pass applies it.
|
||||
fn a_font_gated_pass_offers_the_synthetic_step_again() {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let mut app = app();
|
||||
app.fonts_ready = true;
|
||||
app.fonts_set = true;
|
||||
// Binds `session.text_width`, as eframe's first frame does.
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
|
||||
// The font gate returns before input handling; `begin_pass` has still consumed the events.
|
||||
app.fonts_rebind = true;
|
||||
let mut raw = raw_with_size(egui::vec2(900.0, 700.0));
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1);
|
||||
let _ = ui_pass(&ctx, raw, |ui| app.ui(ui));
|
||||
assert!(app.synthetic_pending, "the early-returning pass did not apply the step");
|
||||
assert_eq!(app.synthetic.len(), 1, "the step is still queued");
|
||||
|
||||
// A completed pass that did not apply it made the hook offer it again for a real pass.
|
||||
app.fonts_rebind = false;
|
||||
let mut raw = raw_with_size(egui::vec2(900.0, 700.0));
|
||||
app.raw_input_hook(&ctx, &mut raw);
|
||||
assert_eq!(raw.events.len(), 1, "the step is not lost across the font gate");
|
||||
let _ = ui_pass(&ctx, raw, |ui| app.ui(ui));
|
||||
assert!(!app.synthetic_pending);
|
||||
assert!(app.synthetic.is_empty(), "the real UI pass applied the step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A language-driven font rebuild waits while a synthetic step is pending and proceeds once it is clear.
|
||||
fn a_pending_step_delays_a_font_change() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.fonts_ready = true;
|
||||
app.fonts_han = Some(theme::HanOrder::Chinese);
|
||||
app.ui.language = i18n::Language::Ja;
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic_pending = true;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(app.fonts_ready, "the font rebuild waits for the pass that applies the step");
|
||||
app.synthetic.clear();
|
||||
app.synthetic_pending = false;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(!app.fonts_ready, "without pending input the language rebuild proceeds");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Logic ticks see fresh viewport and DPI information; resizing back to the original size still advances the capture epoch.
|
||||
fn a_hidden_logic_tick_sees_fresh_viewport_info_and_keeps_the_epoch_monotonic() {
|
||||
/// Runs one logic tick with the requested width and native scale.
|
||||
fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32, native_ppp: f32) {
|
||||
let mut raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
|
||||
if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) {
|
||||
viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0)));
|
||||
viewport.native_pixels_per_point = Some(native_ppp);
|
||||
}
|
||||
let _ = ctx.run_logic(&raw, |ctx| app.logic(ctx));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
tick(&ctx, &mut app, 800.0, 2.0);
|
||||
let first = app.capture_geometry.expect("the first logic tick records the geometry");
|
||||
assert_eq!(first.viewport, Some(egui::vec2(800.0, 400.0)));
|
||||
assert_eq!(first.pixels_per_point, 2.0, "native scale times the interface zoom");
|
||||
tick(&ctx, &mut app, 900.0, 2.0);
|
||||
let resized = app.capture_geometry.expect("geometry");
|
||||
assert_eq!(resized.viewport, Some(egui::vec2(900.0, 400.0)), "run_logic's ViewportInfo is the fresh size");
|
||||
let epoch = app.capture_epoch;
|
||||
tick(&ctx, &mut app, 800.0, 2.0);
|
||||
assert_eq!(app.capture_geometry.map(|g| g.viewport), Some(Some(egui::vec2(800.0, 400.0))));
|
||||
assert!(app.capture_epoch > epoch, "resizing back is still a new epoch: old pixels must not be saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A resize and a resize back both advance the capture epoch.
|
||||
fn the_capture_epoch_changes_even_when_the_geometry_returns() {
|
||||
/// Runs one UI pass at the requested width.
|
||||
fn tick(ctx: &egui::Context, app: &mut SheetApp, width: f32) {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 400.0))), ..Default::default() };
|
||||
let _ = ui_pass(ctx, raw, |ui| app.logic(ui.ctx()));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
tick(&ctx, &mut app, 800.0);
|
||||
let base = app.capture_epoch;
|
||||
tick(&ctx, &mut app, 900.0);
|
||||
let changed = app.capture_epoch;
|
||||
assert!(changed > base, "a resize increments the capture epoch");
|
||||
tick(&ctx, &mut app, 800.0);
|
||||
assert!(app.capture_epoch > changed, "resizing back is still a new epoch: old pixels must not be saved");
|
||||
}
|
||||
|
||||
/// Extracts title viewport commands from a logic output.
|
||||
fn titles(out: &egui::LogicOutput) -> Vec<String> {
|
||||
out.viewport_commands
|
||||
.values()
|
||||
.flatten()
|
||||
.filter_map(|c| match c {
|
||||
egui::ViewportCommand::Title(title) => Some(title.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A hidden logic tick saves a dirty file-backed workbook once and clears its dirty state.
|
||||
fn a_logic_only_tick_autosaves_a_dirty_workbook_once() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-logic-autosave-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("autosave.xlsx");
|
||||
app.run("cell.set", serde_json::json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
app.ui.autosave = true;
|
||||
app.session.active_mut().unwrap().path = Some(path.to_string_lossy().into_owned());
|
||||
app.grid.last_autosave = monotonic_ms() - 3000.0;
|
||||
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(path.exists(), "a hidden logic-only tick saves a dirty workbook with a file");
|
||||
assert!(!app.session.active().unwrap().is_dirty(), "the save clears the dirty state");
|
||||
let saved_at = app.grid.last_autosave;
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert_eq!(app.grid.last_autosave, saved_at, "the same change is not saved a second time");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The title is sent on the first tick and after a rename, but not on every unchanged frame.
|
||||
fn a_logic_only_tick_updates_the_title_once_per_change() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let first = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
let title = app.session.active().unwrap().display_title().to_string();
|
||||
let expected = format!("{title}{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" });
|
||||
assert_eq!(titles(&first), [expected], "the first logic tick titles the window");
|
||||
let second = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert!(titles(&second).is_empty(), "an unchanged title is not sent every frame");
|
||||
|
||||
app.session.active_mut().unwrap().title = "Renamed".into();
|
||||
let third = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
let expected = format!("Renamed{}", if app.session.active().unwrap().is_dirty() { " — Edited" } else { "" });
|
||||
assert_eq!(titles(&third), [expected], "a rename while hidden updates the title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A hidden tick surfaces a failed print job in the app message.
|
||||
fn a_logic_only_tick_polls_printing_feedback() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.printing.printers.push(crate::printing::Printer { name: "Queue".into(), label: String::new(), default: true });
|
||||
app.run("ui.printSettings", serde_json::json!({"printer": "Queue"})).unwrap();
|
||||
app.services.print_native = Some(Box::new(|_, _| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
sender.send(Err("printer is offline".into())).unwrap();
|
||||
receiver
|
||||
}));
|
||||
app.run("ui.printSubmit", serde_json::json!({})).unwrap();
|
||||
let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| app.logic(ctx));
|
||||
assert_eq!(app.printing.message, Some(crate::printing::Feedback::Failed("printer is offline".into())));
|
||||
assert!(app.message.as_ref().is_some_and(|(_, text)| text.contains("printer is offline")), "logic surfaces the failure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Pending synthetic input makes logic request a real hidden paint.
|
||||
fn a_pending_synthetic_step_requests_a_hidden_paint() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.synthetic.push_back(vec![key_event(true)]);
|
||||
app.synthetic_pending = true;
|
||||
let out = ui_pass(&ctx, egui::RawInput::default(), |ui| app.logic(ui.ctx()));
|
||||
let painted = out
|
||||
.viewport_output
|
||||
.get(&ctx.viewport_id())
|
||||
.is_some_and(|output| output.commands.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden)));
|
||||
assert!(painted, "logic must ask for a real UI pass while a synthetic step is unapplied");
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user