mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-02 16:30:50 -06:00
Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bb397a3c1 | ||
|
|
ef927e2e71 | ||
|
|
6558f26ba8 | ||
|
|
9172516aa8 | ||
|
|
824a6fffd2 | ||
|
|
9c6dbfdfab | ||
|
|
e9430daa9a | ||
|
|
7ddcb800ea | ||
|
|
5579ec9a65 | ||
|
|
7c5f417a5d | ||
|
|
f988c2843e | ||
|
|
9f4ca54e9a | ||
|
|
94b292d5a8 | ||
|
|
23e09713a9 | ||
|
|
5119fb5f51 | ||
|
|
b5acb39356 | ||
|
|
9a094696fe | ||
|
|
57d7ea9bc3 | ||
|
|
e8f2becf4a | ||
|
|
ad6aca08bf | ||
|
|
b78afc9b3c | ||
|
|
3bec72f643 | ||
|
|
6ed56683bf | ||
|
|
9ac06dd8cc | ||
|
|
8b96d30c2b | ||
|
|
c1cc8264fe | ||
|
|
52b00fe8f8 | ||
|
|
0151f43af8 | ||
|
|
11b55e0c71 | ||
|
|
8e816dff1e | ||
|
|
745e937b0b | ||
|
|
0524c04756 | ||
|
|
6c478475e4 | ||
|
|
cd51fe110d | ||
|
|
b3a3590db6 | ||
|
|
ec7ee8d4ea | ||
|
|
84d4f6aa84 | ||
|
|
e82e91b451 | ||
|
|
113aeff07c | ||
|
|
44e9ddad45 | ||
|
|
4fbd1dc337 | ||
|
|
58aa00bae4 | ||
|
|
14ab0f8ee3 | ||
|
|
2d56f1b936 | ||
|
|
c03c61fd05 | ||
|
|
44c3a36ea1 | ||
|
|
1d7206ebdd | ||
|
|
fcbbfa5142 | ||
|
|
c6c54c57e6 | ||
|
|
44c663aea2 | ||
|
|
0cb1745597 | ||
|
|
b36a3a9b8b | ||
|
|
c01e46619a |
Generated
+49
@@ -225,6 +225,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -4470,6 +4476,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -5002,6 +5019,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5231,6 +5254,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -6350,6 +6398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
|
||||
@@ -274,3 +274,34 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
+7
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -135,6 +135,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
|
||||
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
|
||||
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
|
||||
| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
|
||||
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
|
||||
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
|
||||
@@ -254,6 +257,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
|
||||
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
|
||||
| 2026-11-02 | minor | Progress log: Python cells as data |
|
||||
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
|
||||
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
|
||||
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
|
||||
|
||||
@@ -24,9 +24,13 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
|
||||
@@ -135,6 +135,10 @@ impl Calc {
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
}
|
||||
} else if cell.py.is_some() {
|
||||
// A Python cell lays out its kept result, so it is calculated like a formula
|
||||
// that reads nothing: its spill is blocked and unblocked like a formula's.
|
||||
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,11 +210,29 @@ impl Calc {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
None if python => {
|
||||
self.graph.insert(k, Node { areas: Vec::new(), dynamic: false });
|
||||
// New code has no result yet: the spill the cell had (its old result's, or a
|
||||
// formula's) goes, and what read it is recalculated, as for a formula
|
||||
// replaced by a constant.
|
||||
if !spills
|
||||
&& let Some(sh) = wb.sheet_mut(k.0)
|
||||
&& let Some(r) = sh.spill_ranges.remove(&k.1)
|
||||
{
|
||||
sh.clear_spill(r);
|
||||
cleared.push((k.0, r));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.graph.remove(k);
|
||||
// A formula replaced by a constant (or cleared) takes its spilled values with it.
|
||||
@@ -338,11 +360,15 @@ impl Calc {
|
||||
exprs.insert(*k, e);
|
||||
}
|
||||
}
|
||||
let python: HashMap<Key, Value> = dirty
|
||||
.iter()
|
||||
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
|
||||
.collect();
|
||||
let now = self.now();
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.calculate(*k);
|
||||
@@ -547,6 +573,48 @@ pub(crate) mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
|
||||
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
|
||||
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
|
||||
let n = 1_000usize;
|
||||
let data: Vec<Value> = (0..n * n).map(|i| Value::Number(i as f64)).collect();
|
||||
let array = gridcraft_core::Array::new(n, n, data.clone()).unwrap();
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let anchor = CellRef::new(0, 0);
|
||||
let py = gridcraft_model::PyCode::new("big");
|
||||
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
|
||||
let area = RangeRef::new(anchor, CellRef::new(n as u32 - 1, n as u32 - 1));
|
||||
sh.spill_ranges.insert(anchor, area);
|
||||
for (i, c) in area.iter().enumerate().skip(1) {
|
||||
sh.spill.insert(c, data[i].clone());
|
||||
}
|
||||
let mut python = HashMap::default();
|
||||
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
|
||||
(wb, python)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_beside_a_large_spill_walks_none_of_it() {
|
||||
let (wb, python) = big_python_spill();
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
|
||||
assert_eq!(host.cell_value(0, CellRef::new(0, 0)), Value::Number(0.0));
|
||||
// Beside the spill (column ALM, the 1,001st), over the rows it covers.
|
||||
let beside = RangeRef::parse("ALM1:ALM1000").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert!(host.range_values(0, beside).iter().all(Value::is_empty));
|
||||
assert!(host.visit_range(0, beside, &mut |_| true), "a disjoint read takes the fast path");
|
||||
assert_eq!(host.spill_visits, 0, "nothing of the spill is walked");
|
||||
// A read inside it still sees the spilled values, walking only the cells it reads.
|
||||
let inside = RangeRef::parse("B2:C3").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert_eq!(host.range_values(0, inside), [1_001.0, 1_002.0, 2_001.0, 2_002.0].map(Value::Number).to_vec());
|
||||
assert!(host.spill_visits <= 8, "{}", host.spill_visits);
|
||||
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
|
||||
@@ -45,6 +45,10 @@ pub(super) struct PassHost<'a> {
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
pub(super) suspensions: usize,
|
||||
/// Spilled values looked at by range reads (tests check that a read outside a large spill
|
||||
/// looks at none of it).
|
||||
#[cfg(test)]
|
||||
pub(super) spill_visits: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
@@ -64,6 +68,10 @@ pub(super) struct PassHost<'a> {
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
|
||||
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
|
||||
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
|
||||
python: HashMap<Key, Value>,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -87,6 +95,8 @@ impl<'a> PassHost<'a> {
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
#[cfg(test)]
|
||||
spill_visits: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
@@ -96,8 +106,17 @@ impl<'a> PassHost<'a> {
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
|
||||
/// are pending until laid out, so every read path calculates them before reading them.
|
||||
pub(super) fn with_python(mut self, python: HashMap<Key, Value>) -> Self {
|
||||
self.pending.extend(python.keys().copied());
|
||||
self.python = python;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
@@ -111,7 +130,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
let own = sh.spill_ranges.get(&anchor);
|
||||
let spilled_over = |c: CellRef| {
|
||||
!own.is_some_and(|o| o.contains(c))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| {
|
||||
// A formula or Python cell recalculated in this pass lays its array out again.
|
||||
*o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o))
|
||||
})
|
||||
};
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
@@ -135,6 +157,42 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
/// The area an array anchored at `anchor` covers.
|
||||
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end)
|
||||
}
|
||||
|
||||
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
|
||||
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
|
||||
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
|
||||
/// Returns how many values were looked at.
|
||||
fn stored_spill_in<'s>(sheet: &'s gridcraft_model::Sheet, range: RangeRef, f: &mut dyn FnMut(CellRef, &'s Value) -> bool) -> usize {
|
||||
let mut seen = 0usize;
|
||||
if sheet.spill.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if (range.height() as usize) < sheet.spill.len() {
|
||||
for row in range.start.row..=range.end.row {
|
||||
for (c, v) in sheet.spill.range(CellRef::new(row, range.start.col)..=CellRef::new(row, range.end.col)) {
|
||||
seen += 1;
|
||||
if !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (c, v) in sheet.spill.range(range.start..=range.end) {
|
||||
seen += 1;
|
||||
if range.contains(*c) && !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
impl PassHost<'_> {
|
||||
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
|
||||
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
|
||||
@@ -188,6 +246,9 @@ impl PassHost<'_> {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if let Some(result) = self.python.get(&k).cloned() {
|
||||
return self.python_result(k, result);
|
||||
}
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
@@ -305,6 +366,36 @@ impl PassHost<'_> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A Python cell's kept result as its value: an array spills from the cell unless it is
|
||||
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
|
||||
/// crafted file holds one), which shows `#SPILL!`.
|
||||
fn python_result(&mut self, k: Key, result: Value) -> Value {
|
||||
self.pending.remove(&k);
|
||||
let v = match result {
|
||||
Value::Array(a) => {
|
||||
let sane = a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
|
||||
if sane {
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
}
|
||||
v => v,
|
||||
};
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A formula's previous value for iterative calculation: from the last iteration, else the
|
||||
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
|
||||
fn previous(&self, k: Key) -> Value {
|
||||
@@ -445,26 +536,29 @@ impl Host for PassHost<'_> {
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
let mut out = vec![Value::Empty; h * w];
|
||||
let Some(sh) = self.wb.sheet(sheet) else { return out };
|
||||
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
|
||||
// `compute`, constants are copied straight from the store.
|
||||
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
|
||||
// through `compute`, constants are copied straight from the store.
|
||||
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
formulas.push((i, c));
|
||||
} else if let Some(slot) = out.get_mut(i) {
|
||||
*slot = cell.value.clone();
|
||||
}
|
||||
}
|
||||
for (c, v) in sh.spill.range(range.start..=range.end) {
|
||||
if range.contains(*c) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
let _seen = stored_spill_in(sh, range, &mut |c, v| {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
true
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
for (i, c) in formulas {
|
||||
let v = self.cell_value(sheet, c);
|
||||
@@ -473,26 +567,31 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
if !self.spills.is_empty() {
|
||||
let new: Vec<(usize, Value)> = self
|
||||
.spills
|
||||
.iter()
|
||||
.filter(|(a, _)| a.0 == sheet)
|
||||
.flat_map(|(a, arr)| {
|
||||
let mut v = Vec::new();
|
||||
for r in 0..arr.rows {
|
||||
for cc in 0..arr.cols {
|
||||
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
|
||||
if c != a.1 && range.contains(c) {
|
||||
v.push((
|
||||
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
|
||||
arr.get(r, cc).cloned().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
// Only the part of each array inside the range is walked: a large array beside a small
|
||||
// read costs nothing.
|
||||
let mut new: Vec<(usize, Value)> = Vec::new();
|
||||
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
|
||||
let area = array_area(a.1, arr);
|
||||
if !area.intersects(&range) {
|
||||
continue;
|
||||
}
|
||||
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
|
||||
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
|
||||
for row in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
let c = CellRef::new(row, col);
|
||||
if c == a.1 {
|
||||
continue;
|
||||
}
|
||||
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
|
||||
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += new.len();
|
||||
}
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
@@ -516,21 +615,31 @@ impl Host for PassHost<'_> {
|
||||
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
let wb = self.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return false };
|
||||
// Formula cells first, in row-major order like `range_values`: reading one calculates it
|
||||
// and the pass remembers the value, so the walk below gets the same value again without
|
||||
// this method holding any.
|
||||
// Formula and Python cells first, in row-major order like `range_values`: reading one
|
||||
// calculates it (a Python cell lays out its result) and the pass remembers the value, so
|
||||
// the walk below gets the same value again without this method holding any.
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
let _ = self.cell_value(sheet, c);
|
||||
}
|
||||
}
|
||||
// Spilled values sit between the stored cells (and the formulas just calculated may have
|
||||
// produced some): leave such blocks to `range_values`.
|
||||
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
|
||||
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
|
||||
let mut stored = false;
|
||||
let _seen = stored_spill_in(sh, range, &mut |_, _| {
|
||||
stored = true;
|
||||
false
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
if arrays || stored {
|
||||
return false;
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2312,3 +2312,63 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
use gridcraft_model::python::{PyCode, PyOutput};
|
||||
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
|
||||
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
|
||||
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
|
||||
(0, c(at))
|
||||
}
|
||||
|
||||
fn column(values: &[f64]) -> Value {
|
||||
let data = values.iter().map(|v| Value::Number(*v)).collect();
|
||||
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
|
||||
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
|
||||
// it in the pass; each must lay it out first rather than read its old value, 1.
|
||||
let build = |t: &mut T| {
|
||||
t.set("B2", "10");
|
||||
t.set("B3", "100");
|
||||
t.set("A1", "=B1");
|
||||
t.set("A2", "=MAX(B1:B3)");
|
||||
t.set("A3", "=SUM(B1:B3)");
|
||||
t.set("A4", "=COUNTA(B1:B3)");
|
||||
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
|
||||
};
|
||||
let check = |t: &T, how: &str| {
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
|
||||
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
|
||||
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
|
||||
assert_eq!(t.num("A4"), 3.0, "{how}");
|
||||
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
|
||||
};
|
||||
|
||||
// A full recalculation.
|
||||
let mut t = T::new();
|
||||
build(&mut t);
|
||||
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
check(&t, "full");
|
||||
|
||||
// An incremental one: B1 becomes a Python cell after its readers.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "1");
|
||||
build(&mut t);
|
||||
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
|
||||
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.cells_changed(&mut t.wb, &[k]);
|
||||
check(&t, "incremental");
|
||||
|
||||
// Unblocked, the readers see the new array.
|
||||
t.set("B2", "");
|
||||
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
|
||||
}
|
||||
|
||||
@@ -21,10 +21,18 @@ pub enum CellError {
|
||||
Calc,
|
||||
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
|
||||
Circ,
|
||||
/// A Python cell's code raised an error, or Python stopped.
|
||||
Python,
|
||||
/// A Python cell is waiting to run.
|
||||
Busy,
|
||||
/// A Python cell ran longer than the time limit.
|
||||
Timeout,
|
||||
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl CellError {
|
||||
pub const ALL: [CellError; 10] = [
|
||||
pub const ALL: [CellError; 14] = [
|
||||
CellError::Null,
|
||||
CellError::Div0,
|
||||
CellError::Value,
|
||||
@@ -35,6 +43,10 @@ impl CellError {
|
||||
CellError::GettingData,
|
||||
CellError::Spill,
|
||||
CellError::Calc,
|
||||
CellError::Python,
|
||||
CellError::Busy,
|
||||
CellError::Timeout,
|
||||
CellError::Blocked,
|
||||
];
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -49,9 +61,16 @@ impl CellError {
|
||||
CellError::Spill => "#SPILL!",
|
||||
CellError::Calc => "#CALC!",
|
||||
CellError::Circ => "#CIRC!",
|
||||
CellError::Python => "#PYTHON!",
|
||||
CellError::Busy => "#BUSY!",
|
||||
CellError::Timeout => "#TIMEOUT!",
|
||||
CellError::Blocked => "#BLOCKED!",
|
||||
}
|
||||
}
|
||||
/// `ERROR.TYPE` number.
|
||||
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
|
||||
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
|
||||
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
|
||||
/// and may change if Excel's numbers become known.
|
||||
pub fn code(&self) -> u32 {
|
||||
match self {
|
||||
CellError::Null => 1,
|
||||
@@ -65,6 +84,10 @@ impl CellError {
|
||||
CellError::Spill => 9,
|
||||
CellError::Calc => 14,
|
||||
CellError::Circ => 15,
|
||||
CellError::Blocked => 11,
|
||||
CellError::Python => 19,
|
||||
CellError::Busy => 20,
|
||||
CellError::Timeout => 21,
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CellError> {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod python;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(python::specs());
|
||||
v.extend(slicer::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
@@ -522,7 +524,7 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||
let filled = after.cell(c).is_some_and(gridcraft_model::Cell::has_content);
|
||||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||
continue;
|
||||
}
|
||||
@@ -538,13 +540,18 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
|
||||
/// is not something the person edits).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => a.value == b.value,
|
||||
(None, None) => match (&a.py, &b.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
|
||||
@@ -1788,7 +1788,7 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json>
|
||||
{
|
||||
for row in rect.start.row..=rect.end.row {
|
||||
for (c, cell) in sh.cells.row(row, rect.start.col, rect.end.col) {
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
if cell.has_content() {
|
||||
occupied.push(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
@@ -1816,13 +1816,13 @@ fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json>
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for c in &occupied {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
|
||||
}
|
||||
let mut touched = occupied.clone();
|
||||
for ((r, c), v) in &g.cells {
|
||||
let cell = CellRef::new(at.row + r, at.col + c);
|
||||
let style = pct_styles.get(&cell).copied().unwrap_or_else(|| sh.style_id(cell));
|
||||
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(cell, Cell { value: v.clone(), formula: None, style, py: None });
|
||||
touched.push(cell);
|
||||
}
|
||||
for c in touched {
|
||||
|
||||
@@ -146,7 +146,16 @@ pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let cell = match crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb) {
|
||||
Ok(cell) => cell,
|
||||
// `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept
|
||||
// as text rather than lost.
|
||||
Err(_) if gridcraft_model::python::is_py_formula(&t) => {
|
||||
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
Some(Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style, py: None })
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
|
||||
@@ -35,7 +35,7 @@ use go_to::go_to_special;
|
||||
#[cfg(test)]
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links};
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -132,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -267,6 +267,7 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
cell.py = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
/// Typing `=PY(` doesn't make a Python cell; this says what does.
|
||||
pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…).";
|
||||
|
||||
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
|
||||
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
|
||||
pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
@@ -21,14 +24,18 @@ pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
if input.is_empty() {
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
let c = Cell { value: Value::Empty, formula: None, style, py: None };
|
||||
return Ok(if c.is_blank() { None } else { Some(c) });
|
||||
}
|
||||
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
|
||||
if fmt_is_text {
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None }));
|
||||
}
|
||||
if is_formula_input(input) {
|
||||
let is_formula = is_formula_input(input);
|
||||
if is_formula && gridcraft_model::python::is_py_formula(input) {
|
||||
return Err(EngineError::Other(PY_FORMULA.into()));
|
||||
}
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
check_arg_counts(&expr)?;
|
||||
@@ -79,7 +86,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
if input.contains('\n') && !wb.styles.get(style).align.wrap {
|
||||
style = wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style, py: None }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
@@ -147,7 +154,7 @@ pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The array takes the whole range: other cells in it lose their contents.
|
||||
for c in range.iter().skip(1).take(1_000_000) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) {
|
||||
let style = old.style;
|
||||
sh.set_cell(c, Cell { style, ..Cell::default() });
|
||||
}
|
||||
@@ -239,10 +246,14 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell {
|
||||
value: Value::Bool(*b),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
// Text as given: no date or number reading, and a leading `'` is part of the
|
||||
// text rather than the marker that makes it text. A formula is still a
|
||||
// formula, which is what the ticket asks for and what `=` means in a cell.
|
||||
@@ -258,7 +269,7 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
|
||||
}
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
|
||||
@@ -92,7 +92,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
|
||||
let mut n = 0;
|
||||
@@ -135,6 +136,30 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
// A Python cell's text is its code: the replacement edits the code and the cell
|
||||
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
|
||||
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
|
||||
if let Some(old) = old_py {
|
||||
let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) };
|
||||
if py.code == old.code {
|
||||
continue;
|
||||
}
|
||||
if !py.fits() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Its old spill is left to the recalculation, which removes it and
|
||||
// recalculates what read it.
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) },
|
||||
);
|
||||
cx.touch(si, c);
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
@@ -149,6 +174,9 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if skipped > 0 {
|
||||
return Ok(json!({"replaced": n, "skipped": skipped}));
|
||||
}
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let v = sheet.value(c);
|
||||
let ok = match kind {
|
||||
"blanks" => v.is_empty(),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()),
|
||||
"comments" | "notes" => sheet.comments.contains_key(&c),
|
||||
"errors" => v.is_error(),
|
||||
"numbers" => v.is_number(),
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
moved_formulas = taken;
|
||||
}
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
@@ -152,7 +153,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
@@ -186,6 +187,33 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
|
||||
// cells its `xl()` references named on its original sheet, as a moved formula does:
|
||||
// they are qualified with that sheet before the workbook-wide move adjustment below.
|
||||
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(Some(py));
|
||||
}
|
||||
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Some(gridcraft_formula::print(&e))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
}
|
||||
None => py,
|
||||
}))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
@@ -194,6 +222,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new.py = None;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
@@ -202,6 +231,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
@@ -214,6 +244,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
@@ -222,6 +253,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
@@ -232,7 +264,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
@@ -292,7 +328,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
|
||||
cx.structural = true;
|
||||
}
|
||||
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
|
||||
@@ -316,7 +352,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
||||
// (a cut moves its arrays itself).
|
||||
@@ -356,6 +392,7 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
}
|
||||
}
|
||||
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
||||
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
@@ -385,3 +422,78 @@ pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
|
||||
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
|
||||
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
|
||||
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
|
||||
/// result is kept while its references still name the same data (a rename, a move); a reference
|
||||
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
|
||||
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
|
||||
/// recalculation removes its spill. A rewrite that would
|
||||
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
|
||||
/// the whole edit, which then changes nothing: see [`check_python_fits`].
|
||||
pub(crate) fn rewrite_python_refs(
|
||||
wb: &mut gridcraft_model::Workbook,
|
||||
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
|
||||
) -> Result<()> {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let host = sh.name.clone();
|
||||
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
let mut lost = false;
|
||||
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let expr = gridcraft_formula::parse(reference).ok()?;
|
||||
let before = gridcraft_formula::print(&expr);
|
||||
let after = gridcraft_formula::print(&f(&host, expr));
|
||||
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
|
||||
(after != before).then_some(after)
|
||||
});
|
||||
if let Some(code) = changed {
|
||||
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
|
||||
new_code.push((c, code, lost));
|
||||
}
|
||||
}
|
||||
if new_code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, code, lost) in new_code {
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Some(py) = cell.py.as_mut()
|
||||
{
|
||||
let py = std::sync::Arc::make_mut(py);
|
||||
py.code = code;
|
||||
if lost {
|
||||
py.output = None;
|
||||
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
|
||||
/// for any other form (a name, an error).
|
||||
fn area_shape(reference: &str) -> Option<(u32, u32)> {
|
||||
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
|
||||
Some((r.height(), r.width()))
|
||||
}
|
||||
|
||||
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
|
||||
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
|
||||
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
|
||||
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
|
||||
if py.fits() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(format!(
|
||||
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
|
||||
gridcraft_formula::quote_sheet(sheet),
|
||||
at.a1(),
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& cell.formula.is_none()
|
||||
&& cell.py.is_none()
|
||||
{
|
||||
// An empty checkbox cell counts as FALSE.
|
||||
let next = match cell.value {
|
||||
|
||||
@@ -138,7 +138,7 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
|
||||
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
|
||||
if !opened_dialog || changed {
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(id, st["params"].clone(), &result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
|
||||
let mut sheet = Sheet::new(sheet_name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
|
||||
let mut cell = Cell::formula(Formula::new(&text));
|
||||
|
||||
@@ -656,8 +656,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -674,8 +673,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -692,7 +690,10 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let blocked = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||
// A Python cell's result is blocked and unblocked like a formula's.
|
||||
.filter(|(_, x)| {
|
||||
(x.formula.is_some() || x.py.is_some()) && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill)
|
||||
});
|
||||
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||
}
|
||||
cx.changed.extend(touched);
|
||||
|
||||
@@ -635,7 +635,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(c);
|
||||
// The second column holds the refers-to text as a text value, with no formula.
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The header row pushes the data down one row: never off the end of the sheet.
|
||||
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
@@ -325,7 +325,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
@@ -476,7 +476,10 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
|
||||
let c = CellRef::new(row, t.range.start.col + i as u32);
|
||||
if let Some(code) = col.totals.subtotal_code() {
|
||||
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
|
||||
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c), py: None },
|
||||
);
|
||||
} else if let Some(l) = &col.totals_label {
|
||||
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
|
||||
} else {
|
||||
@@ -1323,7 +1326,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style, py: None });
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
@@ -137,6 +137,13 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
"comment": sh.comments.get(&c),
|
||||
"hyperlink": sh.hyperlinks.get(&c),
|
||||
"python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({
|
||||
"code": py.code,
|
||||
"output": if py.object { "object" } else { "value" },
|
||||
"typeName": py.output.as_ref().map(|o| o.type_name.as_str()),
|
||||
"preview": py.output.as_ref().map(|o| o.preview.as_str()),
|
||||
"stale": py.stale,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -255,7 +255,10 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, h) in src.headers.iter().enumerate() {
|
||||
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(0, c as u32),
|
||||
Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default(), py: None },
|
||||
);
|
||||
}
|
||||
for (r, i) in records.iter().enumerate() {
|
||||
let Some(rec) = src.rows.get(*i) else { continue };
|
||||
@@ -264,7 +267,7 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style, py: None });
|
||||
}
|
||||
}
|
||||
let name = sh.name.clone();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Python in cells: making and editing Python cells, and their return type. This build stores
|
||||
//! the code and the last result a file cached; it doesn't run Python, so a cell with new or
|
||||
//! changed code shows `#BLOCKED!`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"python.set",
|
||||
"Set Python Code",
|
||||
[],
|
||||
None,
|
||||
"{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"python.output",
|
||||
"Python Output",
|
||||
[],
|
||||
Some("Ctrl+Alt+Shift+M"),
|
||||
"{cell?, mode?: object|value} sets the return type of a Python cell (default: the active cell): `object` (a Python object) or `value` (an Excel value). Without `mode` it switches to the other one. The cell keeps its code; a cached result of the old return type goes, as with `python.set`, so the cell shows #BLOCKED! until it runs",
|
||||
has_doc,
|
||||
output
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = match cell_param_on(s, p, "cell", sheet)? {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
Ok((sheet, at))
|
||||
}
|
||||
|
||||
fn existing(s: &Session, sheet: usize, at: CellRef) -> Result<Option<PyCode>> {
|
||||
Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned())
|
||||
}
|
||||
|
||||
fn output_mode(cmd: &str, p: &Json, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => return Ok(None),
|
||||
Some(Json::String(_)) => {}
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` is `object` or `value`"))),
|
||||
}
|
||||
match str_param(p, key) {
|
||||
None => Ok(None),
|
||||
Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)),
|
||||
Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)),
|
||||
Some(m) => Err(bad(cmd, format!("`{key}` is `object` or `value`, not `{m}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let code = match p.get("code") {
|
||||
Some(Json::String(c)) => c.as_str(),
|
||||
Some(_) => return Err(bad("python.set", "`code` must be text")),
|
||||
None => return Err(bad("python.set", "missing `code`")),
|
||||
};
|
||||
let mode = output_mode("python.set", p, "output")?;
|
||||
let old = existing(s, sheet, at)?;
|
||||
let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false);
|
||||
store(s, "python.set", sheet, at, PyCode { object, ..PyCode::new(code) }, old)?;
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
|
||||
/// Writes `py` into the cell, which held `old`: what `python.set` and `python.output` share. The
|
||||
/// code must fit in one cell and a table header takes none; a locked cell on a protected sheet is
|
||||
/// refused by the edit itself.
|
||||
fn store(s: &mut Session, cmd: &str, sheet: usize, at: CellRef, mut py: PyCode, old: Option<PyCode>) -> Result<()> {
|
||||
if !py.fits() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"),
|
||||
));
|
||||
}
|
||||
// A table header is the column's name, never a formula or code (as in Excel).
|
||||
let header =
|
||||
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at)));
|
||||
if header {
|
||||
return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1())));
|
||||
}
|
||||
// Unchanged code with the same return type keeps its result (and its value); new code, or a
|
||||
// new return type, has none until it runs: a result of the other type would show a value and
|
||||
// preview that don't belong to the cell.
|
||||
let keep = old.filter(|o| o.code == py.code && o.object == py.object);
|
||||
let value = match &keep {
|
||||
Some(o) => {
|
||||
py.output = o.output.clone();
|
||||
py.stale = o.stale;
|
||||
None
|
||||
}
|
||||
None => Some(gridcraft_model::python::BLOCKED),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(at);
|
||||
let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty));
|
||||
// A spill the cell had (a formula's, or the old result's) is left to the recalculation,
|
||||
// which removes it and recalculates what read it.
|
||||
sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) });
|
||||
cx.touch(sheet, at);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `python.output`: the cell's return type. The code stays; a cached result belongs to the old
|
||||
/// return type, so it goes, as with `python.set` and a new return type: the cell shows
|
||||
/// `#BLOCKED!` until it runs, and its old spill goes with its readers recalculated.
|
||||
fn output(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let Some(old) = existing(s, sheet, at)? else {
|
||||
return Err(EngineError::Other(format!("{} isn't a Python cell, so it has no Python output to change", at.a1())));
|
||||
};
|
||||
let object = output_mode("python.output", p, "mode")?.unwrap_or(!old.object);
|
||||
let mode = |object: bool| if object { "object" } else { "value" };
|
||||
if object != old.object {
|
||||
let py = PyCode { code: old.code.clone(), object, output: None, stale: false };
|
||||
store(s, "python.output", sheet, at, py, Some(old))?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "output": mode(object), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
@@ -217,10 +217,10 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
|
||||
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
|
||||
/// source for a structural edit on `target`.
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
|
||||
cx.edits.push((target.to_string(), e.clone()));
|
||||
let wb = &mut cx.wb;
|
||||
super::edit::rewrite_all_formulas(wb, target, e);
|
||||
super::edit::rewrite_all_formulas(wb, target, e)?;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
@@ -246,6 +246,7 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
@@ -303,7 +304,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -368,7 +369,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -420,7 +421,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.cells.shift_cols(before, count as i64);
|
||||
shift_sheet_features(sh, Axis::Cols, before, count, true);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count })?;
|
||||
|
||||
let mut touched = Vec::new();
|
||||
{
|
||||
@@ -458,14 +459,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for cell in touched {
|
||||
cx.touch(sheet, cell);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination })?;
|
||||
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_cols(delete_at + count, -(count as i64));
|
||||
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count })?;
|
||||
*cx.sel = crate::Selection {
|
||||
active: CellRef::new(active_row, final_start),
|
||||
anchor: CellRef::new(active_row, final_start),
|
||||
@@ -496,7 +497,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
@@ -510,7 +511,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
shift_cell_features(sh, &e);
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -545,7 +546,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shift_cell_features(sh, &e);
|
||||
}
|
||||
// References into the deleted cells become #REF!; the rest move.
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
|
||||
@@ -63,6 +63,7 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order))?;
|
||||
// Chart series into the deleted sheet become #REF!, like formulas.
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
|
||||
cx.wb.names.retain(|n| n.scope != Some(i));
|
||||
@@ -133,6 +134,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name))?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -150,6 +152,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(src) = cx.wb.sheets.get(i).cloned() else { return Err(bad("sheet.move", "no such sheet")) };
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
// The copy is a sheet of its own, not the one it was copied from.
|
||||
sh.uid = gridcraft_model::SheetUid::fresh();
|
||||
let base = sh.name.clone();
|
||||
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
|
||||
let name = (2..)
|
||||
|
||||
@@ -303,7 +303,7 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style, py: None });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
|
||||
@@ -263,7 +263,8 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
vars.retain(|c| seen.insert(*c));
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) {
|
||||
// Python cells count as formulas: a solution written over one would lose its code.
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
|
||||
@@ -107,12 +107,12 @@ fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refused, unless `overwrite`, when a cell of the model's column holds a value or a formula
|
||||
/// (a model saved there before included): Save Model then asks before it replaces data, as
|
||||
/// Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
|
||||
/// Refused, unless `overwrite`, when a cell of the model's column holds a value, a formula or
|
||||
/// Python code (a model saved there before included): Save Model then asks before it replaces
|
||||
/// data, as Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
|
||||
/// question.
|
||||
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
|
||||
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()));
|
||||
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(Cell::has_content));
|
||||
match taken {
|
||||
Some(c) if !overwrite => Err(EngineError::Other(format!(
|
||||
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
|
||||
@@ -176,7 +176,7 @@ pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
for c in &stale {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
|
||||
}
|
||||
for c in cells.into_iter().chain(stale) {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
@@ -352,7 +352,7 @@ pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 155.0), (6, 140.0), (7, 140.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
|
||||
@@ -213,7 +213,7 @@ impl Trial {
|
||||
for (si, c, v) in sets {
|
||||
if let Some(sh) = wb.sheet_mut(*si) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None });
|
||||
keys.push((*si, *c));
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,8 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !has_formula(ssi, set) {
|
||||
return Err(EngineError::Other("Cell must contain a formula.".into()));
|
||||
}
|
||||
if has_formula(csi, chg) {
|
||||
// A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code.
|
||||
if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) {
|
||||
return Err(EngineError::Other("Cell must contain a value.".into()));
|
||||
}
|
||||
let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
@@ -342,7 +343,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(csi)?;
|
||||
let style = sh.style_id(chg);
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None });
|
||||
cx.touch(csi, chg);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -422,7 +423,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, v) in out {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
cx.touch(si, c);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
(to, Cell { value: sh.value(src), formula: None, style, py: None })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
Some(Cell { value: Value::Number(n), formula: None, style, py: None })
|
||||
}
|
||||
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
@@ -245,7 +245,7 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
|
||||
@@ -90,14 +90,14 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
if reads_back(&iso) { iso } else { raw }
|
||||
}
|
||||
|
||||
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
||||
/// protected sheet. A spilled cell follows its anchor.
|
||||
/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted
|
||||
/// Hidden on a protected sheet. A spilled cell follows its anchor.
|
||||
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
if !sheet.is_protected() {
|
||||
return false;
|
||||
}
|
||||
let at = match sheet.cell(c) {
|
||||
Some(x) if x.formula.is_some() => c,
|
||||
Some(x) if x.formula.is_some() || x.py.is_some() => c,
|
||||
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
||||
Some((anchor, _)) => *anchor,
|
||||
None => return false,
|
||||
|
||||
@@ -171,10 +171,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
cell.value = Value::Empty;
|
||||
}
|
||||
} else if let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
} {
|
||||
} else if cell.py.is_none()
|
||||
&& let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
}
|
||||
{
|
||||
cell.value = v;
|
||||
}
|
||||
cell.style = style;
|
||||
@@ -296,7 +298,8 @@ impl Pattern {
|
||||
|
||||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||||
// Formulas and Python cells copy.
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Pattern::Copy;
|
||||
}
|
||||
// Numbers.
|
||||
@@ -598,7 +601,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
break;
|
||||
}
|
||||
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style, py: None }));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -657,7 +660,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, out) in &writes {
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style, py: None });
|
||||
}
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
+130
-1
@@ -118,13 +118,142 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names.
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
|
||||
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
|
||||
/// [`enforce_python_limits`].
|
||||
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
|
||||
warnings.extend(crate::macros::load(&mut wb));
|
||||
warnings.extend(adopt_python(&mut wb));
|
||||
warnings.extend(enforce_python_limits(&mut wb));
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its
|
||||
/// Python cells (each one is also limited to [`gridcraft_model::python::MAX_RESULT_CELLS`]).
|
||||
pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a
|
||||
/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets
|
||||
/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most
|
||||
/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value, and the
|
||||
/// values they spilled stay as plain values. Returns the warnings.
|
||||
pub fn adopt_python(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut budget = MAX_ADOPTED_RESULT_CELLS;
|
||||
let mut skipped = 0usize;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let formulas: Vec<(CellRef, gridcraft_model::PyCode)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| gridcraft_model::PyCode::from_formula(&f.text)).map(|p| (c, p)))
|
||||
.collect();
|
||||
let max = gridcraft_model::python::MAX_RESULT_CELLS as u64;
|
||||
let spills: Vec<(CellRef, RangeRef)> = sh
|
||||
.spill_ranges
|
||||
.iter()
|
||||
.filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none()))
|
||||
.map(|(a, r)| (*a, *r))
|
||||
.collect();
|
||||
if formulas.is_empty() && spills.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, py) in formulas {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = None;
|
||||
cell.py = Some(std::sync::Arc::new(py));
|
||||
}
|
||||
}
|
||||
for (anchor, range) in spills {
|
||||
let Some(left) = budget.checked_sub(range.count()) else {
|
||||
skipped += 1;
|
||||
// The cell keeps only its own value; the values it spilled stay as plain values, so
|
||||
// nothing shows as a spill that no result holds, and nothing the file showed is lost.
|
||||
sh.settle_spill(anchor);
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let data: Vec<gridcraft_core::Value> =
|
||||
range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect();
|
||||
let Some(array) = gridcraft_core::Array::new(range.height() as usize, range.width() as usize, data) else { continue };
|
||||
if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output =
|
||||
Some(gridcraft_model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if skipped == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![format!(
|
||||
"The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) have no result until they run again; the values they spilled were kept as plain values."
|
||||
)]
|
||||
}
|
||||
|
||||
/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's
|
||||
/// 8,192-character formula keeps only its cached value, and a kept result that is not a
|
||||
/// well-formed array of at most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells is dropped
|
||||
/// (the cell keeps its value). Either way the values the cell spilled stay as plain values
|
||||
/// ([`gridcraft_model::Sheet::settle_spill`]). Returns the warnings.
|
||||
pub fn enforce_python_limits(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut long = Vec::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let mut over = Vec::new();
|
||||
let mut bad_output = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
if !py.fits() {
|
||||
long.push(format!("{}!{}", sh.name, c.a1()));
|
||||
over.push(c);
|
||||
} else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) {
|
||||
bad_output.push(c);
|
||||
}
|
||||
}
|
||||
if over.is_empty() && bad_output.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dropped: Vec<CellRef> = over.iter().chain(&bad_output).copied().collect();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for c in over {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.py = None;
|
||||
}
|
||||
}
|
||||
for c in bad_output {
|
||||
if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output = None;
|
||||
}
|
||||
}
|
||||
// A dropped result (or code dropped to its value) leaves the values it spilled as plain
|
||||
// values, as the file showed them, with no live spill.
|
||||
for c in dropped {
|
||||
sh.settle_spill(c);
|
||||
}
|
||||
}
|
||||
let Some(first) = long.first() else { return vec![] };
|
||||
let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() };
|
||||
vec![format!(
|
||||
"A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.",
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)]
|
||||
}
|
||||
|
||||
/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at
|
||||
/// most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells.
|
||||
fn sane_python_output(v: &gridcraft_core::Value) -> bool {
|
||||
match v {
|
||||
gridcraft_core::Value::Array(a) => {
|
||||
a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, as they are.
|
||||
fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
|
||||
@@ -461,17 +461,17 @@ fn csv_writes_displayed_text() {
|
||||
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cells = [
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }),
|
||||
];
|
||||
for (a, c) in cells {
|
||||
sh.set_cell(CellRef::parse(a).unwrap(), c);
|
||||
|
||||
@@ -795,7 +795,7 @@ impl Session {
|
||||
// A toggle (Bold) is recorded with the state it set, as Excel records
|
||||
// `Bold = True`: playing it back sets that state rather than flipping it again.
|
||||
let params = match &result {
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r),
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(¶ms), r),
|
||||
_ => cmd::journal_params(¶ms),
|
||||
};
|
||||
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
|
||||
|
||||
@@ -614,7 +614,10 @@ mod tests {
|
||||
let mut wb = wb();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\"")));
|
||||
sh.set_cell(CellRef::new(1, 2), gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(1, 2),
|
||||
gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default(), py: None },
|
||||
);
|
||||
sh.show_formulas = true;
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
|
||||
@@ -1237,26 +1237,39 @@ pub(crate) fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, O
|
||||
(pairs, spare.collect())
|
||||
}
|
||||
|
||||
/// Only a formula's cached value changed (a recalculation, not a write).
|
||||
/// Only a formula's (or a Python cell's) cached value changed (a recalculation, not a write): a
|
||||
/// Python cell whose spill a step blocked shows #SPILL! with its code untouched.
|
||||
fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object && b.style == a.style,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Python code is content, as a formula is, whatever the cell shows.
|
||||
fn has_content(c: &Cell) -> bool {
|
||||
c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
c.formula.is_some() || c.py.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
}
|
||||
|
||||
/// Whether a write changed what the person entered: the formula, the Python code and its return
|
||||
/// type, or the value. A Python cell replaced by its own cached value, or by other code that
|
||||
/// shows the same `#BLOCKED!`, has changed.
|
||||
fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text != y.text,
|
||||
(None, None) => b.value != a.value,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code != y.code || x.object != y.object,
|
||||
(None, None) => b.value != a.value,
|
||||
_ => true,
|
||||
},
|
||||
_ => true,
|
||||
},
|
||||
(Some(b), None) => has_content(b),
|
||||
|
||||
@@ -228,7 +228,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
}
|
||||
if !replace {
|
||||
for (c, cell) in sh.cells.iter_range(area) {
|
||||
if (!cell.value.is_empty() || cell.formula.is_some()) && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
if cell.has_content() && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
return Err(format!(
|
||||
"There's already data in {}!{}. Do you want to replace it? (Run again with replace: true to overwrite.)",
|
||||
quote_sheet(&sh.name),
|
||||
@@ -278,7 +278,10 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
if oc.value.is_empty() && id == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
cells.push((CellRef::new(origin.row + ri as u32, origin.col + ci as u32), Cell { value: oc.value.clone(), formula: None, style: id }));
|
||||
cells.push((
|
||||
CellRef::new(origin.row + ri as u32, origin.col + ci as u32),
|
||||
Cell { value: oc.value.clone(), formula: None, style: id, py: None },
|
||||
));
|
||||
}
|
||||
}
|
||||
// Column widths that fit the labels.
|
||||
|
||||
@@ -74,15 +74,21 @@ impl Default for Recorder {
|
||||
|
||||
/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel
|
||||
/// records `Bold = True`: playing the step back sets that state rather than flipping it again.
|
||||
pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json {
|
||||
if let Some(on @ Json::Bool(_)) = result.get("on") {
|
||||
/// `python.output` without `mode` is a toggle too: it keeps the return type it set (the result's
|
||||
/// `output`) as its `mode`.
|
||||
pub(crate) fn with_toggle_state(id: &str, mut params: Json, result: &Json) -> Json {
|
||||
let state = match id {
|
||||
"python.output" => result.get("output").filter(|o| o.is_string()).map(|o| ("mode", o)),
|
||||
_ => result.get("on").filter(|o| o.is_boolean()).map(|o| ("on", o)),
|
||||
};
|
||||
if let Some((key, state)) = state {
|
||||
if params.is_null() {
|
||||
params = Json::Object(serde_json::Map::new());
|
||||
}
|
||||
if let Json::Object(p) = &mut params
|
||||
&& !p.contains_key("on")
|
||||
&& p.get(key).is_none_or(Json::is_null)
|
||||
{
|
||||
p.insert("on".into(), on.clone());
|
||||
p.insert(key.into(), state.clone());
|
||||
}
|
||||
}
|
||||
params
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
|
||||
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
||||
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(gridcraft_model::Cell::has_content) || sheet.spill.contains_key(&c);
|
||||
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
||||
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
||||
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
||||
|
||||
@@ -5246,6 +5246,9 @@ fn a_script_cannot_switch_windows() {
|
||||
mod equation;
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod python_cells;
|
||||
mod python_commands;
|
||||
mod python_files;
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Python cells under edits: what keeps, moves, rewrites or removes their code.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
/// Makes `cell` on sheet `sheet` a Python cell with `code` and a cached value.
|
||||
fn plant(s: &mut Session, sheet: usize, cell: &str, code: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let sh = wb.sheet_mut(sheet).unwrap();
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
let style = sh.style_id(at);
|
||||
sh.set_cell(at, Cell { value: Value::Number(1.0), formula: None, style, py: Some(Arc::new(PyCode::new(code))) });
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
fn code(s: &Session, sheet: usize, cell: &str) -> Option<String> {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.sheet(sheet).unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_ref()).map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_py_formulas_points_to_python_set() {
|
||||
let mut s = s();
|
||||
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=PY(\"1+1\",0)"})).unwrap_err();
|
||||
assert!(e.to_string().contains("python.set"), "{e}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "A1", "input": "=py"})).is_err());
|
||||
// Other functions starting with PY are not affected.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=PI()"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_or_clearing_a_python_cell_removes_its_code() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
plant(&mut s, 0, "A2", "2 + 2");
|
||||
plant(&mut s, 0, "A3", "3 + 3");
|
||||
let got = s.execute("cell.get", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(got["input"], "1 + 1");
|
||||
assert_eq!(got["python"]["code"], "1 + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "hello"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
s.execute("edit.clearFormats", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2").as_deref(), Some("2 + 2"), "clearing formats keeps the code");
|
||||
s.execute("edit.clearContents", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2"), None);
|
||||
s.execute("edit.clearAll", json!({"range": "A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3"), None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("3 + 3"), "undo brings the code back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_fill_and_sort_carry_the_code_and_paste_values_drops_it() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "xl('B1') * 2");
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "C1").as_deref(), Some("xl('B1') * 2"), "the code is copied as it is");
|
||||
s.execute("edit.pasteSpecial", json!({"at": "D1", "what": "values"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "D1"), None);
|
||||
assert_eq!(super::v(&s, "D1"), Value::Number(1.0), "the cached value is pasted");
|
||||
s.execute("edit.fillDown", json!({"range": "A1:A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("xl('B1') * 2"));
|
||||
// Sorting moves Python cells with their rows.
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[3], [1], [2]]})).unwrap();
|
||||
plant(&mut s, 0, "F1", "'three'");
|
||||
plant(&mut s, 0, "F2", "'one'");
|
||||
s.execute("data.sort", json!({"range": "E1:F3", "keys": [{"column": "E"}]})).unwrap();
|
||||
assert_eq!(code(&s, 0, "F1").as_deref(), Some("'one'"));
|
||||
assert_eq!(code(&s, 0, "F3").as_deref(), Some("'three'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xl_references_follow_inserts_moves_and_renames() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "C5", &format!("a = xl(\"A1:A3\")\nb = xl('{other}!B2', headers=True)\nc = xl(dynamic)\n# xl(\"A1\") stays in comments"));
|
||||
s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap();
|
||||
let c = code(&s, 0, "C6").unwrap();
|
||||
assert!(c.starts_with("a = xl(\"A1:A4\")\n"), "{c}");
|
||||
assert!(c.contains(&format!("xl('{other}!B2', headers=True)")), "{c}");
|
||||
assert!(c.contains("# xl(\"A1\") stays"), "{c}");
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().contains("xl('Data!B2', headers=True)"));
|
||||
// Cutting the cells a reference names moves the reference with them.
|
||||
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"H1:H4\")"), "{:?}", code(&s, 0, "C6"));
|
||||
// Deleting the referenced cells leaves #REF!, which fails when the code runs.
|
||||
// The selection is elsewhere, so only the named column goes.
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "H:H"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"#REF!\")"), "{:?}", code(&s, 0, "C6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_to_a_name_that_needs_quotes_rewrites_single_quoted_references() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "A1", &format!("a = xl('{other}!B2')\nb = xl(\"{other}!C3\")"));
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Q4 2026"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"'Q4 2026'!B2\")\nb = xl(\"'Q4 2026'!C3\")");
|
||||
// A name with both quotes in it: the literal's own quote is escaped.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Bob's \"Q4\""})).unwrap();
|
||||
let c = code(&s, 0, "A1").unwrap();
|
||||
assert_eq!(c, "a = xl(\"'Bob''s \\\"Q4\\\"'!B2\")\nb = xl(\"'Bob''s \\\"Q4\\\"'!C3\")");
|
||||
let refs: Vec<Option<String>> = gridcraft_model::python::xl_calls(&c).into_iter().map(|x| x.reference).collect();
|
||||
assert_eq!(refs, [Some("'Bob''s \"Q4\"'!B2".to_string()), Some("'Bob''s \"Q4\"'!C3".to_string())]);
|
||||
// And back to a plain name.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"Data!B2\")\nb = xl(\"Data!C3\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_to_another_sheet_keeps_references_on_the_source_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let (src, dest) = {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
(wb.sheet(0).unwrap().name.clone(), wb.sheet(1).unwrap().name.clone())
|
||||
};
|
||||
plant(&mut s, 0, "A1", "a = xl(\"B5\")\nb = xl('A2')");
|
||||
plant(&mut s, 0, "A2", "1");
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
// B5 stays on the source sheet; A2 moved with the cut, so it follows to the destination.
|
||||
assert_eq!(code(&s, 1, "C1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
// Within one sheet nothing is qualified.
|
||||
s.execute("edit.cut", json!({"range": "C1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "E1"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "E1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
// A copy to another sheet still carries the code as it is.
|
||||
plant(&mut s, 0, "A9", "xl(\"B5\")");
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A9"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A9"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "A9").as_deref(), Some("xl(\"B5\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_seek_and_solver_refuse_to_change_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "2");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
let e = s.execute("data.goalSeek", json!({"set": "B1", "to": 10, "changing": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("must contain a value"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"), "the code is kept");
|
||||
// The set cell must be a formula: a Python cell isn't one this build can recalculate.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap();
|
||||
assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 10, "changing": "C1"})).is_err());
|
||||
let e = s.execute("data.solver", json!({"objective": "B1", "goal": "max", "variables": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("constants"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_keeps_a_py_piece_as_text() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7"));
|
||||
s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap();
|
||||
// The default text qualifier takes the quotes away, as in Excel.
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared");
|
||||
assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_cells_survive_native_json() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "B2", "x = 1\nx");
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let json = serde_json::to_string(&*wb).unwrap();
|
||||
let back: gridcraft_model::Workbook = serde_json::from_str(&json).unwrap();
|
||||
let cell = back.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).unwrap();
|
||||
assert_eq!(cell.py.as_ref().unwrap().code, "x = 1\nx");
|
||||
// Cells written before Python existed have no `py` field.
|
||||
let plain: Cell = serde_json::from_value(json!({"value": {"t": "Number", "v": 2.0}, "formula": null, "style": 0})).unwrap();
|
||||
assert!(plain.py.is_none());
|
||||
assert!(!serde_json::to_string(&plain).unwrap().contains("py"), "no field is written for ordinary cells");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_errors_parse_and_have_error_type_numbers() {
|
||||
use gridcraft_core::CellError;
|
||||
for (text, e) in
|
||||
[("#PYTHON!", CellError::Python), ("#BUSY!", CellError::Busy), ("#TIMEOUT!", CellError::Timeout), ("#BLOCKED!", CellError::Blocked)]
|
||||
{
|
||||
assert_eq!(CellError::parse(text), Some(e));
|
||||
assert_eq!(e.as_str(), text);
|
||||
}
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=ERROR.TYPE(#BLOCKED!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Number(11.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=ISERROR(#PYTHON!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "formulas"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
assert_eq!(r["selection"], "A1:A2", "{r}");
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "constants"})).unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["selection"], "A3", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() {
|
||||
let mut s = s();
|
||||
// F2 shows 1, its cached value: a step that enters 1 there still replaces the code.
|
||||
plant(&mut s, 0, "F2", "1");
|
||||
// H2's code shows #BLOCKED!, as any other new code there would.
|
||||
s.execute("python.set", json!({"cell": "H2", "code": "'a'"})).unwrap();
|
||||
// J2's result spills over J2:J3. A step that writes J3 blocks it, which doesn't write J2.
|
||||
s.execute("python.set", json!({"cell": "J2", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("J2").unwrap()).unwrap();
|
||||
let data = vec![Value::Number(5.0), Value::Number(6.0)];
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, data).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "J3"), Value::Number(6.0));
|
||||
super::macros::put(
|
||||
&mut s,
|
||||
json!({"name": "Py", "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "F2", "input": "1"}},
|
||||
{"command": "python.set", "params": {"cell": "H2", "code": "'b'"}},
|
||||
{"command": "cell.set", "params": {"cell": "J3", "input": "x"}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Py"})).unwrap();
|
||||
assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["F2", "H2", "J3"]), "{c}");
|
||||
assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["F2", "H2"]), "{c}");
|
||||
let e = s.execute("macro.run", json!({"name": "Py", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_edits_a_python_cells_code_and_keeps_it_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "x = 1\nx");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).object = true;
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
let r = s.execute("edit.replace", json!({"what": "x", "with": "y"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("y = 1\ny"));
|
||||
let d = s.doc().unwrap();
|
||||
let cell = d.wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert!(cell.py.as_ref().unwrap().object, "the return type is kept");
|
||||
assert_eq!(cell.value, Value::Error(gridcraft_core::CellError::Blocked));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("x = 1\nx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_leaves_python_code_that_would_no_longer_fit() {
|
||||
let mut s = s();
|
||||
let long = "a".repeat(gridcraft_model::python::MAX_FORMULA_CHARS / 2);
|
||||
plant(&mut s, 0, "A1", &long);
|
||||
let r = s.execute("edit.replace", json!({"what": "a", "with": "bb"})).unwrap();
|
||||
assert_eq!(r["replaced"], 0);
|
||||
assert_eq!(r["skipped"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_hides_its_code() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
let hidden = |s: &Session, a: &str| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
crate::display::formula_hidden(&d.wb, sh, CellRef::parse(a).unwrap())
|
||||
};
|
||||
assert!(!hidden(&s, "A1"), "an unprotected sheet hides nothing");
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(hidden(&s, "A1"), "Hidden on a protected sheet hides the code, as it hides a formula");
|
||||
assert!(!hidden(&s, "B1"));
|
||||
// Editing it is refused where a formula's would be: the cell is locked.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "2"})).is_err());
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("secret = 1\nsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_merge_keeps_only_the_upper_left_cells_python_code() {
|
||||
let mut s = s();
|
||||
for (cmd, row) in [("home.mergeAcross", 1), ("home.mergeCells", 2), ("home.mergeCenter", 3)] {
|
||||
plant(&mut s, 0, &format!("A{row}"), "'kept'");
|
||||
plant(&mut s, 0, &format!("B{row}"), "'merged away'");
|
||||
s.execute(cmd, json!({"range": format!("A{row}:B{row}")})).unwrap();
|
||||
assert_eq!(code(&s, 0, &format!("A{row}")).as_deref(), Some("'kept'"), "{cmd}");
|
||||
assert_eq!(code(&s, 0, &format!("B{row}")), None, "{cmd}: the hidden cell is no Python cell");
|
||||
assert_eq!(super::v(&s, &format!("B{row}")), Value::Empty, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmerging_lets_a_blocked_python_result_spill_again() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Number(6.0));
|
||||
s.execute("home.mergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill), "a merged cell blocks the spill");
|
||||
s.execute("home.unmergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A1"), super::v(&s, "A2")), (Value::Number(5.0), Value::Number(6.0)), "it spills again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_model_asks_before_it_writes_over_a_python_cell_that_shows_nothing() {
|
||||
let mut s = s();
|
||||
for (cell, input) in [("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
// H2 is a Python cell whose cached value is empty: its code is still content.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(
|
||||
CellRef::parse("H2").unwrap(),
|
||||
Cell { value: Value::Empty, formula: None, style: Default::default(), py: Some(Arc::new(PyCode::new("None"))) },
|
||||
);
|
||||
}
|
||||
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
|
||||
let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_err().to_string();
|
||||
assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "H2").as_deref(), Some("None"), "nothing was written");
|
||||
}
|
||||
|
||||
/// A1 is a Python cell whose kept result `[5, 6]` spills over A1:A2, and B1 reads A2.
|
||||
pub(super) fn spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A2)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[7, 8]"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty, "new code has no result yet");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
// The same code again keeps its result, and its spill.
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A2"), super::v(&s, "B1")), (Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_in_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
let r = s.execute("edit.replace", json!({"what": "[5,", "with": "[7,"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty);
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_pasting_over_or_blocking_a_python_spill_recalculates_its_readers() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("edit.clearContents", json!({"range": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "clear");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "9"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "paste");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("home.mergeCells", json!({"range": "A2:A3"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "merge");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0), "undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "redo");
|
||||
}
|
||||
|
||||
/// `prefix` and a comment padded to the longest code that still fits in one cell.
|
||||
fn at_the_limit(prefix: &str) -> String {
|
||||
let with = |n: usize| format!("{prefix}\n#{}", "x".repeat(n));
|
||||
let (mut lo, mut hi) = (0, gridcraft_model::python::MAX_FORMULA_CHARS);
|
||||
while lo < hi {
|
||||
let mid = (lo + hi).div_ceil(2);
|
||||
if PyCode::new(&with(mid)).fits() { lo = mid } else { hi = mid - 1 }
|
||||
}
|
||||
with(lo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_make_python_code_too_long_is_refused() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
let refused = |e: crate::EngineError, cell: &str| {
|
||||
let e = e.to_string();
|
||||
assert!(e.contains(&format!("!{cell} longer than Excel's 8192-character")) && e.contains("nothing was changed"), "{e}");
|
||||
};
|
||||
// A sheet rename that lengthens a reference.
|
||||
let a1 = at_the_limit(&format!("a = xl(\"{other}!B2\")"));
|
||||
plant(&mut s, 0, "A1", &a1);
|
||||
refused(s.execute("sheet.rename", json!({"sheet": 1, "name": "A much longer sheet name"})).unwrap_err(), "A1");
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(1).unwrap().name, other);
|
||||
// One of the same length is made.
|
||||
let same = "Z".repeat(other.chars().count());
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": same})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1.replace(&other, &same));
|
||||
// Code short of the limit still follows each edit.
|
||||
plant(&mut s, 0, "A7", "d = xl(\"Z1\")");
|
||||
s.execute("selection.set", json!({"range": "B10"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "Y:Y"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A7").unwrap(), "d = xl(\"AA1\")");
|
||||
// An inserted column that turns Z1 into AA1.
|
||||
let a3 = at_the_limit("b = xl(\"Z1\")");
|
||||
plant(&mut s, 0, "A3", &a3);
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "kept"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
refused(s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap_err(), "A3");
|
||||
assert_eq!(code(&s, 0, "A3").unwrap(), a3);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::parse("D1").unwrap()), Value::text("kept"));
|
||||
// A cut to another sheet, which qualifies B9 with the source sheet.
|
||||
let a5 = at_the_limit("c = xl(\"B9\")");
|
||||
plant(&mut s, 0, "A5", &a5);
|
||||
s.execute("edit.cut", json!({"range": "A5"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
refused(s.execute("edit.paste", json!({"at": "C1"})).unwrap_err(), "C1");
|
||||
assert_eq!(code(&s, 0, "A5").unwrap(), a5);
|
||||
assert_eq!(code(&s, 1, "C1"), None);
|
||||
}
|
||||
|
||||
/// Makes `cell` a Python cell whose cached value is empty, as a file's `PY` formula with no cached
|
||||
/// value reads.
|
||||
fn plant_empty(s: &mut Session, cell: &str) {
|
||||
plant(s, 0, cell, "x = 1");
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
if let Some(c) = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse(cell).unwrap()) {
|
||||
c.value = Value::Empty;
|
||||
}
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_code_with_an_empty_value_counts_as_content() {
|
||||
// A report or a PivotTable asks before replacing it.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 1], ["West", 2], ["East", 3]]})).unwrap();
|
||||
plant_empty(&mut s, "D2");
|
||||
let report = json!({"tool": "Descriptive Statistics", "range": "B1:B4", "labels": true, "output": "D1"});
|
||||
assert!(s.execute("data.analysisToolPak", report).is_err(), "the report asks for overwrite");
|
||||
plant_empty(&mut s, "H4");
|
||||
let pivot = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]}));
|
||||
assert!(pivot.is_err_and(|e| e.to_string().contains("already data")));
|
||||
assert_eq!((code(&s, 0, "D2"), code(&s, 0, "H4")), (Some("x = 1".into()), Some("x = 1".into())), "the code is kept");
|
||||
// Cells aren't pushed off the sheet over it.
|
||||
plant_empty(&mut s, "K1048576");
|
||||
assert!(s.execute("home.insertCells", json!({"range": "K1", "shift": "down"})).is_err());
|
||||
assert_eq!(code(&s, 0, "K1048576"), Some("x = 1".into()));
|
||||
// A table without a header row doesn't push it off the sheet either.
|
||||
s.execute("range.setValues", json!({"range": "X1", "values": [[1], [2]]})).unwrap();
|
||||
plant_empty(&mut s, "X1048576");
|
||||
assert!(s.execute("insert.table", json!({"range": "X1:X2", "header": false})).is_err());
|
||||
assert_eq!(code(&s, 0, "X1048576"), Some("x = 1".into()));
|
||||
// It is part of the data region.
|
||||
s.execute("range.setValues", json!({"range": "P1", "values": [[1], [""], [3]]})).unwrap();
|
||||
plant_empty(&mut s, "P2");
|
||||
let sh = s.doc().unwrap().wb.sheet(0).unwrap().clone();
|
||||
assert_eq!(crate::selection::current_region(&sh, CellRef::parse("P1").unwrap()).a1(), "P1:P3");
|
||||
// Pasted into part of a legacy array, it is refused like any content.
|
||||
s.execute("selection.set", json!({"range": "R1:R3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||
plant_empty(&mut s, "T1");
|
||||
s.execute("edit.copy", json!({"range": "T1"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "R2"})).is_err_and(|e| e.to_string().contains("part of an array")));
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The python.* commands.
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
|
||||
fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() {
|
||||
let mut s = s();
|
||||
let r = set(&mut s, "B1", "xl('A1') + 1");
|
||||
assert_eq!(r["cell"], "B1");
|
||||
assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked));
|
||||
let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap();
|
||||
assert_eq!(got["python"]["code"], "xl('A1') + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
assert_eq!(got["input"], "xl('A1') + 1");
|
||||
// The active cell by default.
|
||||
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
s.execute("python.set", json!({"code": "2"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2");
|
||||
// One undo step takes the cell back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_code_keeps_its_cached_result() {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
// As a file would have cached it.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap();
|
||||
let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap());
|
||||
py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true });
|
||||
cell.value = Value::Number(42.0);
|
||||
}
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result");
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object");
|
||||
set(&mut s, "A1", "21 * 3");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_refuses_bad_params_long_code_headers_and_protected_cells() {
|
||||
let mut s = s();
|
||||
for p in [
|
||||
json!({"cell": "A1"}),
|
||||
json!({"cell": "A1", "code": 5}),
|
||||
json!({"cell": "not a cell", "code": "1"}),
|
||||
json!({"cell": "A1", "code": "1", "output": "sideways"}),
|
||||
] {
|
||||
assert!(s.execute("python.set", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// The limit is the stored PY(…) formula's 8,192 characters.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err());
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap();
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err();
|
||||
assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}");
|
||||
// A table header is the column's name.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err());
|
||||
assert_eq!(v(&s, "D1"), Value::text("Score"));
|
||||
set(&mut s, "D2", "1");
|
||||
// A locked cell on a protected sheet refuses it.
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_over_a_spilling_formula_takes_the_spill_away() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty());
|
||||
}
|
||||
|
||||
/// A cached result as a file would have kept it on the cell: `[[1], [2], [3]]`, a list.
|
||||
fn cache_list(s: &mut crate::Session, cell: &str) {
|
||||
let at = gridcraft_core::CellRef::parse(cell).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let py = std::sync::Arc::make_mut(sh.cells.get_mut(at).unwrap().py.as_mut().unwrap());
|
||||
let arr = gridcraft_core::Array::new(3, 1, vec![1.0.into(), 2.0.into(), 3.0.into()]).map(Value::from).unwrap();
|
||||
py.output = Some(gridcraft_model::PyOutput { value: arr, type_name: "list".into(), preview: "[1, 2, 3]".into(), convertible: true });
|
||||
}
|
||||
|
||||
/// A1 holds `[1, 2, 3]` with its cached list spilled over A1:A3 as Excel values; B1 reads A3.
|
||||
fn cached_spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
cache_list(&mut s, "A1");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
s
|
||||
}
|
||||
|
||||
fn output_of(s: &mut crate::Session, cell: &str) -> serde_json::Value {
|
||||
s.execute("cell.get", json!({"cell": cell})).unwrap()["python"]["output"].clone()
|
||||
}
|
||||
|
||||
fn undo_depth(s: &mut crate::Session) -> Option<usize> {
|
||||
s.execute("history.list", json!({})).unwrap()["undo"].as_array().map(Vec::len)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_switches_the_return_type_and_drops_a_result_of_the_other_type() {
|
||||
let mut s = cached_spill_with_a_reader();
|
||||
// The mode it already has changes nothing and adds no undo step.
|
||||
let undo = undo_depth(&mut s);
|
||||
let r = s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(r["output"], "value");
|
||||
assert_eq!(undo_depth(&mut s), undo);
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0), "the kept result still spills");
|
||||
// Without `mode` it switches. The cached list is an Excel value's result: as with python.set
|
||||
// and a new return type, the cell has no result until it runs, and its old spill goes with
|
||||
// its readers recalculated.
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let r = s.execute("python.output", json!({})).unwrap();
|
||||
assert_eq!(r["output"], "object");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty, "the old spill goes");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "and what read it is recalculated");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["code"], "[1, 2, 3]", "the code stays");
|
||||
// The same as python.set with the same code and the other return type.
|
||||
let mut by_set = cached_spill_with_a_reader();
|
||||
by_set.execute("python.set", json!({"cell": "A1", "code": "[1, 2, 3]", "output": "object"})).unwrap();
|
||||
for c in ["A1", "A3", "B1"] {
|
||||
assert_eq!(v(&s, c), v(&by_set, c), "{c}");
|
||||
}
|
||||
assert_eq!(output_of(&mut s, "A1"), output_of(&mut by_set, "A1"));
|
||||
// Switching back brings no result back: the dropped one belonged to the old type.
|
||||
s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
// Each switch is one undo step, and undoing the first brings the result and its spill back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
// A cell with no result keeps showing #BLOCKED!.
|
||||
set(&mut s, "C1", "x = 1");
|
||||
s.execute("python.output", json!({"cell": "C1"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(output_of(&mut s, "C1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_table_headers_and_locked_cells_as_set_does() {
|
||||
let mut s = s();
|
||||
// A Python cell in a table's header row (as a file could hold one): python.set refuses to
|
||||
// put code there, and python.output refuses to change it.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(gridcraft_core::CellRef::parse("D1").unwrap()).unwrap();
|
||||
cell.py = Some(std::sync::Arc::new(gridcraft_model::PyCode::new("'Score'")));
|
||||
}
|
||||
let e = s.execute("python.output", json!({"cell": "D1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("table header"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "D1"), "value");
|
||||
// A locked cell on a protected sheet refuses it; an unlocked one formatted Hidden takes it,
|
||||
// as it takes python.set.
|
||||
set(&mut s, "F1", "1");
|
||||
set(&mut s, "G1", "2");
|
||||
s.execute("home.formatCells", json!({"range": "G1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.output", json!({"cell": "F1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "F1"), "value");
|
||||
s.execute("python.set", json!({"cell": "G1", "code": "2"})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "G1"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "G1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_cells_without_python_and_bad_modes() {
|
||||
let mut s = s();
|
||||
// Empty params: the active cell, which holds no Python code.
|
||||
let e = s.execute("python.output", json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't a Python cell"), "{e}");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap();
|
||||
assert!(s.execute("python.output", json!({"cell": "B1"})).is_err());
|
||||
set(&mut s, "A1", "1");
|
||||
for p in [json!({"cell": "A1", "mode": "sideways"}), json!({"cell": "A1", "mode": 1}), json!({"cell": "nowhere"})] {
|
||||
assert!(s.execute("python.output", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "value");
|
||||
// python.set refuses a return type that isn't text too, rather than ignoring it.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "1", "output": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_switch_keeps_the_return_type_it_set() {
|
||||
// python.output without `mode` switches, so a recording keeps the return type the switch set
|
||||
// (as Bold records `on`): played back on a cell that already has it, nothing switches back.
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
let m = s.execute("macro.get", json!({"name": "Rec"})).unwrap();
|
||||
let m = crate::macros::parse_macro(&m).unwrap();
|
||||
let steps = crate::macros::command_steps(&m.steps);
|
||||
let step = steps.iter().find(|(_, id, _)| *id == "python.output").map(|(_, _, p)| (*p).clone());
|
||||
assert_eq!(step.and_then(|p| p.get("mode").cloned()), Some(json!("object")));
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
}
|
||||
// A run inside a script is recorded the same way.
|
||||
s.execute("automate.newScript", json!({"name": "Switch", "commands": [{"command": "python.output", "params": {"cell": "A1"}}]})).unwrap();
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Switch"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec2"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec2", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Python cells in files: native JSON, and xlsx as `_xlfn._xlws.PY(index,0|1)` with the code in
|
||||
//! the Python part ([MS-XLSX] 2.1.23, 2.3.10) and cached values. Nothing here runs Python: results
|
||||
//! are cached ones, as a file would hold them.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, PyOutput, Workbook};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{Session, Trust};
|
||||
|
||||
fn py(s: &mut Session, cell: &str, code: &str) {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap();
|
||||
}
|
||||
|
||||
/// Gives a Python cell a result, as running it (or a file's cache) would.
|
||||
fn cache(s: &mut Session, cell: &str, value: Value, type_name: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let c = sh.cells.get_mut(CellRef::parse(cell).unwrap()).unwrap();
|
||||
let p = Arc::make_mut(c.py.as_mut().unwrap());
|
||||
p.output = Some(PyOutput { value, type_name: type_name.into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
|
||||
fn array(rows: usize, cols: usize, data: Vec<Value>) -> Value {
|
||||
Value::Array(Arc::new(Array::new(rows, cols, data).unwrap()))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
let r = s.execute("file.saveBytes", json!({"format": format})).unwrap();
|
||||
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) {
|
||||
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
fn sheet_xml(bytes: &[u8]) -> String {
|
||||
part(bytes, "xl/worksheets/sheet1.xml")
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to each XML part (by name), and `add` added.
|
||||
fn with_parts(bytes: &[u8], edit: impl Fn(&str, &str) -> String, add: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if let Ok(text) = std::str::from_utf8(&data) {
|
||||
data = edit(&name, text).into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
for (name, text) in add {
|
||||
out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to the first sheet's XML.
|
||||
fn with_sheet_xml(bytes: &[u8], edit: impl Fn(&str) -> String) -> Vec<u8> {
|
||||
with_parts(bytes, |name, text| if name == "xl/worksheets/sheet1.xml" { edit(text) } else { text.to_string() }, &[])
|
||||
}
|
||||
|
||||
/// `xml` with its `<sheetData>` replaced by `data`.
|
||||
fn replace_sheet_data(xml: &str, data: &str) -> String {
|
||||
match (xml.find("<sheetData"), xml.find("</sheetData>"), xml.find("<sheetData/>")) {
|
||||
(Some(a), Some(b), _) => format!("{}{data}{}", &xml[..a], &xml[b + "</sheetData>".len()..]),
|
||||
(_, _, Some(a)) => format!("{}{data}{}", &xml[..a], &xml[a + "<sheetData/>".len()..]),
|
||||
_ => panic!("no sheetData in {xml}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty workbook's package with `data` as its first sheet's cells and, when `scripts` is
|
||||
/// given, a Python part holding them (the relationship and the content type included), as
|
||||
/// [MS-XLSX] 2.1.23 describes it.
|
||||
fn excel_shaped(data: &str, scripts: Option<&[&str]>) -> Vec<u8> {
|
||||
let mut s = s();
|
||||
let plain = save(&mut s, "xlsx");
|
||||
let Some(scripts) = scripts else { return with_sheet_xml(&plain, |xml| replace_sheet_data(xml, data)) };
|
||||
let codes: String = scripts.iter().map(|c| format!("<pythonScript><code>{c}</code></pythonScript>")).collect();
|
||||
let python = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><environmentDefinition id=\"{{00000000-0000-0000-0000-000000000000}}\"/><pythonScripts>{codes}</pythonScripts></python>"
|
||||
);
|
||||
const REL: &str =
|
||||
"<Relationship Id=\"rIdPy\" Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python/python1.xml\"/>";
|
||||
const TYPE: &str = "<Override PartName=\"/xl/python/python1.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>";
|
||||
let edit = |name: &str, text: &str| match name {
|
||||
"xl/worksheets/sheet1.xml" => replace_sheet_data(text, data),
|
||||
"xl/_rels/workbook.xml.rels" => text.replace("</Relationships>", &format!("{REL}</Relationships>")),
|
||||
"[Content_Types].xml" => text.replace("</Types>", &format!("{TYPE}</Types>")),
|
||||
_ => text.to_string(),
|
||||
};
|
||||
with_parts(&plain, edit, &[("xl/python/python1.xml", &python)])
|
||||
}
|
||||
|
||||
fn code(s: &Session, cell: &str) -> Option<PyCode> {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_deref()).cloned()
|
||||
}
|
||||
|
||||
/// A workbook with a value, a formula over a Python cell, a spilling table, an object, long code
|
||||
/// with quotes and line breaks, and an error.
|
||||
fn build() -> (Session, String) {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
|
||||
py(&mut s, "B1", "xl('A1') * 2");
|
||||
cache(&mut s, "B1", Value::Number(42.0), "int");
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "=B1+1"})).unwrap();
|
||||
py(&mut s, "C1", "[[1, 'two'], [3, \"four\"]]");
|
||||
cache(&mut s, "C1", array(2, 2, vec![1.0.into(), Value::text("two"), 3.0.into(), Value::text("four")]), "list");
|
||||
s.execute("python.set", json!({"cell": "E1", "code": "[1, 2]", "output": "object"})).unwrap();
|
||||
cache(&mut s, "E1", array(2, 1, vec![1.0.into(), 2.0.into()]), "list");
|
||||
let long = format!("# {}\ntext = \"say \\\"hi\\\"\"\nlen(text)", "x".repeat(7_900));
|
||||
assert!(PyCode::new(&long).fits());
|
||||
py(&mut s, "F1", &long);
|
||||
cache(&mut s, "F1", Value::Number(8.0), "int");
|
||||
py(&mut s, "G1", "1/0");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap();
|
||||
sh.cells.get_mut(CellRef::parse("G1").unwrap()).unwrap().value = Value::Error(CellError::Python);
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "E1"), Value::text("list"), "a Python object shows its type");
|
||||
assert_eq!(v(&s, "E2"), Value::Empty, "and doesn't spill");
|
||||
(s, long)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_stores_the_code_in_the_python_part_with_cached_values() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let xml = sheet_xml(&bytes);
|
||||
// The formula names the cell's script by index, in the order the cells are written: B1, C1,
|
||||
// E1, F1, G1.
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,0)</f><v>42</v>"), "{xml}");
|
||||
assert!(xml.contains(">_xlfn._xlws.PY(2,1)</f>"), "the Python object cell has return type 1: {xml}");
|
||||
assert!(!xml.contains("xl('A1')"), "the code is not in the sheet");
|
||||
let python = part(&bytes, "xl/python.xml");
|
||||
assert!(
|
||||
python.contains("<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><pythonScripts><pythonScript><code>xl('A1') * 2</code></pythonScript>"),
|
||||
"{python}"
|
||||
);
|
||||
assert_eq!(python.matches("<pythonScript>").count(), 5);
|
||||
assert!(
|
||||
part(&bytes, "[Content_Types].xml").contains("<Override PartName=\"/xl/python.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>")
|
||||
);
|
||||
assert!(
|
||||
part(&bytes, "xl/_rels/workbook.xml.rels")
|
||||
.contains("Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python.xml\"")
|
||||
);
|
||||
assert!(xml.contains("t=\"array\" ref=\"C1:D2\""), "the spill is saved like a dynamic array's: {xml}");
|
||||
assert!(xml.contains("#VALUE!") && !xml.contains("#PYTHON!"), "Python errors are cached as #VALUE!");
|
||||
|
||||
open(&mut s, "book.xlsx", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// Cached values show, spills included, and the code is back.
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
assert_eq!(code(&s, "B1").unwrap().code, "xl('A1') * 2");
|
||||
assert!(code(&s, "E1").unwrap().object);
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long, "long code with quotes and line breaks survives");
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Value));
|
||||
// A blocker typed into the spill area gives #SPILL!; clearing it spills again, without
|
||||
// running anything.
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Spill));
|
||||
s.execute("edit.clearContents", json!({"range": "D2"})).unwrap();
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
// Saved again, the file holds the same formulas and scripts.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(sheet_xml(&again).contains("<f>_xlfn._xlws.PY(0,0)</f>"));
|
||||
assert_eq!(part(&again, "xl/python.xml"), python);
|
||||
// A workbook without Python cells has no Python part.
|
||||
let mut plain = super::s();
|
||||
assert!(!save(&mut plain, "xlsx").windows(10).any(|w| w == b"python.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_authored_python_cells_are_read_as_python() {
|
||||
// A workbook as [MS-XLSX] 2.3.10 describes it: `_xlfn._xlws.PY(index,0|1,params…)` naming a
|
||||
// script of the Python part, whose code names the parameters as `%Px%`, with a spilled result
|
||||
// cached.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A2\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"B1\" t=\"str\"><f>_xlfn._xlws.PY(1,1,C1:C3)</f><v>DataFrame</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c></row></sheetData>";
|
||||
let bytes = excel_shaped(data, Some(&["[1, 2]", "df = xl(\"%P2%\", headers=True)\ndf.describe()"]));
|
||||
let mut s = s();
|
||||
open(&mut s, "excel.xlsx", &bytes);
|
||||
let a1 = code(&s, "A1").expect("an Excel Python cell is a Python cell");
|
||||
assert_eq!(a1.code, "[1, 2]");
|
||||
assert!(!a1.object);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1.0), Value::Number(2.0)), "its spilled values are kept");
|
||||
let cell = s.doc().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().clone();
|
||||
assert!(cell.formula.is_none());
|
||||
let b1 = code(&s, "B1").unwrap();
|
||||
assert_eq!(b1.code, "df = xl(\"C1:C3\", headers=True)\ndf.describe()", "a parameter is put back as its reference");
|
||||
assert!(b1.object);
|
||||
assert_eq!(v(&s, "B1"), Value::text("DataFrame"));
|
||||
// Saved by GridCraft, the code goes back into a Python part, the reference as text.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(part(&again, "xl/python.xml").contains("<code>df = xl(\"C1:C3\", headers=True)\ndf.describe()</code>"));
|
||||
// The string-literal form (other applications, files GridCraft wrote before) still reads.
|
||||
let literal = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(\"1 + 1\",1)</f><v>2</v></c></row></sheetData>";
|
||||
open(&mut s, "literal.xlsx", &excel_shaped(literal, None));
|
||||
assert_eq!(code(&s, "A1").map(|p| (p.code, p.object)), Some(("1 + 1".to_string(), true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_py_formula_naming_a_missing_script_keeps_only_its_value() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(3,0)</f><v>6</v></c></row></sheetData>";
|
||||
for (name, bytes) in [("one.xlsx", excel_shaped(data, Some(&["5"]))), ("none.xlsx", excel_shaped(data, None))] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none() && b1.formula.is_none(), "{name}: no code to keep, and no formula #NAME? would replace");
|
||||
assert_eq!(b1.value, Value::Number(6.0), "{name}");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{name}: {warnings:?}");
|
||||
}
|
||||
let (wb, _) = crate::io::open_bytes("one.xlsx", &excel_shaped(data, Some(&["5"]))).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
}
|
||||
|
||||
/// `bytes` with the part `name` stored uncompressed and one byte of its data changed, so the
|
||||
/// archive is intact but reading the part fails its checksum.
|
||||
fn with_damaged_part(bytes: &[u8], name: &str, marker: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let entry = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
let options = if entry == name { options.compression_method(zip::CompressionMethod::Stored) } else { options };
|
||||
out.start_file(entry, options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let mut zipped = out.finish().unwrap().into_inner();
|
||||
let at = zipped.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("the marker is stored as it is");
|
||||
zipped[at + 1] ^= 0x20;
|
||||
zipped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><v>9</v></c></row></sheetData>";
|
||||
let good = excel_shaped(data, Some(&["5"]));
|
||||
// The part's data fails its checksum (a damaged archive entry).
|
||||
let damaged = with_damaged_part(&good, "xl/python/python1.xml", "<pythonScripts>");
|
||||
// The part is not well-formed XML, before its first script.
|
||||
let broken = |at: &'static str, with: &'static str| {
|
||||
with_parts(&good, move |name, text| if name == "xl/python/python1.xml" { text.replace(at, with) } else { text.to_string() }, &[])
|
||||
};
|
||||
let malformed = broken("<pythonScripts>", "<pythonScripts><!-- never closed");
|
||||
for (name, bytes) in [("damaged.xlsx", damaged), ("malformed.xlsx", malformed)] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert_eq!(a1.value, Value::Number(5.0), "{name}: the cached value is kept");
|
||||
assert!(a1.py.is_none() && a1.formula.is_none(), "{name}: as for a missing script, only the value");
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(9.0), "{name}: the rest of the sheet is read");
|
||||
let part = warnings.iter().filter(|w| w.contains("Python part is damaged")).count();
|
||||
assert_eq!(part, 1, "{name}: {warnings:?}");
|
||||
}
|
||||
// Damaged after its first script, the part keeps what it read before the damage.
|
||||
let (wb, warnings) = crate::io::open_bytes("late.xlsx", &broken("</pythonScripts>", "<!-- never closed")).unwrap();
|
||||
let a1 = wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().clone();
|
||||
assert_eq!(a1.py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("Python part is damaged")).count(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
|
||||
// A1's cached result spills over A1:A3; C1 is an ordinary dynamic array with a stale cache.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"C1\" cm=\"1\"><f t=\"array\" ref=\"C1:C3\">SEQUENCE(3)</f><v>1</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c><c r=\"C2\"><v>9</v></c></row>\
|
||||
<row r=\"3\"><c r=\"A3\"><v>3</v></c><c r=\"C3\"><v>9</v></c></row></sheetData>";
|
||||
let missing = excel_shaped(data, None);
|
||||
let damaged = with_damaged_part(&excel_shaped(data, Some(&["[1, 2, 3]"])), "xl/python/python1.xml", "<pythonScripts>");
|
||||
for (name, bytes) in [("missing.xlsx", missing), ("damaged.xlsx", damaged)] {
|
||||
let mut s = s();
|
||||
open(&mut s, name, &bytes);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
for a in ["A1", "A2", "A3"] {
|
||||
let cell = sh.cell(CellRef::parse(a).unwrap()).unwrap_or_else(|| panic!("{name}: {a} is stored"));
|
||||
assert!(cell.py.is_none() && cell.formula.is_none(), "{name}: {a} is a plain value");
|
||||
}
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "{name}: no live spill");
|
||||
// The ordinary formula fills its spill again, over the stale cached values.
|
||||
assert_eq!((v(&s, "C2"), v(&s, "C3")), (Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
assert!(sh.cell(CellRef::parse("C2").unwrap()).is_none(), "{name}: C2 is the formula's spill, not a cell");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_whose_blank_cells_have_no_records_is_still_a_spill() {
|
||||
// A1's result spills over A1:A3, and the file holds no records for the blank A2 and A3.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c></row></sheetData>";
|
||||
let mut s = s();
|
||||
open(&mut s, "sparse.xlsx", &excel_shaped(data, Some(&["[1, None, None]"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(sh.spill_ranges.get(&a1), Some(&RangeRef::parse("A1:A3").unwrap()), "the range is recorded at its anchor");
|
||||
assert_eq!(sh.spill_anchor(CellRef::parse("A3").unwrap()), Some(a1));
|
||||
let kept = sh.cell(a1).and_then(|c| c.py.as_ref()).and_then(|p| p.output.as_ref()).map(|o| o.value.clone());
|
||||
assert_eq!(kept, Some(array(3, 1, vec![1.0.into(), Value::Empty, Value::Empty])), "the result is rebuilt over the whole range");
|
||||
// A formula entered in the spill takes the area, as with any spill.
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_range_larger_than_a_result_keeps_its_values_as_plain_values() {
|
||||
// C1:D600000 is 1,200,000 cells, more than a result may have but within what the reader
|
||||
// tracks; its cached values are kept as the file shows them, and nothing spills.
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let crafted = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:D600000\""));
|
||||
assert_ne!(crafted, bytes);
|
||||
open(&mut s, "crafted.xlsx", &crafted);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 2)), "{:?}", sh.spill_ranges);
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
let d2 = sh.cell(CellRef::parse("D2").unwrap()).expect("D2 is stored");
|
||||
assert!(d2.py.is_none() && d2.formula.is_none(), "a plain value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_spills_read_past_the_scan_budget_keep_their_values_as_plain_values() {
|
||||
// A1's result spills over A1:A5000. Four thousand ordinary dynamic arrays on row 1 make each
|
||||
// later plain cell's range lookup cost 4,001 checks, so the reader's scan budget runs out
|
||||
// part way down column A, and the rest of the spill is read as plain cells.
|
||||
let arrays: String = (1..=4_000u32)
|
||||
.map(|c| {
|
||||
let at = CellRef::new(0, c).a1();
|
||||
let below = CellRef::new(1, c).a1();
|
||||
format!("<c r=\"{at}\" cm=\"1\"><f t=\"array\" ref=\"{at}:{below}\">SEQUENCE(2)</f><v>1</v></c>")
|
||||
})
|
||||
.collect();
|
||||
let rows: String = (2..=5_000u32).map(|r| format!("<row r=\"{r}\"><c r=\"A{r}\"><v>{r}</v></c></row>")).collect();
|
||||
let data = format!(
|
||||
"<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A5000\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>{arrays}</row>{rows}</sheetData>"
|
||||
);
|
||||
let mut s = s();
|
||||
open(&mut s, "budget.xlsx", &excel_shaped(&data, Some(&["list(range(1, 5001))"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "no live spill");
|
||||
assert!(code(&s, "A1").is_some_and(|p| p.output.is_none()), "no result is rebuilt");
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A5000")), (Value::Number(2.0), Value::Number(5000.0)), "the values stay");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0), "the cell keeps its value, not #SPILL!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_data_python_code_reads_drops_its_kept_result() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "3"})).unwrap();
|
||||
py(&mut s, "C5", "xl(\"A1:A2\") * 2");
|
||||
cache(&mut s, "C5", array(2, 1, vec![2.0.into(), 4.0.into()]), "list");
|
||||
py(&mut s, "E5", "xl(\"A1:A3\")");
|
||||
cache(&mut s, "E5", Value::Number(3.0), "int");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C6"), Value::Number(4.0));
|
||||
// A move keeps the result: the reference still names the same data.
|
||||
s.execute("home.insertRows", json!({"rows": "1"})).unwrap();
|
||||
assert!(code(&s, "C6").is_some_and(|p| p.output.is_some() && !p.code.contains("A1:A2")), "{:?}", code(&s, "C6"));
|
||||
assert_eq!(v(&s, "C7"), Value::Number(4.0));
|
||||
assert!(code(&s, "E6").is_some_and(|p| p.output.is_some()));
|
||||
// Deleting the rows C6 reads leaves `#REF!`, and deleting part of E6's range shrinks it: in
|
||||
// both, the kept result no longer belongs to the code.
|
||||
s.execute("home.deleteRows", json!({"rows": "2:3"})).unwrap();
|
||||
let c4 = code(&s, "C4").expect("the Python cell moved up to C4");
|
||||
assert!(c4.code.contains("#REF!") && c4.output.is_none(), "{c4:?}");
|
||||
assert_eq!(v(&s, "C4"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "C5"), Value::Empty, "its spill is gone");
|
||||
let e4 = code(&s, "E4").expect("the Python cell moved up to E4");
|
||||
assert!(!e4.code.contains("#REF!") && e4.output.is_none(), "{e4:?}");
|
||||
assert_eq!(v(&s, "E4"), Value::Error(CellError::Blocked));
|
||||
// Rows inserted inside a range grow it: also new data.
|
||||
py(&mut s, "G1", "xl(\"A1:A2\")");
|
||||
cache(&mut s, "G1", Value::Number(2.0), "int");
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
assert!(code(&s, "G1").is_some_and(|p| p.output.is_none()), "{:?}", code(&s, "G1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_measured_after_its_escapes_are_decoded() {
|
||||
let max = gridcraft_model::python::MAX_FORMULA_CHARS;
|
||||
// 2,000 tabs written as `_x0009_`: 14,000 characters in the file, 2,004 as code, which fits.
|
||||
let escaped = format!("x = 1{}end", "_x0009_".repeat(2_000));
|
||||
assert!(escaped.chars().count() > max);
|
||||
// Code longer than a cell may hold once decoded is still refused.
|
||||
let long = format!("{}{}", "_x0009_".repeat(10), "y".repeat(max));
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(1,0)</f><v>2</v></c></row></sheetData>";
|
||||
let (wb, warnings) = crate::io::open_bytes("escapes.xlsx", &excel_shaped(data, Some(&[&escaped, &long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.clone());
|
||||
assert_eq!(a1, Some(format!("x = 1{}end", "\t".repeat(2_000))), "read in full");
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "too long once decoded: only the value");
|
||||
assert_eq!(b1.value, Value::Number(2.0));
|
||||
assert!(warnings.iter().any(|w| w.contains("B1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_cells_naming_one_long_script_take_from_one_budget() {
|
||||
// 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy.
|
||||
let long = "x".repeat(8_000);
|
||||
let mut data = String::from("<sheetData>");
|
||||
for r in 1..=20_000 {
|
||||
data.push_str(&format!("<row r=\"{r}\"><c r=\"A{r}\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c></row>"));
|
||||
}
|
||||
data.push_str("</sheetData>");
|
||||
let (wb, warnings) = crate::io::open_bytes("many.xlsx", &excel_shaped(&data, Some(&[&long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let with_code = sh.cells.iter().filter(|(_, c)| c.py.is_some()).count();
|
||||
assert_eq!(with_code, 64 * 1024 * 1024 / 8_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
assert_eq!(sh.value(CellRef::parse("A20000").unwrap()), Value::Number(1.0), "the others keep their values");
|
||||
}
|
||||
|
||||
/// Plants a cached spill over `range` (anchored at its start) with `value` at `at`, as a file
|
||||
/// read leaves it.
|
||||
fn cached_spill(sh: &mut gridcraft_model::Sheet, range: RangeRef, spilled: &[(CellRef, Value)]) {
|
||||
sh.spill_ranges.insert(range.start, range);
|
||||
for (c, v) in spilled {
|
||||
sh.spill.insert(*c, v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The value stored in the cell itself (not a spilled one), with whether it holds code or a
|
||||
/// formula.
|
||||
fn stored(sh: &gridcraft_model::Sheet, c: CellRef) -> (Value, bool) {
|
||||
sh.cell(c).map(|x| (x.value.clone(), x.py.is_some() || x.formula.is_some())).unwrap_or((Value::Empty, false))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_cut_by_the_budget_keeps_its_cached_spill_as_values() {
|
||||
// Five Python cells each with a cached 1,000 x 1,000 spill: the budget rebuilds four results;
|
||||
// the fifth cell keeps only its own value, and the values it spilled stay as plain values.
|
||||
let mut wb = Workbook::new();
|
||||
let bold = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
// A cell with its own content, and a formatted empty one, inside the spill.
|
||||
sh.set_cell(CellRef::new(2, col), Cell::value(Value::text("own")));
|
||||
sh.set_cell(CellRef::new(3, col), Cell::styled(Value::Empty, bold));
|
||||
let spilled = [
|
||||
(CellRef::new(1, col), Value::Number(7.0)),
|
||||
(CellRef::new(2, col), Value::Number(9.0)),
|
||||
(CellRef::new(3, col), Value::Number(6.0)),
|
||||
(CellRef::new(999, col + 999), Value::Number(8.0)),
|
||||
];
|
||||
cached_spill(sh, RangeRef::new(anchor, CellRef::new(999, col + 999)), &spilled);
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let mut cut = 0;
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
let py = sh.cell(anchor).and_then(|c| c.py.clone()).expect("the code is kept");
|
||||
let shown =
|
||||
[sh.value(CellRef::new(1, col)), sh.value(CellRef::new(2, col)), sh.value(CellRef::new(3, col)), sh.value(CellRef::new(999, col + 999))];
|
||||
assert_eq!(shown, [Value::Number(7.0), Value::text("own"), Value::Number(6.0), Value::Number(8.0)], "every result shows the same values");
|
||||
if py.output.is_some() {
|
||||
assert!(sh.spill_ranges.contains_key(&anchor), "a result within the budget keeps its spill");
|
||||
continue;
|
||||
}
|
||||
cut += 1;
|
||||
assert!(!sh.spill_ranges.contains_key(&anchor), "no live spill");
|
||||
assert!(sh.spill.range(anchor..=CellRef::new(999, col + 999)).all(|(c, _)| c.col < col || c.col > col + 999), "no spilled values left");
|
||||
assert_eq!(stored(sh, anchor), (Value::Number(1.0), true), "the anchor keeps its value and its code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, col)), (Value::Number(7.0), false), "a spilled value is a plain value now");
|
||||
assert_eq!(stored(sh, CellRef::new(999, col + 999)), (Value::Number(8.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(2, col)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(sh.style_id(CellRef::new(3, col)), bold, "nor a format");
|
||||
}
|
||||
assert_eq!(cut, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_too_long_for_a_cell_keeps_its_whole_cached_result_as_values() {
|
||||
let long = PyCode::new(&"x".repeat(gridcraft_model::python::MAX_FORMULA_CHARS));
|
||||
assert!(!long.fits());
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
// A1 spills over A1:B2; D1's own value is empty.
|
||||
sh.set_cell(CellRef::new(0, 0), Cell { py: Some(Arc::new(long.clone())), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.set_cell(CellRef::new(1, 1), Cell::value(Value::text("own")));
|
||||
let spilled = [(CellRef::new(0, 1), Value::Number(2.0)), (CellRef::new(1, 0), Value::Number(3.0)), (CellRef::new(1, 1), Value::Number(4.0))];
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1)), &spilled);
|
||||
sh.set_cell(CellRef::new(0, 3), Cell { py: Some(Arc::new(long)), ..Cell::default() });
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 3), CellRef::new(1, 3)), &[(CellRef::new(1, 3), Value::Number(5.0))]);
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert!(sh.spill_ranges.is_empty() && sh.spill.is_empty(), "no live spill: {:?}", sh.spill_ranges);
|
||||
assert_eq!(stored(sh, CellRef::new(0, 0)), (Value::Number(1.0), false), "the anchor keeps only its value");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 1)), (Value::Number(2.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 0)), (Value::Number(3.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 1)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 3)), (Value::Empty, false), "an empty anchor stays empty, without code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, 3)), (Value::Number(5.0), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_json_keeps_python_cells_and_their_results() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
open(&mut s, "book.json", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Python), "the native format keeps the exact error");
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long);
|
||||
let e1 = code(&s, "E1").unwrap();
|
||||
assert!(e1.object);
|
||||
assert_eq!(e1.output.unwrap().type_name, "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spill_range_claiming_the_whole_sheet_is_dropped_on_open() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let evil = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:XFD1048576\""));
|
||||
assert_ne!(evil, bytes);
|
||||
// Walking the claimed range would take hours; the test finishing is the check.
|
||||
open(&mut s, "evil.xlsx", &evil);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.spill_ranges.values().all(|r| r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64), "{:?}", sh.spill_ranges);
|
||||
// Edits afterwards don't walk the claimed range either.
|
||||
s.execute("cell.set", json!({"cell": "H9", "input": "1"})).unwrap();
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0), "the anchor keeps its cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilt_results_have_a_budget_across_the_workbook() {
|
||||
// Five Python cells each claiming a 1,000 x 1,000 spill: more than the budget in all.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let anchor = CellRef::new(0, i * 1_000);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(999, i * 1_000 + 999)));
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let rebuilt = sh.cells.iter().filter(|(_, c)| c.py.as_ref().is_some_and(|p| p.output.is_some())).count();
|
||||
assert_eq!(rebuilt as u64, crate::io::MAX_ADOPTED_RESULT_CELLS / 1_000_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_python_content_in_files_is_capped() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
// Make the Python cells hostile: code over the limit, and a result whose shape claims far
|
||||
// more cells than it holds (the array fields are public, so a crafted file can say anything).
|
||||
let mut wb: Workbook = serde_json::from_slice(&bytes).unwrap();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let b1 = sh.cells.get_mut(CellRef::parse("B1").unwrap()).unwrap();
|
||||
Arc::make_mut(b1.py.as_mut().unwrap()).code = "y".repeat(9_000);
|
||||
let c1 = sh.cells.get_mut(CellRef::parse("C1").unwrap()).unwrap();
|
||||
let out = Arc::make_mut(c1.py.as_mut().unwrap()).output.as_mut().unwrap();
|
||||
out.value = Value::Array(Arc::new(Array { rows: 100_000, cols: 100_000, data: vec![Value::Number(1.0)] }));
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("evil.json", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "code over the formula limit is dropped");
|
||||
assert_eq!(b1.value, Value::Number(42.0), "the cached value stays");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python cell")), "{warnings:?}");
|
||||
let c1 = sh.cell(CellRef::parse("C1").unwrap()).unwrap();
|
||||
assert!(c1.py.as_ref().unwrap().output.is_none(), "a malformed result is dropped");
|
||||
open(&mut s, "evil.json", &bytes);
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0));
|
||||
|
||||
// PY formulas from other formats become Python cells; non-literal PY calls stay formulas.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(CellRef::new(0, 0), Cell::formula(gridcraft_model::Formula::new("=PY(\"1+\"&\"1\",1)")));
|
||||
sh.set_cell(CellRef::new(1, 0), Cell::formula(gridcraft_model::Formula::new("=PY(A1,0)")));
|
||||
sh.set_cell(CellRef::new(2, 0), Cell::formula(gridcraft_model::Formula::new("=_xlfn._xlws.PY(\"2\",0)")));
|
||||
crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert_eq!(a1.py.as_ref().map(|p| (p.code.as_str(), p.object)), Some(("1+1", true)));
|
||||
assert!(a1.formula.is_none());
|
||||
assert!(sh.cell(CellRef::new(1, 0)).unwrap().py.is_none());
|
||||
assert_eq!(sh.cell(CellRef::new(2, 0)).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_result_never_spills() {
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "x");
|
||||
let bad = Array { rows: 4_000_000_000, cols: 4_000_000_000, data: vec![] };
|
||||
cache(&mut s, "A1", Value::Array(Arc::new(bad)), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn py_formulas_over_the_size_limit_keep_only_their_value() {
|
||||
let lits = |n: usize| {
|
||||
let mut v = vec![format!("\"{}\"", "x".repeat(255)); n / 255];
|
||||
if !n.is_multiple_of(255) {
|
||||
v.push(format!("\"{}\"", "x".repeat(n % 255)));
|
||||
}
|
||||
v.join("&")
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = |text: String| Cell { value: Value::text("cached"), ..Cell::formula(gridcraft_model::Formula::new(&text)) };
|
||||
sh.set_cell(CellRef::new(0, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_080))));
|
||||
sh.set_cell(CellRef::new(1, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_079))));
|
||||
let xlsx = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("long.xlsx", &xlsx).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let over = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert!(over.py.is_none() && over.formula.is_none(), "8,080 characters of code is over the limit");
|
||||
assert_eq!(over.value, Value::text("cached"));
|
||||
let within = sh.cell(CellRef::new(1, 0)).unwrap();
|
||||
assert_eq!(within.py.as_ref().map(|p| p.code.chars().count()), Some(8_079));
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_python_result_keeps_its_spill_as_values() {
|
||||
// A1's code is over the limit and B1's result is malformed; each had spilled down to row 3,
|
||||
// and C1's well-formed result keeps its spill.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let out = |value: Value| Some(PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
let col = array(3, 1, vec![Value::Number(7.0), Value::Number(8.0), Value::Number(9.0)]);
|
||||
let bad = Value::Array(Arc::new(Array { rows: 3, cols: 1, data: vec![Value::Number(7.0)] }));
|
||||
for (at, code, value) in [("A1", "y".repeat(9_000), col.clone()), ("B1", "b".into(), bad), ("C1", "c".into(), col)] {
|
||||
let at = CellRef::parse(at).unwrap();
|
||||
let py = PyCode { output: out(value), ..PyCode::new(&code) };
|
||||
sh.set_cell(at, Cell { value: Value::Number(7.0), formula: None, style: Default::default(), py: Some(Arc::new(py)) });
|
||||
let r = RangeRef::new(at, CellRef::new(2, at.col));
|
||||
sh.spill_ranges.insert(at, r);
|
||||
for c in r.iter().skip(1) {
|
||||
sh.spill.insert(c, Value::Number(8.0));
|
||||
}
|
||||
}
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (anchor, below) in [("A1", "A2"), ("B1", "B3")] {
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::parse(anchor).unwrap()), "{anchor}'s spill range stays");
|
||||
let cell = sh.cell(CellRef::parse(below).unwrap()).unwrap_or_else(|| panic!("{below} is a plain value"));
|
||||
assert_eq!((&cell.value, cell.py.is_none() && cell.formula.is_none()), (&Value::Number(8.0), true), "{below} keeps {anchor}'s value");
|
||||
assert_eq!(sh.value(CellRef::parse(anchor).unwrap()), Value::Number(7.0), "{anchor} keeps its value");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::parse("C2").unwrap()), Value::Number(8.0), "a sound result keeps its spill");
|
||||
// A save writes the kept values as plain values, and the sound spill as a spill.
|
||||
let xml = sheet_xml(&gridcraft_xlsx::write_xlsx(&wb).unwrap());
|
||||
assert!(xml.contains("<c r=\"A2\"><v>8</v></c>") && xml.contains("<c r=\"B3\"><v>8</v></c>"), "{xml}");
|
||||
assert!(xml.contains("r=\"C2\""), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_entered_in_a_python_spill_takes_the_area_as_a_formula_would() {
|
||||
// A1's cached array spills over A1:A3; =SEQUENCE(2) entered in A2 is not blocked by that old
|
||||
// spill (the Python cell is laid out again in the same pass), so it spills and A1 is blocked.
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "[1, 2, 3]");
|
||||
cache(&mut s, "A1", array(3, 1, vec![10.0.into(), 20.0.into(), 30.0.into()]), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(30.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=SEQUENCE(2)"})).unwrap();
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0)));
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut fresh = (**wb).clone();
|
||||
gridcraft_calc::Calc::new().recalc_all(&mut fresh);
|
||||
for c in ["A1", "A2", "A3"] {
|
||||
let c = CellRef::parse(c).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(c), fresh.active().unwrap().value(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_return_type_drops_the_kept_result() {
|
||||
// Same code, the other return type: the result belongs to the old type, so it goes.
|
||||
let mut s = super::python_cells::spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "object"})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(a1.object && a1.output.is_none(), "no result of the other type is kept");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A2"), Value::Empty, "no spill");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill_ranges.is_empty());
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "the reader of the old spill is recalculated");
|
||||
let xml = sheet_xml(&save(&mut s, "xlsx"));
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,1)</f><v>#VALUE!</v>") && !xml.contains("r=\"A2\""), "no old cached value is written: {xml}");
|
||||
// Undo brings the result back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(!a1.object && a1.output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
// Same code, same return type: the result is kept as before.
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "value"})).unwrap();
|
||||
assert!(code(&s, "A1").unwrap().output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
@@ -1135,3 +1135,29 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() {
|
||||
assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula");
|
||||
assert_eq!(trust(&s), Trust::FromFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_until_it_is_copied_or_read_from_a_file() {
|
||||
let mut s = s();
|
||||
let uids = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|sh| sh.uid.get()).collect::<Vec<_>>();
|
||||
let first = uids(&s)[0];
|
||||
// Renamed, moved, deleted and brought back by undo: the same sheet.
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("sheet.rename", json!({"sheet": "Sheet1", "name": "Data"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 1})).unwrap();
|
||||
let data = s.doc().unwrap().wb.sheet_index("Data").unwrap();
|
||||
assert_eq!(uids(&s)[data], first);
|
||||
s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap();
|
||||
assert!(!uids(&s).contains(&first));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(uids(&s).contains(&first), "undo brings the same sheet back");
|
||||
// A copy, or a new sheet of the same name, is another sheet.
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap();
|
||||
let all = uids(&s);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all.iter().collect::<std::collections::BTreeSet<_>>().len(), 3, "{all:?}");
|
||||
// Read from a file: new sheets.
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (read, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
assert!(read.sheets.iter().all(|sh| !all.contains(&sh.uid.get())));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_engine::calc::Calc;
|
||||
use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook};
|
||||
use gridcraft_engine::{EngineError, Session, command_specs, find_command, io};
|
||||
@@ -433,7 +433,12 @@ fn value_diff(a: &Workbook, b: &Workbook, what: &str) -> Option<String> {
|
||||
}
|
||||
for c in keys {
|
||||
let (va, vb) = (x.value(c), y.value(c));
|
||||
if !same_value(&va, &vb) {
|
||||
// A file caches the Python errors as #VALUE! (they aren't error codes of the file
|
||||
// format), in a Python cell and in the formulas that read one; reading it back runs
|
||||
// nothing.
|
||||
let python_error = matches!(va, Value::Error(CellError::Python | CellError::Busy | CellError::Timeout | CellError::Blocked))
|
||||
&& vb == Value::Error(CellError::Value);
|
||||
if !same_value(&va, &vb) && !python_error {
|
||||
let f = x.cell(c).and_then(|c| c.formula.as_ref()).map(|f| format!(" (formula ={})", f.text)).unwrap_or_default();
|
||||
return Some(format!("{}!{c}{f}: {va:?} in memory, {vb:?} after {what}", x.name));
|
||||
}
|
||||
@@ -787,6 +792,8 @@ const CURATED: &[(&str, usize)] = &[
|
||||
("formulas.defineName", 2),
|
||||
("formulas.deleteName", 1),
|
||||
("edit.replace", 1),
|
||||
("python.set", 3),
|
||||
("python.output", 2),
|
||||
];
|
||||
|
||||
fn generate(rng: &mut Rng, s: &Session) -> Step {
|
||||
@@ -820,6 +827,33 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json {
|
||||
p
|
||||
}
|
||||
"range.setValues" => json!({"range": near_cell(rng).a1(), "values": values_2d(rng, wb)}),
|
||||
"python.set" => {
|
||||
// Code with xl() references (which follow structural edits), quotes and line breaks;
|
||||
// sometimes long enough to meet the formula limit.
|
||||
let mut code = match rng.below(5) {
|
||||
0 => format!("xl(\"{}\") + 1", near_range(rng).a1()),
|
||||
1 => format!("a = xl('{}')\nb = \"q\\\"uote\"\na", near_cell(rng).a1()),
|
||||
2 => "[[1, 2], [3, 4]]".to_string(),
|
||||
3 => String::new(),
|
||||
_ => "\"".repeat(rng.int(1, 5000) as usize),
|
||||
};
|
||||
if rng.chance(5) {
|
||||
code.push_str(&"x".repeat(8_100));
|
||||
}
|
||||
let mut p = json!({"cell": near_cell(rng).a1(), "code": code});
|
||||
if rng.chance(30) {
|
||||
p["output"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"python.output" => {
|
||||
// Mostly the cells python.set writes to; a missing mode switches.
|
||||
let mut p = json!({"cell": near_cell(rng).a1()});
|
||||
if rng.chance(50) {
|
||||
p["mode"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"range.fill" => json!({"range": near_range(rng).a1(), "input": input(rng, wb)}),
|
||||
"selection.set" => {
|
||||
let mut p = json!({"range": range(rng)});
|
||||
|
||||
@@ -489,7 +489,8 @@ fn pathological_source_at_the_cap_finishes_in_bounded_time() {
|
||||
fill("\\pu{", "10^", "}"),
|
||||
fill("\\pu{", "kJ//", "}"),
|
||||
];
|
||||
let bound = Duration::from_secs(10);
|
||||
// A CI regression threshold with headroom for shared-runner variance, not a runtime deadline.
|
||||
let bound = Duration::from_secs(15);
|
||||
let mut slow = Vec::new();
|
||||
for s in &sources {
|
||||
let start = Instant::now();
|
||||
|
||||
@@ -6,6 +6,7 @@ use gridcraft_core::{RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::python::PyCode;
|
||||
use crate::style::StyleId;
|
||||
|
||||
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
|
||||
@@ -60,25 +61,45 @@ pub struct CellPicture {
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Cell {
|
||||
/// Constant value, or the cached result of the formula.
|
||||
/// Constant value, or the cached result of the formula or Python code.
|
||||
pub value: Value,
|
||||
pub formula: Option<Arc<Formula>>,
|
||||
pub style: StyleId,
|
||||
/// Python code, for a Python cell (which has no formula).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub py: Option<Arc<PyCode>>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn value(v: Value) -> Cell {
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT }
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
pub fn formula(f: Formula) -> Cell {
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
/// No value, no formula and the default style: storing it is pointless.
|
||||
/// A value cell with this style.
|
||||
pub fn styled(v: Value, style: StyleId) -> Cell {
|
||||
Cell { value: v, formula: None, style, py: None }
|
||||
}
|
||||
/// No value, no formula, no Python code and the default style: storing it is pointless.
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT && self.py.is_none()
|
||||
}
|
||||
/// What the formula bar shows.
|
||||
/// Whether the cell holds content (a value, a formula or Python code), formats aside.
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.value.is_empty() || self.formula.is_some() || self.py.is_some()
|
||||
}
|
||||
/// Removes the content (value, formula and Python code), keeping the style.
|
||||
pub fn clear_content(&mut self) {
|
||||
self.value = Value::Empty;
|
||||
self.formula = None;
|
||||
self.py = None;
|
||||
}
|
||||
/// What the formula bar shows: a formula, a Python cell's code, or the value.
|
||||
pub fn input_text(&self) -> String {
|
||||
if let Some(py) = &self.py {
|
||||
return py.code.clone();
|
||||
}
|
||||
if let Some(f) = &self.formula {
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod features;
|
||||
pub mod macros;
|
||||
pub mod password;
|
||||
pub mod preserved;
|
||||
pub mod python;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
@@ -21,7 +22,8 @@ pub use features::*;
|
||||
pub use macros::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use python::{PyCode, PyOutput};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Python cells: the code a cell holds, its last result, and the text forms around it (the
|
||||
//! `PY("…",0|1)` formula of the function's syntax, and the `xl("…")` references inside it). An
|
||||
//! xlsx file keeps the code in its Python part instead (see the xlsx crate).
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is
|
||||
/// written as the PY formula of the function's syntax ([`PyCode::to_file_formula`]): quotes are
|
||||
/// doubled and every 255 characters add `"&"`, so the limit on the code itself depends on what the
|
||||
/// code holds.
|
||||
pub const MAX_FORMULA_CHARS: usize = 8_192;
|
||||
|
||||
/// The most cells a Python result may have (a crafted file can claim any size).
|
||||
pub const MAX_RESULT_CELLS: usize = 1_000_000;
|
||||
|
||||
/// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`).
|
||||
pub const FILE_PREFIX: &str = "_xlfn._xlws.";
|
||||
|
||||
/// Characters in one string literal of a `PY(…)` formula (Excel's limit for a literal).
|
||||
const CHUNK_CHARS: usize = 255;
|
||||
|
||||
/// A Python cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyCode {
|
||||
pub code: String,
|
||||
/// Show the result as a Python object (true) or convert it to cell values (false).
|
||||
pub object: bool,
|
||||
/// The last result, when the code ran without an error.
|
||||
pub output: Option<PyOutput>,
|
||||
/// The cell's inputs changed while calculation was manual: its value is out of date until
|
||||
/// the workbook is calculated.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A Python result kept on the cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyOutput {
|
||||
/// The result as cells (an array spills), or its preview as text when it has no cell form.
|
||||
pub value: Value,
|
||||
/// Python's name for the result's type.
|
||||
pub type_name: String,
|
||||
/// The result's `repr`, shortened.
|
||||
pub preview: String,
|
||||
/// Whether `value` is the result converted to cells.
|
||||
pub convertible: bool,
|
||||
}
|
||||
|
||||
impl PyCode {
|
||||
pub fn new(code: &str) -> PyCode {
|
||||
PyCode { code: normalize_code(code), ..PyCode::default() }
|
||||
}
|
||||
|
||||
/// What the cell shows for its result: cell values (an array spills from the cell), or, for a
|
||||
/// Python object, its type name. `None` before the code has run.
|
||||
pub fn result_value(&self) -> Option<Value> {
|
||||
let out = self.output.as_ref()?;
|
||||
let scalar = !matches!(out.value, Value::Array(_)) && out.convertible;
|
||||
Some(if self.object && !scalar { Value::text(out.type_name.as_str()) } else { out.value.clone() })
|
||||
}
|
||||
|
||||
/// The cell as a formula (without `=`): `PY("…"&"…",0)`, with the code in string literals of
|
||||
/// at most 255 characters and the return type Excel documents: 0 for an Excel value, 1 for a
|
||||
/// Python object.
|
||||
pub fn to_formula(&self) -> String {
|
||||
let chunks = chunk(&self.code, CHUNK_CHARS);
|
||||
let lits: Vec<String> = chunks.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect();
|
||||
let lits = if lits.is_empty() { "\"\"".to_string() } else { lits.join("&") };
|
||||
format!("PY({lits},{})", u8::from(self.object))
|
||||
}
|
||||
|
||||
/// [`PyCode::to_formula`] with Excel's `_xlfn._xlws.` prefix: the string-literal form
|
||||
/// [`PyCode::from_formula`] reads from files that hold it, and what the size limit measures.
|
||||
/// An xlsx file names the code by index instead ([MS-XLSX] 2.3.10.2).
|
||||
pub fn to_file_formula(&self) -> String {
|
||||
format!("{FILE_PREFIX}{}", self.to_formula())
|
||||
}
|
||||
|
||||
/// Whether the code fits in a PY formula of at most [`MAX_FORMULA_CHARS`] characters.
|
||||
pub fn fits(&self) -> bool {
|
||||
// Code longer than the limit can't fit; this also bounds the work for hostile input.
|
||||
self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none()
|
||||
}
|
||||
|
||||
/// Reads a `PY(…)` formula (with or without `=`, and with or without the `_xlfn.` and
|
||||
/// `_xlws.` prefixes): string literals joined by `&`, optionally followed by `,0` or `,1`.
|
||||
/// `None` for anything else.
|
||||
pub fn from_formula(text: &str) -> Option<PyCode> {
|
||||
let body = py_body(text);
|
||||
let rest = body.get(..3).filter(|h| h.eq_ignore_ascii_case("PY(")).and_then(|_| body.get(3..))?;
|
||||
let mut chars = rest.chars().peekable();
|
||||
let mut code = String::new();
|
||||
loop {
|
||||
skip_space(&mut chars);
|
||||
if chars.next()? != '"' {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
code.push('"');
|
||||
}
|
||||
'"' => break,
|
||||
c => code.push(c),
|
||||
}
|
||||
}
|
||||
skip_space(&mut chars);
|
||||
match chars.next()? {
|
||||
'&' => continue,
|
||||
',' => {
|
||||
skip_space(&mut chars);
|
||||
let object = match chars.next()? {
|
||||
'0' => false,
|
||||
'1' => true,
|
||||
_ => return None,
|
||||
};
|
||||
skip_space(&mut chars);
|
||||
return (chars.next()? == ')' && chars.all(char::is_whitespace)).then(|| PyCode { object, ..PyCode::new(&code) });
|
||||
}
|
||||
')' => return chars.all(char::is_whitespace).then(|| PyCode::new(&code)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` without a leading `=`, spaces and the `_xlfn.` and `_xlws.` prefixes files put before
|
||||
/// newer functions (in any letter case).
|
||||
fn py_body(text: &str) -> &str {
|
||||
let mut body = text.trim().trim_start_matches('=').trim_start();
|
||||
for prefix in ["_xlfn.", "_xlws."] {
|
||||
if body.get(..prefix.len()).is_some_and(|h| h.eq_ignore_ascii_case(prefix)) {
|
||||
body = body.get(prefix.len()..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn skip_space(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Line breaks as `\n`.
|
||||
pub fn normalize_code(code: &str) -> String {
|
||||
code.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
|
||||
/// `s` in pieces of at most `n` characters.
|
||||
fn chunk(s: &str, n: usize) -> Vec<String> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
chars.chunks(n.max(1)).map(|c| c.iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Whether `text` (a formula, with or without `=`) is a `PY(` call: such formulas are Python cells,
|
||||
/// set with `python.set`.
|
||||
pub fn is_py_formula(text: &str) -> bool {
|
||||
let body = py_body(text);
|
||||
body.get(..3).is_some_and(|h| h.eq_ignore_ascii_case("PY(")) || body.eq_ignore_ascii_case("PY")
|
||||
}
|
||||
|
||||
/// An `xl()` call in Python code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct XlCall {
|
||||
/// The reference text, when the first argument is a plain string literal.
|
||||
pub reference: Option<String>,
|
||||
/// Byte range of the literal's contents (between the quotes) in the code.
|
||||
pub span: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
|
||||
/// The `xl(…)` calls in `code`, skipping comments and other string literals. A call whose first
|
||||
/// argument isn't a plain string literal has no reference: the cells it reads are only known when
|
||||
/// it runs.
|
||||
pub fn xl_calls(code: &str) -> Vec<XlCall> {
|
||||
let bytes = code.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
match b {
|
||||
b'#' => {
|
||||
// A comment runs to the end of the line.
|
||||
while bytes.get(i).is_some_and(|c| *c != b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'"' | b'\'' => i = skip_string(bytes, i),
|
||||
b'x' if bytes.get(i + 1) == Some(&b'l') && (i == 0 || bytes.get(i - 1).is_some_and(|p| !is_ident(*p) && *p != b'.')) => {
|
||||
let mut j = i + 2;
|
||||
if bytes.get(j).is_some_and(|c| is_ident(*c)) {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
while bytes.get(j).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
j += 1;
|
||||
}
|
||||
if bytes.get(j) != Some(&b'(') {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while bytes.get(j).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
j += 1;
|
||||
}
|
||||
let call = match bytes.get(j) {
|
||||
Some(&q @ (b'"' | b'\'')) => {
|
||||
let start = j + 1;
|
||||
let end = skip_string(bytes, j);
|
||||
// A plain literal: closed on its own line, not part of an expression
|
||||
// (`xl("A" + n)`), not a triple-quoted string, and escaping nothing but a
|
||||
// quote or a backslash (`\'`, `\"`, `\\`), which a sheet name may need.
|
||||
let close = end.saturating_sub(1);
|
||||
let mut k = end;
|
||||
while bytes.get(k).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
k += 1;
|
||||
}
|
||||
let plain = close > start
|
||||
&& bytes.get(close) == Some(&q)
|
||||
&& bytes.get(start) != Some(&q)
|
||||
&& matches!(bytes.get(k), Some(b')' | b','));
|
||||
i = end;
|
||||
match code.get(start..close).filter(|_| plain).and_then(unescape) {
|
||||
Some(t) => XlCall { reference: Some(t), span: Some(start..close) },
|
||||
None => XlCall { reference: None, span: None },
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
i = j;
|
||||
XlCall { reference: None, span: None }
|
||||
}
|
||||
};
|
||||
out.push(call);
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_ident(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
|
||||
}
|
||||
|
||||
/// The index just past the string literal starting at `at` (single, double or triple quoted).
|
||||
fn skip_string(bytes: &[u8], at: usize) -> usize {
|
||||
let Some(&q) = bytes.get(at) else { return at.saturating_add(1) };
|
||||
let triple = bytes.get(at + 1) == Some(&q) && bytes.get(at + 2) == Some(&q);
|
||||
let mut i = if triple { at + 3 } else { at + 1 };
|
||||
while let Some(&c) = bytes.get(i) {
|
||||
if c == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c == q {
|
||||
if !triple {
|
||||
return i + 1;
|
||||
}
|
||||
if bytes.get(i + 1) == Some(&q) && bytes.get(i + 2) == Some(&q) {
|
||||
return i + 3;
|
||||
}
|
||||
} else if c == b'\n' && !triple {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The text of a plain literal's contents: `\'`, `\"` and `\\` stand for the character after the
|
||||
/// backslash. `None` for any other escape or a line break, which make the literal not plain.
|
||||
fn unescape(raw: &str) -> Option<String> {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\n' => return None,
|
||||
'\\' => match chars.next()? {
|
||||
e @ ('\\' | '\'' | '"') => out.push(e),
|
||||
_ => return None,
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `text` as a plain literal that was delimited by `quote`: its delimiter (`quote`, or the other
|
||||
/// quote when `text` holds `quote` but not the other one, so `xl('Sheet2!B2')` becomes
|
||||
/// `xl("'Q4 2026'!B2")` on a rename) and its contents, with backslashes escaped, and the
|
||||
/// delimiter too when `text` holds both quotes. `None` for a line break, which no literal on
|
||||
/// one line holds.
|
||||
fn literal(text: &str, quote: char) -> Option<(char, String)> {
|
||||
if text.contains('\n') {
|
||||
return None;
|
||||
}
|
||||
let other = if quote == '\'' { '"' } else { '\'' };
|
||||
let delim = if text.contains(quote) && !text.contains(other) { other } else { quote };
|
||||
let mut body = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if c == '\\' || c == delim {
|
||||
body.push('\\');
|
||||
}
|
||||
body.push(c);
|
||||
}
|
||||
Some((delim, body))
|
||||
}
|
||||
|
||||
/// Rewrites the reference of every `xl("…")` literal in `code` with `f`; `None` from `f` leaves
|
||||
/// it. Returns the new code when anything changed. A new reference holding the literal's quote
|
||||
/// (a sheet name that needs quotes, `'Q4 2026'!B2`) gets the other quote as its delimiter, or an
|
||||
/// escaped quote when it holds both, so it never ends the string early.
|
||||
pub fn rewrite_xl(code: &str, mut f: impl FnMut(&str) -> Option<String>) -> Option<String> {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut last = 0usize;
|
||||
let mut changed = false;
|
||||
for call in xl_calls(code) {
|
||||
let (Some(reference), Some(span)) = (call.reference, call.span) else { continue };
|
||||
let Some(new) = f(&reference).filter(|n| *n != reference) else { continue };
|
||||
let open = span.start.saturating_sub(1);
|
||||
let Some(quote) = code.get(open..span.start).and_then(|q| q.chars().next()) else { continue };
|
||||
let Some((delim, body)) = literal(&new, quote) else { continue };
|
||||
// The literal with its quotes: the delimiter may change.
|
||||
out.push_str(code.get(last..open).unwrap_or_default());
|
||||
out.push(delim);
|
||||
out.push_str(&body);
|
||||
out.push(delim);
|
||||
last = span.end.saturating_add(1);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
out.push_str(code.get(last..).unwrap_or_default());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The value a Python cell shows when it can't run: `#BLOCKED!`.
|
||||
pub const BLOCKED: Value = Value::Error(CellError::Blocked);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formula_round_trip_with_chunks_and_quotes() {
|
||||
let code = format!("s = \"quoted\"\n{}\nlen(s)", "x".repeat(600));
|
||||
let py = PyCode { object: true, ..PyCode::new(&code) };
|
||||
let f = py.to_formula();
|
||||
assert!(f.starts_with("PY(\"s = \"\"quoted\"\"\n"));
|
||||
assert!(f.ends_with(",1)"));
|
||||
assert_eq!(f.matches("\"&\"").count(), 2, "{f}");
|
||||
let back = PyCode::from_formula(&format!("={f}")).unwrap();
|
||||
assert_eq!(back.code, code);
|
||||
assert!(back.object);
|
||||
assert_eq!(PyCode::from_formula("_xlfn.PY(\"1+1\")").unwrap().code, "1+1");
|
||||
// Excel writes `_xlfn._xlws.PY`; the reader takes it in any letter case.
|
||||
assert_eq!(PyCode::from_formula("=_xlfn._xlws.PY(\"1+1\",1)").unwrap().code, "1+1");
|
||||
assert_eq!(PyCode::from_formula("_XLFN._XLWS.py(\"2\",0)").unwrap().code, "2");
|
||||
assert_eq!(PyCode::from_formula("_xlws.PY(\"3\",0)").unwrap().code, "3");
|
||||
assert!(is_py_formula("=_xlfn._xlws.PY(\"1\",0)"));
|
||||
assert_eq!(py.to_file_formula(), format!("_xlfn._xlws.{f}"));
|
||||
assert_eq!(PyCode::from_formula(&py.to_file_formula()).unwrap(), PyCode::from_formula(&f).unwrap());
|
||||
assert_eq!(PyCode::from_formula(" py( \"a\" & \"b\" , 0 ) ").unwrap().code, "ab");
|
||||
assert_eq!(PyCode::new("a\r\nb").code, "a\nb");
|
||||
for bad in ["PY(A1)", "PY(\"a\",2)", "PY(\"a\"", "PY(\"a\")+1", "SUM(1)", "PY(\"a\"&B1)", "PY(", ""] {
|
||||
assert!(PyCode::from_formula(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(is_py_formula("=PY(\"1\")") && is_py_formula("=py") && !is_py_formula("=PYTHON(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_limit_counts_the_encoded_formula() {
|
||||
// Plain code: the limit is the formula's, so it is a little under 8,192 characters of code.
|
||||
// `_xlfn._xlws.PY(` + 31 literals of 255 + 30 `&` + `&"…"` + `,0)`: 8,079 characters of
|
||||
// code give a formula of exactly 8,192.
|
||||
let at = PyCode::new(&"y".repeat(8_079));
|
||||
assert_eq!(at.to_file_formula().chars().count(), MAX_FORMULA_CHARS);
|
||||
assert!(at.fits());
|
||||
assert!(!PyCode::new(&"y".repeat(8_080)).fits());
|
||||
assert_eq!(PyCode::from_formula(&at.to_file_formula()).unwrap().code, at.code);
|
||||
// Quotes are doubled in the file, so 7,000 characters with 1,200 of them don't fit.
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
assert!(PyCode::new("ed).to_file_formula().chars().count() > MAX_FORMULA_CHARS);
|
||||
assert!(!PyCode::new("ed).fits());
|
||||
// Hostile sizes are refused without encoding them.
|
||||
assert!(!PyCode::new(&"y".repeat(4_000_000)).fits());
|
||||
assert!(PyCode::new("").fits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_values() {
|
||||
let arr = gridcraft_core::Array::new(2, 1, vec![1.0.into(), 2.0.into()]).map(Value::from).unwrap();
|
||||
let out = PyOutput { value: arr.clone(), type_name: "list".into(), preview: "[1, 2]".into(), convertible: true };
|
||||
let mut py = PyCode { output: Some(out), ..PyCode::new("[1, 2]") };
|
||||
assert_eq!(py.result_value(), Some(arr));
|
||||
py.object = true;
|
||||
assert_eq!(py.result_value(), Some(Value::text("list")));
|
||||
py.output = Some(PyOutput { value: Value::Number(3.0), type_name: "int".into(), preview: "3".into(), convertible: true });
|
||||
assert_eq!(py.result_value(), Some(Value::Number(3.0)), "scalars show as values in object mode too");
|
||||
py.output = None;
|
||||
assert_eq!(py.result_value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_xl_literals() {
|
||||
let code = "a = xl(\"A1:B2\", headers=True)\n# xl('C1') in a comment\ns = \"xl('D1')\"\nb = xl( 'Sheet2!E5' )\nc = xl(name)\nd = df.xl('F1')\nxlx('G1')\ne = xl(\"A\" + \"1\")";
|
||||
let calls = xl_calls(code);
|
||||
let refs: Vec<Option<&str>> = calls.iter().map(|c| c.reference.as_deref()).collect();
|
||||
assert_eq!(refs, vec![Some("A1:B2"), Some("Sheet2!E5"), None, None]);
|
||||
let new = rewrite_xl(code, |r| (r == "A1:B2").then(|| "A2:B3".to_string())).unwrap();
|
||||
assert!(new.starts_with("a = xl(\"A2:B3\", headers=True)"));
|
||||
assert_eq!(new.len(), code.len());
|
||||
assert_eq!(rewrite_xl(code, |_| None), None);
|
||||
// A replacement can't break out of its string: a quote in it changes the delimiter, or is
|
||||
// escaped when it holds both quotes, and the result reads back as the same reference.
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q'!A1".into())), Some("xl(\"'Q'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('Sheet2!B2', headers=True)", |_| Some("'Q4 2026'!B2".into())), Some("xl(\"'Q4 2026'!B2\", headers=True)".into()));
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q\"4'!A1".into())), Some("xl(\"'Q\\\"4'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("'Q\"4'!A1".into())), Some("xl('\\'Q\"4\\'!A1')".into()));
|
||||
for (delim, new) in [("'", "'Q4 2026'!B2"), ("'", "'Q\"4'!A1"), ("\"", "'Q\"4'!A1"), ("\"", "'a\\b'!A1"), ("'", "B2")] {
|
||||
let code = format!("x = xl({delim}A1{delim}) + 1");
|
||||
let out = rewrite_xl(&code, |_| Some(new.to_string())).unwrap();
|
||||
assert_eq!(xl_calls(&out)[0].reference.as_deref(), Some(new), "{out}");
|
||||
assert!(out.ends_with(") + 1"), "{out}");
|
||||
}
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("A\n1".into())), None, "a line break can't go in a literal");
|
||||
// Escaped quotes and backslashes read as the characters; any other escape is not plain.
|
||||
assert_eq!(xl_calls("xl('Bob\\'s!A1')")[0].reference.as_deref(), Some("Bob's!A1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\\\1\")")[0].reference.as_deref(), Some("A\\1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\n1\")")[0].reference, None);
|
||||
assert_eq!(xl_calls("xl(\"A1\\\")")[0].reference, None, "an escaped closing quote leaves the literal open");
|
||||
// Unclosed and odd input doesn't loop or panic.
|
||||
for odd in ["xl(", "xl(\"", "xl('''A1''')", "x", "xl", "\"unterminated", "xl ( \"A1\" )", "é xl('A1')"] {
|
||||
let _ = xl_calls(odd);
|
||||
let _ = rewrite_xl(odd, |_| Some("B2".into()));
|
||||
}
|
||||
assert_eq!(xl_calls("xl ( \"A1\" )")[0].reference.as_deref(), Some("A1"));
|
||||
assert_eq!(xl_calls("xl('''A1''')")[0].reference, None);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -52,9 +53,44 @@ impl SheetViewMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's identity while the app runs, for what must follow one sheet (an open cell draft): a
|
||||
/// sheet made new (added, inserted, copied, read from a file or from JSON) gets a fresh one, and it
|
||||
/// stays with the sheet through renames, moves and the copies undo keeps. Never saved.
|
||||
/// It is not content: any two compare equal, so comparing sheets compares what they hold; compare
|
||||
/// identities with [`SheetUid::get`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SheetUid(u64);
|
||||
|
||||
/// The next [`SheetUid`]; 0 is never given out.
|
||||
static NEXT_SHEET_UID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
impl SheetUid {
|
||||
pub fn fresh() -> SheetUid {
|
||||
SheetUid(NEXT_SHEET_UID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
pub fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SheetUid {
|
||||
fn default() -> SheetUid {
|
||||
SheetUid::fresh()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SheetUid {
|
||||
fn eq(&self, _: &SheetUid) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]). Not in JSON or the file.
|
||||
#[serde(skip)]
|
||||
pub uid: SheetUid,
|
||||
/// The `sheetId` the file gave the sheet, written back on save so that what refers to the
|
||||
/// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON.
|
||||
#[serde(skip)]
|
||||
@@ -149,6 +185,7 @@ impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
uid: SheetUid::fresh(),
|
||||
file_id: 0,
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
@@ -202,6 +239,42 @@ impl Sheet {
|
||||
self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor)
|
||||
}
|
||||
|
||||
/// Removes the spilled values inside `r`. The work is bounded by the values stored, not by
|
||||
/// the size of `r`, so a range from a file that claims the whole sheet costs no more than the
|
||||
/// values it holds.
|
||||
pub fn clear_spill(&mut self, r: RangeRef) {
|
||||
if r.count() <= self.spill.len() as u64 {
|
||||
for c in r.iter() {
|
||||
self.spill.remove(&c);
|
||||
}
|
||||
} else {
|
||||
self.spill.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the spill anchored at `anchor` into plain values, for a Python cell that keeps only
|
||||
/// its value: the range is forgotten, and each spilled value in it becomes its cell's value,
|
||||
/// unless the cell holds content of its own (its format is kept either way). The work is
|
||||
/// bounded by the spilled values stored, not by the size of the range.
|
||||
pub fn settle_spill(&mut self, anchor: CellRef) {
|
||||
let Some(r) = self.spill_ranges.remove(&anchor) else { return };
|
||||
let inside: Vec<CellRef> = self.spill.range(r.start..=r.end).map(|(c, _)| *c).filter(|c| r.contains(*c)).collect();
|
||||
for c in inside {
|
||||
let Some(v) = self.spill.remove(&c) else { continue };
|
||||
if v.is_empty() || c == anchor {
|
||||
continue;
|
||||
}
|
||||
match self.cells.get_mut(c) {
|
||||
Some(cell) if cell.has_content() => {}
|
||||
Some(cell) => cell.value = v,
|
||||
None => {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell::styled(v, style));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating object with workbook-unique id `id` on this sheet.
|
||||
pub fn object_ref(&self, id: u32) -> Option<ObjRef> {
|
||||
let kind = if self.charts.iter().any(|o| o.id == id) {
|
||||
@@ -338,7 +411,7 @@ impl Sheet {
|
||||
}
|
||||
pub fn set_value(&mut self, c: CellRef, v: Value) {
|
||||
let style = self.style_id(c);
|
||||
self.set_cell(c, Cell { value: v, formula: None, style });
|
||||
self.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
/// Replaces content, removing any picture at this address. Style-only edits use set_style.
|
||||
pub fn set_cell(&mut self, c: CellRef, cell: Cell) {
|
||||
@@ -357,7 +430,7 @@ impl Sheet {
|
||||
/// Pictures retain a scalar fallback for calculation and unsupported consumers.
|
||||
pub fn set_picture(&mut self, c: CellRef, picture: Arc<CellPicture>) {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style });
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style, py: None });
|
||||
self.cell_pictures.insert(c, picture);
|
||||
}
|
||||
pub fn input_text(&self, c: CellRef) -> String {
|
||||
@@ -557,6 +630,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::cell::Formula;
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() {
|
||||
let a = Sheet::new("A");
|
||||
let b = Sheet::new("A");
|
||||
assert_ne!(a.uid.get(), b.uid.get(), "two new sheets");
|
||||
assert_ne!(a.uid.get(), 0);
|
||||
// A clone (an undo snapshot, a rename through Arc::make_mut) is the same sheet.
|
||||
let mut renamed = a.clone();
|
||||
renamed.name = "B".into();
|
||||
assert_eq!(renamed.uid.get(), a.uid.get());
|
||||
// Not content: equal sheets compare equal whatever their identity.
|
||||
assert_eq!(a, b);
|
||||
// Not in JSON: a sheet read back is a new one.
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
assert!(!json.contains("uid"), "{json}");
|
||||
let read: Sheet = serde_json::from_str(&json).unwrap();
|
||||
assert_ne!(read.uid.get(), a.uid.get());
|
||||
assert_eq!(read, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_index_matches_linear() {
|
||||
let mut s = Sheet::new("S");
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::cell::Cell;
|
||||
const BAND: u32 = 64;
|
||||
|
||||
fn has_value(cell: &Cell) -> bool {
|
||||
!cell.value.is_empty() || cell.formula.is_some()
|
||||
cell.has_content()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//!
|
||||
//! `cargo run --release -p gridcraft-ui-egui --example snapshot -- [--sample sales] [--in file.xlsx]
|
||||
//! [--size 1440x900] [--scale 2] [--cmd 'id={json}']... [--tab Insert] [--dark] [--language en|es|zh|ja|ko|ru|pt|de|cs|fr|tr]
|
||||
//! [--pane name] [--object id] [--dialog 'name={json}'] [--hf-edit header|footer] out.png`
|
||||
//! [--pane name] [--object id] [--dialog 'name={json}'] [--refedit key=A1:B2] [--hf-edit header|footer] out.png`
|
||||
//!
|
||||
//! `--refedit key=range` collapses the open dialog to its reference box `key`, then selects
|
||||
//! `range` on the grid, as a person picking a range for that box does.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
@@ -22,6 +25,7 @@ fn main() {
|
||||
let mut dialog: Option<String> = None;
|
||||
let mut language: Option<String> = None;
|
||||
let mut hf_edit: Option<String> = None;
|
||||
let mut refedit: Option<String> = None;
|
||||
let mut out = "snapshot.png".to_string();
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
@@ -68,6 +72,10 @@ fn main() {
|
||||
i += 1;
|
||||
dialog = args.get(i).cloned();
|
||||
}
|
||||
"--refedit" => {
|
||||
i += 1;
|
||||
refedit = args.get(i).cloned();
|
||||
}
|
||||
"--hf-edit" => {
|
||||
i += 1;
|
||||
hf_edit = args.get(i).cloned();
|
||||
@@ -134,6 +142,15 @@ fn main() {
|
||||
// The workbook's installed fonts bind once the font scan is done; frames past the first few
|
||||
// apply them, so the snapshot shows them as the app does.
|
||||
harness.run_steps(14);
|
||||
if let Some((key, range)) = refedit.as_deref().and_then(|r| r.split_once('=')) {
|
||||
let app = harness.state_mut();
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
d.collapse_to(app, key);
|
||||
app.dialog = Some(d);
|
||||
}
|
||||
app.session.execute("selection.set", json!({"range": range})).expect("select");
|
||||
harness.run_steps(8);
|
||||
}
|
||||
let img = harness.render().expect("render");
|
||||
img.save(&out).expect("save png");
|
||||
eprintln!("wrote {out} ({}x{})", img.width(), img.height());
|
||||
|
||||
@@ -14,6 +14,8 @@ use super::{
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod editing;
|
||||
#[cfg(test)]
|
||||
pub(crate) use editing::{DRAFT_CODE_HIDDEN, DRAFT_SHEET_GONE};
|
||||
mod files;
|
||||
mod fonts;
|
||||
mod input;
|
||||
@@ -221,7 +223,9 @@ impl SheetApp {
|
||||
} else {
|
||||
ribbon::show(self, ui);
|
||||
widgets::notice_bar(self, ui);
|
||||
if self.session.prefs.formula_bar {
|
||||
// Python mode edits only in the formula bar: it shows while a Python edit is open,
|
||||
// also when the preference hides it.
|
||||
if self.session.prefs.formula_bar || self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
formula_bar::show(self, ui);
|
||||
}
|
||||
if self.ui.status_bar {
|
||||
|
||||
@@ -246,6 +246,21 @@ impl SheetApp {
|
||||
self.open_dialog(name, p.clone());
|
||||
Ok(json!({"dialog": self.dialog.as_ref().map(|d| d.name())}))
|
||||
}
|
||||
"ui.pythonMode" => {
|
||||
// `{"on": true}` starts Python mode on the active cell (or switches the edit in
|
||||
// progress to it), `{"on": false}` leaves it; without `on` it switches. A start
|
||||
// that is refused (a locked cell, a cell inside a spill, hidden code) is an error
|
||||
// with the reason, not `{"on": false}`.
|
||||
let on = self.editor.as_ref().is_some_and(|e| e.python);
|
||||
let want = p.get("on").and_then(Json::as_bool).unwrap_or(!on);
|
||||
if want != on {
|
||||
self.toggle_python_mode();
|
||||
if want && !self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
return Some(Err(self.python_mode_refused()));
|
||||
}
|
||||
}
|
||||
Ok(json!({"on": self.editor.as_ref().is_some_and(|e| e.python)}))
|
||||
}
|
||||
"ui.message.dismiss" => {
|
||||
self.message = None;
|
||||
Ok(Json::Null)
|
||||
@@ -293,11 +308,16 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.follow_open_draft();
|
||||
}
|
||||
|
||||
/// Whether the active sheet of the active document is protected (drives the Protect/Unprotect
|
||||
/// Sheet labels and actions).
|
||||
/// Sheet labels and actions). While a dialog's reference boxes have picked, it is the dialog's
|
||||
/// own sheet: Protect Sheet ends the pick and acts on that sheet, so the label says so too.
|
||||
pub fn active_sheet_protected(&self) -> bool {
|
||||
if let Some(protected) = self.dialog.as_ref().and_then(|d| d.home_sheet_protected(self)) {
|
||||
return protected;
|
||||
}
|
||||
self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -306,6 +326,20 @@ impl SheetApp {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
// A protection action taken while a reference box picks on another sheet is for the
|
||||
// dialog's sheet: the pick ends, showing that sheet again, before the action is chosen.
|
||||
if matches!(name, "protectSheet" | "unprotectSheet")
|
||||
&& let Some(mut d) = self.dialog.take()
|
||||
{
|
||||
let error =
|
||||
if d.home_sheet_deleted(self) { Some(dialogs::SHEET_DELETED.into()) } else { d.end_pick(self).err().map(|e| clean_error(&e)) };
|
||||
if let Some(error) = error {
|
||||
self.message = Some((d.title.clone(), error));
|
||||
self.dialog = Some(d);
|
||||
return;
|
||||
}
|
||||
self.dialog = Some(d);
|
||||
}
|
||||
// Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet
|
||||
// on a protected sheet. A sheet without a password is unprotected straight away.
|
||||
let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name };
|
||||
@@ -360,7 +394,16 @@ impl SheetApp {
|
||||
"Close the macro step editor first: it keeps its window until it closes itself, so its steps and debug run are not lost.".into(),
|
||||
));
|
||||
}
|
||||
other => self.dialog = dialogs::Dialog::open(self, other, params),
|
||||
other => {
|
||||
// A dialog replaced while one of its reference boxes picks (a ribbon command
|
||||
// opening another dialog) first puts the sheet and selection from before picking
|
||||
// back, as closing it does.
|
||||
if let Some(mut old) = self.dialog.take() {
|
||||
// The draft was committed above, so the sheet can be shown again.
|
||||
let _ = old.end_pick(self);
|
||||
}
|
||||
self.dialog = dialogs::Dialog::open(self, other, params);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
use super::{SheetApp, clean_error, editor, formula_bar, formula_locale, json, now_ms};
|
||||
|
||||
/// Why a draft whose sheet was deleted while it was open is dropped.
|
||||
pub(crate) const DRAFT_SHEET_GONE: &str = "The sheet of the cell being edited was deleted, so the entry was discarded.";
|
||||
|
||||
/// Why a draft of Python code that sheet protection hid while it was open is dropped.
|
||||
pub(crate) const DRAFT_CODE_HIDDEN: &str = "Sheet protection now hides the code of the cell being edited, so the entry was discarded.";
|
||||
|
||||
impl SheetApp {
|
||||
/// Starts editing the active cell. `text` replaces the content (typing) or pre-fills it.
|
||||
pub fn begin_edit(&mut self, text: Option<String>, from_formula_bar: bool) {
|
||||
@@ -28,6 +34,22 @@ impl SheetApp {
|
||||
if text.is_none() && sh.cell_pictures.contains_key(&at) {
|
||||
return;
|
||||
}
|
||||
// Editing a Python cell edits its code, in Python mode; typing replaces it as in Excel.
|
||||
if text.is_none()
|
||||
&& let Some(py) = sh.cell(at).and_then(|c| c.py.as_deref())
|
||||
{
|
||||
// Code hidden on a protected sheet isn't loaded into an edit, and a blank edit would
|
||||
// write over it on commit: no edit starts. Typing still replaces the cell.
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return;
|
||||
}
|
||||
let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, py.code.clone()).on_sheet(&d.wb);
|
||||
ed.py_cell = true;
|
||||
ed.window = self.session.active_window();
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
return;
|
||||
}
|
||||
let locale = self.ui.language.formula_locale();
|
||||
let current = if sh.cell_pictures.contains_key(&at) {
|
||||
String::new()
|
||||
@@ -41,7 +63,7 @@ impl SheetApp {
|
||||
Some(t) => (t, true),
|
||||
None => (current.clone(), false),
|
||||
};
|
||||
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar);
|
||||
let mut ed = editor::EditState::new(d.uid, d.wb.active_sheet(), at, text, replace, from_formula_bar).on_sheet(&d.wb);
|
||||
ed.window = self.session.active_window();
|
||||
ed.locale = locale;
|
||||
if replace && self.ui.autocomplete && !ed.is_formula() {
|
||||
@@ -96,10 +118,23 @@ impl SheetApp {
|
||||
}
|
||||
|
||||
fn commit_edit_here(&mut self, dr: i64, dc: i64, array: bool, fill_selection: bool) -> bool {
|
||||
let Some(ed) = self.editor.take() else { return true };
|
||||
// Python code goes in through `python.set`, whichever way the edit is committed (the
|
||||
// formula bar's Enter button, a click on the sheet).
|
||||
if self.editor.as_ref().is_some_and(|e| e.python) {
|
||||
let ok = self.commit_python_edit();
|
||||
if ok && (dr != 0 || dc != 0) {
|
||||
self.move_after_enter(dr, dc);
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
let Some(mut ed) = self.editor.take() else { return true };
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
if !self.follow_draft_sheet(&mut ed) {
|
||||
return true;
|
||||
}
|
||||
let text = match &ed.completion {
|
||||
Some(full) if full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(),
|
||||
// A code edit commits the code as typed, whatever a completion holds.
|
||||
Some(full) if !ed.python && full.to_lowercase().starts_with(&ed.text.to_lowercase()) => full.clone(),
|
||||
_ => ed.text.clone(),
|
||||
};
|
||||
// F2 then Enter (or a file command) on an untouched draft: nothing to write.
|
||||
@@ -153,11 +188,9 @@ impl SheetApp {
|
||||
let unchanged = !fill_selection
|
||||
&& !array
|
||||
&& text.is_empty()
|
||||
&& self
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.sheet(ed.sheet))
|
||||
.is_some_and(|sh| sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.value == gridcraft_engine::core::Value::Empty));
|
||||
&& self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| {
|
||||
sh.cell(ed.cell).is_none_or(|c| c.formula.is_none() && c.py.is_none() && c.value == gridcraft_engine::core::Value::Empty)
|
||||
});
|
||||
let r = if unchanged {
|
||||
Ok(json!({}))
|
||||
} else if fill_selection {
|
||||
@@ -182,6 +215,60 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// After a command, keeps the open draft on its own sheet: a sheet inserted, deleted or moved
|
||||
/// before it (an undo or a macro too) changes which sheet sits at its index. The draft is then
|
||||
/// drawn, pointed from and committed on its sheet, and the point-mode anchor, held by sheet
|
||||
/// index, is let go. A draft is dropped at once with a message when its sheet is gone, or when
|
||||
/// it holds Python code that sheet protection now hides (a draft in Python mode, or one that
|
||||
/// began on a Python cell's code): neither editor shows hidden code, as the Python editor
|
||||
/// window closes then too. A draft typed over a Python cell holds only what was typed, so it
|
||||
/// stays open.
|
||||
pub(crate) fn follow_open_draft(&mut self) {
|
||||
let Some(ed) = self.editor.as_ref() else { return };
|
||||
let Some(d) = self.session.documents().iter().find(|d| d.uid == ed.doc) else { return };
|
||||
let order = editor::sheet_order(&d.wb);
|
||||
let moved = ed.sheet_uid != 0 && order != ed.sheet_order;
|
||||
let found = if moved { ed.sheet_in(&d.wb) } else { Some(ed.sheet) };
|
||||
let hidden = found
|
||||
.and_then(|i| d.wb.sheet(i))
|
||||
.is_some_and(|sh| (ed.python || ed.py_cell) && gridcraft_engine::display::formula_hidden(&d.wb, sh, ed.cell));
|
||||
let why = match found {
|
||||
None => Some(DRAFT_SHEET_GONE),
|
||||
Some(_) if hidden => Some(DRAFT_CODE_HIDDEN),
|
||||
Some(_) => None,
|
||||
};
|
||||
if let Some(why) = why {
|
||||
self.editor = None;
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
self.message = Some(("GridCraft".into(), why.into()));
|
||||
return;
|
||||
}
|
||||
if let (true, Some(sheet), Some(ed)) = (moved, found, self.editor.as_mut()) {
|
||||
ed.sheet = sheet;
|
||||
ed.sheet_order = order;
|
||||
ed.point_anchor = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Points a draft being committed at its sheet, found by identity: commands (a sheet inserted
|
||||
/// or deleted before it, a move, an undo, a macro) change which sheet sits at its index while
|
||||
/// it is open. A draft whose sheet is gone is dropped with a message, as one whose cell
|
||||
/// protection locks is, rather than being entered on whichever sheet took its place; `false`
|
||||
/// then.
|
||||
fn follow_draft_sheet(&mut self, ed: &mut editor::EditState) -> bool {
|
||||
let Some(d) = self.session.active() else { return true };
|
||||
match ed.sheet_in(&d.wb) {
|
||||
Some(sheet) => {
|
||||
ed.sheet = sheet;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.message = Some(("GridCraft".into(), DRAFT_SHEET_GONE.into()));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits the edit with an arrow key. The arrow is an ordinary move, so it ends a run of Tabs
|
||||
/// instead of continuing it (Right) or going back to its column like Enter (Down), as in Excel.
|
||||
pub fn commit_edit_arrow(&mut self, dr: i64, dc: i64) -> bool {
|
||||
@@ -196,6 +283,125 @@ impl SheetApp {
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
}
|
||||
|
||||
/// Starts editing the active cell in Python mode, in the formula bar: its code, or no code
|
||||
/// yet for a cell that isn't a Python cell. A cell inside a spill isn't edited, as with the
|
||||
/// formula bar's other entry paths.
|
||||
pub fn begin_python_edit(&mut self) {
|
||||
if formula_bar::active_spill_anchor(self).is_some() {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.commit_text_box_edit() {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
let Some(d) = self.session.active() else { return };
|
||||
let Some(sh) = d.wb.active() else { return };
|
||||
let at = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
// A locked cell on a protected sheet is refused before a draft exists, as `begin_edit`
|
||||
// refuses it (#2945).
|
||||
if let Some(m) = self.session.locked_cell_message(d.wb.active_sheet(), at) {
|
||||
self.message = Some(("GridCraft".into(), m));
|
||||
return;
|
||||
}
|
||||
let py = sh.cell(at).and_then(|c| c.py.as_deref());
|
||||
// A Python cell's code hidden on a protected sheet stays out of view, and Python mode
|
||||
// doesn't start on it with no code, which a commit would write over the hidden code.
|
||||
if py.is_some() && gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return;
|
||||
}
|
||||
let code = py.map(|py| py.code.clone()).unwrap_or_default();
|
||||
// The draft belongs to this window, as `begin_edit`'s do: switching windows commits it here.
|
||||
let mut ed = editor::EditState::python(d.uid, d.wb.active_sheet(), at, code).on_sheet(&d.wb);
|
||||
ed.py_cell = py.is_some();
|
||||
ed.window = self.session.active_window();
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+Shift+P (and the PY badge): Python mode on or off. An edit in progress keeps its
|
||||
/// text; with no edit, Python mode starts on the active cell.
|
||||
pub fn toggle_python_mode(&mut self) {
|
||||
if self.editor.as_ref().is_some_and(|ed| !ed.python && self.draft_in_spill(ed)) {
|
||||
return;
|
||||
}
|
||||
match self.editor.as_mut() {
|
||||
Some(ed) if ed.python => ed.leave_python(),
|
||||
Some(ed) => ed.enter_python(),
|
||||
None => return self.begin_python_edit(),
|
||||
}
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
}
|
||||
|
||||
/// Whether a draft's cell lies inside another cell's spill. A draft typed there stays an
|
||||
/// ordinary entry: Python mode starts on no cell inside a spill, as the formula bar's entry
|
||||
/// paths don't.
|
||||
pub(crate) fn draft_in_spill(&self, ed: &editor::EditState) -> bool {
|
||||
let doc = self.session.documents().iter().find(|d| d.uid == ed.doc);
|
||||
doc.and_then(|d| d.wb.sheet(ed.sheet)).is_some_and(|sh| sh.spill_anchor(ed.cell).is_some())
|
||||
}
|
||||
|
||||
/// Why Python mode didn't start where a command asked for it: the cell lies inside another
|
||||
/// cell's spill, it is locked on a protected sheet, or its code is hidden there.
|
||||
pub(crate) fn python_mode_refused(&self) -> String {
|
||||
let in_spill = match self.editor.as_ref() {
|
||||
Some(ed) => self.draft_in_spill(ed),
|
||||
None => formula_bar::active_spill_anchor(self).is_some(),
|
||||
};
|
||||
if in_spill {
|
||||
return "Python mode doesn't start on a cell inside another cell's spill.".into();
|
||||
}
|
||||
let locked = self.session.active().and_then(|d| {
|
||||
let at = d.wb.active().and_then(|sh| sh.merge_at(d.selection.active)).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
self.session.locked_cell_message(d.wb.active_sheet(), at)
|
||||
});
|
||||
locked.unwrap_or_else(|| "Python mode can't start on this cell: its code is hidden on a protected sheet.".into())
|
||||
}
|
||||
|
||||
/// Commits a Python mode edit through `python.set`, on the sheet the edit started on; with no
|
||||
/// code left, a Python cell is cleared through `cell.set`. When the command refuses (code
|
||||
/// longer than a cell holds), its message shows and the edit stays open with the code as
|
||||
/// typed.
|
||||
pub fn commit_python_edit(&mut self) -> bool {
|
||||
let Some(mut ed) = self.editor.take() else { return true };
|
||||
self.session.mode = gridcraft_engine::Mode::Ready;
|
||||
if !self.follow_draft_sheet(&mut ed) {
|
||||
return true;
|
||||
}
|
||||
// The code of a Python cell committed unchanged: nothing to write (no undo step, the
|
||||
// workbook stays clean), as with F2 then Enter on any other cell.
|
||||
let code = gridcraft_engine::model::python::normalize_code(&ed.text);
|
||||
let old = self.session.active().and_then(|d| d.wb.sheet(ed.sheet)).and_then(|sh| sh.cell(ed.cell)).and_then(|c| c.py.as_deref());
|
||||
let python_cell = old.is_some();
|
||||
if old.is_some_and(|py| py.code == code) {
|
||||
return true;
|
||||
}
|
||||
// A draft on a cell sheet protection now locks can never be entered: it is dropped with
|
||||
// the warning, as `commit_edit_here` drops an ordinary one (#2945).
|
||||
if let Some(m) = self.session.locked_cell_message(ed.sheet, ed.cell) {
|
||||
self.message = Some(("GridCraft".into(), m));
|
||||
return true;
|
||||
}
|
||||
// No code at all: a Python cell is cleared through cell.set, as an empty entry clears any
|
||||
// other cell (code of spaces is still code). On another cell there is nothing to write.
|
||||
let r = match (ed.text.is_empty(), python_cell) {
|
||||
(true, false) => return true,
|
||||
(true, true) => self.session.run("cell.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "input": ""})),
|
||||
(false, _) => self.session.run("python.set", json!({"sheet": ed.sheet, "cell": ed.cell.a1(), "code": ed.text})),
|
||||
};
|
||||
match r {
|
||||
Ok(_) => {
|
||||
self.after_engine();
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.message = Some(("GridCraft".into(), clean_error(&e)));
|
||||
self.editor = Some(ed);
|
||||
self.session.mode = gridcraft_engine::Mode::Edit;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enter/Tab move inside a multi-cell selection, otherwise to the next cell.
|
||||
pub fn move_after_enter(&mut self, dr: i64, dc: i64) {
|
||||
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
|
||||
|
||||
@@ -410,6 +410,8 @@ pub struct Applied {
|
||||
/// The keyboard events held back for the next pass, in order: the editor's first key after
|
||||
/// an event the text field acts on, and every keyboard event after that key.
|
||||
pub held: Vec<egui::Event>,
|
||||
/// The caller's key the pass stopped at (see [`apply_in_order_until`]).
|
||||
pub stop: Option<egui::Event>,
|
||||
}
|
||||
|
||||
/// Applies the pass's input to `text` in the order it came. A pass can carry several events
|
||||
@@ -426,6 +428,22 @@ pub struct Applied {
|
||||
/// follows each one, not the key. `sel` is the selection as char indices (anchor, caret),
|
||||
/// updated.
|
||||
pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mut (usize, usize), lang: Lang) -> Applied {
|
||||
apply_in_order_until(events, text, sel, lang, &|_| false)
|
||||
}
|
||||
|
||||
/// [`apply_in_order`], stopping at the first event `stop` picks: a key the caller acts on itself
|
||||
/// (commit, cancel, a mode switch). It counts as one of the editor's keys for the order: the
|
||||
/// typing before it is applied first, and after an event the text field acts on it is held back
|
||||
/// for the next pass with the rest. Once the pass stops (`Applied::stop`), the keyboard events
|
||||
/// after the stop key are dropped: the caller acts on the text as it was when the key was
|
||||
/// pressed, and what followed it was typed after it.
|
||||
pub fn apply_in_order_until(
|
||||
events: &mut Vec<egui::Event>,
|
||||
text: &mut String,
|
||||
sel: &mut (usize, usize),
|
||||
lang: Lang,
|
||||
stop: &dyn Fn(&egui::Event) -> bool,
|
||||
) -> Applied {
|
||||
let mut out = Applied::default();
|
||||
// An event the field acts on has gone by: the editor's keys after it wait for the next pass.
|
||||
let mut field_seen = false;
|
||||
@@ -433,10 +451,25 @@ pub fn apply_in_order(events: &mut Vec<egui::Event>, text: &mut String, sel: &mu
|
||||
let mut holding = false;
|
||||
let mut keep = Vec::with_capacity(events.len());
|
||||
for ev in std::mem::take(events) {
|
||||
if out.stop.is_some() {
|
||||
if !is_keyboard(&ev) {
|
||||
keep.push(ev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if holding && is_keyboard(&ev) {
|
||||
out.held.push(ev);
|
||||
continue;
|
||||
}
|
||||
if is_keyboard(&ev) && stop(&ev) {
|
||||
if field_seen {
|
||||
holding = true;
|
||||
out.held.push(ev);
|
||||
} else {
|
||||
out.stop = Some(ev);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match classify(&ev) {
|
||||
Class::Edit(_) if field_seen => {
|
||||
holding = true;
|
||||
@@ -546,18 +579,91 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
|
||||
/// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]).
|
||||
pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option<Range<usize>>) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
|
||||
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font()));
|
||||
let max_lines = max_lines.clamp(1, 200);
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
let options = FieldOptions { max_height: row_h * max_lines as f32, mark: mark.clone(), ..FieldOptions::new(lang) };
|
||||
response = Some(code_field(ui, id, text, &options).response);
|
||||
});
|
||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
||||
}
|
||||
|
||||
/// How a [`code_field`] is laid out, and the keys its caller takes.
|
||||
pub struct FieldOptions<'a> {
|
||||
pub lang: Lang,
|
||||
/// The field's width (`f32::INFINITY`: all there is).
|
||||
pub width: f32,
|
||||
/// The height shown before it scrolls; the scroll area follows the caret.
|
||||
pub max_height: f32,
|
||||
/// Whether it takes input. A dialog or message box over the field has the keyboard.
|
||||
pub interactive: bool,
|
||||
/// Keys the caller acts on itself, in input order (see [`apply_in_order_until`]).
|
||||
pub stop: &'a dyn Fn(&egui::Event) -> bool,
|
||||
/// Esc stays with the field (for a caller that stops at it) instead of releasing the focus.
|
||||
pub keep_escape: bool,
|
||||
/// Puts the caret here (a char index) before the pass's input: the caller changed the text.
|
||||
pub caret: Option<usize>,
|
||||
/// The field's name for screen readers.
|
||||
pub name: &'a str,
|
||||
/// A byte range underlined in the error colour (see [`underline`]).
|
||||
pub mark: Option<Range<usize>>,
|
||||
}
|
||||
|
||||
impl FieldOptions<'_> {
|
||||
pub fn new(lang: Lang) -> FieldOptions<'static> {
|
||||
FieldOptions {
|
||||
lang,
|
||||
width: f32::INFINITY,
|
||||
max_height: 100.0,
|
||||
interactive: true,
|
||||
stop: &|_| false,
|
||||
keep_escape: false,
|
||||
caret: None,
|
||||
// The editor is a generic code field, so the name is not part of the UI's translated
|
||||
// strings unless the caller gives one.
|
||||
name: "Code Editor",
|
||||
mark: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`code_field`] laid out and did.
|
||||
pub struct FieldOutput {
|
||||
pub response: Response,
|
||||
/// The caller's key the pass stopped at, if any.
|
||||
pub stop: Option<egui::Event>,
|
||||
/// Laid-out rows of the text.
|
||||
pub lines: usize,
|
||||
/// The caret on screen, while the field has the focus.
|
||||
pub caret: Option<egui::Rect>,
|
||||
/// The caret and the other end of the selection (char indices).
|
||||
pub selection: (usize, usize),
|
||||
}
|
||||
|
||||
/// The code editor's text field without a frame: monospace text with syntax colours, Enter that
|
||||
/// keeps the indentation, Tab and Shift+Tab, applied in input order (see [`apply_in_order`]).
|
||||
pub fn code_field(ui: &mut Ui, id: egui::Id, text: &mut String, options: &FieldOptions<'_>) -> FieldOutput {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = font();
|
||||
let lang = options.lang;
|
||||
// The input the editor handles itself (Enter with indentation, Tab, Shift+Tab), applied in
|
||||
// input order with the typing around it, before the text field sees the rest; what had to
|
||||
// wait for the field in the previous pass comes first.
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let focused = options.interactive && ui.memory(|m| m.has_focus(id));
|
||||
let held = take_held(ui, id, focused);
|
||||
let mut edited = None;
|
||||
let mut stop = None;
|
||||
let count = text.chars().count();
|
||||
if let Some(caret) = options.caret {
|
||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let caret = caret.min(count);
|
||||
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret))));
|
||||
state.store(ui.ctx(), id);
|
||||
edited = Some(Edit { anchor: caret, caret });
|
||||
}
|
||||
if focused {
|
||||
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let count = text.chars().count();
|
||||
let mut sel = state.cursor.char_range().map(|r| (r.secondary.index.into(), r.primary.index.into())).unwrap_or((count, count));
|
||||
let applied = ui.input_mut(|i| {
|
||||
if !held.is_empty() {
|
||||
@@ -565,7 +671,7 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La
|
||||
events.append(&mut i.events);
|
||||
i.events = events;
|
||||
}
|
||||
apply_in_order(&mut i.events, text, &mut sel, lang)
|
||||
apply_in_order_until(&mut i.events, text, &mut sel, lang, options.stop)
|
||||
});
|
||||
if applied.edited {
|
||||
state.cursor.set_char_range(Some(CCursorRange::two(CCursor::new(sel.0), CCursor::new(sel.1))));
|
||||
@@ -575,46 +681,52 @@ pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: La
|
||||
if !applied.held.is_empty() {
|
||||
hold(ui, id, applied.held);
|
||||
}
|
||||
stop = applied.stop;
|
||||
}
|
||||
let syntax = t.syntax;
|
||||
let plain = t.text;
|
||||
let danger = t.danger;
|
||||
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
|
||||
let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
|
||||
if let Some(m) = mark.clone() {
|
||||
if let Some(m) = options.mark.clone() {
|
||||
underline(&mut job, m, danger);
|
||||
}
|
||||
ui.fonts_mut(|f| f.layout_job(job))
|
||||
};
|
||||
let frame_h = row_h * max_lines as f32 + 8.0;
|
||||
let mut response = None;
|
||||
egui::Frame::new().fill(t.input_bg).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(4.0).inner_margin(4.0).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(frame_h - 8.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: options.keep_escape };
|
||||
let scrolled =
|
||||
egui::ScrollArea::vertical().id_salt(id.with("scroll")).max_height(options.max_height.max(1.0)).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let out = TextEdit::multiline(text)
|
||||
.id(id)
|
||||
.font(font.clone())
|
||||
.code_editor()
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_width(options.width)
|
||||
.desired_rows(1)
|
||||
.lock_focus(true)
|
||||
.interactive(options.interactive)
|
||||
.event_filter(filter)
|
||||
.layouter(&mut layouter)
|
||||
.show(ui);
|
||||
if let Some(e) = edited {
|
||||
let caret = out.galley.pos_from_cursor(CCursor::new(e.caret)).translate(out.galley_pos.to_vec2());
|
||||
ui.scroll_to_rect(caret, None);
|
||||
}
|
||||
// The field has no visible label; name it for screen readers and the strict
|
||||
// kittest accessibility check. The editor is a generic code field, so the name is
|
||||
// not part of the UI's translated strings.
|
||||
let mut r = out.response.response.accessible_name("Code Editor");
|
||||
if edited.is_some() {
|
||||
r.mark_changed();
|
||||
}
|
||||
response = Some(r);
|
||||
out
|
||||
});
|
||||
});
|
||||
response.unwrap_or_else(|| ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()))
|
||||
let out = scrolled.inner;
|
||||
// The field has no visible label; name it for screen readers and the strict kittest
|
||||
// accessibility check.
|
||||
let mut response = out.response.response.accessible_name(options.name);
|
||||
if edited.is_some() {
|
||||
response.mark_changed();
|
||||
}
|
||||
let selection = out.cursor_range.map(|r| (r.primary.index.into(), r.secondary.index.into())).unwrap_or((count, count));
|
||||
let caret = out
|
||||
.cursor_range
|
||||
.map(|r| out.galley.pos_from_cursor(r.primary).translate(out.galley_pos.to_vec2()))
|
||||
.filter(|_| response.has_focus())
|
||||
.map(|r| r.intersect(scrolled.inner_rect.expand(1.0)));
|
||||
FieldOutput { response, stop, lines: out.galley.rows.len().max(1), caret, selection }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -336,6 +336,41 @@ fn tab_and_enter_under_modifiers_never_reach_the_field() {
|
||||
assert!(out.held.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stop_key_ends_the_pass_in_input_order() {
|
||||
let stop = |ev: &egui::Event| matches!(ev, egui::Event::Key { key: Key::Enter, pressed: true, modifiers, .. } if modifiers.command);
|
||||
// The typing before it is applied; the keys after it are dropped, other input stays.
|
||||
let mut events = vec![
|
||||
egui::Event::Text("a".into()),
|
||||
key(Key::Enter, Modifiers::COMMAND),
|
||||
egui::Event::Text("b".into()),
|
||||
egui::Event::PointerMoved(egui::Pos2::ZERO),
|
||||
key(Key::Tab, Modifiers::NONE),
|
||||
];
|
||||
let (mut s, mut sel) = (String::new(), (0, 0));
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(s, "a");
|
||||
assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND)));
|
||||
assert_eq!(events, vec![egui::Event::PointerMoved(egui::Pos2::ZERO)]);
|
||||
assert!(out.held.is_empty());
|
||||
// After a key the field acts on, it waits for the next pass with the editor's keys
|
||||
// before it, so the edit it ends includes them.
|
||||
let mut events = vec![key(Key::A, Modifiers::COMMAND), key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)];
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(out.stop, None);
|
||||
assert_eq!(events, vec![key(Key::A, Modifiers::COMMAND)]);
|
||||
assert_eq!(out.held, vec![key(Key::Tab, Modifiers::NONE), key(Key::Enter, Modifiers::COMMAND)]);
|
||||
// Next pass: the Tab, then the stop.
|
||||
let mut events = out.held;
|
||||
let out = apply_in_order_until(&mut events, &mut s, &mut sel, Lang::Python, &stop);
|
||||
assert_eq!(s, "a ");
|
||||
assert_eq!(out.stop, Some(key(Key::Enter, Modifiers::COMMAND)));
|
||||
// Without a stop the editor behaves as before: the caller's key is the field's.
|
||||
let mut events = vec![key(Key::Enter, Modifiers::COMMAND)];
|
||||
let out = apply_in_order(&mut events, &mut s, &mut sel, Lang::Python);
|
||||
assert_eq!((out.stop, events.len()), (None, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_held_key_wait_with_it_and_pointer_events_do_not() {
|
||||
let mut events = vec![
|
||||
@@ -424,3 +459,24 @@ fn a_repeat_pass_of_the_frame_counts_as_the_next_pass() {
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().0, " a\n b");
|
||||
}
|
||||
|
||||
/// Whether a frame drew text with an underline.
|
||||
fn draws_underline(mark: Option<std::ops::Range<usize>>) -> bool {
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let mut text = String::from("x^{2");
|
||||
code_editor_marked(ui, egui::Id::unique("marked"), &mut text, Lang::Latex, 3, mark.clone());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
out.shapes.iter().any(|s| match &s.shape {
|
||||
egui::Shape::Text(t) => t.galley.job.sections.iter().any(|sec| sec.format.underline.width > 0.0),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marked_editor_underlines_its_mark() {
|
||||
// The equation editor marks an error's position; the code field draws it.
|
||||
assert!(draws_underline(Some(3..4)));
|
||||
assert!(!draws_underline(None));
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
|
||||
"window": [r.width(), r.height()],
|
||||
"grid": app.grid.cells_rect.map(|c| json!([c.left(), c.top(), c.width(), c.height()])),
|
||||
"scroll": [v.scroll.x, v.scroll.y],
|
||||
"editor": app.editor.as_ref().map(|e| json!({"cell": e.cell.a1(), "text": e.text, "formulaBar": e.from_formula_bar, "point": e.can_point()})),
|
||||
"editor": app.editor.as_ref().map(|e| editor_json(app, e)),
|
||||
"dialog": app.dialog.as_ref().map(|d| json!({"name": d.name, "title": d.title, "values": d.values, "tab": d.tab})),
|
||||
"backstage": app.backstage.as_ref().map(|b| b.page.id()),
|
||||
"message": app.message.as_ref().map(|(t, m)| json!({"title": t, "text": m})),
|
||||
@@ -162,6 +162,52 @@ pub fn inspect(app: &SheetApp, ctx: &egui::Context) -> Json {
|
||||
})
|
||||
}
|
||||
|
||||
/// The open edit, for `ui.inspect`. The draft is left out (`text` is null, `hidden` true) while
|
||||
/// its cell's code is hidden on a protected sheet, checked now: a protection command earlier in
|
||||
/// the same batch runs before the next frame. That holds for a draft in Python mode, and for a
|
||||
/// draft that began on a Python cell's code, since leaving Python mode keeps the code as an
|
||||
/// ordinary draft; a draft typed over a Python cell holds only what was typed. The sheet is the
|
||||
/// draft's own, found by identity: a sheet inserted, deleted or moved in the same batch puts
|
||||
/// another at its index. With its sheet gone, such a draft is left out too.
|
||||
fn editor_json(app: &SheetApp, e: &crate::editor::EditState) -> Json {
|
||||
let code = e.python || e.py_cell;
|
||||
let hidden = code
|
||||
&& app.session.documents().iter().find(|d| d.uid == e.doc).is_some_and(|d| match e.sheet_in(&d.wb).and_then(|i| d.wb.sheet(i)) {
|
||||
Some(sh) => gridcraft_engine::display::formula_hidden(&d.wb, sh, e.cell),
|
||||
None => true,
|
||||
});
|
||||
let text = if hidden { Json::Null } else { json!(e.text) };
|
||||
json!({"cell": e.cell.a1(), "text": text, "hidden": hidden, "formulaBar": e.from_formula_bar, "point": e.can_point(), "python": e.python})
|
||||
}
|
||||
|
||||
/// `ui.dialog.cancel`: closes the open dialog as its Cancel button does, going back to the sheet
|
||||
/// and selection from before a reference box picked.
|
||||
fn dialog_cancel(app: &mut SheetApp) -> Outcome {
|
||||
// The macro step editor's draft, overlays and debug run go with its own close.
|
||||
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
|
||||
return match crate::macro_editor::close_now(app) {
|
||||
Ok(()) => ok(Json::Null),
|
||||
Err(e) => err(e),
|
||||
};
|
||||
}
|
||||
// Back to the sheet and selection from before a reference box picked, as Cancel does.
|
||||
// When the dialog's sheet can't be shown again (a text-box draft on the picked sheet
|
||||
// could not be saved), the dialog stays and the script gets the reason; the window's
|
||||
// Cancel closes it and shows the reason, since a person can see the message.
|
||||
// A dialog whose own sheet was deleted (found by identity, so a new sheet given its
|
||||
// name does not count) replies `{"sheetDeleted": true}`: the sheet shown is not the
|
||||
// dialog's, and a script stops before running the dialog's command itself.
|
||||
let mut deleted = false;
|
||||
if let Some(mut d) = app.dialog.take() {
|
||||
deleted = d.home_sheet_deleted(app);
|
||||
if let Err(e) = d.end_pick(app) {
|
||||
app.dialog = Some(d);
|
||||
return err(e);
|
||||
}
|
||||
}
|
||||
ok(if deleted { json!({"sheetDeleted": true}) } else { Json::Null })
|
||||
}
|
||||
|
||||
/// `ui.object.select {id?, kind?}`: selects the object with that id on the active sheet (its kind
|
||||
/// is found from the id when left out, and must match when given); no `id` clears the selection.
|
||||
/// Replies with the object and its rect in window points, for `ui.click` and `ui.drag`.
|
||||
@@ -218,6 +264,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("app.checkForUpdates", "Check for Updates…", ""),
|
||||
("view.collapseRibbon", "Collapse Ribbon", ""),
|
||||
("view.expandFormulaBar", "Expand Formula Bar", "{on?: bool} (toggles without it) → {on}"),
|
||||
("ui.pythonMode", "Python Mode", "{on?: bool} (toggles without it) → {on}; a refused start is an error"),
|
||||
("view.darkMode", "Dark Mode", ""),
|
||||
("view.theme", "Display Theme", ""),
|
||||
("view.animations", "Animations", "{on?: bool} (toggles without it) → {on}"),
|
||||
@@ -290,8 +337,21 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
None => err("no dialog is open"),
|
||||
},
|
||||
"ui.dialog.confirm" => match app.dialog.take() {
|
||||
Some(d) => match d.command {
|
||||
Some(mut d) => match d.command {
|
||||
Some(cmd) => {
|
||||
// References without a sheet name mean the sheet the dialog was opened on,
|
||||
// as OK in the window has it: refused once that sheet is deleted, else shown
|
||||
// again before the command runs.
|
||||
if d.home_sheet_deleted(app) {
|
||||
app.dialog = Some(d);
|
||||
return err(crate::dialogs::SHEET_DELETED);
|
||||
}
|
||||
if let Err(e) = d.end_pick(app) {
|
||||
// The dialog's sheet can't be shown again (a text-box draft on the picked
|
||||
// sheet could not be saved): the command stays unrun, the dialog open.
|
||||
app.dialog = Some(d);
|
||||
return err(e);
|
||||
}
|
||||
let params = crate::dialogs::build_params(&d);
|
||||
let home = app.ui_window();
|
||||
let r = app.run(cmd, params);
|
||||
@@ -307,17 +367,7 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
},
|
||||
None => err("no dialog is open"),
|
||||
},
|
||||
"ui.dialog.cancel" => {
|
||||
// The macro step editor's draft, overlays and debug run go with its own close.
|
||||
if app.dialog.as_ref().is_some_and(|d| d.name == "macroEditor") {
|
||||
return match crate::macro_editor::close_now(app) {
|
||||
Ok(()) => ok(Json::Null),
|
||||
Err(e) => err(e),
|
||||
};
|
||||
}
|
||||
app.dialog = None;
|
||||
ok(Json::Null)
|
||||
}
|
||||
"ui.dialog.cancel" => dialog_cancel(app),
|
||||
"ui.object.select" => select_object(app, p),
|
||||
"ui.message.dismiss" => {
|
||||
app.message = None;
|
||||
|
||||
@@ -977,3 +977,280 @@ fn synthetic_text_key_and_move_producers_honour_the_queue_budget() {
|
||||
assert_eq!(reply["ok"], false, "{reply}");
|
||||
assert_eq!(app.synthetic.iter().map(Vec::len).sum::<usize>(), MAX_QUEUED_SYNTHETIC_EVENTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_ui_commands_are_listed() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let Outcome::Done(reply) = handle(&mut app, &egui::Context::default(), "engine.commands", &json!({"search": "python"})) else {
|
||||
panic!("command discovery must return immediately");
|
||||
};
|
||||
let commands = reply["result"].as_array().cloned().unwrap_or_default();
|
||||
let listed = [("ui.pythonMode", "{on?: bool} (toggles without it) → {on}; a refused start is an error")];
|
||||
for (id, docs) in listed {
|
||||
let command = commands.iter().find(|c| c["id"] == id).unwrap_or_else(|| panic!("engine.commands lists {id}"));
|
||||
assert_eq!(command["ui"], true, "{id}");
|
||||
assert_eq!(command["params"], docs, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `command` through the control channel, as one line of a batch: no frame runs after it.
|
||||
fn execute(app: &mut SheetApp, ctx: &egui::Context, command: &str, params: Json) {
|
||||
let Outcome::Done(reply) = handle(app, ctx, "engine.execute", &json!({"command": command, "params": params})) else {
|
||||
panic!("{command} answers at once");
|
||||
};
|
||||
assert_eq!(reply["ok"], true, "{command}: {reply}");
|
||||
}
|
||||
|
||||
fn inspected(app: &mut SheetApp, ctx: &egui::Context) -> Json {
|
||||
let Outcome::Done(reply) = handle(app, ctx, "ui.inspect", &json!({})) else { panic!("ui.inspect answers at once") };
|
||||
reply["result"].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_on_code_hidden_in_the_same_batch_shows_no_code() {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
let before = inspected(&mut app, &ctx);
|
||||
assert_eq!(before["editor"]["text"], "secret = 41\nsecret + 1", "unprotected, the draft shows its code");
|
||||
// Protected on the session itself, so no app command drops the draft first: `ui.inspect`
|
||||
// still leaves the code out, checked when it is called.
|
||||
app.session.execute("review.protectSheet", json!({})).unwrap();
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.python), "the draft is still open");
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["text"], Json::Null);
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
// Out of Python mode the draft still holds the hidden cell's code.
|
||||
if let Some(e) = app.editor.as_mut() {
|
||||
e.leave_python();
|
||||
}
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft holds the code");
|
||||
let left = inspected(&mut app, &ctx);
|
||||
assert!(!left.to_string().contains("secret"), "{left}");
|
||||
assert_eq!(left["editor"]["hidden"], true);
|
||||
}
|
||||
|
||||
/// Answers one control request that must answer at once with `ok`.
|
||||
fn call(app: &mut SheetApp, ctx: &egui::Context, method: &str, params: Json) {
|
||||
let Outcome::Done(reply) = handle(app, ctx, method, ¶ms) else { panic!("{method} answers at once") };
|
||||
assert_eq!(reply["ok"], true, "{method}: {reply}");
|
||||
}
|
||||
|
||||
/// A Python draft of hidden code at B2 on Sheet1, protected after the draft opened, with an
|
||||
/// unprotected sheet `Other` after it when `other` is set.
|
||||
fn hidden_python_draft(other: bool) -> (SheetApp, egui::Context) {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41\nsecret + 1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true}}})).unwrap();
|
||||
if other {
|
||||
session.execute("home.insertSheet", json!({"name": "Other", "before": 1})).unwrap();
|
||||
session.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
}
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
// On the session itself, so the draft stays open for `ui.inspect` to be checked against.
|
||||
app.session.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"]["hidden"], true, "hidden on its own sheet");
|
||||
(app, ctx)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_shows_no_code_when_another_sheet_takes_its_index_in_the_same_batch() {
|
||||
let (mut app, ctx) = hidden_python_draft(false);
|
||||
// An unprotected sheet now sits at the draft's old index, in the same batch.
|
||||
app.session.execute("home.insertSheet", json!({"name": "New", "before": 0})).unwrap();
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["text"], Json::Null);
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_shows_no_code_once_its_sheet_is_deleted_in_the_same_batch() {
|
||||
let (mut app, ctx) = hidden_python_draft(true);
|
||||
// The draft's own sheet goes, and the unprotected `Other` takes its index.
|
||||
app.session.execute("home.deleteSheet", json!({"sheet": "Sheet1"})).unwrap();
|
||||
assert_eq!(app.session.active().map(|d| d.wb.sheets.len()), Some(1));
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert!(!after.to_string().contains("secret"), "{after}");
|
||||
assert_eq!(after["editor"]["hidden"], true);
|
||||
// The next app command (an undo that brings the protected sheet back) drops the draft, since
|
||||
// its code is hidden there; deleting a draft's sheet is covered by
|
||||
// `a_draft_whose_sheet_is_deleted_is_dropped_with_a_message`.
|
||||
execute(&mut app, &ctx, "edit.undo", json!({}));
|
||||
assert!(app.editor.is_none(), "the draft is dropped");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN));
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null);
|
||||
}
|
||||
|
||||
/// Sheet `name`'s A1: its input text and whether it is a Python cell.
|
||||
fn a1(app: &SheetApp, name: &str) -> Option<(String, bool)> {
|
||||
let d = app.session.active()?;
|
||||
let sh = d.wb.sheet(d.wb.sheet_index(name)?)?;
|
||||
sh.cell(gridcraft_engine::core::CellRef::new(0, 0)).map(|c| (c.input_text(), c.py.is_some()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_is_committed_on_its_own_sheet_after_a_sheet_is_inserted_before_it() {
|
||||
// An ordinary draft and a Python draft on Sheet1!A1, then a sheet inserted before Sheet1.
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"}));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "New", "before": 0}));
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
assert!(app.editor.is_none(), "python {python}");
|
||||
assert_eq!(a1(&app, "New"), None, "python {python}: nothing lands on the inserted sheet");
|
||||
let got = a1(&app, "Sheet1");
|
||||
if python {
|
||||
assert_eq!(got.map(|g| g.1), Some(true), "the code lands on the draft's own sheet");
|
||||
} else {
|
||||
assert_eq!(got, Some(("42".to_string(), false)), "the entry lands on the draft's own sheet");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_whose_sheet_is_deleted_is_dropped_with_a_message() {
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1}));
|
||||
execute(&mut app, &ctx, "sheet.activate", json!({"sheet": 0}));
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
call(&mut app, &ctx, "ui.edit.text", json!({"text": "1 + 1"}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "42"}));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.deleteSheet", json!({"sheet": "Sheet1"}));
|
||||
// Dropped once the command is done, not left drawn on the sheet in its place.
|
||||
assert!(app.editor.is_none(), "python {python}: the draft is dropped");
|
||||
assert_eq!(inspected(&mut app, &ctx)["editor"], Json::Null, "python {python}");
|
||||
assert_eq!(a1(&app, "Other"), None, "python {python}: nothing lands on the sheet in its place");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_SHEET_GONE), "python {python}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_draft_follows_its_sheet_when_a_sheet_is_inserted_before_it() {
|
||||
for python in [false, true] {
|
||||
let mut app = test_app();
|
||||
let ctx = egui::Context::default();
|
||||
if python {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
} else {
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=SUM("}));
|
||||
}
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
ed.point_anchor = Some((0, gridcraft_engine::core::CellRef::parse("C3").unwrap()));
|
||||
}
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Before", "before": 0}));
|
||||
let ed = app.editor.as_ref().expect("the draft stays open");
|
||||
assert_eq!(ed.sheet, 1, "python {python}: the draft is on Sheet1, now at index 1, so it is drawn there");
|
||||
assert_eq!(ed.point_anchor, None, "python {python}: the anchor's sheet index no longer names its sheet");
|
||||
// A rename changes no index: nothing is let go.
|
||||
if let Some(ed) = app.editor.as_mut() {
|
||||
ed.point_anchor = Some((1, gridcraft_engine::core::CellRef::parse("C3").unwrap()));
|
||||
}
|
||||
execute(&mut app, &ctx, "sheet.rename", json!({"sheet": "Before", "name": "First"}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.sheet == 1 && e.point_anchor.is_some()), "python {python}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_of_python_code_that_protection_hides_is_dropped_with_a_message() {
|
||||
// In Python mode, and after leaving it (an ordinary draft of a Python cell's code).
|
||||
for leave in [false, true] {
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap();
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2:C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": true}));
|
||||
if leave {
|
||||
execute(&mut app, &ctx, "ui.pythonMode", json!({"on": false}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text.contains("secret")), "an ordinary draft of the code");
|
||||
}
|
||||
execute(&mut app, &ctx, "review.protectSheet", json!({}));
|
||||
assert!(app.editor.is_none(), "leave {leave}: neither editor keeps hidden code on screen");
|
||||
assert_eq!(app.message.as_ref().map(|m| m.1.as_str()), Some(crate::app::DRAFT_CODE_HIDDEN), "leave {leave}");
|
||||
assert!(!inspected(&mut app, &ctx).to_string().contains("secret"));
|
||||
}
|
||||
// An ordinary formula draft on a hidden cell stays open, as on main.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": "=1+1"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "C2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "C2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "=2+2"}));
|
||||
execute(&mut app, &ctx, "review.protectSheet", json!({}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.text == "=2+2"), "a formula draft is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_draft_typed_over_a_hidden_python_cell_stays_open_through_commands() {
|
||||
// An unlocked, hidden Python cell on a protected sheet: typing replaces it, as in Excel. The
|
||||
// draft holds only what was typed, so a command neither drops it nor hides it from ui.inspect.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "B2", "code": "secret = 41"})).unwrap();
|
||||
session.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"hidden": true, "locked": false}}})).unwrap();
|
||||
session.execute("review.protectSheet", json!({})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "7"}));
|
||||
execute(&mut app, &ctx, "home.insertSheet", json!({"name": "Other", "before": 1}));
|
||||
assert!(app.editor.as_ref().is_some_and(|e| e.text == "7"), "the typed entry stays open");
|
||||
assert!(app.message.is_none(), "{:?}", app.message);
|
||||
let after = inspected(&mut app, &ctx);
|
||||
assert_eq!(after["editor"]["text"], "7");
|
||||
assert_eq!(after["editor"]["hidden"], false);
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
let b2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 1)).cloned());
|
||||
assert_eq!(b2.map(|c| (c.input_text(), c.py.is_some())), Some(("7".to_string(), false)), "the typed entry replaces the Python cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_mode_does_not_start_on_a_draft_typed_inside_a_spill() {
|
||||
// Typing on a spill child is an ordinary entry; switching that draft to Python mode would let
|
||||
// Ctrl+Enter write code into a cell another cell's result spills over, which every other way
|
||||
// into Python mode refuses.
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let mut app = SheetApp::new(session, crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
call(&mut app, &ctx, "ui.edit.begin", json!({"text": "5"}));
|
||||
let Outcome::Done(reply) = handle(&mut app, &ctx, "engine.execute", &json!({"command": "ui.pythonMode", "params": {"on": true}})) else {
|
||||
panic!("ui.pythonMode answers at once");
|
||||
};
|
||||
assert_eq!(reply["ok"], false, "the start is refused: {reply}");
|
||||
assert!(app.editor.as_ref().is_some_and(|e| !e.python && e.text == "5"), "the draft stays an ordinary entry");
|
||||
call(&mut app, &ctx, "ui.edit.commit", json!({"move": "none"}));
|
||||
let a2 = app.session.active().and_then(|d| d.wb.sheet(0)).and_then(|sh| sh.cell(gridcraft_engine::core::CellRef::new(1, 0)).cloned());
|
||||
assert_eq!(a2.map(|c| (c.input_text(), c.py.is_some())), Some(("5".to_string(), false)));
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ mod format_cells;
|
||||
mod insert_function;
|
||||
mod names;
|
||||
mod paste;
|
||||
mod ref_edit;
|
||||
mod show;
|
||||
mod spelling;
|
||||
mod zoom;
|
||||
@@ -34,13 +35,17 @@ use data::{abs, data_validation_fields, remove_duplicates_range, validation_defa
|
||||
use find::go_to_entries;
|
||||
pub(crate) use names::insert_name;
|
||||
use names::pasteable_names;
|
||||
pub(crate) use ref_edit::SHEET_DELETED;
|
||||
pub(crate) use show::build_params;
|
||||
pub use show::show;
|
||||
use zoom::zoom_preset;
|
||||
|
||||
/// A form field. `Ref` is a range reference that can also be picked by selecting on the grid
|
||||
/// (a RefEdit box).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Field {
|
||||
Text { key: &'static str, label: &'static str },
|
||||
Ref { key: &'static str, label: &'static str },
|
||||
Password { key: &'static str, label: &'static str },
|
||||
Number { key: &'static str, label: &'static str },
|
||||
Choice { key: &'static str, label: &'static str, options: Vec<(&'static str, &'static str)> },
|
||||
@@ -74,6 +79,8 @@ pub struct Dialog {
|
||||
pick: Option<RefPick>,
|
||||
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
|
||||
original_link_text: Option<String>,
|
||||
/// The state of the dialog's reference boxes (RefEdit).
|
||||
refs: ref_edit::RefBoxes,
|
||||
}
|
||||
|
||||
/// A formula box in point mode, as in Excel: a click on the grid writes the cell's absolute
|
||||
@@ -127,6 +134,39 @@ impl Dialog {
|
||||
self.values.insert(key.into(), value);
|
||||
}
|
||||
|
||||
/// The reference box the dialog is collapsed to, if any.
|
||||
pub fn collapsed(&self) -> Option<&str> {
|
||||
self.refs.collapsed.as_deref()
|
||||
}
|
||||
|
||||
/// Collapses the dialog to its reference box `key` and starts picking into it, as the box's
|
||||
/// collapse button does.
|
||||
pub fn collapse_to(&mut self, app: &SheetApp, key: &str) {
|
||||
ref_edit::begin_pick(app, self, key);
|
||||
self.refs.collapsed = Some(key.to_string());
|
||||
}
|
||||
|
||||
/// Ends picking into the dialog's reference boxes and puts back the sheet and selection from
|
||||
/// before picking began, as closing the dialog does. Fails when the dialog's sheet could not
|
||||
/// be shown again (a text-box draft on the picked sheet could not be saved): a dialog about
|
||||
/// to run its command must then stay open instead.
|
||||
pub(crate) fn end_pick(&mut self, app: &mut SheetApp) -> Result<(), String> {
|
||||
ref_edit::end_pick(app, self)
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// that sheet exists (the sheet [`Self::end_pick`] shows again); `None` otherwise.
|
||||
pub(crate) fn home_sheet_protected(&self, app: &SheetApp) -> Option<bool> {
|
||||
ref_edit::home_sheet_protected(app, self)
|
||||
}
|
||||
|
||||
/// Whether the sheet the dialog was opened on was deleted while its reference boxes picked:
|
||||
/// OK is then refused with [`SHEET_DELETED`], since references without a sheet name have
|
||||
/// nothing to refer to.
|
||||
pub(crate) fn home_sheet_deleted(&mut self, app: &SheetApp) -> bool {
|
||||
ref_edit::home_sheet_deleted(app, self)
|
||||
}
|
||||
|
||||
/// Whether a click on the grid goes to a formula box of this dialog rather than the selection.
|
||||
pub fn picks_references(&self) -> bool {
|
||||
self.pick.is_some()
|
||||
@@ -187,6 +227,7 @@ impl Dialog {
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
refs: ref_edit::RefBoxes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,6 +251,7 @@ impl Dialog {
|
||||
focus_initialized: false,
|
||||
pick: None,
|
||||
original_link_text: None,
|
||||
refs: ref_edit::RefBoxes::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,43 @@
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, ref_edit};
|
||||
use super::show::{build_params, form_fields, ok_cancel};
|
||||
use super::{Dialog, Field, Json, Map, SheetApp, json, vec2};
|
||||
|
||||
pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool) {
|
||||
let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let (mut set, mut to, mut changing) = (get("set"), get("to"), get("changing"));
|
||||
/// Data › What-If Analysis › Goal Seek. OK runs `data.goalSeek` here; Cancel clears `open`, as
|
||||
/// the shared buttons do, so cancelling is never taken for a confirmation.
|
||||
pub(super) fn goal_seek(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
egui::Grid::new("gs").show(ui, |ui| {
|
||||
ui.label("Set cell:");
|
||||
ui.text_edit_singleline(&mut set);
|
||||
let l = ui.label("Set cell:");
|
||||
ref_edit(app, ui, d, "set", RefLabel::By(l.id), 200.0);
|
||||
ui.end_row();
|
||||
ui.label("To value:");
|
||||
ui.text_edit_singleline(&mut to);
|
||||
let l = ui.label("To value:");
|
||||
let mut to = d.values.get("to").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut to).desired_width(170.0)).labelled_by(l.id).changed() {
|
||||
d.values.insert("to".into(), json!(to));
|
||||
}
|
||||
ui.end_row();
|
||||
ui.label("By changing cell:");
|
||||
ui.text_edit_singleline(&mut changing);
|
||||
let l = ui.label("By changing cell:");
|
||||
ref_edit(app, ui, d, "changing", RefLabel::By(l.id), 200.0);
|
||||
ui.end_row();
|
||||
});
|
||||
d.values.insert("set".into(), json!(set.clone()));
|
||||
d.values.insert("to".into(), json!(to.clone()));
|
||||
d.values.insert("changing".into(), json!(changing.clone()));
|
||||
let mut open = true;
|
||||
ok_cancel(ui, confirm, &mut open);
|
||||
let get = |k: &str| d.values.get(k).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let (set, to, changing) = (get("set"), get("to"), get("changing"));
|
||||
ok_cancel(ui, confirm, open);
|
||||
if *confirm && home_sheet_deleted(app, d) {
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
*confirm = false;
|
||||
}
|
||||
if *confirm && let Err(e) = end_pick(app, d) {
|
||||
// The dialog's sheet can't be shown again (a draft on the picked sheet could not be
|
||||
// saved): the cells would be read against the wrong sheet, so Goal Seek doesn't run.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
*confirm = false;
|
||||
}
|
||||
if *confirm {
|
||||
match app.run("data.goalSeek", json!({"set": set, "to": to.parse::<f64>().unwrap_or(0.0), "changing": changing})) {
|
||||
Ok(r) => app.toast = Some((format!("Goal Seeking found a solution: {}", r["value"]), crate::now_ms())),
|
||||
Err(e) => app.message = Some(("Goal Seek".into(), crate::clean_error(&e))),
|
||||
}
|
||||
}
|
||||
if !open {
|
||||
*confirm = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The fields of one Analysis ToolPak tool's form (input and options; output is drawn apart).
|
||||
@@ -36,8 +45,8 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
use Field::*;
|
||||
let grouped = || Choice { key: "groupedBy", label: "Grouped By:", options: vec![("columns", "Columns"), ("rows", "Rows")] };
|
||||
let labels = || Check { key: "labels", label: "Labels in first row" };
|
||||
let range = || Text { key: "range", label: "Input Range:" };
|
||||
let two = || vec![Text { key: "range1", label: "Variable 1 Range:" }, Text { key: "range2", label: "Variable 2 Range:" }];
|
||||
let range = || Ref { key: "range", label: "Input Range:" };
|
||||
let two = || vec![Ref { key: "range1", label: "Variable 1 Range:" }, Ref { key: "range2", label: "Variable 2 Range:" }];
|
||||
match tool {
|
||||
"descriptive" => vec![
|
||||
range(),
|
||||
@@ -50,7 +59,7 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
],
|
||||
"histogram" => vec![
|
||||
range(),
|
||||
Text { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||||
Ref { key: "bins", label: "Bin Range (blank for automatic bins):" },
|
||||
labels(),
|
||||
Check { key: "pareto", label: "Pareto (sorted histogram)" },
|
||||
Check { key: "cumulative", label: "Cumulative Percentage" },
|
||||
@@ -69,8 +78,8 @@ fn toolpak_fields(tool: &str, values: &Map<String, Json>) -> Vec<Field> {
|
||||
Text { key: "alpha", label: "Alpha:" },
|
||||
],
|
||||
"regression" => vec![
|
||||
Text { key: "inputY", label: "Input Y Range:" },
|
||||
Text { key: "inputX", label: "Input X Range:" },
|
||||
Ref { key: "inputY", label: "Input Y Range:" },
|
||||
Ref { key: "inputX", label: "Input X Range:" },
|
||||
labels(),
|
||||
Check { key: "constantZero", label: "Constant is Zero" },
|
||||
Text { key: "confidence", label: "Confidence Level (%):" },
|
||||
@@ -262,19 +271,36 @@ pub(super) fn analysis_toolpak(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Di
|
||||
// The tool's options scroll in a short window (the patterned Random Number Generation form
|
||||
// is taller than 600 points); the output options and OK below always stay in view.
|
||||
let room = (ui.ctx().content_rect().height() - TOOLPAK_CHROME).max(120.0);
|
||||
egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(ui, d));
|
||||
egui::ScrollArea::vertical().id_salt("toolpakForm").max_height(room).show(ui, |ui| form_fields(app, ui, d));
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Output options").strong());
|
||||
let to = d.values.get("outputTo").and_then(Json::as_str).unwrap_or("newSheet").to_string();
|
||||
for (key, choice, text_label) in [("output", "range", "Output Range:"), ("newSheet", "newSheet", "New Worksheet Ply:")] {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.radio(to == choice, text_label).clicked() {
|
||||
let r = ui.radio(to == choice, text_label);
|
||||
if r.clicked() {
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
// Another output option ends picking into Output Range, so a later selection on
|
||||
// the grid doesn't choose it again.
|
||||
if key != "output" && d.refs.target.as_deref() == Some("output") {
|
||||
d.refs.target = None;
|
||||
}
|
||||
}
|
||||
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
|
||||
d.values.insert(key.into(), json!(t));
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
if key == "output" {
|
||||
let before = d.values.get(key).cloned();
|
||||
let was_target = d.refs.target.as_deref() == Some(key);
|
||||
ref_edit(app, ui, d, key, RefLabel::By(r.id), 190.0);
|
||||
// Clicking into the box, typing or picking an output range chooses it; only a
|
||||
// change does, so an option chosen later stays chosen.
|
||||
if d.refs.target.as_deref() == Some(key) && (!was_target || d.values.get(key) != before.as_ref()) {
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
} else {
|
||||
let mut t = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
if ui.add(egui::TextEdit::singleline(&mut t).desired_width(160.0)).changed() {
|
||||
d.values.insert(key.into(), json!(t));
|
||||
d.values.insert("outputTo".into(), json!(choice));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -377,6 +403,23 @@ fn clear_solver_options(d: &mut Dialog) {
|
||||
d.values.remove("requireBounds");
|
||||
}
|
||||
|
||||
/// Before one of Solver's commands other than Solve runs from the form (Reset All, Load, Save,
|
||||
/// Replace): the dialog's sheet must be there and shown again, as `dialogs::show` requires for
|
||||
/// Solve, since the commands act on the active sheet and read references without a sheet name
|
||||
/// against it. Shows the reason and answers `false` when the command must not run: the sheet
|
||||
/// was deleted, or it cannot be shown again (a draft on the picked sheet could not be saved).
|
||||
fn at_home(app: &mut SheetApp, d: &mut Dialog) -> bool {
|
||||
if home_sheet_deleted(app, d) {
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = end_pick(app, d) {
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Save Model from the Solver Parameters form: writes the form's model down from `range`. A
|
||||
/// range that holds data is replaced only with `overwrite`; without it the form asks first.
|
||||
fn save_solver_model(app: &mut SheetApp, d: &mut Dialog, range: &str, overwrite: bool) {
|
||||
@@ -410,8 +453,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
return;
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Set Objective:");
|
||||
edit(ui, d, "objective", 200.0, "Set Objective:");
|
||||
let l = ui.label("Set Objective:");
|
||||
ref_edit(app, ui, d, "objective", RefLabel::By(l.id), 230.0);
|
||||
});
|
||||
let goal = text(d, "goal");
|
||||
ui.horizontal(|ui| {
|
||||
@@ -423,8 +466,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
edit(ui, d, "value", 80.0, "Value Of:");
|
||||
});
|
||||
ui.label("By Changing Variable Cells:");
|
||||
edit(ui, d, "variables", 420.0, "By Changing Variable Cells:");
|
||||
let l = ui.label("By Changing Variable Cells:");
|
||||
ref_edit(app, ui, d, "variables", RefLabel::By(l.id), 420.0);
|
||||
ui.label("Subject to the Constraints:");
|
||||
let list: Vec<String> = d
|
||||
.values
|
||||
@@ -443,7 +486,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
edit(ui, d, "newLhs", 110.0, "Constraint Cell Reference");
|
||||
ref_edit(app, ui, d, "newLhs", RefLabel::Named("Constraint Cell Reference"), 130.0);
|
||||
let op = text(d, "newOp");
|
||||
craft_ui::ComboBox::from_id_salt("solverOp")
|
||||
.selected_text(op.clone())
|
||||
@@ -458,7 +501,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
.response
|
||||
.accessible_name(lang.tr("Constraint Operator"));
|
||||
if !matches!(op.as_str(), "int" | "bin") {
|
||||
edit(ui, d, "newRhs", 110.0, "Constraint Value");
|
||||
ref_edit(app, ui, d, "newRhs", RefLabel::Named("Constraint Value"), 130.0);
|
||||
}
|
||||
if ui.button("Add").clicked() {
|
||||
let lhs = text(d, "newLhs");
|
||||
@@ -513,7 +556,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Reset All").clicked() {
|
||||
if ui.button("Reset All").clicked() && at_home(app, d) {
|
||||
match app.run("data.solverReset", json!({})) {
|
||||
Ok(_) => {
|
||||
for (k, v) in [
|
||||
@@ -539,6 +582,8 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
}
|
||||
let shown = d.values.get("modelArea").and_then(Json::as_bool).unwrap_or(false);
|
||||
// Hiding the area ends picking into Model Range, as a box that is not drawn is no longer
|
||||
// picked into (`ref_edit::window_shown`).
|
||||
if ui.selectable_label(shown, "Load/Save").clicked() {
|
||||
d.values.insert("modelArea".into(), json!(!shown));
|
||||
}
|
||||
@@ -548,11 +593,11 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
});
|
||||
if d.values.get("modelArea").and_then(Json::as_bool) == Some(true) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Model Range:");
|
||||
edit(ui, d, "modelRange", 140.0, "Model Range:");
|
||||
let l = ui.label("Model Range:");
|
||||
ref_edit(app, ui, d, "modelRange", RefLabel::By(l.id), 170.0);
|
||||
let range = text(d, "modelRange");
|
||||
let ready = !range.trim().is_empty();
|
||||
if ui.add_enabled(ready, egui::Button::new("Load")).clicked() {
|
||||
if ui.add_enabled(ready, egui::Button::new("Load")).clicked() && at_home(app, d) {
|
||||
d.values.remove("askModelOverwrite");
|
||||
match app.run("data.solverLoadModel", json!({"from": range.trim()})) {
|
||||
Ok(r) => {
|
||||
@@ -570,7 +615,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
}
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(ready, egui::Button::new("Save")).clicked() {
|
||||
if ui.add_enabled(ready, egui::Button::new("Save")).clicked() && at_home(app, d) {
|
||||
save_solver_model(app, d, &range, false);
|
||||
}
|
||||
});
|
||||
@@ -583,7 +628,7 @@ pub(super) fn solver(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, conf
|
||||
} else if let Some(asked) = asked {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(format!("{asked} already holds data. Replace it with the model?"));
|
||||
if ui.button("Replace").clicked() {
|
||||
if ui.button("Replace").clicked() && at_home(app, d) {
|
||||
save_solver_model(app, d, &asked, true);
|
||||
}
|
||||
if ui.button("Don't Replace").clicked() {
|
||||
|
||||
@@ -150,15 +150,15 @@ pub(super) fn data_validation_fields(kind: &str, operator: &str) -> Vec<Field> {
|
||||
fields
|
||||
}
|
||||
|
||||
pub(super) fn data_validation_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
pub(super) fn data_validation_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
let kind = d.values.get("type").and_then(Json::as_str).unwrap_or("any");
|
||||
let operator = d.values.get("operator").and_then(Json::as_str).unwrap_or("between");
|
||||
d.fields = data_validation_fields(kind, operator);
|
||||
form_fields(ui, d);
|
||||
form_fields(app, ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
pub(super) fn advanced_filter_form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
use Field::*;
|
||||
let copy = d.values.get("action").and_then(Json::as_str) == Some("copy");
|
||||
d.fields = vec![
|
||||
@@ -174,7 +174,7 @@ pub(super) fn advanced_filter_form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &
|
||||
d.fields.push(Text { key: "copyTo", label: "Copy to:" });
|
||||
}
|
||||
d.fields.push(Check { key: "unique", label: "Unique rows only" });
|
||||
form_fields(ui, d);
|
||||
form_fields(app, ui, d);
|
||||
// Constrain the button row: the shared right-to-left layout otherwise
|
||||
// consumes the window's remaining height instead of fitting the buttons.
|
||||
ui.allocate_ui(vec2(ui.available_width(), ui.spacing().interact_size.y + 8.0), |ui| ok_cancel(ui, confirm, open));
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
//! Reference boxes (Excel's RefEdit): text fields that take a range by selection on the grid, with
|
||||
//! a button that collapses the dialog to the box while the person selects.
|
||||
|
||||
use gridcraft_engine::model::{Sheet, SheetUid, Workbook};
|
||||
|
||||
use super::data::abs;
|
||||
use super::{Dialog, Icon, Json, SheetApp, json, theme, vec2};
|
||||
|
||||
/// What a dialog's reference boxes are doing: which one grid selections fill, and whether the
|
||||
/// dialog is collapsed to one of them.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct RefBoxes {
|
||||
/// The reference box that grid selections are written into.
|
||||
pub(super) target: Option<String>,
|
||||
/// The selection last written into the target (or present when picking began); only a
|
||||
/// different selection replaces what the box holds.
|
||||
seen: String,
|
||||
/// The sheet shown when `seen` was taken: changing sheets only refreshes `seen`, since the
|
||||
/// other sheet's own selection is not a pick.
|
||||
seen_sheet: String,
|
||||
/// The dialog's own sheet, from the first pick until the dialog closes, and the selection to
|
||||
/// put back when it runs its command or closes.
|
||||
home: Option<Home>,
|
||||
/// The target took a selection from the grid and takes the keyboard back once the pointer
|
||||
/// is released, so typing still edits the box.
|
||||
refocus: bool,
|
||||
/// The target box was drawn this frame. A box the dialog stops showing (another view of the
|
||||
/// dialog, a hidden area, a constraint operator that takes no value) is no longer picked
|
||||
/// into, so a selection made meanwhile does not land in it once it is shown again (see
|
||||
/// [`window_shown`]).
|
||||
target_drawn: bool,
|
||||
/// The reference box the dialog is collapsed to.
|
||||
pub(super) collapsed: Option<String>,
|
||||
/// Where the expanded dialog was last drawn: the collapsed dialog keeps its top-left corner,
|
||||
/// and the expanded one comes back where it was.
|
||||
rect: Option<egui::Rect>,
|
||||
/// The dialog was drawn collapsed last frame.
|
||||
was_collapsed: bool,
|
||||
/// A text field other than the collapsed box (the Name Box, say) owned the keyboard when
|
||||
/// the last frame ended: its keys are its own, not the collapsed picker's (see
|
||||
/// [`place_window`]). Kept from the frame before because egui drops the focus for Escape
|
||||
/// before anything is drawn, so the frame of the Escape no longer says who had it.
|
||||
other_field: bool,
|
||||
}
|
||||
|
||||
/// Where picking began: the dialog's own sheet, kept for as long as the dialog is open (a failed
|
||||
/// OK leaves it open, and a later OK still needs to know whether that sheet is there), and the
|
||||
/// selection to put back once.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Home {
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]): fresh for every sheet added,
|
||||
/// inserted, copied or read, and kept through renames, moves, undo and redo. The sheet is
|
||||
/// found by it whatever happened meanwhile ([`home_sheet`]): a sheet given its name or a
|
||||
/// copy of it is never taken for it, and an undo that brings it back makes it the dialog's
|
||||
/// again.
|
||||
uid: SheetUid,
|
||||
/// The ranges and active cell from before picking began on the dialog's sheet, put back once
|
||||
/// when the dialog runs its command or closes.
|
||||
selection: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// The dialog's own sheet in `wb`, or `None` once it was deleted.
|
||||
fn home_sheet<'a>(wb: &'a Workbook, home: &Home) -> Option<&'a Sheet> {
|
||||
wb.sheets.iter().map(|s| &**s).find(|s| s.uid.get() == home.uid.get())
|
||||
}
|
||||
|
||||
/// Whether the dialog's own sheet is protected, once its reference boxes have picked and while
|
||||
/// that sheet exists: the sheet [`end_pick`] shows again. `None` otherwise.
|
||||
pub(super) fn home_sheet_protected(app: &SheetApp, d: &Dialog) -> Option<bool> {
|
||||
let home = d.refs.home.as_ref()?;
|
||||
app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(Sheet::is_protected)
|
||||
}
|
||||
|
||||
/// The message OK shows once the dialog's own sheet is gone.
|
||||
pub(crate) const SHEET_DELETED: &str =
|
||||
"The sheet this dialog was opened on was deleted. Cancel, then open the dialog again on the sheet it should use.";
|
||||
|
||||
/// Whether the sheet the dialog was opened on has been deleted while it picked: its references
|
||||
/// without a sheet name have nothing to refer to, so OK is refused with [`SHEET_DELETED`].
|
||||
pub(super) fn home_sheet_deleted(app: &SheetApp, d: &mut Dialog) -> bool {
|
||||
d.refs.home.as_ref().is_some_and(|h| app.session.active().is_none_or(|doc| home_sheet(&doc.wb, h).is_none()))
|
||||
}
|
||||
|
||||
/// How a reference box is named for screen readers.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum RefLabel<'a> {
|
||||
/// By the label drawn beside it.
|
||||
By(egui::Id),
|
||||
/// By an English name, translated (a box without a label of its own).
|
||||
Named(&'a str),
|
||||
}
|
||||
|
||||
/// The egui id of the reference box `key`, the same wherever the box is drawn.
|
||||
fn ref_edit_id(key: &str) -> egui::Id {
|
||||
egui::Id::unique("gridcraft.ref_edit").with(key)
|
||||
}
|
||||
|
||||
/// Whether the reference box `key` of dialog `dialog` takes several areas joined by commas.
|
||||
/// Solver's variable cells do; every other box takes one cell or range, which its command parses
|
||||
/// as a single reference.
|
||||
fn takes_several_areas(dialog: &str, key: &str) -> bool {
|
||||
dialog == "solver" && key == "variables"
|
||||
}
|
||||
|
||||
/// The grid selection as a reference, with the sheet name when the selection is on another sheet
|
||||
/// than the one the dialog was opened on: every area joined by commas when `several`, otherwise
|
||||
/// only the area being selected (the last one).
|
||||
fn selection_ref(app: &SheetApp, home: Option<&Home>, several: bool) -> String {
|
||||
let Some(doc) = app.session.active() else { return String::new() };
|
||||
let current = [doc.selection.current()];
|
||||
let areas = if several { doc.selection.ranges.as_slice() } else { ¤t[..] }.iter().map(abs);
|
||||
match doc.wb.active() {
|
||||
// Once the dialog's own sheet is deleted, every reference carries its sheet's name.
|
||||
Some(sh) if home.is_some_and(|h| h.uid.get() != sh.uid.get()) => {
|
||||
let sheet = gridcraft_engine::formula::quote_sheet(&sh.name);
|
||||
areas.map(|a| format!("{sheet}!{a}")).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
_ => areas.collect::<Vec<_>>().join(","),
|
||||
}
|
||||
}
|
||||
|
||||
/// Places the dialog's window before it is drawn, and returns the reference box the dialog is
|
||||
/// collapsed to. Escape or Enter expands a collapsed dialog again, rather than closing it or
|
||||
/// pressing OK, and the arrow keys select on the grid, unless another text field (the Name Box)
|
||||
/// owns the keyboard: its keys stay its own, as they were before the dialog collapsed.
|
||||
/// Collapsed, the dialog shrinks to the box where its top-left corner was, leaving the sheet
|
||||
/// free for selecting; expanded again, it comes back where it was.
|
||||
pub(super) fn place_window<'a>(
|
||||
app: &mut SheetApp,
|
||||
ctx: &egui::Context,
|
||||
d: &mut Dialog,
|
||||
window: egui::Window<'a>,
|
||||
window_id: egui::Id,
|
||||
) -> (egui::Window<'a>, Option<String>) {
|
||||
if let Some(key) = d.refs.collapsed.as_deref() {
|
||||
let other_field = d.refs.other_field || other_field_focused(ctx, key);
|
||||
// Escape and Enter are used up either way: left in the frame's input, the dialog would
|
||||
// read them as its own and close or confirm. When another field owned them, that field
|
||||
// (drawn before the dialogs) has read them already, and nothing more happens here.
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape) | i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
|
||||
if !other_field {
|
||||
d.refs.collapsed = None;
|
||||
}
|
||||
} else if !other_field {
|
||||
arrows_move_selection(app, ctx);
|
||||
}
|
||||
}
|
||||
let collapsed = d.refs.collapsed.clone();
|
||||
if collapsed.is_none()
|
||||
&& d.refs.was_collapsed
|
||||
&& let Some(rect) = d.refs.rect
|
||||
&& let Some(mut state) = egui::AreaState::load(ctx, window_id)
|
||||
{
|
||||
// The window's stored position wins over `current_pos` for a window moved by its title
|
||||
// bar, so the expanded position is written into its state.
|
||||
state.pivot = egui::Align2::CENTER_CENTER;
|
||||
state.pivot_pos = Some(rect.center());
|
||||
ctx.memory_mut(|m| m.areas_mut().set_state(egui::LayerId::new(egui::Order::Middle, window_id), state));
|
||||
}
|
||||
let window = match (&collapsed, d.refs.rect) {
|
||||
(Some(_), Some(rect)) => window.pivot(egui::Align2::LEFT_TOP).fixed_pos(rect.min),
|
||||
_ => window,
|
||||
};
|
||||
(window, collapsed)
|
||||
}
|
||||
|
||||
/// Whether a text field other than the reference box `key` has the keyboard.
|
||||
fn other_field_focused(ctx: &egui::Context, key: &str) -> bool {
|
||||
ctx.text_edit_focused() && ctx.memory(|m| m.focused()) != Some(ref_edit_id(key))
|
||||
}
|
||||
|
||||
/// While the dialog is collapsed, the arrow keys move the selection on the grid, Shift+arrow
|
||||
/// extends it and Ctrl+arrow (Cmd on a Mac) jumps to the edge of the data, as they do with no
|
||||
/// dialog open: the box follows the selection as it does for the mouse. The keys are taken out
|
||||
/// of the frame's input before the box's text field reads them, so they don't move its caret
|
||||
/// instead; expanded, they edit the box.
|
||||
fn arrows_move_selection(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp};
|
||||
let mut moves = Vec::new();
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|e| match e {
|
||||
egui::Event::Key { key: key @ (ArrowUp | ArrowDown | ArrowLeft | ArrowRight), pressed: true, modifiers, .. } => {
|
||||
moves.push((*key, *modifiers));
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
});
|
||||
if moves.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Arrows follow the screen: on a right-to-left sheet Left goes to the next column.
|
||||
let rtl = app.session.active().and_then(|d| d.wb.active()).is_some_and(|sh| sh.right_to_left);
|
||||
for (key, m) in moves {
|
||||
let (dr, dc) = match key {
|
||||
ArrowUp => (-1, 0),
|
||||
ArrowDown => (1, 0),
|
||||
ArrowLeft => (0, if rtl { 1 } else { -1 }),
|
||||
_ => (0, if rtl { -1 } else { 1 }),
|
||||
};
|
||||
let from = app.session.active().map(|d| if m.shift { d.selection.moving_end() } else { d.selection.active });
|
||||
let _ = app.run("selection.move", json!({"dr": dr, "dc": dc, "extend": m.shift, "jump": m.command}));
|
||||
app.grid.ensure_visible = true;
|
||||
app.grid.reveal_moving_end = m.shift;
|
||||
app.grid.reveal_from = from;
|
||||
}
|
||||
}
|
||||
|
||||
/// After the dialog's window is drawn: remembers where the expanded window is, and ends picking
|
||||
/// into a reference box when the box was not drawn (the dialog stopped showing it) or when
|
||||
/// another text field of the dialog takes the keyboard. A collapsed dialog shows no other field,
|
||||
/// so its box keeps picking whatever else is clicked (the Name Box, the formula bar, the
|
||||
/// ribbon): only Escape, Enter or the expand button end that state.
|
||||
pub(super) fn window_shown(ctx: &egui::Context, d: &mut Dialog, collapsed: bool, rect: Option<egui::Rect>) {
|
||||
if !collapsed {
|
||||
d.refs.rect = rect;
|
||||
}
|
||||
d.refs.was_collapsed = collapsed;
|
||||
d.refs.other_field = d.refs.collapsed.as_deref().is_some_and(|key| other_field_focused(ctx, key));
|
||||
// The box compares the grid selection with the one it last saw only while it is drawn: a
|
||||
// hidden box would take a selection made meanwhile when it comes back.
|
||||
if !std::mem::take(&mut d.refs.target_drawn) {
|
||||
d.refs.target = None;
|
||||
}
|
||||
if !collapsed && ctx.text_edit_focused() && d.refs.target.as_deref().is_none_or(|key| !ctx.memory(|m| m.has_focus(ref_edit_id(key)))) {
|
||||
d.refs.target = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the reference box `key` the one grid selections fill. The first pick remembers the
|
||||
/// sheet and selection, which are put back when the dialog runs its command or closes.
|
||||
pub(super) fn begin_pick(app: &SheetApp, d: &mut Dialog, key: &str) {
|
||||
if let Some(doc) = app.session.active()
|
||||
&& let Some(sheet) = doc.wb.active()
|
||||
{
|
||||
let ranges: Vec<String> = doc.selection.ranges.iter().map(|r| r.a1()).collect();
|
||||
let selection = Some((ranges.join(","), doc.selection.active.a1()));
|
||||
match d.refs.home.as_mut() {
|
||||
None => d.refs.home = Some(Home { uid: sheet.uid, selection }),
|
||||
// Picking again after a failed OK: the selection to put back is the one now on the
|
||||
// dialog's sheet.
|
||||
Some(home) if home.selection.is_none() && home.uid.get() == sheet.uid.get() => {
|
||||
home.selection = selection;
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
d.refs.target = Some(key.to_string());
|
||||
d.refs.refocus = false;
|
||||
d.refs.seen = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
d.refs.seen_sheet = active_sheet_name(app);
|
||||
}
|
||||
|
||||
/// The name of the sheet shown in the active workbook.
|
||||
fn active_sheet_name(app: &SheetApp) -> String {
|
||||
app.session.active().and_then(|doc| doc.wb.active()).map(|sh| sh.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Stops picking and goes back to the dialog's own sheet, putting back the selection from before
|
||||
/// picking began (once), unless that sheet was deleted: the selection is then left where it is.
|
||||
/// The sheet itself is kept until the dialog closes.
|
||||
///
|
||||
/// The commands go through [`SheetApp::run`], as the ribbon's do: showing the sheet first saves
|
||||
/// a text-box draft left open on the sheet picked on, and the UI's after-command pass runs.
|
||||
///
|
||||
/// Fails when the sheet could not be shown again (the draft could not be saved): the person
|
||||
/// stays with their text, the selection waits for the next time the dialog lets go, and a
|
||||
/// dialog about to run its command must not, since references without a sheet name would be
|
||||
/// read against the sheet still shown.
|
||||
pub(super) fn end_pick(app: &mut SheetApp, d: &mut Dialog) -> Result<(), String> {
|
||||
d.refs.target = None;
|
||||
d.refs.collapsed = None;
|
||||
let Some(home) = d.refs.home.as_mut() else { return Ok(()) };
|
||||
let Some(sheet) = app.session.active().and_then(|doc| home_sheet(&doc.wb, home)).map(|s| s.name.clone()) else { return Ok(()) };
|
||||
// After an OK that failed, the selection is already back and only the sheet is shown again,
|
||||
// so references without a sheet name mean it.
|
||||
if !active_sheet_name(app).eq_ignore_ascii_case(&sheet) {
|
||||
app.run("sheet.activate", json!({"sheet": sheet}))?;
|
||||
}
|
||||
if let Some((range, active)) = home.selection.take() {
|
||||
let _ = app.run("selection.set", json!({"range": range, "active": active}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A reference box (RefEdit): a text field that the grid selection fills while it is the box
|
||||
/// being picked into, and a button that collapses the dialog to the box or expands it again.
|
||||
pub(super) fn ref_edit(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: RefLabel<'_>, width: f32) {
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
let mut s = d.values.get(key).and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let collapsed = d.refs.collapsed.as_deref() == Some(key);
|
||||
ui.horizontal(|ui| {
|
||||
let id = ref_edit_id(key);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut s).id(id).desired_width(width - 30.0));
|
||||
let r = match label {
|
||||
RefLabel::By(label) => r.labelled_by(label),
|
||||
RefLabel::Named(name) => r.accessible_name(lang.tr(name)),
|
||||
};
|
||||
// F4 cycles the anchors of the reference in the box, as in Excel's reference boxes.
|
||||
if r.has_focus() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F4)) {
|
||||
s = cycle_anchors(&s);
|
||||
}
|
||||
if r.gained_focus() {
|
||||
begin_pick(app, d, key);
|
||||
} else if d.refs.target.as_deref() == Some(key) {
|
||||
let sel = selection_ref(app, d.refs.home.as_ref(), takes_several_areas(&d.name, key));
|
||||
let sheet = active_sheet_name(app);
|
||||
if sheet != d.refs.seen_sheet {
|
||||
// Another sheet was shown: its selection is where that sheet was left, not a pick.
|
||||
d.refs.seen = sel;
|
||||
d.refs.seen_sheet = sheet;
|
||||
} else if sel != d.refs.seen {
|
||||
// The person selected on the grid: the selection becomes the reference.
|
||||
s = sel.clone();
|
||||
d.refs.seen = sel;
|
||||
d.refs.refocus = true;
|
||||
}
|
||||
if d.refs.refocus && !ui.input(|i| i.pointer.any_down()) {
|
||||
d.refs.refocus = false;
|
||||
r.request_focus();
|
||||
}
|
||||
}
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
let (icon, tip) = if collapsed { (Icon::Expand, "Expand dialog") } else { (Icon::Collapse, "Collapse dialog") };
|
||||
let b = crate::widgets::icon_button(ui, icon, t.text_dim, tip, vec2(22.0, 20.0));
|
||||
let name = lang.tr(tip).to_string();
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &name));
|
||||
if b.clicked() {
|
||||
if collapsed {
|
||||
d.refs.collapsed = None;
|
||||
} else {
|
||||
d.collapse_to(app, key);
|
||||
}
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
});
|
||||
if d.refs.target.as_deref() == Some(key) {
|
||||
d.refs.target_drawn = true;
|
||||
}
|
||||
d.values.insert(key.into(), json!(s));
|
||||
}
|
||||
|
||||
/// The anchors of one part of a reference (`$A$1`, `A`, `$3`): its column letters and row digits,
|
||||
/// and whether each carries a `$`.
|
||||
struct Anchors {
|
||||
letters: String,
|
||||
digits: String,
|
||||
col_abs: bool,
|
||||
row_abs: bool,
|
||||
}
|
||||
|
||||
/// Reads `part` as a cell (`A1`), a column (`A`) or a row (`3`) with its anchors: at most three
|
||||
/// letters and seven digits, nothing else. A `$` alone before the digits of a row anchors the row.
|
||||
fn anchors(part: &str) -> Option<Anchors> {
|
||||
let (col_abs, rest) = match part.strip_prefix('$') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, part),
|
||||
};
|
||||
let letters: String = rest.chars().take_while(char::is_ascii_alphabetic).collect();
|
||||
let rest = rest.get(letters.len()..)?;
|
||||
let (row_abs, rest) = match rest.strip_prefix('$') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, rest),
|
||||
};
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
if !rest.get(digits.len()..)?.is_empty() || letters.len() > 3 || digits.len() > 7 || (letters.is_empty() && digits.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
if letters.is_empty() {
|
||||
// `$3`: the one `$` belongs to the row; `$$3` is not a reference.
|
||||
if col_abs && row_abs {
|
||||
return None;
|
||||
}
|
||||
return Some(Anchors { letters, digits, col_abs: false, row_abs: col_abs || row_abs });
|
||||
}
|
||||
Some(Anchors { letters, digits, col_abs, row_abs })
|
||||
}
|
||||
|
||||
/// The areas of a reference list, split at the commas between them. A comma inside a quoted
|
||||
/// sheet name (`'Sales, West'!A1`) is part of the name, and a doubled quote inside the quotes
|
||||
/// (`'O''Brien, Jr'!A1`) is an escaped quote, not the end of the name: a quote toggles the
|
||||
/// quoted state, so a doubled one toggles it twice.
|
||||
fn split_areas(reference: &str) -> Vec<&str> {
|
||||
let mut areas = Vec::new();
|
||||
let (mut start, mut quoted) = (0, false);
|
||||
for (i, c) in reference.char_indices() {
|
||||
match c {
|
||||
'\'' => quoted = !quoted,
|
||||
',' if !quoted => {
|
||||
areas.push(reference.get(start..i).unwrap_or_default());
|
||||
start = i.saturating_add(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
areas.push(reference.get(start..).unwrap_or_default());
|
||||
areas
|
||||
}
|
||||
|
||||
/// Excel's F4 in a reference box: `A1` → `$A$1` → `A$1` → `$A1` → `A1`, every part of every
|
||||
/// area moving together from the first part's anchors (`A1:B2,D4` → `$A$1:$B$2,$D$4`), sheet
|
||||
/// names kept; whole columns and rows have one anchor each (`A:A` → `$A:$A` → `A:A`). Text that
|
||||
/// isn't a reference comes back as it is.
|
||||
pub(crate) fn cycle_anchors(reference: &str) -> String {
|
||||
let mut areas: Vec<(&str, Vec<Anchors>)> = Vec::new();
|
||||
for area in split_areas(reference) {
|
||||
let area = area.trim();
|
||||
let (prefix, body) = match area.rsplit_once('!') {
|
||||
Some((sheet, body)) => (area.get(..sheet.len() + 1).unwrap_or_default(), body),
|
||||
None => ("", area),
|
||||
};
|
||||
let parts: Vec<&str> = body.split(':').collect();
|
||||
if parts.len() > 2 {
|
||||
return reference.to_string();
|
||||
}
|
||||
let Some(parts) = parts.iter().map(|p| anchors(p)).collect::<Option<Vec<_>>>() else { return reference.to_string() };
|
||||
areas.push((prefix, parts));
|
||||
}
|
||||
let Some(first) = areas.first().and_then(|(_, parts)| parts.first()) else { return reference.to_string() };
|
||||
let (col_abs, row_abs) = if first.letters.is_empty() {
|
||||
(!first.row_abs, !first.row_abs)
|
||||
} else if first.digits.is_empty() {
|
||||
(!first.col_abs, !first.col_abs)
|
||||
} else {
|
||||
match (first.col_abs, first.row_abs) {
|
||||
(false, false) => (true, true),
|
||||
(true, true) => (false, true),
|
||||
(false, true) => (true, false),
|
||||
(true, false) => (false, false),
|
||||
}
|
||||
};
|
||||
let anchor = |on: bool, text: &str| if on && !text.is_empty() { format!("${text}") } else { text.to_string() };
|
||||
areas
|
||||
.iter()
|
||||
.map(|(prefix, parts)| {
|
||||
let body: Vec<String> = parts.iter().map(|a| format!("{}{}", anchor(col_abs, &a.letters), anchor(row_abs, &a.digits))).collect();
|
||||
format!("{prefix}{}", body.join(":"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cycle_anchors, split_areas};
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
assert_eq!(split_areas("'Sales, West'!$A$1"), ["'Sales, West'!$A$1"]);
|
||||
assert_eq!(split_areas("'O''Brien, Jr'!A1:B2,D4"), ["'O''Brien, Jr'!A1:B2", "D4"]);
|
||||
assert_eq!(split_areas("'Q1, Q2'!A1,'Q3, Q4'!B2"), ["'Q1, Q2'!A1", "'Q3, Q4'!B2"]);
|
||||
assert_eq!(split_areas(""), [""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_keeps_a_comma_inside_a_quoted_sheet_name() {
|
||||
assert_eq!(cycle_anchors("'Sales, West'!$A$1"), "'Sales, West'!A$1");
|
||||
assert_eq!(cycle_anchors("'O''Brien, Jr'!A1:B2,D4"), "'O''Brien, Jr'!$A$1:$B$2,$D$4");
|
||||
assert_eq!(cycle_anchors("B2:B4,D1"), "$B$2:$B$4,$D$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_cycles_cells_ranges_columns_and_rows() {
|
||||
assert_eq!(cycle_anchors("A1"), "$A$1");
|
||||
assert_eq!(cycle_anchors("$A$1"), "A$1");
|
||||
assert_eq!(cycle_anchors("A$1"), "$A1");
|
||||
assert_eq!(cycle_anchors("$A1"), "A1");
|
||||
assert_eq!(cycle_anchors("A1:B2"), "$A$1:$B$2");
|
||||
assert_eq!(cycle_anchors("$A$1:$B$2"), "A$1:B$2");
|
||||
// Every area follows the first part, and sheet names stay as typed.
|
||||
assert_eq!(cycle_anchors("'O''Brien Rates'!A2:A4, D4"), "'O''Brien Rates'!$A$2:$A$4,$D$4");
|
||||
assert_eq!(cycle_anchors("Sheet1!$A$1:B2"), "Sheet1!A$1:B$2");
|
||||
assert_eq!(cycle_anchors("A:A"), "$A:$A");
|
||||
assert_eq!(cycle_anchors("$A:$A"), "A:A");
|
||||
assert_eq!(cycle_anchors("1:3"), "$1:$3");
|
||||
assert_eq!(cycle_anchors("$1:$3"), "1:3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f4_leaves_text_that_is_not_a_reference() {
|
||||
for text in ["", "hello", "A1:hello", "ABCD1", "A12345678", "$$1", "$$A1", "A1:B2:C3", "Sheet1!", "A1,", "=A1"] {
|
||||
assert_eq!(cycle_anchors(text), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use super::format_cells::format_cells;
|
||||
use super::insert_function::insert_function;
|
||||
use super::names::{name_manager, paste_name};
|
||||
use super::paste::{paste_special, paste_special_shortcuts};
|
||||
use super::ref_edit::{RefLabel, SHEET_DELETED, end_pick, home_sheet_deleted, place_window, ref_edit, window_shown};
|
||||
use super::spelling::spelling;
|
||||
use super::zoom::zoom;
|
||||
use super::{Dialog, Field, Json, Key, Map, RefPick, SheetApp, json, vec2};
|
||||
@@ -157,8 +158,9 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"pasteSpecial" => 480.0,
|
||||
_ => 380.0,
|
||||
};
|
||||
let window_id = egui::Id::unique("dialog").with(&d.name);
|
||||
let window = egui::Window::new(app.ui.language.tr(&d.title))
|
||||
.id(egui::Id::unique("dialog").with(&d.name))
|
||||
.id(window_id)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.default_width(width)
|
||||
@@ -171,12 +173,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"advancedFilter" => window.default_height(0.0),
|
||||
_ => window,
|
||||
};
|
||||
window.show(ctx, |ui| {
|
||||
let (window, collapsed) = place_window(app, ctx, &mut d, window, window_id);
|
||||
let shown = window.show(ctx, |ui| {
|
||||
if let Some(key) = &collapsed {
|
||||
let title = d.title.clone();
|
||||
ref_edit(app, ui, &mut d, key, RefLabel::Named(&title), (width - 40.0).min(380.0));
|
||||
return;
|
||||
}
|
||||
ui.set_min_width(width - 20.0);
|
||||
match d.name.as_str() {
|
||||
"formatCells" => format_cells(app, ui, &mut d, &mut confirm),
|
||||
"dataValidation" => data_validation_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"advancedFilter" => advanced_filter_form(ui, &mut d, &mut confirm, &mut open),
|
||||
"dataValidation" => data_validation_form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"advancedFilter" => advanced_filter_form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"insertFunction" => insert_function(app, ui, &mut d, &mut confirm),
|
||||
"find" => find(app, ui, &mut d),
|
||||
"goTo" => go_to(app, ui, &mut d, &mut confirm),
|
||||
@@ -188,7 +196,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
"recommendedCharts" => recommended_charts(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
|
||||
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"equation" => crate::equation::editor(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),
|
||||
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
|
||||
@@ -365,9 +373,10 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
_ => form(ui, &mut d, &mut confirm, &mut open),
|
||||
_ => form(app, ui, &mut d, &mut confirm, &mut open),
|
||||
}
|
||||
});
|
||||
window_shown(ctx, &mut d, collapsed.is_some(), shown.map(|r| r.response.rect));
|
||||
// Hyperlinks inside dialogs (About ▸ Contributors) open through the host's `open_url`, since
|
||||
// the native shell has no browser integration of its own.
|
||||
if let Some(open_url) = &app.services.open_url {
|
||||
@@ -398,6 +407,18 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
app.message = Some((d.title.clone(), "Return to the original worksheet to insert this picture, or cancel and choose another cell.".into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && d.command.is_some() && home_sheet_deleted(app, &mut d) {
|
||||
// References without a sheet name would land on whichever sheet is shown now.
|
||||
app.message = Some((d.title.clone(), SHEET_DELETED.into()));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Err(e) = end_pick(app, &mut d) {
|
||||
// References without a sheet name mean the sheet the dialog was opened on: when it can't
|
||||
// be shown again (a text-box draft on the picked sheet could not be saved), the command
|
||||
// would read them against the wrong sheet, so it doesn't run and the dialog stays.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
confirm = false;
|
||||
}
|
||||
if confirm && let Some(cmd) = d.command {
|
||||
let params = build_params(&d);
|
||||
match app.run(cmd, params) {
|
||||
@@ -424,6 +445,19 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
} else if confirm {
|
||||
open = false;
|
||||
}
|
||||
if !open
|
||||
&& !confirm
|
||||
&& let Err(e) = end_pick(app, &mut d)
|
||||
{
|
||||
// Cancelled or closed: back to the sheet and selection from before picking. A confirmed
|
||||
// OK went back before its command ran, and stays where the command left things (Data
|
||||
// Analysis shows the report sheet it added). When the dialog's sheet can't be shown
|
||||
// again (a text-box draft on the picked sheet could not be saved), Cancel still closes
|
||||
// the dialog, since there is no command left to protect: the reason is shown, and the
|
||||
// draft and the selection stay where they are for the person to deal with. A script's
|
||||
// `ui.dialog.cancel` gets the error instead and keeps the dialog, so it can act on it.
|
||||
app.message = Some((d.title.clone(), crate::clean_error(&e)));
|
||||
}
|
||||
if app.ui_window() != home {
|
||||
// A command run from the dialog switched windows: whatever it opened shows in the new
|
||||
// window, and this dialog (or the one it returns to) stays in its own.
|
||||
@@ -470,8 +504,8 @@ pub(super) fn ok_cancel(ui: &mut egui::Ui, confirm: &mut bool, open: &mut bool)
|
||||
});
|
||||
}
|
||||
|
||||
fn form(ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
form_fields(ui, d);
|
||||
fn form(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
|
||||
form_fields(app, ui, d);
|
||||
ok_cancel(ui, confirm, open);
|
||||
}
|
||||
|
||||
@@ -517,7 +551,7 @@ fn track_pick(pick: &mut RefPick, r: &egui::Response, typed: bool) {
|
||||
}
|
||||
|
||||
/// Draws a dialog's `fields`, editing its values.
|
||||
pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
pub(super) fn form_fields(app: &SheetApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Field labels, choices and notes are English source strings, translated as they are drawn.
|
||||
let lang = crate::i18n::current(ui.ctx());
|
||||
egui::Grid::new("form").num_columns(1).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
|
||||
@@ -546,6 +580,12 @@ pub(super) fn form_fields(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
}
|
||||
Field::Ref { key, label } => {
|
||||
ui.vertical(|ui| {
|
||||
let l = ui.label(lang.tr(label));
|
||||
ref_edit(app, ui, d, key, RefLabel::By(l.id), 320.0);
|
||||
});
|
||||
}
|
||||
Field::Password { key, label } => {
|
||||
// Masked as typed (Excel shows dots); "Show password" reveals it on purpose.
|
||||
ui.vertical(|ui| {
|
||||
|
||||
@@ -13,7 +13,19 @@ pub struct EditState {
|
||||
/// The window it was typed in: switching to another window, even on the same workbook,
|
||||
/// commits it there first (`None` until the app sets it).
|
||||
pub window: Option<u64>,
|
||||
/// The sheet's index when the draft was last resolved; [`EditState::sheet_in`] finds it again
|
||||
/// by `sheet_uid` after sheets are inserted, deleted or moved.
|
||||
pub sheet: usize,
|
||||
/// The sheet's identity while the app runs (`model::SheetUid`), recorded when the draft is
|
||||
/// created ([`EditState::on_sheet`]); 0 (never given out) when none was.
|
||||
pub sheet_uid: u64,
|
||||
/// The draft began on a Python cell, with its code as the text. Kept so the code stays out of
|
||||
/// view once the cell can't be looked at, its sheet gone.
|
||||
pub py_cell: bool,
|
||||
/// The order of the workbook's sheets ([`sheet_order`]) when the draft was last resolved: a
|
||||
/// change after a command points the draft at its sheet again and lets go of
|
||||
/// `point_anchor`, which holds a sheet index.
|
||||
pub sheet_order: u64,
|
||||
/// Syntax of this edit, captured when it starts (prevents a language switch reinterpreting it).
|
||||
pub locale: gridcraft_engine::formula::FormulaLocale,
|
||||
pub cell: CellRef,
|
||||
@@ -47,6 +59,15 @@ pub struct EditState {
|
||||
/// The cell's text when an F2-style edit began. Committing it unchanged leaves the cell alone
|
||||
/// (no undo step, the workbook stays clean). `None` for an edit started by typing.
|
||||
pub original: Option<String>,
|
||||
/// Python mode: the text is a Python cell's code, edited in the formula bar's code editor and
|
||||
/// committed with Ctrl+Enter (Cmd+Enter on macOS) through `python.set`.
|
||||
pub python: bool,
|
||||
}
|
||||
|
||||
/// A digest of the order of `wb`'s sheets by identity: it changes when a sheet is inserted,
|
||||
/// deleted or moved, not when one is renamed or edited.
|
||||
pub fn sheet_order(wb: &gridcraft_engine::model::Workbook) -> u64 {
|
||||
wb.sheets.iter().fold(0xcbf2_9ce4_8422_2325_u64, |h, sh| (h ^ sh.uid.get()).wrapping_mul(0x0000_0100_0000_01b3))
|
||||
}
|
||||
|
||||
impl EditState {
|
||||
@@ -57,6 +78,9 @@ impl EditState {
|
||||
doc,
|
||||
window: None,
|
||||
sheet,
|
||||
sheet_uid: 0,
|
||||
py_cell: false,
|
||||
sheet_order: 0,
|
||||
locale: Default::default(),
|
||||
cell,
|
||||
text,
|
||||
@@ -75,13 +99,79 @@ impl EditState {
|
||||
caret_to_end: true,
|
||||
completion: None,
|
||||
original,
|
||||
python: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// An edit of a cell's Python code, in the formula bar.
|
||||
pub fn python(doc: u64, sheet: usize, cell: CellRef, code: String) -> EditState {
|
||||
let mut ed = EditState::new(doc, sheet, cell, code, false, true);
|
||||
ed.python = true;
|
||||
ed
|
||||
}
|
||||
|
||||
/// Records the identity of the draft's sheet, from the workbook it is typed in.
|
||||
pub fn on_sheet(mut self, wb: &gridcraft_engine::model::Workbook) -> EditState {
|
||||
self.sheet_uid = wb.sheet(self.sheet).map_or(0, |sh| sh.uid.get());
|
||||
self.sheet_order = sheet_order(wb);
|
||||
self
|
||||
}
|
||||
|
||||
/// The draft's sheet's index now, found by its identity, since commands insert, delete and
|
||||
/// move sheets under an open draft; `None` once the sheet is gone (or none was recorded).
|
||||
pub fn sheet_in(&self, wb: &gridcraft_engine::model::Workbook) -> Option<usize> {
|
||||
if self.sheet_uid == 0 {
|
||||
return None;
|
||||
}
|
||||
if wb.sheet(self.sheet).is_some_and(|sh| sh.uid.get() == self.sheet_uid) {
|
||||
return Some(self.sheet);
|
||||
}
|
||||
wb.sheets.iter().position(|sh| sh.uid.get() == self.sheet_uid)
|
||||
}
|
||||
|
||||
/// Whether the text is the `=PY` that Tab turns into Python mode, as in Excel (`=PY(` too,
|
||||
/// in any letter case).
|
||||
pub fn is_py_trigger(&self) -> bool {
|
||||
let t = self.text.trim();
|
||||
!self.python && (t.eq_ignore_ascii_case("=PY") || t.eq_ignore_ascii_case("=PY("))
|
||||
}
|
||||
|
||||
/// Switches the edit to Python mode, in the formula bar. The `=PY` trigger becomes empty code;
|
||||
/// other text is kept as the code, so the shortcut pressed mid-edit loses nothing.
|
||||
pub fn enter_python(&mut self) {
|
||||
if self.is_py_trigger() {
|
||||
self.text.clear();
|
||||
}
|
||||
self.python = true;
|
||||
self.from_formula_bar = true;
|
||||
self.enter_mode = false;
|
||||
self.point = None;
|
||||
self.point_cell = None;
|
||||
self.point_anchor = None;
|
||||
self.autocomplete.clear();
|
||||
self.completion = None;
|
||||
self.caret = self.text.chars().count();
|
||||
self.anchor = self.caret;
|
||||
self.caret_to_end = false;
|
||||
self.request_focus = true;
|
||||
self.sync_caret = true;
|
||||
}
|
||||
|
||||
/// Leaves Python mode: the edit goes on as an ordinary entry of the same text.
|
||||
pub fn leave_python(&mut self) {
|
||||
self.python = false;
|
||||
self.caret = self.text.chars().count();
|
||||
self.anchor = self.caret;
|
||||
self.request_focus = true;
|
||||
self.sync_caret = true;
|
||||
}
|
||||
|
||||
/// Whether the text is a formula (never in Python mode: there it is code).
|
||||
pub fn is_formula(&self) -> bool {
|
||||
self.text.starts_with('=')
|
||||
|| (self.text.starts_with('+') && self.text.len() > 1)
|
||||
|| (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit()))
|
||||
!self.python
|
||||
&& (self.text.starts_with('=')
|
||||
|| (self.text.starts_with('+') && self.text.len() > 1)
|
||||
|| (self.text.starts_with('-') && self.text.len() > 1 && !self.text[1..].starts_with(|c: char| c.is_ascii_digit())))
|
||||
}
|
||||
|
||||
fn byte_caret(&self) -> usize {
|
||||
@@ -157,7 +247,7 @@ impl EditState {
|
||||
/// Updates function autocomplete for the word before the caret.
|
||||
pub fn update_autocomplete(&mut self, names: &[String]) {
|
||||
self.autocomplete.clear();
|
||||
if !self.is_formula() {
|
||||
if self.python || !self.is_formula() {
|
||||
return;
|
||||
}
|
||||
let before = self.text.get(..self.byte_caret()).unwrap_or("");
|
||||
@@ -231,7 +321,7 @@ impl EditState {
|
||||
|
||||
/// The function call the caret is inside and the argument index (for the hint tooltip).
|
||||
pub fn current_function(&self) -> Option<(String, usize)> {
|
||||
if !self.is_formula() {
|
||||
if self.python || !self.is_formula() {
|
||||
return None;
|
||||
}
|
||||
let before = self.text.get(..self.byte_caret())?;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! The formula bar (Name Box, ✕ ✓ fx, formula field) and the shared cell editor widget.
|
||||
//! The formula bar (Name Box, ✕ ✓ fx, the PY badge, formula field) and the shared cell editor
|
||||
//! widget. In Python mode the field is the code editor.
|
||||
|
||||
use egui::{FontId, Key, Rect, Sense, Stroke, StrokeKind, TextEdit, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SheetApp;
|
||||
use crate::code_editor::{self, FieldOptions, Lang};
|
||||
use crate::editor::formula_refs;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -100,6 +102,9 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
app.open_dialog("insertFunction", json!({}));
|
||||
}
|
||||
layout.buttons = cancel.rect.union(fx.rect);
|
||||
if let Some(badge) = python_badge(app, ui, &t) {
|
||||
layout.buttons = layout.buttons.union(badge);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// The formula field fills the rest, as tall as the bar.
|
||||
let avail = ui.available_width() - 24.0;
|
||||
@@ -114,10 +119,16 @@ pub fn show(app: &mut SheetApp, ui: &mut egui::Ui) {
|
||||
let info = if app.editor.is_some() {
|
||||
editor_widget(app, &mut child, id, font.clone(), true, inner.width(), inner.height())
|
||||
} else {
|
||||
// Show the active cell's content; clicking starts editing in the bar.
|
||||
// Show the active cell's content (a Python cell's code in the code editor's
|
||||
// colours); clicking starts editing in the bar.
|
||||
let text = active_input(app);
|
||||
let color = if read_only { t.text_disabled } else { t.text };
|
||||
let galley = child.painter().layout(text, font.clone(), color, inner.width());
|
||||
let galley = if active_python(app).is_some() && !read_only {
|
||||
let job = code_editor::layout_job(&text, Lang::Python, &code_editor::font(), color, &t.syntax, inner.width());
|
||||
child.fonts_mut(|f| f.layout_job(job))
|
||||
} else {
|
||||
child.painter().layout(text, font.clone(), color, inner.width())
|
||||
};
|
||||
let lines = galley.rows.len();
|
||||
// Each cell's view starts at its top: the offset is kept per cell, not per bar.
|
||||
let shown = app.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active.row, d.selection.active.col));
|
||||
@@ -156,6 +167,16 @@ pub(crate) fn active_spill_anchor(app: &SheetApp) -> Option<gridcraft_engine::co
|
||||
sh.spill_anchor(at)
|
||||
}
|
||||
|
||||
/// The active cell's Python cell (itself, or the anchor whose result spills over it): its sheet,
|
||||
/// cell and code.
|
||||
pub(crate) fn active_python(app: &SheetApp) -> Option<(usize, gridcraft_engine::core::CellRef, std::sync::Arc<gridcraft_engine::model::PyCode>)> {
|
||||
let d = app.session.active()?;
|
||||
let sh = d.wb.active()?;
|
||||
let a = sh.merge_at(d.selection.active).map(|m| m.start).unwrap_or(d.selection.active);
|
||||
let at = sh.spill_anchor(a).unwrap_or(a);
|
||||
sh.cell(at).and_then(|c| c.py.clone()).map(|py| (d.wb.active_sheet(), at, py))
|
||||
}
|
||||
|
||||
fn active_input(app: &SheetApp) -> String {
|
||||
let Some(d) = app.session.active() else { return String::new() };
|
||||
let Some(sh) = d.wb.active() else { return String::new() };
|
||||
@@ -163,6 +184,14 @@ fn active_input(app: &SheetApp) -> String {
|
||||
if let Some(picture) = sh.cell_pictures.get(&a) {
|
||||
return if picture.alt.is_empty() { "Picture in cell".into() } else { format!("Picture: {}", picture.alt) };
|
||||
}
|
||||
// Python code is shown as it is: it isn't a formula to translate or a value to format. Like
|
||||
// a formula, it stays out of view in a Hidden cell on a protected sheet.
|
||||
if let Some((_, at, py)) = active_python(app) {
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, at) {
|
||||
return String::new();
|
||||
}
|
||||
return py.code.clone();
|
||||
}
|
||||
if let Some(anchor) = sh.spill_anchor(a) {
|
||||
if gridcraft_engine::display::formula_hidden(&d.wb, sh, anchor) {
|
||||
return String::new();
|
||||
@@ -173,7 +202,8 @@ fn active_input(app: &SheetApp) -> String {
|
||||
.unwrap_or_default();
|
||||
}
|
||||
if let Some(c) = sh.cell(a) {
|
||||
if c.formula.is_some() && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
|
||||
// A Hidden cell on a protected sheet shows neither its formula nor its Python code.
|
||||
if (c.formula.is_some() || c.py.is_some()) && d.wb.styles.get(c.style).protection.hidden && sh.is_protected() {
|
||||
return String::new();
|
||||
}
|
||||
return if let Some(f) = &c.formula {
|
||||
@@ -187,6 +217,66 @@ fn active_input(app: &SheetApp) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+Shift+P, and Cmd+Option+Shift+P on macOS: Python mode on or off.
|
||||
pub fn is_python_shortcut(ev: &egui::Event) -> bool {
|
||||
matches!(ev, egui::Event::Key { key: Key::P, pressed: true, modifiers: m, .. } if m.shift && m.alt && (m.ctrl || m.command || m.mac_cmd))
|
||||
}
|
||||
|
||||
/// Takes Ctrl+Alt+Shift+P out of this pass's input; whether it was there.
|
||||
fn take_python_shortcut(i: &mut egui::InputState) -> bool {
|
||||
let found = i.events.iter().any(is_python_shortcut);
|
||||
if found {
|
||||
i.events.retain(|e| !is_python_shortcut(e));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// What a key in Python mode does, besides editing the code.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum PythonKey {
|
||||
/// Ctrl+Enter (Cmd+Enter on macOS): put the code in the cell.
|
||||
Commit,
|
||||
/// Esc: cancel the edit.
|
||||
Cancel,
|
||||
/// Ctrl+Alt+Shift+P: leave Python mode.
|
||||
Leave,
|
||||
}
|
||||
|
||||
fn python_key(ev: &egui::Event) -> Option<PythonKey> {
|
||||
match ev {
|
||||
egui::Event::Key { key: Key::Enter, pressed: true, modifiers: m, .. } if (m.ctrl || m.command || m.mac_cmd) && !m.alt => {
|
||||
Some(PythonKey::Commit)
|
||||
}
|
||||
egui::Event::Key { key: Key::Escape, pressed: true, .. } => Some(PythonKey::Cancel),
|
||||
_ if is_python_shortcut(ev) => Some(PythonKey::Leave),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The PY badge beside fx: shown in Python mode and on a selected Python cell. Clicking it
|
||||
/// switches Python mode, as Ctrl+Alt+Shift+P does. Returns its rect when shown.
|
||||
fn python_badge(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Option<Rect> {
|
||||
let show = match &app.editor {
|
||||
Some(ed) => ed.python,
|
||||
None => active_python(app).is_some(),
|
||||
};
|
||||
if !show {
|
||||
return None;
|
||||
}
|
||||
let text = ui.painter().layout_no_wrap("PY".into(), theme::ui_bold(11.0), t.on_badge);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(text.size().x + 12.0, 24.0), Sense::click());
|
||||
let pill = Rect::from_center_size(rect.center(), vec2(rect.width(), 18.0));
|
||||
ui.painter().rect_filled(pill, 4.0, t.badge_py);
|
||||
ui.painter().galley(pill.center() - text.size() / 2.0, text, t.on_badge);
|
||||
let label = app.ui.language.tr("Python Mode").to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
let resp = resp.on_hover_text(format!("{label} (Ctrl+Alt+Shift+P)"));
|
||||
if resp.clicked() {
|
||||
app.toggle_python_mode();
|
||||
}
|
||||
Some(rect)
|
||||
}
|
||||
|
||||
/// Draws the Name Box and its name dropdown; Enter jumps to a reference or known name (or defines a new name) and is consumed so the grid does not move down.
|
||||
fn name_box(app: &mut SheetApp, ui: &mut egui::Ui, t: &Tokens) -> Rect {
|
||||
let id = egui::Id::unique("gridcraft.name_box");
|
||||
@@ -291,12 +381,19 @@ fn show_edit<'t>(
|
||||
/// The text editor used in the cell and in the formula bar (they share `app.editor`). It shows
|
||||
/// `max_height` of text and scrolls beyond that, keeping the caret in view.
|
||||
pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font: FontId, multiline: bool, width: f32, max_height: f32) -> EditorInfo {
|
||||
if app.editor.as_ref().is_some_and(|e| e.python) {
|
||||
// Python mode edits in the formula bar only.
|
||||
if id != egui::Id::unique("gridcraft.formula_bar") {
|
||||
return EditorInfo::default();
|
||||
}
|
||||
return python_editor(app, ui, id, width, max_height);
|
||||
}
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
let mine = ed.from_formula_bar == (id == egui::Id::unique("gridcraft.formula_bar"));
|
||||
// Keys the editor handles itself (before the TextEdit sees them), only where it has focus.
|
||||
let focused = ui.ctx().memory(|m| m.focused());
|
||||
let has_focus = focused == Some(id) || (mine && (ed.request_focus || focused.is_none()));
|
||||
let EditorKeyOutcome { action, arrow, cancel } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus);
|
||||
let EditorKeyOutcome { action, arrow, cancel, to_python } = editor_shortcuts(app, ui, id, &mut ed, mine, has_focus);
|
||||
// The text widget.
|
||||
let refs = formula_refs(&ed.text);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -389,8 +486,10 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
}
|
||||
if resp.changed() {
|
||||
ed.completion = None;
|
||||
// Python code is never completed from the column's text values.
|
||||
if app.ui.autocomplete
|
||||
&& !ed.is_formula()
|
||||
&& !ed.python
|
||||
&& !entry_shrank(chars_before, &ed.text)
|
||||
&& ed.caret == ed.text.chars().count()
|
||||
&& let Some(sh) = app.session.active().and_then(|d| d.wb.sheet(ed.sheet))
|
||||
@@ -419,6 +518,12 @@ pub fn editor_widget(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, font:
|
||||
show_editor_suggestions(ui, EditorSuggestion { app, id, resp: &resp, ed: &mut ed, galley: &output.galley, tokens: &t, mine });
|
||||
let lines = output.galley.rows.len().max(1);
|
||||
let caret = Some(output.galley.pos_from_cursor(egui::text::CCursor::new(ed.caret)).translate(output.galley_pos.to_vec2()));
|
||||
if to_python && !app.draft_in_spill(&ed) {
|
||||
ed.enter_python();
|
||||
app.editor = Some(ed);
|
||||
app.session.mode = gridcraft_engine::Mode::Edit;
|
||||
return EditorInfo { lines, caret };
|
||||
}
|
||||
app.editor = Some(ed);
|
||||
if cancel {
|
||||
app.cancel_edit();
|
||||
@@ -438,6 +543,8 @@ struct EditorKeyOutcome {
|
||||
action: Option<EditorAction>,
|
||||
arrow: bool,
|
||||
cancel: bool,
|
||||
/// Ctrl+Alt+Shift+P, or `=PY` then Tab: the edit turns into Python code.
|
||||
to_python: bool,
|
||||
}
|
||||
|
||||
fn editor_shortcuts(
|
||||
@@ -448,14 +555,17 @@ fn editor_shortcuts(
|
||||
mine: bool,
|
||||
has_focus: bool,
|
||||
) -> EditorKeyOutcome {
|
||||
let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false };
|
||||
let mut outcome = EditorKeyOutcome { action: None, arrow: false, cancel: false, to_python: false };
|
||||
// A message or dialog owns Enter and Escape until it closes.
|
||||
if !has_focus || !mine || app.message.is_some() || app.dialog.is_some() {
|
||||
return outcome;
|
||||
}
|
||||
let (action, cancel) = editor_confirm_keys(ui, ed);
|
||||
// Ctrl+Alt+Shift+P turns the edit into Python code.
|
||||
outcome.to_python = ui.input_mut(take_python_shortcut);
|
||||
let (action, cancel, py_trigger) = editor_confirm_keys(ui, ed);
|
||||
outcome.action = action;
|
||||
outcome.cancel = cancel;
|
||||
outcome.to_python |= py_trigger;
|
||||
let (action, arrow) = editor_arrow_keys(app, ui, ed, outcome.action);
|
||||
outcome.action = action;
|
||||
outcome.arrow = arrow;
|
||||
@@ -463,7 +573,9 @@ fn editor_shortcuts(
|
||||
outcome
|
||||
}
|
||||
|
||||
fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option<EditorAction>, bool) {
|
||||
/// Enter, Tab, Esc and their variants: the move to make, whether the edit is cancelled, and
|
||||
/// whether `=PY` then Tab starts Python mode.
|
||||
fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) -> (Option<EditorAction>, bool, bool) {
|
||||
let (enter, tab, esc, alt_enter, cmd_enter, ctrl_shift_enter, shift) = ui.input_mut(|i| {
|
||||
let m = i.modifiers;
|
||||
let alt_enter = i.consume_key(egui::Modifiers::ALT, Key::Enter) || i.consume_key(egui::Modifiers::CTRL | egui::Modifiers::ALT, Key::Enter);
|
||||
@@ -482,7 +594,11 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) ->
|
||||
ed.caret_to_end = false;
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
let mut action = if !ed.autocomplete.is_empty() && tab {
|
||||
// `=PY` then Tab starts Python mode, as in Excel.
|
||||
let py_trigger = tab && !shift && ed.is_py_trigger();
|
||||
let mut action = if py_trigger {
|
||||
None
|
||||
} else if !ed.autocomplete.is_empty() && tab {
|
||||
ed.accept_autocomplete();
|
||||
None
|
||||
} else if enter {
|
||||
@@ -509,7 +625,7 @@ fn editor_confirm_keys(ui: &mut egui::Ui, ed: &mut crate::editor::EditState) ->
|
||||
} else {
|
||||
true
|
||||
};
|
||||
(action, cancel)
|
||||
(action, cancel, py_trigger)
|
||||
}
|
||||
|
||||
fn editor_arrow_keys(
|
||||
@@ -603,6 +719,57 @@ fn editor_other_shortcuts(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, e
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula bar in Python mode: the code editor (monospace, Python colours). Its keys are
|
||||
/// applied in input order with the typing: Enter starts a new line with the indentation, Tab and
|
||||
/// Shift+Tab indent, Ctrl+Enter (Cmd+Enter on macOS) commits through `python.set`, Esc cancels and
|
||||
/// Ctrl+Alt+Shift+P leaves Python mode.
|
||||
fn python_editor(app: &mut SheetApp, ui: &mut egui::Ui, id: egui::Id, width: f32, max_height: f32) -> EditorInfo {
|
||||
let Some(mut ed) = app.editor.take() else { return EditorInfo::default() };
|
||||
// A message box (the code was refused) or a dialog over the edit has the keyboard; the
|
||||
// editor takes it back when it closes.
|
||||
let blocked = app.message.is_some() || app.dialog.is_some();
|
||||
let mut caret = None;
|
||||
if blocked {
|
||||
ed.request_focus = true;
|
||||
} else {
|
||||
// Focus left on nothing (a click on the sheet's chrome, a closed message box whose modal
|
||||
// layer held it one more pass) comes back to the code.
|
||||
if std::mem::take(&mut ed.request_focus) || ui.memory(|m| m.focused()).is_none() {
|
||||
ui.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
if std::mem::take(&mut ed.sync_caret) {
|
||||
caret = Some(ed.caret);
|
||||
}
|
||||
}
|
||||
let name = app.ui.language.tr("Formula Bar").to_string();
|
||||
let stop = |ev: &egui::Event| python_key(ev).is_some();
|
||||
let options = FieldOptions {
|
||||
width,
|
||||
max_height,
|
||||
interactive: !blocked,
|
||||
stop: &stop,
|
||||
keep_escape: true,
|
||||
caret,
|
||||
name: &name,
|
||||
..FieldOptions::new(Lang::Python)
|
||||
};
|
||||
let out = code_editor::code_field(ui, id, &mut ed.text, &options);
|
||||
if !blocked && caret.is_none() {
|
||||
(ed.caret, ed.anchor) = out.selection;
|
||||
}
|
||||
let info = EditorInfo { lines: out.lines, caret: out.caret };
|
||||
app.editor = Some(ed);
|
||||
match out.stop.as_ref().and_then(python_key) {
|
||||
Some(PythonKey::Commit) => {
|
||||
app.commit_python_edit();
|
||||
}
|
||||
Some(PythonKey::Cancel) => app.cancel_edit(),
|
||||
Some(PythonKey::Leave) => app.toggle_python_mode(),
|
||||
None => {}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
fn cycle_anchor(ed: &mut crate::editor::EditState) {
|
||||
let caret_b = ed.text.char_indices().nth(ed.caret).map(|(i, _)| i).unwrap_or(ed.text.len());
|
||||
for (a, b, ..) in formula_refs(&ed.text) {
|
||||
@@ -753,6 +920,23 @@ mod spill_tests {
|
||||
app.run("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
check(&mut app, "A2", "=FILTER(D1:D3,D1:D3>0)", true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code() {
|
||||
let mut app = app();
|
||||
app.run("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap();
|
||||
app.run("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap();
|
||||
set(&mut app, "C1", "=1+1");
|
||||
app.run("home.formatCells", json!({"range": "A1:C1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
app.run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true, "hidden": false}}})).unwrap();
|
||||
select(&mut app, "A1");
|
||||
assert_eq!(active_input(&app), "secret = 1\nsecret", "an unprotected sheet hides nothing");
|
||||
app.run("review.protectSheet", json!({})).unwrap();
|
||||
for (cell, text) in [("A1", ""), ("B1", "'shown'"), ("C1", "")] {
|
||||
select(&mut app, cell);
|
||||
assert_eq!(active_input(&app), text, "{cell}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
/// A disabled spill formula paints disabled and does not edit; clicking an active formula starts formula-bar editing in both light and dark themes, with accessibility checks.
|
||||
fn formula_bar_click_and_theme_presentation() {
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Kontrola chyb",
|
||||
"Trace Error" => "Sledovat chybu",
|
||||
"Circular References" => "Cyklické odkazy",
|
||||
"Python Mode" => "Režim Pythonu",
|
||||
"Evaluate Formula" => "Vyhodnotit vzorec",
|
||||
"Watch Window" => "Okno sledování",
|
||||
"Calculation\nOptions" => "Možnosti\nvýpočtu",
|
||||
@@ -570,6 +571,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Zastavit",
|
||||
"New Macro" => "Nové makro",
|
||||
"Show" => "Zobrazit",
|
||||
"Collapse dialog" => "Sbalit dialogové okno",
|
||||
"Expand dialog" => "Rozbalit dialogové okno",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Fehlerüberprüfung",
|
||||
"Trace Error" => "Spur zum Fehler",
|
||||
"Circular References" => "Zirkelbezüge",
|
||||
"Python Mode" => "Python-Modus",
|
||||
"Evaluate Formula" => "Formel auswerten",
|
||||
"Watch Window" => "Überwachungsfenster",
|
||||
"Calculation\nOptions" => "Berechnungs-\noptionen",
|
||||
@@ -486,6 +487,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Beenden",
|
||||
"New Macro" => "Neues Makro",
|
||||
"Show" => "Anzeigen",
|
||||
"Collapse dialog" => "Dialogfeld reduzieren",
|
||||
"Expand dialog" => "Dialogfeld erweitern",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Έλεγχος σφαλμάτων",
|
||||
"Trace Error" => "Εντοπισμός σφάλματος",
|
||||
"Circular References" => "Κυκλικές αναφορές",
|
||||
"Python Mode" => "Λειτουργία Python",
|
||||
"Evaluate Formula" => "Αξιολόγηση τύπου",
|
||||
"Watch Window" => "Παράθυρο παρακολούθησης",
|
||||
"Calculation\nOptions" => "Επιλογές\nυπολογισμού",
|
||||
@@ -993,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Καταγραφή",
|
||||
"New Macro" => "Νέα μακροεντολή",
|
||||
"Show" => "Εμφάνιση",
|
||||
"Collapse dialog" => "Σύμπτυξη παραθύρου διαλόγου",
|
||||
"Expand dialog" => "Ανάπτυξη παραθύρου διαλόγου",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Comprobación de errores",
|
||||
"Trace Error" => "Rastrear error",
|
||||
"Circular References" => "Referencias circulares",
|
||||
"Python Mode" => "Modo Python",
|
||||
"Evaluate Formula" => "Evaluar fórmula",
|
||||
"Watch Window" => "Ventana Inspección",
|
||||
"Calculation\nOptions" => "Opciones\nde cálculo",
|
||||
@@ -492,6 +493,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Detener",
|
||||
"New Macro" => "Nueva macro",
|
||||
"Show" => "Mostrar",
|
||||
"Collapse dialog" => "Contraer diálogo",
|
||||
"Expand dialog" => "Expandir diálogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +161,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Vérification des erreurs",
|
||||
"Trace Error" => "Repérer une erreur",
|
||||
"Circular References" => "Références circulaires",
|
||||
"Python Mode" => "Mode Python",
|
||||
"Evaluate Formula" => "Évaluation de formule",
|
||||
"Watch Window" => "Fenêtre Espion",
|
||||
"Calculation\nOptions" => "Options de\ncalcul",
|
||||
@@ -464,6 +465,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Arrêter",
|
||||
"New Macro" => "Nouvelle macro",
|
||||
"Show" => "Afficher",
|
||||
"Collapse dialog" => "Réduire la boîte de dialogue",
|
||||
"Expand dialog" => "Développer la boîte de dialogue",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Controllo errori",
|
||||
"Trace Error" => "Individua errore",
|
||||
"Circular References" => "Riferimenti circolari",
|
||||
"Python Mode" => "Modalità Python",
|
||||
"Evaluate Formula" => "Valuta formula",
|
||||
"Watch Window" => "Finestra controlli",
|
||||
"Calculation\nOptions" => "Opzioni di\ncalcolo",
|
||||
@@ -996,6 +997,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Registrazione",
|
||||
"New Macro" => "Nuova macro",
|
||||
"Show" => "Mostra",
|
||||
"Collapse dialog" => "Comprimi finestra di dialogo",
|
||||
"Expand dialog" => "Espandi finestra di dialogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Error Checking", "エラーチェック"),
|
||||
("Trace Error", "エラーのトレース"),
|
||||
("Circular References", "循環参照"),
|
||||
("Python Mode", "Python モード"),
|
||||
("Evaluate Formula", "数式の検証"),
|
||||
("Watch Window", "ウォッチ ウィンドウ"),
|
||||
("Calculation\nOptions", "計算方法の設定"),
|
||||
@@ -1001,6 +1002,8 @@ pub(super) const DIALOGS: &[(&str, &str)] = &[
|
||||
("Tab Color", "シートの見出しの色"),
|
||||
("Unhide…", "再表示…"),
|
||||
("Unprotect Sheet…", "シート保護の解除…"),
|
||||
("Collapse dialog", "ダイアログを折りたたむ"),
|
||||
("Expand dialog", "ダイアログを展開する"),
|
||||
];
|
||||
|
||||
/// Labels of the ribbon menus, pop-ups and palettes that the reference catalog does not cover (the
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "오류 검사",
|
||||
"Trace Error" => "오류 추적",
|
||||
"Circular References" => "순환 참조",
|
||||
"Python Mode" => "Python 모드",
|
||||
"Evaluate Formula" => "수식 계산",
|
||||
"Watch Window" => "감시 창",
|
||||
"Calculation\nOptions" => "계산\n옵션",
|
||||
@@ -460,6 +461,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "중지",
|
||||
"New Macro" => "새 매크로",
|
||||
"Show" => "표시",
|
||||
"Collapse dialog" => "대화 상자 축소",
|
||||
"Expand dialog" => "대화 상자 확장",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +155,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Foutopsporing",
|
||||
"Trace Error" => "Fout traceren",
|
||||
"Circular References" => "Kringverwijzingen",
|
||||
"Python Mode" => "Python-modus",
|
||||
"Evaluate Formula" => "Formule evalueren",
|
||||
"Watch Window" => "Bewakingsvenster",
|
||||
"Calculation\nOptions" => "Berekenings-\nopties",
|
||||
@@ -539,6 +540,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Stoppen",
|
||||
"New Macro" => "Nieuwe macro",
|
||||
"Show" => "Weergeven",
|
||||
"Collapse dialog" => "Dialoogvenster samenvouwen",
|
||||
"Expand dialog" => "Dialoogvenster uitvouwen",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,6 +150,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Sprawdzanie błędów",
|
||||
"Trace Error" => "Śledź błąd",
|
||||
"Circular References" => "Odwołania cykliczne",
|
||||
"Python Mode" => "Tryb Python",
|
||||
"Evaluate Formula" => "Szacuj formułę",
|
||||
"Watch Window" => "Okno czujki",
|
||||
"Calculation\nOptions" => "Opcje\nobliczania",
|
||||
@@ -987,6 +988,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Rejestrowanie",
|
||||
"New Macro" => "Nowe makro",
|
||||
"Show" => "Pokaż",
|
||||
"Collapse dialog" => "Zwiń okno dialogowe",
|
||||
"Expand dialog" => "Rozwiń okno dialogowe",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Verificação de Erros",
|
||||
"Trace Error" => "Rastrear Erro",
|
||||
"Circular References" => "Referências Circulares",
|
||||
"Python Mode" => "Modo Python",
|
||||
"Evaluate Formula" => "Avaliar Fórmula",
|
||||
"Watch Window" => "Janela de Inspeção",
|
||||
"Calculation\nOptions" => "Opções de\nCálculo",
|
||||
@@ -993,6 +994,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Gravando",
|
||||
"New Macro" => "Nova Macro",
|
||||
"Show" => "Mostrar",
|
||||
"Collapse dialog" => "Recolher caixa de diálogo",
|
||||
"Expand dialog" => "Expandir caixa de diálogo",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -157,6 +157,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Verificare erori",
|
||||
"Trace Error" => "Trasare eroare",
|
||||
"Circular References" => "Referințe circulare",
|
||||
"Python Mode" => "Mod Python",
|
||||
"Evaluate Formula" => "Evaluare formulă",
|
||||
"Watch Window" => "Fereastră de urmărire",
|
||||
"Calculation\nOptions" => "Opțiuni de\ncalcul",
|
||||
@@ -1049,6 +1050,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Înregistrare",
|
||||
"New Macro" => "Macrocomandă nouă",
|
||||
"Show" => "Afișare",
|
||||
"Collapse dialog" => "Restrângere casetă de dialog",
|
||||
"Expand dialog" => "Extindere casetă de dialog",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Проверка ошибок",
|
||||
"Trace Error" => "Источник ошибки",
|
||||
"Circular References" => "Циклические ссылки",
|
||||
"Python Mode" => "Режим Python",
|
||||
"Evaluate Formula" => "Вычислить формулу",
|
||||
"Watch Window" => "Окно контрольных значений",
|
||||
"Calculation\nOptions" => "Параметры\nвычислений",
|
||||
@@ -462,6 +463,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Остановить",
|
||||
"New Macro" => "Новый макрос",
|
||||
"Show" => "Показать",
|
||||
"Collapse dialog" => "Свернуть диалоговое окно",
|
||||
"Expand dialog" => "Развернуть диалоговое окно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Provera grešaka",
|
||||
"Trace Error" => "Prati grešku",
|
||||
"Circular References" => "Kružne reference",
|
||||
"Python Mode" => "Python režim",
|
||||
"Evaluate Formula" => "Proceni formulu",
|
||||
"Watch Window" => "Prozor za praćenje",
|
||||
"Calculation\nOptions" => "Opcije\nizračunavanja",
|
||||
@@ -459,6 +460,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Zaustavi",
|
||||
"New Macro" => "Novi makro",
|
||||
"Show" => "Prikaži",
|
||||
"Collapse dialog" => "Skupi dijalog",
|
||||
"Expand dialog" => "Proširi dijalog",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Hata Denetimi",
|
||||
"Trace Error" => "Hatayı İzle",
|
||||
"Circular References" => "Döngüsel Başvurular",
|
||||
"Python Mode" => "Python Modu",
|
||||
"Evaluate Formula" => "Formülü Değerlendir",
|
||||
"Watch Window" => "Gözcü Penceresi",
|
||||
"Calculation\nOptions" => "Hesaplama\nSeçenekleri",
|
||||
@@ -429,6 +430,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Durdur",
|
||||
"New Macro" => "Yeni Makro",
|
||||
"Show" => "Göster",
|
||||
"Collapse dialog" => "İletişim kutusunu daralt",
|
||||
"Expand dialog" => "İletişim kutusunu genişlet",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Перевірка помилок",
|
||||
"Trace Error" => "Джерело помилки",
|
||||
"Circular References" => "Циклічні посилання",
|
||||
"Python Mode" => "Режим Python",
|
||||
"Evaluate Formula" => "Обчислити формулу",
|
||||
"Watch Window" => "Окно контрольних значень",
|
||||
"Calculation\nOptions" => "Параметри\nобчислень",
|
||||
@@ -991,6 +992,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Recording" => "Запис",
|
||||
"New Macro" => "Новий макрос",
|
||||
"Show" => "Показати",
|
||||
"Collapse dialog" => "Згорнути діалогове вікно",
|
||||
"Expand dialog" => "Розгорнути діалогове вікно",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "Kiểm tra lỗi",
|
||||
"Trace Error" => "Truy tìm lỗi",
|
||||
"Circular References" => "Tham chiếu vòng",
|
||||
"Python Mode" => "Chế độ Python",
|
||||
"Evaluate Formula" => "Đánh giá công thức",
|
||||
"Watch Window" => "Cửa sổ theo dõi",
|
||||
"Calculation\nOptions" => "Tùy chọn\ntính toán",
|
||||
@@ -431,6 +432,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "Dừng",
|
||||
"New Macro" => "Macro Mới",
|
||||
"Show" => "Hiện",
|
||||
"Collapse dialog" => "Thu gọn hộp thoại",
|
||||
"Expand dialog" => "Mở rộng hộp thoại",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +156,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Error Checking" => "错误检查",
|
||||
"Trace Error" => "追踪错误",
|
||||
"Circular References" => "循环引用",
|
||||
"Python Mode" => "Python 模式",
|
||||
"Evaluate Formula" => "公式求值",
|
||||
"Watch Window" => "监视窗口",
|
||||
"Calculation\nOptions" => "计算\n选项",
|
||||
@@ -452,6 +453,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Stop" => "停止",
|
||||
"New Macro" => "新建宏",
|
||||
"Show" => "显示",
|
||||
"Collapse dialog" => "折叠对话框",
|
||||
"Expand dialog" => "展开对话框",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ pub enum Shortcut {
|
||||
DropDown,
|
||||
/// Ctrl+Alt+= / Ctrl+Alt+-: zoom in / out.
|
||||
Zoom(i32),
|
||||
/// Ctrl+Alt+Shift+P: edit the active cell as Python code (Python mode).
|
||||
PythonMode,
|
||||
}
|
||||
|
||||
fn run(id: &'static str) -> Shortcut {
|
||||
@@ -219,6 +221,8 @@ fn lookup_control_alt_shortcuts(ctx: LookupContext) -> Option<Shortcut> {
|
||||
Equals | Plus if ctrl_alt => Shortcut::Zoom(10),
|
||||
Minus if ctrl_alt && !mac => Shortcut::Zoom(-10),
|
||||
F9 if ctrl_alt => run("formulas.calculateNow"),
|
||||
// Python in cells (Cmd+Option on macOS too).
|
||||
P if ctrl_alt && s => Shortcut::PythonMode,
|
||||
R if ctrl_alt && mac => run("view.collapseRibbon"),
|
||||
Num0 if ctrl_alt && mac => borders("outside"),
|
||||
Minus if ctrl_alt && mac => borders("none"),
|
||||
@@ -342,6 +346,7 @@ pub(crate) fn dispatch(app: &mut SheetApp, ctx: &egui::Context, key: Key, physic
|
||||
app.grid.reveal_from = from;
|
||||
}
|
||||
Shortcut::Edit => app.begin_edit(None, false),
|
||||
Shortcut::PythonMode => app.toggle_python_mode(),
|
||||
Shortcut::ContextMenu => app.grid.context_menu = Some(geo.cell_rect(sh, active).center()),
|
||||
Shortcut::DropDown => {
|
||||
if sh.validations.iter().any(|d| d.in_cell_dropdown && d.ranges.iter().any(|r| r.contains(active))) {
|
||||
|
||||
@@ -65,7 +65,7 @@ pub(super) fn review(app: &mut SheetApp, ui: &mut Ui) {
|
||||
);
|
||||
});
|
||||
sep(ui);
|
||||
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
|
||||
let protected = app.active_sheet_protected();
|
||||
let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false);
|
||||
control_keytip(app, ui, &__k, "dialog:protectSheet");
|
||||
if __k.clicked() {
|
||||
|
||||
@@ -114,6 +114,13 @@ pub struct Tokens {
|
||||
pub sheet_macro_footprint: Color32,
|
||||
pub sheet_macro_conflict: Color32,
|
||||
pub sheet_macro_target: Color32,
|
||||
/// The PY badge in the formula bar (Python mode) and its text.
|
||||
pub badge_py: Color32,
|
||||
pub on_badge: Color32,
|
||||
/// The PY tag on a Python cell and its text. They follow the sheet, so they are the same in
|
||||
/// both themes.
|
||||
pub sheet_py_tag: Color32,
|
||||
pub on_sheet_py_tag: Color32,
|
||||
/// Trace arrows on the sheet: precedents and dependents, and the links Trace Error follows
|
||||
/// between cells showing an error. Sheet colours.
|
||||
pub sheet_trace_arrow: Color32,
|
||||
@@ -172,6 +179,8 @@ const SHEET_CONTROL_BG: Color32 = rgb(0xF3F3F3);
|
||||
const SHEET_CONTROL_BORDER: Color32 = rgb(0x8A8A8A);
|
||||
const SHEET_CONTROL_TEXT: Color32 = rgb(0x1F1F1F);
|
||||
const SHEET_TRACK: Color32 = rgb(0xD6D6D6);
|
||||
/// The PY tag on Python cells: a deep green, white text on it.
|
||||
const SHEET_PY_TAG: Color32 = rgb(0x1E6E4E);
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Tokens {
|
||||
@@ -251,6 +260,10 @@ impl Tokens {
|
||||
sheet_macro_footprint: SHEET_MACRO_FOOTPRINT,
|
||||
sheet_macro_conflict: SHEET_MACRO_CONFLICT,
|
||||
sheet_macro_target: SHEET_MACRO_TARGET,
|
||||
badge_py: pick(dark, SHEET_PY_TAG, rgb(0x5CBF94)),
|
||||
on_badge: pick(dark, Color32::WHITE, rgb(0x0B1F17)),
|
||||
sheet_py_tag: SHEET_PY_TAG,
|
||||
on_sheet_py_tag: Color32::WHITE,
|
||||
sheet_trace_arrow: SHEET_TRACE_ARROW,
|
||||
sheet_trace_error: SHEET_TRACE_ERROR,
|
||||
}
|
||||
@@ -412,6 +425,11 @@ mod contrast_tests {
|
||||
add("text on current step", t.text, t.accent_soft, TEXT);
|
||||
add("current step arrow", t.accent, t.accent_soft, GLYPH);
|
||||
add("error marker on window", t.danger, t.window, GLYPH);
|
||||
add("PY badge text", t.on_badge, t.badge_py, TEXT);
|
||||
add("PY badge on window", t.badge_py, t.window, GLYPH);
|
||||
add("PY badge on input", t.badge_py, t.input_bg, GLYPH);
|
||||
add("PY tag text", t.on_sheet_py_tag, t.sheet_py_tag, TEXT);
|
||||
add("PY tag on sheet", t.sheet_py_tag, t.grid_bg, GLYPH);
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,10 @@ mod pivot_show_details_menu;
|
||||
mod popup_dismissal;
|
||||
mod protect_password;
|
||||
mod protected_cell;
|
||||
mod python_cell_edit;
|
||||
mod python_hidden;
|
||||
mod python_mode;
|
||||
mod ref_edit;
|
||||
mod ribbon_layout;
|
||||
mod right_to_left;
|
||||
mod scroll_position;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Editing a Python cell's content (F2, the formula bar) edits its code, in Python mode; typing
|
||||
//! over it replaces it, as typing over a formula does.
|
||||
|
||||
use egui::{Event, Key, Modifiers};
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn harness() -> Harness {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("python.set", json!({"cell": "A1", "code": "1 + 1", "output": "object"})).unwrap();
|
||||
session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
crate::common::harness_with(crate::common::builder().with_size(egui::vec2(1200.0, 800.0)), crate::common::app(session), 4)
|
||||
}
|
||||
|
||||
fn text(h: &mut Harness, text: &str) {
|
||||
h.input_mut().events.push(Event::Text(text.into()));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key_with(h: &mut Harness, key: Key, modifiers: Modifiers) {
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers });
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn key(h: &mut Harness, key: Key) {
|
||||
key_with(h, key, Modifiers::NONE);
|
||||
}
|
||||
|
||||
/// Ctrl+Enter (Cmd+Enter on macOS): commits Python mode, where Enter starts a new line.
|
||||
fn commit(h: &mut Harness) {
|
||||
key_with(h, Key::Enter, Modifiers::COMMAND);
|
||||
}
|
||||
|
||||
/// A1's code and return type (true: a Python object), or `None` when it isn't a Python cell.
|
||||
fn python(h: &Harness) -> Option<(String, bool)> {
|
||||
let sh = h.state().session.active().unwrap().wb.active().unwrap();
|
||||
sh.cell(CellRef::parse("A1").unwrap()).and_then(|c| c.py.as_deref()).map(|p| (p.code.clone(), p.object))
|
||||
}
|
||||
|
||||
fn select_a1(h: &mut Harness) {
|
||||
h.state_mut().session.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_edits_a_python_cells_code_and_typing_replaces_it() {
|
||||
let mut h = harness();
|
||||
let undo_depth = |h: &Harness| h.state().session.active().unwrap().undo.len();
|
||||
let before = undo_depth(&h);
|
||||
|
||||
// F2 and a commit without a change writes nothing.
|
||||
key(&mut h, Key::F2);
|
||||
assert_eq!(h.state().editor.as_ref().map(|e| (e.text.as_str(), e.python)), Some(("1 + 1", true)));
|
||||
commit(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(undo_depth(&h), before, "an unchanged edit is no undo step");
|
||||
assert_eq!(python(&h), Some(("1 + 1".into(), true)));
|
||||
|
||||
// A change is new code for the same Python cell, which keeps its return type.
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
text(&mut h, " + 2");
|
||||
commit(&mut h);
|
||||
assert!(h.state().editor.is_none(), "{:?}", h.state().message);
|
||||
assert_eq!(python(&h), Some(("1 + 1 + 2".into(), true)), "the cell stays a Python cell with the new code");
|
||||
|
||||
// Typing over the cell replaces it.
|
||||
select_a1(&mut h);
|
||||
text(&mut h, "5");
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(python(&h), None);
|
||||
assert_eq!(h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_all_the_code_clears_the_cell() {
|
||||
let mut h = harness();
|
||||
let a1 = |h: &Harness| h.state().session.active().unwrap().wb.active().unwrap().value(CellRef::parse("A1").unwrap());
|
||||
// Code of spaces is still code, for the same Python cell.
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text = " ".into();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert_eq!(python(&h), Some((" ".into(), true)));
|
||||
// No code at all clears the cell, as an empty entry clears any other cell.
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(python(&h), None, "the cell is no longer a Python cell");
|
||||
assert_eq!(a1(&h), Value::Empty);
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(python(&h), Some((" ".into(), true)), "one undo step brings the code back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_all_the_code_clears_a_python_cell_with_no_cached_value() {
|
||||
let mut h = harness();
|
||||
// A Python cell from a file that cached no value (the value is empty, the code is not).
|
||||
{
|
||||
let d = h.state_mut().session.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let i = wb.active_sheet();
|
||||
let sh = wb.sheet_mut(i).unwrap();
|
||||
let at = CellRef::parse("A1").unwrap();
|
||||
let mut cell = sh.cell(at).cloned().unwrap();
|
||||
cell.value = Value::Empty;
|
||||
sh.set_cell(at, cell);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
}
|
||||
h.run_steps(2);
|
||||
key(&mut h, Key::F2);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
assert!(h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(python(&h), None, "the code is removed, not kept as an unchanged empty cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_code_edit_is_never_completed_from_the_column() {
|
||||
let mut h = harness();
|
||||
// A2 holds text that starts with A1's code: typing at the end of the code would offer it.
|
||||
h.state_mut().session.execute("cell.set", json!({"cell": "A2", "input": "1 + 1 and more"})).unwrap();
|
||||
select_a1(&mut h);
|
||||
key(&mut h, Key::F2);
|
||||
text(&mut h, " ");
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.completion.clone()), None, "no completion while code is edited");
|
||||
commit(&mut h);
|
||||
assert_eq!(python(&h), Some(("1 + 1 ".into(), true)), "the typed code is kept");
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! A Python cell formatted Hidden on a protected sheet keeps its code out of view, as a hidden
|
||||
//! formula does: the formula bar shows nothing, and F2, a double-click, the formula bar,
|
||||
//! Ctrl+Alt+Shift+P and the PY badge start no edit that would show the code, or
|
||||
//! that would write blank code over it.
|
||||
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::Harness;
|
||||
|
||||
const CODE: &str = "secret_total = 41\nsecret_total";
|
||||
|
||||
/// A Python cell at B2, selected, with its protection `hidden` flag, on a sheet protected or not.
|
||||
fn harness(hidden: bool, protected: bool) -> Harness {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("python.set", json!({"cell": "B2", "code": CODE})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B2", "style": {"protection": {"locked": false, "hidden": hidden}}})).unwrap();
|
||||
if protected {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(s), 4)
|
||||
}
|
||||
|
||||
fn send(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn press(h: &mut Harness, key: Key, modifiers: Modifiers) {
|
||||
send(
|
||||
h,
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
|
||||
|
||||
/// Two clicks on B2 in a row: a double-click on the grid.
|
||||
fn double_click_b2(h: &mut Harness) {
|
||||
let at = {
|
||||
let app = h.state();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse("B2").unwrap()).center()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
for event in [
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
] {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The open edit's text, and whether it is in Python mode.
|
||||
fn editor(h: &Harness) -> Option<(String, bool)> {
|
||||
h.state().editor.as_ref().map(|e| (e.text.clone(), e.python))
|
||||
}
|
||||
|
||||
fn bar_shows_code(h: &Harness) -> bool {
|
||||
h.query_by_label_contains("secret_total").is_some()
|
||||
}
|
||||
|
||||
fn stored_code(h: &Harness) -> Option<String> {
|
||||
let d = h.state().session.active()?;
|
||||
d.wb.active()?.cell(CellRef::parse("B2")?)?.py.as_ref().map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
fn cancel(h: &mut Harness) {
|
||||
h.state_mut().cancel_edit();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_shows_no_code_in_the_formula_bar() {
|
||||
let h = harness(true, true);
|
||||
assert!(!bar_shows_code(&h), "the formula bar shows nothing for hidden code");
|
||||
assert!(h.query_by_label("Python Mode").is_some(), "the PY badge still marks a Python cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f2_a_double_click_and_the_formula_bar_start_no_edit_on_hidden_code() {
|
||||
let mut h = harness(true, true);
|
||||
press(&mut h, Key::F2, Modifiers::NONE);
|
||||
assert_eq!(editor(&h), None, "F2");
|
||||
double_click_b2(&mut h);
|
||||
assert_eq!(editor(&h), None, "a double-click");
|
||||
h.state_mut().begin_edit(None, true);
|
||||
assert_eq!(editor(&h), None, "a click in the formula bar");
|
||||
assert!(!bar_shows_code(&h));
|
||||
assert_eq!(stored_code(&h).as_deref(), Some(CODE), "the code is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_mode_starts_no_edit_on_hidden_code() {
|
||||
let mut h = harness(true, true);
|
||||
press(&mut h, Key::P, CTRL_ALT_SHIFT);
|
||||
assert_eq!(editor(&h), None, "Ctrl+Alt+Shift+P");
|
||||
h.get_by_label("Python Mode").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(editor(&h), None, "the PY badge");
|
||||
let refused = h.state_mut().run("ui.pythonMode", json!({"on": true})).expect_err("ui.pythonMode");
|
||||
assert!(refused.contains("hidden") && !refused.contains("secret"), "{refused}");
|
||||
assert_eq!(editor(&h), None, "ui.pythonMode");
|
||||
assert!(!bar_shows_code(&h));
|
||||
// Ctrl+Enter with no edit open writes nothing over the code.
|
||||
press(&mut h, Key::Enter, Modifiers::COMMAND);
|
||||
assert_eq!(stored_code(&h).as_deref(), Some(CODE));
|
||||
}
|
||||
|
||||
/// The same cell, unprotected or not hidden, shows and edits its code.
|
||||
#[test]
|
||||
fn the_code_shows_and_edits_when_the_sheet_is_unprotected_or_the_cell_not_hidden() {
|
||||
for (hidden, protected) in [(true, false), (false, true)] {
|
||||
let what = format!("hidden {hidden}, protected {protected}");
|
||||
let mut h = harness(hidden, protected);
|
||||
assert!(bar_shows_code(&h), "{what}: the formula bar shows the code");
|
||||
press(&mut h, Key::F2, Modifiers::NONE);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: F2");
|
||||
cancel(&mut h);
|
||||
double_click_b2(&mut h);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: a double-click");
|
||||
cancel(&mut h);
|
||||
press(&mut h, Key::P, CTRL_ALT_SHIFT);
|
||||
assert_eq!(editor(&h), Some((CODE.to_string(), true)), "{what}: Ctrl+Alt+Shift+P");
|
||||
cancel(&mut h);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Python mode in the formula bar: entering and leaving it, its keys (applied in input order, also
|
||||
//! several in one frame), committing through `python.set`, and the PY badge.
|
||||
|
||||
use egui::{Event, Key, Modifiers, vec2};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellError, CellRef, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::common::Harness;
|
||||
|
||||
fn blank() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn harness(session: Session) -> Harness {
|
||||
crate::common::harness_with(crate::common::builder().with_size(vec2(1200.0, 800.0)), crate::common::app(session), 4)
|
||||
}
|
||||
|
||||
fn mac_harness(session: Session) -> Harness {
|
||||
crate::common::os_harness(session, egui::os::OperatingSystem::Mac)
|
||||
}
|
||||
|
||||
fn press(key: Key, modifiers: Modifiers) -> [Event; 2] {
|
||||
[
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
|
||||
Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
|
||||
]
|
||||
}
|
||||
|
||||
/// Sends `events` in one frame, then lets the app settle.
|
||||
fn send(h: &mut Harness, events: impl IntoIterator<Item = Event>) {
|
||||
h.input_mut().events.extend(events);
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn type_text(h: &mut Harness, text: &str) {
|
||||
send(h, [Event::Text(text.into())]);
|
||||
}
|
||||
|
||||
const CTRL_ALT_SHIFT: Modifiers = Modifiers { alt: true, ctrl: true, shift: true, mac_cmd: false, command: true };
|
||||
const CMD_OPTION_SHIFT: Modifiers = Modifiers { alt: true, ctrl: false, shift: true, mac_cmd: true, command: true };
|
||||
|
||||
fn shortcut(h: &mut Harness) {
|
||||
send(h, press(Key::P, CTRL_ALT_SHIFT));
|
||||
}
|
||||
|
||||
fn ctrl_enter(h: &mut Harness) {
|
||||
send(h, press(Key::Enter, Modifiers::COMMAND));
|
||||
}
|
||||
|
||||
/// Whether an edit is open, and in Python mode.
|
||||
fn mode(h: &Harness) -> Option<bool> {
|
||||
h.state().editor.as_ref().map(|e| e.python)
|
||||
}
|
||||
|
||||
fn text(h: &Harness) -> String {
|
||||
h.state().editor.as_ref().map(|e| e.text.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn code(h: &Harness, cell: &str) -> Option<String> {
|
||||
let d = h.state().session.active()?;
|
||||
d.wb.active()?.cell(CellRef::parse(cell)?)?.py.as_ref().map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
fn value(h: &Harness, cell: &str) -> Value {
|
||||
let d = h.state().session.active().unwrap();
|
||||
d.wb.active().unwrap().value(CellRef::parse(cell).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcut_enters_and_leaves_python_mode_and_esc_cancels() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true), "Ctrl+Alt+Shift+P starts Python mode on the active cell");
|
||||
let ed = h.state().editor.as_ref().unwrap();
|
||||
assert!(ed.from_formula_bar, "Python mode edits in the formula bar");
|
||||
assert_eq!(ed.cell, CellRef::new(0, 0));
|
||||
h.check_accessibility();
|
||||
type_text(&mut h, "x = 1");
|
||||
assert_eq!(text(&h), "x = 1");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(false), "the shortcut again leaves Python mode");
|
||||
assert_eq!(text(&h), "x = 1", "and the edit keeps its text");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None, "Esc cancels the edit");
|
||||
assert_eq!(code(&h, "A1"), None, "and changes nothing");
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mac_shortcut_is_cmd_option_shift_p_and_cmd_enter_commits() {
|
||||
let mut h = mac_harness(blank());
|
||||
send(&mut h, press(Key::P, CMD_OPTION_SHIFT));
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, "1 + 1");
|
||||
send(&mut h, press(Key::Enter, Modifiers::MAC_CMD | Modifiers::COMMAND));
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("1 + 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_py_then_tab_enters_python_mode_as_in_excel() {
|
||||
let mut h = harness(blank());
|
||||
type_text(&mut h, "=PY");
|
||||
assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry in the cell");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true), "=PY then Tab is Python mode");
|
||||
assert_eq!(text(&h), "", "the trigger is not code");
|
||||
assert!(h.state().editor.as_ref().is_some_and(|e| e.from_formula_bar));
|
||||
let active = h.state().session.active().map(|d| d.selection.active);
|
||||
assert_eq!(active, Some(CellRef::new(0, 0)), "Tab did not move on");
|
||||
type_text(&mut h, "len('abc')");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("len('abc')"));
|
||||
assert_eq!(value(&h, "A1"), Value::Error(CellError::Blocked), "this build stores the code and doesn't run it");
|
||||
// Any other entry still commits with Tab.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
type_text(&mut h, "=PI");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None);
|
||||
}
|
||||
|
||||
/// A cell inside a spill: typing there is an ordinary entry, as on any spill child, and neither
|
||||
/// `=PY` then Tab nor the shortcut turns that draft into code for the cell, as Python mode starts
|
||||
/// on no cell inside a spill.
|
||||
#[test]
|
||||
fn a_draft_typed_inside_a_spill_does_not_enter_python_mode() {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
type_text(&mut h, "=PY");
|
||||
assert_eq!(mode(&h), Some(false), "typing starts an ordinary entry");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(false), "=PY then Tab");
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(false), "the shortcut");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A2"), None, "no code is written into the spill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_tab_edit_the_code_and_ctrl_enter_commits_through_python_set() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "C3"})).unwrap();
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "for i in range(3):");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
type_text(&mut h, "print(i)");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
send(&mut h, press(Key::Tab, Modifiers::SHIFT));
|
||||
type_text(&mut h, "x");
|
||||
assert_eq!(text(&h), "for i in range(3):\n print(i)\nx", "Enter keeps the indentation, Shift+Tab takes it back");
|
||||
send(&mut h, press(Key::Tab, Modifiers::NONE));
|
||||
assert_eq!(text(&h), "for i in range(3):\n print(i)\nx ");
|
||||
assert_eq!(mode(&h), Some(true), "Enter and Tab stay in the edit");
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "C3").as_deref(), Some("for i in range(3):\n print(i)\nx "));
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(2, 2)), "Ctrl+Enter stays on the cell");
|
||||
// One undo takes the code away.
|
||||
h.state_mut().run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&h, "C3"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_keys_in_one_frame_are_applied_in_order() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "a");
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
type_text(&mut h, "b");
|
||||
// Select all, then Tab, in one frame: Tab indents the selected lines rather than replacing
|
||||
// them (the #456 review regression).
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::NONE));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), " a\n b");
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::SHIFT));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), "a\nb", "Shift+Tab after select-all dedents every line");
|
||||
// A keystroke sequence as the platform sends it: a key before each text, then Enter.
|
||||
send(&mut h, press(Key::End, Modifiers::COMMAND));
|
||||
let mut events = Vec::new();
|
||||
for (k, c) in [(Key::I, "i"), (Key::F, "f"), (Key::Space, " "), (Key::Y, "y")] {
|
||||
events.push(Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
events.push(Event::Text(c.into()));
|
||||
}
|
||||
events.push(Event::Key { key: Key::Colon, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::SHIFT });
|
||||
events.push(Event::Text(":".into()));
|
||||
events.extend(press(Key::Enter, Modifiers::NONE));
|
||||
events.push(Event::Text("z".into()));
|
||||
send(&mut h, events);
|
||||
assert_eq!(text(&h), "a\nbif y:\n z");
|
||||
// Select all, Tab and Ctrl+Enter in one frame: the commit waits for the Tab.
|
||||
let mut events = Vec::new();
|
||||
events.extend(press(Key::A, Modifiers::COMMAND));
|
||||
events.extend(press(Key::Tab, Modifiers::NONE));
|
||||
events.extend(press(Key::Enter, Modifiers::COMMAND));
|
||||
send(&mut h, events);
|
||||
assert_eq!(mode(&h), None);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some(" a\n bif y:\n z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_typed_after_the_commit_key_in_the_same_frame_are_not_committed() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
let mut events = vec![Event::Text("1".into())];
|
||||
events.extend(press(Key::Enter, Modifiers::COMMAND));
|
||||
events.push(Event::Text("2".into()));
|
||||
send(&mut h, events);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("1"));
|
||||
assert_eq!(value(&h, "B1"), Value::Empty);
|
||||
assert!(h.state().editor.is_none(), "the text after the commit key starts nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_too_long_for_a_cell_shows_the_commands_message_and_keeps_the_code() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
let long = "y".repeat(8_080);
|
||||
if let Some(ed) = h.state_mut().editor.as_mut() {
|
||||
ed.text = long.clone();
|
||||
ed.caret = long.chars().count();
|
||||
ed.sync_caret = true;
|
||||
}
|
||||
h.run_steps(2);
|
||||
ctrl_enter(&mut h);
|
||||
let message = h.state().message.clone().map(|m| m.1).unwrap_or_default();
|
||||
assert!(message.contains("8192"), "the command's message: {message}");
|
||||
assert_eq!(mode(&h), Some(true), "the edit stays open in Python mode");
|
||||
assert_eq!(text(&h), long, "with the code as typed");
|
||||
assert_eq!(code(&h, "A1"), None);
|
||||
// Keys go to the message box, not the code, until it closes.
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
assert!(h.state().message.is_none(), "Enter closed the message box");
|
||||
assert_eq!(text(&h), long, "and added no line to the code");
|
||||
// The editor has the keyboard back: shortening the code commits it.
|
||||
send(&mut h, press(Key::Backspace, Modifiers::NONE));
|
||||
assert_eq!(text(&h).len(), 8_079);
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(code(&h, "A1").map(|c| c.len()), Some(8_079));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_a_python_cell_edits_its_code_and_typing_replaces_it() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "B2", "code": "x = 1\nx"})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// The formula bar shows the code, with the PY badge.
|
||||
assert!(h.query_by_label("Python Mode").is_some(), "the PY badge shows on a selected Python cell");
|
||||
send(&mut h, press(Key::F2, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true), "F2 on a Python cell is Python mode");
|
||||
assert_eq!(text(&h), "x = 1\nx");
|
||||
// The badge leaves Python mode, as the shortcut does.
|
||||
h.get_by_label("Python Mode").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(mode(&h), Some(false));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), None);
|
||||
assert!(h.query_by_label("Python Mode").is_some());
|
||||
// Typing replaces the cell, as in Excel.
|
||||
type_text(&mut h, "7");
|
||||
assert_eq!(mode(&h), Some(false));
|
||||
send(&mut h, press(Key::Enter, Modifiers::NONE));
|
||||
assert_eq!(code(&h, "B2"), None);
|
||||
assert_eq!(value(&h, "B2"), Value::Number(7.0));
|
||||
// No badge on an ordinary cell.
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Python Mode").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_python_mode_switches_from_the_control_channel_without_a_dialog() {
|
||||
let mut h = harness(blank());
|
||||
let r = h.state_mut().run("ui.pythonMode", json!({})).unwrap();
|
||||
assert_eq!(r["on"], true);
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": true})).unwrap()["on"], true, "already on");
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false);
|
||||
assert!(h.state().dialog.is_none() && h.state().message.is_none());
|
||||
h.state_mut().cancel_edit();
|
||||
assert_eq!(h.state_mut().run("ui.pythonMode", json!({"on": false})).unwrap()["on"], false, "off with no edit does nothing");
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_ui_python_mode_start_is_an_error_with_the_reason() {
|
||||
let mut s = blank();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "C1", "code": "secret = 1"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "C1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// A locked cell (B1), a cell inside a spill (A2), code hidden on a protected sheet (C1), and a
|
||||
// draft typed inside a spill: each is refused with an error, by `on: true` and by a switch.
|
||||
for (cell, typed, says) in [("B1", None, "protected"), ("A2", None, "spill"), ("C1", None, "hidden"), ("A2", Some("5"), "spill")] {
|
||||
h.state_mut().cancel_edit();
|
||||
h.state_mut().run("ui.message.dismiss", json!({})).unwrap();
|
||||
h.state_mut().run("selection.set", json!({"cell": cell})).unwrap();
|
||||
if let Some(text) = typed {
|
||||
h.state_mut().begin_edit(Some(text.into()), false);
|
||||
}
|
||||
for params in [json!({"on": true}), json!({})] {
|
||||
let e = h.state_mut().run("ui.pythonMode", params.clone()).expect_err(cell);
|
||||
assert!(e.contains(says), "{cell} {params}: {e}");
|
||||
assert_ne!(mode(&h), Some(true), "{cell}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clicks the middle of `a1` on screen.
|
||||
fn click_cell(h: &mut Harness, a1: &str) {
|
||||
let at = {
|
||||
let app = h.state();
|
||||
let sh = app.session.active().unwrap().wb.active().unwrap();
|
||||
app.grid.geo.as_ref().unwrap().screen_cell_rect(sh, CellRef::parse(a1).unwrap()).center()
|
||||
};
|
||||
for event in [
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE },
|
||||
Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE },
|
||||
] {
|
||||
h.input_mut().events.push(event);
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_enter_button_and_a_click_on_the_sheet_commit_the_code_through_python_set() {
|
||||
let mut h = harness(blank());
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "'=SUM(1)'");
|
||||
h.get_by_label("Enter").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("'=SUM(1)'"), "the code, not a formula or text");
|
||||
h.state_mut().session.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
shortcut(&mut h);
|
||||
type_text(&mut h, "2");
|
||||
click_cell(&mut h, "D4");
|
||||
assert_eq!(code(&h, "A2").as_deref(), Some("2"));
|
||||
assert_eq!(h.state().session.active().map(|d| d.selection.active), Some(CellRef::new(3, 3)));
|
||||
assert!(h.state().editor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formula_bar_shows_while_python_mode_is_open_when_the_preference_hides_it() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().session.prefs.formula_bar = false;
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, "x = 1");
|
||||
assert_eq!(text(&h), "x = 1", "the code editor is drawn and takes the typing");
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(code(&h, "A1").as_deref(), Some("x = 1"));
|
||||
assert!(!h.state().session.prefs.formula_bar, "the preference stays as it was");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committing_no_code_clears_a_python_cell_and_writes_nothing_elsewhere() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "1 + 1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "5"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
// A Python cell whose code is deleted is cleared, as an empty entry clears any other cell.
|
||||
shortcut(&mut h);
|
||||
h.state_mut().editor.as_mut().unwrap().text.clear();
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none(), "{:?}", h.state().message);
|
||||
assert_eq!(code(&h, "A1"), None);
|
||||
assert_eq!(value(&h, "A1"), Value::Empty);
|
||||
// Python mode on a value cell, committed with no code: the value stays.
|
||||
h.state_mut().run("selection.set", json!({"range": "A2"})).unwrap();
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(text(&h), "");
|
||||
ctrl_enter(&mut h);
|
||||
assert!(h.state().editor.is_none());
|
||||
assert_eq!(code(&h, "A2"), None);
|
||||
assert_eq!(value(&h, "A2"), Value::Number(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_draft_belongs_to_the_window_it_was_typed_in() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap();
|
||||
let mut h = harness(s);
|
||||
let first = h.state().session.active_window().unwrap();
|
||||
h.state_mut().run_or_alert("view.newWindow", json!({}));
|
||||
h.run_steps(2);
|
||||
let second = h.state().session.active_window().unwrap();
|
||||
assert_ne!(first, second);
|
||||
// Both ways into Python mode record the window, as an ordinary edit does.
|
||||
shortcut(&mut h);
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second));
|
||||
send(&mut h, press(Key::Escape, Modifiers::NONE));
|
||||
h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap();
|
||||
send(&mut h, press(Key::F2, Modifiers::NONE));
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
assert_eq!(h.state().editor.as_ref().and_then(|e| e.window), Some(second));
|
||||
// A script brings window 1 forward; the draft is committed in its own window, and when its
|
||||
// workbook refuses it (too long for a cell), that window comes back with the draft open.
|
||||
h.state_mut().editor.as_mut().unwrap().text = "y".repeat(9_000);
|
||||
h.state_mut().session.activate_window(first);
|
||||
assert!(!h.state_mut().commit_edit(0, 0, false, false));
|
||||
assert_eq!(h.state().session.active_window(), Some(second));
|
||||
assert_eq!(mode(&h), Some(true), "the draft is still open");
|
||||
}
|
||||
|
||||
/// A locked cell on a protected sheet: Python mode is refused before a draft exists, and a draft
|
||||
/// whose cell sheet protection locks while it is open is dropped with the warning, as an ordinary
|
||||
/// edit's is, so no draft is left that can never be entered and blocks Save (#2945).
|
||||
#[test]
|
||||
fn python_mode_leaves_no_draft_on_a_locked_cell() {
|
||||
let mut s = blank();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "1"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "2"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": false}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let mut h = harness(s);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), None, "no draft on the locked A1");
|
||||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")), "{:?}", h.state().message);
|
||||
h.state_mut().run("ui.message.dismiss", json!({})).unwrap();
|
||||
// The unlocked B1 edits; then protection locks it while the draft is open.
|
||||
h.state_mut().run("selection.set", json!({"range": "B1"})).unwrap();
|
||||
h.run_steps(2);
|
||||
shortcut(&mut h);
|
||||
assert_eq!(mode(&h), Some(true));
|
||||
type_text(&mut h, " + 1");
|
||||
h.state_mut().run("review.unprotectSheet", json!({})).unwrap();
|
||||
h.state_mut().run("home.formatCells", json!({"range": "B1", "style": {"protection": {"locked": true}}})).unwrap();
|
||||
h.state_mut().run("review.protectSheet", json!({})).unwrap();
|
||||
ctrl_enter(&mut h);
|
||||
assert_eq!(mode(&h), None, "the draft is dropped, not left open");
|
||||
assert!(h.state().message.as_ref().is_some_and(|(_, m)| m.contains("protected sheet")));
|
||||
assert_eq!(code(&h, "B1").as_deref(), Some("2"));
|
||||
assert!(h.state_mut().run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user